13 KiB
Issues & Optimization Candidates
Tracked optimization proposals and known issues. Analyzed but not yet implemented.
Event-Driven Scheduling for Match State Transitions (2026-06-15)
Status: Analyzed, deferred (no implementation yet)
Issue
Latency in the Matches tab when a match transitions to "over" state. Currently, the system uses polling (OCC_TICK_MS = 60s) to check if any match has entered "over" status (either via JSON status==='finished' or clock reaching kickoff + window). This causes up to 60 seconds of delay between the actual state change and the UI update (the "Pendente de resultado" chip appearing on a match card).
Proposed Solution
Implement event-driven scheduling instead of polling:
- Calculate exact timestamps when each match will transition states (kickoff → "live", kickoff + window → "over")
- Use
setTimeoutto schedule precise callbacks for these moments - Render the list only when a timeout fires
- Revalidate/reschedule timeouts when
getData()updates (daily refresh)
Benefits
- Latency: Reduced from up to 60s to ~0s
- Efficiency: Zero CPU wasted on unnecessary checks between state changes
- Deterministic: Transition moments are calculable with precision
Technical Feasibility
✅ Viable. The matchState() function already computes state based on kickoff and window, so timestamps are known. Logic to manage ~200 timeouts (104 matches × 2 transitions) is straightforward but requires cleanup/reschedule logic on getData() updates.
Why Not Implemented Yet (Cost-Benefit Analysis)
Complexity vs. impact trade-off: The improvement is real but limited:
-
Limited real-world UX impact
- The "match over but JSON not updated" state is transitory (~minutes), lasting only until the daily manual refresh lands
- Most users either watch the hero (which updates every 1s and already flips to the next match instantly) or check the Matches tab after a refresh
- Polling at 60s is already so infrequent (0.017 Hz) that CPU cost is negligible
-
Moderate implementation cost
- Managing 200+ live timeouts and cleaning up old ones on data refresh adds complexity
- Must handle race conditions: JSON update and timeout firing simultaneously
- Adds another system to maintain/debug
-
Narrow use case
- Would matter if thousands of simultaneous matches existed, or if users commonly left the Matches tab open for hours
- Current tournament is 72 group matches + 32 knockout matches (104 total); no real-time data updates (daily manual refresh)
When to Implement
Only if:
- Latency in the Matches tab becomes a reported UX complaint
- The tournament adds real-time data feeds (WebSocket/API polling) instead of manual daily refresh
- Similar polling patterns accumulate elsewhere and warrant a systematic refactor
How to Implement (if revisited)
- Create
scheduleMatchStateChanges()inschedule.js - For each match, calculate
kickoffTimeandkickoffTime + matchWindowMs(match) - Schedule
setTimeoutcallbacks for both transitions - On
getData()refetch, cancel old timeouts and reschedule - Callback directly fires
renderList() - Guard against duplicate timers (similar to
startHeroClockpattern inapp.js)
PWA Tier 2 — Service Worker + Offline (2026-06-16)
Status: Analyzed, deferred (Tier 1 shipped 2026-06-16 — see project-memory "PWA — installable app").
Context
The PWA install issue was delivered as Tier 1 (manifest + icons + meta tags), which already meets every acceptance criterion (installable, correct name/icon, standalone launch from the OS shortcut, no app-pipeline risk). Tier 2 — a service worker for offline launch and the strongest cross-browser "app feel" — was intentionally left out. It is not required for the install prompt in modern Chrome/Edge.
Why deferred (the real risk)
A naïve precaching SW would cache data/*.json and silently defeat the 2026-06-16 live-refresh
system (the 90s results.json poll with cache:'no-store' + the DATA_VERSION cache-buster) —
open tabs would stop seeing new scores, and DATA_VERSION bumps would do nothing. It would also make
the "stale JS module" gotcha (#5) permanent (cached assets live until the cache name changes).
How to implement (if revisited) — constraints, not optional
- Never cache
data/*.json. Use network-only, or network-first with the cache only as an offline fallback (so an offline launch shows the last-seen results). The 90s poll must stay the owner of freshness. - Version the SW cache with a constant mirroring/derived from
DATA_VERSION; clean up old caches onactivate— otherwise every code deploy risks serving stale JS forever (gotcha #5). - Register at the subpath (
worldcup2026/sw.js) so the SW scope matches the deploy (gotcha #7); keepstart_url/scoperelative as they already are. - App-shell strategy: cache-first (versioned) for
index.html+assets/css+assets/js+assets/icons; precache oninstall. - Verify the poll still updates an open tab with the SW active (the easy thing to regress).
When to implement
Only if offline launch / a fuller install experience is actually wanted, and only with the data-cache exclusion + cache-versioning above. Otherwise Tier 1 is sufficient.
Live Data Refresh — Stale Results Until Page Reload (2026-06-15)
Status: ✅ Implemented 2026-06-16 (Option A⁺ — "Fixed polling done right"). The analysis below
is kept for the rationale; the shipped implementation (functions, files, verification) is documented in
project-memory.md → "Live data refresh — poll de results.json sem F5 (2026-06-16, Opção A⁺)".
Issue
A user with the tab open keeps seeing the data that was loaded once at page load. When the daily
refresh publishes a new results.json (final score + stats for a finished match), open tabs do not
pick it up — only a full F5 reloads it. loadData() runs once and memoizes data in a module-level
variable (app.js:16-37); nothing ever refetches results.json afterward.
Reframe (the key architectural fact)
This is not a live-feed problem. results.json is updated manually (the /update-worldcup
runbook: edit → commit → push → FTP deploy), and always after a match has ended — never during
play. So:
- During a "live" match there is no new data on the server to fetch — the server's
results.jsonstill has no score until the dev pushes the final result. - The only latency that matters is "dev pushes the final result → how long until an open tab shows it", which is bounded by the poll interval regardless of match state.
- The "site feels dead" symptom is already largely solved by the clock-driven hero
(
matchState/heroTick, app.js) which advances upcoming→live→over and switches to the next match with no new data. What's missing is purely surfacing newly-published server data (final scores + stats) without an F5.
This kills the premise behind the "30s during live" tier of dynamic-polling proposals: there is nothing new to fetch during the live window, so a faster poll there buys nothing.
Options considered
- Fixed polling (5 min) + compare — right direction; two real but cheaply-fixable weaknesses (fixed interval wastes cycles when idle; "finished-count" signature is too weak).
- Dynamic/state-based polling (30s live / 60s post / 5 min gaps) — rejected: optimizes a scenario the data model doesn't have (no live server data), paying state-machine complexity + double-schedule risk (cf. gotcha #6) for no real gain.
- Fuzzy "smart timing" (lower poll near kickoff) — rejected (self-refuting): lowering the poll 10 min before kickoff doesn't help when the update lands ~3h later, post-match.
Proposed Solution — "Fixed polling done right" (recommended)
Fixed-interval poll of results.json only, with three cheap upgrades that remove both weaknesses of
the naive fixed poll without the dynamic-polling complexity (~35-40 lines):
- Pause when the tab is hidden (Page Visibility API).
visibilitychangestops thesetIntervalin background and fires one immediate fetch on return. Eliminates the idle/battery cost — ~80% of the dynamic option's battery benefit in ~3 lines instead of a state machine. - Stop entirely when nothing remains to fetch.
clearIntervalonceFINALisover(tournament done) — polling forever afterward is pure waste. (Optionally slow the interval when all of the day's matches are alreadyoverby clock.) - Content-based signature, not finished-count. Compare the raw response text (or a cheap hash).
A count-of-finished signature misses score corrections (1-0 → 2-0, same count),
statsbackfill on an already-finished match (done routinely — see 2026-06-14 stats backfill), and added penalties.results.jsonis ~10-20KB, so full-text compare is free and catches everything.
Cache-busting (mandatory): the poll must NOT use ?v=${DATA_VERSION} (app.js:25)
— that constant is frozen in the open tab and Hostinger sends no cache headers (gotcha #2), so the
same URL serves the cached copy. Use data/results.json?t=${Date.now()} with cache: 'no-store'.
Benefits
- Latency: "infinite (needs F5)" → bounded by the interval (~90-120s).
- Efficiency: zero polling in background tabs and after the tournament ends; re-render only fires when the content signature actually changes (rare — a few pushes/day), so no DOM churn.
- Low risk: reuses the existing event-driven re-render pattern; no new state machine.
Technical Feasibility
✅ Viable, ~35-40 lines for the loop. The real work is the re-render fan-out, not the loop.
data is a single object with derived maps (app.js:30-35), so applying new
results means, in order:
data.results = newResults- rebuild
data.resultByMatchId = new Map(...)(consumed by schedule/groups/bracket/stats — reassigningdata.resultsalone leaves it stale) invalidateBracket()(the tree is cached — project-memory step 7)document.dispatchEvent(new Event('datachange'))
Each view then re-renders itself on datachange, exactly like it already does for
langchange/simchange/favchange/timemodechange (schedule.js:34-36).
Only a datachange listener per view (schedule, groups, bracket, stats, hero) is added — no new
paradigm.
Gotchas:
- Simulation: go through
invalidateBracket()+ tree rebuild, not a partial patch, sodecide()(real) andapplySimulation()(user picks) recombine under the existing "real result wins over sim" rule (project-memory step 9). Via the rebuilt tree this works for free. thirdPlaceAssignmentlives inbracket-config.json, notresults.json— polling results alone would leave the open tab on the old in-memorybracketConfig(the 8 third-place slots needing an F5), even though the server updated both files together (the poll never fetches the config). Resolved in the shipped version by piggybacking: when the poll detects a results change it refetchesbracket-config.jsonin the same cycle and swapsdata.bracketConfig. The one-time 3rd-place fill always ships in the same daily push as a results change, so this costs one extra fetch only on the rare change event — no per-tick config polling.- Mid-interaction re-render: a re-render while the user is dragging the bracket, has a modal open, or is typing in the search filter could be jarring. Low risk because the signature changes only a few times/day; if it bites, defer re-render of the view currently being interacted with.
Why Not Implemented Yet
Same posture as the entry above: the symptom is real but bounded (a stale tab between a manual push and the user's next F5), and the hero already keeps the home feeling alive. Worth doing before/at the knockout stage when more users may keep a tab open, but not urgent.
Relationship to "Event-Driven Scheduling" (above)
Complementary, not overlapping. That entry is about clock-state latency (the "Pendente de
resultado" chip via the 60s OCC_TICK_MS poll); this one is about server-data freshness (new
scores/stats). Both can coexist: the clock advances state instantly; this poll surfaces the published
result within one interval.
How to Implement (if revisited)
- Add
startResultsPolling()toapp.js(near the hero clock); call it frominit(). Guard against duplicate timers (if (resultsTimer) return, likestartHeroClock). - Each tick:
fetch('data/results.json?t=' + Date.now(), { cache: 'no-store' })→ read as text. - Compare text to the last-seen signature; bail if equal.
- On change:
JSON.parse, setdata.results, rebuilddata.resultByMatchId,invalidateBracket(),dispatchEvent('datachange'). - Interval ~90-120s while
!document.hidden; pause onvisibilitychange(hidden) + immediate fetch on return;clearIntervalonceFINALisover. - Add a
datachangelistener to schedule, groups, bracket, stats, and the hero (mirrors the existinglangchangelisteners).