merge: stats final screen — A-F + release polish (Stage J round 1)

Brings the post-Cup Stats screen to master: degradation engine + fault-tolerant
loadData, sticky scrollspy sub-nav, verdict hero + goals-by-round chart, final
ranking 1-48, favorite-row highlight, team/match record cards, format-48 debuts
band, and the team comparator. Stage E (in-tab archive) skipped; data layers
G/H/I + a 2nd polish remain (see .agents/TODO.md).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Lucas Kalil 2026-06-17 11:10:51 -03:00
commit 941a519891
8 changed files with 1220 additions and 44 deletions

View file

@ -72,6 +72,32 @@ Use checkboxes to track progress. Items marked **🔴 BLOCKER** prevent release;
---
## 7. Stats final screen — `feature/stats-final-screen`
Build of `.agents/stats-screen-plan.md`, stage by stage with an approval gate each. The **pure-UI
build (AF, minus skipped E) + a release polish (J round 1)** was **merged to `master` on 2026-06-17**
and is live. The screen renders fully with today's data and auto-lights the post-Cup sections (verdict,
champion path, debuts champion) once the final lands. The **data-layer stages (G/H/I) + a second polish
(J round 2)** remain for when their data arrives near/after the Cup.
### ✅ Shipped to `master` (2026-06-17)
- [x] ~~Stage 0 — branch `feature/stats-final-screen` off `master`~~
- [x] ~~Stage A — degradation engine + fault-tolerant `loadData` + sticky scrollspy sub-nav + media fallback~~
- [x] ~~Stage B — verdict hero (gated on FINAL finished; aggregate-hero fallback) + goals-by-round chart~~
- [x] ~~Stage C — final ranking 148 (phase-reached chain), favorite-row highlight, team record cards~~
- [x] ~~Stage D — auto record-cards + "format-48 debuts" band~~
- [x] ~~Stage E — in-tab results archive~~**SKIPPED (Option B):** kept the "See all matches → Matches" link; the Matches tab already covers browsing.
- [x] ~~Stage F — team comparator (diverging bars)~~ — teams only; the Teams/Players toggle is deferred to Stage H per graceful degradation.
- [x] ~~Stage J (round 1, release polish) — a11y/responsive/i18n/README audit on AF~~ — passed clean (no code fixes). **Lighthouse + final `DATA_VERSION` bump still pending an actual deploy.**
### 🔭 Future (data layers + 2nd polish — near/after the Cup)
- [ ] **Stage G — Layer 2 cheap data.** Extend `results.json` (attendance, **`cards`→{y,r} migration** — breaking for `modal.js` + `stats.js` `aggregateTeams`, add a backward-compatible reader; `decidedIn`; backfill `stats`), `teams.json` (`ranking`/`wcDebut`/`confederation`), `stadiums.json` (`lat`/`lng`). Light up records: attendance, discipline/fair-play, ranking upsets, confederation performance, distance. **SCHEDULE LATE — conflicts with master's daily `results.json` refreshes.** Bump `DATA_VERSION`.
- [ ] **Stage H — Layer 3 players.** `players.json` + `player-events.json` + `awards.json` (+ optional `keeper-stats.json`). Top-scorers podium, assists/cards/saves chips, awards block, Squad of the Tournament, **Teams/Players toggle in the comparator** (the deferred half of Stage F), goal-time records. Relative photo paths (gotcha #7).
- [ ] **Stage I — Layer 4 editorial.** `curiosities.json` (bilingual EN+PT) + `all-time-baselines.json`; editorial record-cards + "this Cup vs history" panel.
- [ ] **Stage J (round 2) — polish over the NEW G/H/I features.** Repeat the audit on the added sections (a11y/responsive/perf, Lighthouse, EN/PT review) + bump `DATA_VERSION` for the new data files + README refresh.
---
## Quick final checklist
```

View file

@ -3,6 +3,8 @@
Navigation map of the codebase. Use this to find which file owns a concern before reading code.
> **Status 2026-06-12 (all 12 steps + real-data migration done):** everything works with **real World Cup 2026 data** — all views, bracket interactions, simulation, responsive/a11y pass, favorites, time toggle, challenge, share link, `.ics` export. Remaining: keep `results.json` current, fill `thirdPlaceAssignment` after the group stage (~Jun 27), Lighthouse run + GitHub Pages deploy. Spec source of truth: `world-cup-2026-hub-spec-en.md` + `complement-spec-worldcup2026-en.md` (complement **wins on conflict**).
>
> **Branch note (2026-06-16):** the full post-Cup Stats screen (`.agents/stats-screen-plan.md`, stages AJ) is being built on **`feature/stats-final-screen`** (merges to `master` at the end of the Cup). **Stages AD + F + J(round 1 polish) done and MERGED TO MASTER (2026-06-17)** — built on that branch (degradation engine + fault-tolerant `loadData` + sticky scrollspy sub-nav + flag monogram fallback; verdict-or-aggregate hero + goals-by-round chart; final ranking 148 by stage-reached + favorite-row highlight + team record cards; Records section = match records + format-48 debuts band; team comparator with diverging bars). Stage E skipped. Sub-nav live chips: Overview · Teams · Records · Comparator. **Stage E (in-tab results archive) skipped by decision** — the Matches tab stays the single surface for browsing; the "See all matches →" link is kept. `master` keeps the partial Stats tab + daily refreshes. Descriptions below reflect the branch.
---
@ -51,7 +53,10 @@ worldcup2026/
│ │ │ live data refresh (startResultsPolling: 90s poll of
│ │ │ results.json, no-store + ?t, content signature, pauses
│ │ │ when tab hidden, stops at FINAL; on change also refetches
│ │ │ bracket-config.json; fires `datachange`)
│ │ │ bracket-config.json; fires `datachange`);
│ │ │ loadOptional() = fault-tolerant fetch of the stats screen's
│ │ │ optional data layers (absent → silent empty default);
│ │ │ trackHeaderHeight() keeps the --header-h CSS var live
│ │ ├── schedule.js Match list, filters (incl. occurrence toggle
│ │ │ Played/Upcoming via hybrid matchState), search,
│ │ │ sort, "My Matches"; 60s clock-tick re-render
@ -63,8 +68,16 @@ worldcup2026/
│ │ ├── storage.js localStorage wrapper — wc2026_* keys, auto-JSON
│ │ ├── i18n.js EN/PT-BR dicts + t(key), lang toggle
│ │ ├── stats.js ★ Stats tab: tournament-to-date aggregates (finished
│ │ │ matches only), hero pulse + overview + goals-by-stage.
│ │ │ PARTIAL (during-cup) — grows into the post-cup plan.
│ │ │ matches only); verdict-or-aggregate hero + overview + goals-by-stage/round +
│ │ │ 48-team table ranked 148 by stage-reached (sortable, # = canonical rank) +
│ │ │ favorite-row highlight + team record cards (win streak,
│ │ │ champion path) + Records section (biggest win/high-scoring
│ │ │ match → modal, format-48 debuts band) + team comparator
│ │ │ (A-vs-B diverging bars). SECTIONS registry (graceful-
│ │ │ degradation gate: section + chip render only if available,
│ │ │ else removed from DOM) + sticky scrollspy sub-nav (anchor
│ │ │ chips, hash-safe) + flagImg fallback; imports getBracketTree/getFavorites/openMatchModal. Grows into
│ │ │ the post-cup plan (.agents/stats-screen-plan.md, AJ).
│ │ └── calendar.js .ics export (RFC 5545, CRLF, Blob download)
│ ├── images/ Team flag SVGs, stadium placeholders
│ └── icons/ PWA app icons (from the header trophy logo): icon.svg
@ -80,9 +93,13 @@ worldcup2026/
│ ├── results.json { matchId, homeScore, awayScore, penalties?, status } —
│ │ update as the tournament progresses
│ ├── stadiums.json 16 real venues: { id, name, city, capacity, image, timezone }
│ └── bracket-config.json ★ official R32 structure + thirdPlaceAssignment (all null) —
│ the ONLY file to edit once real 3rd places are known
│ (slot → allowed-groups table in project-memory.md)
│ ├── bracket-config.json ★ official R32 structure + thirdPlaceAssignment (all null) —
│ │ the ONLY file to edit once real 3rd places are known
│ │ (slot → allowed-groups table in project-memory.md)
│ └── (optional, NOT yet created) stats-screen data layers loaded fault-tolerantly by
│ loadOptional(): players.json, player-events.json,
│ awards.json, keeper-stats.json, curiosities.json,
│ all-time-baselines.json — absent = silent empty default
├── README.md Setup, GitHub Pages deploy, JSON maintenance guide
├── how-update.md Real-data migration runbook (mock → real — DONE 2026-06-12)

View file

@ -240,6 +240,68 @@ Static web app showing the FIFA World Cup 2026 (Mexico/USA/Canada, 48 teams) —
- **Deploy:** os arquivos novos (manifest.json, favicon.ico, assets/icons/) **não** estão no `exclude` do `deploy.yml` → sobem normalmente. HTTPS da Hostinger já satisfaz o requisito de PWA.
- **Verificado (preview localhost:8126, contexto seguro):** manifest 200 e parseado (name/short/start `.`/scope `./`/display standalone/theme `#081421`); todos os ícones 200 `image/png`; `favicon.ico` 200; `<meta theme-color>` + apple tags presentes; **console limpo**; app intacto (hero FRA×SEN, 4 cards do dashboard, 16 encerradas/88 próximas — sem regressão visual). **Não testável pelo preview:** o prompt de instalação real / "Add to Home Screen" + ícone no SO — confirmar no Chrome/Edge devtools (aba Application) ou num celular após o deploy.
### Stats final screen — branch + Stage A (2026-06-16)
- **New feature branch `feature/stats-final-screen`** (off `master`) to build the full post-Cup stats screen from `.agents/stats-screen-plan.md` (stages AJ). `master` stays live with the partial Stats tab + daily refreshes; merge `master`→branch periodically; the branch merges back to `master` at the **end of the Cup** (= release). Pure-UI stages **AF first**; schema-changing data stages (esp. **G**, which migrates `results.json` `cards``{y,r}`) deferred to late to avoid conflicting with master's daily `results.json` edits. **Approval gate before each stage.**
- **Stage A — degradation engine + scaffolding (DONE, branch only):**
- **Fault-tolerant `loadData()` (`app.js`):** the 6 core files still **throw** on failure (fatal → `showError`); 6 NEW optional layers (`players`, `player-events`, `awards`, `keeper-stats`, `curiosities`, `all-time-baselines`) load via `loadOptional(name, fallback)` → an **absent/404 file returns the empty default SILENTLY** (graceful degradation §0.2; absence is the normal "layer not arrived yet" state and keeps the console clean for verification), and it **warns only on a present-but-malformed file**. Exposed on `data` as `players/playerEvents/awards/keeperStats/curiosities/allTimeBaselines`. Core + optional fetch concurrently (two `Promise.all`, core awaited first).
- **`DATA_VERSION` deliberately NOT bumped in Stage A** — no deployed *data* changed (the optional files don't exist yet), and bumping it on the branch would conflict with master's daily bump on every merge. Bump only when real data files land (Stage G/H). (Supersedes the plan's literal "bump when wiring loadData".)
- **Section-gating contract — `SECTIONS` registry in `stats.js`:** each section `{ id, navKey, available(model), body(model) }` renders — and shows its sub-nav chip — **only when `available` holds**; otherwise it is **omitted from the DOM entirely** (no placeholder, no `—`, no "coming soon"), and the nav never points at emptiness (§0.1). Overview/Teams are `available:()=>true`; players/records/comparator/archive are `available:()=>false` (chips absent until later stages flip them + supply `body`).
- **Sticky scrollspy sub-nav:** `render()` now emits hero + `<nav.stats-subnav>` (anchor chips) + one `<section.stats-section>` per available section + footer. Chips are `<a href="#stats-{id}">` but their click is **`preventDefault` + `scrollIntoView`** — they **NEVER set `location.hash`** (the tab router listens on `hashchange`; a real `#stats-teams` fragment would route to an unknown tab → bounce to Home — the key gotcha here). Scrollspy is **position-based** (rAF-throttled `scroll` listener reading `getBoundingClientRect`), **not** an IntersectionObserver band: the band left a short final section unlit at the page bottom, so the position approach + an explicit "at page bottom → last section" rule is used (robust on short pages). `setActiveChip` keeps the active chip visible via the nav's own `scrollLeft` (no page jump).
- **`--header-h` CSS var** kept live by `trackHeaderHeight()` (`app.js`, `ResizeObserver` on the variable-height sticky header — one row ≥1100px, two bands below). The sub-nav sticks at `top: var(--header-h)` and sections use `scroll-margin-top: calc(var(--header-h) + 3.75rem)`, correct at every breakpoint (verified 137px wide / 98px at 375px).
- **Media fallback (§0.3):** `flagImg(team,w,h)` emits the flag `<img>` with `data-monogram="<id>"`; a one-time capture-phase `error` listener (`installImageFallback`) replaces a broken flag with a `<span.flag-fallback>` of the 3-letter code — never a broken-image icon. Existing stats flag usages (leaders, team table) converted to `flagImg`.
- **New i18n keys** (EN+PT): `stats.sectionsNav` + `stats.navOverview/navTeams/navPlayers/navRecords/navComparator/navArchive`. **New CSS** in `stats.css`: `.stats-subnav`/`.stats-subnav-chip`, `.stats-section` (scroll-margin + inter-section spacing + first-heading margin reset), `.flag-fallback`. **No `index.html` change** — the sub-nav is JS-rendered into the existing `#stats-root`.
- **Verified (preview 8126, branch):** console clean (no errors, no 404 warnings); 6 optional layers = empty defaults, core 48/104/104; sub-nav sticky at `--header-h`, only Overview+Teams chips; scrollspy top→Overview / bottom→Teams; chip click leaves hash `#stats`; EN↔PT relabels chips + sections/table survive; flag fallback → "XYZ" span at 22×15; mobile 375px (2-band header, 2×2 tiles, nav follows 98px header); Home hero+dashboard regression clean.
- **Next:** Stage B (verdict hero gated on FINAL finished, falling back to the current aggregate hero; + goals-by-round chart) — awaiting approval. Will consume `getBracketTree().champion` + `resolveBracketTeams('FINAL'|'THIRD-PLACE')` from `bracket.js`.
### Stats final screen — Stage B: verdict hero + goals-by-round (2026-06-16)
- **Verdict hero (`stats.js`):** `heroHTML()` now dispatches — `model.verdict ? verdictHeroHTML() : aggregateHeroHTML()`. The verdict hero shows the champion (trophy + gold-outlined flag + name) and a **2/3/4 podium** (runner-up / 3rd / 4th) above the same 4 count-up tiles (extracted to `heroTilesHTML()`, shared by both heroes). **Gated on the REAL final:** `computeVerdict()` reads `getBracketTree().nodesByRef.get('FINAL')` and returns `null` unless `result.status==='finished' && !simulated && winner` — so a user's **simulated** champion never leaks into the verdict (`decide()` sets the real winner first, so `simulated:false` ⇒ real). Third/fourth come from `tree.third` the same way, independently (podium degrades gracefully if only the final is in). Until the final is really finished it falls back to the existing **aggregate "tournament in progress" hero**, so an early merge stays correct (current 17/104 data shows the aggregate hero — verified).
- **`stats.js` now imports `getBracketTree` from `bracket.js`** — the 4th intentional circular import with `app.js` (safe: only called at render runtime, and `bracket.js` evaluates before `stats.js` in `app.js`'s import order).
- **Goals-by-round chart (`goalsByRoundHTML`, in the Overview section after goals-by-stage):** finer companion to goals-by-stage — the group stage is split into its **3 matchdays** (derived per group by `computeGroupMatchdays`: sort each group's 6 fixtures by kickoff, chunk into pairs → MD1/2/3; **`matches.json` has no matchday field**) plus each knockout round on its own. `ROUND_ORDER = ['MD1','MD2','MD3', ...STAGE_ORDER]`. **Hidden until ≥2 rounds have data** (`order.length < 2 → ''`) so early on it doesn't show a lone bar duplicating goals-by-stage's "Group" bar (current data = only MD1 → chart hidden, verified). The model gained `byRound` + `verdict`.
- **New i18n keys** (EN+PT): `stats.goalsByRound`, `stats.matchday` ("Matchday"/"Rodada"), `stats.verdictTitle`, `stats.runnerUp`, `stats.thirdPlace`, `stats.fourthPlace`. **New CSS** in `stats.css`: `.stats-verdict` + `.verdict-champion/-trophy/-flag/-name/-crown/-podium/-place/-rank/-place-name/-place-label`.
- **Verified (preview 8126, branch):** real 17/104 → aggregate hero + goals-by-round hidden, console clean; **faked all-104-finished** (+ a valid `thirdPlaceAssignment`, injected via `preview_eval`, restored by reload) → verdict hero (champion + 2/3/4 podium, tiles 276 goals / 2.65 avg / 6 / 55) + goals-by-round with all 9 buckets (MD13 + R32→Final); EN↔PT relabels the verdict + round chart and survives re-render; no regression after restore.
- **Next:** Stage C — final ranking 148 (phase-reached → pts → GD → GF → id chain), favorite-row highlight (`getFavorites` + `favchange`), team record cards (biggest rout → modal, champion's path, form). Awaiting approval.
### Stats final screen — Stage C: final ranking, favorites, team records (2026-06-17)
- **Canonical final ranking 148 (`stats.js`):** each `teamStats` row gets a `.rank` from `assignRanks()``computeRankTiers()`: primary key is the deepest stage REACHED from REAL knockout results (champion 0 → runner-up 1 → 3rd 2 → 4th 3 → QF 4 → R16 5 → R32 6 → group 7), then points → GD → GF → id. **Real results only** — a simulated pick never moves the ranking (same `!simulated && status==='finished'` gate as the verdict). During the group stage everyone is tier 7 → the ranking is the global points table; post-knockout the champion is #1 even with fewer points than the runner-up (tier dominates — verified with the fake: NED #1 @4pts above COL #2 @5pts).
- **`#` column is now the canonical rank AND a sortable header** (the default sort on load). The `#` cell always shows the team's canonical rank (stable identity) regardless of the active column sort; clicking another column re-sorts the rows but `#` keeps the rank, and clicking `#` returns to the canonical order. Non-rank sorts fall back to `a.rank - b.rank` as the stable tiebreak. Default changed `sortKey 'gf'→'rank'`, `sortDir 'desc'→'asc'`.
- **Favorite-team row highlight (gold):** `tableHTML` adds `row-fav` when `getFavorites()` includes the team — gold inset-left border on the sticky `#` cell + a gold row tint. `initStats` listens for `favchange``renderTeamTable()` only (no model rebuild; favorites aren't in the model). No stars in the stats table (highlight-only, like the bracket).
- **Team record cards (Teams section, after the leaders band):** `computeRecords()` derives — **biggest win** (largest margin, tie→most goals; a `<button>``openMatchModal`), **longest win streak** (≥2 consecutive wins by any team, chronological; hidden below 2 → currently hidden at 19/104), and **champion's path** (the champion's R32→Final route with scores, each row → `openMatchModal`; gated on the verdict, so absent pre-final). Each card degrades away individually when its data is null (§0.1).
- **stats.js now imports `getFavorites` (storage.js) + `openMatchModal` (modal.js)** — modal.js is the 5th intentional circular import via app.js (render-time only). Record-card / champion-path clicks are wired in `render()` over `[data-record-match]` (elements recreated each render → no listener stacking).
- **New i18n keys** (EN+PT): `tip.rank`, `stats.rankCol`, `stats.biggestWin`, `stats.winStreak`, `stats.championPath`. **New CSS:** `.row-fav` (after the hover rules so it wins specificity), `.stats-records-grid`/`.record-card`/`.record-*`, `.champ-path`/`.champ-path-row`.
- **Branch hygiene:** before this stage, merged `master``feature/stats-final-screen` (commit `beac605`) to bring matches 18 (IRQ 14 NOR) + 19 (ARG 30 ALG), `DATA_VERSION` rev4, and deploy.yml; `app.js` auto-merged cleanly (master's DATA_VERSION string + my Stage A loadData rewrite — adjacent lines, no conflict).
- **Verified (preview 8126, branch, 19/104 real):** console clean; default sort = rank (Germany #1 by GD during groups); `#` sortable + stays canonical when sorting other columns; biggest-win card → modal (GER 71 CUW); favorite GER → gold row; streak/champion-path hidden (graceful). **Faked all-104-finished:** champion NED #1, runner-up COL #2, 3rd HAI, 4th CAN, QF losers #58; champion's-path card with all 5 rounds (clickable); streak card appears. EN↔PT relabels records/path/rank-tooltip and survives re-render; mobile 375px keeps sticky #/team + legend; real state restored.
- **Deferred (noted):** home/away splits + a per-match W/D/L form column → Stage J/later (the plan's §C "splits"/detailed form).
- **Next:** Stage D — auto record-cards (match/tournament records not already shown by Stage C) + "format-48 debuts" band (104 matches, R32 as a new round, best-3rd mechanic, first 48-team champion). Awaiting approval.
### Stats final screen — Stage D: Records section + format-48 debuts (2026-06-17)
- **New `records` sub-nav section is live** (`SECTIONS` `records` flipped to `available: () => true`, `body: recordsSectionHTML`). Sub-nav now shows **Overview · Teams · Records** (players/comparator/archive still dark). The section is always available — its anchor content (the debuts band) is meaningful from match 0.
- **Match-record cards consolidated into Records** (cleaner C/D split): **`biggestWin` moved out of the Teams section into Records**; Teams now holds only team-level cards (win streak, champion path). Records shows **biggest win** (margin) + **highest-scoring match** (most combined goals; tie→bigger margin), both `<button>``openMatchModal`. **Dedup:** the high-score card is suppressed when it's the same match as biggest win (they coincide early, diverge later) — verified (real data: both = GER 71 m9 → 1 card; forcing a 65 → both cards).
- **`computeRecords` gained `highestScoringMatch`.** New render fns `recordsSectionHTML` + `highScoreCardHTML` + `formatDebutsHTML`.
- **"Format debuts" band (`formatDebutsHTML`):** the firsts of the 48-team era — 48 teams, 104 matches (up from 64), 12 groups, "Round of 32" (a new knockout round, via `translatePhase`), 8 best third-placed teams advance, and **first champion of the 48-team era** (lights up post-final from `model.verdict`). Mostly static format facts + the one dynamic champion fact; counts come from `getData()`/model, not hardcoded.
- **New i18n keys** (EN+PT): `stats.recordsTitle`, `stats.highScoreMatch`, `stats.formatDebutsTitle`, `stats.debutTeams/debutMatches/debutGroups/debutR32/debutThird/debutChampion`. **New CSS:** `.stats-subhead`, `.debut-band`/`.debut-fact`/`.debut-value`/`.debut-value-sm`/`.debut-label`.
- **Verified (preview 8126, branch, 19/104 real):** console clean; 3 chips (Overview/Teams/Records); Records = biggest-win card (high-score deduped) + debuts band (48/104/12/R32/8, no champion); Teams record cards now 0 (biggest-win moved). **Faked all-104-finished:** champion fact "Netherlands" appears in the band; forcing a distinct top-scoring match (65) shows both record cards. EN↔PT relabels section/cards/band ("16 avos de final" via translatePhase), survives re-render; mobile 375px band stacks 1-col; real state restored.
- **Next:** Stage E — 104-match results archive in-tab (accordion by phase, filters/sort, row → `openMatchModal`, reusing schedule.js patterns + `resolveBracketTeams` for knockout labels). At the gate I'll confirm full archive vs. keeping the "See all matches → Matches tab" link. Awaiting approval.
### Stats final screen — Stage E SKIPPED by decision (2026-06-17, Option B)
- **The in-tab 104-match results archive will NOT be built.** User decision (Option B over a full archive / a lighter one): keep the existing **"See all matches →" footer link** (`#stats-see-matches``navigateTo('matches')`) and let the **Matches tab remain the single surface** for browsing matches.
- **Rationale:** the Matches tab (`schedule.js`) already lists all 104 with filters (date/group/phase/team/stadium), search, sort, occurrence toggle, "My matches", and card→modal. An in-tab archive would duplicate it for little gain and add the heaviest section (104 rows) to maintain.
- **State:** the `archive` entry in `SECTIONS` stays `available: () => false` / `body: () => ''` (no chip, no DOM) — leave it as a dormant slot, don't delete the registry line. **No code change** was made for this decision. If ever revisited, the lighter "phase-accordion, results-only" variant (Option C) was the recommended shape.
- **Next:** Stage F — team comparator (A-vs-B selector + diverging mirrored bars; players side stays dark until Stage H). Awaiting approval.
### Stats final screen — Stage F: team comparator (2026-06-17)
- **New `comparator` sub-nav section** (`SECTIONS` `comparator``available: (m) => m.finishedCount > 0`, `body: comparatorSectionHTML`). Sub-nav now: **Overview · Teams · Records · Comparator** (gated so the chip appears once ≥1 match is played).
- **A-vs-B team comparator with diverging mirrored bars:** two `<select>`s (alphabetical, all 48 teams, reuse `.filter-control`), default to the **top-2 ranked** teams; the choice survives langchange (module-level `cmpA`/`cmpB`, like the table sort). On `change`, only the bars panel re-renders (`refreshComparator()``#cmp-panel`), keeping select focus and replaying the grow animation. Bars: A grows leftward from the center metric label, B rightward; each row scales to `max(a,b,1)` so the longer bar = higher value; the higher side's number is gold (`.lead`). Metrics (`CMP_METRICS`, all non-negative so mirroring reads cleanly): P, W, GF, GA, CS, Pts (GD excluded — it's GF/GA derived and can be negative). A=gold gradient, B=blue gradient; `cmp-grow` scaleX animation with origin at the center edge, off under `prefers-reduced-motion`.
- **Players side deferred to Stage H (graceful degradation, NOT the plan's literal "teams/players toggle"):** a disabled "Players" toggle would be a visible dead control = violates §0 ("missing data → removed from DOM, no placeholder"). So Stage F ships the **teams comparator only**; the Teams/Players toggle appears in Stage H when `players.json` lands. Documented deviation.
- **New i18n keys** (EN+PT): `stats.comparatorTitle`, `stats.cmpTeamA`, `stats.cmpTeamB` (`navComparator` already existed). **New CSS:** `.cmp-controls`/`.cmp-select`/`.cmp-vs`/`.cmp-panel`/`.cmp-head`/`.cmp-team`/`.cmp-row`/`.cmp-val(.lead)`/`.cmp-track(.a/.b)`/`.cmp-bar(.a/.b)` + `@keyframes cmp-grow`.
- **Verified (preview 8126, branch, 19/104 real):** console clean; 4 chips; default GER vs SWE (top-2 ranked); bars scale right (GP 7→100% / 5→71%); changing B→USA updates the header ("Germany vs United States") and re-scales (GP 7→100% / 4→57%); EN↔PT relabels title/metrics and **selection persists** across langchange; mobile 375px selects side-by-side + rows fit (no overflow). Screenshots desktop+mobile.
- **Next:** Stage G — Layer 2 cheap data (attendance, **`cards`→{y,r} migration**, `decidedIn`, team `ranking`/`wcDebut`/`confederation`, stadium coords; backfill `stats`). **SCHEDULE LATE / near end of Cup** — the `cards` migration is a breaking change for `modal.js` + `stats.js` (`aggregateTeams` reads `s.cards.home`) and would conflict with master's daily `cards` writes. Awaiting approval (and likely deferral).
### Stats final screen — Stage J round 1 (release polish) + merge to master (2026-06-17)
- **Release polish pass over AF** (E skipped): **i18n audit** — no hardcoded user-facing strings in `stats.js` (all via `t()`); **a11y** — the 4 sub-nav sections are `aria-label`led + `tabindex=-1`, the table has a caption + 11 `aria-label`led sort buttons + `aria-sort`, comparator selects + record-card buttons are `aria-label`led, sub-nav is a `<nav>`; **reduced-motion** — chart bars, comparator `cmp-grow`, and count-ups are all gated; **cross-tab regression** — home/matches/groups/bracket/stadiums/stats all render with content, console clean. **No code fixes were needed** (per-stage work was already clean). Added a **Stats bullet to README**.
- **Deferred to the actual deploy:** the Lighthouse run (needs a deployed URL) and a final `DATA_VERSION` bump. **Caveat (pre-existing, accepted):** the stats screen is **code** (HTML/CSS/JS), and the project only cache-busts `data/*.json` via `DATA_VERSION` — JS/CSS are not versioned (no build step), so on Hostinger (no cache headers) returning visitors may serve stale assets until their browser re-fetches. True for every code deploy; new visitors / hard-refresh see it at once.
- **Merge to `master`:** the pure-UI build (AF) + this polish was merged to `master` for release. Sequence used: merge latest `master`→branch first (resolve conflicts on the branch, never on master), re-verify, then `master` ← branch (`--no-ff`). **Pushing to origin** (which triggers the Hostinger FTP deploy via `deploy.yml`) is the user's explicit final go — production.
- **Remaining (TODO §7):** Stage G (Layer-2 schema, schedule late — conflicts with daily refreshes), H (players + the deferred comparator Teams/Players toggle), I (editorial), and **a second polish J round 2** over those new features.
### How to update real-world data (scores, schedule)
Follow `how-refresh-data.md` (project root). In short:
1. Edit `data/results.json` (scores/status) or `data/matches.json` (schedule, rare).

View file

@ -26,6 +26,12 @@ no backend, no framework, no build step. All content lives in JSON files.
- **Time zones** — show kickoff times in your local time or the stadium's time.
- **Add to calendar** — download any match as an RFC 5545 `.ics` file.
- **Match modal** — details for every match, with space reserved for future stats.
- **Stats** — a sub-navigated screen (Overview · Teams · Records · Comparator):
tournament-to-date aggregates and goals-by-stage/round charts; a verdict hero
(champion + podium) that takes over once the final is played; a final ranking
148 by stage reached; team record cards (biggest win, win streak, champion's
path); a "format debuts" band; and an A-vs-B team comparator. Sections appear
only when they have data (graceful degradation).
- Responsive (mobile / tablet / desktop), keyboard-accessible, honors
`prefers-reduced-motion`.

View file

@ -48,6 +48,94 @@
color: var(--text-secondary);
}
/* -------------------------------------------------------- verdict hero */
/* post-tournament hero: champion + podium above the count-up tiles */
.stats-verdict {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.3rem;
}
.stats-verdict .hero-label { margin-bottom: 0.1rem; }
.verdict-champion {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.45rem;
}
.verdict-trophy {
font-size: 2.4rem;
line-height: 1;
}
.verdict-flag {
border-radius: 4px;
box-shadow: 0 6px 22px rgba(0, 0, 0, 0.45);
outline: 2px solid var(--accent-gold);
}
.verdict-name {
font-size: clamp(1.6rem, 5vw, 2.5rem);
font-weight: 800;
line-height: 1.05;
color: var(--accent-gold-soft);
}
.verdict-crown {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.14em;
color: var(--accent-gold);
}
.verdict-podium {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.8rem 1.5rem;
}
.verdict-place {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.28rem;
min-width: 84px;
}
.verdict-rank {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
background: var(--glass-bg-strong);
border: 1px solid var(--glass-border);
font-size: 0.78rem;
font-weight: 700;
color: var(--text-secondary);
}
.verdict-place-name {
font-size: 0.92rem;
font-weight: 600;
}
.verdict-place-label {
font-size: 0.66rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-secondary);
}
.stats-verdict .stats-hero-tiles { width: 100%; }
/* ----------------------------------------------------------- overview */
.stats-overview-grid {
@ -234,6 +322,215 @@
background: var(--bg-secondary);
}
/* favorite-team row highlight (gold) — after the hover rules so it persists */
.stats-table tbody tr.row-fav td { background: rgba(212, 175, 55, 0.12); }
.stats-table tbody tr.row-fav .col-rank,
.stats-table tbody tr.row-fav .col-team { background: var(--bg-secondary); }
.stats-table tbody tr.row-fav .col-rank { box-shadow: inset 3px 0 0 var(--accent-gold); }
/* ------------------------------------------------------ team records */
.stats-records-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 1.25rem;
}
.record-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1.1rem 1rem;
text-align: center;
width: 100%;
}
button.record-card { cursor: pointer; transition: border-color 0.2s, background-color 0.2s; }
button.record-card:hover { border-color: var(--accent-gold); background: var(--glass-bg-strong); }
.record-label {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--accent-gold);
font-weight: 600;
}
.record-main { display: flex; align-items: center; gap: 0.6rem; }
.record-score {
font-size: 1.5rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--accent-gold-soft);
}
.record-teams { font-size: 0.85rem; color: var(--text-secondary); }
.record-vs { opacity: 0.55; }
.champ-path {
display: grid;
gap: 0.35rem;
padding: 1.1rem 1.2rem;
margin-bottom: 1.25rem;
}
.champ-path .record-label { margin-bottom: 0.3rem; }
.champ-path-row {
display: grid;
grid-template-columns: 1fr auto 1.4fr;
align-items: center;
gap: 0.6rem;
padding: 0.4rem 0.5rem;
border-radius: var(--radius-sm);
}
.champ-path-row.clickable { cursor: pointer; }
.champ-path-row.clickable:hover { background: var(--glass-bg-strong); }
.champ-path-phase { font-size: 0.82rem; color: var(--text-secondary); text-align: left; }
.champ-path-score { font-weight: 700; font-variant-numeric: tabular-nums; }
.champ-path-opp { display: flex; align-items: center; gap: 0.4rem; justify-content: flex-end; font-size: 0.88rem; }
/* -------------------------------------------------- format-48 debuts band */
.stats-subhead {
font-size: 1.05rem;
font-weight: 700;
margin: 1.5rem 0 0.8rem;
}
.debut-band {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem 1.25rem;
padding: 1.4rem 1.5rem;
}
.debut-fact {
display: flex;
flex-direction: column;
gap: 0.3rem;
text-align: center;
}
.debut-value {
font-size: clamp(1.6rem, 4.5vw, 2.2rem);
font-weight: 800;
line-height: 1.05;
color: var(--accent-gold-soft);
font-variant-numeric: tabular-nums;
}
.debut-value-sm { font-size: clamp(1rem, 3vw, 1.3rem); }
.debut-label {
font-size: 0.76rem;
line-height: 1.35;
color: var(--text-secondary);
}
/* ---------------------------------------------------- team comparator */
.cmp-controls {
display: flex;
align-items: center;
justify-content: center;
gap: 0.8rem;
margin-bottom: 1rem;
}
.cmp-select { flex: 1 1 0; min-width: 0; max-width: 16rem; }
.cmp-vs {
flex: 0 0 auto;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-secondary);
}
.cmp-panel {
display: grid;
gap: 0.7rem;
padding: 1.3rem 1.4rem;
}
.cmp-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin-bottom: 0.4rem;
font-weight: 700;
}
.cmp-team { display: flex; align-items: center; gap: 0.5rem; min-width: 0; }
.cmp-team span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cmp-team-b { flex-direction: row; text-align: right; }
.cmp-row {
display: grid;
grid-template-columns: 2.2rem 1fr minmax(56px, auto) 1fr 2.2rem;
align-items: center;
gap: 0.5rem;
}
.cmp-val {
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--text-secondary);
}
.cmp-val.a { text-align: right; }
.cmp-val.b { text-align: left; }
.cmp-val.lead { color: var(--accent-gold-soft); }
.cmp-track {
height: 12px;
background: var(--glass-bg-strong);
border-radius: 999px;
overflow: hidden;
display: flex;
}
.cmp-track.a { justify-content: flex-end; }
.cmp-track.b { justify-content: flex-start; }
.cmp-bar {
height: 100%;
border-radius: 999px;
animation: cmp-grow 0.5s ease both;
}
.cmp-bar.a {
background: linear-gradient(90deg, var(--accent-gold-soft), var(--accent-gold));
transform-origin: right center;
}
.cmp-bar.b {
background: linear-gradient(90deg, var(--accent-blue), #5aa9f0);
transform-origin: left center;
}
@keyframes cmp-grow {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
.cmp-label {
text-align: center;
font-size: 0.78rem;
color: var(--text-secondary);
}
@media (prefers-reduced-motion: reduce) {
.cmp-bar { animation: none; }
}
.col-sort {
display: inline-flex;
align-items: center;
@ -381,6 +678,75 @@
background: var(--glass-bg-strong);
}
/* -------------------------------------------------- sub-nav (scrollspy) */
/* sticks just below the variable-height sticky header (--header-h is kept live
by app.js trackHeaderHeight). NOT a tablist it's a <nav> of in-page anchors
whose clicks are intercepted, so the top tablist's arrow keys never conflict. */
.stats-subnav {
position: sticky;
top: var(--header-h, 64px);
z-index: 20;
display: flex;
gap: 0.35rem;
margin: 1.4rem 0;
padding: 0.4rem;
overflow-x: auto;
scrollbar-width: none;
background: rgba(8, 20, 33, 0.88);
backdrop-filter: blur(10px) saturate(1.2);
-webkit-backdrop-filter: blur(10px) saturate(1.2);
border: 1px solid var(--glass-border);
border-radius: 999px;
}
.stats-subnav::-webkit-scrollbar { display: none; }
.stats-subnav-chip {
flex: 0 0 auto;
padding: 0.4rem 0.95rem;
border-radius: 999px;
color: var(--text-secondary);
font-size: 0.85rem;
font-weight: 600;
text-decoration: none;
white-space: nowrap;
transition: color 0.2s, background-color 0.2s;
}
.stats-subnav-chip:hover { color: var(--text-primary); }
.stats-subnav-chip.active {
color: var(--bg-primary);
background: linear-gradient(135deg, var(--accent-gold), var(--accent-gold-soft));
}
/* offset scroll targets so the sticky header + sub-nav never cover a heading */
.stats-section {
scroll-margin-top: calc(var(--header-h, 64px) + 3.75rem);
}
.stats-section + .stats-section { margin-top: 2.5rem; }
.stats-section:focus { outline: none; }
/* section spacing is owned by the wrapper, not the first heading inside it */
.stats-section > .section-title:first-child { margin-top: 0; }
/* flag monogram fallback (graceful degradation §0.3) — never a broken-img icon */
.flag-fallback {
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--glass-bg-strong);
border: 1px solid var(--glass-border);
border-radius: 3px;
color: var(--text-secondary);
font-size: 0.5rem;
font-weight: 700;
letter-spacing: 0.01em;
vertical-align: middle;
overflow: hidden;
}
/* ---------------------------------------------------------- responsive */
@media (max-width: 600px) {

View file

@ -17,18 +17,49 @@ let data = null;
const DATA_VERSION = '2026-06-16-rev4';
// Optional data layers for the post-tournament stats screen (players, awards,
// editorial — see .agents/stats-screen-plan.md §0.2). They don't exist yet, so
// an absent/404 file is the NORMAL "this layer hasn't arrived" state: return the
// empty default silently (graceful degradation — never surface the gap, and keep
// the console clean). Warn only when a file is present but malformed (a real dev
// error). Never throws — the stats screen lights these up as the JSON lands.
async function loadOptional(name, fallback) {
try {
const res = await fetch(`data/${name}.json?v=${DATA_VERSION}`);
if (!res.ok) return fallback; // not provided yet → empty, no noise
return await res.json();
} catch (err) {
console.warn(`data/${name}.json present but unreadable — ignoring`, err);
return fallback;
}
}
export async function loadData() {
if (data) return data;
const files = ['teams', 'groups', 'matches', 'results', 'stadiums', 'bracket-config'];
const [teams, groups, matches, results, stadiums, bracketConfig] = await Promise.all(
// Core files are mandatory: a failure here is fatal (throws → showError()).
const corePromise = Promise.all(
files.map(async (name) => {
const res = await fetch(`data/${name}.json?v=${DATA_VERSION}`);
if (!res.ok) throw new Error(`data/${name}.json — HTTP ${res.status}`);
return res.json();
}),
);
// Optional layers fetched concurrently; each defaults to empty, never fatal.
const optionalPromise = Promise.all([
loadOptional('players', []),
loadOptional('player-events', []),
loadOptional('awards', {}),
loadOptional('keeper-stats', []),
loadOptional('curiosities', []),
loadOptional('all-time-baselines', {}),
]);
const [teams, groups, matches, results, stadiums, bracketConfig] = await corePromise;
const [players, playerEvents, awards, keeperStats, curiosities, allTimeBaselines] =
await optionalPromise;
data = {
teams, groups, matches, results, stadiums, bracketConfig,
players, playerEvents, awards, keeperStats, curiosities, allTimeBaselines,
teamById: new Map(teams.map((team) => [team.id, team])),
stadiumByName: new Map(stadiums.map((s) => [s.name, s])),
resultByMatchId: new Map(results.map((r) => [r.matchId, r])),
@ -515,6 +546,18 @@ function initLangSwitch() {
sync();
}
// The header is sticky with a VARIABLE height (one row ≥1100px, two bands below).
// Expose its live height as --header-h so the stats sub-nav can stick right
// beneath it and sections can offset their scroll target at every breakpoint.
function trackHeaderHeight() {
const header = document.querySelector('.site-header');
if (!header) return;
const set = () => document.documentElement.style.setProperty('--header-h', `${header.offsetHeight}px`);
set();
if ('ResizeObserver' in window) new ResizeObserver(set).observe(header);
else window.addEventListener('resize', set);
}
function renderHome() {
renderHero();
renderDashboard();
@ -530,6 +573,7 @@ function showError(error) {
async function init() {
initI18n();
initTabs();
trackHeaderHeight();
initLangSwitch();
initTimeToggle();
initFavorites();

View file

@ -119,12 +119,25 @@ const dicts = {
'stats.tileAvg': 'Goals / match',
'stats.tileBiggestMargin': 'Biggest margin',
'stats.tileCleanSheets': 'Clean sheets',
'stats.sectionsNav': 'Statistics sections',
'stats.navOverview': 'Overview',
'stats.navTeams': 'Teams',
'stats.navPlayers': 'Players',
'stats.navRecords': 'Records',
'stats.navComparator': 'Comparator',
'stats.navArchive': 'Archive',
'stats.overviewTitle': 'Overview',
'stats.played': 'Matches played',
'stats.decisive': 'Decisive',
'stats.draws': 'Draws',
'stats.goalsByPhase': 'Goals by stage',
'stats.goalsByRound': 'Goals by round',
'stats.matchday': 'Matchday',
'stats.stageGroup': 'Group stage',
'stats.verdictTitle': 'Final verdict',
'stats.runnerUp': 'Runner-up',
'stats.thirdPlace': 'Third place',
'stats.fourthPlace': 'Fourth place',
'stats.teamStatsTitle': 'Team statistics',
'stats.colGpg': 'G/M',
'stats.colCS': 'CS',
@ -138,9 +151,26 @@ const dicts = {
'tip.pts': 'Points',
'tip.gpg': 'Goals per match (average)',
'tip.cs': 'Clean sheets (no goals conceded)',
'tip.rank': 'Final ranking — deepest stage reached, then points',
'stats.rankCol': 'Rank',
'stats.bestAttack': 'Best attack',
'stats.bestDefense': 'Best defense',
'stats.mostCleanSheets': 'Most clean sheets',
'stats.biggestWin': 'Biggest win',
'stats.winStreak': 'Longest win streak',
'stats.championPath': "Champion's path",
'stats.recordsTitle': 'Records',
'stats.highScoreMatch': 'Highest-scoring match',
'stats.formatDebutsTitle': 'Format debuts',
'stats.debutTeams': 'First 48-team World Cup',
'stats.debutMatches': 'Matches (up from 64)',
'stats.debutGroups': 'Groups',
'stats.debutR32': 'A new knockout round',
'stats.debutThird': 'Best third-placed teams advance',
'stats.debutChampion': 'First champion of the 48-team era',
'stats.comparatorTitle': 'Team comparator',
'stats.cmpTeamA': 'Team A',
'stats.cmpTeamB': 'Team B',
'stats.prevPage': 'Previous page',
'stats.nextPage': 'Next page',
'stats.seeAllMatches': 'See all matches',
@ -259,12 +289,25 @@ const dicts = {
'stats.tileAvg': 'Gols por jogo',
'stats.tileBiggestMargin': 'Maior margem',
'stats.tileCleanSheets': 'Sem sofrer gols',
'stats.sectionsNav': 'Seções de estatísticas',
'stats.navOverview': 'Visão geral',
'stats.navTeams': 'Seleções',
'stats.navPlayers': 'Jogadores',
'stats.navRecords': 'Recordes',
'stats.navComparator': 'Comparador',
'stats.navArchive': 'Arquivo',
'stats.overviewTitle': 'Visão geral',
'stats.played': 'Jogos disputados',
'stats.decisive': 'Decididas',
'stats.draws': 'Empates',
'stats.goalsByPhase': 'Gols por fase',
'stats.goalsByRound': 'Gols por rodada',
'stats.matchday': 'Rodada',
'stats.stageGroup': 'Fase de grupos',
'stats.verdictTitle': 'Veredito final',
'stats.runnerUp': 'Vice-campeão',
'stats.thirdPlace': 'Terceiro lugar',
'stats.fourthPlace': 'Quarto lugar',
'stats.teamStatsTitle': 'Estatísticas por time',
'stats.colGpg': 'G/J',
'stats.colCS': 'CS',
@ -278,9 +321,26 @@ const dicts = {
'tip.pts': 'Pontos',
'tip.gpg': 'Gols por jogo (média)',
'tip.cs': 'Clean sheets (sem sofrer gols)',
'tip.rank': 'Classificação final — fase alcançada, depois pontos',
'stats.rankCol': 'Posição',
'stats.bestAttack': 'Melhor ataque',
'stats.bestDefense': 'Melhor defesa',
'stats.mostCleanSheets': 'Mais clean sheets',
'stats.biggestWin': 'Maior goleada',
'stats.winStreak': 'Maior sequência de vitórias',
'stats.championPath': 'Caminho do campeão',
'stats.recordsTitle': 'Recordes',
'stats.highScoreMatch': 'Jogo com mais gols',
'stats.formatDebutsTitle': 'Estreias do formato',
'stats.debutTeams': 'Primeira Copa com 48 seleções',
'stats.debutMatches': 'Partidas (eram 64)',
'stats.debutGroups': 'Grupos',
'stats.debutR32': 'Uma nova fase eliminatória',
'stats.debutThird': 'Melhores terceiros avançam',
'stats.debutChampion': 'Primeiro campeão da era dos 48',
'stats.comparatorTitle': 'Comparador de times',
'stats.cmpTeamA': 'Time A',
'stats.cmpTeamB': 'Time B',
'stats.prevPage': 'Página anterior',
'stats.nextPage': 'Próxima página',
'stats.seeAllMatches': 'Ver todas as partidas',

View file

@ -6,12 +6,20 @@
// sections gate on data so player/award/editorial blocks slot in later.
import { getData, flagSrc, navigateTo } from './app.js';
import { getBracketTree } from './bracket.js';
import { getFavorites } from './storage.js';
import { openMatchModal } from './modal.js';
import { t, translatePhase } from './i18n.js';
// "Goals by stage" collapses all 12 groups into one bucket; knockout phases
// keep their own. Order used to render the chart left-to-right.
const STAGE_ORDER = ['Round of 32', 'Round of 16', 'Quarterfinals', 'Semifinals', 'Third Place', 'Final'];
// "Goals by round" is finer: the group stage is split into its 3 matchdays
// (derived per group), then each knockout round stands alone — a goals-over-time
// view distinct from goals-by-stage (which lumps all group games together).
const ROUND_ORDER = ['MD1', 'MD2', 'MD3', ...STAGE_ORDER];
// Per-team table: all 48 teams, 8 per page (6 fixed pages). Sortable columns —
// existing standings.* labels are reused for the abbreviations the user already
// knows from the Groups tab; the two new ones carry a full-name title tooltip.
@ -29,12 +37,43 @@ const COLUMNS = [
{ key: 'cleanSheets', label: 'stats.colCS', tip: 'tip.cs' },
];
// Sub-nav sections (graceful-degradation contract, stats-screen-plan.md §0.1): a
// section renders — and its sub-nav chip appears — only when `available(model)`
// holds. Otherwise it is omitted from the DOM entirely (no placeholder, no "—",
// no "coming soon") and the nav never points at emptiness. Later stages flip
// `available` and supply `body` for players/records/comparator/archive; the same
// code base thus renders a coherent, "full" screen with only today's data and
// lights up sections as each data layer arrives.
const SECTIONS = [
{ id: 'overview', navKey: 'stats.navOverview', available: () => true, body: overviewHTML },
{ id: 'teams', navKey: 'stats.navTeams', available: () => true, body: teamsSectionHTML },
{ id: 'players', navKey: 'stats.navPlayers', available: () => false, body: () => '' },
{ id: 'records', navKey: 'stats.navRecords', available: () => true, body: recordsSectionHTML },
{ id: 'comparator', navKey: 'stats.navComparator', available: (m) => m.finishedCount > 0, body: comparatorSectionHTML },
{ id: 'archive', navKey: 'stats.navArchive', available: () => false, body: () => '' },
];
// Metrics shown as diverging bars in the team comparator — all non-negative so
// the mirrored bars read cleanly (GD is excluded; it's GF/GA derived). Reuses
// the standings.* abbreviations the user already knows.
const CMP_METRICS = [
{ key: 'played', label: 'standings.played' },
{ key: 'won', label: 'standings.won' },
{ key: 'gf', label: 'standings.gf' },
{ key: 'ga', label: 'standings.ga' },
{ key: 'cleanSheets', label: 'stats.colCS' },
{ key: 'points', label: 'standings.pts' },
];
let model = null;
// table interaction state — survives langchange re-renders (default on load:
// most goals first, page 1), like the bracket keeps its zoom across re-renders.
let sortKey = 'gf';
let sortDir = 'desc';
// table interaction state — survives langchange re-renders. Default on load is
// the canonical final ranking (page 1); like the bracket keeps its zoom.
let sortKey = 'rank';
let sortDir = 'asc';
let teamPage = 0;
// comparator selection (team ids) — survives langchange like the table state
let cmpA = null;
let cmpB = null;
function stageOf(phase) {
return phase.startsWith('Group ') ? 'Group' : phase;
@ -93,6 +132,8 @@ function buildStatsModel() {
let decisive = 0;
let biggestMargin = 0;
const byStage = new Map();
const byRound = new Map();
const groupMatchday = computeGroupMatchdays(matches);
for (const m of finished) {
const r = resultByMatchId.get(m.id);
@ -105,6 +146,12 @@ function buildStatsModel() {
bucket.goals += total;
bucket.count += 1;
byStage.set(stage, bucket);
// finer round bucket: group → its matchday, knockout → the stage itself
const roundKey = m.phase.startsWith('Group ') ? `MD${groupMatchday.get(m.id)}` : stage;
const rb = byRound.get(roundKey) ?? { goals: 0, count: 0 };
rb.goals += total;
rb.count += 1;
byRound.set(roundKey, rb);
}
const agg = aggregateTeams(finished, resultByMatchId);
@ -135,6 +182,9 @@ function buildStatsModel() {
};
});
const verdict = computeVerdict();
assignRanks(teamStats);
return {
totalMatches: matches.length,
finishedCount: finished.length,
@ -145,11 +195,173 @@ function buildStatsModel() {
biggestMargin,
cleanSheets,
byStage,
byRound,
verdict,
teamStats,
leaders: computeLeaders(teamStats),
records: computeRecords(finished, resultByMatchId, verdict),
};
}
// Matchday (13) for every group match, derived per group: a 4-team group plays
// two games per matchday, so sorting a group's six fixtures by kickoff and
// chunking into pairs reproduces the official matchdays (no stored field).
function computeGroupMatchdays(matches) {
const byGroup = new Map();
for (const m of matches) {
if (!m.phase.startsWith('Group ')) continue;
if (!byGroup.has(m.phase)) byGroup.set(m.phase, []);
byGroup.get(m.phase).push(m);
}
const matchday = new Map();
for (const list of byGroup.values()) {
list.sort((a, b) => `${a.date}T${a.time}`.localeCompare(`${b.date}T${b.time}`) || a.id - b.id);
list.forEach((m, i) => matchday.set(m.id, Math.floor(i / 2) + 1));
}
return matchday;
}
// The tournament verdict — REAL results only. The bracket tree's champion can be
// a user simulation; gate on the FINAL node carrying a real finished result
// (decide() sets winner from real results first, so !simulated means it's real).
// Third/fourth come from the third-place match the same way; each is independent
// so the podium degrades gracefully if (somehow) only the final is in.
function computeVerdict() {
const tree = getBracketTree();
const finalNode = tree.nodesByRef.get('FINAL');
if (!finalNode || finalNode.simulated || finalNode.result?.status !== 'finished' || !finalNode.winner) {
return null;
}
const verdict = { champion: finalNode.winner, runnerUp: finalNode.loser };
const third = tree.third;
if (third && !third.simulated && third.result?.status === 'finished' && third.winner) {
verdict.third = third.winner;
verdict.fourth = third.loser;
}
return verdict;
}
// Canonical final ranking 148 (stats-screen-plan.md §6.5): primary key is the
// deepest stage REACHED (champion → runner-up → 3rd → 4th → QF → R16 → R32 →
// group), then points → GD → GF → id. Reproducible and stable; each team carries
// its rank, so the table can sort by any column yet still show this # identity.
const GROUP_TIER = 7;
function assignRanks(teamStats) {
const tiers = computeRankTiers();
const ranked = [...teamStats].sort((a, b) =>
(tiers.get(a.teamId) ?? GROUP_TIER) - (tiers.get(b.teamId) ?? GROUP_TIER)
|| b.points - a.points || b.gd - a.gd || b.gf - a.gf || a.teamId.localeCompare(b.teamId));
ranked.forEach((row, i) => { row.rank = i + 1; });
}
// Phase-reached tier per team, from REAL knockout results only (a simulated pick
// never affects the ranking). Champion 0, runner-up 1, 3rd 2, 4th 3, then losers
// by round (QF 4, R16 5, R32 6). Absent → group tier (7) via the default above.
function computeRankTiers() {
const tree = getBracketTree();
const tier = new Map();
const set = (id, value) => { if (id && !tier.has(id)) tier.set(id, value); };
const finalNode = tree.nodesByRef.get('FINAL');
if (finalNode && !finalNode.simulated && finalNode.result?.status === 'finished' && finalNode.winner) {
set(finalNode.winner, 0);
set(finalNode.loser, 1);
}
const third = tree.third;
if (third && !third.simulated && third.result?.status === 'finished' && third.winner) {
set(third.winner, 2);
set(third.loser, 3);
}
const roundTier = { QF: 4, R16: 5, R32: 6 };
for (const round of tree.rounds) {
const value = roundTier[round.id];
if (value === undefined) continue;
for (const node of round.nodes) {
if (!node.simulated && node.result?.status === 'finished' && node.loser) set(node.loser, value);
}
}
return tier;
}
// Auto-derived team records over finished matches. Each is null when its data
// isn't there yet, so the cards degrade away individually (§0.1).
function computeRecords(finished, resultByMatchId, verdict) {
let biggestWin = null;
for (const m of finished) {
const r = resultByMatchId.get(m.id);
const margin = Math.abs(r.homeScore - r.awayScore);
if (margin === 0) continue;
const total = r.homeScore + r.awayScore;
if (!biggestWin || margin > biggestWin.margin || (margin === biggestWin.margin && total > biggestWin.total)) {
const homeWon = r.homeScore > r.awayScore;
biggestWin = {
matchId: m.id, margin, total,
winnerId: homeWon ? m.homeTeam : m.awayTeam,
loserId: homeWon ? m.awayTeam : m.homeTeam,
score: homeWon ? `${r.homeScore}-${r.awayScore}` : `${r.awayScore}-${r.homeScore}`,
};
}
}
// longest run of consecutive wins by any team, in chronological order
const order = [...finished].sort((a, b) => `${a.date}T${a.time}`.localeCompare(`${b.date}T${b.time}`) || a.id - b.id);
const current = new Map();
let longestWinStreak = null;
for (const m of order) {
const r = resultByMatchId.get(m.id);
const homeWin = r.homeScore > r.awayScore || (r.homeScore === r.awayScore && r.penalties && r.penalties.home > r.penalties.away);
const awayWin = r.awayScore > r.homeScore || (r.homeScore === r.awayScore && r.penalties && r.penalties.away > r.penalties.home);
for (const [teamId, won] of [[m.homeTeam, homeWin], [m.awayTeam, awayWin]]) {
const run = won ? (current.get(teamId) ?? 0) + 1 : 0;
current.set(teamId, run);
if (won && (!longestWinStreak || run > longestWinStreak.count)) longestWinStreak = { teamId, count: run };
}
}
// highest-scoring match (most combined goals; tie → bigger margin)
let highestScoringMatch = null;
for (const m of finished) {
const r = resultByMatchId.get(m.id);
const total = r.homeScore + r.awayScore;
const margin = Math.abs(r.homeScore - r.awayScore);
if (!highestScoringMatch || total > highestScoringMatch.total
|| (total === highestScoringMatch.total && margin > highestScoringMatch.margin)) {
highestScoringMatch = { matchId: m.id, total, margin, homeTeam: m.homeTeam, awayTeam: m.awayTeam, score: `${r.homeScore}-${r.awayScore}` };
}
}
return {
biggestWin,
highestScoringMatch,
longestWinStreak: longestWinStreak && longestWinStreak.count >= 2 ? longestWinStreak : null,
championPath: computeChampionPath(verdict),
};
}
// The champion's knockout route (R32 → Final) with each result, for the path
// card. Null unless there's a real champion (verdict present).
function computeChampionPath(verdict) {
if (!verdict) return null;
const tree = getBracketTree();
const champ = verdict.champion;
const path = [];
for (const round of tree.rounds) {
const node = round.nodes.find((n) => n.winner === champ && (n.home.teamId === champ || n.away.teamId === champ));
if (!node || !node.result) continue;
const side = node.home.teamId === champ ? 'home' : 'away';
const r = node.result;
path.push({
matchId: node.match?.id ?? null,
phase: node.phase,
opponentId: side === 'home' ? node.away.teamId : node.home.teamId,
gf: side === 'home' ? r.homeScore : r.awayScore,
ga: side === 'home' ? r.awayScore : r.homeScore,
pens: r.penalties ? (side === 'home' ? `${r.penalties.home}-${r.penalties.away}` : `${r.penalties.away}-${r.penalties.home}`) : null,
});
}
return path.length ? path : null;
}
// Highlight leaders consider only teams that have played, so a 0-game team's
// empty record never counts as "best defense". Null before any match finishes.
function computeLeaders(teamStats) {
@ -165,49 +377,214 @@ function computeLeaders(teamStats) {
// ---------------------------------------------------------------- render
export function initStats() {
installImageFallback();
render();
// labels re-render on language change; the derived model never changes at
// runtime (data is static per page load) so it is reused.
document.addEventListener('langchange', render);
// new published results change the aggregates → rebuild the memoized model
document.addEventListener('datachange', () => { model = null; render(); });
// favorites change elsewhere (schedule/groups/modal) → re-render the table so
// the gold favorite-row highlight stays in sync (no model rebuild needed).
document.addEventListener('favchange', renderTeamTable);
}
function render() {
if (!model) model = buildStatsModel();
const root = document.getElementById('stats-root');
root.innerHTML = heroHTML() + overviewHTML() + teamsSectionHTML() + footerHTML();
const sections = SECTIONS.filter((section) => section.available(model));
root.innerHTML =
heroHTML()
+ subNavHTML(sections)
+ sections.map((section) => `
<section id="stats-${section.id}" class="stats-section" tabindex="-1" aria-label="${t(section.navKey)}">
${section.body(model)}
</section>`).join('')
+ footerHTML();
root.querySelector('#stats-see-matches')?.addEventListener('click', () => navigateTo('matches'));
const teamsHost = root.querySelector('#stats-teams-table');
if (teamsHost) {
teamsHost.addEventListener('click', onTeamTableClick);
renderTeamTable();
}
// record cards / champion-path rows that reference a match open it in the modal
for (const el of root.querySelectorAll('[data-record-match]')) {
const open = () => openMatchModal(Number(el.dataset.recordMatch));
el.addEventListener('click', open);
el.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); open(); }
});
}
// comparator selects → update the chosen side, re-render just the bars panel
const cmpAEl = root.querySelector('#cmp-a');
const cmpBEl = root.querySelector('#cmp-b');
if (cmpAEl && cmpBEl) {
cmpAEl.addEventListener('change', () => { cmpA = cmpAEl.value; refreshComparator(); });
cmpBEl.addEventListener('change', () => { cmpB = cmpBEl.value; refreshComparator(); });
}
setupCountUps(root);
setupSubNav(root, sections);
}
// ----------------------------------------------------------- sub-nav
function subNavHTML(sections) {
if (sections.length < 2) return ''; // a lone section needs no navigation
const chips = sections.map((section, i) => `
<a class="stats-subnav-chip${i === 0 ? ' active' : ''}" href="#stats-${section.id}"
data-section="${section.id}" aria-current="${i === 0 ? 'true' : 'false'}">${t(section.navKey)}</a>`).join('');
return `<nav class="stats-subnav" aria-label="${t('stats.sectionsNav')}">${chips}</nav>`;
}
let spyScrollHandler = null;
function setupSubNav(root, sections) {
const nav = root.querySelector('.stats-subnav');
if (!nav) return;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// chip → smooth-scroll to the section WITHOUT touching location.hash: the tab
// router (app.js) listens on hashchange, so a real #fragment would route to
// an unknown tab and bounce the user to Home. preventDefault keeps us in-tab.
nav.addEventListener('click', (event) => {
const chip = event.target.closest('.stats-subnav-chip');
if (!chip) return;
event.preventDefault();
document.getElementById(`stats-${chip.dataset.section}`)
?.scrollIntoView({ behavior: reduce ? 'auto' : 'smooth', block: 'start' });
setActiveChip(nav, chip.dataset.section);
});
// scrollspy: active = the last section whose heading has scrolled under the
// sticky sub-nav line; at the page bottom the last section always wins (a short
// final section may never reach the line — the classic scrollspy edge case an
// IntersectionObserver band leaves unlit). Reading getBoundingClientRect on a
// handful of sections per frame is cheap and always correct on short pages.
const ids = sections.map((section) => section.id);
const updateSpy = () => {
const headerH = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--header-h')) || 64;
const line = headerH + 80; // just beneath the sticky sub-nav
let activeId = ids[0];
for (const id of ids) {
if (document.getElementById(`stats-${id}`)?.getBoundingClientRect().top <= line) activeId = id;
}
if (window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2) {
activeId = ids[ids.length - 1]; // bottom reached → last section
}
setActiveChip(nav, activeId);
};
if (spyScrollHandler) window.removeEventListener('scroll', spyScrollHandler);
let raf = 0;
spyScrollHandler = () => {
if (raf) return;
raf = requestAnimationFrame(() => { raf = 0; updateSpy(); });
};
window.addEventListener('scroll', spyScrollHandler, { passive: true });
updateSpy();
}
function setActiveChip(nav, id) {
for (const chip of nav.querySelectorAll('.stats-subnav-chip')) {
const on = chip.dataset.section === id;
chip.classList.toggle('active', on);
chip.setAttribute('aria-current', on ? 'true' : 'false');
}
// keep the active chip visible when the nav scrolls horizontally on mobile
// (only moves the nav's own scroll, never the page).
const active = nav.querySelector('.stats-subnav-chip.active');
if (active) nav.scrollLeft = active.offsetLeft - (nav.clientWidth - active.clientWidth) / 2;
}
// ----------------------------------------------------------- flags
// Flag <img> that degrades to a 3-letter monogram if the SVG is missing — never
// a broken-image icon (graceful degradation §0.3). Used everywhere the stats
// screen shows a flag so the fallback is uniform.
function flagImg(team, w, h, cls = 'flag') {
return `<img class="${cls}" src="${flagSrc(team)}" alt="" width="${w}" height="${h}" loading="lazy" data-monogram="${team.id}">`;
}
let fallbackInstalled = false;
function installImageFallback() {
if (fallbackInstalled) return;
fallbackInstalled = true;
// error events don't bubble → listen in the capture phase. Only opted-in
// images (data-monogram) are touched, so other views are unaffected.
document.addEventListener('error', (event) => {
const img = event.target;
if (!(img instanceof HTMLImageElement) || !img.dataset.monogram) return;
const span = document.createElement('span');
span.className = 'flag-fallback';
span.style.width = `${img.getAttribute('width')}px`;
span.style.height = `${img.getAttribute('height')}px`;
span.textContent = img.dataset.monogram;
img.replaceWith(span);
}, true);
}
// The hero becomes the tournament's verdict (champion + podium) once the FINAL
// has a real result; until then it falls back to the live "in progress"
// aggregate hero, so the screen stays correct even if merged before the Cup ends.
function heroHTML() {
return model.verdict ? verdictHeroHTML() : aggregateHeroHTML();
}
function aggregateHeroHTML() {
const m = model;
const progress = t('stats.heroProgress')
.replace('{x}', String(m.finishedCount))
.replace('{y}', String(m.totalMatches));
return `
<section class="stats-hero glass slide-up">
<p class="hero-label">${t('stats.heroTitle')}<span class="hero-phase">${progress}</span></p>
<div class="stats-hero-tiles">${heroTilesHTML()}</div>
</section>`;
}
function verdictHeroHTML() {
const v = model.verdict;
const team = (id) => getData().teamById.get(id);
const champion = team(v.champion);
const places = [
{ label: t('stats.runnerUp'), rank: '2', id: v.runnerUp },
v.third ? { label: t('stats.thirdPlace'), rank: '3', id: v.third } : null,
v.fourth ? { label: t('stats.fourthPlace'), rank: '4', id: v.fourth } : null,
].filter(Boolean);
return `
<section class="stats-hero stats-verdict glass slide-up">
<p class="hero-label">${t('stats.verdictTitle')}</p>
<div class="verdict-champion">
<span class="verdict-trophy" aria-hidden="true">🏆</span>
${flagImg(champion, 92, 61, 'flag verdict-flag')}
<span class="verdict-name">${champion.name}</span>
<span class="verdict-crown">${t('bracket.champion')}</span>
</div>
<div class="verdict-podium">
${places.map((p) => `
<div class="verdict-place">
<span class="verdict-rank" aria-hidden="true">${p.rank}</span>
${flagImg(team(p.id), 36, 24)}
<span class="verdict-place-name">${team(p.id).name}</span>
<span class="verdict-place-label">${p.label}</span>
</div>`).join('')}
</div>
<div class="stats-hero-tiles">${heroTilesHTML()}</div>
</section>`;
}
function heroTilesHTML() {
const m = model;
const tiles = [
{ value: m.totalGoals, decimals: 0, label: t('stats.tileGoals') },
{ value: Number(m.avgGoals.toFixed(2)), decimals: 2, label: t('stats.tileAvg') },
{ value: m.biggestMargin, decimals: 0, label: t('stats.tileBiggestMargin') },
{ value: m.cleanSheets, decimals: 0, label: t('stats.tileCleanSheets') },
];
return `
<section class="stats-hero glass slide-up">
<p class="hero-label">${t('stats.heroTitle')}<span class="hero-phase">${progress}</span></p>
<div class="stats-hero-tiles">
${tiles.map((tile) => `
return tiles.map((tile) => `
<div class="stats-tile">
<span class="stats-tile-value" data-countup="${tile.value}" data-decimals="${tile.decimals}">${tile.decimals ? '0.00' : '0'}</span>
<span class="stats-tile-label">${tile.label}</span>
</div>`).join('')}
</div>
</section>`;
</div>`).join('');
}
function overviewHTML() {
@ -226,7 +603,8 @@ function overviewHTML() {
<span class="stat-label">${card.label}</span>
</div>`).join('')}
</div>
${goalsByStageHTML()}`;
${goalsByStageHTML()}
${goalsByRoundHTML()}`;
}
function footerHTML() {
@ -256,16 +634,226 @@ function goalsByStageHTML() {
<div class="stats-chart glass">${rows}</div>`;
}
// Finer companion to goals-by-stage: group matchdays + each knockout round.
// Hidden until ≥2 rounds have data, so it never shows a lone bar that just
// duplicates the goals-by-stage "Group" bar early in the tournament.
function goalsByRoundHTML() {
const order = ROUND_ORDER.filter((round) => model.byRound.has(round));
if (order.length < 2) return '';
const max = Math.max(...order.map((round) => model.byRound.get(round).goals));
const rows = order.map((round) => {
const bucket = model.byRound.get(round);
const pct = max ? Math.round((bucket.goals / max) * 100) : 0;
const label = round.startsWith('MD') ? `${t('stats.matchday')} ${round.slice(2)}` : translatePhase(round);
return `
<div class="chart-row">
<span class="chart-bar-label">${label}</span>
<div class="chart-track"><div class="chart-bar" style="width:${pct}%"></div></div>
<span class="chart-bar-val">${bucket.goals}</span>
</div>`;
}).join('');
return `
<h2 class="section-title">${t('stats.goalsByRound')}</h2>
<div class="stats-chart glass">${rows}</div>`;
}
// ----------------------------------------------------- team statistics
function teamsSectionHTML() {
return `
<h2 class="section-title">${t('stats.teamStatsTitle')}</h2>
${leadersHTML()}
${teamRecordsHTML()}
<div id="stats-teams-table" class="stats-teams-table"></div>
${legendHTML(COLUMNS)}`;
}
// Team-level cards in the Teams section: longest win streak + the champion's
// path (post-final). Match-level records live in the Records section. Each
// degrades away individually when its data is null.
function teamRecordsHTML() {
const rec = model.records;
const cards = [];
if (rec.longestWinStreak) cards.push(streakCardHTML(rec.longestWinStreak));
const grid = cards.length ? `<div class="stats-records-grid">${cards.join('')}</div>` : '';
return grid + (rec.championPath ? championPathHTML(rec.championPath) : '');
}
function biggestWinCardHTML(win) {
const winner = getData().teamById.get(win.winnerId);
const loser = getData().teamById.get(win.loserId);
return `
<button type="button" class="record-card glass" data-record-match="${win.matchId}"
aria-label="${t('stats.biggestWin')}: ${winner.name} ${win.score} ${loser.name}">
<span class="record-label">${t('stats.biggestWin')}</span>
<span class="record-main">
${flagImg(winner, 26, 17)}
<span class="record-score">${win.score}</span>
${flagImg(loser, 26, 17)}
</span>
<span class="record-teams">${winner.name} <span class="record-vs">${t('hero.vs')}</span> ${loser.name}</span>
</button>`;
}
function streakCardHTML(streak) {
const team = getData().teamById.get(streak.teamId);
return `
<div class="record-card glass">
<span class="record-label">${t('stats.winStreak')}</span>
<span class="record-main">
${flagImg(team, 26, 17)}
<span class="record-score">${streak.count}</span>
</span>
<span class="record-teams">${team.name}</span>
</div>`;
}
function championPathHTML(path) {
const rows = path.map((step) => {
const opp = getData().teamById.get(step.opponentId);
const pens = step.pens ? ` <small>(${t('status.pens')} ${step.pens})</small>` : '';
const clickable = step.matchId != null;
const attrs = clickable
? `data-record-match="${step.matchId}" role="button" tabindex="0" aria-label="${translatePhase(step.phase)}: ${step.gf}${step.ga} ${opp.name}"`
: '';
return `
<div class="champ-path-row${clickable ? ' clickable' : ''}" ${attrs}>
<span class="champ-path-phase">${translatePhase(step.phase)}</span>
<span class="champ-path-score">${step.gf}${step.ga}${pens}</span>
<span class="champ-path-opp">${flagImg(opp, 20, 13)} ${opp.name}</span>
</div>`;
}).join('');
return `
<div class="champ-path glass">
<span class="record-label">${t('stats.championPath')}</span>
${rows}
</div>`;
}
// ----------------------------------------------------- records section
// Match/tournament records + the "format-48 debuts" band. Match record cards
// degrade away individually; the debuts band is always meaningful (format facts),
// so this section (and its sub-nav chip) is always present.
function recordsSectionHTML() {
const rec = model.records;
const cards = [];
if (rec.biggestWin) cards.push(biggestWinCardHTML(rec.biggestWin));
// skip the high-score card when it's the very same match as the biggest win
// (early in the tournament they often coincide); they diverge as it goes on.
if (rec.highestScoringMatch && rec.highestScoringMatch.matchId !== rec.biggestWin?.matchId) {
cards.push(highScoreCardHTML(rec.highestScoringMatch));
}
const grid = cards.length ? `<div class="stats-records-grid">${cards.join('')}</div>` : '';
return `
<h2 class="section-title">${t('stats.recordsTitle')}</h2>
${grid}
${formatDebutsHTML()}`;
}
function highScoreCardHTML(rec) {
const home = getData().teamById.get(rec.homeTeam);
const away = getData().teamById.get(rec.awayTeam);
return `
<button type="button" class="record-card glass" data-record-match="${rec.matchId}"
aria-label="${t('stats.highScoreMatch')}: ${home.name} ${rec.score} ${away.name}">
<span class="record-label">${t('stats.highScoreMatch')}</span>
<span class="record-main">
${flagImg(home, 26, 17)}
<span class="record-score">${rec.score}</span>
${flagImg(away, 26, 17)}
</span>
<span class="record-teams">${home.name} <span class="record-vs">${t('hero.vs')}</span> ${away.name}</span>
</button>`;
}
// "Format debuts" band — the firsts of the 48-team era. Mostly static format
// facts (always true); the champion fact lights up once the verdict is in.
function formatDebutsHTML() {
const data = getData();
const facts = [
{ value: String(data.teams.length), label: t('stats.debutTeams') },
{ value: String(model.totalMatches), label: t('stats.debutMatches') },
{ value: String(Object.keys(data.groups).length), label: t('stats.debutGroups') },
{ value: translatePhase('Round of 32'), label: t('stats.debutR32'), small: true },
{ value: '8', label: t('stats.debutThird') },
];
if (model.verdict) {
facts.push({ value: data.teamById.get(model.verdict.champion).name, label: t('stats.debutChampion'), small: true });
}
return `
<h3 class="stats-subhead">${t('stats.formatDebutsTitle')}</h3>
<div class="debut-band glass">
${facts.map((f) => `
<div class="debut-fact">
<span class="debut-value${f.small ? ' debut-value-sm' : ''}">${f.value}</span>
<span class="debut-label">${f.label}</span>
</div>`).join('')}
</div>`;
}
// --------------------------------------------------- comparator section
// Default the two sides to the top-2 ranked teams; the choice then survives
// langchange (module-level cmpA/cmpB), like the table sort.
function ensureComparatorDefaults() {
if (cmpA && cmpB) return;
const byRank = [...model.teamStats].sort((a, b) => a.rank - b.rank);
cmpA = cmpA ?? byRank[0]?.teamId;
cmpB = cmpB ?? byRank[1]?.teamId;
}
function comparatorSectionHTML() {
ensureComparatorDefaults();
const teams = [...getData().teams].sort((a, b) => a.name.localeCompare(b.name));
const options = (selected) => teams
.map((team) => `<option value="${team.id}"${team.id === selected ? ' selected' : ''}>${team.name}</option>`).join('');
return `
<h2 class="section-title">${t('stats.comparatorTitle')}</h2>
<div class="cmp-controls">
<select class="filter-control cmp-select" id="cmp-a" aria-label="${t('stats.cmpTeamA')}">${options(cmpA)}</select>
<span class="cmp-vs">${t('hero.vs')}</span>
<select class="filter-control cmp-select" id="cmp-b" aria-label="${t('stats.cmpTeamB')}">${options(cmpB)}</select>
</div>
<div class="cmp-panel glass" id="cmp-panel">${comparatorBarsHTML()}</div>`;
}
// Diverging mirrored bars: A grows leftward from the center label, B rightward.
// Each row scales to max(a,b) so the longer bar is the higher value.
function comparatorBarsHTML() {
const byId = new Map(model.teamStats.map((row) => [row.teamId, row]));
const a = byId.get(cmpA);
const b = byId.get(cmpB);
const teamA = getData().teamById.get(cmpA);
const teamB = getData().teamById.get(cmpB);
const header = `
<div class="cmp-head">
<div class="cmp-team">${flagImg(teamA, 28, 19)} <span>${teamA.name}</span></div>
<div class="cmp-team cmp-team-b"><span>${teamB.name}</span> ${flagImg(teamB, 28, 19)}</div>
</div>`;
const rows = CMP_METRICS.map((metric) => {
const av = a[metric.key];
const bv = b[metric.key];
const max = Math.max(av, bv, 1);
return `
<div class="cmp-row">
<span class="cmp-val a${av >= bv ? ' lead' : ''}">${av}</span>
<div class="cmp-track a"><div class="cmp-bar a" style="width:${Math.round((av / max) * 100)}%"></div></div>
<span class="cmp-label">${t(metric.label)}</span>
<div class="cmp-track b"><div class="cmp-bar b" style="width:${Math.round((bv / max) * 100)}%"></div></div>
<span class="cmp-val b${bv >= av ? ' lead' : ''}">${bv}</span>
</div>`;
}).join('');
return header + rows;
}
// Re-render only the bars panel on a selection change (keeps the selects'
// focus/scroll and replays the grow animation on the new bars).
function refreshComparator() {
const panel = document.getElementById('cmp-panel');
if (panel) panel.innerHTML = comparatorBarsHTML();
}
// Compact abbreviation key — hidden on desktop (the hover tooltip covers it
// there), shown on small screens where hover doesn't fire.
function legendHTML(columns) {
@ -292,7 +880,7 @@ function leaderCardHTML({ label, row, value }) {
<div class="leader-card glass">
<span class="leader-label">${label}</span>
<div class="leader-team">
<img class="flag" src="${flagSrc(team)}" alt="" width="30" height="20" loading="lazy">
${flagImg(team, 30, 20)}
<span class="leader-name">${team.name}</span>
</div>
<span class="leader-value">${value}</span>
@ -301,11 +889,13 @@ function leaderCardHTML({ label, row, value }) {
function sortedTeamStats() {
const dir = sortDir === 'asc' ? 1 : -1;
if (sortKey === 'rank') {
return [...model.teamStats].sort((a, b) => (a.rank - b.rank) * dir);
}
return [...model.teamStats].sort((a, b) => {
const primary = (a[sortKey] - b[sortKey]) * dir;
if (primary) return primary;
// tiebreak is always GD → GF → name, independent of the sort direction
return b.gd - a.gd || b.gf - a.gf || a.teamId.localeCompare(b.teamId);
return a.rank - b.rank; // canonical rank is the stable tiebreak
});
}
@ -316,31 +906,36 @@ function renderTeamTable() {
const pages = Math.ceil(sorted.length / PAGE_SIZE);
teamPage = Math.max(0, Math.min(teamPage, pages - 1));
const start = teamPage * PAGE_SIZE;
host.innerHTML = tableHTML(sorted.slice(start, start + PAGE_SIZE), start) + paginationHTML(pages);
host.innerHTML = tableHTML(sorted.slice(start, start + PAGE_SIZE)) + paginationHTML(pages);
}
function tableHTML(rows, startIndex) {
const head = COLUMNS.map((col) => {
const active = col.key === sortKey;
const aria = active ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none';
// One sortable header cell; `aria` falls back to the visible label.
function sortHeaderHTML(key, label, tip, cls, aria = label) {
const active = key === sortKey;
const ariaSort = active ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none';
const arrow = active ? `<span class="sort-arrow" aria-hidden="true">${sortDir === 'asc' ? '▲' : '▼'}</span>` : '';
const tip = t(col.tip);
return `<th scope="col" class="col-num${active ? ' sorted' : ''}" aria-sort="${aria}">
<button type="button" class="col-sort has-tip" data-sort="${col.key}" data-tip="${tip}" aria-label="${t(col.label)} — ${tip}">${t(col.label)}${arrow}</button>
return `<th scope="col" class="${cls}${active ? ' sorted' : ''}" aria-sort="${ariaSort}">
<button type="button" class="col-sort has-tip" data-sort="${key}" data-tip="${tip}" aria-label="${aria} — ${tip}">${label}${arrow}</button>
</th>`;
}).join('');
}
const body = rows.map((row, i) => {
function tableHTML(rows) {
const rankHead = sortHeaderHTML('rank', '#', t('tip.rank'), 'col-rank', t('stats.rankCol'));
const head = COLUMNS.map((col) => sortHeaderHTML(col.key, t(col.label), t(col.tip), 'col-num')).join('');
const favs = new Set(getFavorites());
const body = rows.map((row) => {
const team = getData().teamById.get(row.teamId);
const cells = COLUMNS.map((col) => {
const value = col.key === 'gpg' ? row.gpg.toFixed(2) : col.key === 'gd' ? fmtGd(row.gd) : row[col.key];
return `<td class="col-num${col.key === sortKey ? ' sorted' : ''}">${value}</td>`;
}).join('');
const classes = [row.played === 0 ? 'row-idle' : '', favs.has(row.teamId) ? 'row-fav' : ''].filter(Boolean).join(' ');
return `
<tr class="${row.played === 0 ? 'row-idle' : ''}">
<td class="col-rank">${startIndex + i + 1}</td>
<tr class="${classes}">
<td class="col-rank${sortKey === 'rank' ? ' sorted' : ''}">${row.rank}</td>
<td class="col-team">
<img class="flag" src="${flagSrc(team)}" alt="" width="22" height="15" loading="lazy">
${flagImg(team, 22, 15)}
<span>${team.name}</span>
</td>
${cells}
@ -353,7 +948,7 @@ function tableHTML(rows, startIndex) {
<caption class="sr-only">${t('stats.teamStatsTitle')}</caption>
<thead>
<tr>
<th scope="col" class="col-rank">#</th>
${rankHead}
<th scope="col" class="col-team">${t('standings.team')}</th>
${head}
</tr>
@ -383,7 +978,7 @@ function onTeamTableClick(event) {
if (sortBtn) {
const key = sortBtn.dataset.sort;
if (key === sortKey) sortDir = sortDir === 'desc' ? 'asc' : 'desc';
else { sortKey = key; sortDir = 'desc'; }
else { sortKey = key; sortDir = key === 'rank' ? 'asc' : 'desc'; }
teamPage = 0;
renderTeamTable();
return;