// 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' }, ]; // 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: () => false, body: () => '' }, { id: 'comparator', navKey: 'stats.navComparator', available: () => false, body: () => '' }, { id: 'archive', navKey: 'stats.navArchive', available: () => false, body: () => '' }, ]; 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() { installImageFallback(); render(); // labels re-render on language change; the derived model never changes at // runtime (data is static per page load) so it is reused. document.addEventListener('langchange', render); // new published results change the aggregates → rebuild the memoized model document.addEventListener('datachange', () => { model = null; render(); }); } function render() { if (!model) model = buildStatsModel(); const root = document.getElementById('stats-root'); const sections = SECTIONS.filter((section) => section.available(model)); root.innerHTML = heroHTML() + subNavHTML(sections) + sections.map((section) => `
${section.body(model)}
`).join('') + footerHTML(); root.querySelector('#stats-see-matches')?.addEventListener('click', () => navigateTo('matches')); const teamsHost = root.querySelector('#stats-teams-table'); if (teamsHost) { teamsHost.addEventListener('click', onTeamTableClick); renderTeamTable(); } 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) => ` ${t(section.navKey)}`).join(''); return ``; } 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 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 ``; } 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); } 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 `

${t('stats.heroTitle')}${progress}

${tiles.map((tile) => `
${tile.decimals ? '0.00' : '0'} ${tile.label}
`).join('')}
`; } 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 `

${t('stats.overviewTitle')}

${cards.map((card) => `
${card.value}${card.sub ? `${card.sub}` : ''} ${card.label}
`).join('')}
${goalsByStageHTML()}`; } function footerHTML() { return `

`; } 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 `
${label}
${bucket.goals}
`; }).join(''); return `

${t('stats.goalsByPhase')}

${rows}
`; } // ----------------------------------------------------- team statistics function teamsSectionHTML() { return `

${t('stats.teamStatsTitle')}

${leadersHTML()}
${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) => `${t(col.label)} = ${t(col.tip)}`) .join(''); return `

${pairs}

`; } 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 `
${cards.map(leaderCardHTML).join('')}
`; } function leaderCardHTML({ label, row, value }) { const team = getData().teamById.get(row.teamId); return `
${label}
${flagImg(team, 30, 20)} ${team.name}
${value}
`; } 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 ? `` : ''; const tip = t(col.tip); return ` `; }).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 `${value}`; }).join(''); return ` ${startIndex + i + 1} ${flagImg(team, 22, 15)} ${team.name} ${cells} `; }).join(''); return `
${head} ${body}
${t('stats.teamStatsTitle')}
# ${t('standings.team')}
`; } function paginationHTML(pages) { if (pages <= 1) return ''; const nums = Array.from({ length: pages }, (_, p) => ` `).join(''); return ` `; } 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); }