Games in intermission now appear in their own section between Live and Scheduled. The section is hidden when no games are in intermission, matching the behavior of the other section headings. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
250 lines
9.3 KiB
JavaScript
250 lines
9.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: '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();
|
|
}
|
|
|
|
// ── 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);
|
|
});
|
|
}
|
|
});
|