mirror of
https://github.com/LucasKalil-Programador/world-2026-hub.git
synced 2026-07-04 17:41:28 -03:00
feat(stats): final ranking 1-48, favorite highlight, team records (Stage C)
- Canonical final ranking: each team carries a .rank from the stage-reached chain (champion>runner-up>3rd>4th>QF>R16>R32>group, then pts>GD>GF>id), computed from REAL knockout results only (simulation never moves it). The # column shows this rank and is the default sort; it stays canonical when sorting other columns, and clicking # returns to it. - Favorite-team row highlight (gold inset border + tint); re-renders on favchange. No stars in the table (highlight-only, like the bracket). - Team record cards: biggest win (-> openMatchModal), longest win streak (>=2, else hidden), and champion's path (R32->Final route, gated on the verdict, rows -> openMatchModal). Each degrades away when its data is null. - stats.js imports getFavorites (storage) + openMatchModal (modal). i18n tip.rank/stats.rankCol/biggestWin/winStreak/championPath (EN/PT); CSS for .row-fav, record cards, champion path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
beac60518f
commit
8a521575aa
3 changed files with 301 additions and 23 deletions
|
|
@ -322,6 +322,79 @@
|
||||||
background: var(--bg-secondary);
|
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 {
|
.col-sort {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -151,9 +151,14 @@ const dicts = {
|
||||||
'tip.pts': 'Points',
|
'tip.pts': 'Points',
|
||||||
'tip.gpg': 'Goals per match (average)',
|
'tip.gpg': 'Goals per match (average)',
|
||||||
'tip.cs': 'Clean sheets (no goals conceded)',
|
'tip.cs': 'Clean sheets (no goals conceded)',
|
||||||
|
'tip.rank': 'Final ranking — deepest stage reached, then points',
|
||||||
|
'stats.rankCol': 'Rank',
|
||||||
'stats.bestAttack': 'Best attack',
|
'stats.bestAttack': 'Best attack',
|
||||||
'stats.bestDefense': 'Best defense',
|
'stats.bestDefense': 'Best defense',
|
||||||
'stats.mostCleanSheets': 'Most clean sheets',
|
'stats.mostCleanSheets': 'Most clean sheets',
|
||||||
|
'stats.biggestWin': 'Biggest win',
|
||||||
|
'stats.winStreak': 'Longest win streak',
|
||||||
|
'stats.championPath': "Champion's path",
|
||||||
'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',
|
||||||
|
|
@ -304,9 +309,14 @@ const dicts = {
|
||||||
'tip.pts': 'Pontos',
|
'tip.pts': 'Pontos',
|
||||||
'tip.gpg': 'Gols por jogo (média)',
|
'tip.gpg': 'Gols por jogo (média)',
|
||||||
'tip.cs': 'Clean sheets (sem sofrer gols)',
|
'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.bestAttack': 'Melhor ataque',
|
||||||
'stats.bestDefense': 'Melhor defesa',
|
'stats.bestDefense': 'Melhor defesa',
|
||||||
'stats.mostCleanSheets': 'Mais clean sheets',
|
'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.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',
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@
|
||||||
|
|
||||||
import { getData, flagSrc, navigateTo } from './app.js';
|
import { getData, flagSrc, navigateTo } from './app.js';
|
||||||
import { getBracketTree } from './bracket.js';
|
import { getBracketTree } from './bracket.js';
|
||||||
|
import { getFavorites } from './storage.js';
|
||||||
|
import { openMatchModal } from './modal.js';
|
||||||
import { t, translatePhase } from './i18n.js';
|
import { t, translatePhase } from './i18n.js';
|
||||||
|
|
||||||
// "Goals by stage" collapses all 12 groups into one bucket; knockout phases
|
// "Goals by stage" collapses all 12 groups into one bucket; knockout phases
|
||||||
|
|
@ -52,10 +54,10 @@ const SECTIONS = [
|
||||||
];
|
];
|
||||||
|
|
||||||
let model = null;
|
let model = null;
|
||||||
// table interaction state — survives langchange re-renders (default on load:
|
// table interaction state — survives langchange re-renders. Default on load is
|
||||||
// most goals first, page 1), like the bracket keeps its zoom across re-renders.
|
// the canonical final ranking (page 1); like the bracket keeps its zoom.
|
||||||
let sortKey = 'gf';
|
let sortKey = 'rank';
|
||||||
let sortDir = 'desc';
|
let sortDir = 'asc';
|
||||||
let teamPage = 0;
|
let teamPage = 0;
|
||||||
|
|
||||||
function stageOf(phase) {
|
function stageOf(phase) {
|
||||||
|
|
@ -165,6 +167,9 @@ function buildStatsModel() {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const verdict = computeVerdict();
|
||||||
|
assignRanks(teamStats);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalMatches: matches.length,
|
totalMatches: matches.length,
|
||||||
finishedCount: finished.length,
|
finishedCount: finished.length,
|
||||||
|
|
@ -176,9 +181,10 @@ function buildStatsModel() {
|
||||||
cleanSheets,
|
cleanSheets,
|
||||||
byStage,
|
byStage,
|
||||||
byRound,
|
byRound,
|
||||||
verdict: computeVerdict(),
|
verdict,
|
||||||
teamStats,
|
teamStats,
|
||||||
leaders: computeLeaders(teamStats),
|
leaders: computeLeaders(teamStats),
|
||||||
|
records: computeRecords(finished, resultByMatchId, verdict),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -220,6 +226,114 @@ function computeVerdict() {
|
||||||
return verdict;
|
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
|
// 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.
|
// empty record never counts as "best defense". Null before any match finishes.
|
||||||
function computeLeaders(teamStats) {
|
function computeLeaders(teamStats) {
|
||||||
|
|
@ -242,6 +356,9 @@ export function initStats() {
|
||||||
document.addEventListener('langchange', render);
|
document.addEventListener('langchange', render);
|
||||||
// new published results change the aggregates → rebuild the memoized model
|
// new published results change the aggregates → rebuild the memoized model
|
||||||
document.addEventListener('datachange', () => { model = null; render(); });
|
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() {
|
function render() {
|
||||||
|
|
@ -262,6 +379,14 @@ function render() {
|
||||||
teamsHost.addEventListener('click', onTeamTableClick);
|
teamsHost.addEventListener('click', onTeamTableClick);
|
||||||
renderTeamTable();
|
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);
|
setupCountUps(root);
|
||||||
setupSubNav(root, sections);
|
setupSubNav(root, sections);
|
||||||
}
|
}
|
||||||
|
|
@ -503,10 +628,73 @@ function teamsSectionHTML() {
|
||||||
return `
|
return `
|
||||||
<h2 class="section-title">${t('stats.teamStatsTitle')}</h2>
|
<h2 class="section-title">${t('stats.teamStatsTitle')}</h2>
|
||||||
${leadersHTML()}
|
${leadersHTML()}
|
||||||
|
${teamRecordsHTML()}
|
||||||
<div id="stats-teams-table" class="stats-teams-table"></div>
|
<div id="stats-teams-table" class="stats-teams-table"></div>
|
||||||
${legendHTML(COLUMNS)}`;
|
${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 ? `<div class="stats-records-grid">${cards.join('')}</div>` : '';
|
||||||
|
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 `
|
||||||
|
<button type="button" class="record-card glass" data-record-match="${win.matchId}"
|
||||||
|
aria-label="${t('stats.biggestWin')}: ${winner.name} ${win.score} ${loser.name}">
|
||||||
|
<span class="record-label">${t('stats.biggestWin')}</span>
|
||||||
|
<span class="record-main">
|
||||||
|
${flagImg(winner, 26, 17)}
|
||||||
|
<span class="record-score">${win.score}</span>
|
||||||
|
${flagImg(loser, 26, 17)}
|
||||||
|
</span>
|
||||||
|
<span class="record-teams">${winner.name} <span class="record-vs">${t('hero.vs')}</span> ${loser.name}</span>
|
||||||
|
</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function streakCardHTML(streak) {
|
||||||
|
const team = getData().teamById.get(streak.teamId);
|
||||||
|
return `
|
||||||
|
<div class="record-card glass">
|
||||||
|
<span class="record-label">${t('stats.winStreak')}</span>
|
||||||
|
<span class="record-main">
|
||||||
|
${flagImg(team, 26, 17)}
|
||||||
|
<span class="record-score">${streak.count}</span>
|
||||||
|
</span>
|
||||||
|
<span class="record-teams">${team.name}</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function championPathHTML(path) {
|
||||||
|
const rows = path.map((step) => {
|
||||||
|
const opp = getData().teamById.get(step.opponentId);
|
||||||
|
const pens = step.pens ? ` <small>(${t('status.pens')} ${step.pens})</small>` : '';
|
||||||
|
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 `
|
||||||
|
<div class="champ-path-row${clickable ? ' clickable' : ''}" ${attrs}>
|
||||||
|
<span class="champ-path-phase">${translatePhase(step.phase)}</span>
|
||||||
|
<span class="champ-path-score">${step.gf}–${step.ga}${pens}</span>
|
||||||
|
<span class="champ-path-opp">${flagImg(opp, 20, 13)} ${opp.name}</span>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
return `
|
||||||
|
<div class="champ-path glass">
|
||||||
|
<span class="record-label">${t('stats.championPath')}</span>
|
||||||
|
${rows}
|
||||||
|
</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) {
|
||||||
|
|
@ -542,11 +730,13 @@ function leaderCardHTML({ label, row, value }) {
|
||||||
|
|
||||||
function sortedTeamStats() {
|
function sortedTeamStats() {
|
||||||
const dir = sortDir === 'asc' ? 1 : -1;
|
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) => {
|
return [...model.teamStats].sort((a, b) => {
|
||||||
const primary = (a[sortKey] - b[sortKey]) * dir;
|
const primary = (a[sortKey] - b[sortKey]) * dir;
|
||||||
if (primary) return primary;
|
if (primary) return primary;
|
||||||
// tiebreak is always GD → GF → name, independent of the sort direction
|
return a.rank - b.rank; // canonical rank is the stable tiebreak
|
||||||
return b.gd - a.gd || b.gf - a.gf || a.teamId.localeCompare(b.teamId);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -557,29 +747,34 @@ function renderTeamTable() {
|
||||||
const pages = Math.ceil(sorted.length / PAGE_SIZE);
|
const pages = Math.ceil(sorted.length / PAGE_SIZE);
|
||||||
teamPage = Math.max(0, Math.min(teamPage, pages - 1));
|
teamPage = Math.max(0, Math.min(teamPage, pages - 1));
|
||||||
const start = teamPage * PAGE_SIZE;
|
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) {
|
// One sortable header cell; `aria` falls back to the visible label.
|
||||||
const head = COLUMNS.map((col) => {
|
function sortHeaderHTML(key, label, tip, cls, aria = label) {
|
||||||
const active = col.key === sortKey;
|
const active = key === sortKey;
|
||||||
const aria = active ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none';
|
const ariaSort = active ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none';
|
||||||
const arrow = active ? `<span class="sort-arrow" aria-hidden="true">${sortDir === 'asc' ? '▲' : '▼'}</span>` : '';
|
const arrow = active ? `<span class="sort-arrow" aria-hidden="true">${sortDir === 'asc' ? '▲' : '▼'}</span>` : '';
|
||||||
const tip = t(col.tip);
|
return `<th scope="col" class="${cls}${active ? ' sorted' : ''}" aria-sort="${ariaSort}">
|
||||||
return `<th scope="col" class="col-num${active ? ' sorted' : ''}" aria-sort="${aria}">
|
<button type="button" class="col-sort has-tip" data-sort="${key}" data-tip="${tip}" aria-label="${aria} — ${tip}">${label}${arrow}</button>
|
||||||
<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>`;
|
||||||
</th>`;
|
}
|
||||||
}).join('');
|
|
||||||
|
|
||||||
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 team = getData().teamById.get(row.teamId);
|
||||||
const cells = COLUMNS.map((col) => {
|
const cells = COLUMNS.map((col) => {
|
||||||
const value = col.key === 'gpg' ? row.gpg.toFixed(2) : col.key === 'gd' ? fmtGd(row.gd) : row[col.key];
|
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>`;
|
return `<td class="col-num${col.key === sortKey ? ' sorted' : ''}">${value}</td>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
const classes = [row.played === 0 ? 'row-idle' : '', favs.has(row.teamId) ? 'row-fav' : ''].filter(Boolean).join(' ');
|
||||||
return `
|
return `
|
||||||
<tr class="${row.played === 0 ? 'row-idle' : ''}">
|
<tr class="${classes}">
|
||||||
<td class="col-rank">${startIndex + i + 1}</td>
|
<td class="col-rank${sortKey === 'rank' ? ' sorted' : ''}">${row.rank}</td>
|
||||||
<td class="col-team">
|
<td class="col-team">
|
||||||
${flagImg(team, 22, 15)}
|
${flagImg(team, 22, 15)}
|
||||||
<span>${team.name}</span>
|
<span>${team.name}</span>
|
||||||
|
|
@ -594,7 +789,7 @@ function tableHTML(rows, startIndex) {
|
||||||
<caption class="sr-only">${t('stats.teamStatsTitle')}</caption>
|
<caption class="sr-only">${t('stats.teamStatsTitle')}</caption>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col" class="col-rank">#</th>
|
${rankHead}
|
||||||
<th scope="col" class="col-team">${t('standings.team')}</th>
|
<th scope="col" class="col-team">${t('standings.team')}</th>
|
||||||
${head}
|
${head}
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -624,7 +819,7 @@ function onTeamTableClick(event) {
|
||||||
if (sortBtn) {
|
if (sortBtn) {
|
||||||
const key = sortBtn.dataset.sort;
|
const key = sortBtn.dataset.sort;
|
||||||
if (key === sortKey) sortDir = sortDir === 'desc' ? 'asc' : 'desc';
|
if (key === sortKey) sortDir = sortDir === 'desc' ? 'asc' : 'desc';
|
||||||
else { sortKey = key; sortDir = 'desc'; }
|
else { sortKey = key; sortDir = key === 'rank' ? 'asc' : 'desc'; }
|
||||||
teamPage = 0;
|
teamPage = 0;
|
||||||
renderTeamTable();
|
renderTeamTable();
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue