async function fetchScoreboardData() { try { const res = await fetch('/scoreboard'); if (!res.ok) throw new Error(res.status); updateScoreboard(await res.json()); } catch (e) { console.error('Failed to fetch scoreboard data:', e); } } function updateScoreboard(data) { applyMeta(data.meta); const sections = [ { 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) { const section = document.getElementById(sectionId); const grid = document.getElementById(gridId); const hasGames = games && games.length > 0; section.classList.toggle('hidden', !hasGames); // Snapshot current clock state before blowing away the DOM const clockSnapshot = snapshotClocks(grid); grid.innerHTML = hasGames ? games.map(render).join('') : ''; // Restore smooth local anchors unless we're in the final 60s if (hasGames) restoreClocks(grid, clockSnapshot); } 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 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 periodText = playoffOT ? (game['OT Label'] || 'OT') : ordinalPeriod(period); const periodBadge = intermission ? `${intermissionLabel(period)}` : playoffOT ? `${periodText} · SUDDEN DEATH` : `${periodText}`; const dot = running ? `` : ''; const shouldTick = running || intermission; const rawSeconds = timeToSeconds(time); const clockAttrs = shouldTick ? `data-seconds="${rawSeconds}" data-received-at="${Date.now()}"` : ''; const hype = !intermission ? `
Hype Meter
` : ''; 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, `
${playoffContext(game)}
${periodBadge} ${time} ${ppBadge(game)}
${dot}
${teamRow(game, 'Away', 'live')} ${teamRow(game, 'Home', 'live')} ${hype} ${seriesBlurb(game)}
`); } 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, `
${playoffContext(game)}
${game['Start Time']}
${teamRow(game, 'Away', 'pre')} ${teamRow(game, 'Home', 'pre')} ${seriesBlurb(game)}
`); } function renderFinalGame(game, opts = {}) { const labels = { REG: 'Final', OT: 'Final/OT', SO: 'Final/SO' }; const label = labels[game['Last Period Type']] ?? 'Final'; const isPlayoff = game['Is Playoff']; const playoffClass = isPlayoff ? ' game-box-playoff' : ''; const pinnedClass = opts.pinned ? ' game-box-pinned' : ''; return wrapSeriesLink(game, `
${playoffContext(game)}
${label}
${teamRow(game, 'Away', 'final')} ${teamRow(game, 'Home', 'final')} ${seriesBlurb(game)}
`); } function wrapSeriesLink(game, html) { const sid = game['Series ID']; if (!sid) return html; return `${html}`; } // ── Playoff context (badges row + series summary) ───── function playoffContext(game) { if (!game['Is Playoff']) return ''; const badges = (game['Series Badges'] || []) .map(b => `${b}`) .join(''); const summary = game['Series Summary'] ? `${game['Series Summary']}` : ''; if (!badges && !summary) return ''; return `
${badges}${summary}
`; } 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 `
${game['Series Blurb']}
`; } // ── Team Row ───────────────────────────────────────── function teamRow(game, side, state) { const name = game[`${side} Team`]; const logo = game[`${side} Logo`]; const score = game[`${side} Score`]; const sog = game[`${side} Shots`]; const record = game[`${side} Record`]; const sogHtml = (state === 'live' || state === 'final') && sog !== undefined ? `${sog} SOG` : ''; const right = state === 'pre' ? `${record}` : `${score}`; return `
${name} ${sogHtml}
${right}
`; } function ppBadge(game) { const awayPP = game['Away Power Play']; const homePP = game['Home Power Play']; const pp = awayPP || homePP; if (!pp) return ''; const team = awayPP ? game['Away Team'] : game['Home Team']; const timeStr = pp.replace('PP ', ''); const seconds = timeToSeconds(timeStr); const attrs = `data-seconds="${seconds}" data-received-at="${Date.now()}" data-pp-clock`; return `PP ${team} ${timeStr}`; } function gameKey(game) { return `${game['Away Team']}|${game['Home Team']}`; } // ── Gauge ──────────────────────────────────────────── function updateGauges() { document.querySelectorAll('.gauge').forEach(el => { const score = Math.min(1000, Math.max(0, parseInt(el.dataset.score, 10))); el.style.width = `${(score / 1000) * 100}%`; el.style.backgroundColor = score <= 350 ? '#4a90e2' : score <= 650 ? '#f97316' : '#ef4444'; }); } const CLOCK_SYNC_THRESHOLD = 60; // seconds — only resync from API in final 60s // ── Clock ───────────────────────────────────────────── function timeToSeconds(str) { if (!str || str === 'END') return 0; const [m, s] = str.split(':').map(Number); return m * 60 + s; } function secondsToTime(s) { if (s <= 0) return 'END'; const m = Math.floor(s / 60); const sec = s % 60; return `${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`; } function snapshotClocks(grid) { const snapshot = new Map(); grid.querySelectorAll('[data-game-key]').forEach(card => { const badge = card.querySelector('[data-seconds][data-received-at]:not([data-pp-clock])'); if (!badge) return; const seconds = parseInt(badge.dataset.seconds, 10); const receivedAt = parseInt(badge.dataset.receivedAt, 10); const elapsed = Math.floor((Date.now() - receivedAt) / 1000); const current = Math.max(0, seconds - elapsed); snapshot.set(card.dataset.gameKey, { current, ts: Date.now() }); }); return snapshot; } function restoreClocks(grid, snapshot) { grid.querySelectorAll('[data-game-key]').forEach(card => { const prior = snapshot.get(card.dataset.gameKey); if (!prior) return; const badge = card.querySelector('[data-seconds][data-received-at]:not([data-pp-clock])'); if (!badge) return; if (prior.current > CLOCK_SYNC_THRESHOLD) { badge.dataset.seconds = prior.current; badge.dataset.receivedAt = prior.ts; badge.textContent = secondsToTime(prior.current); } }); } function tickClocks() { const now = Date.now(); document.querySelectorAll('[data-seconds][data-received-at]').forEach(el => { const seconds = parseInt(el.dataset.seconds, 10); const receivedAt = parseInt(el.dataset.receivedAt, 10); const elapsed = Math.floor((now - receivedAt) / 1000); el.textContent = secondsToTime(Math.max(0, seconds - elapsed)); }); } // ── Helpers ────────────────────────────────────────── function ordinalPeriod(period) { return ['1st', '2nd', '3rd', 'OT'][period - 1] ?? 'SO'; } 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() { fetchScoreboardData(); setTimeout(autoRefresh, 5000); } window.addEventListener('load', () => { wireOTButton(); autoRefresh(); setInterval(tickClocks, 1000); if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/sw.js').catch(err => { console.warn('Service worker registration failed:', err); }); } });