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

574 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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-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) 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.**
- **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.
### 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 end** —
`tournamentOver()` 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.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-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 browser** — `python -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.
---
## 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.
### 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`):
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/`, `README.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 (`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 && 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-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
- `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-label`led + `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.
### Leader cards — tied-team carousel (2026-06-19)
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` / `gd` — *not* 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.
### 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-02.** 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-02** — **R32 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-01** — **R32 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.