// app.js — entry point: loadData() over data/*.json, tab routing with lastTab // persistence, formatMatchTime(), hero (live or next match + countdown), // dashboard cards. import { getPrefs, setPref, toggleFavorite } from './storage.js'; import { initI18n, setLang, getLang, getLocale, t, translatePhase } from './i18n.js'; import { initSchedule } from './schedule.js'; import { initGroups } from './groups.js'; import { initStadiums } from './stadiums.js'; import { initModal } from './modal.js'; import { initBracket } from './bracket.js'; import { initStats } from './stats.js'; // ---------------------------------------------------------------- data let data = null; const DATA_VERSION = '2026-06-15-rev1'; 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( 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(); }), ); data = { teams, groups, matches, results, stadiums, bracketConfig, 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])), }; return data; } export function getData() { return data; } // ---------------------------------------------------------------- time export function matchDateUTC(match) { return new Date(`${match.date}T${match.time}:00Z`); } export function formatMatchTime(match, stadium, mode = getPrefs().timeMode ?? 'local') { const options = { dateStyle: 'medium', timeStyle: 'short' }; if (mode === 'stadium' && stadium?.timezone) options.timeZone = stadium.timezone; return new Intl.DateTimeFormat(getLocale(), options).format(matchDateUTC(match)); } export function flagSrc(team) { return `assets/images/${team.flag}`; } // ---------------------------------------------------------------- tabs const TABS = ['home', 'matches', 'groups', 'bracket', 'stadiums', 'stats']; function activateTab(id, { updateHash = true } = {}) { const tab = TABS.includes(id) ? id : 'home'; for (const btn of document.querySelectorAll('.tab-btn')) { const active = btn.dataset.tab === tab; btn.classList.toggle('active', active); btn.setAttribute('aria-selected', String(active)); btn.setAttribute('tabindex', active ? '0' : '-1'); } for (const panelId of TABS) { document.getElementById(`panel-${panelId}`).hidden = panelId !== tab; } setPref('lastTab', tab); if (updateHash) history.replaceState(null, '', `#${tab}`); } // programmatic navigation for cross-view links (e.g. stadium → its matches) export function navigateTo(tab) { activateTab(tab); window.scrollTo({ top: 0 }); } function initTabs() { for (const btn of document.querySelectorAll('.tab-btn')) { btn.addEventListener('click', () => activateTab(btn.dataset.tab)); } // roving tabindex + arrow keys per the WAI-ARIA tabs pattern document.querySelector('.tabs').addEventListener('keydown', (event) => { if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return; event.preventDefault(); const buttons = [...document.querySelectorAll('.tab-btn')]; const current = buttons.findIndex((b) => b.classList.contains('active')); const next = event.key === 'ArrowLeft' ? (current - 1 + buttons.length) % buttons.length : event.key === 'ArrowRight' ? (current + 1) % buttons.length : event.key === 'Home' ? 0 : buttons.length - 1; activateTab(buttons[next].dataset.tab); buttons[next].focus(); }); window.addEventListener('hashchange', () => activateTab(location.hash.slice(1), { updateHash: false })); activateTab(location.hash.slice(1) || getPrefs().lastTab || 'home'); } // ---------------------------------------------------------------- hero // How long a match stays "in progress" after kickoff while results.json hasn't // caught up yet. Group games run ~90'+stoppage (~2h); knockout games can reach // extra time + penalties (~3h). JSON (finished/live) still overrides the clock. const GROUP_WINDOW_MS = 2 * 60 * 60 * 1000; const KO_WINDOW_MS = 3 * 60 * 60 * 1000; function matchWindowMs(match) { return match.phase.startsWith('Group') ? GROUP_WINDOW_MS : KO_WINDOW_MS; } // Hybrid state of a match at instant `now`: the JSON wins when it says finished // or live; otherwise the clock advances the state so the hero flips at kickoff // and again at kickoff+window with no JSON edit. Pure function, easy to reason about. // Exported so schedule.js shares the exact same hybrid rule (occurrence filter + chip). export function matchState(match, result, now) { const status = result?.status ?? 'scheduled'; const kickoff = matchDateUTC(match).getTime(); if (status === 'finished' || now >= kickoff + matchWindowMs(match)) return 'over'; if (status === 'live' || now >= kickoff) return 'live'; return 'upcoming'; } // Featured match = the earliest match that isn't over yet (in progress or // upcoming); ties broken by id, matching schedule.js ordering. function findFeaturedMatch(now) { const { matches, resultByMatchId } = data; return matches .filter((m) => matchState(m, resultByMatchId.get(m.id), now) !== 'over') .sort((a, b) => matchDateUTC(a) - matchDateUTC(b) || a.id - b.id)[0] ?? null; } // Compact signature of "what the hero should show now"; a change drives a rebuild. function heroSignature(match, now) { if (!match) return '∅'; return `${match.id}:${matchState(match, data.resultByMatchId.get(match.id), now)}`; } function heroTeamHTML(teamId) { const team = data.teamById.get(teamId); if (!team) return `
${t('app.tbd')}
`; return `
${team.name}
`; } let heroTimer = null; let heroSig = null; let countdownTarget = null; let countdownEls = null; function renderHero() { const root = document.getElementById('hero-content'); const now = Date.now(); const match = findFeaturedMatch(now); heroSig = heroSignature(match, now); countdownTarget = null; countdownEls = null; if (!match) { root.innerHTML = ''; startHeroClock(); return; } const result = data.resultByMatchId.get(match.id); const stadium = data.stadiumByName.get(match.stadium); const live = matchState(match, result, now) === 'live'; const hasScore = result?.homeScore != null && result?.awayScore != null; // Live shows the JSON score only when it exists; a clock-driven in-progress // match (JSON not updated yet) falls back to "vs", like an upcoming match. const center = live && hasScore ? `
${result.homeScore}${result.awayScore}
` : `
${t('hero.vs')}
`; root.innerHTML = `

