feat(stats): degradation engine + scrollspy sub-nav (Stage A)

Scaffold the post-Cup stats screen (.agents/stats-screen-plan.md) on the
feature/stats-final-screen branch.

- loadData(): fault-tolerant optional data layers (players, player-events,
  awards, keeper-stats, curiosities, all-time-baselines) via loadOptional() —
  an absent/404 file defaults to empty SILENTLY (graceful degradation, keeps
  the console clean), warning only on a present-but-malformed file. The 6 core
  files still throw on failure.
- stats.js SECTIONS registry: a section and its sub-nav chip render only when
  available(model) holds, else they are omitted from the DOM entirely (no
  placeholder / no coming-soon). Overview/Teams live; the 4 future sections
  stay dark until later stages.
- Sticky scrollspy sub-nav: hash-safe anchor chips (preventDefault +
  scrollIntoView, never touch location.hash so the tab router does not bounce
  to Home); position-based scrollspy with an explicit page-bottom -> last
  section rule (robust on short pages). --header-h kept live via a
  ResizeObserver so the nav sticks correctly under the variable-height header.
- flagImg() monogram fallback: a broken flag SVG becomes a 3-letter code span,
  never a broken-image icon.
- i18n stats.nav* keys (EN/PT); stats.css for sub-nav / section / fallback.

No DATA_VERSION bump (no deployed data changed). No index.html change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Lucas Kalil 2026-06-16 19:36:32 -03:00
parent 79e746e4d4
commit 387fab3c8b
4 changed files with 253 additions and 4 deletions

View file

