world-2026-hub/.agents/project-memory.md

50 KiB
Raw Permalink Blame History

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

  1. Spec compliance — complement spec wins on conflict.
  2. Visual quality — FIFA/UCL/Apple-inspired, glassmorphism, smooth animations; portfolio-grade.
  3. Interactive bracket — hover path highlight, zoom, drag, simulation; the centerpiece feature.
  4. Easy maintenance — real data drop-in via JSON; bracket-config.json is the only structural file edited after the group stage.
  5. 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 in wc2026_prefs.lang. Static HTML uses data-i18n / data-i18n-aria re-applied by applyI18n(); dynamic renders call t() and listen for langchange. Phases via translatePhase() (PT: R32 = "16 avos de final"). Default language: navigator.language startsWith pt → PT, else EN; only persisted on toggle.
  • storage.js is the only access path to localStorage (wc2026_* keys, auto JSON). Holds prefs (lang, lastTab, timeMode), wc2026_favorites, wc2026_simulation.
  • Per-view modules (schedule/groups/bracket/stadiums/stats) + app.js entry. Circular imports app.js ⇄ view modules are intentional and safe in native ESM — all cross-calls happen at render runtime, after every module has evaluated. stats.js imports getBracketTree, getFavorites, openMatchModal this way too.
  • Custom events on document drive 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 by formatMatchTime(match, stadium, mode) via Intl.DateTimeFormat (mode = "local" browser tz, or "stadium" timezone). .ics export depends on this.
  • Match ids: group matches 172 = chronological by UTC kickoff (≠ 6-per-group blocks); 73104 = 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-1R32-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) and isGroupFinished() are exported and reused by bracket.js / stats.js (no recompute).
  • Best third-placed teams table (2026-06-28). computeThirdPlaceRanking() (exported) takes each group's 3rd row (standings[letter][2]), ranks the 12 across groups by the same key (Pts → GD → GF → id) and flags the top 8 qualified. Rendered as a full-width section below the 12 group cards in the Grupos tab, gated on allGroupsFinished() (meaningless mid-stage → omitted from the DOM). Reuses .standings-table styling, header tooltips and the favorite-row highlight; gold .row-third
    • ✓ for the 8 that advance, muted .row-out + — for 912, a dashed .cut line between 8 and 9. It only ranks the thirds for display — the slot→group allocation still lives in bracket-config.json (FIFA combination table), never derived from this ranking.

Bracket (bracket.js)

  • Tree is language-neutral: slots are { teamId } or { ph: {kind,…} }; placeholder text is produced at render time by slotDisplay(), 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:1 slots, 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. RETIRED 2026-07-03 by the wallchart redesign — see "Bracket redesign (2026-07-03)" below; connectors are now SVG paths generated from the same JS geometry as the cards, so no CSS invariant exists anymore.
  • Simulation: decide() applies only real finished results; applySimulation() overlays user picks afterwards and never overrides a real result (so simulated:false ⇒ real). Stale entries (winner no longer resolved) are silently ignored. Eligible nodes (both teams resolved, real result still scheduled) get dashed blue borders + a SIM chip.
  • Interactions: full-path highlight computed from ref arithmetic (floor(i/2) up, 2i/2i+1 down), no tree lookup. Zoom = CSS transform:scale() on the canvas + a sized #bracket-zoom box, pointer-anchored, clamped 0.42; natural size measured lazily (ensureMeasured() — panel may be hidden at render). Pan/pinch via Pointer Events, touch-action:none on the wrap. Dragclick conflict: capture pointer only after the >5px threshold (gotcha #6).
  • Share/import: ?prediction=base64(simulation) via getShareableLink() / loadPredictionFromURL(); stripped from the URL (history.replaceState) whether applied or not; unknown refs rejected wholesale. Challenge card scores sim vs real finished knockout results.

Bracket redesign — center-out wallchart (2026-07-03, Step 1 of 4)

