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) {
|
||||
|
||||
Reference in New Issue
Block a user