feat: make scoreboard playoff-aware with banner, bracket, and series drill-down
Turn a regular-season-looking Tuesday into a full playoff experience: - Playoff banner with round + day + series + elimination counts, gold/silver Cup theme toggled by body.playoff-mode - Series context on each playoff card: round chip, series score, stake badges (GAME 7, CLINCHER, PIVOTAL), and one-line blurb - Game 7s pin to a new Spotlight section above Live - Playoff OT renders with SUDDEN DEATH badge and pulsing gold border - Client-side OT notifications via bell button in the banner - New /series/<id> drill-down with headline, next-game, and game-by-game history - New /bracket page: 7-column desktop grid, accordion on mobile - Day N banner count auto-anchors on first playoff scoreboard hit - SQLite cache for bracket + per-series schedules, stale-on-failure up to 24h Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+216
-21
@@ -9,11 +9,14 @@ async function fetchScoreboardData() {
|
||||
}
|
||||
|
||||
function updateScoreboard(data) {
|
||||
applyMeta(data.meta);
|
||||
|
||||
const sections = [
|
||||
{ sectionId: 'live-section', gridId: 'live-games-section', games: data.live_games, render: renderLiveGame },
|
||||
{ sectionId: 'intermission-section', gridId: 'intermission-games-section', games: data.intermission_games, render: renderLiveGame },
|
||||
{ sectionId: 'pre-section', gridId: 'pre-games-section', games: data.pre_games, render: renderPreGame },
|
||||
{ sectionId: 'final-section', gridId: 'final-games-section', games: data.final_games, render: renderFinalGame },
|
||||
{ sectionId: 'pinned-section', gridId: 'pinned-games-section', games: data.pinned_games, render: renderPinnedGame },
|
||||
{ sectionId: 'live-section', gridId: 'live-games-section', games: data.live_games, render: renderLiveGame },
|
||||
{ sectionId: 'intermission-section', gridId: 'intermission-games-section', games: data.intermission_games, render: renderLiveGame },
|
||||
{ sectionId: 'pre-section', gridId: 'pre-games-section', games: data.pre_games, render: renderPreGame },
|
||||
{ sectionId: 'final-section', gridId: 'final-games-section', games: data.final_games, render: renderFinalGame },
|
||||
];
|
||||
|
||||
for (const { sectionId, gridId, games, render } of sections) {
|
||||
@@ -32,23 +35,96 @@ function updateScoreboard(data) {
|
||||
}
|
||||
|
||||
updateGauges();
|
||||
maybeNotifyOT(data);
|
||||
}
|
||||
|
||||
// ── Banner / Meta ─────────────────────────────────────
|
||||
|
||||
function applyMeta(meta) {
|
||||
const banner = document.getElementById('playoff-banner');
|
||||
if (!meta || !meta.playoff_mode) {
|
||||
document.body.classList.remove('playoff-mode');
|
||||
banner.classList.add('hidden');
|
||||
banner.setAttribute('aria-hidden', 'true');
|
||||
return;
|
||||
}
|
||||
|
||||
document.body.classList.add('playoff-mode');
|
||||
banner.classList.remove('hidden');
|
||||
banner.setAttribute('aria-hidden', 'false');
|
||||
|
||||
setText(banner.querySelector('.banner-year'), meta.year ? String(meta.year) : '');
|
||||
setText(banner.querySelector('.meta-round'), meta.round_label || '');
|
||||
|
||||
const dayEl = banner.querySelector('.meta-day');
|
||||
if (meta.day_n != null) {
|
||||
const total = meta.day_total ? ` of ~${meta.day_total}` : '';
|
||||
setText(dayEl, `Day ${meta.day_n}${total}`);
|
||||
dayEl.classList.remove('hidden');
|
||||
} else {
|
||||
dayEl.classList.add('hidden');
|
||||
}
|
||||
|
||||
const seriesEl = banner.querySelector('.meta-series');
|
||||
if (meta.series_active) {
|
||||
const word = meta.series_active === 1 ? 'series' : 'series';
|
||||
setText(seriesEl, `${meta.series_active} ${word} in action`);
|
||||
seriesEl.classList.remove('hidden');
|
||||
} else {
|
||||
seriesEl.classList.add('hidden');
|
||||
}
|
||||
|
||||
const elimEl = banner.querySelector('.meta-elim');
|
||||
if (meta.elimination_count > 0) {
|
||||
const n = meta.elimination_count;
|
||||
setText(elimEl, `${n} elimination game${n === 1 ? '' : 's'}`);
|
||||
elimEl.classList.remove('hidden');
|
||||
} else {
|
||||
elimEl.classList.add('hidden');
|
||||
}
|
||||
|
||||
const g7El = banner.querySelector('.meta-game7');
|
||||
if (meta.game7_count > 0) {
|
||||
const n = meta.game7_count;
|
||||
setText(g7El, `${n} Game 7${n === 1 ? '' : 's'}`);
|
||||
g7El.classList.remove('hidden');
|
||||
} else {
|
||||
g7El.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function setText(el, text) {
|
||||
if (el) el.textContent = text;
|
||||
}
|
||||
|
||||
// ── Renderers ────────────────────────────────────────
|
||||
|
||||
function renderLiveGame(game) {
|
||||
function renderPinnedGame(game) {
|
||||
if (game['Game State'] === 'PRE') return renderPreGame(game, { pinned: true });
|
||||
if (game['Game State'] === 'FINAL') return renderFinalGame(game, { pinned: true });
|
||||
return renderLiveGame(game, { pinned: true });
|
||||
}
|
||||
|
||||
function renderLiveGame(game, opts = {}) {
|
||||
const intermission = game['Intermission'];
|
||||
const period = game['Period'];
|
||||
const time = game['Time Remaining'];
|
||||
const running = game['Time Running'];
|
||||
const isPlayoff = game['Is Playoff'];
|
||||
const playoffOT = game['Playoff OT'];
|
||||
|
||||
const periodLabel = intermission
|
||||
const periodText = playoffOT
|
||||
? (game['OT Label'] || 'OT')
|
||||
: ordinalPeriod(period);
|
||||
|
||||
const periodBadge = intermission
|
||||
? `<span class="badge">${intermissionLabel(period)}</span>`
|
||||
: `<span class="badge badge-live">${ordinalPeriod(period)}</span>`;
|
||||
: playoffOT
|
||||
? `<span class="badge badge-sudden-death">${periodText} · SUDDEN DEATH</span>`
|
||||
: `<span class="badge badge-live">${periodText}</span>`;
|
||||
|
||||
const dot = running ? `<span class="live-dot"></span>` : '';
|
||||
|
||||
// Tick the clock locally when the clock is running or during intermission
|
||||
const shouldTick = running || intermission;
|
||||
const rawSeconds = timeToSeconds(time);
|
||||
const clockAttrs = shouldTick
|
||||
@@ -63,11 +139,17 @@ function renderLiveGame(game) {
|
||||
</div>
|
||||
</div>` : '';
|
||||
|
||||
return `
|
||||
<div class="game-box ${intermission ? 'game-box-intermission' : 'game-box-live'}" data-game-key="${game['Away Team']}|${game['Home Team']}">
|
||||
const stateClass = intermission ? 'game-box-intermission' : 'game-box-live';
|
||||
const playoffClass = isPlayoff ? ' game-box-playoff' : '';
|
||||
const otClass = playoffOT ? ' game-box-sudden-death' : '';
|
||||
const pinnedClass = opts.pinned ? ' game-box-pinned' : '';
|
||||
|
||||
return wrapSeriesLink(game, `
|
||||
<div class="game-box ${stateClass}${playoffClass}${otClass}${pinnedClass}" data-game-key="${gameKey(game)}">
|
||||
${playoffContext(game)}
|
||||
<div class="card-header">
|
||||
<div class="badges">
|
||||
${periodLabel}
|
||||
${periodBadge}
|
||||
<span class="badge" ${clockAttrs}>${time}</span>
|
||||
${ppBadge(game)}
|
||||
</div>
|
||||
@@ -76,12 +158,17 @@ function renderLiveGame(game) {
|
||||
${teamRow(game, 'Away', 'live')}
|
||||
${teamRow(game, 'Home', 'live')}
|
||||
${hype}
|
||||
</div>`;
|
||||
${seriesBlurb(game)}
|
||||
</div>`);
|
||||
}
|
||||
|
||||
function renderPreGame(game) {
|
||||
return `
|
||||
<div class="game-box">
|
||||
function renderPreGame(game, opts = {}) {
|
||||
const isPlayoff = game['Is Playoff'];
|
||||
const playoffClass = isPlayoff ? ' game-box-playoff' : '';
|
||||
const pinnedClass = opts.pinned ? ' game-box-pinned' : '';
|
||||
return wrapSeriesLink(game, `
|
||||
<div class="game-box${playoffClass}${pinnedClass}" data-game-key="${gameKey(game)}">
|
||||
${playoffContext(game)}
|
||||
<div class="card-header">
|
||||
<div class="badges">
|
||||
<span class="badge">${game['Start Time']}</span>
|
||||
@@ -89,14 +176,19 @@ function renderPreGame(game) {
|
||||
</div>
|
||||
${teamRow(game, 'Away', 'pre')}
|
||||
${teamRow(game, 'Home', 'pre')}
|
||||
</div>`;
|
||||
${seriesBlurb(game)}
|
||||
</div>`);
|
||||
}
|
||||
|
||||
function renderFinalGame(game) {
|
||||
function renderFinalGame(game, opts = {}) {
|
||||
const labels = { REG: 'Final', OT: 'Final/OT', SO: 'Final/SO' };
|
||||
const label = labels[game['Last Period Type']] ?? 'Final';
|
||||
return `
|
||||
<div class="game-box">
|
||||
const isPlayoff = game['Is Playoff'];
|
||||
const playoffClass = isPlayoff ? ' game-box-playoff' : '';
|
||||
const pinnedClass = opts.pinned ? ' game-box-pinned' : '';
|
||||
return wrapSeriesLink(game, `
|
||||
<div class="game-box${playoffClass}${pinnedClass}" data-game-key="${gameKey(game)}">
|
||||
${playoffContext(game)}
|
||||
<div class="card-header">
|
||||
<div class="badges">
|
||||
<span class="badge badge-muted">${label}</span>
|
||||
@@ -104,7 +196,42 @@ function renderFinalGame(game) {
|
||||
</div>
|
||||
${teamRow(game, 'Away', 'final')}
|
||||
${teamRow(game, 'Home', 'final')}
|
||||
</div>`;
|
||||
${seriesBlurb(game)}
|
||||
</div>`);
|
||||
}
|
||||
|
||||
function wrapSeriesLink(game, html) {
|
||||
const sid = game['Series ID'];
|
||||
if (!sid) return html;
|
||||
return `<a class="series-link" href="/series/${sid}" aria-label="Series detail">${html}</a>`;
|
||||
}
|
||||
|
||||
// ── Playoff context (badges row + series summary) ─────
|
||||
|
||||
function playoffContext(game) {
|
||||
if (!game['Is Playoff']) return '';
|
||||
const badges = (game['Series Badges'] || [])
|
||||
.map(b => `<span class="badge ${badgeClassFor(b)}">${b}</span>`)
|
||||
.join('');
|
||||
const summary = game['Series Summary']
|
||||
? `<span class="series-summary">${game['Series Summary']}</span>`
|
||||
: '';
|
||||
if (!badges && !summary) return '';
|
||||
return `<div class="playoff-context">${badges}${summary}</div>`;
|
||||
}
|
||||
|
||||
function badgeClassFor(label) {
|
||||
if (label === 'GAME 7') return 'badge-game7';
|
||||
if (label === 'CLINCHER') return 'badge-clincher';
|
||||
if (label === 'PIVOTAL') return 'badge-pivotal';
|
||||
if (label === 'CUP FINAL') return 'badge-round badge-cup';
|
||||
if (label === 'CONF FINAL')return 'badge-round badge-conf';
|
||||
return 'badge-round';
|
||||
}
|
||||
|
||||
function seriesBlurb(game) {
|
||||
if (!game['Is Playoff'] || !game['Series Blurb']) return '';
|
||||
return `<div class="series-blurb">${game['Series Blurb']}</div>`;
|
||||
}
|
||||
|
||||
// ── Team Row ─────────────────────────────────────────
|
||||
@@ -148,6 +275,10 @@ function ppBadge(game) {
|
||||
return `<span class="badge badge-pp">PP ${team} <span ${attrs}>${timeStr}</span></span>`;
|
||||
}
|
||||
|
||||
function gameKey(game) {
|
||||
return `${game['Away Team']}|${game['Home Team']}`;
|
||||
}
|
||||
|
||||
// ── Gauge ────────────────────────────────────────────
|
||||
|
||||
function updateGauges() {
|
||||
@@ -197,7 +328,6 @@ function restoreClocks(grid, snapshot) {
|
||||
if (!prior) return;
|
||||
const badge = card.querySelector('[data-seconds][data-received-at]:not([data-pp-clock])');
|
||||
if (!badge) return;
|
||||
// Only restore if we're outside the final sync window
|
||||
if (prior.current > CLOCK_SYNC_THRESHOLD) {
|
||||
badge.dataset.seconds = prior.current;
|
||||
badge.dataset.receivedAt = prior.ts;
|
||||
@@ -226,6 +356,70 @@ function intermissionLabel(period) {
|
||||
return ['1st Int', '2nd Int', '3rd Int'][period - 1] ?? 'Int';
|
||||
}
|
||||
|
||||
// ── OT Notifications (Phase 1: client-only) ──────────
|
||||
|
||||
const OT_SEEN_KEY = 'nhl_ot_seen_v1';
|
||||
|
||||
function seenOTKeys() {
|
||||
try { return new Set(JSON.parse(sessionStorage.getItem(OT_SEEN_KEY) || '[]')); }
|
||||
catch { return new Set(); }
|
||||
}
|
||||
|
||||
function persistSeenOT(set) {
|
||||
sessionStorage.setItem(OT_SEEN_KEY, JSON.stringify([...set]));
|
||||
}
|
||||
|
||||
function maybeNotifyOT(data) {
|
||||
if (!('Notification' in window)) return;
|
||||
if (Notification.permission !== 'granted') return;
|
||||
|
||||
const seen = seenOTKeys();
|
||||
const candidates = [...(data.pinned_games || []), ...(data.live_games || [])];
|
||||
let changed = false;
|
||||
for (const g of candidates) {
|
||||
if (!g['Playoff OT']) continue;
|
||||
const k = `${gameKey(g)}|${g['Period']}`;
|
||||
if (seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
changed = true;
|
||||
try {
|
||||
new Notification('Playoff OT \u2014 Sudden Death', {
|
||||
body: `${g['Away Team']} @ ${g['Home Team']}`,
|
||||
silent: false,
|
||||
tag: k,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('Notification failed:', e);
|
||||
}
|
||||
}
|
||||
if (changed) persistSeenOT(seen);
|
||||
}
|
||||
|
||||
function wireOTButton() {
|
||||
const btn = document.querySelector('.banner-notify');
|
||||
if (!btn) return;
|
||||
if (!('Notification' in window)) {
|
||||
btn.disabled = true;
|
||||
btn.title = 'Notifications not supported in this browser';
|
||||
return;
|
||||
}
|
||||
reflectOTPermission(btn);
|
||||
btn.addEventListener('click', async () => {
|
||||
if (Notification.permission === 'default') {
|
||||
await Notification.requestPermission();
|
||||
}
|
||||
reflectOTPermission(btn);
|
||||
});
|
||||
}
|
||||
|
||||
function reflectOTPermission(btn) {
|
||||
const state = Notification.permission;
|
||||
btn.dataset.perm = state;
|
||||
if (state === 'granted') btn.title = 'OT alerts enabled';
|
||||
else if (state === 'denied') btn.title = 'OT alerts blocked in browser settings';
|
||||
else btn.title = 'Enable OT alerts';
|
||||
}
|
||||
|
||||
// ── Init ─────────────────────────────────────────────
|
||||
|
||||
function autoRefresh() {
|
||||
@@ -234,6 +428,7 @@ function autoRefresh() {
|
||||
}
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
wireOTButton();
|
||||
autoRefresh();
|
||||
setInterval(tickClocks, 1000);
|
||||
if ('serviceWorker' in navigator) {
|
||||
|
||||
@@ -12,6 +12,19 @@
|
||||
--gap: 1rem;
|
||||
--radius: 12px;
|
||||
--card-w: 290px;
|
||||
|
||||
/* Cup theme palette — only referenced when body.playoff-mode is set */
|
||||
--cup-gold-1: #d4af37;
|
||||
--cup-gold-2: #f5d76e;
|
||||
--cup-gold-dim: #8a6d1a;
|
||||
--cup-silver-1: #c0c8d0;
|
||||
--cup-silver-2: #e8ecef;
|
||||
--cup-silver-dim: #6b7580;
|
||||
--ice-1: #0a1628;
|
||||
--ice-2: #162844;
|
||||
--ice-accent: #4fc3f7;
|
||||
--gold-gradient: linear-gradient(90deg, var(--cup-gold-dim), var(--cup-gold-2) 50%, var(--cup-gold-1));
|
||||
--banner-bg: linear-gradient(135deg, #0a1628 0%, #162844 55%, #1a1a2e 100%);
|
||||
}
|
||||
|
||||
*, *::before, *::after {
|
||||
@@ -352,3 +365,780 @@ main {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Playoff Banner ─────────────────────────────── */
|
||||
|
||||
.playoff-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.875rem 1.25rem;
|
||||
background: var(--banner-bg);
|
||||
border-bottom: 2px solid transparent;
|
||||
border-image: var(--gold-gradient) 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.playoff-banner.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.banner-trophy {
|
||||
width: 36px;
|
||||
height: 44px;
|
||||
flex-shrink: 0;
|
||||
filter: drop-shadow(0 0 6px rgba(212, 175, 55, 0.25));
|
||||
}
|
||||
|
||||
.banner-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.banner-title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.12em;
|
||||
background: var(--gold-gradient);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.banner-year {
|
||||
color: var(--cup-silver-2);
|
||||
-webkit-text-fill-color: var(--cup-silver-2);
|
||||
font-weight: 700;
|
||||
margin-left: 0.35rem;
|
||||
}
|
||||
|
||||
.banner-meta {
|
||||
margin-top: 0.2rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem 0.7rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--cup-silver-1);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.banner-meta > span.hidden { display: none; }
|
||||
.banner-meta > span:not(.hidden) + span:not(.hidden)::before {
|
||||
content: "\00b7";
|
||||
color: var(--cup-gold-dim);
|
||||
margin-right: 0.7rem;
|
||||
}
|
||||
|
||||
.meta-elim, .meta-game7 {
|
||||
color: var(--cup-gold-2);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.banner-notify {
|
||||
background: rgba(212, 175, 55, 0.08);
|
||||
border: 1px solid var(--cup-gold-dim);
|
||||
color: var(--cup-gold-2);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
padding: 0.45rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
|
||||
.banner-notify:hover {
|
||||
background: rgba(212, 175, 55, 0.18);
|
||||
border-color: var(--cup-gold-1);
|
||||
}
|
||||
|
||||
.banner-notify[data-perm="granted"] {
|
||||
background: rgba(212, 175, 55, 0.2);
|
||||
border-color: var(--cup-gold-1);
|
||||
color: var(--cup-gold-2);
|
||||
}
|
||||
|
||||
.banner-notify[data-perm="denied"] {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.playoff-banner {
|
||||
padding: 1.125rem 2rem;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
.banner-trophy { width: 44px; height: 54px; }
|
||||
.banner-title { font-size: 1.15rem; }
|
||||
.banner-meta { font-size: 0.85rem; }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.playoff-banner {
|
||||
flex-wrap: wrap;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
.banner-notify {
|
||||
order: 3;
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Playoff Game Cards ─────────────────────────── */
|
||||
|
||||
.playoff-mode .section-heading-gold {
|
||||
background: var(--gold-gradient);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
-webkit-text-fill-color: transparent;
|
||||
letter-spacing: 0.18em;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.game-box-playoff {
|
||||
border-top-width: 3px;
|
||||
border-image: var(--gold-gradient) 1;
|
||||
border-image-slice: 1;
|
||||
}
|
||||
|
||||
/* Gold top stripe wins over the green-accent live stripe on playoff games */
|
||||
.game-box-playoff.game-box-live,
|
||||
.game-box-playoff.game-box-intermission {
|
||||
border-image: var(--gold-gradient) 1;
|
||||
}
|
||||
|
||||
.game-box-pinned {
|
||||
box-shadow: 0 0 0 1px rgba(212, 175, 55, 0.35),
|
||||
0 6px 20px rgba(212, 175, 55, 0.1);
|
||||
}
|
||||
|
||||
.game-box-sudden-death {
|
||||
border-top-width: 3px;
|
||||
animation: pulse-gold 2.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-gold {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 1px rgba(212, 175, 55, 0.35),
|
||||
0 0 10px rgba(212, 175, 55, 0.25);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 1px rgba(245, 215, 110, 0.75),
|
||||
0 0 22px rgba(245, 215, 110, 0.45);
|
||||
}
|
||||
}
|
||||
|
||||
/* Playoff context row above the period badges */
|
||||
.playoff-context {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.35rem 0.6rem;
|
||||
margin-bottom: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
}
|
||||
|
||||
.series-summary {
|
||||
font-size: 0.72rem;
|
||||
color: var(--cup-silver-1);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* Playoff stake badges */
|
||||
.badge-round {
|
||||
background: rgba(212, 175, 55, 0.1);
|
||||
color: var(--cup-gold-2);
|
||||
border: 1px solid rgba(212, 175, 55, 0.35);
|
||||
}
|
||||
|
||||
.badge-conf {
|
||||
background: rgba(79, 195, 247, 0.12);
|
||||
color: var(--ice-accent);
|
||||
border-color: rgba(79, 195, 247, 0.4);
|
||||
}
|
||||
|
||||
.badge-cup {
|
||||
background: linear-gradient(90deg, rgba(212, 175, 55, 0.25), rgba(245, 215, 110, 0.25));
|
||||
color: #1a1200;
|
||||
border: 1px solid var(--cup-gold-1);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.badge-game7 {
|
||||
background: linear-gradient(90deg, var(--cup-gold-dim), var(--cup-gold-1));
|
||||
color: #1a1200;
|
||||
border: 1px solid var(--cup-gold-2);
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.badge-clincher {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #fca5a5;
|
||||
border: 1px solid rgba(239, 68, 68, 0.45);
|
||||
}
|
||||
|
||||
.badge-pivotal {
|
||||
background: rgba(79, 195, 247, 0.12);
|
||||
color: var(--ice-accent);
|
||||
border: 1px solid rgba(79, 195, 247, 0.4);
|
||||
}
|
||||
|
||||
.badge-sudden-death {
|
||||
background: linear-gradient(90deg, var(--cup-gold-dim), var(--cup-gold-1));
|
||||
color: #1a1200;
|
||||
border: 1px solid var(--cup-gold-2);
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.series-blurb {
|
||||
margin-top: 0.65rem;
|
||||
padding-top: 0.55rem;
|
||||
border-top: 1px solid var(--card-border);
|
||||
font-size: 0.72rem;
|
||||
color: var(--cup-silver-1);
|
||||
letter-spacing: 0.01em;
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* In playoff mode, retint the hype meter to silver → gold */
|
||||
.playoff-mode .hype-label {
|
||||
color: var(--cup-silver-dim);
|
||||
}
|
||||
|
||||
/* Override the red live dot with gold for playoff-mode bodies */
|
||||
.playoff-mode .game-box-playoff .live-dot {
|
||||
background: var(--cup-gold-1);
|
||||
box-shadow: 0 0 6px rgba(245, 215, 110, 0.7);
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.series-summary { font-size: 0.82rem; }
|
||||
.series-blurb { font-size: 0.82rem; }
|
||||
}
|
||||
|
||||
@media (min-width: 1400px) {
|
||||
.series-summary { font-size: 0.9rem; }
|
||||
.series-blurb { font-size: 0.9rem; }
|
||||
}
|
||||
|
||||
.series-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.25rem 0.25rem;
|
||||
}
|
||||
|
||||
.series-header .header-title {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
/* Clickable playoff card wrapper */
|
||||
.series-link {
|
||||
display: contents;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.series-link .game-box {
|
||||
cursor: pointer;
|
||||
transition: transform 0.12s ease, border-color 0.12s ease;
|
||||
}
|
||||
.series-link:hover .game-box {
|
||||
border-color: var(--cup-gold-1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.series-link:focus-visible .game-box {
|
||||
outline: 2px solid var(--cup-gold-2);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ── Series detail page (/series/<id>) ──────────── */
|
||||
|
||||
.header-link {
|
||||
text-decoration: none;
|
||||
color: var(--cup-silver-2);
|
||||
}
|
||||
|
||||
.series-main {
|
||||
padding: 1rem 1.25rem 3rem;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.series-hero {
|
||||
background: var(--banner-bg);
|
||||
border: 1px solid var(--cup-gold-dim);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.series-hero-eyebrow {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.series-teams {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.series-team {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.series-team-leader {
|
||||
border-color: var(--cup-gold-dim);
|
||||
background: rgba(212, 175, 55, 0.06);
|
||||
}
|
||||
|
||||
.series-team-logo {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.series-team-name {
|
||||
font-weight: 600;
|
||||
font-size: 1.05rem;
|
||||
color: var(--cup-silver-2);
|
||||
}
|
||||
|
||||
.series-team-meta {
|
||||
font-size: 0.78rem;
|
||||
color: var(--cup-silver-dim);
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.series-team-wins {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--cup-gold-2);
|
||||
line-height: 1;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.series-versus {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.series-versus-label {
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.15em;
|
||||
color: var(--cup-silver-dim);
|
||||
}
|
||||
|
||||
.series-versus-score {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
color: var(--cup-gold-2);
|
||||
}
|
||||
|
||||
.series-versus-best {
|
||||
font-size: 0.75rem;
|
||||
color: var(--cup-silver-dim);
|
||||
}
|
||||
|
||||
.series-headline {
|
||||
font-size: 1rem;
|
||||
color: var(--cup-silver-1);
|
||||
max-width: 46ch;
|
||||
}
|
||||
|
||||
.series-next-card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--cup-gold-dim);
|
||||
border-radius: 10px;
|
||||
padding: 0.9rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
.series-next-matchup {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
align-items: center;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.series-next-team {
|
||||
color: var(--cup-silver-2);
|
||||
}
|
||||
|
||||
.series-next-at {
|
||||
color: var(--cup-silver-dim);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.series-next-meta {
|
||||
font-size: 0.85rem;
|
||||
color: var(--cup-silver-1);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.series-games {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
.series-game {
|
||||
display: grid;
|
||||
grid-template-columns: 90px 1fr auto;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
padding: 0.7rem 0.9rem;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.series-game-live,
|
||||
.series-game-completed {
|
||||
border-color: var(--cup-gold-dim);
|
||||
}
|
||||
|
||||
.series-game-col-number {
|
||||
font-weight: 600;
|
||||
color: var(--cup-silver-1);
|
||||
letter-spacing: 0.03em;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.series-game-col-matchup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.series-game-team {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.6rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.series-game-abbrev {
|
||||
font-weight: 600;
|
||||
color: var(--cup-silver-2);
|
||||
}
|
||||
|
||||
.series-game-score {
|
||||
color: var(--cup-silver-1);
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 1.6em;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.series-game-winner {
|
||||
color: var(--cup-gold-2);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.series-game-state {
|
||||
font-size: 0.8rem;
|
||||
color: var(--cup-silver-dim);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.series-teams {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.series-versus {
|
||||
order: 3;
|
||||
}
|
||||
.series-team-logo {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
}
|
||||
.series-game {
|
||||
grid-template-columns: 70px 1fr;
|
||||
}
|
||||
.series-game-col-state {
|
||||
grid-column: 1 / -1;
|
||||
text-align: left;
|
||||
}
|
||||
.series-game-state {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Bracket page (/bracket) ─────────────────────── */
|
||||
|
||||
.bracket-main {
|
||||
padding: 1rem 1.25rem 3rem;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.bracket-hero {
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.bracket-title {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
color: var(--cup-silver-2);
|
||||
letter-spacing: 0.05em;
|
||||
background: var(--gold-gradient);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.bracket-subtitle {
|
||||
color: var(--cup-silver-dim);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Desktop grid layout */
|
||||
.bracket-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.bracket-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-around;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.bracket-col-heading {
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--cup-silver-dim);
|
||||
text-align: center;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.bracket-cup-heading {
|
||||
color: var(--cup-gold-2);
|
||||
}
|
||||
|
||||
.bracket-col-cup {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.bracket-matchup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 8px;
|
||||
padding: 0.45rem 0.55rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: border-color 0.12s ease, transform 0.12s ease;
|
||||
}
|
||||
|
||||
.bracket-matchup:hover {
|
||||
border-color: var(--cup-gold-1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.bracket-matchup:focus-visible {
|
||||
outline: 2px solid var(--cup-gold-2);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.bracket-matchup-active {
|
||||
border-color: var(--cup-gold-dim);
|
||||
}
|
||||
|
||||
.bracket-matchup-complete {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.bracket-matchup-empty {
|
||||
background: transparent;
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.bracket-col-cup .bracket-matchup {
|
||||
border-color: var(--cup-gold-dim);
|
||||
background: linear-gradient(135deg, #162844 0%, #1a1a2e 100%);
|
||||
padding: 0.7rem 0.6rem;
|
||||
}
|
||||
|
||||
.bracket-col-cup .bracket-matchup:hover {
|
||||
border-color: var(--cup-gold-2);
|
||||
}
|
||||
|
||||
.bracket-team {
|
||||
display: grid;
|
||||
grid-template-columns: 22px 1fr auto auto;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.2rem 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.bracket-team-logo {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.bracket-team-abbrev {
|
||||
font-weight: 600;
|
||||
color: var(--cup-silver-2);
|
||||
}
|
||||
|
||||
.bracket-team-seed {
|
||||
font-size: 0.7rem;
|
||||
color: var(--cup-silver-dim);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.bracket-team-wins {
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 1.2em;
|
||||
text-align: right;
|
||||
color: var(--cup-silver-1);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bracket-team-winner {
|
||||
color: var(--cup-gold-2);
|
||||
}
|
||||
.bracket-team-winner .bracket-team-abbrev,
|
||||
.bracket-team-winner .bracket-team-wins {
|
||||
color: var(--cup-gold-2);
|
||||
}
|
||||
|
||||
.bracket-team-placeholder {
|
||||
color: var(--cup-silver-dim);
|
||||
text-align: center;
|
||||
padding: 0.3rem 0;
|
||||
font-size: 0.8rem;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* Mobile accordion — hidden on desktop */
|
||||
.bracket-accordion {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.bracket-round {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bracket-round-summary {
|
||||
padding: 0.8rem 1rem;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
color: var(--cup-gold-2);
|
||||
letter-spacing: 0.03em;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bracket-round-summary::after {
|
||||
content: "+";
|
||||
color: var(--cup-silver-dim);
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.bracket-round[open] .bracket-round-summary::after {
|
||||
content: "−";
|
||||
}
|
||||
|
||||
.bracket-round-body {
|
||||
padding: 0 0.8rem 0.8rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.bracket-round-half-heading {
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--cup-silver-dim);
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.bracket-round-half {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
/* Switch layout at narrow widths */
|
||||
@media (max-width: 900px) {
|
||||
.bracket-grid { display: none; }
|
||||
.bracket-accordion { display: flex; }
|
||||
}
|
||||
|
||||
/* Banner bracket link (both pages) */
|
||||
.banner-bracket-link {
|
||||
background: transparent;
|
||||
border: 1px solid var(--cup-gold-dim);
|
||||
color: var(--cup-gold-2);
|
||||
padding: 0.35rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.05em;
|
||||
text-decoration: none;
|
||||
transition: border-color 0.12s ease, color 0.12s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.banner-bracket-link:hover {
|
||||
border-color: var(--cup-gold-1);
|
||||
color: var(--cup-gold-2);
|
||||
}
|
||||
|
||||
|
||||
+15
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'nhl-scoreboard-v1';
|
||||
const CACHE = 'nhl-scoreboard-v3';
|
||||
const PRECACHE = [
|
||||
'/',
|
||||
'/static/styles.css',
|
||||
@@ -33,6 +33,20 @@ self.addEventListener('fetch', event => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Network-first for bracket + series detail pages; fall back to cache offline
|
||||
if (pathname === '/bracket' || pathname.startsWith('/series/')) {
|
||||
event.respondWith(
|
||||
fetch(event.request).then(response => {
|
||||
if (response.ok) {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE).then(c => c.put(event.request, clone));
|
||||
}
|
||||
return response;
|
||||
}).catch(() => caches.match(event.request))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cache-first for everything else (static assets, shell)
|
||||
event.respondWith(
|
||||
caches.match(event.request).then(cached => {
|
||||
|
||||
Reference in New Issue
Block a user