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();