@ -17,18 +17,49 @@ let data = null;
const DATA_VERSION = '2026-06-16-rev2';
// Optional data layers for the post-tournament stats screen (players, awards,
// editorial — see .agents/stats-screen-plan.md §0.2). They don't exist yet, so
// an absent/404 file is the NORMAL "this layer hasn't arrived" state: return the
// empty default silently (graceful degradation — never surface the gap, and keep
// the console clean). Warn only when a file is present but malformed (a real dev
// error). Never throws — the stats screen lights these up as the JSON lands.
async function loadOptional(name, fallback) {
try {
const res = await fetch(`data/${name}.json?v=${DATA_VERSION}`);
if (!res.ok) return fallback; // not provided yet → empty, no noise
return await res.json();
} catch (err) {
console.warn(`data/${name}.json present but unreadable — ignoring`, err);
return fallback;
}
}
export async function loadData() {
if (data) return data;
const files = ['teams', 'groups', 'matches', 'results', 'stadiums', 'bracket-config'];
const [teams, groups, matches, results, stadiums, bracketConfig] = await Promise.all(
// Core files are mandatory: a failure here is fatal (throws → showError()).
const corePromise = Promise.all(
files.map(async (name) => {
const res = await fetch(`data/${name}.json?v=${DATA_VERSION}`);
if (!res.ok) throw new Error(`data/${name}.json — HTTP ${res.status}`);
return res.json();
}),
);
// Optional layers fetched concurrently; each defaults to empty, never fatal.
const optionalPromise = Promise.all([
loadOptional('players', []),
loadOptional('player-events', []),
loadOptional('awards', {}),
loadOptional('keeper-stats', []),
loadOptional('curiosities', []),
loadOptional('all-time-baselines', {}),
]);
const [teams, groups, matches, results, stadiums, bracketConfig] = await corePromise;
const [players, playerEvents, awards, keeperStats, curiosities, allTimeBaselines] =
await optionalPromise;
data = {
teams, groups, matches, results, stadiums, bracketConfig,
players, playerEvents, awards, keeperStats, curiosities, allTimeBaselines,
teamById: new Map(teams.map((team) => [team.id, team])),
stadiumByName: new Map(stadiums.map((s) => [s.name, s])),
resultByMatchId: new Map(results.map((r) => [r.matchId, r])),
@ -515,6 +546,18 @@ function initLangSwitch() {
sync();
}
// The header is sticky with a VARIABLE height (one row ≥1100px, two bands below).
// Expose its live height as --header-h so the stats sub-nav can stick right
// beneath it and sections can offset their scroll target at every breakpoint.
function trackHeaderHeight() {
const header = document.querySelector('.site-header');
if (!header) return;
const set = () => document.documentElement.style.setProperty('--header-h', `${header.offsetHeight}px`);
set();
if ('ResizeObserver' in window) new ResizeObserver(set).observe(header);
else window.addEventListener('resize', set);
}
function renderHome() {
renderHero();
renderDashboard();
@ -530,6 +573,7 @@ function showError(error) {
async function init() {
initI18n();
initTabs();
trackHeaderHeight();
initLangSwitch();
initTimeToggle();
initFavorites();

View file

@ -119,6 +119,13 @@ const dicts = {
'stats.tileAvg': 'Goals / match',
'stats.tileBiggestMargin': 'Biggest margin',
'stats.tileCleanSheets': 'Clean sheets',
'stats.sectionsNav': 'Statistics sections',
'stats.navOverview': 'Overview',
'stats.navTeams': 'Teams',
'stats.navPlayers': 'Players',
'stats.navRecords': 'Records',
'stats.navComparator': 'Comparator',
'stats.navArchive': 'Archive',
'stats.overviewTitle': 'Overview',
'stats.played': 'Matches played',
'stats.decisive': 'Decisive',
@ -259,6 +266,13 @@ const dicts = {
'stats.tileAvg': 'Gols por jogo',
'stats.tileBiggestMargin': 'Maior margem',
'stats.tileCleanSheets': 'Sem sofrer gols',
'stats.sectionsNav': 'Seções de estatísticas',
'stats.navOverview': 'Visão geral',
'stats.navTeams': 'Seleções',
'stats.navPlayers': 'Jogadores',
'stats.navRecords': 'Recordes',
'stats.navComparator': 'Comparador',
'stats.navArchive': 'Arquivo',
'stats.overviewTitle': 'Visão geral',
'stats.played': 'Jogos disputados',
'stats.decisive': 'Decididas',

View file

@ -29,6 +29,22 @@ const COLUMNS = [
{ key: 'cleanSheets', label: 'stats.colCS', tip: 'tip.cs' },
];
// Sub-nav sections (graceful-degradation contract, stats-screen-plan.md §0.1): a
// section renders — and its sub-nav chip appears — only when `available(model)`
// holds. Otherwise it is omitted from the DOM entirely (no placeholder, no "—",
// no "coming soon") and the nav never points at emptiness. Later stages flip
// `available` and supply `body` for players/records/comparator/archive; the same
// code base thus renders a coherent, "full" screen with only today's data and
// lights up sections as each data layer arrives.
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: 'comparator', navKey: 'stats.navComparator', available: () => false, body: () => '' },
{ id: 'archive', navKey: 'stats.navArchive', available: () => false, body: () => '' },
];
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.
@ -165,6 +181,7 @@ function computeLeaders(teamStats) {
// ---------------------------------------------------------------- render
export function initStats() {
installImageFallback();
render();
// labels re-render on language change; the derived model never changes at
// runtime (data is static per page load) so it is reused.
@ -176,7 +193,15 @@ export function initStats() {
function render() {
if (!model) model = buildStatsModel();
const root = document.getElementById('stats-root');
root.innerHTML = heroHTML() + overviewHTML() + teamsSectionHTML() + footerHTML();
const sections = SECTIONS.filter((section) => section.available(model));
root.innerHTML =
heroHTML()
+ subNavHTML(sections)
+ sections.map((section) => `
<section id="stats-${section.id}" class="stats-section" tabindex="-1" aria-label="${t(section.navKey)}">
${section.body(model)}
</section>`).join('')
+ footerHTML();
root.querySelector('#stats-see-matches')?.addEventListener('click', () => navigateTo('matches'));
const teamsHost = root.querySelector('#stats-teams-table');
if (teamsHost) {
@ -184,6 +209,103 @@ function render() {
renderTeamTable();
}
setupCountUps(root);
setupSubNav(root, sections);
}
// ----------------------------------------------------------- sub-nav
function subNavHTML(sections) {
if (sections.length < 2) return ''; // a lone section needs no navigation
const chips = sections.map((section, i) => `
<a class="stats-subnav-chip${i === 0 ? ' active' : ''}" href="#stats-${section.id}"
data-section="${section.id}" aria-current="${i === 0 ? 'true' : 'false'}">${t(section.navKey)}</a>`).join('');
return `<nav class="stats-subnav" aria-label="${t('stats.sectionsNav')}">${chips}</nav>`;
}
let spyScrollHandler = null;
function setupSubNav(root, sections) {
const nav = root.querySelector('.stats-subnav');
if (!nav) return;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// chip → smooth-scroll to the section WITHOUT touching location.hash: the tab
// router (app.js) listens on hashchange, so a real #fragment would route to
// an unknown tab and bounce the user to Home. preventDefault keeps us in-tab.
nav.addEventListener('click', (event) => {
const chip = event.target.closest('.stats-subnav-chip');
if (!chip) return;
event.preventDefault();
document.getElementById(`stats-${chip.dataset.section}`)
?.scrollIntoView({ behavior: reduce ? 'auto' : 'smooth', block: 'start' });
setActiveChip(nav, chip.dataset.section);
});
// scrollspy: active = the last section whose heading has scrolled under the
// sticky sub-nav line; at the page bottom the last section always wins (a short
// final section may never reach the line — the classic scrollspy edge case an
// IntersectionObserver band leaves unlit). Reading getBoundingClientRect on a
// handful of sections per frame is cheap and always correct on short pages.
const ids = sections.map((section) => section.id);
const updateSpy = () => {
const headerH = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--header-h')) || 64;
const line = headerH + 80; // just beneath the sticky sub-nav
let activeId = ids[0];
for (const id of ids) {
if (document.getElementById(`stats-${id}`)?.getBoundingClientRect().top <= line) activeId = id;
}
if (window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2) {
activeId = ids[ids.length - 1]; // bottom reached → last section
}
setActiveChip(nav, activeId);
};
if (spyScrollHandler) window.removeEventListener('scroll', spyScrollHandler);
let raf = 0;
spyScrollHandler = () => {
if (raf) return;
raf = requestAnimationFrame(() => { raf = 0; updateSpy(); });
};
window.addEventListener('scroll', spyScrollHandler, { passive: true });
updateSpy();
}
function setActiveChip(nav, id) {
for (const chip of nav.querySelectorAll('.stats-subnav-chip')) {
const on = chip.dataset.section === id;
chip.classList.toggle('active', on);
chip.setAttribute('aria-current', on ? 'true' : 'false');
}
// keep the active chip visible when the nav scrolls horizontally on mobile
// (only moves the nav's own scroll, never the page).
const active = nav.querySelector('.stats-subnav-chip.active');
if (active) nav.scrollLeft = active.offsetLeft - (nav.clientWidth - active.clientWidth) / 2;
}
// ----------------------------------------------------------- flags
// Flag <img> that degrades to a 3-letter monogram if the SVG is missing — never
// a broken-image icon (graceful degradation §0.3). Used everywhere the stats
// screen shows a flag so the fallback is uniform.
function flagImg(team, w, h, cls = 'flag') {
return `<img class="${cls}" src="${flagSrc(team)}" alt="" width="${w}" height="${h}" loading="lazy" data-monogram="${team.id}">`;
}
let fallbackInstalled = false;
function installImageFallback() {
if (fallbackInstalled) return;
fallbackInstalled = true;
// error events don't bubble → listen in the capture phase. Only opted-in
// images (data-monogram) are touched, so other views are unaffected.
document.addEventListener('error', (event) => {
const img = event.target;
if (!(img instanceof HTMLImageElement) || !img.dataset.monogram) return;
const span = document.createElement('span');
span.className = 'flag-fallback';
span.style.width = `${img.getAttribute('width')}px`;
span.style.height = `${img.getAttribute('height')}px`;
span.textContent = img.dataset.monogram;
img.replaceWith(span);
}, true);
}
function heroHTML() {
@ -292,7 +414,7 @@ function leaderCardHTML({ label, row, value }) {
<div class="leader-card glass">
<span class="leader-label">${label}</span>
<div class="leader-team">
<img class="flag" src="${flagSrc(team)}" alt="" width="30" height="20" loading="lazy">
${flagImg(team, 30, 20)}
<span class="leader-name">${team.name}</span>
</div>
<span class="leader-value">${value}</span>
@ -340,7 +462,7 @@ function tableHTML(rows, startIndex) {
<tr class="${row.played === 0 ? 'row-idle' : ''}">
<td class="col-rank">${startIndex + i + 1}</td>
<td class="col-team">
<img class="flag" src="${flagSrc(team)}" alt="" width="22" height="15" loading="lazy">
${flagImg(team, 22, 15)}
<span>${team.name}</span>
</td>
${cells}