diff --git a/assets/css/stats.css b/assets/css/stats.css index a4277c9..7a4a7cd 100644 --- a/assets/css/stats.css +++ b/assets/css/stats.css @@ -322,6 +322,79 @@ 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; } + .col-sort { display: inline-flex; align-items: center; diff --git a/assets/js/i18n.js b/assets/js/i18n.js index b1172bb..63656cc 100644 --- a/assets/js/i18n.js +++ b/assets/js/i18n.js @@ -151,9 +151,14 @@ const dicts = { 'tip.pts': 'Points', 'tip.gpg': 'Goals per match (average)', 'tip.cs': 'Clean sheets (no goals conceded)', + 'tip.rank': 'Final ranking — deepest stage reached, then points', + 'stats.rankCol': 'Rank', 'stats.bestAttack': 'Best attack', 'stats.bestDefense': 'Best defense', 'stats.mostCleanSheets': 'Most clean sheets', + 'stats.biggestWin': 'Biggest win', + 'stats.winStreak': 'Longest win streak', + 'stats.championPath': "Champion's path", 'stats.prevPage': 'Previous page', 'stats.nextPage': 'Next page', 'stats.seeAllMatches': 'See all matches', @@ -304,9 +309,14 @@ const dicts = { 'tip.pts': 'Pontos', 'tip.gpg': 'Gols por jogo (média)', 'tip.cs': 'Clean sheets (sem sofrer gols)', + 'tip.rank': 'Classificação final — fase alcançada, depois pontos', + 'stats.rankCol': 'Posição', 'stats.bestAttack': 'Melhor ataque', 'stats.bestDefense': 'Melhor defesa', 'stats.mostCleanSheets': 'Mais clean sheets', + 'stats.biggestWin': 'Maior goleada', + 'stats.winStreak': 'Maior sequência de vitórias', + 'stats.championPath': 'Caminho do campeão', 'stats.prevPage': 'Página anterior', 'stats.nextPage': 'Próxima página', 'stats.seeAllMatches': 'Ver todas as partidas', diff --git a/assets/js/stats.js b/assets/js/stats.js index 58a2ebb..63a51d4 100644 --- a/assets/js/stats.js +++ b/assets/js/stats.js @@ -7,6 +7,8 @@ import { getData, flagSrc, navigateTo } from './app.js'; import { getBracketTree } from './bracket.js'; +import { getFavorites } from './storage.js'; +import { openMatchModal } from './modal.js'; import { t, translatePhase } from './i18n.js'; // "Goals by stage" collapses all 12 groups into one bucket; knockout phases @@ -52,10 +54,10 @@ const SECTIONS = [ ]; let model = null; -// table interaction state — survives langchange re-renders (default on load: -// most goals first, page 1), like the bracket keeps its zoom across re-renders. -let sortKey = 'gf'; -let sortDir = 'desc'; +// table interaction state — survives langchange re-renders. Default on load is +// the canonical final ranking (page 1); like the bracket keeps its zoom. +let sortKey = 'rank'; +let sortDir = 'asc'; let teamPage = 0; function stageOf(phase) { @@ -165,6 +167,9 @@ function buildStatsModel() { }; }); + const verdict = computeVerdict(); + assignRanks(teamStats); + return { totalMatches: matches.length, finishedCount: finished.length, @@ -176,9 +181,10 @@ function buildStatsModel() { cleanSheets, byStage, byRound, - verdict: computeVerdict(), + verdict, teamStats, leaders: computeLeaders(teamStats), + records: computeRecords(finished, resultByMatchId, verdict), }; } @@ -220,6 +226,114 @@ function computeVerdict() { 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 }; + } + } + + return { + biggestWin, + longestWinStreak: longestWinStreak && longestWinStreak.count >= 2 ? longestWinStreak : null, + championPath: computeChampionPath(verdict), + }; +} + +// The champion's knockout route (R32 → Final) with each result, for the path +// card. Null unless there's a real champion (verdict present). +function computeChampionPath(verdict) { + if (!verdict) return null; + const tree = getBracketTree(); + const champ = verdict.champion; + const path = []; + for (const round of tree.rounds) { + const node = round.nodes.find((n) => n.winner === champ && (n.home.teamId === champ || n.away.teamId === champ)); + if (!node || !node.result) continue; + const side = node.home.teamId === champ ? 'home' : 'away'; + const r = node.result; + path.push({ + matchId: node.match?.id ?? null, + phase: node.phase, + opponentId: side === 'home' ? node.away.teamId : node.home.teamId, + gf: side === 'home' ? r.homeScore : r.awayScore, + ga: side === 'home' ? r.awayScore : r.homeScore, + pens: r.penalties ? (side === 'home' ? `${r.penalties.home}-${r.penalties.away}` : `${r.penalties.away}-${r.penalties.home}`) : null, + }); + } + return path.length ? path : null; +} + // Highlight leaders consider only teams that have played, so a 0-game team's // empty record never counts as "best defense". Null before any match finishes. function computeLeaders(teamStats) { @@ -242,6 +356,9 @@ export function initStats() { document.addEventListener('langchange', render); // new published results change the aggregates → rebuild the memoized model document.addEventListener('datachange', () => { model = null; render(); }); + // favorites change elsewhere (schedule/groups/modal) → re-render the table so + // the gold favorite-row highlight stays in sync (no model rebuild needed). + document.addEventListener('favchange', renderTeamTable); } function render() { @@ -262,6 +379,14 @@ function render() { teamsHost.addEventListener('click', onTeamTableClick); renderTeamTable(); } + // record cards / champion-path rows that reference a match open it in the modal + for (const el of root.querySelectorAll('[data-record-match]')) { + const open = () => openMatchModal(Number(el.dataset.recordMatch)); + el.addEventListener('click', open); + el.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); open(); } + }); + } setupCountUps(root); setupSubNav(root, sections); } @@ -503,10 +628,73 @@ function teamsSectionHTML() { return `

${t('stats.teamStatsTitle')}

${leadersHTML()} + ${teamRecordsHTML()}
${legendHTML(COLUMNS)}`; } +// Auto record cards (degrade individually when their data is null). Biggest win +// opens the match modal; champion's path appears only post-final. +function teamRecordsHTML() { + const rec = model.records; + const cards = []; + if (rec.biggestWin) cards.push(biggestWinCardHTML(rec.biggestWin)); + if (rec.longestWinStreak) cards.push(streakCardHTML(rec.longestWinStreak)); + const grid = cards.length ? `
${cards.join('')}
` : ''; + 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 ` + `; +} + +function streakCardHTML(streak) { + const team = getData().teamById.get(streak.teamId); + return ` +
+ ${t('stats.winStreak')} + + ${flagImg(team, 26, 17)} + ${streak.count} + + ${team.name} +
`; +} + +function championPathHTML(path) { + const rows = path.map((step) => { + const opp = getData().teamById.get(step.opponentId); + const pens = step.pens ? ` (${t('status.pens')} ${step.pens})` : ''; + 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 ` +
+ ${translatePhase(step.phase)} + ${step.gf}–${step.ga}${pens} + ${flagImg(opp, 20, 13)} ${opp.name} +
`; + }).join(''); + return ` +
+ ${t('stats.championPath')} + ${rows} +
`; +} + // Compact abbreviation key — hidden on desktop (the hover tooltip covers it // there), shown on small screens where hover doesn't fire. function legendHTML(columns) { @@ -542,11 +730,13 @@ function leaderCardHTML({ label, row, value }) { function sortedTeamStats() { const dir = sortDir === 'asc' ? 1 : -1; + if (sortKey === 'rank') { + return [...model.teamStats].sort((a, b) => (a.rank - b.rank) * dir); + } return [...model.teamStats].sort((a, b) => { const primary = (a[sortKey] - b[sortKey]) * dir; if (primary) return primary; - // tiebreak is always GD → GF → name, independent of the sort direction - return b.gd - a.gd || b.gf - a.gf || a.teamId.localeCompare(b.teamId); + return a.rank - b.rank; // canonical rank is the stable tiebreak }); } @@ -557,29 +747,34 @@ function renderTeamTable() { const pages = Math.ceil(sorted.length / PAGE_SIZE); teamPage = Math.max(0, Math.min(teamPage, pages - 1)); const start = teamPage * PAGE_SIZE; - host.innerHTML = tableHTML(sorted.slice(start, start + PAGE_SIZE), start) + paginationHTML(pages); + host.innerHTML = tableHTML(sorted.slice(start, start + PAGE_SIZE)) + paginationHTML(pages); } -function tableHTML(rows, startIndex) { - const head = COLUMNS.map((col) => { - const active = col.key === sortKey; - const aria = active ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none'; - const arrow = active ? `` : ''; - const tip = t(col.tip); - return ` - - `; - }).join(''); +// One sortable header cell; `aria` falls back to the visible label. +function sortHeaderHTML(key, label, tip, cls, aria = label) { + const active = key === sortKey; + const ariaSort = active ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none'; + const arrow = active ? `` : ''; + return ` + + `; +} - const body = rows.map((row, i) => { +function tableHTML(rows) { + const rankHead = sortHeaderHTML('rank', '#', t('tip.rank'), 'col-rank', t('stats.rankCol')); + const head = COLUMNS.map((col) => sortHeaderHTML(col.key, t(col.label), t(col.tip), 'col-num')).join(''); + const favs = new Set(getFavorites()); + + const body = rows.map((row) => { const team = getData().teamById.get(row.teamId); const cells = COLUMNS.map((col) => { const value = col.key === 'gpg' ? row.gpg.toFixed(2) : col.key === 'gd' ? fmtGd(row.gd) : row[col.key]; return `${value}`; }).join(''); + const classes = [row.played === 0 ? 'row-idle' : '', favs.has(row.teamId) ? 'row-fav' : ''].filter(Boolean).join(' '); return ` - - ${startIndex + i + 1} + + ${row.rank} ${flagImg(team, 22, 15)} ${team.name} @@ -594,7 +789,7 @@ function tableHTML(rows, startIndex) { ${t('stats.teamStatsTitle')} - # + ${rankHead} ${t('standings.team')} ${head} @@ -624,7 +819,7 @@ function onTeamTableClick(event) { if (sortBtn) { const key = sortBtn.dataset.sort; if (key === sortKey) sortDir = sortDir === 'desc' ? 'asc' : 'desc'; - else { sortKey = key; sortDir = 'desc'; } + else { sortKey = key; sortDir = key === 'rank' ? 'asc' : 'desc'; } teamPage = 0; renderTeamTable(); return;