33 KiB
Project Memory — World Cup 2026 Hub
Persistent memory for this project. Read before any significant change.
Fixed structure (keep this order): Context · Architecture & Decisions · Gotchas · Operational Runbooks · Stats Screen · Patterns & How-tos · Current State.
Maintenance rule (set 2026-06-17): this file holds durable knowledge only — architecture, decisions, gotchas, patterns. Per-match daily-refresh detail lives in git commits (see the commit convention), not here. The Current State section keeps a rolling window of the last 3 refreshes and is pruned on each update (do not append new dated refresh logs). New decisions / gotchas / patterns are appended to their section. Content is kept in its original language (EN or PT) where it was written that way; new scaffolding is in English.
Context
Static web app for the FIFA World Cup 2026 (Mexico/USA/Canada, 48 teams) — schedule, group
standings, interactive knockout bracket with user simulation, stadiums, and a post-tournament stats
screen. All content from data/*.json. Started 2026-06-11 from two spec documents; built
step-by-step with user approval between steps; now live with real WC2026 data, refreshed daily.
What it is: a personal/portfolio piece (visual polish is a primary goal); a static SPA
(one index.html, ES-module vanilla JS, JSON as the only "database"); maintained by editing JSON
only — code should never need touching to update scores/teams.
What it is not: no backend, database, build step, bundler, CDN dependency, or framework; no automated tests / linter (explicit spec constraint).
Spec source of truth: world-cup-2026-hub-spec-en.md + complement-spec-worldcup2026-en.md
(complement wins on conflict).
Priority objectives
- Spec compliance — complement spec wins on conflict.
- Visual quality — FIFA/UCL/Apple-inspired, glassmorphism, smooth animations; portfolio-grade.
- Interactive bracket — hover path highlight, zoom, drag, simulation; the centerpiece feature.
- Easy maintenance — real data drop-in via JSON;
bracket-config.jsonis the only structural file edited after the group stage. - Performance/accessibility — Lighthouse > 90, first render < 2s, JS < 300KB, ARIA + keyboard nav.
Architecture & Decisions
Stack & module pattern
- Vanilla HTML/CSS/JS ES2022+, ES Modules, relative paths, no bundler/CDN/framework — spec mandate (GitHub Pages / Hostinger serve static files only).
- EN/PT-BR UI toggle via
i18n.js: tiny dict +t(key), persisted inwc2026_prefs.lang. Static HTML usesdata-i18n/data-i18n-ariare-applied byapplyI18n(); dynamic renders callt()and listen forlangchange. Phases viatranslatePhase()(PT: R32 = "16 avos de final"). Default language:navigator.languagestartsWithpt→ PT, else EN; only persisted on toggle. storage.jsis the only access path tolocalStorage(wc2026_*keys, auto JSON). Holds prefs (lang,lastTab,timeMode),wc2026_favorites,wc2026_simulation.- Per-view modules (
schedule/groups/bracket/stadiums/stats) +app.jsentry. Circular importsapp.js⇄ view modules are intentional and safe in native ESM — all cross-calls happen at render runtime, after every module has evaluated.stats.jsimportsgetBracketTree,getFavorites,openMatchModalthis way too. - Custom events on
documentdrive re-renders — each view owns its own:langchange,simchange,favchange,timemodechange,datachange(live refresh). No shared render loop.
Data model
- All match times are UTC in
matches.json; converted at render byformatMatchTime(match, stadium, mode)viaIntl.DateTimeFormat(mode="local"browser tz, or"stadium"timezone)..icsexport depends on this. - Match ids: group matches 1–72 = chronological by UTC kickoff (≠ 6-per-group blocks);
73–104 = FIFA official match numbers (knockout, carry
bracketRef). - Knockout matches carry
bracketRef, not teams — resolved at runtime from standings +bracket-config.json; rounds after R32 have no config and are generated by sequential pairing of winners (indices 0-1 → 0, 2-3 → 1, …). - Simulation never mutates JSON — overlay in
localStorage.wc2026_simulation, keyed by bracketRef (R32-6: { winner: "FRA", score: "2-1" }, score home-away). - Team ids are 3-letter uppercase (
MEX,BRA). Knockout ids:R32-1…R32-16,R16-1…,QF-1…,SF-1/SF-2,THIRD-PLACE,FINAL.
Standings (groups.js)
- Only
status:"finished"counts toward standings (live scores ignored until full-time → stable standings + deterministic bracket resolution). - Tiebreak: points → goal difference → goals for → team id alphabetical (stable fallback).
computeStandings()(per-group, finished only) andisGroupFinished()are exported and reused bybracket.js/stats.js(no recompute).
Bracket (bracket.js)
- Tree is language-neutral: slots are
{ teamId }or{ ph: {kind,…} }; placeholder text is produced at render time byslotDisplay(), so language switches never invalidate the tree. - Tree is cached;
invalidateBracket()drops it (simulation overlay + live refresh). resolveBracketTeams(matchOrRef)→{ home, away }of{ team: Team|null, label }for any match (group or knockout); reused by schedule cards, modal, and search/team filters (so knockout matches become searchable/filterable once resolved).getBracketTree()→{ rounds, third, nodesByRef, champion }.- CSS connectors depend on an equal-height invariant: all columns share height with
flex:1slots, so pair children sit at 25%/75% and the next node at 50%; pure-CSS stubs meet exactly. Column gap = 2 × stub (44px desktop / 36px ≤767). Breaking equal height breaks the lines. - Simulation:
decide()applies only real finished results;applySimulation()overlays user picks afterwards and never overrides a real result (sosimulated:false⇒ real). Stale entries (winner no longer resolved) are silently ignored. Eligible nodes (both teams resolved, real result stillscheduled) get dashed blue borders + a SIM chip. - Interactions: full-path highlight computed from ref arithmetic (
floor(i/2)up,2i/2i+1down), no tree lookup. Zoom = CSStransform:scale()on the canvas + a sized#bracket-zoombox, pointer-anchored, clamped 0.4–2; natural size measured lazily (ensureMeasured()— panel may behiddenat render). Pan/pinch via Pointer Events,touch-action:noneon the wrap. Drag–click conflict: capture pointer only after the >5px threshold (gotcha #6). - Share/import:
?prediction=base64(simulation)viagetShareableLink()/loadPredictionFromURL(); stripped from the URL (history.replaceState) whether applied or not; unknown refs rejected wholesale. Challenge card scores sim vs real finished knockout results.
Modal (modal.js)
- Native
<dialog>+showModal()→ focus trap, Esc,::backdropcome free. Backdrop click =event.target === dialog. Focus restored to the opener on close. Card→modal is event delegation on#schedule-root(click + Enter/Space), surviving list re-renders.openMatchModal(matchId)is the public API for every view. - Match stats in modal: optional
statsfield per game inresults.json({ possession, shots, cards }, home/away followinghomeTeam/awayTeam; possession %, total shots, yellow cards). Renders real stats when present, else the—placeholder +modal.statsSoonnote. Adding stats to more games = editresults.jsononly.
Hero — hybrid clock+JSON (app.js)
- The home hero advances by the clock, not only by the JSON.
matchState(match, result, now)(pure, exported, reused by the schedule occurrence filter):overifstatus==='finished'ORnow ≥ kickoff + window;liveifstatus==='live'ORnow ≥ kickoff; elseupcoming. JSON always wins (finished/live force); the clock only advances when JSON lags. Window:GROUP_WINDOW_MS = 2hforGroup*, elseKO_WINDOW_MS = 3h. findFeaturedMatches(now)picks the earliest non-overmatch and returns all sharing that exact kickoff → the hero stacks simultaneous group-final matches (last round = 12 pairs, always 2); 1-match render is DOM-identical to before. One persistent 1sheroTick; signature"id:state"(joined for the set) → fullrenderHero()on change, else justupdateCountdown().renderHerois idempotent and re-arms the timer (if (heroTimer) return).- Live score shown only if
result.homeScore/awayScoreare non-null; no elapsed-time clock (would be inaccurate on a static site). Badge "Bola rolando!" = keyhero.inProgress(renamed fromhero.kickoff);hero.livestill used by schedule/modal. Scope: hero only — Matches/Modal/ Bracket live badges stay JSON-status-driven (small transient inconsistency accepted). When the Final goesover, the hero is empty (post-Cup home state is a TODO).
Live data refresh — poll results.json without F5 (2026-06-16, Option A⁺)
- The data is not live — it's a manual push after each match. So poll is fixed
(
POLL_INTERVAL_MS = 90s), not state-based.startResultsPolling()(called at the end ofinit(), after views register listeners) arms onesetInterval(if (pollTimer) return).pollResults()fetchesdata/results.json?t=${Date.now()}withcache:'no-store'. (As of 2026-06-18 the initialloadData()fetch also uses?t=Date.now(); the old hand-bumped?v=DATA_VERSIONcache-buster was removed — see Cache-busting runbook.) - Signature = full response text (catches score corrections,
statsbackfill, penalties — a finished-count signature would miss them). On change: rewritedata.resultsand rebuilddata.resultByMatchId(the derived map),invalidateBracket(), dispatchdatachange. - 3 reinforcements over plain fixed poll: (1) Page Visibility — interval no-ops when
document.hidden;visibilitychangedoes an immediate fetch on return. (2) Stop at the end —tournamentOver()checksFINAL's JSONstatus==='finished'(not clock-over, which would stop 3h after kickoff before the score lands) →stopResultsPolling(). (3) Content signature (above). bracket-config.jsonpiggybacks the change event: the poll fetches onlyresults.jsoneach tick, but on a detected change it also refetchesbracket-config.jsonthe same cycle (data.bracketConfig) — the one-time 3rd-place fill ships in the same push as a results change, so no per-tick config polling, but the open tab still gets the newthirdPlaceAssignmentwithout F5.- Fan-out: every view has a
datachangelistener (app.js→renderHome,schedule.js→renderList,groups.js→render,bracket.js→render,stats.js→rebuild model). Not handled (accepted, rare changes): open modal doesn't auto-update; re-render during drag/typing.
Performance & responsive/a11y
- No
backdrop-filteron repeated cards —.match-cardoverrides.glassblur (huge paint × 104 cards). Same rule for any future card grid. - Fixed gradient lives on
body::before(position:fixed), notbackground-attachment:fixed(avoids repainting the background on scroll). - Breakpoints: ≤767 (tight; bracket
--node-w:168px/gap 36px — stub offsets stay at gap/2), 768–1100 (two-band header), 1100+ (single-row header; the flip moved 768→1100, see header pattern), 1440+ (.containerwidens to 1360px). - WAI-ARIA tabs: roving tabindex + Arrow/Home/End in
initTabs(), focus follows activation. Dialogs getaria-labelat open; schedule countaria-live="polite"; countdownrole="timer". Entry animations (panel fade, card stagger) all gated byprefers-reduced-motion.
Gotchas
fetch()of JSON fails onfile://— always serve viapython -m http.server(Claude Previewworldcup2026, port 8126). Symptom: blank app + CORS errors.- GitHub Pages / Hostinger serve under a subpath — use relative paths everywhere
(
data/matches.json,assets/...); root-absolute (/data/...) 404s in production. .icsrequires CRLF line endings (calendar.js) — RFC 5545 mandates\r\n; some calendar apps silently reject\n.- Third-place slots are
nulluntil filled (bracket-config.json.thirdPlaceAssignment) —resolveBracketTeams()must return placeholder labels ("Best 3rd #1", "Group A Winner") whenever a slot isnullor its group isn't finished. Symptom if forgotten: crash / "undefined" in R32. - Stale JS modules in the dev browser —
python -m http.serversends no cache headers, so browsers heuristically cache ES modules. Hard-reload viaPromise.all(files.map(f => fetch(f, {cache:'reload'}))) → location.reload(), or DevTools hard reload. setPointerCaptureon pointerdown kills element clicks (bracket.js) — capturing retargets the eventualclick, so delegation never matches → modal/sim clicks die. Capture only after the5px drag threshold, in
pointermove, try/catch. Verify click flows withpreview_click(trusted input), notelement.click().- Claude Preview screenshots can hang (tooling, not app) —
preview_evalkeeps working;preview_stop+preview_startrecovers. Verify state viapreview_evalbefore suspecting the app. - Claude Preview: resize beyond the native window (~791 CSS px) breaks clicks/screenshots —
viewport emulation desyncs the capture surface. At emulated widths > native, navigate via
preview_eval+navigateTo()and verify geometry via eval/inspect; trust screenshots only at widths ≤ native.preview_resize preset: desktopresets it.
Operational Runbooks
Daily data refresh
Follow how-refresh-data.md (project root) before touching any data/*.json. In short: edit
data/results.json (scores/status, two-source rule, penalties only on knockout ids 73–104) →
verify in preview → commit (two-commit convention) → push (user's go) → deploy.
Frozen files (never edit): stadiums/teams/groups/bracket-config.round32/assets/code.
how-update.md stays as the schema reference for the (completed) mock→real migration.
thirdPlaceAssignment (one-time, after the group stage ~Jun 27–28)
When all 72 group matches are finished, fill bracket-config.json.thirdPlaceAssignment
(slot → group LETTER, per FIFA's published allocation — never derive it yourself). Each group
letter appears in at most one slot; unfilled slots stay null:
| Slot | Feeds (FIFA match) | Allowed groups |
|---|---|---|
| 1 | M74 (vs Winner E) | A/B/C/D/F |
| 2 | M77 (vs Winner I) | C/D/F/G/H |
| 3 | M81 (vs Winner D) | B/E/F/I/J |
| 4 | M82 (vs Winner G) | A/E/H/I/J |
| 5 | M79 (vs Winner A) | C/E/F/H/I |
| 6 | M80 (vs Winner L) | E/H/I/J/K |
| 7 | M85 (vs Winner B) | E/F/G/I/J |
| 8 | M87 (vs Winner K) | D/E/I/J/L |
Cache-busting (2026-06-18: DATA_VERSION removed)
app.js loadData() appends ?t=${Date.now()} to every data/*.json fetch — same scheme the
live-refresh poll already used. There is no DATA_VERSION constant to bump anymore (removed
2026-06-18); every load gets a unique URL, so Hostinger can never serve a stale results.json and
the daily refresh has zero cache step. Previously appended Note: JS/CSS are not versioned (no build step) — on Hostinger returning
visitors may serve stale code until their browser re-fetches; new visitors / hard-refresh see it at
once. Accepted.?v=${DATA_VERSION} (a hand-bumped
YYYY-MM-DD-revN constant) — retired because the manual bump was easy to forget and Date.now()
guarantees freshness.
App version (footer)
Single source of truth: assets/js/i18n.js line 9 — const APP_VERSION = 'v1.0.X'. Auto-shown
in both EN and PT footers via t('footer.note'). Bump after a notable ship (new section, major
bugfix, schema change, deploy). Commit e.g. refactor(footer): bump version to vX.Y.Z.
Commit convention (standardized 2026-06-15)
Every /update-worldcup run = two commits (full spec in how-refresh-data.md):
- Data commit (
results.json, +bracket-config.jsonon the 3rd-place day):- 1 game →
data: update DD/MM/YYYY HH:MM HOMExAWAY HxA - N games →
data: update DD/MM/YYYY — N jogos+ one body line per game. - Penalties (knockout only): suffix
(pen HxA).
- 1 game →
- Docs commit:
docs: log daily refresh DD/MM/YYYY(.agents/+ TODO).
Rules: DD/MM/YYYY + HH:MM are the match's UTC kickoff (as in matches.json); codes = 3
uppercase letters; separator lowercase x. .agents/ is excluded from the FTP deploy → keeping it
a separate commit keeps the data commit's diff clean.
Deploy — Hostinger via FTP (GitHub Actions, 2026-06-14)
.github/workflows/deploy.yml: everypushtomaster(orworkflow_dispatch) deploys viaSamKirkland/FTP-Deploy-Action@v4.3.5(protocol: ftps,port: 21,local-dir: ./,server-dir: worldcup2026/).- origin =
https://github.com/LucasKalil-Programador/world-2026-hub.git(branchmaster). Push via Windows credential manager (gh CLI is NOT installed on this machine). - Secrets (repo → Settings → Secrets → Actions):
FTP_SERVER,FTP_USERNAME,FTP_PASSWORD(from Hostinger hPanel). Without them the workflow fails. - Gotcha: the Hostinger FTP account logs in already inside
public_html, soserver-diris relative to it — do not prefixpublic_html/(causespublic_html/public_html/...). Final path:public_html/worldcup2026/. If FTPS is rejected, switchprotocoltoftp. excluderemoves.git*,.github/,.agents/,README.md,how-*.md,*-en.mdspecs — onlyindex.html+assets/+data/reach the site. Newdata//manifest.json/assets/icons/files are deployed. Incremental sync state (.ftp-deploy-sync-state.json) lives only on the server — don't commit it.
Real-data migration (DONE 2026-06-12)
All 6 data/*.json hold real WC2026 data (sources: Wikipedia per-group + knockout articles,
cross-checked vs ESPN/FOX/olympics.com). Stadiums trimmed 30 → 16; cities use FIFA host-city
names ("New York/New Jersey", "San Francisco Bay Area", "Boston") — matches.json and
stadiums.json must match exactly. bracket-config app-order ↔ FIFA mapping: R32-1..16 = FIFA
matches 74, 77, 73, 75, 83, 84, 81, 82, 76, 78, 79, 80, 86, 88, 85, 87 (so the app's sequential
pairing reproduces the official R16/QF/SF progression). Re-verify near Jul 6: match 94 (R16,
Lumen Field) kickoff was single-source (Wikipedia 17:00 PDT vs an ESPN summary implying 14:00 PDT).
Stats Screen (feature/stats-final-screen)
Full post-Cup stats screen built from .agents/stats-screen-plan.md (stages A–J). The pure-UI
build (A–D + F, E skipped) + J round 1 polish was merged to master 2026-06-17 and is
live. master keeps the partial screen + daily refreshes. Live sub-nav chips: Overview · Teams ·
Records · Comparator. Data-layer stages (G/H/I) + a second J polish remain for near/after the Cup.
Plan & first-order requirement
Plan generated 2026-06-14 via a 5-sub-agent workflow; scope = 4 data layers (✅ existing · 🟡🧩
cheap additions · 🔴 player data · 📝 editorial). First-order requirement — graceful degradation:
when a datum is missing, the UI must not break nor reveal to the end user that anything is missing
— no —, no empty cards, no "coming soon". A datum/section renders only when complete enough to be
authoritative; otherwise it is removed from the DOM (not hidden). Sub-nav chips of empty sections
disappear too; loadData() tolerates a missing optional file (empty default, not an exception).
Stage A — degradation engine + scaffolding
- Fault-tolerant
loadData(): the 6 core files still throw on failure (fatal); 6 optional layers (players,player-events,awards,keeper-stats,curiosities,all-time-baselines) load vialoadOptional(name, fallback)→ absent/404 returns the empty default silently, warns only on a present-but-malformed file. Core + optional fetch concurrently. - Section-gating —
SECTIONSregistry instats.js: each section{ id, navKey, available(model), body(model) }renders (and shows its chip) only whenavailableholds; else it is omitted from the DOM entirely and the nav never points at emptiness. - Sticky scrollspy sub-nav: hero +
<nav.stats-subnav>(anchor chips) + one section per available section + footer. Chips are<a href="#stats-{id}">butpreventDefault+scrollIntoView— they NEVER setlocation.hash(the tab router listens onhashchange; a real#stats-teamswould route to an unknown tab → bounce to Home). Scrollspy is position-based (rAF-throttledscrollreadinggetBoundingClientRect) + an explicit "at page bottom → last section" rule (an IntersectionObserver band left a short final section unlit). --header-hCSS var kept live bytrackHeaderHeight()(ResizeObserveron the variable-height sticky header). Sub-nav sticks attop: var(--header-h); sections usescroll-margin-top.- Media fallback (§0.3):
flagImg(team,w,h)emits the flag withdata-monogram="<id>"; a one-time capture-phaseerrorlistener replaces a broken flag with a 3-letter<span.flag-fallback>— never a broken-image icon.
Stage B — verdict hero + goals-by-round
heroHTML()→model.verdict ? verdictHeroHTML() : aggregateHeroHTML(). The verdict hero (champion- 2/3/4 podium, shared count-up tiles) is gated on the REAL final:
computeVerdict()readsgetBracketTree().nodesByRef.get('FINAL')and returnsnullunlessstatus==='finished' && !simulated && winner(a user's simulated champion never leaks). Falls back to the aggregate "in progress" hero until the final is really finished.
- 2/3/4 podium, shared count-up tiles) is gated on the REAL final:
- Goals-by-round chart (Overview): group stage split into 3 matchdays (
computeGroupMatchdays: sort each group's 6 fixtures by kickoff, chunk into pairs —matches.jsonhas no matchday field) plus each knockout round. Hidden until ≥2 rounds have data.
Stage C — final ranking 1–48, favorites, team records
- Canonical ranking 1–48 (
assignRanks→computeRankTiers): primary key is the deepest stage reached from REAL knockout results (champion 0 → … → group 7), then points → GD → GF → id. Real results only (same!simulated && finishedgate as the verdict). During groups everyone is tier 7 → it's the global points table; post-knockout the champion is #1 even with fewer points. #column = canonical rank AND the default sortable header. The#cell always shows the canonical rank regardless of active sort; non-rank sorts fall back toa.rank - b.rank.- Favorite-team row highlight (gold):
row-favwhengetFavorites()includes the team;favchangere-renders the table only (favorites aren't in the model). Highlight-only, no stars. - Team record cards (Teams): longest win streak (≥2, hidden below) + champion's path (gated on
verdict).
stats.jsimportsgetFavorites(storage.js) +openMatchModal(modal.js).
Stage D — Records section + format-48 debuts
recordssection is always available (body: recordsSectionHTML). Sub-nav = Overview · Teams · Records. Match-record cards live here: biggest win (margin) + highest-scoring match (combined goals); high-score card deduped when it's the same match as biggest win. (biggestWinmoved out of Teams into Records for a clean C/D split.)- "Format debuts" band: firsts of the 48-team era (48 teams, 104 matches, 12 groups, "Round of 32"
via
translatePhase, 8 best thirds advance, first 48-team champion — lights up post-final frommodel.verdict). Counts come fromgetData()/model, not hardcoded.
Stage E — SKIPPED (Option B, 2026-06-17)
The in-tab 104-match results archive will not be built. The Matches tab (schedule.js) already
lists all 104 with filters/search/sort/occurrence/"My matches"/modal — an in-tab archive would
duplicate it. The footer keeps a "See all matches →" link (#stats-see-matches →
navigateTo('matches')). The archive entry stays available:()=>false / body:()=>'' — a dormant
slot, don't delete the registry line. If revisited, the lighter "phase-accordion, results-only"
variant (Option C) was the recommended shape.
Stage F — team comparator
comparatorsection,available:(m)=>m.finishedCount > 0. Two<select>s (alphabetical, 48 teams) default to the top-2 ranked; choice survives langchange (module-levelcmpA/cmpB). On change, only the bars panel re-renders. Diverging mirrored bars scaled tomax(a,b,1); higher side's number gold. MetricsCMP_METRICSare all non-negative (P, W, GF, GA, CS, Pts — GD excluded, it can be negative).cmp-growscaleX animation, off underprefers-reduced-motion.- Players side deferred to Stage H (graceful degradation, not the plan's literal Teams/Players toggle — a disabled toggle would be a visible dead control). Teams comparator only for now.
Stage J round 1 — release polish + merge to master (2026-06-17)
Polish over A–F: i18n audit (no hardcoded strings), a11y (sections aria-labelled + tabindex=-1,
table caption + sort buttons + aria-sort, sub-nav is a <nav>), reduced-motion gating, cross-tab
regression — no code fixes were needed. README got a Stats bullet. Deferred to the actual
deploy: the Lighthouse run (the once-deferred final DATA_VERSION bump is moot — DATA_VERSION
was removed 2026-06-18). Merge sequence: merge latest
master→branch (resolve conflicts on the branch, never on master), re-verify, then master ← branch --no-ff. Pushing to origin (which triggers the deploy) is the user's explicit final go.
Sub-nav polish — inner track + edge fades + spy-suppress (2026-06-17, on master)
- Edge fades mirror the header tabs: chips live in an inner
.stats-subnav-track(the scroll container); the fademask-imageis on the track, so the pill's background/rounded ends stay crisp..stats-subnavisoverflow:hidden;updateSubnavFades(nav)toggles.fade-left/-rightfrom the track's scroll metrics. All sub-nav scroll JS targets the track, not the nav. - Scrollspy "jump" on chip click fixed: a chip click sets
suppressSpyUntil = Date.now()+700(0 under reduced-motion) andupdateSpy()early-returns while suppressed, so the clicked chip owns the active state until the smooth scroll settles.
Partial stats tab built during the Cup (foundation, 2026-06-14)
The 6th stats tab was first shipped incrementally as the evolving foundation of the post-Cup plan
(same tab/module; post-Cup sections "light up" later). Files: assets/js/stats.js +
assets/css/stats.css. Philosophy (decided via /grill-me): current-to-date aggregates, only
status==='finished' (consistent with computeStandings); "X of 104" is framing, not a gap.
aggregateTeams() is its own tournament-wide aggregation (group + knockout); optional per-game
stats enters with per-game gating. Memoized model (let model), re-render of labels on langchange.
Patterns & How-tos
How to add a UI label
- Add the key to both
enandptdicts inassets/js/i18n.js. - Use
t("key")at the render site — never hardcode UI text in HTML/JS. (Data values — team/stadium names, cities — come from JSON and are not translated.)
How to add a new localStorage preference
- Extend the
wc2026_prefsshape (document the new field here). - Read/write only via
storage.jsget/set.
Tooltips + mobile legend (2026-06-14)
- Table-header abbreviations (Stats team table + the 12 Groups tables) get a custom glass tooltip
(not native
title).initTooltips()inapp.js: a singleposition:fixed.app-tooltipvia event delegation ondocument(so it survives re-renders and is never clipped byoverflow-x:autocontainers); clamps to viewport, flips below if it doesn't fit above. - Give a header a tooltip: add
has-tip+data-tip="<text>"+aria-label="<abbr> — <text>"; texts ini18n.jsnamespacetip.*(EN/PT), reused by both tables. - Mobile legend:
<p class="stats-legend">(display:nonedesktop,flex≤600px) — covers touch where hover doesn't fire.legendHTML()instats.js/groups.js. CSS lives instats.css(loaded globally, so it also applies to Groups).
How to add a stadium SVG
Follow the trimmed structure of the 16 existing ones (chrome stripped 2026-06-14 — stadiums.js
renders name/city/capacity as HTML, so the SVG must not duplicate them): <svg viewBox="...">
(no width/height) → <defs><style> with only the
struct/thin/hair/concrete/stands/canopy/void/pitch/pline/acc/accs/green/ribs/louver classes +
frit pattern → a single <g> illustration cropped tightly (~10px padding). Aim for a viewBox aspect
ratio near 4:3 (~1.2–1.3) to match .stadium-img { aspect-ratio: 4/3; object-fit: cover } in
style.css (4:3, not 16:9 — the SVGs' natural ratios are ~1.07–1.32, and 16:9 cropped ~28% of
height, slicing the illustrations). The white tower shapes on some cards (class="void") are the
press-box/scoreboard — intentional, don't remove.
PWA — installable (Tier 1, 2026-06-16)
Scope shipped = Tier 1 (manifest + icons + meta tags) — meets every install criterion; no JS
changed. Files: manifest.json (root), favicon.ico (root), assets/icons/ (icon.svg master +
192/512 PNGs any + maskable + apple-touch 180 + favicon-16/32). index.html <head> got the PWA
block (manifest link, <meta theme-color #081421>, favicons, apple-mobile-web-app-* meta). Manifest:
name "World Cup 2026 Hub" / short_name "WC 2026 Hub", display:standalone, colors #081421
(--bg-primary), start_url:"." + scope:"./" relative (gotcha #2). Named manifest.json (not
.webmanifest) for safe MIME on Hostinger. To change the icon: edit the SVG(s) and re-run the
ImageMagick rasterize commands (magick -background none icon.svg -resize NxN ...; favicon.ico =
16+32). Tier 2 (service worker / offline) is deliberately deferred — see issues.md; if built it
must exclude data/*.json from the cache or it breaks the live-refresh poll.
Responsive header — 2 bands + scrollable tabs (2026-06-15)
Single-row flip (.tabs { flex:0 1 auto; margin-inline:auto }) moved from @media (min-width:768px)
→ @media (min-width:1100px) (single row needs ~950px of content; below that the controls
overflowed). Below 1100px: two stable bands (band 1 = logo + controls, band 2 = scrollable tabs).
Edge fades via mask-image toggled by updateTabFades(); active tab kept visible via
scrollActiveTabIntoView() (uses scrollLeft, not scrollIntoView, to avoid scrolling the
page). The time button collapses to a 🕐 icon at ≤420px (a11y intact via data-i18n-aria). This
supersedes the old "768–1439 single-row header" note.
How to record a decision (after finishing a unit of work)
- Tick the item in
.agents/TODO.md. - Append the new decision/gotcha/pattern to the right section here (don't rewrite existing entries; don't add dated refresh logs — those go in git + the Current State rolling window).
- Rewrite
project-map.mdif structure/functions changed.
Current State
Updated 2026-06-18. Data: results through match 26/104 (26 of 72 group-stage matches
finished; group stage in progress). thirdPlaceAssignment still all null (fill ~Jun 27).
Cache-busting is now automatic (?t=Date.now(); DATA_VERSION removed 2026-06-18). APP_VERSION = v1.0.1. Build: all 12 steps + real-data migration
done; Stats stages A–D + F + J(r1) merged to master and live (E skipped).
Recent refreshes (rolling — keep the last 3, prune older; full detail in git)
- 2026-06-18 (rev1–rev3) — matches 24–26: UZB 1–3 COL, CZE 1–1 RSA, SUI 4–1 BIH (all 4 goals came 74'+; FIFA/FOX still cached at 0–0 — score confirmed via ESPN report + Wikipedia minutes; card count 1/3 from FOX named bookings, ESPN box lagged at 0/1).
- 2026-06-17 (rev1–rev3) — matches 18–23: IRQ 1–4 NOR, ARG 3–0 ALG, AUT 3–1 JOR, POR 1–1 COD, ENG 4–2 CRO, GHA 1–0 PAN.
Pending / next
thirdPlaceAssignmentfill once the group stage ends (~Jun 27) — slot→group table above.- Lighthouse > 90 run (needs a deployed URL).
- Post-Cup home state — when the Final goes
overthe hero is empty; build a champion/epilogue state (likely converges with the Stats screen). - Stats Stage G (Layer-2 cheap data —
cards→{y,r} migration is breaking formodal.js+stats.js; schedule LATE, conflicts with dailyresults.jsonedits), Stage H (players + the deferred comparator Teams/Players toggle), Stage I (editorial), Stage J round 2 polish. - PWA Tier 2 (service worker + offline) — deferred; must exclude
data/*.json(seeissues.md).
Success metrics
Lighthouse > 90; first render < 2s; total JS < 300KB (74 KB measured at build). Spec §18 acceptance criteria all checked (README checklist).
Communication
User communicates in an English/Portuguese mix; docs in English where practical (retained PT passages kept as written). Ask before each build step — never chain into the next without explicit go-ahead.