mirror of
https://github.com/LucasKalil-Programador/world-2026-hub.git
synced 2026-07-04 17:41:28 -03:00
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:
parent
79e746e4d4
commit
387fab3c8b
4 changed files with 253 additions and 4 deletions
|
|
@ -381,6 +381,75 @@
|
|||
background: var(--glass-bg-strong);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------- sub-nav (scrollspy) */
|
||||
|
||||
/* sticks just below the variable-height sticky header (--header-h is kept live
|
||||
by app.js trackHeaderHeight). NOT a tablist — it's a <nav> of in-page anchors
|
||||
whose clicks are intercepted, so the top tablist's arrow keys never conflict. */
|
||||
.stats-subnav {
|
||||
position: sticky;
|
||||
top: var(--header-h, 64px);
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
margin: 1.4rem 0;
|
||||
padding: 0.4rem;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
background: rgba(8, 20, 33, 0.88);
|
||||
backdrop-filter: blur(10px) saturate(1.2);
|
||||
-webkit-backdrop-filter: blur(10px) saturate(1.2);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.stats-subnav::-webkit-scrollbar { display: none; }
|
||||
|
||||
.stats-subnav-chip {
|
||||
flex: 0 0 auto;
|
||||
padding: 0.4rem 0.95rem;
|
||||
border-radius: 999px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
transition: color 0.2s, background-color 0.2s;
|
||||
}
|
||||
|
||||
.stats-subnav-chip:hover { color: var(--text-primary); }
|
||||
|
||||
.stats-subnav-chip.active {
|
||||
color: var(--bg-primary);
|
||||
background: linear-gradient(135deg, var(--accent-gold), var(--accent-gold-soft));
|
||||
}
|
||||
|
||||
/* offset scroll targets so the sticky header + sub-nav never cover a heading */
|
||||
.stats-section {
|
||||
scroll-margin-top: calc(var(--header-h, 64px) + 3.75rem);
|
||||
}
|
||||
|
||||
.stats-section + .stats-section { margin-top: 2.5rem; }
|
||||
.stats-section:focus { outline: none; }
|
||||
/* section spacing is owned by the wrapper, not the first heading inside it */
|
||||
.stats-section > .section-title:first-child { margin-top: 0; }
|
||||
|
||||
/* flag monogram fallback (graceful degradation §0.3) — never a broken-img icon */
|
||||
.flag-fallback {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--glass-bg-strong);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 3px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.5rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
vertical-align: middle;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- responsive */
|
||||
|
||||
@media (max-width: 600px) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue