feat(stats): Records section + format-48 debuts band (Stage D)

- New Records sub-nav section (always available): match-record cards plus the
  format-48 debuts band. Sub-nav now Overview / Teams / Records.
- Moved the biggest-win card out of Teams into Records (it is a match record);
  Teams keeps team-level cards (win streak, champion path). Records shows
  biggest win + highest-scoring match (both -> openMatchModal), with the
  high-score card deduped when it is the same match as the biggest win.
- computeRecords gains highestScoringMatch. New recordsSectionHTML /
  highScoreCardHTML / formatDebutsHTML.
- Format debuts band: 48 teams, 104 matches, 12 groups, Round of 32, 8 best
  thirds, and (post-final) first champion of the 48-team era. Counts derived
  from data, champion from the verdict.
- i18n recordsTitle/highScoreMatch/formatDebutsTitle/debut* (EN/PT); CSS for
  .stats-subhead and the .debut-band.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Lucas Kalil 2026-06-17 00:33:59 -03:00
parent ddfc656f5d
commit e69ea7bd4d
3 changed files with 135 additions and 4 deletions

View file

@ -395,6 +395,44 @@ button.record-card:hover { border-color: var(--accent-gold); background: var(--g
.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);
}
.col-sort {
display: inline-flex;
align-items: center;

View file

@ -159,6 +159,15 @@ const dicts = {
'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.prevPage': 'Previous page',
'stats.nextPage': 'Next page',
'stats.seeAllMatches': 'See all matches',
@ -317,6 +326,15 @@ const dicts = {
'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.prevPage': 'Página anterior',
'stats.nextPage': 'Próxima página',
'stats.seeAllMatches': 'Ver todas as partidas',

View file

@ -48,7 +48,7 @@ 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: 'records', navKey: 'stats.navRecords', available: () => true, body: recordsSectionHTML },
{ id: 'comparator', navKey: 'stats.navComparator', available: () => false, body: () => '' },
{ id: 'archive', navKey: 'stats.navArchive', available: () => false, body: () => '' },
];
@ -303,8 +303,21 @@ function computeRecords(finished, resultByMatchId, verdict) {
}
}
// 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),
};
@ -633,12 +646,12 @@ function teamsSectionHTML() {
${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.
// 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.biggestWin) cards.push(biggestWinCardHTML(rec.biggestWin));
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) : '');
@ -695,6 +708,68 @@ function championPathHTML(path) {
</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>`;
}
// Compact abbreviation key — hidden on desktop (the hover tooltip covers it
// there), shown on small screens where hover doesn't fire.
function legendHTML(columns) {