let failCount = 0; const STALE_THRESHOLD = 3; // ── Date Navigation ────────────────────────────────── function localDateStr() { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; } let viewingDate = localDateStr(); function isToday() { return viewingDate === localDateStr(); } function shiftDate(offset) { const [y, m, d] = viewingDate.split('-').map(Number); const dt = new Date(y, m - 1, d + offset); viewingDate = `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`; updateDateLabel(); startAutoRefresh(); } function formatDateLabel(dateStr) { if (dateStr === localDateStr()) return 'Today'; const [y, m, d] = dateStr.split('-').map(Number); const dt = new Date(y, m - 1, d); const yesterday = new Date(); yesterday.setDate(yesterday.getDate() - 1); if (dt.toDateString() === yesterday.toDateString()) return 'Yesterday'; const tomorrow = new Date(); tomorrow.setDate(tomorrow.getDate() + 1); if (dt.toDateString() === tomorrow.toDateString()) return 'Tomorrow'; return dt.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }); } function updateDateLabel() { const label = document.getElementById('date-label'); if (label) label.textContent = formatDateLabel(viewingDate); } async function fetchScoreboardData() { const url = isToday() ? '/scoreboard' : `/scoreboard?date=${viewingDate}`; try { const res = await fetch(url); if (!res.ok) throw new Error(res.status); failCount = 0; setStale(false); updateScoreboard(await res.json()); } catch (e) { console.error('Failed to fetch scoreboard data:', e); failCount++; if (failCount >= STALE_THRESHOLD) setStale(true); } } function setStale(stale) { document.getElementById('stale-banner').classList.toggle('hidden', !stale); document.querySelector('main').classList.toggle('stale', stale); } 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); } const anyGames = sections.some(s => s.games && s.games.length > 0); document.getElementById('empty-state').classList.toggle('hidden', anyGames); restoreScroll(); const liveCount = (data.live_games || []).length + (data.intermission_games || []).length + (data.pinned_games || []).filter(g => g['Game State'] === 'LIVE').length; document.title = liveCount ? `NHL Scoreboard (${liveCount} Live)` : 'NHL Scoreboard'; 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) { setText(dayEl, `Day ${meta.day_n}`); 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}
${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 pp = game[`${side} Power Play`]; const sogHtml = (state === 'live' || state === 'final') && sog !== undefined ? `${sog} SOG` : ''; const ppHtml = state === 'live' && pp ? teamPpIndicator(pp, game['Time Running']) : ''; const subParts = [sogHtml, ppHtml].filter(Boolean).join(''); const subline = subParts ? `
${subParts}
` : ''; const right = state === 'pre' ? `${record}` : `${score}`; return `
${name} ${subline}
${right}
`; } function teamPpIndicator(pp, running) { const timeStr = pp.replace('PP ', ''); if (!running) { return `PP ${timeStr}`; } const seconds = timeToSeconds(timeStr); const attrs = `data-seconds="${seconds}" data-received-at="${Date.now()}" data-pp-clock`; return `PP ${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; const candidates = [...(data.pinned_games || []), ...(data.live_games || [])]; const hasPlayoffOT = candidates.some(g => g['Playoff OT']); if (hasPlayoffOT && Notification.permission === 'default') { Notification.requestPermission().catch(() => {}); return; } if (Notification.permission !== 'granted') return; const seen = seenOTKeys(); 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); } // ── Update Toast ───────────────────────────────────── function showUpdateToast() { if (document.getElementById('update-toast')) return; const toast = document.createElement('div'); toast.id = 'update-toast'; toast.className = 'update-toast'; toast.innerHTML = 'New version available '; toast.querySelector('button').addEventListener('click', () => location.reload()); document.body.appendChild(toast); } // ── Scroll Restoration ─────────────────────────────── const SCROLL_KEY = 'nhl_scroll_y'; let scrollRestored = false; function saveScroll() { sessionStorage.setItem(SCROLL_KEY, String(window.scrollY)); } function restoreScroll() { if (scrollRestored) return; scrollRestored = true; const y = parseInt(sessionStorage.getItem(SCROLL_KEY) || '0', 10); if (y > 0) { requestAnimationFrame(() => window.scrollTo(0, y)); } sessionStorage.removeItem(SCROLL_KEY); } // ── Init ───────────────────────────────────────────── let refreshTimer = null; function startAutoRefresh() { stopAutoRefresh(); fetchScoreboardData(); if (isToday()) { refreshTimer = setTimeout(startAutoRefresh, 5000); } } function stopAutoRefresh() { if (refreshTimer) { clearTimeout(refreshTimer); refreshTimer = null; } } window.addEventListener('load', () => { updateDateLabel(); document.getElementById('date-prev').addEventListener('click', () => shiftDate(-1)); document.getElementById('date-next').addEventListener('click', () => shiftDate(1)); document.addEventListener('click', e => { if (e.target.closest('.series-link')) saveScroll(); }); startAutoRefresh(); setInterval(tickClocks, 1000); if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/sw.js').catch(err => { console.warn('Service worker registration failed:', err); }); navigator.serviceWorker.addEventListener('controllerchange', () => { showUpdateToast(); }); } });