feat: make scoreboard playoff-aware with banner, bracket, and series drill-down
CI / Lint (push) Failing after 8s
CI / Test (push) Has been skipped
CI / Build & Push (push) Has been skipped

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:
2026-04-19 12:47:31 -04:00
parent e0db8f0859
commit ebe770fecd
21 changed files with 3163 additions and 31 deletions
+216 -21
View File
@@ -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} &middot; 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) {