e0a1c033cf
tickClocks iterates every [data-seconds][data-received-at] element. Rendering the PP indicator with those attrs during an intermission made the clock bleed seconds even though play is paused and the penalty isn't running. Drop the ticking attrs when game['Intermission'] is true — render a plain static "PP MM:SS" that resumes ticking on the next live payload. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
422 lines
16 KiB
JavaScript
422 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>
|
|
</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['Intermission'])
|
|
: '';
|
|
|
|
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, intermission) {
|
|
const timeStr = pp.replace('PP ', '');
|
|
if (intermission) {
|
|
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;
|
|
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 requestNotificationPermission() {
|
|
if (!('Notification' in window)) return;
|
|
if (Notification.permission !== 'default') return;
|
|
Notification.requestPermission().catch(() => {});
|
|
}
|
|
|
|
// ── Init ─────────────────────────────────────────────
|
|
|
|
function autoRefresh() {
|
|
fetchScoreboardData();
|
|
setTimeout(autoRefresh, 5000);
|
|
}
|
|
|
|
window.addEventListener('load', () => {
|
|
requestNotificationPermission();
|
|
autoRefresh();
|
|
setInterval(tickClocks, 1000);
|
|
if ('serviceWorker' in navigator) {
|
|
navigator.serviceWorker.register('/sw.js').catch(err => {
|
|
console.warn('Service worker registration failed:', err);
|
|
});
|
|
}
|
|
});
|