2da60e27ae
- Stale data banner after 3 consecutive fetch failures, auto-clears on recovery - Date navigation with left/right arrows (Yesterday/Today/Tomorrow labels), fetches from NHL API for non-today dates, disables auto-refresh on history - Empty state message when no games are scheduled - Series detail page auto-refreshes every 30s when a game is live - Notification permission deferred until a playoff OT actually occurs - Scroll position saved/restored when navigating to/from series detail - Team records rendered with better contrast and tabular nums - Active bracket round highlighted with gold heading + underline, completed rounds dimmed more aggressively, mobile accordion auto-opens current round - Browser tab title shows live game count (e.g. "NHL Scoreboard (3 Live)") - Service worker update shows a dismissable toast instead of force-reloading Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
531 lines
20 KiB
JavaScript
531 lines
20 KiB
JavaScript
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
|
|
? `<span class="badge">${intermissionLabel(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>` : '';
|
|
|
|
const shouldTick = running || intermission;
|
|
const rawSeconds = timeToSeconds(time);
|
|
const clockAttrs = shouldTick
|
|
? `data-seconds="${rawSeconds}" data-received-at="${Date.now()}"`
|
|
: '';
|
|
|
|
const hype = !intermission ? `
|
|
<div class="hype-meter">
|
|
<span class="hype-label">Hype Meter</span>
|
|
<div class="gauge-track">
|
|
<div class="gauge" data-score="${game['Priority']}"></div>
|
|
</div>
|
|
</div>` : '';
|
|
|
|
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">
|
|
${periodBadge}
|
|
<span class="badge" ${clockAttrs}>${time}</span>
|
|
</div>
|
|
${dot}
|
|
</div>
|
|
${teamRow(game, 'Away', 'live')}
|
|
${teamRow(game, 'Home', 'live')}
|
|
${hype}
|
|
${seriesBlurb(game)}
|
|
</div>`);
|
|
}
|
|
|
|
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>
|
|
</div>
|
|
</div>
|
|
${teamRow(game, 'Away', 'pre')}
|
|
${teamRow(game, 'Home', 'pre')}
|
|
${seriesBlurb(game)}
|
|
</div>`);
|
|
}
|
|
|
|
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, `
|
|
<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>
|
|
</div>
|
|
</div>
|
|
${teamRow(game, 'Away', 'final')}
|
|
${teamRow(game, 'Home', 'final')}
|
|
${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 ─────────────────────────────────────────
|
|
|
|
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
|
|
? `<span class="team-sog">${sog} SOG</span>` : '';
|
|
const ppHtml = state === 'live' && pp
|
|
? teamPpIndicator(pp, game['Time Running'])
|
|
: '';
|
|
|
|
const subParts = [sogHtml, ppHtml].filter(Boolean).join('');
|
|
const subline = subParts ? `<div class="team-subline">${subParts}</div>` : '';
|
|
|
|
const right = state === 'pre'
|
|
? `<span class="team-record">${record}</span>`
|
|
: `<span class="team-score">${score}</span>`;
|
|
|
|
return `
|
|
<div class="team-row">
|
|
<img src="${logo}" alt="${name} logo" class="team-logo">
|
|
<div class="team-meta">
|
|
<span class="team-name">${name}</span>
|
|
${subline}
|
|
</div>
|
|
${right}
|
|
</div>`;
|
|
}
|
|
|
|
function teamPpIndicator(pp, running) {
|
|
const timeStr = pp.replace('PP ', '');
|
|
if (!running) {
|
|
return `<span class="team-pp">PP ${timeStr}</span>`;
|
|
}
|
|
const seconds = timeToSeconds(timeStr);
|
|
const attrs = `data-seconds="${seconds}" data-received-at="${Date.now()}" data-pp-clock`;
|
|
return `<span class="team-pp">PP <span ${attrs}>${timeStr}</span></span>`;
|
|
}
|
|
|
|
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 <button class="update-toast-btn">Reload</button>';
|
|
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();
|
|
});
|
|
}
|
|
});
|