From 5b1707a764892a9417791bae5d0d26bfb1af4bfe Mon Sep 17 00:00:00 2001 From: Lucas Kalil Date: Tue, 16 Jun 2026 00:12:50 -0300 Subject: [PATCH] feat(app): poll results.json for live updates without reload --- assets/js/app.js | 71 ++++++++++++++++++++++++++++++++++++++++++- assets/js/bracket.js | 1 + assets/js/groups.js | 1 + assets/js/schedule.js | 1 + assets/js/stats.js | 2 ++ 5 files changed, 75 insertions(+), 1 deletion(-) diff --git a/assets/js/app.js b/assets/js/app.js index 482085f..4b12e59 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -8,7 +8,7 @@ import { initSchedule } from './schedule.js'; import { initGroups } from './groups.js'; import { initStadiums } from './stadiums.js'; import { initModal } from './modal.js'; -import { initBracket } from './bracket.js'; +import { initBracket, invalidateBracket } from './bracket.js'; import { initStats } from './stats.js'; // ---------------------------------------------------------------- data @@ -40,6 +40,73 @@ export function getData() { return data; } +// ------------------------------------------------------ live data refresh +// results.json is the only file that changes during the tournament, and it is +// updated by a MANUAL daily push (scores land post-match, on deploy) — not a +// live feed. An open tab fetches it once at load; this poll surfaces a newly +// published result/stats without an F5. Static host → polling is the only +// option; because the data isn't live, a plain fixed interval is right (a +// per-match "live" tier would have nothing new to fetch). Paused while the tab +// is hidden and stopped once the final result is in — see .agents/issues.md. +const POLL_INTERVAL_MS = 90 * 1000; +let pollTimer = null; +let resultsSig = null; + +// Nothing left to fetch once the final's REAL result is in the data. Guard on +// the JSON status, not the clock-driven 'over' — clock-over fires 3h after +// kickoff and could stop the poll before the actual score is published. +function tournamentOver() { + const final = data.matches.find((m) => m.bracketRef === 'FINAL'); + return final ? data.resultByMatchId.get(final.id)?.status === 'finished' : false; +} + +async function pollResults() { + if (tournamentOver()) { stopResultsPolling(); return; } + let results; + try { + // ?t bypasses the frozen DATA_VERSION + Hostinger's missing cache headers + const res = await fetch(`data/results.json?t=${Date.now()}`, { cache: 'no-store' }); + if (!res.ok) return; + results = await res.json(); + } catch { + return; // network blip or mid-deploy partial — just retry next tick + } + // Content signature: catches scores, stats backfill and penalties alike — + // a finished-count signature would miss corrections and stats-only edits. + const sig = JSON.stringify(results); + if (sig === resultsSig) return; // unchanged → zero re-render + resultsSig = sig; + data.results = results; + data.resultByMatchId = new Map(results.map((r) => [r.matchId, r])); // derived map must be rebuilt too + // bracket-config.json (thirdPlaceAssignment) only ever changes alongside a + // results change — the one-time 3rd-place fill ships in the same daily push. + // So piggyback a refetch on the rare results-changed event (not every tick): + // closes the gap where the 8 third-place slots would otherwise need an F5. + try { + const cfg = await fetch(`data/bracket-config.json?t=${Date.now()}`, { cache: 'no-store' }); + if (cfg.ok) data.bracketConfig = await cfg.json(); + } catch { /* keep the in-memory config */ } + invalidateBracket(); // cached tree depends on results + bracketConfig + document.dispatchEvent(new CustomEvent('datachange')); // each view re-renders itself + if (tournamentOver()) stopResultsPolling(); +} + +function onVisibility() { + if (!document.hidden) pollResults(); // catch up the instant the user returns +} + +function startResultsPolling() { + if (pollTimer || tournamentOver()) return; + resultsSig = JSON.stringify(data.results); // seed from what loadData() already fetched + pollTimer = setInterval(() => { if (!document.hidden) pollResults(); }, POLL_INTERVAL_MS); + document.addEventListener('visibilitychange', onVisibility); +} + +function stopResultsPolling() { + if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } + document.removeEventListener('visibilitychange', onVisibility); +} + // ---------------------------------------------------------------- time export function matchDateUTC(match) { @@ -461,6 +528,7 @@ async function init() { initTooltips(); document.addEventListener('langchange', renderHome); document.addEventListener('timemodechange', renderHero); + document.addEventListener('datachange', renderHome); // poll picked up new results → refresh hero + dashboard counts try { await loadData(); renderHome(); @@ -470,6 +538,7 @@ async function init() { initBracket(); initStadiums(); initStats(); + startResultsPolling(); // after the views register their datachange listeners } catch (error) { showError(error); } diff --git a/assets/js/bracket.js b/assets/js/bracket.js index 9f4c8c2..be80afd 100644 --- a/assets/js/bracket.js +++ b/assets/js/bracket.js @@ -268,6 +268,7 @@ export function initBracket() { render(); document.addEventListener('langchange', render); document.addEventListener('favchange', render); + document.addEventListener('datachange', render); // tree already invalidated by the poll → rebuilds loadPredictionFromURL(); const root = document.getElementById('bracket-root'); diff --git a/assets/js/groups.js b/assets/js/groups.js index 12ae2bb..608d91f 100644 --- a/assets/js/groups.js +++ b/assets/js/groups.js @@ -58,6 +58,7 @@ export function initGroups() { render(); document.addEventListener('langchange', render); document.addEventListener('favchange', render); + document.addEventListener('datachange', render); // new results → recompute standings } function render() { diff --git a/assets/js/schedule.js b/assets/js/schedule.js index 6c31808..4a231b8 100644 --- a/assets/js/schedule.js +++ b/assets/js/schedule.js @@ -34,6 +34,7 @@ export function initSchedule() { document.addEventListener('simchange', renderList); document.addEventListener('favchange', renderList); document.addEventListener('timemodechange', renderList); + document.addEventListener('datachange', renderList); // new published results/status // delegation on the panel root — survives every list re-render const root = document.getElementById('schedule-root'); diff --git a/assets/js/stats.js b/assets/js/stats.js index 48f8253..4a9819b 100644 --- a/assets/js/stats.js +++ b/assets/js/stats.js @@ -169,6 +169,8 @@ export function initStats() { // labels re-render on language change; the derived model never changes at // runtime (data is static per page load) so it is reused. document.addEventListener('langchange', render); + // new published results change the aggregates → rebuild the memoized model + document.addEventListener('datachange', () => { model = null; render(); }); } function render() {