// 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.
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
? `