${live ? `● ${t('hero.inProgress')}` : t('hero.nextMatch')} ${translatePhase(match.phase)}

${heroTeamHTML(match.homeTeam)} ${center} ${heroTeamHTML(match.awayTeam)}

${formatMatchTime(match, stadium)} · ${match.stadium}, ${match.city}

${live ? '' : `
`} `; if (!live) setupCountdown(matchDateUTC(match).getTime()); startHeroClock(); } function setupCountdown(target) { const root = document.getElementById('countdown'); const units = ['days', 'hours', 'minutes', 'seconds']; root.innerHTML = units.map((unit) => `
0 ${t(`countdown.${unit}`)}
`).join(''); countdownTarget = target; countdownEls = Object.fromEntries( units.map((unit) => [unit, root.querySelector(`[data-unit="${unit}"]`)]), ); updateCountdown(); } function updateCountdown() { if (!countdownEls) return; const seconds = Math.floor(Math.max(0, countdownTarget - Date.now()) / 1000); countdownEls.days.textContent = Math.floor(seconds / 86400); countdownEls.hours.textContent = String(Math.floor((seconds % 86400) / 3600)).padStart(2, '0'); countdownEls.minutes.textContent = String(Math.floor((seconds % 3600) / 60)).padStart(2, '0'); countdownEls.seconds.textContent = String(seconds % 60).padStart(2, '0'); } // Single persistent 1s driver. Most ticks only refresh the countdown digits; // when the featured match or its state changes (kickoff, end-of-window, next // match), the signature flips and we rebuild the hero — no reload, no JSON edit. function startHeroClock() { if (heroTimer) return; heroTimer = setInterval(heroTick, 1000); } function heroTick() { const now = Date.now(); const sig = heroSignature(findFeaturedMatch(now), now); if (sig !== heroSig) renderHero(); else updateCountdown(); } // ------------------------------------------------------------ dashboard const ICONS = { ball: '', check: '', clock: '', shield: '', }; function renderDashboard() { const { matches, teams, results } = data; const finished = results.filter((r) => r.status === 'finished').length; const scheduled = results.filter((r) => r.status === 'scheduled').length; const cards = [ { icon: ICONS.ball, value: matches.length, label: 'dash.total' }, { icon: ICONS.check, value: finished, label: 'dash.completed' }, { icon: ICONS.clock, value: scheduled, label: 'dash.upcoming' }, { icon: ICONS.shield, value: teams.length, label: 'dash.teams' }, ]; document.getElementById('dashboard').innerHTML = cards.map((card) => `
${card.icon} ${card.value} ${t(card.label)}
`).join(''); } // ---------------------------------------------------------------- init // shared tooltip for abbreviated table headers (Stats + Groups). A single // fixed-position bubble driven by event delegation, so it survives table // re-renders and is never clipped by a table's overflow/stacking context. // Hover + keyboard focus both trigger it; screen readers use the header's // aria-label, and small screens fall back to the visible legend. function initTooltips() { const tip = document.createElement('div'); tip.className = 'app-tooltip'; tip.setAttribute('role', 'tooltip'); tip.hidden = true; document.body.appendChild(tip); let current = null; const show = (el) => { current = el; tip.textContent = el.dataset.tip; tip.style.left = '-9999px'; tip.style.top = '-9999px'; tip.hidden = false; const rect = el.getBoundingClientRect(); const box = tip.getBoundingClientRect(); let left = Math.round(rect.left + rect.width / 2 - box.width / 2); left = Math.max(8, Math.min(left, window.innerWidth - box.width - 8)); let top = Math.round(rect.top - box.height - 8); if (top < 8) top = Math.round(rect.bottom + 8); // flip below if no room above tip.style.left = `${left}px`; tip.style.top = `${top}px`; }; const hide = (el) => { if (!el || el === current) { tip.hidden = true; current = null; } }; for (const event of ['mouseover', 'focusin']) { document.addEventListener(event, (e) => { const el = e.target.closest?.('.has-tip[data-tip]'); if (el) show(el); }); } for (const event of ['mouseout', 'focusout']) { document.addEventListener(event, (e) => { const el = e.target.closest?.('.has-tip[data-tip]'); if (el) hide(el); }); } document.addEventListener('scroll', () => hide(current), true); } // global star delegation — stars exist in schedule, groups, and modal function initFavorites() { document.addEventListener('click', (event) => { const btn = event.target.closest('.fav-btn'); if (!btn) return; event.stopPropagation(); toggleFavorite(btn.dataset.fav); document.dispatchEvent(new CustomEvent('favchange')); }, true); } function syncTimeToggle() { const btn = document.getElementById('time-toggle'); const mode = getPrefs().timeMode ?? 'local'; btn.textContent = `🕐 ${t(mode === 'local' ? 'time.local' : 'time.stadium')}`; btn.setAttribute('aria-pressed', String(mode === 'stadium')); } function initTimeToggle() { const btn = document.getElementById('time-toggle'); btn.addEventListener('click', () => { const next = (getPrefs().timeMode ?? 'local') === 'local' ? 'stadium' : 'local'; setPref('timeMode', next); syncTimeToggle(); document.dispatchEvent(new CustomEvent('timemodechange')); }); document.addEventListener('langchange', syncTimeToggle); syncTimeToggle(); } function initLangSwitch() { const buttons = document.querySelectorAll('.lang-btn'); const sync = () => { for (const btn of buttons) btn.classList.toggle('active', btn.dataset.lang === getLang()); }; for (const btn of buttons) { btn.addEventListener('click', () => { setLang(btn.dataset.lang); sync(); }); } sync(); } function renderHome() { renderHero(); renderDashboard(); } function showError(error) { document.getElementById('hero-content').innerHTML = `

${t('app.error')}

${t('app.errorHint')}

${error.message}

`; } async function init() { initI18n(); initTabs(); initLangSwitch(); initTimeToggle(); initFavorites(); initTooltips(); document.addEventListener('langchange', renderHome); document.addEventListener('timemodechange', renderHero); try { await loadData(); renderHome(); initModal(); initSchedule(); initGroups(); initBracket(); initStadiums(); initStats(); } catch (error) { showError(error); } } init();