mirror of
https://github.com/LucasKalil-Programador/world-2026-hub.git
synced 2026-07-04 17:41:28 -03:00
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:
parent
ddfc656f5d
commit
e69ea7bd4d
3 changed files with 135 additions and 4 deletions
|
|
@ -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-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; }
|
.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 {
|
.col-sort {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -159,6 +159,15 @@ const dicts = {
|
||||||
'stats.biggestWin': 'Biggest win',
|
'stats.biggestWin': 'Biggest win',
|
||||||
'stats.winStreak': 'Longest win streak',
|
'stats.winStreak': 'Longest win streak',
|
||||||
'stats.championPath': "Champion's path",
|
'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.prevPage': 'Previous page',
|
||||||
'stats.nextPage': 'Next page',
|
'stats.nextPage': 'Next page',
|
||||||
'stats.seeAllMatches': 'See all matches',
|
'stats.seeAllMatches': 'See all matches',
|
||||||
|
|
@ -317,6 +326,15 @@ const dicts = {
|
||||||
'stats.biggestWin': 'Maior goleada',
|
'stats.biggestWin': 'Maior goleada',
|
||||||
'stats.winStreak': 'Maior sequência de vitórias',
|
'stats.winStreak': 'Maior sequência de vitórias',
|
||||||
'stats.championPath': 'Caminho do campeão',
|
'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.prevPage': 'Página anterior',
|
||||||
'stats.nextPage': 'Próxima página',
|
'stats.nextPage': 'Próxima página',
|
||||||
'stats.seeAllMatches': 'Ver todas as partidas',
|
'stats.seeAllMatches': 'Ver todas as partidas',
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ const SECTIONS = [
|
||||||
{ id: 'overview', navKey: 'stats.navOverview', available: () => true, body: overviewHTML },
|
{ id: 'overview', navKey: 'stats.navOverview', available: () => true, body: overviewHTML },
|
||||||
{ id: 'teams', navKey: 'stats.navTeams', available: () => true, body: teamsSectionHTML },
|
{ id: 'teams', navKey: 'stats.navTeams', available: () => true, body: teamsSectionHTML },
|
||||||
{ id: 'players', navKey: 'stats.navPlayers', available: () => false, body: () => '' },
|
{ 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: 'comparator', navKey: 'stats.navComparator', available: () => false, body: () => '' },
|
||||||
{ id: 'archive', navKey: 'stats.navArchive', 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 {
|
return {
|
||||||
biggestWin,
|
biggestWin,
|
||||||
|
highestScoringMatch,
|
||||||
longestWinStreak: longestWinStreak && longestWinStreak.count >= 2 ? longestWinStreak : null,
|
longestWinStreak: longestWinStreak && longestWinStreak.count >= 2 ? longestWinStreak : null,
|
||||||
championPath: computeChampionPath(verdict),
|
championPath: computeChampionPath(verdict),
|
||||||
};
|
};
|
||||||
|
|
@ -633,12 +646,12 @@ function teamsSectionHTML() {
|
||||||
${legendHTML(COLUMNS)}`;
|
${legendHTML(COLUMNS)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto record cards (degrade individually when their data is null). Biggest win
|
// Team-level cards in the Teams section: longest win streak + the champion's
|
||||||
// opens the match modal; champion's path appears only post-final.
|
// path (post-final). Match-level records live in the Records section. Each
|
||||||
|
// degrades away individually when its data is null.
|
||||||
function teamRecordsHTML() {
|
function teamRecordsHTML() {
|
||||||
const rec = model.records;
|
const rec = model.records;
|
||||||
const cards = [];
|
const cards = [];
|
||||||
if (rec.biggestWin) cards.push(biggestWinCardHTML(rec.biggestWin));
|
|
||||||
if (rec.longestWinStreak) cards.push(streakCardHTML(rec.longestWinStreak));
|
if (rec.longestWinStreak) cards.push(streakCardHTML(rec.longestWinStreak));
|
||||||
const grid = cards.length ? `<div class="stats-records-grid">${cards.join('')}</div>` : '';
|
const grid = cards.length ? `<div class="stats-records-grid">${cards.join('')}</div>` : '';
|
||||||
return grid + (rec.championPath ? championPathHTML(rec.championPath) : '');
|
return grid + (rec.championPath ? championPathHTML(rec.championPath) : '');
|
||||||
|
|
@ -695,6 +708,68 @@ function championPathHTML(path) {
|
||||||
</div>`;
|
</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
|
// Compact abbreviation key — hidden on desktop (the hover tooltip covers it
|
||||||
// there), shown on small screens where hover doesn't fire.
|
// there), shown on small screens where hover doesn't fire.
|
||||||
function legendHTML(columns) {
|
function legendHTML(columns) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue