Files
NHL-Scoreboard/app/static/script.js
T
josh 2da60e27ae
CI / Lint (push) Failing after 10s
CI / Test (push) Has been skipped
CI / Build & Push (push) Has been skipped
feat: add 10 UX improvements from interface review
- 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>
2026-04-23 20:22:03 -04:00

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} &middot; 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();
});
}
});