a88e2edef0
The banner read "Day 1 of ~60" on day 2 of the playoffs because the old anchor recorded whatever date we first polled a playoff game as Day 1. Now round start dates come from /v1/schedule/playoff-series, so Day N is authoritative and resets at each round boundary. Drops the noisy "of ~60" denominator. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
439 lines
16 KiB
JavaScript
439 lines
16 KiB
JavaScript
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) {
|
|
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>
|
|
${ppBadge(game)}
|
|
</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 sogHtml = (state === 'live' || state === 'final') && sog !== undefined
|
|
? `<span class="team-sog">${sog} SOG</span>` : '';
|
|
|
|
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>
|
|
${sogHtml}
|
|
</div>
|
|
${right}
|
|
</div>`;
|
|
}
|
|
|
|
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 `<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() {
|
|
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);
|
|
});
|
|
}
|
|
});
|