Files
NHL-Scoreboard/app/static/script.js
josh 3d77c7cd5a
All checks were successful
CI / Lint (push) Successful in 7s
CI / Test (push) Successful in 6s
CI / Build & Push (push) Successful in 17s
feat: PWA support with hockey puck icon
Adds full PWA compliance: web app manifest, service worker with
cache-first static / network-first scoreboard strategy, and a
generated hockey puck icon (512, 192, 180, 32px) on the app's
dark navy background. Includes all required meta tags for iOS
standalone mode and a /favicon.ico route.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 19:04:33 -04:00

249 lines
9.1 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) {
const sections = [
{ sectionId: 'live-section', gridId: 'live-games-section', games: data.live_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();
}
// ── Renderers ────────────────────────────────────────
function renderLiveGame(game) {
const intermission = game['Intermission'];
const period = game['Period'];
const time = game['Time Remaining'];
const running = game['Time Running'];
const periodLabel = intermission
? `<span class="badge">${intermissionLabel(period)}</span>`
: `<span class="badge badge-live">${ordinalPeriod(period)}</span>`;
const dot = running ? `<span class="live-dot"></span>` : '';
// Tick the clock locally when the clock is running or during intermission
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>` : '';
return `
<div class="game-box ${intermission ? 'game-box-intermission' : 'game-box-live'}" data-game-key="${game['Away Team']}|${game['Home Team']}">
<div class="card-header">
<div class="badges">
${periodLabel}
<span class="badge" ${clockAttrs}>${time}</span>
</div>
${dot}
</div>
${teamRow(game, 'Away', 'live')}
${teamRow(game, 'Home', 'live')}
${ppIndicator(game)}
${hype}
</div>`;
}
function renderPreGame(game) {
return `
<div class="game-box">
<div class="card-header">
<div class="badges">
<span class="badge">${game['Start Time']}</span>
</div>
</div>
${teamRow(game, 'Away', 'pre')}
${teamRow(game, 'Home', 'pre')}
</div>`;
}
function renderFinalGame(game) {
const labels = { REG: 'Final', OT: 'Final/OT', SO: 'Final/SO' };
const label = labels[game['Last Period Type']] ?? 'Final';
return `
<div class="game-box">
<div class="card-header">
<div class="badges">
<span class="badge badge-muted">${label}</span>
</div>
</div>
${teamRow(game, 'Away', 'final')}
${teamRow(game, 'Home', 'final')}
</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 ppIndicator(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 `
<div class="pp-indicator">
<span class="pp-label">PP</span>
<span class="pp-team">${team}</span>
<span class="pp-clock" ${attrs}>${timeStr}</span>
</div>`;
}
// ── Gauge ────────────────────────────────────────────
function updateGauges() {
document.querySelectorAll('.gauge').forEach(el => {
const score = Math.min(700, Math.max(0, parseInt(el.dataset.score, 10)));
el.style.width = `${(score / 700) * 100}%`;
el.style.backgroundColor = score <= 300 ? '#4a90e2'
: score <= 550 ? '#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;
// Only restore if we're outside the final sync window
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';
}
// ── Init ─────────────────────────────────────────────
function autoRefresh() {
fetchScoreboardData();
setTimeout(autoRefresh, 5000);
}
window.addEventListener('load', () => {
autoRefresh();
setInterval(tickClocks, 1000);
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(err => {
console.warn('Service worker registration failed:', err);
});
}
});