mirror of
https://github.com/LucasKalil-Programador/world-2026-hub.git
synced 2026-07-04 17:41:28 -03:00
560 lines
41 KiB
Markdown
560 lines
41 KiB
Markdown
# 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 **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) 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 9–12, 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.4–2; natural size measured lazily (`ensureMeasured()` — panel may be
|
||
`hidden` at render). Pan/pinch via Pointer Events, `touch-action:none` on the wrap. Drag–click
|
||
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),
|
||
768–1100 (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 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 `?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 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 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 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 && 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 A–F: 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.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)
|
||
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-01.** Data: **R32 underway** — group stage COMPLETE (1–72) + R32 matches **73
|
||
(RSA 0–1 CAN)**, **74 (GER 1–1 PAR, PAR 4–3 pens)**, **75 (NED 1–1 MAR, MAR 3–2 pens)**, **76
|
||
(BRA 2–1 JPN)**, **77 (FRA 3–0 SWE)**, **78 (CIV 1–2 NOR)** and **79 (MEX 2–0 ECU)** finished;
|
||
remaining R32 ids 80–88 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 A–D + 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-01** — **R32 ids 77 & 79.** Match 77 (R32-2, FRA I1 × SWE F3): **FRA 3–0 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 2–0 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 80–88.
|
||
- **2026-06-30 (b)** — **R32 id 78 (R32-10, CIV E2 × NOR I2): CIV 1–2 NOR.** Nusa 39', Diallo 74',
|
||
Haaland 86' (Norway's first-ever WC knockout win) — 2-source confirmed ESPN(gid 760490)/Outlook/
|
||
CBS/Yahoo/FOX. Stats: poss 47/53, shots 5/4 (ESPN match box; the auto search-summary's 44/56 + 6/5
|
||
is the lower-confidence figure — used the page box). Cards **0/1** (only NOR Nusa 45' yellow on
|
||
Sofascore; clean game, 6/7 fouls). Regulation, **no penalties**. Winner propagated: NOR → R16-5 vs
|
||
BRA (verified). Next R32: id 77 (R32-2, 30/06 21:00 UTC), then 79–88.
|
||
- **2026-06-30** — **R32 continues — two penalty shootouts.** Match 74 (R32-1, GER E1 × PAR 3rd-D):
|
||
**GER 1–1 PAR, PAR win 4–3 on pens** (Enciso 42', Havertz 54'; Tah's ET header VAR-ruled out, then
|
||
skied the decisive pen) — 2-source confirmed ESPN(gid 760489)/FOX/CBS/France24/Opta. Stats: poss
|
||
76/24 (FotMob; Opta cites GER 75.4%), shots 21/7 (FotMob), cards 2/2 (FOX bookings: GER Havertz,
|
||
Musiala; PAR Cubas, Galarza — no reds). Match 75 (R32-4, NED F1 × MAR C2): **NED 1–1 MAR, MAR win
|
||
3–2 on pens** (Gakpo 72', Diop 90+1'; Saibari decisive pen) — 2-source confirmed FotMob(jps8z)/ESPN
|
||
(gid 760488)/Outlook/SI. Stats: poss 30/70 (FotMob **and** ESPN agree), shots 6/11 (FotMob; ESPN
|
||
on-target 2/5), cards **0/1** — **single-source/low-confidence**: only Diop 47' yellow surfaced on
|
||
Sofascore; the physical 120'+ tie likely had more, card section was JS-unextractable elsewhere.
|
||
Penalties on KO ids only. Both winners propagated & verified in bracket (PAR→R16-1, MAR→R16-2 vs
|
||
CAN). Next R32: ids 77, 78 (30/06), then 79–88.
|
||
|
||
### Pending / next
|
||
- **Knockout R32 (ids 73–88) — in progress.** Done: 73 (RSA 0–1 CAN), 74 (GER 1–1 PAR, PAR 4–3 pens),
|
||
75 (NED 1–1 MAR, MAR 3–2 pens), 76 (BRA 2–1 JPN), 77 (FRA 3–0 SWE), 78 (CIV 1–2 NOR), 79 (MEX 2–0 ECU).
|
||
Next: ids 80–88. `penalties` apply on ids 73–104 (KO only — append
|
||
`"penalties": {home,away}` and keep `homeScore/awayScore` as the 90+30 score). R16 ids 89–96 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.
|