feat(stats): add tournament-to-date stats tab

This commit is contained in:
Lucas Kalil 2026-06-14 21:38:01 -03:00
parent ba81e49eac
commit d5a9dadc5d
9 changed files with 1263 additions and 4 deletions

View file

@ -13,6 +13,8 @@ worldcup2026/
├── .agents/ ← Internal documentation for AI agents
│ ├── project-map.md This file
│ ├── project-memory.md Context, decisions, gotchas
│ ├── stats-screen-plan.md Plan for the post-tournament "final stats" screen
│ │ (NOT implemented — planning only, 2026-06-14)
│ └── TODO.md 12-step build checklist
├── .github/workflows/
@ -21,7 +23,7 @@ worldcup2026/
│ .gitignore OS/editor junk
├── index.html ★ SPA shell — header, nav tabs (Home, Matches,
│ Groups, Knockout, Stadiums), hero, dashboard,
│ Groups, Knockout, Stadiums, Stats), hero, dashboard,
│ modal container; loads app.js as ES module
├── assets/
@ -29,6 +31,7 @@ worldcup2026/
│ │ ├── style.css ★ Palette variables, glassmorphism base, layout,
│ │ │ components — mobile-first
│ │ ├── bracket.css Bracket columns, connectors, highlight states
│ │ ├── stats.css Stats tab: hero "pulse", overview cards, goals-by-stage chart
│ │ └── animations.css Entry (fade-in, slide-up/left) + interaction
│ │ (hover-scale/glow, pulse, line-draw)
│ ├── js/
@ -43,6 +46,9 @@ worldcup2026/
│ │ ├── modal.js Match detail modal (ARIA dialog)
│ │ ├── storage.js localStorage wrapper — wc2026_* keys, auto-JSON
│ │ ├── i18n.js EN/PT-BR dicts + t(key), lang toggle
│ │ ├── stats.js ★ Stats tab: tournament-to-date aggregates (finished
│ │ │ matches only), hero pulse + overview + goals-by-stage.
│ │ │ PARTIAL (during-cup) — grows into the post-cup plan.
│ │ └── calendar.js .ics export (RFC 5545, CRLF, Blob download)
│ └── images/ Team flag SVGs, stadium placeholders

View file

@ -272,6 +272,27 @@ Follow `how-refresh-data.md` (project root). In short:
- **`assets/js/app.js` `loadData()` appends `?v=${DATA_VERSION}`** to every `data/*.json` fetch (`DATA_VERSION` constant near the top of the file, currently `'2026-06-13-rev1'`). Fixes production browsers/Hostinger caching stale `results.json` after a daily refresh — `cache: 'reload'` only helps the developer's own browser, not real visitors.
- **Must be bumped on every data refresh** — added as step 4 of the daily routine in `how-refresh-data.md`. Format `YYYY-MM-DD-revN`; increment `revN` for same-day re-edits.
### Stats screen — planning (2026-06-14)
- **Plano completo em `.agents/stats-screen-plan.md`** (pós-Copa "tela de estatísticas finais"). Gerado via workflow de 5 sub-agentes (Times/Individuais/Partidas/Curiosidades/UX). **Nada implementado ainda** — só planejamento, aprovado pelo usuário.
- **Escopo aprovado: as 4 camadas** — ✅ dados existentes · 🟡🧩 acréscimos baratos (attendance, cartões y/r, ranking/wcDebut/confederation em teams.json, coords em stadiums.json, backfill do `stats`) · 🔴 dados de jogadores (`players.json` + log de eventos + `awards.json`) · 📝 editorial (`curiosities.json`).
- **REQUISITO DE 1ª ORDEM — degradação graciosa:** quando um dado não existir, a UI **não pode quebrar nem deixar visível ao usuário final que falta algo**. Regra: dado/seção só renderiza com cobertura completa; senão é **removido do DOM** (não placeholder/"—"/"em breve"); chips de sub-nav de seções vazias também somem; `loadData()` deve tolerar fetch de arquivo novo ausente (default vazio, não exceção).
- **Decisões técnicas planejadas:** novo 6º tab `stats``#panel-stats` + `assets/js/stats.js` + `assets/css/stats.css` (segue padrão por-view + import circular com app.js); sub-nav = `<nav>` de âncoras com scrollspy (NÃO um 2º tablist); **sem chart-lib** (SVG/CSS inline, respeita budget <300KB); render preguiçoso por seção; modelo de stats memoizado, re-render de labels no `langchange`. Roadmap AJ com portões de aprovação (AF entregam a tela com dados de hoje; G/H/I acendem camadas 2/3/4).
### Stats tab — PARCIAL durante a copa (2026-06-14, Etapa A+B)
- **Nova 6ª aba `stats`** implementada de forma incremental (plano em `C:\Users\Lucas\.claude\plans\contexto-o-planejamento-foamy-brook.md`). É a **fundação evolutiva** do plano pós-copa (`.agents/stats-screen-plan.md`) — mesmo tab/módulo; seções pós-copa "acendem" depois.
- **Arquivos novos:** `assets/js/stats.js` + `assets/css/stats.css`. Integração: `stats.css` linkado no `<head>`; botão+painel `panel-stats`/`#stats-root` no `index.html`; `'stats'` adicionado a `TABS` em `app.js`; `initStats()` chamado no `try` de `init()`; chaves `nav.stats` + `stats.*` (EN/PT) em `i18n.js`.
- **Filosofia (decidida via /grill-me):** agregados correntes "até agora", **só `status==='finished'`** (consistente com `computeStandings`); "X de 104" é moldura, não lacuna. `stats` opcional (posse/chutes/cartões) entra com **gating por-jogo** (`aggregateTeams` ignora jogo sem o campo). Sem polling — recomputa no load + re-render de labels no `langchange` (modelo memoizado em `let model`).
- **Implementado nesta etapa (A+B):** `aggregateTeams()` (agregação torneio-wide, grupo+mata-mata, própria — `computeStandings` é só por-grupo); hero "pulso" com tiles count-up (IntersectionObserver dispara a animação quando o painel abre, reduced-motion-safe); Overview (jogos/decididas/empates) + chart "gols por fase" (só fases com ≥1 jogo — sem barras zeradas de R32+); link "ver partidas" → `navigateTo('matches')`. Verificado: 27 gols/3.00 média/margem 6/3 clean sheets, EN↔PT, mobile 2×2, console limpo.
- **Seção "Estatísticas por time" (2026-06-14, +/grill-me):** tabela paginada das **48 seleções, 6 páginas fixas de 8** (`PAGE_SIZE`/`COLUMNS` no topo de `stats.js`). 10 colunas ordenáveis (J/V/E/D/GP/GC/SG/Pts/G·J/CS; rótulos curtos reusam `standings.*`, os 2 novos têm `title`). Clique no header faz **toggle desc↔asc** (seta + `aria-sort`), default **GP desc** ao abrir; desempate fixo SG→GP→nome; trocar coluna ou re-ordenar volta à pág. 1. Coluna # = **rank global** 148. Times sem jogo = zeros reais (não lacuna), caem ao fim. Faixa **top-3 leaders** (melhor ataque=GP / defesa=GC entre quem jogou / mais clean sheets) acima — `computeLeaders` só considera `played>0`. Estado `sortKey`/`sortDir`/`teamPage` é módulo-level e **sobrevive ao langchange** (como o zoom do bracket); ordenação/paginação re-renderizam só `#stats-teams-table` (não re-disparam count-ups). Mobile: `.stats-table-wrap` scroll-x com `#`+`Seleção` `position:sticky` (left 0 / 2.5rem). Verificado: sort/toggle/troca-de-coluna/paginação/EN↔PT/mobile-sticky, console limpo.
- **Falta (etapas DG, aguardando aprovação):** líderes posse/chutes/disciplina; recordes auto (maior goleada→modal); comparador time-vs-time; polimento. **Sem** arquivo de resultados (linka p/ Matches). `penalties` ainda não existe em results.json → card de pênaltis só renderiza quando ≥1 disputa ocorrer.
### Tooltips de header + legenda (2026-06-14, +/grill-me)
- **Tooltips nas siglas das tabelas** (Stats "Estatísticas por time" **e** as 12 tabelas de Grupos). Decidido via /grill-me: tooltip custom glass (não `title` nativo), **nome + definição onde ajuda**, e **legenda compacta só no mobile**.
- **`initTooltips()` em `app.js`** (chamado no `init()`): um único balão `.app-tooltip` `position:fixed`, via **delegação de eventos** em `document` (`mouseover`/`focusin` mostram, `mouseout`/`focusout`/`scroll` escondem). Sobrevive a re-renders das tabelas e **nunca é cortado** por overflow/stacking (motivo de não usar `::after` dentro do `.stats-table-wrap` que tem `overflow-x:auto`). Centraliza sobre o elemento, faz clamp na viewport e **flip pra baixo** se não couber acima.
- **Como dar tooltip a um header:** adicionar classe `has-tip` + `data-tip="<texto>"` + `aria-label="<sigla> — <texto>"` (o aria-label cobre leitor de tela, já que o balão é visual). Textos em `i18n.js` no namespace **`tip.*`** (EN/PT), reusados pelas duas tabelas (`tip.played/won/drawn/lost/gf/ga/gd/pts/gpg/cs`).
- **Legenda mobile:** `<p class="stats-legend">` com pares `<b>sigla</b> = texto``display:none` no desktop, `flex` em `≤600px` (estilo em `stats.css`). Uma por tabela: `legendHTML()` em `stats.js` (usa `COLUMNS`) e em `groups.js` (lista fixa das 8 colunas). Cobre o touch, onde o hover não dispara.
- CSS do tooltip/legenda vive em `stats.css` (carregado global, então `.has-tip`/`.app-tooltip`/`.stats-legend` valem também na aba Grupos).
### How to add a UI label
1. Add the key to both `en` and `pt` dicts in `assets/js/i18n.js`.
2. Use `t("key")` at the render site — never hardcode the string.

View file

@ -0,0 +1,264 @@
# Plano de Implementação — Tela de Estatísticas Finais (World Cup 2026 Hub)
> Documento de planejamento (NÃO é implementação). Gerado em 2026-06-14 após workflow multi-agente
> (5 perspectivas) + aprovação da lista de dados pelo usuário. Escopo aprovado: **as 4 camadas**
> (✅ existentes · 🟡🧩 acréscimos baratos · 🔴 dados de jogadores · 📝 editorial).
>
> **PRINCÍPIO REITOR (requisito explícito do usuário):** *degradação graciosa*. Quando um dado não
> puder ser obtido, o tratamento deve ser elegante — **não pode quebrar a UI nem deixar visível para o
> usuário final que algo está faltando**. Sem `—`, sem cards vazios, sem "dados em breve". A regra é:
> **um dado/seção só aparece quando está completo o bastante para ser apresentado como autoritativo;
> caso contrário é removido do DOM** (não escondido com placeholder, *removido*). Ver §0 e §6.
---
## 0 · Camada de degradação graciosa (a espinha dorsal de todo o resto)
Esta é a peça arquitetural mais importante do plano. Tudo abaixo se apoia nela.
### 0.1 Contrato de disponibilidade
Cada estatística, card, linha de tabela e sub-seção declara um predicado `isAvailable(model)`:
- **Sub-seção inteira sem dados** → a seção **não é renderizada** e seu chip no sub-nav **é removido**
(navegação nunca aponta para o vazio).
- **Card/recorde individual sem dados** → o card não é inserido; o grid reflui naturalmente.
- **Célula opcional numa linha** (ex.: xG de um time sem feed) → a coluna some para todos OU a célula
cai para um valor neutro só se isso não denunciar ausência. Preferência: **a coluna inteira só existe
se todos os times tiverem o dado.**
- **Agregado sobre `stats` esparso** (posse/chutes/cartões) → só renderiza com **cobertura completa**
(todos os jogos da amostra relevante preenchidos). Sem cobertura → **escondido, sem disclaimer**.
(Decisão: o usuário NÃO quer "baseado em N jogos" visível. Ou faz backfill total, ou não mostra.)
### 0.2 Carga de dados tolerante a falha
`loadData()` em `app.js` passa a buscar os novos JSON; cada fetch novo é **opcional**:
arquivo ausente / 404 / JSON inválido → `console.warn` (dev) + **default vazio** (`[]`/`{}`),
**nunca** exceção que derrube o app. Os 6 arquivos atuais continuam obrigatórios.
### 0.3 Mídia com fallback
Fotos de jogadores / bandeiras quebradas → `onerror` cai para iniciais (monograma) ou silhueta
genérica. Nunca ícone de imagem quebrada.
### 0.4 Camadas se auto-desligam
- Sem `players.json`/eventos → **toda** a seção Jogadores + Prêmios + comparador de jogadores +
recordes de tempo de gol somem (chips inclusive). A tela continua completa só com dados de time.
- Sem `curiosities.json` → Recordes mostra só os auto-deriváveis; nada de "em breve".
- Sem `attendance` → recordes de público somem; o resto do Overview fica intacto.
Resultado: a mesma base de código entrega uma tela coerente e "cheia" com **só os dados existentes
hoje**, e vai "acendendo" seções conforme os dados das camadas 2/3/4 forem entrando — sem nenhuma
data de corte nem buraco visível.
---
## 1 · Arquitetura da tela
### 1.1 Estrutura de componentes (segue o padrão por-view existente)
```
index.html
├─ nav.tabs + <button data-tab="stats" ...> (6º tab, após Stadiums)
└─ <section id="panel-stats" class="panel" role="tabpanel" hidden>
<h2 class="section-title" data-i18n="nav.stats"></h2>
<div id="stats-root"><p class="placeholder glass" data-i18n="app.comingSoon"></p></div>
assets/js/stats.js (NOVO módulo — espelha schedule.js/groups.js/bracket.js)
- initStats() chamado por app.js (mesmo padrão de import circular intencional)
- buildStatsModel(data) deriva TODO o modelo computável 1x, memoizado
- renderStats() / renderSection(id) render preguiçoso por seção (IntersectionObserver)
- escuta langchange (re-render só de labels) / favchange / timemodechange
assets/css/stats.css (NOVO — como bracket.css; componentes da tela de stats)
```
### 1.2 Rotas
- Hash route `#stats` + persistência `wc2026_prefs.lastTab`**de graça** (roteamento por hash já existe).
- Sub-navegação interna **não** é rota nem segundo `tablist`: é uma `<nav>` de âncoras (`#stats-teams`…)
com *scrollspy* via IntersectionObserver (evita conflito de setas com o `tablist` do topo — ver §3).
- Opcional (nice-to-have): deep-link `#stats=players` que faz scroll até a seção ao carregar.
### 1.3 Estado global
- **Nenhum estado persistente novo é obrigatório.** O modelo de stats é derivado de `getData()` e
**memoizado em memória** no módulo (`let statsModel`); recalcula só se os dados mudarem (não mudam
em runtime). `langchange` re-renderiza labels, **não** recomputa valores (nomes não se traduzem).
- Reusa estado existente: `wc2026_favorites` (destaque dourado em linhas/cards do time favorito),
`wc2026_prefs.timeMode` (arquivo de resultados + modal).
- Opcional: `wc2026_prefs.statsSort` p/ lembrar a última ordenação de tabela (baixa prioridade).
### 1.4 Integração com o existente (reuso, não reinvenção)
- `openMatchModal(matchId)` → cards de recorde, linhas do arquivo e qualquer linha ligada a um jogo.
- `resolveBracketTeams`, `computeStandings`, `getBracketTree`, `calculateChallengeScore` → reusados
para ranking, caminho do campeão, fase alcançada.
- `t(key)`, eventos `langchange`/`favchange`/`timemodechange`, `.glass`, `.slide-up`, `.container`.
- `DATA_VERSION` (cache-busting) **deve ser bumpado** quando os novos `data/*.json` entrarem no
`Promise.all` de `loadData()`.
---
## 2 · Fontes de dados
### 2.1 Já existe (Camada 1 — ~70% da tela, zero coleta)
`teams.json`, `groups.json`, `matches.json`, `results.json` (placar/status/penalties + `stats`
opcional), `stadiums.json` (capacidade, timezone), `bracket-config.json`. Computados: standings,
bracket, challenge.
### 2.2 Precisa coletar/calcular
| Camada | Arquivo / campo novo | Schema | Destrava | Esforço |
|---|---|---|---|---|
| 🟡 2 | `results.json``attendance` | inteiro por jogo | público total/médio/ocupação, maior público | dado público FIFA |
| 🟡 2 | `results.json``cards: {home:{y,r}, away:{y,r}}` | split amarelo/vermelho | disciplina completa, fair-play | reformatar campo atual |
| 🟡 2 | `results.json``decidedIn` | `"regulation"\|"ET"\|"penalties"` | separar prorrogação de pênaltis | trivial |
| 🟡 2 | `results.json` → backfill `stats` nos 104 | posse/chutes existentes | posse/chutes/conversão como agregado confiável | médio (104 jogos) |
| 🟡 2 | `results.json``shotsOnTarget`, `passes`, `passAccuracy` (opc.) | nível time | chutes no alvo, passes | opcional |
| 🧩 2 | `teams.json``ranking`/`seed`, `wcDebut`, `confederation` | por time | zebras, Cinderela, estreantes, desempenho por confederação | trivial 1x |
| 🧩 2 | `stadiums.json``lat`,`lng` | por estádio | distância total percorrida | trivial 1x |
| 🔴 3 | `players.json` | roster mínimo `{id,name,team,position,birthDate,shirt?}` — só envolvidos | base de todos os individuais | médio |
| 🔴 3 | `player-events.json` | log append-only `{type:goal\|card\|ownGoal, player, team, matchId, minute, goalType?, assist?, card?}` | artilharia, hat-tricks, tempo de gol, disciplina | **alto, ~400+ linhas** |
| 🔴 3 | `awards.json` | `{goldenBall, goldenBoot, goldenGlove, bestYoung, squadOfTournament[], goalOfTournament}` | prêmios FIFA + Seleção do Torneio | trivial (1x pós-final) |
| 🔴 3 | (opc.) `keeper-stats.json` | defesas/clean-sheets por goleiro | Luva de Ouro detalhada | opcional |
| 📝 4 | `curiosities.json` | array de cards `{id,type,priority,titleEN/PT,bodyEN/PT,matchId?,teamId?,mediaRef?,statRef?}` | gol mais bonito, VAR, histórias | redação bilíngue |
| 📝 4 | `all-time-baselines.json` (ou embutido) | recordes históricos fixos p/ comparação | painel "esta Copa vs história" | trivial 1x |
> **Decisão de modelagem (jogadores):** usar **roster + log de eventos** (não agregados pré-somados).
> O log deriva tudo (artilharia, assistências, hat-tricks, gol mais rápido/jovem/tardio) com o mesmo
> padrão do projeto (results.json é por jogo; o código computa o resto). Minutos jogados e defesas de
> goleiro — caros por evento — ficam como **agregado opcional** (`keeper-stats.json`) ou são omitidos.
> Entrada **incremental por rodada** (encaixa no fluxo diário `/update-worldcup`), não num lote único.
### 2.3 Fora de escopo v1
- **xG**: não existe feed; exige provedor externo. Slot de UI previsto, **escondido** até haver dado.
- Distância percorrida por jogador, dribles, sprints: custo de manutenção alto demais para site estático.
---
## 3 · UI/UX — Proposta de navegação (wireframe textual)
**Colocação:** novo 6º tab `Stats` / `Estatísticas`, último (é o epílogo do torneio). Reusa o
`tablist` WAI-ARIA existente (roving tabindex, Setas/Home/End) — zero paradigma novo.
**Sub-navegação:** barra de chips *sticky* (scrollspy) abaixo do hero — **`<nav>` de âncoras, não um
segundo tablist** (evita conflito de Setas com o tab do topo; é também o gatilho do render preguiçoso).
Chips: **Overview · Times · Jogadores · Recordes · Comparador · Arquivo** (chips de seções vazias somem).
```
┌─ #panel-stats ───────────────────────────────────────────────────────────┐
│ ╔═ HERO "o veredito" (glass, slide-up) ════════════════════════════════╗ │
│ ║ 🏆 CAMPEÃO [bandeira] vice · 3º · Chuteira de Ouro · Seleção ║ │
│ ║ [ 172 ] [ 2.68 ] [ 31 ] [ 3.1M ] ← tiles count-up ║ │
│ ║ Gols Gols/jogo Pênaltis Público ║ │
│ ╚═══════════════════════════════════════════════════════════════════════╝ │
│ ┌─ SUB-NAV sticky (scrollspy) ● Overview Times Jogadores … Arquivo ───┐ │
│ ═══ #stats-overview ═══ cards (Partidas/Média/Cartões/Clean sheets) + │
│ CHART "gols por fase" (barras SVG, reveal) + "gols por rodada" │
│ ═══ #stats-teams ═══ filtros[confederação▾ fase▾ ⌕] + │
│ LEADERBOARD ordenável (#, time, P W E D, GF GA GD, Pts, [xG]) + │
│ cards: maior goleada, caminho do campeão, forma V/E/D │
│ ═══ #stats-players ═══ PÓDIO top-3 (Chuteira de Ouro, count-up) + │
│ chips[Artilheiros·Assist·Cartões·Defesas] → troca corpo da tabela +│
│ bloco PRÊMIOS + SELEÇÃO DO TORNEIO (gráfico de formação) │
│ ═══ #stats-records ═══ grid de record-cards (auto) + faixa "ESTREIAS DO │
│ FORMATO 48" (destaque) + cards editoriais (curiosities.json) │
│ ═══ #stats-comparator ═══ [A▾] vs [B▾] toggle Times/Jogadores + │
│ barras divergentes espelhadas (anima na escolha) │
│ ═══ #stats-archive ═══ 104 resultados, accordion por fase, filtros/sort, │
│ linha → openMatchModal() (preguiçoso, por último) │
└───────────────────────────────────────────────────────────────────────────┘
```
Acima da dobra (desktop): hero veredito + 4 tiles + sub-nav. O resto é recompensa de scroll.
**Componentes reutilizáveis:** `stat-card` (tile count-up), `leaderboard-table` (ordenável, a11y,
linha favorita dourada), `podium` (top-3), `record-card` (clica → modal), `chart-panel` (SVG/CSS +
`<details>` tabela alternativa), `comparator`, `filter-bar`, `section-nav` (scrollspy),
`results-archive`, `chip-tabs` (toggle intra-seção via `aria-pressed`, não tablist).
**Interações/animações** (todas atrás de `prefers-reduced-motion`):
count-up dos números (IntersectionObserver, ~900ms ease-out), barras crescendo da base,
linha com `stroke-dashoffset`, FLIP no re-sort, duelo de barras no comparador, reveal por seção.
**Responsividade:** ≤767 hero empilha + tiles 2×2, sub-nav scroll horizontal com snap, tabelas com
rank+nome congelados e scroll-x numa região focável; 7681439 grids 2-col; 1440+ `.container`
(`min(1200px,100%-2rem)`), grid 4-col, hero numa linha.
**Acessibilidade:** `<table>` real com `<caption>`/`scope`/`aria-sort` (header ordenável = `<button>`);
sub-nav é `<nav>` (não tablist); charts com alternativa textual (`<details>` tabela + `aria-label`);
count-up: valor final é o DOM real, tween só visual (`aria-live="off"`); foco visível via
`:focus-visible` existente.
---
## 4 · Stack e dependências
**Princípio:** zero dependência nova de runtime. Mantém o mandato do projeto (vanilla ES Modules, sem
framework/bundler/CDN) e o orçamento de **JS < 300KB (hoje ~74KB)**.
- **Gráficos: SEM biblioteca.** Barras/linhas/donut feitos à mão em **SVG inline + CSS** (casa com a
estética e com o orçamento). Se um gráfico realmente exigir mais, `import()` dinâmico de micro-lib
(<poucos KB) quando a seção entra na viewport **postura padrão: peso zero de chart-lib.**
- **Animação:** reusa `animations.css` + IntersectionObserver; helper `countUp()` em vanilla
(`requestAnimationFrame`). Tudo desligado em `prefers-reduced-motion`.
- **i18n:** novo namespace `stats.*` em `i18n.js` (dicts EN **e** PT). Nomes de dados não se traduzem.
- **CSS:** novo `assets/css/stats.css` (linkado no `<head>` como `bracket.css`), usando os tokens
existentes (`--accent-gold`, `--glass-bg`, `--radius`, etc.).
- **Tooling opcional (não-shippado):** script Node sem deps para gerar/validar agregados de jogadores
a partir do log de eventos — facilita a entrada manual. Fora do bundle do site.
---
## 5 · Roadmap de implementação (incremental, com portões de aprovação)
> Convenção do projeto: 1 etapa por vez, resumo + aprovação antes da próxima. Esforço: **S** ≈ meia
> sessão · **M** ≈ 1 sessão · **L** ≈ ~2 sessões. Etapas AF entregam uma tela completa só com dados
> existentes; GI acendem as camadas 2/3/4; J é polimento.
| # | Etapa | Entrega | Camada | Esforço |
|---|---|---|---|---|
| **A** | **Scaffolding + motor de degradação** | 6º tab, `#panel-stats`, `stats.js`, `stats.css`, namespace `stats.*`, render preguiçoso, scrollspy, `loadData()` tolerante a falha, contrato `isAvailable` (§0) | infra | **M** |
| **B** | **Overview + Hero** | veredito (campeão/vice/3º/4º), 4 tiles count-up, gráfico gols-por-fase e gols-por-rodada | ✅1 | **M** |
| **C** | **Estatísticas de times** | `leaderboard-table` ordenável+a11y, **ranking final 148 (cadeia de desempate)**, cards (maior goleada, caminho do campeão, forma, splits, clean sheets, sequências) | ✅1 | **L** |
| **D** | **Recordes + Estreias do formato 48** | record-cards auto-deriváveis + faixa de destaque "104 jogos / maior caminho à final / Round of 32 / melhor 3º / 1º campeão da nova era" | ✅1 | **M** |
| **E** | **Arquivo de resultados** | 104 jogos navegáveis, filtros/sort, linha → modal (reusa padrões de `schedule.js`) | ✅1 | **M** |
| **F** | **Comparador de times** | seletor A vs B + barras divergentes animadas | ✅1 | **M** |
| **G** | **Camada 2 — dados baratos** | estende `results.json` (attendance, cards y/r, decidedIn), `teams.json` (ranking/wcDebut/confederation), `stadiums.json` (coords); backfill `stats`; liga recordes de público, disciplina, zebras, distância | 🟡🧩2 | **M** + entrada de dados |
| **H** | **Camada 3 — jogadores** | `players.json`+`player-events.json`+`awards.json`; pódio artilharia, chips (assist/cartões/defesas), bloco de prêmios, **Seleção do Torneio** (formação), comparador de jogadores, recordes de tempo de gol | 🔴3 | **L** (maior) + entrada contínua |
| **I** | **Camada 4 — editorial** | `curiosities.json` + `all-time-baselines.json`; render de cards editoriais + painel "esta Copa vs história" | 📝4 | **M** + redação |
| **J** | **Polimento** | auditoria responsiva/a11y, performance (lazy, sem blur em cards repetidos), Lighthouse, bump `DATA_VERSION`, README + i18n review | todas | **M** |
Caminho mínimo para uma tela publicável e bonita: **A→B→C→D→E→F** (tudo com dados de hoje).
G/H/I são aditivos e podem entrar em qualquer ordem conforme os dados aparecem (graças a §0).
---
## 6 · Pontos de atenção (edge cases, dados incompletos, fallbacks)
1. **Torneio ainda não acabou (hoje 2026-06-14).** A tela é pós-Copa. Campeão/ranking só existem após
a final → o **hero veredito não renderiza** enquanto `FINAL` não estiver `finished`. Opção: manter o
tab **oculto** até a final terminar, ou deixá-lo "acender" progressivamente. Decidir antes da Etapa B.
2. **`stats` esparso (hoje ~9/104).** Agregados de posse/chutes/cartões só aparecem com **cobertura
completa**; senão ficam escondidos (sem disclaimer — requisito do usuário). Backfill é a Etapa G.
3. **`cards` é só amarelo hoje.** Índice de disciplina/fair-play e "time mais faltoso" ficam
incompletos até o split y/r (Etapa G). Antes disso: rotular como "amarelos" ou esconder.
4. **Convenção de V/E/D no mata-mata.** Empate decidido nos pênaltis conta como empate (para gols) mas
vitória/derrota (para avanço). Definir e documentar na camada de dados antes de somar retrospectos.
5. **Desempate do ranking 148.** Cadeia determinística explícita: fase alcançada → pontos → SG → GP →
(fallback id). Documentar; sem isso o ranking não é reproduzível.
6. **Burden e confiabilidade dos dados de jogador.** ~400+ linhas manuais; risco de typo/evento
perdido. Usar **uma fonte autoritativa** (site oficial FIFA), entrada incremental por rodada. UI
esconde seções/linhas sem dado (§0) — nunca mostra lacuna.
7. **Editorial bilíngue.** Todo card de `curiosities.json` precisa EN **e** PT; se faltar um idioma,
*fallback* para o outro (não renderiza em branco).
8. **Performance.** Render preguiçoso por seção; **sem `backdrop-filter` em cards repetidos** (custo de
paint — regra já vigente no projeto); charts em SVG/CSS sem lib; memoizar o modelo.
9. **Cache-busting & deploy.** Bumpar `DATA_VERSION` quando novos `data/*.json` entrarem; novos
arquivos em `data/` **são** deployados (bom); `.agents/` (este plano) é excluído (bom). Paths
relativos para fotos de jogador (gotcha #7 — subpath Hostinger/Pages).
10. **Não quebrar o existente.** Manter o padrão de import circular com `app.js`; novo tab não pode
alterar o contrato do `tablist`/roteamento atual.
11. **Mídia ausente.** Fotos de jogador/bandeiras → `onerror` para monograma/silhueta; nunca imagem
quebrada.
12. **xG sem feed.** Coluna/seção xG só existe se todos os times tiverem o dado; senão, removida (não
mostrar coluna pela metade).
---
## Apêndice — proveniência
Consolidado de 5 sub-agentes (Times, Individuais, Partidas, Curiosidades, UX). Lista completa de
métricas com tags de disponibilidade está no histórico da sessão de 2026-06-14 (Fase 1). Escopo
aprovado: 4 camadas + degradação graciosa como requisito de primeira ordem.

394
assets/css/stats.css Normal file
View file

@ -0,0 +1,394 @@
/* stats.css "Stats" tab: tournament-to-date hero, overview cards and the
goals-by-stage chart. Reuses the global tokens and the .stat-card pattern.
Repeated tiles use a flat translucent fill (no backdrop-filter) per the
project's perf rule; blur is reserved for the few large .glass surfaces. */
/* --------------------------------------------------------- hero "pulse" */
.stats-hero {
padding: clamp(1.4rem, 4vw, 2.4rem);
text-align: center;
}
.stats-hero .hero-label {
flex-direction: column;
gap: 0.4rem;
margin-bottom: 1.5rem;
}
.stats-hero-tiles {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.85rem;
}
.stats-tile {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.35rem;
padding: 1rem 0.6rem;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
}
.stats-tile-value {
font-size: clamp(1.5rem, 4.5vw, 2.1rem);
font-weight: 700;
color: var(--accent-gold-soft);
font-variant-numeric: tabular-nums;
line-height: 1;
}
.stats-tile-label {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-secondary);
}
/* ----------------------------------------------------------- overview */
.stats-overview-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
}
.stats-overview-grid .stat-value {
display: inline-flex;
align-items: baseline;
gap: 0.3rem;
}
.stat-sub {
font-size: 1rem;
font-weight: 400;
color: var(--text-secondary);
}
/* ------------------------------------------------- goals-by-stage chart */
.stats-chart {
display: grid;
gap: 0.85rem;
padding: 1.25rem 1.4rem;
}
.chart-row {
display: grid;
grid-template-columns: clamp(86px, 22vw, 150px) 1fr auto;
align-items: center;
gap: 0.8rem;
}
.chart-bar-label {
font-size: 0.85rem;
color: var(--text-secondary);
}
.chart-track {
height: 14px;
background: var(--glass-bg-strong);
border-radius: 999px;
overflow: hidden;
}
.chart-bar {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, var(--accent-blue), var(--accent-gold));
transform-origin: left center;
animation: stats-bar-grow 0.6s ease both;
}
@keyframes stats-bar-grow {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
.chart-bar-val {
font-size: 0.9rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
min-width: 1.5ch;
text-align: right;
}
/* ------------------------------------------------- team statistics */
.stats-leaders {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
margin-bottom: 1.25rem;
}
.leader-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1.1rem 1rem;
text-align: center;
}
.leader-label {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--accent-gold);
font-weight: 600;
}
.leader-team {
display: flex;
align-items: center;
gap: 0.5rem;
}
.leader-name {
font-size: 1rem;
font-weight: 600;
}
.leader-value {
font-size: 1.6rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--accent-gold-soft);
}
/* horizontal scroll on narrow screens; rank + team columns stay frozen */
.stats-table-wrap {
overflow-x: auto;
border-radius: var(--radius);
border: 1px solid var(--glass-border);
-webkit-overflow-scrolling: touch;
}
.stats-table {
width: 100%;
border-collapse: collapse;
font-size: 0.88rem;
background: var(--glass-bg);
}
.stats-table th,
.stats-table td {
padding: 0.6rem 0.7rem;
text-align: right;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.stats-table thead th {
position: sticky;
top: 0;
background: var(--bg-secondary);
border-bottom: 1px solid var(--glass-border);
font-weight: 600;
color: var(--text-secondary);
}
.stats-table tbody tr {
border-top: 1px solid rgba(255, 255, 255, 0.05);
}
.stats-table tbody tr:hover {
background: var(--glass-bg-strong);
}
.col-rank {
text-align: center;
color: var(--text-secondary);
width: 2.5rem;
}
.col-team {
text-align: left;
display: flex;
align-items: center;
gap: 0.5rem;
}
/* freeze rank + team to the left while metric columns scroll under them */
.stats-table .col-rank,
.stats-table .col-team {
position: sticky;
background: var(--bg-secondary);
z-index: 1;
}
.stats-table .col-rank { left: 0; }
.stats-table .col-team { left: 2.5rem; }
.stats-table tbody .col-rank,
.stats-table tbody .col-team {
background: var(--bg-primary);
}
.stats-table tbody tr:hover .col-rank,
.stats-table tbody tr:hover .col-team {
background: var(--bg-secondary);
}
.col-sort {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0;
color: inherit;
font: inherit;
font-weight: 600;
}
.col-sort:hover {
color: var(--text-primary);
}
.stats-table th.sorted,
.stats-table td.sorted {
color: var(--accent-gold-soft);
}
.sort-arrow {
font-size: 0.7rem;
}
.row-idle td {
color: var(--text-secondary);
opacity: 0.7;
}
.stats-pagination {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 1rem;
}
.page-btn {
min-width: 2.2rem;
padding: 0.4rem 0.6rem;
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
transition: color 0.2s, border-color 0.2s, background-color 0.2s;
}
.page-btn:hover:not(:disabled) {
color: var(--text-primary);
border-color: var(--accent-gold);
}
.page-btn.active {
color: var(--bg-primary);
background: linear-gradient(135deg, var(--accent-gold), var(--accent-gold-soft));
border-color: transparent;
font-weight: 600;
}
.page-btn:disabled {
opacity: 0.35;
cursor: default;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* ---------------------------------------------- header tooltips + key */
.has-tip { cursor: help; }
.col-sort.has-tip { cursor: pointer; }
.app-tooltip {
position: fixed;
z-index: 1000;
max-width: 220px;
padding: 0.45rem 0.65rem;
background: var(--bg-secondary);
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-soft);
color: var(--text-primary);
font-size: 0.76rem;
font-weight: 400;
line-height: 1.4;
text-align: center;
text-transform: none;
letter-spacing: normal;
pointer-events: none;
}
.app-tooltip[hidden] { display: none; }
/* abbreviation key — hidden on desktop (tooltips cover it), shown on mobile */
.stats-legend { display: none; }
.legend-pair b {
color: var(--text-primary);
font-weight: 600;
}
@media (max-width: 600px) {
.stats-legend {
display: flex;
flex-wrap: wrap;
gap: 0.35rem 0.9rem;
margin: 0.9rem 0 0;
padding: 0.8rem 1rem;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
font-size: 0.76rem;
color: var(--text-secondary);
line-height: 1.5;
}
}
/* --------------------------------------------------------------- misc */
.stats-more {
margin-top: 1.5rem;
text-align: center;
}
.stats-link {
padding: 0.55rem 1.2rem;
border: 1px solid var(--glass-border);
border-radius: 999px;
color: var(--text-secondary);
font-size: 0.9rem;
transition: color 0.2s, border-color 0.2s, background-color 0.2s;
}
.stats-link:hover {
color: var(--text-primary);
border-color: var(--accent-gold);
background: var(--glass-bg-strong);
}
/* ---------------------------------------------------------- responsive */
@media (max-width: 600px) {
.stats-hero-tiles {
grid-template-columns: repeat(2, 1fr);
}
}
@media (prefers-reduced-motion: reduce) {
.chart-bar { animation: none; }
}

View file

@ -9,6 +9,7 @@ import { initGroups } from './groups.js';
import { initStadiums } from './stadiums.js';
import { initModal } from './modal.js';
import { initBracket } from './bracket.js';
import { initStats } from './stats.js';
// ---------------------------------------------------------------- data
@ -57,7 +58,7 @@ export function flagSrc(team) {
// ---------------------------------------------------------------- tabs
const TABS = ['home', 'matches', 'groups', 'bracket', 'stadiums'];
const TABS = ['home', 'matches', 'groups', 'bracket', 'stadiums', 'stats'];
function activateTab(id, { updateHash = true } = {}) {
const tab = TABS.includes(id) ? id : 'home';
@ -216,6 +217,53 @@ function renderDashboard() {
// ---------------------------------------------------------------- init
// shared tooltip for abbreviated table headers (Stats + Groups). A single
// fixed-position bubble driven by event delegation, so it survives table
// re-renders and is never clipped by a table's overflow/stacking context.
// Hover + keyboard focus both trigger it; screen readers use the header's
// aria-label, and small screens fall back to the visible legend.
function initTooltips() {
const tip = document.createElement('div');
tip.className = 'app-tooltip';
tip.setAttribute('role', 'tooltip');
tip.hidden = true;
document.body.appendChild(tip);
let current = null;
const show = (el) => {
current = el;
tip.textContent = el.dataset.tip;
tip.style.left = '-9999px';
tip.style.top = '-9999px';
tip.hidden = false;
const rect = el.getBoundingClientRect();
const box = tip.getBoundingClientRect();
let left = Math.round(rect.left + rect.width / 2 - box.width / 2);
left = Math.max(8, Math.min(left, window.innerWidth - box.width - 8));
let top = Math.round(rect.top - box.height - 8);
if (top < 8) top = Math.round(rect.bottom + 8); // flip below if no room above
tip.style.left = `${left}px`;
tip.style.top = `${top}px`;
};
const hide = (el) => {
if (!el || el === current) { tip.hidden = true; current = null; }
};
for (const event of ['mouseover', 'focusin']) {
document.addEventListener(event, (e) => {
const el = e.target.closest?.('.has-tip[data-tip]');
if (el) show(el);
});
}
for (const event of ['mouseout', 'focusout']) {
document.addEventListener(event, (e) => {
const el = e.target.closest?.('.has-tip[data-tip]');
if (el) hide(el);
});
}
document.addEventListener('scroll', () => hide(current), true);
}
// global star delegation — stars exist in schedule, groups, and modal
function initFavorites() {
document.addEventListener('click', (event) => {
@ -278,6 +326,7 @@ async function init() {
initLangSwitch();
initTimeToggle();
initFavorites();
initTooltips();
document.addEventListener('langchange', renderHome);
document.addEventListener('timemodechange', renderHero);
try {
@ -288,6 +337,7 @@ async function init() {
initGroups();
initBracket();
initStadiums();
initStats();
} catch (error) {
showError(error);
}

View file

@ -69,13 +69,27 @@ function render() {
</p>
<div class="groups-grid">
${Object.entries(standings).map(([letter, rows]) => groupCardHTML(letter, rows)).join('')}
</div>`;
</div>
${legendHTML()}`;
}
// Abbreviation key shown only on small screens (where the header hover tooltips
// don't fire); reuses the shared .stats-legend styling.
function legendHTML() {
const pairs = ['played', 'won', 'drawn', 'lost', 'gf', 'ga', 'gd', 'pts']
.map((key) => `<span class="legend-pair"><b>${t(`standings.${key}`)}</b> = ${t(`tip.${key}`)}</span>`)
.join('');
return `<p class="stats-legend">${pairs}</p>`;
}
function groupCardHTML(letter, rows) {
const finished = isGroupFinished(letter);
const headers = ['played', 'won', 'drawn', 'lost', 'gf', 'ga', 'gd', 'pts']
.map((key) => `<th class="${key === 'gf' || key === 'ga' ? 'col-goals' : ''}" scope="col">${t(`standings.${key}`)}</th>`)
.map((key) => {
const tip = t(`tip.${key}`);
const goals = key === 'gf' || key === 'ga' ? 'col-goals ' : '';
return `<th class="${goals}has-tip" scope="col" data-tip="${tip}" aria-label="${t(`standings.${key}`)}${tip}">${t(`standings.${key}`)}</th>`;
})
.join('');
return `

View file

@ -15,6 +15,7 @@ const dicts = {
'nav.groups': 'Groups',
'nav.bracket': 'Knockout',
'nav.stadiums': 'Stadiums',
'nav.stats': 'Stats',
'hero.live': 'Live',
'hero.nextMatch': 'Next match',
'hero.kickoff': 'Kickoff!',
@ -106,6 +107,37 @@ const dicts = {
'share.copied': 'Link copied!',
'share.confirm': 'Apply the shared prediction? Your current picks will be replaced.',
'modal.addCalendar': 'Add to calendar',
'stats.heroTitle': 'Tournament in progress',
'stats.heroProgress': '{x} of {y} matches played',
'stats.tileGoals': 'Goals',
'stats.tileAvg': 'Goals / match',
'stats.tileBiggestMargin': 'Biggest margin',
'stats.tileCleanSheets': 'Clean sheets',
'stats.overviewTitle': 'Overview',
'stats.played': 'Matches played',
'stats.decisive': 'Decisive',
'stats.draws': 'Draws',
'stats.goalsByPhase': 'Goals by stage',
'stats.stageGroup': 'Group stage',
'stats.teamStatsTitle': 'Team statistics',
'stats.colGpg': 'G/M',
'stats.colCS': 'CS',
'tip.played': 'Matches played',
'tip.won': 'Wins',
'tip.drawn': 'Draws',
'tip.lost': 'Losses',
'tip.gf': 'Goals for (scored)',
'tip.ga': 'Goals against (conceded)',
'tip.gd': 'Goal difference (for against)',
'tip.pts': 'Points',
'tip.gpg': 'Goals per match (average)',
'tip.cs': 'Clean sheets (no goals conceded)',
'stats.bestAttack': 'Best attack',
'stats.bestDefense': 'Best defense',
'stats.mostCleanSheets': 'Most clean sheets',
'stats.prevPage': 'Previous page',
'stats.nextPage': 'Next page',
'stats.seeAllMatches': 'See all matches',
'footer.note': 'Fan-made static hub — all data lives in JSON files.',
},
pt: {
@ -117,6 +149,7 @@ const dicts = {
'nav.groups': 'Grupos',
'nav.bracket': 'Mata-mata',
'nav.stadiums': 'Estádios',
'nav.stats': 'Estatísticas',
'hero.live': 'Ao vivo',
'hero.nextMatch': 'Próxima partida',
'hero.kickoff': 'Bola rolando!',
@ -208,6 +241,37 @@ const dicts = {
'share.copied': 'Link copiado!',
'share.confirm': 'Aplicar os palpites compartilhados? Seus palpites atuais serão substituídos.',
'modal.addCalendar': 'Adicionar à agenda',
'stats.heroTitle': 'Copa em andamento',
'stats.heroProgress': '{x} de {y} jogos disputados',
'stats.tileGoals': 'Gols',
'stats.tileAvg': 'Gols por jogo',
'stats.tileBiggestMargin': 'Maior margem',
'stats.tileCleanSheets': 'Sem sofrer gols',
'stats.overviewTitle': 'Visão geral',
'stats.played': 'Jogos disputados',
'stats.decisive': 'Decididas',
'stats.draws': 'Empates',
'stats.goalsByPhase': 'Gols por fase',
'stats.stageGroup': 'Fase de grupos',
'stats.teamStatsTitle': 'Estatísticas por time',
'stats.colGpg': 'G/J',
'stats.colCS': 'CS',
'tip.played': 'Jogos disputados',
'tip.won': 'Vitórias',
'tip.drawn': 'Empates',
'tip.lost': 'Derrotas',
'tip.gf': 'Gols pró (marcados)',
'tip.ga': 'Gols contra (sofridos)',
'tip.gd': 'Saldo de gols (pró contra)',
'tip.pts': 'Pontos',
'tip.gpg': 'Gols por jogo (média)',
'tip.cs': 'Clean sheets (sem sofrer gols)',
'stats.bestAttack': 'Melhor ataque',
'stats.bestDefense': 'Melhor defesa',
'stats.mostCleanSheets': 'Mais clean sheets',
'stats.prevPage': 'Página anterior',
'stats.nextPage': 'Próxima página',
'stats.seeAllMatches': 'Ver todas as partidas',
'footer.note': 'Hub estático feito por fãs — todos os dados vivem em arquivos JSON.',
},
};

439
assets/js/stats.js Normal file
View file

@ -0,0 +1,439 @@
// stats.js — "Stats" tab. Tournament-to-date aggregates derived ONLY from data
// the project already has (results.json scores/status + optional per-match
// stats, matches.json phase). Counts finished matches only, consistent with
// computeStandings (live/scheduled ignored). Built as the evolving foundation
// for the post-tournament stats screen (see .agents/stats-screen-plan.md):
// sections gate on data so player/award/editorial blocks slot in later.
import { getData, flagSrc, navigateTo } from './app.js';
import { t, translatePhase } from './i18n.js';
// "Goals by stage" collapses all 12 groups into one bucket; knockout phases
// keep their own. Order used to render the chart left-to-right.
const STAGE_ORDER = ['Round of 32', 'Round of 16', 'Quarterfinals', 'Semifinals', 'Third Place', 'Final'];
// Per-team table: all 48 teams, 8 per page (6 fixed pages). Sortable columns —
// existing standings.* labels are reused for the abbreviations the user already
// knows from the Groups tab; the two new ones carry a full-name title tooltip.
const PAGE_SIZE = 8;
const COLUMNS = [
{ key: 'played', label: 'standings.played', tip: 'tip.played' },
{ key: 'won', label: 'standings.won', tip: 'tip.won' },
{ key: 'drawn', label: 'standings.drawn', tip: 'tip.drawn' },
{ key: 'lost', label: 'standings.lost', tip: 'tip.lost' },
{ key: 'gf', label: 'standings.gf', tip: 'tip.gf' },
{ key: 'ga', label: 'standings.ga', tip: 'tip.ga' },
{ key: 'gd', label: 'standings.gd', tip: 'tip.gd' },
{ key: 'points', label: 'standings.pts', tip: 'tip.pts' },
{ key: 'gpg', label: 'stats.colGpg', tip: 'tip.gpg' },
{ key: 'cleanSheets', label: 'stats.colCS', tip: 'tip.cs' },
];
let model = null;
// table interaction state — survives langchange re-renders (default on load:
// most goals first, page 1), like the bracket keeps its zoom across re-renders.
let sortKey = 'gf';
let sortDir = 'desc';
let teamPage = 0;
function stageOf(phase) {
return phase.startsWith('Group ') ? 'Group' : phase;
}
// Tournament-wide team aggregation over finished matches (group + knockout).
// computeStandings() only covers group matches, so this is its own pass.
// possession/shots/cards are gated per-match: a finished match without the
// optional `stats` object simply doesn't contribute (no visible distortion).
function aggregateTeams(finished, resultByMatchId) {
const rows = new Map();
const row = (id) => {
if (!rows.has(id)) {
rows.set(id, {
teamId: id, played: 0, won: 0, drawn: 0, lost: 0, gf: 0, ga: 0,
cleanSheets: 0, possSum: 0, possCount: 0, shots: 0, cards: 0,
});
}
return rows.get(id);
};
for (const m of finished) {
const r = resultByMatchId.get(m.id);
const home = row(m.homeTeam);
const away = row(m.awayTeam);
applySide(home, r.homeScore, r.awayScore);
applySide(away, r.awayScore, r.homeScore);
if (r.stats) {
const s = r.stats;
if (s.possession) {
home.possSum += s.possession.home; home.possCount += 1;
away.possSum += s.possession.away; away.possCount += 1;
}
if (s.shots) { home.shots += s.shots.home; away.shots += s.shots.away; }
if (s.cards) { home.cards += s.cards.home; away.cards += s.cards.away; }
}
}
return rows;
}
function applySide(row, gf, ga) {
row.played += 1;
row.gf += gf;
row.ga += ga;
if (ga === 0) row.cleanSheets += 1;
if (gf > ga) row.won += 1;
else if (gf === ga) row.drawn += 1;
else row.lost += 1;
}
function buildStatsModel() {
const { matches, resultByMatchId } = getData();
const finished = matches.filter((m) => resultByMatchId.get(m.id)?.status === 'finished');
let totalGoals = 0;
let draws = 0;
let decisive = 0;
let biggestMargin = 0;
const byStage = new Map();
for (const m of finished) {
const r = resultByMatchId.get(m.id);
const total = r.homeScore + r.awayScore;
totalGoals += total;
if (r.homeScore === r.awayScore) draws += 1; else decisive += 1;
biggestMargin = Math.max(biggestMargin, Math.abs(r.homeScore - r.awayScore));
const stage = stageOf(m.phase);
const bucket = byStage.get(stage) ?? { goals: 0, count: 0 };
bucket.goals += total;
bucket.count += 1;
byStage.set(stage, bucket);
}
const agg = aggregateTeams(finished, resultByMatchId);
let cleanSheets = 0;
for (const r of agg.values()) cleanSheets += r.cleanSheets;
// one row per team for ALL 48 (teams that haven't played yet are real zeros,
// not gaps), with the derived columns the table needs.
const teamStats = getData().teams.map((team) => {
const a = agg.get(team.id);
const gf = a?.gf ?? 0;
const ga = a?.ga ?? 0;
const won = a?.won ?? 0;
const drawn = a?.drawn ?? 0;
const played = a?.played ?? 0;
return {
teamId: team.id,
played,
won,
drawn,
lost: a?.lost ?? 0,
gf,
ga,
gd: gf - ga,
points: won * 3 + drawn,
cleanSheets: a?.cleanSheets ?? 0,
gpg: played ? gf / played : 0,
};
});
return {
totalMatches: matches.length,
finishedCount: finished.length,
totalGoals,
avgGoals: finished.length ? totalGoals / finished.length : 0,
draws,
decisive,
biggestMargin,
cleanSheets,
byStage,
teamStats,
leaders: computeLeaders(teamStats),
};
}
// Highlight leaders consider only teams that have played, so a 0-game team's
// empty record never counts as "best defense". Null before any match finishes.
function computeLeaders(teamStats) {
const played = teamStats.filter((row) => row.played > 0);
if (!played.length) return null;
return {
bestAttack: [...played].sort((a, b) => b.gf - a.gf || b.gd - a.gd)[0],
bestDefense: [...played].sort((a, b) => a.ga - b.ga || b.cleanSheets - a.cleanSheets || b.gd - a.gd)[0],
mostCleanSheets: [...played].sort((a, b) => b.cleanSheets - a.cleanSheets || a.ga - b.ga)[0],
};
}
// ---------------------------------------------------------------- render
export function initStats() {
render();
// labels re-render on language change; the derived model never changes at
// runtime (data is static per page load) so it is reused.
document.addEventListener('langchange', render);
}
function render() {
if (!model) model = buildStatsModel();
const root = document.getElementById('stats-root');
root.innerHTML = heroHTML() + overviewHTML() + teamsSectionHTML() + footerHTML();
root.querySelector('#stats-see-matches')?.addEventListener('click', () => navigateTo('matches'));
const teamsHost = root.querySelector('#stats-teams-table');
if (teamsHost) {
teamsHost.addEventListener('click', onTeamTableClick);
renderTeamTable();
}
setupCountUps(root);
}
function heroHTML() {
const m = model;
const progress = t('stats.heroProgress')
.replace('{x}', String(m.finishedCount))
.replace('{y}', String(m.totalMatches));
const tiles = [
{ value: m.totalGoals, decimals: 0, label: t('stats.tileGoals') },
{ value: Number(m.avgGoals.toFixed(2)), decimals: 2, label: t('stats.tileAvg') },
{ value: m.biggestMargin, decimals: 0, label: t('stats.tileBiggestMargin') },
{ value: m.cleanSheets, decimals: 0, label: t('stats.tileCleanSheets') },
];
return `
<section class="stats-hero glass slide-up">
<p class="hero-label">${t('stats.heroTitle')}<span class="hero-phase">${progress}</span></p>
<div class="stats-hero-tiles">
${tiles.map((tile) => `
<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() {
const m = model;
const cards = [
{ value: String(m.finishedCount), sub: `/ ${m.totalMatches}`, label: t('stats.played') },
{ value: String(m.decisive), label: t('stats.decisive') },
{ value: String(m.draws), label: t('stats.draws') },
];
return `
<h2 class="section-title">${t('stats.overviewTitle')}</h2>
<div class="stats-overview-grid">
${cards.map((card) => `
<div class="stat-card glass">
<span class="stat-value">${card.value}${card.sub ? `<span class="stat-sub">${card.sub}</span>` : ''}</span>
<span class="stat-label">${card.label}</span>
</div>`).join('')}
</div>
${goalsByStageHTML()}`;
}
function footerHTML() {
return `
<p class="stats-more">
<button class="stats-link" id="stats-see-matches" type="button">${t('stats.seeAllMatches')} </button>
</p>`;
}
function goalsByStageHTML() {
const order = ['Group', ...STAGE_ORDER].filter((stage) => model.byStage.has(stage));
if (!order.length) return '';
const max = Math.max(...order.map((stage) => model.byStage.get(stage).goals));
const rows = order.map((stage) => {
const bucket = model.byStage.get(stage);
const pct = max ? Math.round((bucket.goals / max) * 100) : 0;
const label = stage === 'Group' ? t('stats.stageGroup') : translatePhase(stage);
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.goalsByPhase')}</h2>
<div class="stats-chart glass">${rows}</div>`;
}
// ----------------------------------------------------- team statistics
function teamsSectionHTML() {
return `
<h2 class="section-title">${t('stats.teamStatsTitle')}</h2>
${leadersHTML()}
<div id="stats-teams-table" class="stats-teams-table"></div>
${legendHTML(COLUMNS)}`;
}
// Compact abbreviation key — hidden on desktop (the hover tooltip covers it
// there), shown on small screens where hover doesn't fire.
function legendHTML(columns) {
const pairs = columns
.map((col) => `<span class="legend-pair"><b>${t(col.label)}</b> = ${t(col.tip)}</span>`)
.join('');
return `<p class="stats-legend">${pairs}</p>`;
}
function leadersHTML() {
const leaders = model.leaders;
if (!leaders) return '';
const cards = [
{ label: t('stats.bestAttack'), row: leaders.bestAttack, value: leaders.bestAttack.gf },
{ label: t('stats.bestDefense'), row: leaders.bestDefense, value: leaders.bestDefense.ga },
{ label: t('stats.mostCleanSheets'), row: leaders.mostCleanSheets, value: leaders.mostCleanSheets.cleanSheets },
];
return `<div class="stats-leaders">${cards.map(leaderCardHTML).join('')}</div>`;
}
function leaderCardHTML({ label, row, value }) {
const team = getData().teamById.get(row.teamId);
return `
<div class="leader-card glass">
<span class="leader-label">${label}</span>
<div class="leader-team">
<img class="flag" src="${flagSrc(team)}" alt="" width="30" height="20" loading="lazy">
<span class="leader-name">${team.name}</span>
</div>
<span class="leader-value">${value}</span>
</div>`;
}
function sortedTeamStats() {
const dir = sortDir === 'asc' ? 1 : -1;
return [...model.teamStats].sort((a, b) => {
const primary = (a[sortKey] - b[sortKey]) * dir;
if (primary) return primary;
// tiebreak is always GD → GF → name, independent of the sort direction
return b.gd - a.gd || b.gf - a.gf || a.teamId.localeCompare(b.teamId);
});
}
function renderTeamTable() {
const host = document.getElementById('stats-teams-table');
if (!host) return;
const sorted = sortedTeamStats();
const pages = Math.ceil(sorted.length / PAGE_SIZE);
teamPage = Math.max(0, Math.min(teamPage, pages - 1));
const start = teamPage * PAGE_SIZE;
host.innerHTML = tableHTML(sorted.slice(start, start + PAGE_SIZE), start) + paginationHTML(pages);
}
function tableHTML(rows, startIndex) {
const head = COLUMNS.map((col) => {
const active = col.key === sortKey;
const aria = active ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none';
const arrow = active ? `<span class="sort-arrow" aria-hidden="true">${sortDir === 'asc' ? '▲' : '▼'}</span>` : '';
const tip = t(col.tip);
return `<th scope="col" class="col-num${active ? ' sorted' : ''}" aria-sort="${aria}">
<button type="button" class="col-sort has-tip" data-sort="${col.key}" data-tip="${tip}" aria-label="${t(col.label)} — ${tip}">${t(col.label)}${arrow}</button>
</th>`;
}).join('');
const body = rows.map((row, i) => {
const team = getData().teamById.get(row.teamId);
const cells = COLUMNS.map((col) => {
const value = col.key === 'gpg' ? row.gpg.toFixed(2) : col.key === 'gd' ? fmtGd(row.gd) : row[col.key];
return `<td class="col-num${col.key === sortKey ? ' sorted' : ''}">${value}</td>`;
}).join('');
return `
<tr class="${row.played === 0 ? 'row-idle' : ''}">
<td class="col-rank">${startIndex + i + 1}</td>
<td class="col-team">
<img class="flag" src="${flagSrc(team)}" alt="" width="22" height="15" loading="lazy">
<span>${team.name}</span>
</td>
${cells}
</tr>`;
}).join('');
return `
<div class="stats-table-wrap" role="region" aria-label="${t('stats.teamStatsTitle')}" tabindex="0">
<table class="stats-table">
<caption class="sr-only">${t('stats.teamStatsTitle')}</caption>
<thead>
<tr>
<th scope="col" class="col-rank">#</th>
<th scope="col" class="col-team">${t('standings.team')}</th>
${head}
</tr>
</thead>
<tbody>${body}</tbody>
</table>
</div>`;
}
function paginationHTML(pages) {
if (pages <= 1) return '';
const nums = Array.from({ length: pages }, (_, p) => `
<button type="button" class="page-btn${p === teamPage ? ' active' : ''}" data-page="${p}"
aria-current="${p === teamPage ? 'page' : 'false'}">${p + 1}</button>`).join('');
return `
<nav class="stats-pagination" aria-label="${t('stats.teamStatsTitle')}">
<button type="button" class="page-btn page-arrow" data-page="${teamPage - 1}"
${teamPage === 0 ? 'disabled' : ''} aria-label="${t('stats.prevPage')}"></button>
${nums}
<button type="button" class="page-btn page-arrow" data-page="${teamPage + 1}"
${teamPage >= pages - 1 ? 'disabled' : ''} aria-label="${t('stats.nextPage')}"></button>
</nav>`;
}
function onTeamTableClick(event) {
const sortBtn = event.target.closest('.col-sort');
if (sortBtn) {
const key = sortBtn.dataset.sort;
if (key === sortKey) sortDir = sortDir === 'desc' ? 'asc' : 'desc';
else { sortKey = key; sortDir = 'desc'; }
teamPage = 0;
renderTeamTable();
return;
}
const pageBtn = event.target.closest('.page-btn');
if (pageBtn && !pageBtn.disabled) {
teamPage = Number(pageBtn.dataset.page);
renderTeamTable();
}
}
function fmtGd(gd) {
return gd > 0 ? `+${gd}` : String(gd);
}
// ------------------------------------------------------------- count-up
function fmt(value, decimals) {
return decimals ? value.toFixed(decimals) : String(Math.round(value));
}
function setupCountUps(root) {
const els = [...root.querySelectorAll('[data-countup]')];
if (!els.length) return;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
for (const el of els) el.textContent = fmt(Number(el.dataset.countup), Number(el.dataset.decimals) || 0);
return;
}
// animate each tile when it first scrolls into view — the panel is hidden
// until the Stats tab is opened, so this fires on arrival, not at load.
const io = new IntersectionObserver((entries, obs) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
animateCount(entry.target);
obs.unobserve(entry.target);
}
}, { threshold: 0.4 });
for (const el of els) io.observe(el);
}
function animateCount(el) {
const target = Number(el.dataset.countup);
const decimals = Number(el.dataset.decimals) || 0;
const duration = 900;
const start = performance.now();
const step = (now) => {
const p = Math.min(1, (now - start) / duration);
const eased = 1 - (1 - p) ** 3;
el.textContent = fmt(target * eased, decimals);
if (p < 1) requestAnimationFrame(step);
else el.textContent = fmt(target, decimals);
};
requestAnimationFrame(step);
}

View file

@ -7,6 +7,7 @@
<title>World Cup 2026 Hub</title>
<link rel="stylesheet" href="assets/css/style.css">
<link rel="stylesheet" href="assets/css/bracket.css">
<link rel="stylesheet" href="assets/css/stats.css">
<link rel="stylesheet" href="assets/css/animations.css">
</head>
<body>
@ -32,6 +33,7 @@
<button class="tab-btn" data-tab="groups" role="tab" aria-selected="false" aria-controls="panel-groups" data-i18n="nav.groups">Groups</button>
<button class="tab-btn" data-tab="bracket" role="tab" aria-selected="false" aria-controls="panel-bracket" data-i18n="nav.bracket">Knockout</button>
<button class="tab-btn" data-tab="stadiums" role="tab" aria-selected="false" aria-controls="panel-stadiums" data-i18n="nav.stadiums">Stadiums</button>
<button class="tab-btn" data-tab="stats" role="tab" aria-selected="false" aria-controls="panel-stats" data-i18n="nav.stats">Stats</button>
</nav>
<div class="header-controls">
@ -74,6 +76,11 @@
<h2 class="section-title" data-i18n="nav.stadiums">Stadiums</h2>
<div id="stadiums-root"><p class="placeholder glass" data-i18n="app.comingSoon"></p></div>
</section>
<section id="panel-stats" class="panel" role="tabpanel" hidden>
<h2 class="section-title" data-i18n="nav.stats">Stats</h2>
<div id="stats-root"><p class="placeholder glass" data-i18n="app.comingSoon"></p></div>
</section>
</main>
<footer class="site-footer">