feat(stats): rotate tied teams in leader cards + new metric cards

Leader cards (Best attack/defense, Most clean sheets) now rotate through every
team tied on the headline metric instead of showing only the top one, and three
new cards are added: Most wins, Most goals conceded, Best goal difference.

- group by the metric value alone; order within the group by existing tiebreakers
- auto-advance (3.5s), pause on hover/focus, off under prefers-reduced-motion
- discrete edge arrows with a full-height side click strip; wrap-around
- dots indicator up to 8 tied teams, else an i/n counter; single team = plain card
- bump APP_VERSION to v1.0.2
This commit is contained in:
Lucas Kalil 2026-06-19 10:03:51 -03:00
parent 35b55a4c78
commit 71f7490e0f
3 changed files with 204 additions and 19 deletions

View file

@ -220,6 +220,7 @@
gap: 0.5rem; gap: 0.5rem;
padding: 1.1rem 1rem; padding: 1.1rem 1rem;
text-align: center; text-align: center;
position: relative; /* anchor for the full-height side click areas */
} }
.leader-label { .leader-label {
@ -248,6 +249,80 @@
color: var(--accent-gold-soft); color: var(--accent-gold-soft);
} }
/* tied-leader carousel: arrows pinned to the card edges (so they never shift
with the team-name width), dots/counter below */
.leader-stage {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
/* keep the team clear of the full-height side click areas */
padding-inline: 2.75rem;
}
/* each arrow is a full-height strip down the card side: clicking anywhere on the
lateral navigates. The chevron glyph stays small, centered in the strip ( the
team row, since the card's vertical centre sits on it). */
.leader-nav {
position: absolute;
top: 0;
bottom: 0;
width: 2.75rem;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
background: transparent;
color: var(--text-secondary);
opacity: 0.5;
font-size: 1rem;
line-height: 1;
cursor: pointer;
transition: color 0.15s ease, opacity 0.15s ease;
}
.leader-prev { left: 0; }
.leader-next { right: 0; }
.leader-nav:hover,
.leader-nav:focus-visible {
opacity: 1;
color: var(--accent-gold);
}
.leader-dots {
display: flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
flex-wrap: wrap;
margin-top: -0.15rem; /* tuck the smaller dots closer to the value */
}
.leader-dot {
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--glass-border);
transition: background 0.2s ease, transform 0.2s ease;
}
.leader-dot.active {
background: var(--accent-gold);
transform: scale(1.25);
}
.leader-counter {
font-size: 0.68rem;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
}
@media (prefers-reduced-motion: reduce) {
.leader-dot { transition: none; }
}
/* horizontal scroll on narrow screens; rank + team columns stay frozen */ /* horizontal scroll on narrow screens; rank + team columns stay frozen */
.stats-table-wrap { .stats-table-wrap {
overflow-x: auto; overflow-x: auto;

View file

@ -6,7 +6,7 @@
import { getPrefs, setPref } from './storage.js'; import { getPrefs, setPref } from './storage.js';
// App version for footer display — bump this after any notable changes // App version for footer display — bump this after any notable changes
const APP_VERSION = 'v1.0.1'; const APP_VERSION = 'v1.0.2';
const dicts = { const dicts = {
en: { en: {
@ -159,6 +159,11 @@ const dicts = {
'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.mostWins': 'Most wins',
'stats.mostConceded': 'Most goals conceded',
'stats.bestGoalDiff': 'Best goal difference',
'stats.leaderPrev': 'Previous team',
'stats.leaderNext': 'Next team',
'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",
@ -329,6 +334,11 @@ const dicts = {
'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.mostWins': 'Mais vitórias',
'stats.mostConceded': 'Mais gols sofridos',
'stats.bestGoalDiff': 'Melhor saldo de gols',
'stats.leaderPrev': 'Time anterior',
'stats.leaderNext': 'Próximo time',
'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',

View file

@ -362,16 +362,31 @@ function computeChampionPath(verdict) {
return path.length ? path : null; return path.length ? path : null;
} }
// Highlight leaders consider only teams that have played, so a 0-game team's // Leader cards in the Teams section. Each rotates through the teams TIED on its
// empty record never counts as "best defense". Null before any match finishes. // headline metric — grouped by that metric's value ALONE (decision 2026-06-19),
// so e.g. every team level on goals-for shares the "Best attack" card. `cmp`
// orders within the group (leader first), so the team shown first is unchanged.
const LEADER_CARDS = [
{ id: 'bestAttack', labelKey: 'stats.bestAttack', metric: 'gf', cmp: (a, b) => b.gf - a.gf || b.gd - a.gd },
{ id: 'bestDefense', labelKey: 'stats.bestDefense', metric: 'ga', cmp: (a, b) => a.ga - b.ga || b.cleanSheets - a.cleanSheets || b.gd - a.gd },
{ id: 'mostCleanSheets', labelKey: 'stats.mostCleanSheets', metric: 'cleanSheets', cmp: (a, b) => b.cleanSheets - a.cleanSheets || a.ga - b.ga },
{ id: 'mostWins', labelKey: 'stats.mostWins', metric: 'won', cmp: (a, b) => b.won - a.won || b.gd - a.gd || b.gf - a.gf },
{ id: 'mostConceded', labelKey: 'stats.mostConceded', metric: 'ga', cmp: (a, b) => b.ga - a.ga || a.gd - b.gd },
{ id: 'bestGoalDiff', labelKey: 'stats.bestGoalDiff', metric: 'gd', cmp: (a, b) => b.gd - a.gd || b.gf - a.gf },
];
// Each card carries the FULL tied group (same value on its headline metric),
// ordered by the card's tiebreakers; the carousel rotates through it. Only teams
// that have played count (a 0-game team's empty record never "leads"). Null
// before any match finishes → the whole leaders strip degrades away.
function computeLeaders(teamStats) { function computeLeaders(teamStats) {
const played = teamStats.filter((row) => row.played > 0); const played = teamStats.filter((row) => row.played > 0);
if (!played.length) return null; if (!played.length) return null;
return { return LEADER_CARDS.map(({ id, labelKey, metric, cmp }) => {
bestAttack: [...played].sort((a, b) => b.gf - a.gf || b.gd - a.gd)[0], const sorted = [...played].sort(cmp);
bestDefense: [...played].sort((a, b) => a.ga - b.ga || b.cleanSheets - a.cleanSheets || b.gd - a.gd)[0], const best = sorted[0][metric];
mostCleanSheets: [...played].sort((a, b) => b.cleanSheets - a.cleanSheets || a.ga - b.ga)[0], return { id, labelKey, metric, group: sorted.filter((row) => row[metric] === best) };
}; });
} }
// ---------------------------------------------------------------- render // ---------------------------------------------------------------- render
@ -391,6 +406,7 @@ export function initStats() {
function render() { function render() {
if (!model) model = buildStatsModel(); if (!model) model = buildStatsModel();
clearLeaderTimers(); // drop any carousel intervals from the previous render
const root = document.getElementById('stats-root'); const root = document.getElementById('stats-root');
const sections = SECTIONS.filter((section) => section.available(model)); const sections = SECTIONS.filter((section) => section.available(model));
root.innerHTML = root.innerHTML =
@ -423,6 +439,7 @@ function render() {
cmpBEl.addEventListener('change', () => { cmpB = cmpBEl.value; refreshComparator(); }); cmpBEl.addEventListener('change', () => { cmpB = cmpBEl.value; refreshComparator(); });
} }
setupCountUps(root); setupCountUps(root);
setupLeaderCarousels(root);
setupSubNav(root, sections); setupSubNav(root, sections);
} }
@ -900,30 +917,113 @@ function legendHTML(columns) {
return `<p class="stats-legend">${pairs}</p>`; return `<p class="stats-legend">${pairs}</p>`;
} }
const ROTATE_MS = 3500; // auto-advance cadence for tied-leader carousels
const DOTS_MAX = 8; // above this many tied teams, dots give way to "i / n"
function leadersHTML() { function leadersHTML() {
const leaders = model.leaders; const leaders = model.leaders;
if (!leaders) return ''; if (!leaders) return '';
const cards = [ return `<div class="stats-leaders">${leaders.map(leaderCardHTML).join('')}</div>`;
{ 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 `<div class="stats-leaders">${cards.map(leaderCardHTML).join('')}</div>`;
} }
function leaderCardHTML({ label, row, value }) { function leaderValueText(metric, value) {
return metric === 'gd' && value > 0 ? `+${value}` : `${value}`;
}
function leaderTeamHTML(row) {
const team = getData().teamById.get(row.teamId); const team = getData().teamById.get(row.teamId);
return `${flagImg(team, 30, 20)}<span class="leader-name">${team.name}</span>`;
}
// A leader card. Single-team group → a plain static card (identical to before).
// A tie → arrows + an indicator (dots up to DOTS_MAX, else an "i / n" counter);
// setupLeaderCarousels() wires the rotation. The big value is shared by the whole
// group (all tied on the metric), so only the flag+name swap as it rotates.
function leaderCardHTML({ id, labelKey, metric, group }) {
const label = t(labelKey);
const value = leaderValueText(metric, group[0][metric]);
if (group.length < 2) {
return `
<div class="leader-card glass">
<span class="leader-label">${label}</span>
<div class="leader-team">${leaderTeamHTML(group[0])}</div>
<span class="leader-value">${value}</span>
</div>`;
}
const indicator = group.length <= DOTS_MAX
? `<div class="leader-dots" aria-hidden="true">${group.map((_, i) =>
`<span class="leader-dot${i === 0 ? ' active' : ''}"></span>`).join('')}</div>`
: `<span class="leader-counter" aria-hidden="true">1 / ${group.length}</span>`;
return ` return `
<div class="leader-card glass"> <div class="leader-card glass" data-leader="${id}" role="group" aria-label="${label}">
<span class="leader-label">${label}</span> <span class="leader-label">${label}</span>
<div class="leader-team"> <div class="leader-stage">
${flagImg(team, 30, 20)} <button type="button" class="leader-nav leader-prev" aria-label="${t('stats.leaderPrev')}">&lsaquo;</button>
<span class="leader-name">${team.name}</span> <div class="leader-team">${leaderTeamHTML(group[0])}</div>
<button type="button" class="leader-nav leader-next" aria-label="${t('stats.leaderNext')}">&rsaquo;</button>
</div> </div>
<span class="leader-value">${value}</span> <span class="leader-value">${value}</span>
${indicator}
</div>`; </div>`;
} }
// Tied-leader carousels. Timers are tracked module-level and cleared at the top
// of render() so a re-render (langchange/datachange) never leaves an interval
// firing on detached DOM (cf. gotcha #6 — never double-schedule).
let leaderTimers = [];
function clearLeaderTimers() {
for (const id of leaderTimers) clearInterval(id);
leaderTimers = [];
}
// Auto-advance pauses on hover/focus and is disabled entirely under
// prefers-reduced-motion (arrows still work). A manual arrow click restarts the
// cadence implicitly: while the pointer/focus is on the card it stays paused, and
// leaving the card starts a fresh full interval.
function setupLeaderCarousels(root) {
const leaders = model.leaders;
if (!leaders) return;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
for (const leader of leaders) {
if (leader.group.length < 2) continue; // single team: static, no timer
const card = root.querySelector(`.leader-card[data-leader="${leader.id}"]`);
if (!card) continue;
const teamHost = card.querySelector('.leader-team');
const dots = card.querySelectorAll('.leader-dot');
const counter = card.querySelector('.leader-counter');
const group = leader.group;
let idx = 0;
let timer = null;
let paused = false;
const show = (i) => {
idx = (i + group.length) % group.length;
teamHost.innerHTML = leaderTeamHTML(group[idx]);
dots.forEach((dot, di) => dot.classList.toggle('active', di === idx));
if (counter) counter.textContent = `${idx + 1} / ${group.length}`;
};
const stop = () => {
if (!timer) return;
clearInterval(timer);
leaderTimers = leaderTimers.filter((x) => x !== timer);
timer = null;
};
const start = () => {
if (reduce || paused || timer) return;
timer = setInterval(() => show(idx + 1), ROTATE_MS);
leaderTimers.push(timer);
};
const pause = () => { paused = true; stop(); };
const resume = () => { paused = false; start(); };
card.querySelector('.leader-prev')?.addEventListener('click', () => show(idx - 1));
card.querySelector('.leader-next')?.addEventListener('click', () => show(idx + 1));
card.addEventListener('mouseenter', pause);
card.addEventListener('mouseleave', resume);
card.addEventListener('focusin', pause);
card.addEventListener('focusout', resume);
start();
}
}
function sortedTeamStats() { function sortedTeamStats() {
const dir = sortDir === 'asc' ? 1 : -1; const dir = sortDir === 'asc' ? 1 : -1;
if (sortKey === 'rank') { if (sortKey === 'rank') {