Full redesign spec settled via /grill-me: two switchable chart layouts (center-out wallchart = default, radial = Step 3) + a mobile round pager default ≤767px (Step 2), stadium-night art direction, lean escalating cards, fit-whole-chart initial framing, gold-real/blue-sim champion celebration (Step 4), built directly on master (deploy still gated on user-approved push). Step 1 (shipped): wallchart replaces the old left-to-right columns.

  • computeWallchartLayout() in bracket.js is the single geometry source: absolute px positions for every card/title/champion box (GEO constants; R32 18 left half, 916 right, later nodes at feeder midpoints) and the SVG bezier connectors + champion stem derived from the same numbers — cards and lines cannot drift apart. Tree/sim/share/challenge logic untouched; DOM contract preserved (data-ref, data-match-id, delegation, keyboard activation).
  • Fit-to-chart zoom: geometry is computed (never DOM-measured); a ResizeObserver on #bracket-wrap computes view.fit when the panel becomes visible (hidden panel = clientWidth 0) and re-fits on resize unless the user zoomed. Zoom label "100%" = fit, reset returns to fit, clamp = [fit, 2]. The wrap carries an inline aspect-ratio: W/H from the engine so the fit view is never letterboxed (capped max-height: min(80vh, 840px)).
  • Cards: lean two-row + microline (.bk-meta: kickoff via kickoffShort() honoring the Local/Stadium toggle — bracket now listens to timemodechange; LIVE pulse; FT = t('bracket.ft'), new i18n key EN/PT). Tier classes bk-r32…bk-final escalate size/heat; the Final hero card also shows venue (.bk-venue). Champion box: has-champion gold; is-sim = blue + SIM chip (a simulated champion must never read as real — same rule as the stats verdict; previously the old champion box showed sim champions in gold).
  • Path highlight now also lights SVG connectors: a path turns gold when both its data-from/data-to endpoints are in pathRefs(); the champion stem carries FINAL→FINAL.
  • Motion: cards rise + connectors draw (pathLength="1" + dash animation), staggered by round, all inside @media (prefers-reduced-motion: no-preference); replays on each tab open (display:none restarts CSS animations) — intentional "chart assembles" effect.
  • Interim states until later steps: mobile ≤767 shows the pinch-zoom wallchart (pager = Step 2); no view toggle yet (registry lands with the second view). Superseded same day — Steps 2+3 below.

Bracket redesign — Steps 2+3: view toggle, rounds pager, radial "orbit" (2026-07-03)

  • View toggle: segmented Fases | Chaveamento | Radial in the toolbar; explicit choice persists in wc2026_prefs.bracketView; with no stored pref the default follows the breakpoint (≤767px → rounds pager, else wallchart; a matchMedia('change') listener re-renders while unset). Zoom controls render only for chart views. render() dispatches: pager / chartHTMLwallchartInnerHTML | radialInnerHTML. Switching chart layouts resets to a fresh fit (view.layoutId check in initInteractions).
  • Rounds pager — button navigation ONLY (user decision): the first build used a scroll-snap swipe track; the user rejected horizontal scrolling, so pages are plain sections toggled with hidden by the chips (initPager ~15 lines, no ResizeObserver/height-clamp needed). Grid is max 2 columns (≥700px) — 34 columns made cards too narrow (user feedback). Opens on the first round with an unfinished match (firstOpenPage); pagerIndex survives re-renders. Cards (.bk-pcard) carry venue·city + status row and reuse teamRowHTML + the same data-ref/data-match-id delegation (modal + sim editor work unchanged).
  • Radial = "orbit" view, redesigned to a user-supplied reference image (circular predictions bracket with the trophy at center). NOT rectangular cards: circular flag tokens on concentric rings — outer ring = 32 entrants, each ring inward = a round's winner slots (a match's winner slot doubles as the next match's participant), trophy centerpiece (= the FINAL's winner slot, champion flag + name when decided, sim-blue when simulated), third-place = small labeled pair below the circle. TGEO radii chosen so adjacent/consecutive rings never collide (validated with an automated pairwise-overlap eval in preview — keep doing that after any radius change). Semantics: elbow route lines (radial segment + bend dot) turn gold = real advancement, dashed blue = simulated pick; eliminated entrants grey out (tk-out), TBD slots are striped discs (tk-tbd); names/scores live in the shared app tooltip (has-tip/data-tip, delegation in app.js initTooltips) and the modal. Sim affordance lives on the winner slot (opts.slot, not opts.winner — a TBD slot is exactly the simulatable one; that inversion was a shipped-then- fixed bug). Path highlight generalized: showPath lights ANY [data-ref] element + .bk-le endpoints; hover/focus delegation uses closest('[data-ref]').
  • Toolbar gotcha: .bracket-tools-left holds 6 controls — needs flex-wrap: wrap or it forces ~500px of page overflow at 375px (found via body scrollWidth sweep on mobile).

Modal (modal.js)

  • Native <dialog> + showModal() → focus trap, Esc, ::backdrop come 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 stats field per game in results.json ({ possession, shots, cards }, home/away following homeTeam/awayTeam; possession %, total shots, yellow cards). Renders real stats when present, else the placeholder + modal.statsSoon note. Adding stats to more games = edit results.json only.

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): over if status==='finished' OR now ≥ kickoff + window; live if status==='live' OR now ≥ kickoff; else upcoming. JSON always wins (finished/live force); the clock only advances when JSON lags. Window: GROUP_WINDOW_MS = 2h for Group*, else KO_WINDOW_MS = 3h.
  • findFeaturedMatches(now) picks the earliest non-over match 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 1s heroTick; signature "id:state" (joined for the set) → full renderHero() on change, else just updateCountdown(). renderHero is idempotent and re-arms the timer (if (heroTimer) return).
  • Hero resolves teams via resolveBracketTeams(match) (not raw match.homeTeam), so knockout featured matches show real teams/flags once resolved and a placeholder label otherwise — same path as schedule cards/modal. heroTeamHTML(slot) takes a {team,label} slot. Bug fixed 2026-06-28: the hero previously read match.homeTeam/awayTeam directly; harmless during the group stage (those fields exist) but the moment the next match became an R32 game (ids 73+, which carry only bracketRef) the home hero showed "A definir vs A definir". Watch for this class of bug anywhere that reads match.homeTeam raw instead of resolving.
  • Live score shown only if result.homeScore/awayScore are non-null; no elapsed-time clock (would be inaccurate on a static site). Badge "Bola rolando!" = key hero.inProgress (renamed from hero.kickoff); hero.live still used by schedule/modal. Scope: hero only — Matches/Modal/ Bracket live badges stay JSON-status-driven (small transient inconsistency accepted). When the Final goes over, 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 of init(), after views register listeners) arms one setInterval (if (pollTimer) return). pollResults() fetches data/results.json?t=${Date.now()} with cache:'no-store'. (As of 2026-06-18 the initial loadData() fetch also uses ?t=Date.now(); the old hand-bumped ?v=DATA_VERSION cache-buster was removed — see Cache-busting runbook.)
  • Signature = full response text (catches score corrections, stats backfill, penalties — a finished-count signature would miss them). On change: rewrite data.results and rebuild data.resultByMatchId (the derived map), invalidateBracket(), dispatch datachange.
  • 3 reinforcements over plain fixed poll: (1) Page Visibility — interval no-ops when document.hidden; visibilitychange does an immediate fetch on return. (2) Stop at the endtournamentOver() checks FINAL's JSON status==='finished' (not clock-over, which would stop 3h after kickoff before the score lands) → stopResultsPolling(). (3) Content signature (above).
  • bracket-config.json piggybacks the change event: the poll fetches only results.json each tick, but on a detected change it also refetches bracket-config.json the 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 new thirdPlaceAssignment without F5.
  • Fan-out: every view has a datachange listener (app.jsrenderHome, schedule.jsrenderList, groups.jsrender, bracket.jsrender, 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-filter on repeated cards.match-card overrides .glass blur (huge paint × 104 cards). Same rule for any future card grid.
  • Fixed gradient lives on body::before (position:fixed), not background-attachment:fixed (avoids repainting the background on scroll).
  • Breakpoints: ≤767 (tight; bracket --node-w:168px/gap 36px — stub offsets stay at gap/2), 7681100 (two-band header), 1100+ (single-row header; the flip moved 768→1100, see header pattern), 1440+ (.container widens to 1360px).
  • WAI-ARIA tabs: roving tabindex + Arrow/Home/End in initTabs(), focus follows activation. Dialogs get aria-label at open; schedule count aria-live="polite"; countdown role="timer". Entry animations (panel fade, card stagger) all gated by prefers-reduced-motion.

Gotchas

  1. fetch() of JSON fails on file:// — always serve via python -m http.server (Claude Preview worldcup2026, port 8126). Symptom: blank app + CORS errors.
  2. GitHub Pages / Hostinger serve under a subpath — use relative paths everywhere (data/matches.json, assets/...); root-absolute (/data/...) 404s in production.
  3. .ics requires CRLF line endings (calendar.js) — RFC 5545 mandates \r\n; some calendar apps silently reject \n.
  4. Third-place slots are null until filled (bracket-config.json.thirdPlaceAssignment) — resolveBracketTeams() must return placeholder labels ("Best 3rd #1", "Group A Winner") whenever a slot is null or its group isn't finished. Symptom if forgotten: crash / "undefined" in R32.
  5. Stale JS modules in the dev browserpython -m http.server sends no cache headers, so browsers heuristically cache ES modules. Hard-reload via Promise.all(files.map(f => fetch(f, {cache:'reload'}))) → location.reload(), or DevTools hard reload.
  6. setPointerCapture on pointerdown kills element clicks (bracket.js) — capturing retargets the eventual click, so delegation never matches → modal/sim clicks die. Capture only after the

    5px drag threshold, in pointermove, try/catch. Verify click flows with preview_click (trusted input), not element.click().

  7. Claude Preview screenshots can hang (tooling, not app) — preview_eval keeps working; preview_stop + preview_start recovers. Verify state via preview_eval before suspecting the app.
  8. 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: desktop resets it.
  9. aspect-ratio + min-height can transfer size INTO width (bracket wallchart, 2026-07-03) — on a box with width: auto, a violated min-height transfers back through the ratio and widens the element past its container (on mobile the 220px min became 585px of page overflow). Fix: give the box a definite width (width: 100%); then the ratio only drives height and min/max-height clamp it without transfer.

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 73104) → 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 2728)

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 ?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. 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.

Single source of truth: assets/js/i18n.js line 9const 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):

  1. Data commit (results.json, + bracket-config.json on 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).
  2. 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: every push to master (or workflow_dispatch) deploys via SamKirkland/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 (branch master). 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, so server-dir is relative to it — do not prefix public_html/ (causes public_html/public_html/...). Final path: public_html/worldcup2026/. If FTPS is rejected, switch protocol to ftp.
  • exclude removes .git*, .github/, .agents/, docs/, README.md, DEVELOPMENT.md, how-*.md, *-en.md specs — only index.html + assets/ + data/ reach the site. New data/ / 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 AJ). The pure-UI build (AD + 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 via loadOptional(name, fallback) → absent/404 returns the empty default silently, warns only on a present-but-malformed file. Core + optional fetch concurrently.
  • Section-gating — SECTIONS registry in stats.js: each section { id, navKey, available(model), body(model) } renders (and shows its chip) only when available holds; 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}"> but preventDefault + scrollIntoView — they NEVER set location.hash (the tab router listens on hashchange; a real #stats-teams would route to an unknown tab → bounce to Home). Scrollspy is position-based (rAF-throttled scroll reading getBoundingClientRect) + an explicit "at page bottom → last section" rule (an IntersectionObserver band left a short final section unlit).
  • --header-h CSS var kept live by trackHeaderHeight() (ResizeObserver on the variable-height sticky header). Sub-nav sticks at top: var(--header-h); sections use scroll-margin-top.
  • Media fallback (§0.3): flagImg(team,w,h) emits the flag with data-monogram="<id>"; a one-time capture-phase error listener 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() reads getBracketTree().nodesByRef.get('FINAL') and returns null unless status==='finished' && !simulated && winner (a user's simulated champion never leaks). Falls back to the aggregate "in progress" hero until the final is really finished.
  • Goals-by-round chart (Overview): group stage split into 3 matchdays (computeGroupMatchdays: sort each group's 6 fixtures by kickoff, chunk into pairs — matches.json has no matchday field) plus each knockout round. Hidden until ≥2 rounds have data.

Stage C — final ranking 148, favorites, team records

  • Canonical ranking 148 (assignRankscomputeRankTiers): 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 && finished gate 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 to a.rank - b.rank.
  • Favorite-team row highlight (gold): row-fav when getFavorites() includes the team; favchange re-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.js imports getFavorites (storage.js) + openMatchModal (modal.js).

Stage D — Records section + format-48 debuts

  • records section 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. (biggestWin moved 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 from model.verdict). Counts come from getData()/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-matchesnavigateTo('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

  • comparator section, available:(m)=>m.finishedCount > 0. Two <select>s (alphabetical, 48 teams) default to the top-2 ranked; choice survives langchange (module-level cmpA/cmpB). On change, only the bars panel re-renders. Diverging mirrored bars scaled to max(a,b,1); higher side's number gold. Metrics CMP_METRICS are all non-negative (P, W, GF, GA, CS, Pts — GD excluded, it can be negative). cmp-grow scaleX animation, off under prefers-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 AF: 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 fade mask-image is on the track, so the pill's background/rounded ends stay crisp. .stats-subnav is overflow:hidden; updateSubnavFades(nav) toggles .fade-left/-right from 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) and updateSpy() early-returns while suppressed, so the clicked chip owns the active state until the smooth scroll settles.

The Team-statistics "leader" cards (Best attack / Best defense / Most clean sheets) became a config-driven set of 6 and each now rotates through ALL teams tied on its headline metric (was: only the single top team). New cards: Most wins, Most goals conceded, Best goal difference (GD value shows a + sign when positive).

  • Tie grouping is by the headline metric ALONE (decided via /grill-me) — gf / ga / cleanSheets / won / ga / gdnot the secondary tiebreakers, so e.g. all teams level on goals-for share one card. Within the group the existing cmp (with tiebreakers) sets order, so the first team shown is unchanged from before. Driven by the LEADER_CARDS array in stats.js; computeLeaders now returns [{ id, labelKey, metric, group: Row[] }] (was an object of single rows).
  • Carousel UX: auto-advance every ROTATE_MS = 3500; pauses on hover/focus, disabled under prefers-reduced-motion (arrows still work). ◀▶ arrows are circular (wrap-around); a manual click effectively restarts the cadence (it resumes fresh on pointer/focus-leave). Indicator = dots (one per tied team, active = gold) up to DOTS_MAX = 8; above 8 the dots become an "i / n" counter (keeps the card compact — e.g. early-Cup Best defense routinely has 8 teams at GA 0). A 1-team group renders the plain static card, identical to before (no arrows/dots/timer).
  • Timer lifecycle (cf. gotcha #6): setupLeaderCarousels(root) runs at the end of render(); intervals are tracked in module-level leaderTimers and cleared at the top of render() (clearLeaderTimers()) so a langchange/datachange re-render never leaves a timer firing on detached DOM. favchange does not touch these cards, so their carousels survive it untouched. Only the flag+name swap on rotate — the big value is shared by the whole tied group, so it never changes.
  • i18n keys added (EN+PT): stats.mostWins, stats.mostConceded, stats.bestGoalDiff, stats.leaderPrev, stats.leaderNext. CSS: .leader-stage/.leader-nav/.leader-dots/.leader-dot/ .leader-counter in stats.css.

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

  1. Add the key to both en and pt dicts in assets/js/i18n.js.
  2. 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

  1. Extend the wc2026_prefs shape (document the new field here).
  2. Read/write only via storage.js get/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() in app.js: a single position:fixed .app-tooltip via event delegation on document (so it survives re-renders and is never clipped by overflow-x:auto containers); 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 in i18n.js namespace tip.* (EN/PT), reused by both tables.
  • Mobile legend: <p class="stats-legend"> (display:none desktop, flex ≤600px) — covers touch where hover doesn't fire. legendHTML() in stats.js / groups.js. CSS lives in stats.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.21.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.071.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 "7681439 single-row header" note.

Docs — README showcase + DEVELOPMENT.md split (2026-07-04)

The root README was reframed (via /grill-me) from a dev/maintenance guide into a non-technical showcase (English): tagline, shields.io badges, prominent live-demo link (https://lucaskalil.com/worldcup2026 — the public URL; not previously recorded anywhere), per-page screenshot gallery (Home/Matches/Groups/Knockout/Stadiums/Stats), and a plain-language "Under the hood" section. All the old technical content (run locally, project structure, JSON maintenance, local storage, deploy, acceptance criteria, roadmap) moved to a new DEVELOPMENT.md; the README's stale "mock data / GitHub Pages" framing was corrected to real-data + the real Hostinger deploy. Screenshots live in docs/screenshots/*.png, captured with headless Edge (msedge --headless=new --window-size=1366,H --virtual-time-budget=6000 --lang=en-US --screenshot, --lang=en-US forces the EN UI; served from Claude Preview worldcup2026 on :8126) — repeat that to refresh them. Deploy exclude updated to drop docs/ + DEVELOPMENT.md (docs never ship to the live site — see Deploy runbook). No app-code/version change (APP_VERSION untouched; docs are excluded from deploy).

How to record a decision (after finishing a unit of work)

  1. Tick the item in .agents/TODO.md.
  2. 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).
  3. Rewrite project-map.md if structure/functions changed.

Current State

Updated 2026-07-03. Bracket redesign Steps 13 shipped on master (not yet pushed): the Knockout tab has 3 switchable views — center-out wallchart (desktop default), radial "orbit" (flag tokens per the user's reference image), rounds pager (mobile default, button-only navigation, ≤2 columns). See Architecture → "Bracket redesign" (both entries). Pending: Step 4 champion celebration + polish pass + version bump to v1.1.0. Data: R32 underway — group stage COMPLETE (172) + R32 matches 73 (RSA 01 CAN), 74 (GER 11 PAR, PAR 43 pens), 75 (NED 11 MAR, MAR 32 pens), 76 (BRA 21 JPN), 77 (FRA 30 SWE), 78 (CIV 12 NOR), 79 (MEX 20 ECU), 80 (ENG 21 COD), 81 (USA 20 BIH) and 82 (BEL 32 SEN, AET) finished (82/104 total); remaining R32 ids 8388 are next. thirdPlaceAssignment FILLED (8 best thirds → R32 — see the rolling refresh list below). Cache-busting is now automatic (?t=Date.now(); DATA_VERSION removed 2026-06-18). APP_VERSION = v1.0.3 (bumped 2026-06-28: hero knockout-resolution fix + best-third ranking table in the Grupos tab). Build: all 12 steps + real-data migration done; Stats stages AD + F + J(r1) merged to master and live (E skipped). Stats Team-statistics leader cards now rotate through tied teams + 3 new metric cards (Most wins / Most goals conceded / Best goal difference) — see Stats Screen → "Leader cards — tied-team carousel".

Recent refreshes (rolling — keep the last 3, prune older; full detail in git)

  • 2026-07-02R32 ids 81 & 82. Match 81 (R32-7, USA D1 × BIH 3rd-B): USA 20 BIH — Balogun 45' (his 3rd of the tournament) then sent off 64' for a reckless tackle on Muharemović, Tillman sealed it with an 82' direct free-kick; USA's first WC knockout win since 2002, playing the final ~30min with 10 men — 2-source+ confirmed ESPN(gid 760494)/CBS/NBC Bay Area/NPR/CNN/FOX/US Soccer/Opta Analyst. Stats: poss 48/52 (ESPN match box), shots 8/10 (Opta Analyst total-shots — cross-checked against ESPN's own SOG-conversion %: 2/8=25% and 3/10=30%, both match exactly). Cards 1/1 (Balogun red 64' only; BIH's Radeljić yellow 80' — excluded a 2nd BIH yellow shown to head coach Barbarez, a bench/technical-area card, not a player card). Regulation, no penalties. Winner propagated: USA → R16-4 vs BEL (verified in bracket, matches real-world reporting). Match 82 (R32-8, BEL G1 × SEN 3rd-I): BEL 32 SEN (AET) — Diarra 25', Sarr 51' put Senegal 2-0 up; sub Lukaku 86' and Tielemans 89' forced extra time, Tielemans converted a 125' penalty (VAR) for the latest goal in WC history — 2-source+ confirmed ESPN(gid 760493)/Outlook India/Opta Analyst/CBS/ FOX/Sofascore. Stats: poss 52/48 (FotMob — ESPN's page didn't expose a possession stat, same gap as the 77 FRA-SWE refresh), shots 18/5 (ESPN match box — initial reads were inconsistent/swapped, took 2 more fetches with explicit home/away attribution to settle; FotMob's conflicting 19/19 discarded as the outlier). Cards 1/1 (Mechele BEL yellow 64', Camara SEN yellow 67'; excluded Belgium coach Rudi Garcia's dissent yellow and an uncorroborated FotMob claim of a 2nd Camara yellow at 73' — no source reports Senegal playing a man down). Decided in extra time by a live-play goal, not a shootout — no penalties field. Winner propagated: BEL → R16-4 vs USA (verified in bracket). Next R32: ids 8388.
  • 2026-07-01 (b)R32 id 80 (R32-12, ENG L1 × COD 3rd-K): ENG 21 COD. Cipenga 7' gave Congo an early lead (their first-ever WC knockout appearance); Kane leveled 75' (header) and won it 86' (strike), both assists from Gordon — 2-source+ confirmed ESPN(gid 760495)/Olympics.com/ Fox Sports/englandfootball.com/CBS/NBC/Yahoo. Stats: poss 60/40, shots 9/5 (ESPN match box — a Sofascore-derived search snippet suggested 16/7 but that looked like a different attempt-counting method, so ESPN's box was used per the authoritative-source rule), cards 1/1 (Bellingham 19' ENG, Sadiki ~27' COD, both yellow, no reds — confirmed via Sofascore's card list). Regulation, no penalties. Winner propagated: ENG → R16-2 vs MEX (verified in bracket). Next R32: ids 8188.
  • 2026-07-01R32 ids 77 & 79. Match 77 (R32-2, FRA I1 × SWE F3): FRA 30 SWE — Mbappé 45', 74' (his 6th of the tournament, level with Messi's career WC tally), Barcola 53' — 2-source confirmed ESPN(gid 760492)/FIFA match centre/Al Jazeera/FOX/RTE. Stats: poss 61/39 (FotMob; ESPN's page didn't expose a possession stat), shots 11/6 (ESPN match box — used over FotMob's much higher 25/8 figure, which looked like a different attempt-counting method), cards 0/0 — checked ESPN boxscore + playbyplay, FOX, Al Jazeera live blog, RTE, Yahoo recap; none reported any booking, so treated as a clean game rather than an unconfirmed gap. Regulation, no penalties. Winner propagated: FRA → R16-1 vs PAR (verified in bracket). Match 79 (R32-11, MEX A1 × ECU E3): MEX 20 ECU — Quiñones 22', Jiménez 31'; Ecuador's Hincapié sent off 90+5' (covering his mouth during a dispute with Giménez, a new directive this tournament) — 2-source confirmed ESPN(gid 760491)/FIFA match centre/Al Jazeera/Yahoo/CBS. Stats: poss 43/57, shots 9/7, cards 0/3 (2 yellow + Hincapié's red) — all from the ESPN match box, red card cross-confirmed by the Yahoo live blog. Regulation, no penalties. Mexico (co-host) reached R16 for the first time in the format; winner propagated to R16-2 (verified in bracket). Next R32: ids 8088.

Pending / next

  • Knockout R32 (ids 7388) — in progress. Done: 73 (RSA 01 CAN), 74 (GER 11 PAR, PAR 43 pens), 75 (NED 11 MAR, MAR 32 pens), 76 (BRA 21 JPN), 77 (FRA 30 SWE), 78 (CIV 12 NOR), 79 (MEX 20 ECU), 80 (ENG 21 COD), 81 (USA 20 BIH), 82 (BEL 32 SEN AET). Next: ids 8388. penalties apply on ids 73104 (KO only — append "penalties": {home,away} and keep homeScore/awayScore as the 90+30 score; id 82 was decided in extra time by a live goal, not a shootout, so it carries no penalties field despite going past 90+30). R16 ids 8996 from 2026-07-04. Note: the 75 (NED×MAR) card count is single-source (Sofascore, only Diop 47') — re-confirm if a clean box surfaces.
  • thirdPlaceAssignment — DONE (2026-06-28). All 8 slots filled from FIFA's official combination table; bracket verified. No longer pending.
  • Lighthouse > 90 run (needs a deployed URL).
  • Post-Cup home state — when the Final goes over the 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 for modal.js + stats.js; schedule LATE, conflicts with daily results.json edits), 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 (see issues.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.