Snapshot the locally-computed clock state before each re-render and restore it afterwards, so the API response doesn't cause a visible jump. Only resync to the API value in the final 60 seconds, where accuracy matters. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
226 lines
8.3 KiB
JavaScript
226 lines
8.3 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')}
|
|
${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 pp = game[`${side} Power Play`];
|
|
const record = game[`${side} Record`];
|
|
|
|
const sogHtml = (state === 'live' || state === 'final') && sog !== undefined
|
|
? `<span class="team-sog">${sog} SOG</span>` : '';
|
|
const ppHtml = pp ? `<span class="team-pp">${pp}</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}${ppHtml}
|
|
</div>
|
|
${right}
|
|
</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]');
|
|
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]');
|
|
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);
|
|
});
|