mirror of
https://github.com/LucasKalil-Programador/world-2026-hub.git
synced 2026-07-04 17:41:28 -03:00
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:
commit
941a519891
8 changed files with 1220 additions and 44 deletions
|
|
@ -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 (A–F, 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 1–48 (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 A–F~~ — 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
|
## Quick final checklist
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
Navigation map of the codebase. Use this to find which file owns a concern before reading code.
|
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**).
|
> **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 A–J) is being built on **`feature/stats-final-screen`** (merges to `master` at the end of the Cup). **Stages A–D + 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 1–48 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
|
│ │ │ live data refresh (startResultsPolling: 90s poll of
|
||||||
│ │ │ results.json, no-store + ?t, content signature, pauses
|
│ │ │ results.json, no-store + ?t, content signature, pauses
|
||||||
│ │ │ when tab hidden, stops at FINAL; on change also refetches
|
│ │ │ 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
|
│ │ ├── schedule.js Match list, filters (incl. occurrence toggle
|
||||||
│ │ │ Played/Upcoming via hybrid matchState), search,
|
│ │ │ Played/Upcoming via hybrid matchState), search,
|
||||||
│ │ │ sort, "My Matches"; 60s clock-tick re-render
|
│ │ │ sort, "My Matches"; 60s clock-tick re-render
|
||||||
|
|
@ -63,8 +68,16 @@ worldcup2026/
|
||||||
│ │ ├── storage.js localStorage wrapper — wc2026_* keys, auto-JSON
|
│ │ ├── storage.js localStorage wrapper — wc2026_* keys, auto-JSON
|
||||||
│ │ ├── i18n.js EN/PT-BR dicts + t(key), lang toggle
|
│ │ ├── i18n.js EN/PT-BR dicts + t(key), lang toggle
|
||||||
│ │ ├── stats.js ★ Stats tab: tournament-to-date aggregates (finished
|
│ │ ├── stats.js ★ Stats tab: tournament-to-date aggregates (finished
|
||||||
│ │ │ matches only), hero pulse + overview + goals-by-stage.
|
│ │ │ matches only); verdict-or-aggregate hero + overview + goals-by-stage/round +
|
||||||
│ │ │ PARTIAL (during-cup) — grows into the post-cup plan.
|
│ │ │ 48-team table ranked 1–48 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, A–J).
|
||||||
│ │ └── calendar.js .ics export (RFC 5545, CRLF, Blob download)
|
│ │ └── calendar.js .ics export (RFC 5545, CRLF, Blob download)
|
||||||
│ ├── images/ Team flag SVGs, stadium placeholders
|
│ ├── images/ Team flag SVGs, stadium placeholders
|
||||||
│ └── icons/ PWA app icons (from the header trophy logo): icon.svg
|
│ └── icons/ PWA app icons (from the header trophy logo): icon.svg
|
||||||
|
|
@ -80,9 +93,13 @@ worldcup2026/
|
||||||
│ ├── results.json { matchId, homeScore, awayScore, penalties?, status } —
|
│ ├── results.json { matchId, homeScore, awayScore, penalties?, status } —
|
||||||
│ │ update as the tournament progresses
|
│ │ update as the tournament progresses
|
||||||
│ ├── stadiums.json 16 real venues: { id, name, city, capacity, image, timezone }
|
│ ├── stadiums.json 16 real venues: { id, name, city, capacity, image, timezone }
|
||||||
│ └── bracket-config.json ★ official R32 structure + thirdPlaceAssignment (all null) —
|
│ ├── bracket-config.json ★ official R32 structure + thirdPlaceAssignment (all null) —
|
||||||
│ the ONLY file to edit once real 3rd places are known
|
│ │ the ONLY file to edit once real 3rd places are known
|
||||||
│ (slot → allowed-groups table in project-memory.md)
|
│ │ (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
|
├── README.md Setup, GitHub Pages deploy, JSON maintenance guide
|
||||||
├── how-update.md Real-data migration runbook (mock → real — DONE 2026-06-12)
|
├── how-update.md Real-data migration runbook (mock → real — DONE 2026-06-12)
|
||||||
|
|
|
||||||
|
|
@ -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.
|
- **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.
|
- **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 A–J). `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 **A–F 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 (MD1–3 + R32→Final); EN↔PT relabels the verdict + round chart and survives re-render; no regression after restore.
|
||||||
|
- **Next:** Stage C — final ranking 1–48 (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 1–48 (`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 1–4 NOR) + 19 (ARG 3–0 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 7–1 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 #5–8; 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 7–1 m9 → 1 card; forcing a 6–5 → 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 (6–5) 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 A–F** (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 (A–F) + 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)
|
### How to update real-world data (scores, schedule)
|
||||||
Follow `how-refresh-data.md` (project root). In short:
|
Follow `how-refresh-data.md` (project root). In short:
|
||||||
1. Edit `data/results.json` (scores/status) or `data/matches.json` (schedule, rare).
|
1. Edit `data/results.json` (scores/status) or `data/matches.json` (schedule, rare).
|
||||||
|
|
|
||||||
|
|
@ -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.
|
- **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.
|
- **Add to calendar** — download any match as an RFC 5545 `.ics` file.
|
||||||
- **Match modal** — details for every match, with space reserved for future stats.
|
- **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
|
||||||
|
1–48 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
|
- Responsive (mobile / tablet / desktop), keyboard-accessible, honors
|
||||||
`prefers-reduced-motion`.
|
`prefers-reduced-motion`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,94 @@
|
||||||
color: var(--text-secondary);
|
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 */
|
/* ----------------------------------------------------------- overview */
|
||||||
|
|
||||||
.stats-overview-grid {
|
.stats-overview-grid {
|
||||||
|
|
@ -234,6 +322,215 @@
|
||||||
background: var(--bg-secondary);
|
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 {
|
.col-sort {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -381,6 +678,75 @@
|
||||||
background: var(--glass-bg-strong);
|
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 */
|
/* ---------------------------------------------------------- responsive */
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
|
|
|
||||||
|
|
@ -17,18 +17,49 @@ let data = null;
|
||||||
|
|
||||||
const DATA_VERSION = '2026-06-16-rev4';
|
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() {
|
export async function loadData() {
|
||||||
if (data) return data;
|
if (data) return data;
|
||||||
const files = ['teams', 'groups', 'matches', 'results', 'stadiums', 'bracket-config'];
|
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) => {
|
files.map(async (name) => {
|
||||||
const res = await fetch(`data/${name}.json?v=${DATA_VERSION}`);
|
const res = await fetch(`data/${name}.json?v=${DATA_VERSION}`);
|
||||||
if (!res.ok) throw new Error(`data/${name}.json — HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`data/${name}.json — HTTP ${res.status}`);
|
||||||
return res.json();
|
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 = {
|
data = {
|
||||||
teams, groups, matches, results, stadiums, bracketConfig,
|
teams, groups, matches, results, stadiums, bracketConfig,
|
||||||
|
players, playerEvents, awards, keeperStats, curiosities, allTimeBaselines,
|
||||||
teamById: new Map(teams.map((team) => [team.id, team])),
|
teamById: new Map(teams.map((team) => [team.id, team])),
|
||||||
stadiumByName: new Map(stadiums.map((s) => [s.name, s])),
|
stadiumByName: new Map(stadiums.map((s) => [s.name, s])),
|
||||||
resultByMatchId: new Map(results.map((r) => [r.matchId, r])),
|
resultByMatchId: new Map(results.map((r) => [r.matchId, r])),
|
||||||
|
|
@ -515,6 +546,18 @@ function initLangSwitch() {
|
||||||
sync();
|
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() {
|
function renderHome() {
|
||||||
renderHero();
|
renderHero();
|
||||||
renderDashboard();
|
renderDashboard();
|
||||||
|
|
@ -530,6 +573,7 @@ function showError(error) {
|
||||||
async function init() {
|
async function init() {
|
||||||
initI18n();
|
initI18n();
|
||||||
initTabs();
|
initTabs();
|
||||||
|
trackHeaderHeight();
|
||||||
initLangSwitch();
|
initLangSwitch();
|
||||||
initTimeToggle();
|
initTimeToggle();
|
||||||
initFavorites();
|
initFavorites();
|
||||||
|
|
|
||||||
|
|
@ -119,12 +119,25 @@ const dicts = {
|
||||||
'stats.tileAvg': 'Goals / match',
|
'stats.tileAvg': 'Goals / match',
|
||||||
'stats.tileBiggestMargin': 'Biggest margin',
|
'stats.tileBiggestMargin': 'Biggest margin',
|
||||||
'stats.tileCleanSheets': 'Clean sheets',
|
'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.overviewTitle': 'Overview',
|
||||||
'stats.played': 'Matches played',
|
'stats.played': 'Matches played',
|
||||||
'stats.decisive': 'Decisive',
|
'stats.decisive': 'Decisive',
|
||||||
'stats.draws': 'Draws',
|
'stats.draws': 'Draws',
|
||||||
'stats.goalsByPhase': 'Goals by stage',
|
'stats.goalsByPhase': 'Goals by stage',
|
||||||
|
'stats.goalsByRound': 'Goals by round',
|
||||||
|
'stats.matchday': 'Matchday',
|
||||||
'stats.stageGroup': 'Group stage',
|
'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.teamStatsTitle': 'Team statistics',
|
||||||
'stats.colGpg': 'G/M',
|
'stats.colGpg': 'G/M',
|
||||||
'stats.colCS': 'CS',
|
'stats.colCS': 'CS',
|
||||||
|
|
@ -138,9 +151,26 @@ const dicts = {
|
||||||
'tip.pts': 'Points',
|
'tip.pts': 'Points',
|
||||||
'tip.gpg': 'Goals per match (average)',
|
'tip.gpg': 'Goals per match (average)',
|
||||||
'tip.cs': 'Clean sheets (no goals conceded)',
|
'tip.cs': 'Clean sheets (no goals conceded)',
|
||||||
|
'tip.rank': 'Final ranking — deepest stage reached, then points',
|
||||||
|
'stats.rankCol': 'Rank',
|
||||||
'stats.bestAttack': 'Best attack',
|
'stats.bestAttack': 'Best attack',
|
||||||
'stats.bestDefense': 'Best defense',
|
'stats.bestDefense': 'Best defense',
|
||||||
'stats.mostCleanSheets': 'Most clean sheets',
|
'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.prevPage': 'Previous page',
|
||||||
'stats.nextPage': 'Next page',
|
'stats.nextPage': 'Next page',
|
||||||
'stats.seeAllMatches': 'See all matches',
|
'stats.seeAllMatches': 'See all matches',
|
||||||
|
|
@ -259,12 +289,25 @@ const dicts = {
|
||||||
'stats.tileAvg': 'Gols por jogo',
|
'stats.tileAvg': 'Gols por jogo',
|
||||||
'stats.tileBiggestMargin': 'Maior margem',
|
'stats.tileBiggestMargin': 'Maior margem',
|
||||||
'stats.tileCleanSheets': 'Sem sofrer gols',
|
'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.overviewTitle': 'Visão geral',
|
||||||
'stats.played': 'Jogos disputados',
|
'stats.played': 'Jogos disputados',
|
||||||
'stats.decisive': 'Decididas',
|
'stats.decisive': 'Decididas',
|
||||||
'stats.draws': 'Empates',
|
'stats.draws': 'Empates',
|
||||||
'stats.goalsByPhase': 'Gols por fase',
|
'stats.goalsByPhase': 'Gols por fase',
|
||||||
|
'stats.goalsByRound': 'Gols por rodada',
|
||||||
|
'stats.matchday': 'Rodada',
|
||||||
'stats.stageGroup': 'Fase de grupos',
|
'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.teamStatsTitle': 'Estatísticas por time',
|
||||||
'stats.colGpg': 'G/J',
|
'stats.colGpg': 'G/J',
|
||||||
'stats.colCS': 'CS',
|
'stats.colCS': 'CS',
|
||||||
|
|
@ -278,9 +321,26 @@ const dicts = {
|
||||||
'tip.pts': 'Pontos',
|
'tip.pts': 'Pontos',
|
||||||
'tip.gpg': 'Gols por jogo (média)',
|
'tip.gpg': 'Gols por jogo (média)',
|
||||||
'tip.cs': 'Clean sheets (sem sofrer gols)',
|
'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.bestAttack': 'Melhor ataque',
|
||||||
'stats.bestDefense': 'Melhor defesa',
|
'stats.bestDefense': 'Melhor defesa',
|
||||||
'stats.mostCleanSheets': 'Mais clean sheets',
|
'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.prevPage': 'Página anterior',
|
||||||
'stats.nextPage': 'Próxima página',
|
'stats.nextPage': 'Próxima página',
|
||||||
'stats.seeAllMatches': 'Ver todas as partidas',
|
'stats.seeAllMatches': 'Ver todas as partidas',
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,20 @@
|
||||||
// sections gate on data so player/award/editorial blocks slot in later.
|
// sections gate on data so player/award/editorial blocks slot in later.
|
||||||
|
|
||||||
import { getData, flagSrc, navigateTo } from './app.js';
|
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';
|
import { t, translatePhase } from './i18n.js';
|
||||||
|
|
||||||
// "Goals by stage" collapses all 12 groups into one bucket; knockout phases
|
// "Goals by stage" collapses all 12 groups into one bucket; knockout phases
|
||||||
// keep their own. Order used to render the chart left-to-right.
|
// 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'];
|
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 —
|
// 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
|
// 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.
|
// 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' },
|
{ 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;
|
let model = null;
|
||||||
// table interaction state — survives langchange re-renders (default on load:
|
// table interaction state — survives langchange re-renders. Default on load is
|
||||||
// most goals first, page 1), like the bracket keeps its zoom across re-renders.
|
// the canonical final ranking (page 1); like the bracket keeps its zoom.
|
||||||
let sortKey = 'gf';
|
let sortKey = 'rank';
|
||||||
let sortDir = 'desc';
|
let sortDir = 'asc';
|
||||||
let teamPage = 0;
|
let teamPage = 0;
|
||||||
|
// comparator selection (team ids) — survives langchange like the table state
|
||||||
|
let cmpA = null;
|
||||||
|
let cmpB = null;
|
||||||
|
|
||||||
function stageOf(phase) {
|
function stageOf(phase) {
|
||||||
return phase.startsWith('Group ') ? 'Group' : phase;
|
return phase.startsWith('Group ') ? 'Group' : phase;
|
||||||
|
|
@ -93,6 +132,8 @@ function buildStatsModel() {
|
||||||
let decisive = 0;
|
let decisive = 0;
|
||||||
let biggestMargin = 0;
|
let biggestMargin = 0;
|
||||||
const byStage = new Map();
|
const byStage = new Map();
|
||||||
|
const byRound = new Map();
|
||||||
|
const groupMatchday = computeGroupMatchdays(matches);
|
||||||
|
|
||||||
for (const m of finished) {
|
for (const m of finished) {
|
||||||
const r = resultByMatchId.get(m.id);
|
const r = resultByMatchId.get(m.id);
|
||||||
|
|
@ -105,6 +146,12 @@ function buildStatsModel() {
|
||||||
bucket.goals += total;
|
bucket.goals += total;
|
||||||
bucket.count += 1;
|
bucket.count += 1;
|
||||||
byStage.set(stage, bucket);
|
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);
|
const agg = aggregateTeams(finished, resultByMatchId);
|
||||||
|
|
@ -135,6 +182,9 @@ function buildStatsModel() {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const verdict = computeVerdict();
|
||||||
|
assignRanks(teamStats);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalMatches: matches.length,
|
totalMatches: matches.length,
|
||||||
finishedCount: finished.length,
|
finishedCount: finished.length,
|
||||||
|
|
@ -145,11 +195,173 @@ function buildStatsModel() {
|
||||||
biggestMargin,
|
biggestMargin,
|
||||||
cleanSheets,
|
cleanSheets,
|
||||||
byStage,
|
byStage,
|
||||||
|
byRound,
|
||||||
|
verdict,
|
||||||
teamStats,
|
teamStats,
|
||||||
leaders: computeLeaders(teamStats),
|
leaders: computeLeaders(teamStats),
|
||||||
|
records: computeRecords(finished, resultByMatchId, verdict),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Matchday (1–3) 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 1–48 (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
|
// 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.
|
// empty record never counts as "best defense". Null before any match finishes.
|
||||||
function computeLeaders(teamStats) {
|
function computeLeaders(teamStats) {
|
||||||
|
|
@ -165,49 +377,214 @@ function computeLeaders(teamStats) {
|
||||||
// ---------------------------------------------------------------- render
|
// ---------------------------------------------------------------- render
|
||||||
|
|
||||||
export function initStats() {
|
export function initStats() {
|
||||||
|
installImageFallback();
|
||||||
render();
|
render();
|
||||||
// labels re-render on language change; the derived model never changes at
|
// labels re-render on language change; the derived model never changes at
|
||||||
// runtime (data is static per page load) so it is reused.
|
// runtime (data is static per page load) so it is reused.
|
||||||
document.addEventListener('langchange', render);
|
document.addEventListener('langchange', render);
|
||||||
// new published results change the aggregates → rebuild the memoized model
|
// new published results change the aggregates → rebuild the memoized model
|
||||||
document.addEventListener('datachange', () => { model = null; render(); });
|
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() {
|
function render() {
|
||||||
if (!model) model = buildStatsModel();
|
if (!model) model = buildStatsModel();
|
||||||
const root = document.getElementById('stats-root');
|
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'));
|
root.querySelector('#stats-see-matches')?.addEventListener('click', () => navigateTo('matches'));
|
||||||
const teamsHost = root.querySelector('#stats-teams-table');
|
const teamsHost = root.querySelector('#stats-teams-table');
|
||||||
if (teamsHost) {
|
if (teamsHost) {
|
||||||
teamsHost.addEventListener('click', onTeamTableClick);
|
teamsHost.addEventListener('click', onTeamTableClick);
|
||||||
renderTeamTable();
|
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);
|
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() {
|
function heroHTML() {
|
||||||
|
return model.verdict ? verdictHeroHTML() : aggregateHeroHTML();
|
||||||
|
}
|
||||||
|
|
||||||
|
function aggregateHeroHTML() {
|
||||||
const m = model;
|
const m = model;
|
||||||
const progress = t('stats.heroProgress')
|
const progress = t('stats.heroProgress')
|
||||||
.replace('{x}', String(m.finishedCount))
|
.replace('{x}', String(m.finishedCount))
|
||||||
.replace('{y}', String(m.totalMatches));
|
.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 = [
|
const tiles = [
|
||||||
{ value: m.totalGoals, decimals: 0, label: t('stats.tileGoals') },
|
{ value: m.totalGoals, decimals: 0, label: t('stats.tileGoals') },
|
||||||
{ value: Number(m.avgGoals.toFixed(2)), decimals: 2, label: t('stats.tileAvg') },
|
{ value: Number(m.avgGoals.toFixed(2)), decimals: 2, label: t('stats.tileAvg') },
|
||||||
{ value: m.biggestMargin, decimals: 0, label: t('stats.tileBiggestMargin') },
|
{ value: m.biggestMargin, decimals: 0, label: t('stats.tileBiggestMargin') },
|
||||||
{ value: m.cleanSheets, decimals: 0, label: t('stats.tileCleanSheets') },
|
{ value: m.cleanSheets, decimals: 0, label: t('stats.tileCleanSheets') },
|
||||||
];
|
];
|
||||||
return `
|
return tiles.map((tile) => `
|
||||||
<section class="stats-hero glass slide-up">
|
<div class="stats-tile">
|
||||||
<p class="hero-label">${t('stats.heroTitle')}<span class="hero-phase">${progress}</span></p>
|
<span class="stats-tile-value" data-countup="${tile.value}" data-decimals="${tile.decimals}">${tile.decimals ? '0.00' : '0'}</span>
|
||||||
<div class="stats-hero-tiles">
|
<span class="stats-tile-label">${tile.label}</span>
|
||||||
${tiles.map((tile) => `
|
</div>`).join('');
|
||||||
<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>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function overviewHTML() {
|
function overviewHTML() {
|
||||||
|
|
@ -226,7 +603,8 @@ function overviewHTML() {
|
||||||
<span class="stat-label">${card.label}</span>
|
<span class="stat-label">${card.label}</span>
|
||||||
</div>`).join('')}
|
</div>`).join('')}
|
||||||
</div>
|
</div>
|
||||||
${goalsByStageHTML()}`;
|
${goalsByStageHTML()}
|
||||||
|
${goalsByRoundHTML()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function footerHTML() {
|
function footerHTML() {
|
||||||
|
|
@ -256,16 +634,226 @@ function goalsByStageHTML() {
|
||||||
<div class="stats-chart glass">${rows}</div>`;
|
<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
|
// ----------------------------------------------------- team statistics
|
||||||
|
|
||||||
function teamsSectionHTML() {
|
function teamsSectionHTML() {
|
||||||
return `
|
return `
|
||||||
<h2 class="section-title">${t('stats.teamStatsTitle')}</h2>
|
<h2 class="section-title">${t('stats.teamStatsTitle')}</h2>
|
||||||
${leadersHTML()}
|
${leadersHTML()}
|
||||||
|
${teamRecordsHTML()}
|
||||||
<div id="stats-teams-table" class="stats-teams-table"></div>
|
<div id="stats-teams-table" class="stats-teams-table"></div>
|
||||||
${legendHTML(COLUMNS)}`;
|
${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
|
// Compact abbreviation key — hidden on desktop (the hover tooltip covers it
|
||||||
// there), shown on small screens where hover doesn't fire.
|
// there), shown on small screens where hover doesn't fire.
|
||||||
function legendHTML(columns) {
|
function legendHTML(columns) {
|
||||||
|
|
@ -292,7 +880,7 @@ function leaderCardHTML({ label, row, value }) {
|
||||||
<div class="leader-card glass">
|
<div class="leader-card glass">
|
||||||
<span class="leader-label">${label}</span>
|
<span class="leader-label">${label}</span>
|
||||||
<div class="leader-team">
|
<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>
|
<span class="leader-name">${team.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="leader-value">${value}</span>
|
<span class="leader-value">${value}</span>
|
||||||
|
|
@ -301,11 +889,13 @@ function leaderCardHTML({ label, row, value }) {
|
||||||
|
|
||||||
function sortedTeamStats() {
|
function sortedTeamStats() {
|
||||||
const dir = sortDir === 'asc' ? 1 : -1;
|
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) => {
|
return [...model.teamStats].sort((a, b) => {
|
||||||
const primary = (a[sortKey] - b[sortKey]) * dir;
|
const primary = (a[sortKey] - b[sortKey]) * dir;
|
||||||
if (primary) return primary;
|
if (primary) return primary;
|
||||||
// tiebreak is always GD → GF → name, independent of the sort direction
|
return a.rank - b.rank; // canonical rank is the stable tiebreak
|
||||||
return b.gd - a.gd || b.gf - a.gf || a.teamId.localeCompare(b.teamId);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -316,31 +906,36 @@ function renderTeamTable() {
|
||||||
const pages = Math.ceil(sorted.length / PAGE_SIZE);
|
const pages = Math.ceil(sorted.length / PAGE_SIZE);
|
||||||
teamPage = Math.max(0, Math.min(teamPage, pages - 1));
|
teamPage = Math.max(0, Math.min(teamPage, pages - 1));
|
||||||
const start = teamPage * PAGE_SIZE;
|
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) {
|
// One sortable header cell; `aria` falls back to the visible label.
|
||||||
const head = COLUMNS.map((col) => {
|
function sortHeaderHTML(key, label, tip, cls, aria = label) {
|
||||||
const active = col.key === sortKey;
|
const active = key === sortKey;
|
||||||
const aria = active ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none';
|
const ariaSort = active ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none';
|
||||||
const arrow = active ? `<span class="sort-arrow" aria-hidden="true">${sortDir === 'asc' ? '▲' : '▼'}</span>` : '';
|
const arrow = active ? `<span class="sort-arrow" aria-hidden="true">${sortDir === 'asc' ? '▲' : '▼'}</span>` : '';
|
||||||
const tip = t(col.tip);
|
return `<th scope="col" class="${cls}${active ? ' sorted' : ''}" aria-sort="${ariaSort}">
|
||||||
return `<th scope="col" class="col-num${active ? ' sorted' : ''}" aria-sort="${aria}">
|
<button type="button" class="col-sort has-tip" data-sort="${key}" data-tip="${tip}" aria-label="${aria} — ${tip}">${label}${arrow}</button>
|
||||||
<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>
|
</th>`;
|
||||||
</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 team = getData().teamById.get(row.teamId);
|
||||||
const cells = COLUMNS.map((col) => {
|
const cells = COLUMNS.map((col) => {
|
||||||
const value = col.key === 'gpg' ? row.gpg.toFixed(2) : col.key === 'gd' ? fmtGd(row.gd) : row[col.key];
|
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>`;
|
return `<td class="col-num${col.key === sortKey ? ' sorted' : ''}">${value}</td>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
const classes = [row.played === 0 ? 'row-idle' : '', favs.has(row.teamId) ? 'row-fav' : ''].filter(Boolean).join(' ');
|
||||||
return `
|
return `
|
||||||
<tr class="${row.played === 0 ? 'row-idle' : ''}">
|
<tr class="${classes}">
|
||||||
<td class="col-rank">${startIndex + i + 1}</td>
|
<td class="col-rank${sortKey === 'rank' ? ' sorted' : ''}">${row.rank}</td>
|
||||||
<td class="col-team">
|
<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>
|
<span>${team.name}</span>
|
||||||
</td>
|
</td>
|
||||||
${cells}
|
${cells}
|
||||||
|
|
@ -353,7 +948,7 @@ function tableHTML(rows, startIndex) {
|
||||||
<caption class="sr-only">${t('stats.teamStatsTitle')}</caption>
|
<caption class="sr-only">${t('stats.teamStatsTitle')}</caption>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col" class="col-rank">#</th>
|
${rankHead}
|
||||||
<th scope="col" class="col-team">${t('standings.team')}</th>
|
<th scope="col" class="col-team">${t('standings.team')}</th>
|
||||||
${head}
|
${head}
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -383,7 +978,7 @@ function onTeamTableClick(event) {
|
||||||
if (sortBtn) {
|
if (sortBtn) {
|
||||||
const key = sortBtn.dataset.sort;
|
const key = sortBtn.dataset.sort;
|
||||||
if (key === sortKey) sortDir = sortDir === 'desc' ? 'asc' : 'desc';
|
if (key === sortKey) sortDir = sortDir === 'desc' ? 'asc' : 'desc';
|
||||||
else { sortKey = key; sortDir = 'desc'; }
|
else { sortKey = key; sortDir = key === 'rank' ? 'asc' : 'desc'; }
|
||||||
teamPage = 0;
|
teamPage = 0;
|
||||||
renderTeamTable();
|
renderTeamTable();
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue