mirror of
https://github.com/LucasKalil-Programador/world-2026-hub.git
synced 2026-07-04 17:41:28 -03:00
feat: add world cup 2026 hub spa with bracket simulation and i18n
This commit is contained in:
parent
c7088bc31b
commit
39f5881d33
13 changed files with 3702 additions and 0 deletions
294
assets/js/app.js
Normal file
294
assets/js/app.js
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
// 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';
|
||||
|
||||
// ---------------------------------------------------------------- data
|
||||
|
||||
let data = null;
|
||||
|
||||
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`);
|
||||
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'];
|
||||
|
||||
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
|
||||
|
||||
let countdownTimer = null;
|
||||
|
||||
function findFeaturedMatch() {
|
||||
const { matches, resultByMatchId } = data;
|
||||
const live = matches.find((m) => resultByMatchId.get(m.id)?.status === 'live');
|
||||
if (live) return live;
|
||||
return matches
|
||||
.filter((m) => (resultByMatchId.get(m.id)?.status ?? 'scheduled') === 'scheduled')
|
||||
.sort((a, b) => matchDateUTC(a) - matchDateUTC(b))[0] ?? null;
|
||||
}
|
||||
|
||||
function heroTeamHTML(teamId) {
|
||||
const team = data.teamById.get(teamId);
|
||||
if (!team) return `<div class="hero-team"><span class="hero-team-name">${t('app.tbd')}</span></div>`;
|
||||
return `
|
||||
<div class="hero-team">
|
||||
<img class="flag flag-lg" src="${flagSrc(team)}" alt="" width="64" height="43">
|
||||
<span class="hero-team-name">${team.name}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderHero() {
|
||||
clearInterval(countdownTimer);
|
||||
const root = document.getElementById('hero-content');
|
||||
const match = findFeaturedMatch();
|
||||
if (!match) {
|
||||
root.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
const result = data.resultByMatchId.get(match.id);
|
||||
const stadium = data.stadiumByName.get(match.stadium);
|
||||
const live = result?.status === 'live';
|
||||
|
||||
const center = live
|
||||
? `<div class="hero-score">${result.homeScore}<span class="hero-score-sep">–</span>${result.awayScore}</div>`
|
||||
: `<div class="hero-vs">${t('hero.vs')}</div>`;
|
||||
|
||||
root.innerHTML = `
|
||||
<p class="hero-label">
|
||||
${live ? `<span class="live-badge pulse">● ${t('hero.live')}</span>` : t('hero.nextMatch')}
|
||||
<span class="hero-phase">${translatePhase(match.phase)}</span>
|
||||
</p>
|
||||
<div class="hero-matchup">
|
||||
${heroTeamHTML(match.homeTeam)}
|
||||
${center}
|
||||
${heroTeamHTML(match.awayTeam)}
|
||||
</div>
|
||||
<p class="hero-meta">${formatMatchTime(match, stadium)} · ${match.stadium}, ${match.city}</p>
|
||||
${live ? '' : `<div class="countdown" id="countdown" role="timer" aria-label="${t('hero.countdownLabel')}"></div>`}
|
||||
`;
|
||||
if (!live) startCountdown(matchDateUTC(match));
|
||||
}
|
||||
|
||||
function startCountdown(target) {
|
||||
const root = document.getElementById('countdown');
|
||||
const units = ['days', 'hours', 'minutes', 'seconds'];
|
||||
root.innerHTML = units.map((unit) => `
|
||||
<div class="count-box">
|
||||
<span class="count-value" data-unit="${unit}">0</span>
|
||||
<span class="count-label">${t(`countdown.${unit}`)}</span>
|
||||
</div>`).join('');
|
||||
const values = Object.fromEntries(
|
||||
units.map((unit) => [unit, root.querySelector(`[data-unit="${unit}"]`)]),
|
||||
);
|
||||
|
||||
const tick = () => {
|
||||
const diff = target - Date.now();
|
||||
if (diff <= 0) {
|
||||
clearInterval(countdownTimer);
|
||||
root.innerHTML = `<p class="hero-kickoff">${t('hero.kickoff')}</p>`;
|
||||
return;
|
||||
}
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
values.days.textContent = Math.floor(seconds / 86400);
|
||||
values.hours.textContent = String(Math.floor((seconds % 86400) / 3600)).padStart(2, '0');
|
||||
values.minutes.textContent = String(Math.floor((seconds % 3600) / 60)).padStart(2, '0');
|
||||
values.seconds.textContent = String(seconds % 60).padStart(2, '0');
|
||||
};
|
||||
tick();
|
||||
countdownTimer = setInterval(tick, 1000);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ dashboard
|
||||
|
||||
const ICONS = {
|
||||
ball: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7.5 16 10.4l-1.5 4.7h-5L8 10.4z"/></svg>',
|
||||
check: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="m8.5 12.2 2.4 2.4 4.6-5.2"/></svg>',
|
||||
clock: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3.2 1.8"/></svg>',
|
||||
shield: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M12 3.5 19 6v6c0 4.4-3 7.4-7 8.5-4-1.1-7-4.1-7-8.5V6z"/></svg>',
|
||||
};
|
||||
|
||||
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) => `
|
||||
<div class="stat-card glass fade-in">
|
||||
<span class="stat-icon">${card.icon}</span>
|
||||
<span class="stat-value">${card.value}</span>
|
||||
<span class="stat-label">${t(card.label)}</span>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- init
|
||||
|
||||
// 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 = `
|
||||
<p class="hero-label">${t('app.error')}</p>
|
||||
<p class="hero-meta">${t('app.errorHint')}</p>
|
||||
<p class="hero-meta"><code>${error.message}</code></p>`;
|
||||
}
|
||||
|
||||
async function init() {
|
||||
initI18n();
|
||||
initTabs();
|
||||
initLangSwitch();
|
||||
initTimeToggle();
|
||||
initFavorites();
|
||||
document.addEventListener('langchange', renderHome);
|
||||
document.addEventListener('timemodechange', renderHero);
|
||||
try {
|
||||
await loadData();
|
||||
renderHome();
|
||||
initModal();
|
||||
initSchedule();
|
||||
initGroups();
|
||||
initBracket();
|
||||
initStadiums();
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
782
assets/js/bracket.js
Normal file
782
assets/js/bracket.js
Normal file
|
|
@ -0,0 +1,782 @@
|
|||
// bracket.js — knockout bracket. R32 slots come from bracket-config.json
|
||||
// (group positions resolved via computeStandings(), best-third slots via
|
||||
// thirdPlaceAssignment); every later round is generated by sequential pairing
|
||||
// of winners (nodes 0-1 → next 0, 2-3 → next 1, …). SF losers feed the
|
||||
// third-place match. Unresolvable slots render as placeholders, unplayed
|
||||
// matches as TBD. Interactions land in step 8, simulation in step 9.
|
||||
|
||||
import { getData, flagSrc } from './app.js';
|
||||
import { get as storageGet, set as storageSet, getFavorites } from './storage.js';
|
||||
import { computeStandings, isGroupFinished } from './groups.js';
|
||||
import { t, translatePhase } from './i18n.js';
|
||||
import { openMatchModal } from './modal.js';
|
||||
|
||||
const ROUNDS = [
|
||||
{ id: 'R32', phase: 'Round of 32', size: 16 },
|
||||
{ id: 'R16', phase: 'Round of 16', size: 8 },
|
||||
{ id: 'QF', phase: 'Quarterfinals', size: 4 },
|
||||
{ id: 'SF', phase: 'Semifinals', size: 2 },
|
||||
{ id: 'FINAL', phase: 'Final', size: 1 },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------- tree
|
||||
|
||||
let cachedTree = null;
|
||||
|
||||
// results are static per page load; simulation (step 9) will call this
|
||||
export function invalidateBracket() {
|
||||
cachedTree = null;
|
||||
}
|
||||
|
||||
export function getBracketTree() {
|
||||
if (!cachedTree) cachedTree = buildTree();
|
||||
return cachedTree;
|
||||
}
|
||||
|
||||
function buildTree() {
|
||||
const { matches, resultByMatchId, bracketConfig } = getData();
|
||||
const simulation = storageGet('simulation', {});
|
||||
const standings = computeStandings();
|
||||
const matchByRef = new Map(matches.filter((m) => m.bracketRef).map((m) => [m.bracketRef, m]));
|
||||
const nodesByRef = new Map();
|
||||
const rounds = [];
|
||||
let previous = null;
|
||||
|
||||
for (const round of ROUNDS) {
|
||||
const nodes = [];
|
||||
for (let i = 0; i < round.size; i += 1) {
|
||||
const ref = round.id === 'FINAL' ? 'FINAL' : `${round.id}-${i + 1}`;
|
||||
const node = {
|
||||
ref,
|
||||
round: round.id,
|
||||
phase: round.phase,
|
||||
match: matchByRef.get(ref) ?? null,
|
||||
home: null,
|
||||
away: null,
|
||||
winner: null,
|
||||
loser: null,
|
||||
result: null,
|
||||
};
|
||||
if (round.id === 'R32') {
|
||||
const entry = bracketConfig.round32[i];
|
||||
node.home = configSlot(entry.home, standings, bracketConfig);
|
||||
node.away = configSlot(entry.away, standings, bracketConfig);
|
||||
} else {
|
||||
node.home = previous[i * 2].winner ? { teamId: previous[i * 2].winner } : { ph: { kind: 'tbd' } };
|
||||
node.away = previous[i * 2 + 1].winner ? { teamId: previous[i * 2 + 1].winner } : { ph: { kind: 'tbd' } };
|
||||
}
|
||||
decide(node, resultByMatchId);
|
||||
applySimulation(node, simulation);
|
||||
nodes.push(node);
|
||||
nodesByRef.set(ref, node);
|
||||
}
|
||||
rounds.push({ ...round, nodes });
|
||||
previous = nodes;
|
||||
}
|
||||
|
||||
const [sf1, sf2] = rounds.find((r) => r.id === 'SF').nodes;
|
||||
const third = {
|
||||
ref: 'THIRD-PLACE',
|
||||
round: 'THIRD',
|
||||
phase: 'Third Place',
|
||||
match: matchByRef.get('THIRD-PLACE') ?? null,
|
||||
home: sf1.loser ? { teamId: sf1.loser } : { ph: { kind: 'tbd' } },
|
||||
away: sf2.loser ? { teamId: sf2.loser } : { ph: { kind: 'tbd' } },
|
||||
winner: null,
|
||||
loser: null,
|
||||
result: null,
|
||||
};
|
||||
decide(third, resultByMatchId);
|
||||
applySimulation(third, simulation);
|
||||
nodesByRef.set('THIRD-PLACE', third);
|
||||
|
||||
return { rounds, third, nodesByRef, champion: nodesByRef.get('FINAL').winner };
|
||||
}
|
||||
|
||||
function configSlot(spec, standings, config) {
|
||||
if (spec.type === 'third') {
|
||||
const group = config.thirdPlaceAssignment?.[String(spec.slot)];
|
||||
if (group && isGroupFinished(group)) return { teamId: standings[group][2].teamId };
|
||||
return { ph: { kind: 'third', n: spec.slot } };
|
||||
}
|
||||
if (isGroupFinished(spec.ref)) return { teamId: standings[spec.ref][spec.pos - 1].teamId };
|
||||
return { ph: { kind: spec.pos === 1 ? 'groupWinner' : 'groupRunnerUp', g: spec.ref } };
|
||||
}
|
||||
|
||||
// real finished results only — the simulation overlay is applySimulation()'s
|
||||
// job and always loses to a real result
|
||||
function decide(node, resultByMatchId) {
|
||||
const result = node.match ? resultByMatchId.get(node.match.id) : null;
|
||||
node.result = result ?? null;
|
||||
node.simulated = false;
|
||||
node.simScore = null;
|
||||
if (!result || result.status !== 'finished' || !node.home.teamId || !node.away.teamId) return;
|
||||
let side = result.homeScore > result.awayScore ? 'home'
|
||||
: result.homeScore < result.awayScore ? 'away'
|
||||
: null;
|
||||
if (!side && result.penalties) {
|
||||
side = result.penalties.home > result.penalties.away ? 'home' : 'away';
|
||||
}
|
||||
if (!side) return;
|
||||
node.winner = node[side].teamId;
|
||||
node.loser = node[side === 'home' ? 'away' : 'home'].teamId;
|
||||
}
|
||||
|
||||
function isSimulatable(node) {
|
||||
const realLocked = node.result && node.result.status !== 'scheduled';
|
||||
return Boolean(node.match) && !realLocked && Boolean(node.home.teamId) && Boolean(node.away.teamId);
|
||||
}
|
||||
|
||||
// User simulation overlays matches without a real final result. Entries whose
|
||||
// winner no longer matches either resolved team (stale picks after upstream
|
||||
// changes) are silently ignored — same validation the prediction import uses.
|
||||
function applySimulation(node, simulation) {
|
||||
if (node.winner || !node.home.teamId || !node.away.teamId) return;
|
||||
if (node.result?.status === 'finished') return;
|
||||
const entry = simulation[node.ref];
|
||||
if (!entry || (entry.winner !== node.home.teamId && entry.winner !== node.away.teamId)) return;
|
||||
node.winner = entry.winner;
|
||||
node.loser = entry.winner === node.home.teamId ? node.away.teamId : node.home.teamId;
|
||||
node.simulated = true;
|
||||
const parsed = /^(\d+)-(\d+)$/.exec(entry.score ?? '');
|
||||
node.simScore = parsed ? { home: Number(parsed[1]), away: Number(parsed[2]) } : null;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------ display helpers
|
||||
|
||||
function slotDisplay(slot) {
|
||||
if (slot?.teamId) {
|
||||
const team = getData().teamById.get(slot.teamId);
|
||||
return { team, label: team.name };
|
||||
}
|
||||
const ph = slot?.ph ?? { kind: 'tbd' };
|
||||
switch (ph.kind) {
|
||||
case 'groupWinner': return { team: null, label: t('bracket.groupWinner').replace('{g}', ph.g) };
|
||||
case 'groupRunnerUp': return { team: null, label: t('bracket.groupRunnerUp').replace('{g}', ph.g) };
|
||||
case 'third': return { team: null, label: t('bracket.bestThird').replace('{n}', ph.n) };
|
||||
default: return { team: null, label: t('app.tbd') };
|
||||
}
|
||||
}
|
||||
|
||||
// Shared by schedule.js and modal.js: given a match (group or knockout) or a
|
||||
// bracketRef string, returns { home, away } as { team: Team|null, label }.
|
||||
export function resolveBracketTeams(matchOrRef) {
|
||||
const ref = typeof matchOrRef === 'string' ? matchOrRef : matchOrRef.bracketRef;
|
||||
if (!ref) {
|
||||
const { teamById } = getData();
|
||||
const make = (id) => {
|
||||
const team = teamById.get(id) ?? null;
|
||||
return { team, label: team?.name ?? t('app.tbd') };
|
||||
};
|
||||
return { home: make(matchOrRef.homeTeam), away: make(matchOrRef.awayTeam) };
|
||||
}
|
||||
const node = getBracketTree().nodesByRef.get(ref);
|
||||
if (!node) {
|
||||
const tbd = { team: null, label: t('app.tbd') };
|
||||
return { home: tbd, away: tbd };
|
||||
}
|
||||
return { home: slotDisplay(node.home), away: slotDisplay(node.away) };
|
||||
}
|
||||
|
||||
// ------------------------------------------------ shared favorites helper
|
||||
|
||||
// Matches involving a favorited team — shared by schedule.js and bracket.js.
|
||||
// Uses resolved slots so knockout matches count once their teams are known.
|
||||
export function getFavoriteMatches(matches, favorites) {
|
||||
if (!favorites.length) return [];
|
||||
const favSet = new Set(favorites);
|
||||
return matches.filter((match) => {
|
||||
const slots = resolveBracketTeams(match);
|
||||
return favSet.has(slots.home.team?.id) || favSet.has(slots.away.team?.id);
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------------------------- challenge (step 12)
|
||||
|
||||
// For every knockout match with a real finished result, compares the actual
|
||||
// winner with the user's simulated pick. Recalculated on every render — no
|
||||
// persistence of its own.
|
||||
export function calculateChallengeScore(simulation, results, bracketTree) {
|
||||
const resultById = new Map(results.map((r) => [r.matchId, r]));
|
||||
const byPhaseCount = {};
|
||||
let correct = 0;
|
||||
let total = 0;
|
||||
for (const node of bracketTree.nodesByRef.values()) {
|
||||
if (!node.match || !node.home.teamId || !node.away.teamId) continue;
|
||||
const result = resultById.get(node.match.id);
|
||||
if (result?.status !== 'finished') continue;
|
||||
let side = result.homeScore > result.awayScore ? 'home'
|
||||
: result.homeScore < result.awayScore ? 'away'
|
||||
: null;
|
||||
if (!side && result.penalties) {
|
||||
side = result.penalties.home > result.penalties.away ? 'home' : 'away';
|
||||
}
|
||||
if (!side) continue;
|
||||
const realWinner = node[side].teamId;
|
||||
total += 1;
|
||||
byPhaseCount[node.phase] ??= { correct: 0, total: 0 };
|
||||
byPhaseCount[node.phase].total += 1;
|
||||
if (simulation[node.ref]?.winner === realWinner) {
|
||||
correct += 1;
|
||||
byPhaseCount[node.phase].correct += 1;
|
||||
}
|
||||
}
|
||||
const byPhase = Object.fromEntries(
|
||||
Object.entries(byPhaseCount).map(([phase, c]) => [phase, `${c.correct}/${c.total}`]),
|
||||
);
|
||||
return { correct, total, byPhase };
|
||||
}
|
||||
|
||||
// ------------------------------------------- share prediction (step 12)
|
||||
|
||||
export function getShareableLink() {
|
||||
const url = new URL(location.href);
|
||||
url.searchParams.set('prediction', btoa(JSON.stringify(getSimulation())));
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
// Reads ?prediction= on load. Applies only if every key exists in the current
|
||||
// bracket tree and the user confirms; invalid/incompatible data is silently
|
||||
// ignored. The param is removed from the URL either way.
|
||||
export function loadPredictionFromURL() {
|
||||
const param = new URLSearchParams(location.search).get('prediction');
|
||||
if (!param) return;
|
||||
const cleanURL = new URL(location.href);
|
||||
cleanURL.searchParams.delete('prediction');
|
||||
history.replaceState(null, '', cleanURL);
|
||||
try {
|
||||
const data = JSON.parse(atob(param));
|
||||
if (typeof data !== 'object' || data === null || Array.isArray(data)) return;
|
||||
const entries = Object.entries(data);
|
||||
if (!entries.length) return;
|
||||
const tree = getBracketTree();
|
||||
const valid = entries.every(([ref, entry]) =>
|
||||
tree.nodesByRef.has(ref) && entry && typeof entry.winner === 'string');
|
||||
if (!valid) return;
|
||||
if (window.confirm(t('share.confirm'))) {
|
||||
storageSet('simulation', data);
|
||||
refreshAfterSimChange();
|
||||
}
|
||||
} catch {
|
||||
// malformed base64/JSON — ignore silently per spec
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- render
|
||||
|
||||
export function initBracket() {
|
||||
render();
|
||||
document.addEventListener('langchange', render);
|
||||
document.addEventListener('favchange', render);
|
||||
loadPredictionFromURL();
|
||||
|
||||
const root = document.getElementById('bracket-root');
|
||||
const activate = (el) => {
|
||||
if (simMode) {
|
||||
const node = getBracketTree().nodesByRef.get(el.dataset.ref);
|
||||
if (node && isSimulatable(node)) {
|
||||
openSimEditor(el.dataset.ref);
|
||||
return;
|
||||
}
|
||||
}
|
||||
openMatchModal(Number(el.dataset.matchId));
|
||||
};
|
||||
root.addEventListener('click', (event) => {
|
||||
const node = event.target.closest('[data-match-id]');
|
||||
if (node) activate(node);
|
||||
});
|
||||
root.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
const node = event.target.closest('[data-match-id]');
|
||||
if (node) {
|
||||
event.preventDefault();
|
||||
activate(node);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function challengeCardHTML(challenge) {
|
||||
const phases = Object.entries(challenge.byPhase)
|
||||
.map(([phase, score]) => `<span class="challenge-phase">${translatePhase(phase)}: ${score}</span>`)
|
||||
.join('');
|
||||
const summary = t('challenge.correct')
|
||||
.replace('{x}', challenge.correct)
|
||||
.replace('{y}', challenge.total);
|
||||
return `
|
||||
<div class="challenge-card glass">
|
||||
<span class="challenge-title">${t('challenge.title')}</span>
|
||||
<span class="challenge-score">${summary}</span>
|
||||
${phases}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function render() {
|
||||
const tree = getBracketTree();
|
||||
const simulation = getSimulation();
|
||||
const challenge = calculateChallengeScore(simulation, getData().results, tree);
|
||||
const hasPicks = Object.keys(simulation).length > 0;
|
||||
document.getElementById('bracket-root').innerHTML = `
|
||||
${challenge.total ? challengeCardHTML(challenge) : ''}
|
||||
<div class="bracket-toolbar">
|
||||
<div class="bracket-tools-left">
|
||||
<button class="zoom-btn sim-toggle ${simMode ? 'active' : ''}" id="sim-toggle"
|
||||
aria-pressed="${simMode}">${t('sim.mode')}</button>
|
||||
<button class="zoom-btn" id="sim-reset">${t('sim.reset')}</button>
|
||||
<button class="zoom-btn" id="share-pred" ${hasPicks ? '' : 'disabled'}>${t('share.button')}</button>
|
||||
</div>
|
||||
<div class="bracket-tools-right">
|
||||
<button class="zoom-btn" id="zoom-out" aria-label="${t('bracket.zoomOut')}">−</button>
|
||||
<button class="zoom-btn zoom-reset" id="zoom-reset" aria-label="${t('bracket.zoomReset')}">100%</button>
|
||||
<button class="zoom-btn" id="zoom-in" aria-label="${t('bracket.zoomIn')}">+</button>
|
||||
</div>
|
||||
</div>
|
||||
${simMode ? `<p class="sim-note">${t('sim.hint')}</p>` : ''}
|
||||
<div class="bracket-wrap" id="bracket-wrap">
|
||||
<div class="bracket-zoom" id="bracket-zoom">
|
||||
<div class="bracket ${simMode ? 'sim-on' : ''}" id="bracket-canvas">
|
||||
${tree.rounds.filter((r) => r.id !== 'FINAL').map(roundColumnHTML).join('')}
|
||||
${finalColumnHTML(tree)}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
document.getElementById('sim-toggle').addEventListener('click', () => {
|
||||
simMode = !simMode;
|
||||
render();
|
||||
});
|
||||
document.getElementById('sim-reset').addEventListener('click', () => {
|
||||
storageSet('simulation', {});
|
||||
refreshAfterSimChange();
|
||||
});
|
||||
document.getElementById('share-pred').addEventListener('click', async (event) => {
|
||||
const btn = event.currentTarget;
|
||||
const link = getShareableLink();
|
||||
try {
|
||||
await navigator.clipboard.writeText(link);
|
||||
const original = btn.textContent;
|
||||
btn.textContent = t('share.copied');
|
||||
setTimeout(() => { btn.textContent = original; }, 2000);
|
||||
} catch {
|
||||
window.prompt(t('share.button'), link); // clipboard unavailable — let the user copy manually
|
||||
}
|
||||
});
|
||||
initInteractions();
|
||||
}
|
||||
|
||||
function roundColumnHTML(round) {
|
||||
const pairs = [];
|
||||
for (let i = 0; i < round.nodes.length; i += 2) {
|
||||
pairs.push(`
|
||||
<div class="bracket-pair">
|
||||
<div class="bracket-slot">${matchNodeHTML(round.nodes[i])}</div>
|
||||
<div class="bracket-slot">${matchNodeHTML(round.nodes[i + 1])}</div>
|
||||
</div>`);
|
||||
}
|
||||
return `
|
||||
<div class="bracket-round" data-round="${round.id}">
|
||||
<h3 class="bracket-round-title">${translatePhase(round.phase)}</h3>
|
||||
<div class="bracket-matches">${pairs.join('')}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function finalColumnHTML(tree) {
|
||||
const finalNode = tree.rounds.find((r) => r.id === 'FINAL').nodes[0];
|
||||
const champion = tree.champion ? slotDisplay({ teamId: tree.champion }) : null;
|
||||
return `
|
||||
<div class="bracket-round bracket-final-col" data-round="FINAL">
|
||||
<h3 class="bracket-round-title">${translatePhase('Final')}</h3>
|
||||
<div class="bracket-matches">
|
||||
<div class="bracket-slot bracket-champion-slot">
|
||||
<div class="champion-box ${champion ? 'has-champion' : ''}">
|
||||
<span class="champion-trophy" aria-hidden="true">🏆</span>
|
||||
<span class="champion-label">${t('bracket.champion')}</span>
|
||||
<span class="champion-name">${champion ? champion.label : t('app.tbd')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bracket-slot">${matchNodeHTML(finalNode)}</div>
|
||||
<div class="bracket-slot bracket-third-slot">
|
||||
<div class="third-place-block">
|
||||
<h4>${translatePhase('Third Place')}</h4>
|
||||
${matchNodeHTML(tree.third)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function matchNodeHTML(node) {
|
||||
const home = slotDisplay(node.home);
|
||||
const away = slotDisplay(node.away);
|
||||
const live = node.result?.status === 'live';
|
||||
const favorites = getFavorites();
|
||||
const hasFav = favorites.includes(node.home.teamId) || favorites.includes(node.away.teamId);
|
||||
const classes = [
|
||||
'bracket-match',
|
||||
live ? 'is-live' : '',
|
||||
node.simulated ? 'is-sim' : '',
|
||||
hasFav ? 'has-fav' : '',
|
||||
simMode && isSimulatable(node) ? 'simulatable' : '',
|
||||
].filter(Boolean).join(' ');
|
||||
const interactive = node.match
|
||||
? `data-match-id="${node.match.id}" tabindex="0" role="button"
|
||||
aria-label="${home.label} ${t('hero.vs')} ${away.label} — ${translatePhase(node.phase)}"`
|
||||
: '';
|
||||
return `
|
||||
<article class="${classes}" data-ref="${node.ref}" ${interactive}>
|
||||
${node.simulated ? `<span class="sim-chip">${t('sim.chip')}</span>` : ''}
|
||||
${teamRowHTML(home, node, 'home')}
|
||||
${teamRowHTML(away, node, 'away')}
|
||||
</article>`;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------- simulation (step 9)
|
||||
|
||||
let simMode = false;
|
||||
|
||||
function getSimulation() {
|
||||
return storageGet('simulation', {});
|
||||
}
|
||||
|
||||
function refreshAfterSimChange() {
|
||||
invalidateBracket();
|
||||
render();
|
||||
document.dispatchEvent(new CustomEvent('simchange'));
|
||||
}
|
||||
|
||||
function ensureSimDialog() {
|
||||
if (document.getElementById('sim-dialog')) return;
|
||||
document.getElementById('modal-root').insertAdjacentHTML(
|
||||
'beforeend',
|
||||
'<dialog class="match-modal sim-modal" id="sim-dialog"></dialog>',
|
||||
);
|
||||
const dialog = document.getElementById('sim-dialog');
|
||||
dialog.addEventListener('click', (event) => {
|
||||
if (event.target === dialog) dialog.close();
|
||||
});
|
||||
}
|
||||
|
||||
function openSimEditor(ref) {
|
||||
ensureSimDialog();
|
||||
const dialog = document.getElementById('sim-dialog');
|
||||
const node = getBracketTree().nodesByRef.get(ref);
|
||||
const existing = getSimulation()[ref] ?? null;
|
||||
const home = slotDisplay(node.home);
|
||||
const away = slotDisplay(node.away);
|
||||
const [scoreHome, scoreAway] = (existing?.score ?? '').split('-');
|
||||
let selected = existing?.winner ?? null;
|
||||
|
||||
const teamButton = (slot, display) => `
|
||||
<button class="sim-team ${selected === slot.teamId ? 'active' : ''}" data-team="${slot.teamId}">
|
||||
<img class="flag" src="${flagSrc(display.team)}" alt="" width="34" height="23">
|
||||
<span>${display.label}</span>
|
||||
</button>`;
|
||||
|
||||
dialog.innerHTML = `
|
||||
<div class="modal-content">
|
||||
<header class="modal-top">
|
||||
<p class="modal-phase">${t('sim.title')} — ${translatePhase(node.phase)}</p>
|
||||
<button class="modal-close" data-close aria-label="${t('modal.close')}">✕</button>
|
||||
</header>
|
||||
<p class="sim-hint">${t('sim.pickWinner')}</p>
|
||||
<div class="sim-teams">
|
||||
${teamButton(node.home, home)}
|
||||
${teamButton(node.away, away)}
|
||||
</div>
|
||||
<div class="sim-scores">
|
||||
<label>${home.label}
|
||||
<input class="filter-control" type="number" id="sim-score-home" min="0" max="99"
|
||||
inputmode="numeric" value="${scoreHome ?? ''}">
|
||||
</label>
|
||||
<label>${away.label}
|
||||
<input class="filter-control" type="number" id="sim-score-away" min="0" max="99"
|
||||
inputmode="numeric" value="${scoreAway ?? ''}">
|
||||
</label>
|
||||
</div>
|
||||
<div class="sim-actions">
|
||||
<button class="link-btn" id="sim-clear" ${existing ? '' : 'hidden'}>${t('sim.clear')}</button>
|
||||
<button class="btn-primary" id="sim-save" ${selected ? '' : 'disabled'}>${t('sim.save')}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const saveBtn = dialog.querySelector('#sim-save');
|
||||
const homeInput = dialog.querySelector('#sim-score-home');
|
||||
const awayInput = dialog.querySelector('#sim-score-away');
|
||||
|
||||
const select = (teamId) => {
|
||||
selected = teamId;
|
||||
for (const btn of dialog.querySelectorAll('.sim-team')) {
|
||||
btn.classList.toggle('active', btn.dataset.team === teamId);
|
||||
}
|
||||
saveBtn.disabled = !selected;
|
||||
};
|
||||
|
||||
for (const btn of dialog.querySelectorAll('.sim-team')) {
|
||||
btn.addEventListener('click', () => select(btn.dataset.team));
|
||||
}
|
||||
|
||||
// an unequal score implies the winner; a draw needs an explicit pick (pens)
|
||||
const onScoreInput = () => {
|
||||
const h = homeInput.value === '' ? null : Number(homeInput.value);
|
||||
const a = awayInput.value === '' ? null : Number(awayInput.value);
|
||||
if (h === null || a === null || h === a) return;
|
||||
select(h > a ? node.home.teamId : node.away.teamId);
|
||||
};
|
||||
homeInput.addEventListener('input', onScoreInput);
|
||||
awayInput.addEventListener('input', onScoreInput);
|
||||
|
||||
dialog.setAttribute('aria-label', `${t('sim.title')} — ${home.label} ${t('hero.vs')} ${away.label}`);
|
||||
dialog.querySelector('[data-close]').addEventListener('click', () => dialog.close());
|
||||
dialog.querySelector('#sim-clear').addEventListener('click', () => {
|
||||
const simulation = getSimulation();
|
||||
delete simulation[ref];
|
||||
storageSet('simulation', simulation);
|
||||
dialog.close();
|
||||
refreshAfterSimChange();
|
||||
});
|
||||
saveBtn.addEventListener('click', () => {
|
||||
if (!selected) return;
|
||||
const h = homeInput.value === '' ? (selected === node.home.teamId ? 1 : 0) : Number(homeInput.value);
|
||||
const a = awayInput.value === '' ? (selected === node.away.teamId ? 1 : 0) : Number(awayInput.value);
|
||||
const simulation = getSimulation();
|
||||
simulation[ref] = { winner: selected, score: `${h}-${a}` };
|
||||
storageSet('simulation', simulation);
|
||||
dialog.close();
|
||||
refreshAfterSimChange();
|
||||
});
|
||||
|
||||
dialog.showModal();
|
||||
}
|
||||
|
||||
// ------------------------------------------------- interactions (step 8)
|
||||
|
||||
const ROUND_ORDER = ['R32', 'R16', 'QF', 'SF', 'FINAL'];
|
||||
// zoom level survives re-renders (language switch) but not reloads
|
||||
const view = { scale: 1, natW: 0, natH: 0 };
|
||||
const MIN_SCALE = 0.4;
|
||||
const MAX_SCALE = 2;
|
||||
|
||||
function initInteractions() {
|
||||
const wrap = document.getElementById('bracket-wrap');
|
||||
const zoomBox = document.getElementById('bracket-zoom');
|
||||
const canvas = document.getElementById('bracket-canvas');
|
||||
view.natW = 0; // re-measure lazily — panel may be hidden right now (offsetWidth 0)
|
||||
if (view.scale !== 1) applyScale(wrap, zoomBox, canvas);
|
||||
updateZoomLabel();
|
||||
|
||||
const ensureMeasured = () => {
|
||||
if (view.natW) return true;
|
||||
if (!canvas.offsetWidth) return false;
|
||||
view.natW = canvas.offsetWidth;
|
||||
view.natH = canvas.offsetHeight;
|
||||
return true;
|
||||
};
|
||||
|
||||
const setScale = (next, cx, cy) => {
|
||||
if (!ensureMeasured()) return;
|
||||
const scale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, next));
|
||||
if (scale === view.scale) return;
|
||||
const rect = wrap.getBoundingClientRect();
|
||||
const px = (cx - rect.left + wrap.scrollLeft) / view.scale;
|
||||
const py = (cy - rect.top + wrap.scrollTop) / view.scale;
|
||||
view.scale = scale;
|
||||
applyScale(wrap, zoomBox, canvas);
|
||||
wrap.scrollLeft = px * scale - (cx - rect.left);
|
||||
wrap.scrollTop = py * scale - (cy - rect.top);
|
||||
updateZoomLabel();
|
||||
};
|
||||
|
||||
const zoomAtCenter = (factor) => {
|
||||
const rect = wrap.getBoundingClientRect();
|
||||
setScale(view.scale * factor, rect.left + rect.width / 2, rect.top + rect.height / 2);
|
||||
};
|
||||
|
||||
document.getElementById('zoom-in').addEventListener('click', () => zoomAtCenter(1.25));
|
||||
document.getElementById('zoom-out').addEventListener('click', () => zoomAtCenter(1 / 1.25));
|
||||
document.getElementById('zoom-reset').addEventListener('click', () => {
|
||||
if (!ensureMeasured()) return;
|
||||
view.scale = 1;
|
||||
applyScale(wrap, zoomBox, canvas);
|
||||
updateZoomLabel();
|
||||
});
|
||||
|
||||
wrap.addEventListener('wheel', (event) => {
|
||||
event.preventDefault();
|
||||
setScale(view.scale * (event.deltaY < 0 ? 1.12 : 1 / 1.12), event.clientX, event.clientY);
|
||||
}, { passive: false });
|
||||
|
||||
// drag to pan (1 pointer) + pinch to zoom (2 pointers)
|
||||
const pointers = new Map();
|
||||
let dragging = false;
|
||||
let dragMoved = false;
|
||||
let start = null;
|
||||
let pinch = null;
|
||||
|
||||
wrap.addEventListener('pointerdown', (event) => {
|
||||
pointers.set(event.pointerId, event);
|
||||
// IMPORTANT: do NOT setPointerCapture here — capturing retargets the
|
||||
// eventual click to the wrap, which kills match-node clicks (modal and
|
||||
// simulation). Capture only once a real drag/pinch begins.
|
||||
if (pointers.size === 1) {
|
||||
dragging = true;
|
||||
dragMoved = false;
|
||||
start = { x: event.clientX, y: event.clientY, left: wrap.scrollLeft, top: wrap.scrollTop };
|
||||
} else if (pointers.size === 2) {
|
||||
dragging = false;
|
||||
for (const p of pointers.values()) {
|
||||
try { wrap.setPointerCapture(p.pointerId); } catch { /* pointer already gone */ }
|
||||
}
|
||||
const [a, b] = [...pointers.values()];
|
||||
pinch = { dist: Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY), scale: view.scale };
|
||||
}
|
||||
});
|
||||
|
||||
wrap.addEventListener('pointermove', (event) => {
|
||||
if (!pointers.has(event.pointerId)) return;
|
||||
pointers.set(event.pointerId, event);
|
||||
if (pointers.size === 2 && pinch) {
|
||||
const [a, b] = [...pointers.values()];
|
||||
const dist = Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
|
||||
setScale(pinch.scale * (dist / pinch.dist), (a.clientX + b.clientX) / 2, (a.clientY + b.clientY) / 2);
|
||||
} else if (dragging) {
|
||||
const dx = event.clientX - start.x;
|
||||
const dy = event.clientY - start.y;
|
||||
if (!dragMoved && (Math.abs(dx) > 5 || Math.abs(dy) > 5)) {
|
||||
dragMoved = true;
|
||||
try { wrap.setPointerCapture(event.pointerId); } catch { /* pointer already gone */ }
|
||||
wrap.classList.add('dragging');
|
||||
}
|
||||
if (dragMoved) {
|
||||
wrap.scrollLeft = start.left - dx;
|
||||
wrap.scrollTop = start.top - dy;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const endPointer = (event) => {
|
||||
pointers.delete(event.pointerId);
|
||||
if (pointers.size < 2) pinch = null;
|
||||
if (pointers.size === 0) {
|
||||
dragging = false;
|
||||
wrap.classList.remove('dragging');
|
||||
}
|
||||
};
|
||||
wrap.addEventListener('pointerup', endPointer);
|
||||
wrap.addEventListener('pointercancel', endPointer);
|
||||
|
||||
// a real drag must not fire the match-node click underneath
|
||||
wrap.addEventListener('click', (event) => {
|
||||
if (dragMoved) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
dragMoved = false;
|
||||
}
|
||||
}, true);
|
||||
|
||||
// hover/focus path highlight
|
||||
canvas.addEventListener('mouseover', (event) => {
|
||||
const node = event.target.closest('.bracket-match');
|
||||
if (node) showPath(node.dataset.ref);
|
||||
});
|
||||
canvas.addEventListener('mouseout', () => clearPath());
|
||||
canvas.addEventListener('focusin', (event) => {
|
||||
const node = event.target.closest('.bracket-match');
|
||||
if (node) showPath(node.dataset.ref);
|
||||
});
|
||||
canvas.addEventListener('focusout', () => clearPath());
|
||||
}
|
||||
|
||||
function applyScale(wrap, zoomBox, canvas) {
|
||||
if (view.natW) {
|
||||
zoomBox.style.width = `${view.natW * view.scale}px`;
|
||||
zoomBox.style.height = `${view.natH * view.scale}px`;
|
||||
}
|
||||
canvas.style.transform = view.scale === 1 ? '' : `scale(${view.scale})`;
|
||||
if (view.scale === 1) {
|
||||
zoomBox.style.width = '';
|
||||
zoomBox.style.height = '';
|
||||
}
|
||||
}
|
||||
|
||||
function updateZoomLabel() {
|
||||
const label = document.getElementById('zoom-reset');
|
||||
if (label) label.textContent = `${Math.round(view.scale * 100)}%`;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------- path highlight
|
||||
|
||||
function refOf(roundIndex, nodeIndex) {
|
||||
const id = ROUND_ORDER[roundIndex];
|
||||
return id === 'FINAL' ? 'FINAL' : `${id}-${nodeIndex + 1}`;
|
||||
}
|
||||
|
||||
// full path = the hovered node, every feeder below it, and its winner's
|
||||
// route up to the final
|
||||
function pathRefs(ref) {
|
||||
const refs = new Set([ref]);
|
||||
if (ref === 'THIRD-PLACE') {
|
||||
refs.add('SF-1');
|
||||
refs.add('SF-2');
|
||||
return refs;
|
||||
}
|
||||
const [roundId, num] = ref === 'FINAL' ? ['FINAL', '1'] : ref.split('-');
|
||||
const r = ROUND_ORDER.indexOf(roundId);
|
||||
if (r < 0) return refs;
|
||||
let i = Number(num) - 1;
|
||||
|
||||
for (let rr = r, ii = i; rr < ROUND_ORDER.length - 1; rr += 1) {
|
||||
ii = Math.floor(ii / 2);
|
||||
refs.add(refOf(rr + 1, ii));
|
||||
}
|
||||
const stack = [[r, i]];
|
||||
while (stack.length) {
|
||||
const [cr, ci] = stack.pop();
|
||||
if (cr === 0) continue;
|
||||
for (const child of [ci * 2, ci * 2 + 1]) {
|
||||
refs.add(refOf(cr - 1, child));
|
||||
stack.push([cr - 1, child]);
|
||||
}
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
function showPath(ref) {
|
||||
const canvas = document.getElementById('bracket-canvas');
|
||||
clearPath();
|
||||
canvas.classList.add('has-path');
|
||||
for (const r of pathRefs(ref)) {
|
||||
const el = canvas.querySelector(`[data-ref="${r}"]`);
|
||||
if (!el) continue;
|
||||
el.classList.add('path-on');
|
||||
el.closest('.bracket-slot')?.classList.add('path-on');
|
||||
el.closest('.bracket-pair')?.classList.add('path-on');
|
||||
}
|
||||
}
|
||||
|
||||
function clearPath() {
|
||||
const canvas = document.getElementById('bracket-canvas');
|
||||
canvas.classList.remove('has-path');
|
||||
for (const el of canvas.querySelectorAll('.path-on')) el.classList.remove('path-on');
|
||||
}
|
||||
|
||||
function teamRowHTML(display, node, side) {
|
||||
const { result } = node;
|
||||
let score = '';
|
||||
if (result && result.status !== 'scheduled' && node.home.teamId && node.away.teamId) {
|
||||
const goals = side === 'home' ? result.homeScore : result.awayScore;
|
||||
const pens = result.penalties
|
||||
? `<small>(${side === 'home' ? result.penalties.home : result.penalties.away})</small>`
|
||||
: '';
|
||||
score = `<span class="bt-score">${goals}${pens}</span>`;
|
||||
} else if (node.simulated && node.simScore) {
|
||||
score = `<span class="bt-score sim">${node.simScore[side]}</span>`;
|
||||
}
|
||||
const isWinner = node.winner !== null && node[side].teamId === node.winner;
|
||||
const flag = display.team
|
||||
? `<img class="flag" src="${flagSrc(display.team)}" alt="" width="20" height="14" loading="lazy">`
|
||||
: '<span class="bt-flag-ph" aria-hidden="true"></span>';
|
||||
return `
|
||||
<div class="bracket-team ${isWinner ? 'winner' : ''} ${display.team ? '' : 'tbd'}">
|
||||
${flag}
|
||||
<span class="bt-name">${display.label}</span>
|
||||
${score}
|
||||
</div>`;
|
||||
}
|
||||
49
assets/js/calendar.js
Normal file
49
assets/js/calendar.js
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
// calendar.js — "Add to calendar" export (RFC 5545). One VEVENT per match,
|
||||
// DTSTART/DTEND in UTC with a fixed 2h duration, CRLF line endings (required
|
||||
// by the spec — some calendar apps reject \n). Knockout team names come from
|
||||
// resolveBracketTeams().
|
||||
|
||||
import { matchDateUTC } from './app.js';
|
||||
import { resolveBracketTeams } from './bracket.js';
|
||||
import { translatePhase } from './i18n.js';
|
||||
|
||||
// Date → 20260611T160000Z
|
||||
function icsDate(date) {
|
||||
return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
||||
}
|
||||
|
||||
// RFC 5545 TEXT escaping: backslash, comma, semicolon, newline
|
||||
function icsEscape(text) {
|
||||
return String(text).replace(/\\/g, '\\\\').replace(/,/g, '\\,').replace(/;/g, '\\;').replace(/\n/g, '\\n');
|
||||
}
|
||||
|
||||
export function exportMatchToICS(match, stadium) {
|
||||
const start = matchDateUTC(match);
|
||||
const end = new Date(start.getTime() + 2 * 60 * 60 * 1000);
|
||||
const { home, away } = resolveBracketTeams(match);
|
||||
const location = stadium ? `${stadium.name}, ${stadium.city}` : match.city;
|
||||
|
||||
const lines = [
|
||||
'BEGIN:VCALENDAR',
|
||||
'VERSION:2.0',
|
||||
'PRODID:-//WorldCup2026Hub//EN',
|
||||
'BEGIN:VEVENT',
|
||||
`UID:match-${match.id}@worldcup2026hub`,
|
||||
`DTSTAMP:${icsDate(start)}`,
|
||||
`DTSTART:${icsDate(start)}`,
|
||||
`DTEND:${icsDate(end)}`,
|
||||
`SUMMARY:${icsEscape(`${home.label} x ${away.label} — ${translatePhase(match.phase)}`)}`,
|
||||
`LOCATION:${icsEscape(location)}`,
|
||||
'END:VEVENT',
|
||||
'END:VCALENDAR',
|
||||
];
|
||||
|
||||
const blob = new Blob([lines.join('\r\n') + '\r\n'], { type: 'text/calendar' });
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = `match-${match.id}.ics`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(link.href);
|
||||
}
|
||||
121
assets/js/groups.js
Normal file
121
assets/js/groups.js
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
// groups.js — standings computed from groups.json + results.json and the 12
|
||||
// group tables. Only matches with status "finished" count toward standings
|
||||
// (live scores are ignored until full-time). computeStandings() and
|
||||
// isGroupFinished() are reused by bracket.js to resolve the Round of 32.
|
||||
|
||||
import { getData, flagSrc } from './app.js';
|
||||
import { t } from './i18n.js';
|
||||
import { getFavorites } from './storage.js';
|
||||
|
||||
// Tiebreak order per complement spec §2: points, goal difference, goals for.
|
||||
// Team id alphabetical as a final stable fallback.
|
||||
export function computeStandings() {
|
||||
const { groups, matches, resultByMatchId } = getData();
|
||||
const tables = {};
|
||||
for (const [letter, teamIds] of Object.entries(groups)) {
|
||||
tables[letter] = new Map(teamIds.map((id) => [id, {
|
||||
teamId: id, played: 0, won: 0, drawn: 0, lost: 0, gf: 0, ga: 0, gd: 0, points: 0,
|
||||
}]));
|
||||
}
|
||||
|
||||
for (const match of matches) {
|
||||
if (!match.phase.startsWith('Group ')) continue;
|
||||
const result = resultByMatchId.get(match.id);
|
||||
if (result?.status !== 'finished') continue;
|
||||
const rows = tables[match.phase.slice(6)];
|
||||
applyResult(rows.get(match.homeTeam), result.homeScore, result.awayScore);
|
||||
applyResult(rows.get(match.awayTeam), result.awayScore, result.homeScore);
|
||||
}
|
||||
|
||||
const standings = {};
|
||||
for (const [letter, rows] of Object.entries(tables)) {
|
||||
standings[letter] = [...rows.values()].sort((a, b) =>
|
||||
b.points - a.points || b.gd - a.gd || b.gf - a.gf || a.teamId.localeCompare(b.teamId));
|
||||
}
|
||||
return standings;
|
||||
}
|
||||
|
||||
function applyResult(row, scored, conceded) {
|
||||
row.played += 1;
|
||||
row.gf += scored;
|
||||
row.ga += conceded;
|
||||
row.gd = row.gf - row.ga;
|
||||
if (scored > conceded) { row.won += 1; row.points += 3; }
|
||||
else if (scored === conceded) { row.drawn += 1; row.points += 1; }
|
||||
else { row.lost += 1; }
|
||||
}
|
||||
|
||||
export function isGroupFinished(letter) {
|
||||
const { matches, resultByMatchId } = getData();
|
||||
return matches
|
||||
.filter((m) => m.phase === `Group ${letter}`)
|
||||
.every((m) => resultByMatchId.get(m.id)?.status === 'finished');
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- render
|
||||
|
||||
export function initGroups() {
|
||||
render();
|
||||
document.addEventListener('langchange', render);
|
||||
document.addEventListener('favchange', render);
|
||||
}
|
||||
|
||||
function render() {
|
||||
const standings = computeStandings();
|
||||
document.getElementById('groups-root').innerHTML = `
|
||||
<p class="standings-legend">
|
||||
<span class="legend-item"><span class="legend-dot qualified"></span>${t('standings.legendTop2')}</span>
|
||||
<span class="legend-item"><span class="legend-dot third"></span>${t('standings.legendThird')}</span>
|
||||
</p>
|
||||
<div class="groups-grid">
|
||||
${Object.entries(standings).map(([letter, rows]) => groupCardHTML(letter, rows)).join('')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function groupCardHTML(letter, rows) {
|
||||
const finished = isGroupFinished(letter);
|
||||
const headers = ['played', 'won', 'drawn', 'lost', 'gf', 'ga', 'gd', 'pts']
|
||||
.map((key) => `<th class="${key === 'gf' || key === 'ga' ? 'col-goals' : ''}" scope="col">${t(`standings.${key}`)}</th>`)
|
||||
.join('');
|
||||
|
||||
return `
|
||||
<section class="group-card glass" aria-labelledby="group-title-${letter}">
|
||||
<header class="group-card-header">
|
||||
<h3 id="group-title-${letter}">${t('phase.group')} ${letter}</h3>
|
||||
${finished ? '' : `<span class="group-progress">${t('standings.inProgress')}</span>`}
|
||||
</header>
|
||||
<table class="standings-table">
|
||||
<thead>
|
||||
<tr><th scope="col">#</th><th class="col-team" scope="col">${t('standings.team')}</th>${headers}</tr>
|
||||
</thead>
|
||||
<tbody>${rows.map(standingRowHTML).join('')}</tbody>
|
||||
</table>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function standingRowHTML(row, index) {
|
||||
const team = getData().teamById.get(row.teamId);
|
||||
const fav = getFavorites().includes(team.id);
|
||||
const rankClass = [
|
||||
index < 2 ? 'row-qualified' : index === 2 ? 'row-third' : '',
|
||||
fav ? 'fav-row' : '',
|
||||
].filter(Boolean).join(' ');
|
||||
return `
|
||||
<tr class="${rankClass}">
|
||||
<td>${index + 1}</td>
|
||||
<td class="col-team">
|
||||
<img class="flag" src="${flagSrc(team)}" alt="" width="22" height="15" loading="lazy">
|
||||
<span>${team.name}</span>
|
||||
<button class="fav-btn ${fav ? 'active' : ''}" data-fav="${team.id}"
|
||||
aria-pressed="${fav}" aria-label="${t('fav.toggle')} ${team.name}">${fav ? '★' : '☆'}</button>
|
||||
</td>
|
||||
<td>${row.played}</td>
|
||||
<td>${row.won}</td>
|
||||
<td>${row.drawn}</td>
|
||||
<td>${row.lost}</td>
|
||||
<td class="col-goals">${row.gf}</td>
|
||||
<td class="col-goals">${row.ga}</td>
|
||||
<td>${row.gd > 0 ? '+' : ''}${row.gd}</td>
|
||||
<td class="col-pts">${row.points}</td>
|
||||
</tr>`;
|
||||
}
|
||||
269
assets/js/i18n.js
Normal file
269
assets/js/i18n.js
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
// i18n.js — EN/PT-BR dictionaries + t(key). Every UI string in the app goes
|
||||
// through t(); static HTML uses data-i18n / data-i18n-aria attributes.
|
||||
// Language persists in wc2026_prefs.lang; changing it dispatches "langchange"
|
||||
// so modules can re-render dynamic content.
|
||||
|
||||
import { getPrefs, setPref } from './storage.js';
|
||||
|
||||
const dicts = {
|
||||
en: {
|
||||
'a11y.skip': 'Skip to content',
|
||||
'a11y.mainNav': 'Main navigation',
|
||||
'a11y.langSwitch': 'Switch language',
|
||||
'nav.home': 'Home',
|
||||
'nav.matches': 'Matches',
|
||||
'nav.groups': 'Groups',
|
||||
'nav.bracket': 'Knockout',
|
||||
'nav.stadiums': 'Stadiums',
|
||||
'hero.live': 'Live',
|
||||
'hero.nextMatch': 'Next match',
|
||||
'hero.kickoff': 'Kickoff!',
|
||||
'hero.countdownLabel': 'Time until kickoff',
|
||||
'hero.vs': 'vs',
|
||||
'countdown.days': 'days',
|
||||
'countdown.hours': 'hours',
|
||||
'countdown.minutes': 'min',
|
||||
'countdown.seconds': 'sec',
|
||||
'dash.title': 'Tournament overview',
|
||||
'dash.total': 'Total matches',
|
||||
'dash.completed': 'Completed',
|
||||
'dash.upcoming': 'Upcoming',
|
||||
'dash.teams': 'Teams',
|
||||
'app.loading': 'Loading data…',
|
||||
'app.error': 'Could not load tournament data.',
|
||||
'app.errorHint': 'If you opened index.html directly from disk, serve the folder instead: python -m http.server',
|
||||
'app.comingSoon': 'This section arrives in a later build step.',
|
||||
'app.tbd': 'TBD',
|
||||
'phase.group': 'Group',
|
||||
'phase.r32': 'Round of 32',
|
||||
'phase.r16': 'Round of 16',
|
||||
'phase.qf': 'Quarterfinals',
|
||||
'phase.sf': 'Semifinals',
|
||||
'phase.third': 'Third Place',
|
||||
'phase.final': 'Final',
|
||||
'schedule.searchPlaceholder': 'Search team, city or stadium…',
|
||||
'schedule.dateFilter': 'Filter by date',
|
||||
'schedule.allGroups': 'All groups',
|
||||
'schedule.allPhases': 'All phases',
|
||||
'schedule.groupStage': 'Group stage',
|
||||
'schedule.allTeams': 'All teams',
|
||||
'schedule.allStadiums': 'All stadiums',
|
||||
'schedule.sortAsc': 'Date ↑',
|
||||
'schedule.sortDesc': 'Date ↓',
|
||||
'schedule.match': 'match',
|
||||
'schedule.matches': 'matches',
|
||||
'schedule.noResults': 'No matches found — adjust the filters.',
|
||||
'schedule.clear': 'Clear filters',
|
||||
'status.finished': 'Full-time',
|
||||
'status.pens': 'pens',
|
||||
'standings.team': 'Team',
|
||||
'standings.played': 'P',
|
||||
'standings.won': 'W',
|
||||
'standings.drawn': 'D',
|
||||
'standings.lost': 'L',
|
||||
'standings.gf': 'GF',
|
||||
'standings.ga': 'GA',
|
||||
'standings.gd': 'GD',
|
||||
'standings.pts': 'Pts',
|
||||
'standings.legendTop2': 'Advance to the Round of 32',
|
||||
'standings.legendThird': 'In contention for best third place',
|
||||
'standings.inProgress': 'In progress',
|
||||
'stadiums.capacity': 'Capacity',
|
||||
'stadiums.viewMatches': 'View matches',
|
||||
'status.scheduled': 'Scheduled',
|
||||
'modal.close': 'Close',
|
||||
'modal.date': 'Date & time',
|
||||
'modal.stadium': 'Stadium',
|
||||
'modal.city': 'City',
|
||||
'modal.stats': 'Match stats',
|
||||
'modal.possession': 'Possession',
|
||||
'modal.shots': 'Shots',
|
||||
'modal.cards': 'Cards',
|
||||
'modal.statsSoon': 'Detailed stats will appear here once available.',
|
||||
'bracket.groupWinner': 'Group {g} Winner',
|
||||
'bracket.groupRunnerUp': 'Group {g} Runner-up',
|
||||
'bracket.bestThird': 'Best 3rd #{n}',
|
||||
'bracket.champion': 'Champion',
|
||||
'bracket.zoomIn': 'Zoom in',
|
||||
'bracket.zoomOut': 'Zoom out',
|
||||
'bracket.zoomReset': 'Reset zoom',
|
||||
'sim.mode': 'Simulation',
|
||||
'sim.reset': 'Reset picks',
|
||||
'sim.hint': 'Simulation on — click a highlighted match to pick its winner. Real results are never changed.',
|
||||
'sim.title': 'Simulate',
|
||||
'sim.pickWinner': 'Pick the winner. Equal or empty score means penalties.',
|
||||
'sim.save': 'Save pick',
|
||||
'sim.clear': 'Remove pick',
|
||||
'sim.chip': 'SIM',
|
||||
'time.local': 'Local time',
|
||||
'time.stadium': 'Stadium time',
|
||||
'time.toggleAria': 'Toggle between local and stadium time',
|
||||
'schedule.myMatches': 'My matches',
|
||||
'fav.toggle': 'Favorite',
|
||||
'challenge.title': 'Bracket challenge',
|
||||
'challenge.correct': '{x} of {y} picks correct',
|
||||
'share.button': 'Share prediction',
|
||||
'share.copied': 'Link copied!',
|
||||
'share.confirm': 'Apply the shared prediction? Your current picks will be replaced.',
|
||||
'modal.addCalendar': 'Add to calendar',
|
||||
'footer.note': 'Fan-made static hub — all data lives in JSON files.',
|
||||
},
|
||||
pt: {
|
||||
'a11y.skip': 'Pular para o conteúdo',
|
||||
'a11y.mainNav': 'Navegação principal',
|
||||
'a11y.langSwitch': 'Trocar idioma',
|
||||
'nav.home': 'Início',
|
||||
'nav.matches': 'Partidas',
|
||||
'nav.groups': 'Grupos',
|
||||
'nav.bracket': 'Mata-mata',
|
||||
'nav.stadiums': 'Estádios',
|
||||
'hero.live': 'Ao vivo',
|
||||
'hero.nextMatch': 'Próxima partida',
|
||||
'hero.kickoff': 'Bola rolando!',
|
||||
'hero.countdownLabel': 'Tempo até o início da partida',
|
||||
'hero.vs': 'vs',
|
||||
'countdown.days': 'dias',
|
||||
'countdown.hours': 'horas',
|
||||
'countdown.minutes': 'min',
|
||||
'countdown.seconds': 'seg',
|
||||
'dash.title': 'Visão geral do torneio',
|
||||
'dash.total': 'Total de partidas',
|
||||
'dash.completed': 'Encerradas',
|
||||
'dash.upcoming': 'Próximas',
|
||||
'dash.teams': 'Seleções',
|
||||
'app.loading': 'Carregando dados…',
|
||||
'app.error': 'Não foi possível carregar os dados do torneio.',
|
||||
'app.errorHint': 'Se você abriu o index.html direto do disco, sirva a pasta: python -m http.server',
|
||||
'app.comingSoon': 'Esta seção chega em uma próxima etapa.',
|
||||
'app.tbd': 'A definir',
|
||||
'phase.group': 'Grupo',
|
||||
'phase.r32': '16 avos de final',
|
||||
'phase.r16': 'Oitavas de final',
|
||||
'phase.qf': 'Quartas de final',
|
||||
'phase.sf': 'Semifinais',
|
||||
'phase.third': 'Disputa de 3º lugar',
|
||||
'phase.final': 'Final',
|
||||
'schedule.searchPlaceholder': 'Buscar seleção, cidade ou estádio…',
|
||||
'schedule.dateFilter': 'Filtrar por data',
|
||||
'schedule.allGroups': 'Todos os grupos',
|
||||
'schedule.allPhases': 'Todas as fases',
|
||||
'schedule.groupStage': 'Fase de grupos',
|
||||
'schedule.allTeams': 'Todas as seleções',
|
||||
'schedule.allStadiums': 'Todos os estádios',
|
||||
'schedule.sortAsc': 'Data ↑',
|
||||
'schedule.sortDesc': 'Data ↓',
|
||||
'schedule.match': 'partida',
|
||||
'schedule.matches': 'partidas',
|
||||
'schedule.noResults': 'Nenhuma partida encontrada — ajuste os filtros.',
|
||||
'schedule.clear': 'Limpar filtros',
|
||||
'status.finished': 'Encerrado',
|
||||
'status.pens': 'pên.',
|
||||
'standings.team': 'Seleção',
|
||||
'standings.played': 'J',
|
||||
'standings.won': 'V',
|
||||
'standings.drawn': 'E',
|
||||
'standings.lost': 'D',
|
||||
'standings.gf': 'GP',
|
||||
'standings.ga': 'GC',
|
||||
'standings.gd': 'SG',
|
||||
'standings.pts': 'Pts',
|
||||
'standings.legendTop2': 'Avançam aos 16 avos de final',
|
||||
'standings.legendThird': 'Na briga por melhor 3º lugar',
|
||||
'standings.inProgress': 'Em andamento',
|
||||
'stadiums.capacity': 'Capacidade',
|
||||
'stadiums.viewMatches': 'Ver partidas',
|
||||
'status.scheduled': 'Agendada',
|
||||
'modal.close': 'Fechar',
|
||||
'modal.date': 'Data e hora',
|
||||
'modal.stadium': 'Estádio',
|
||||
'modal.city': 'Cidade',
|
||||
'modal.stats': 'Estatísticas',
|
||||
'modal.possession': 'Posse de bola',
|
||||
'modal.shots': 'Finalizações',
|
||||
'modal.cards': 'Cartões',
|
||||
'modal.statsSoon': 'Estatísticas detalhadas aparecerão aqui quando disponíveis.',
|
||||
'bracket.groupWinner': '1º do Grupo {g}',
|
||||
'bracket.groupRunnerUp': '2º do Grupo {g}',
|
||||
'bracket.bestThird': 'Melhor 3º #{n}',
|
||||
'bracket.champion': 'Campeão',
|
||||
'bracket.zoomIn': 'Aproximar',
|
||||
'bracket.zoomOut': 'Afastar',
|
||||
'bracket.zoomReset': 'Restaurar zoom',
|
||||
'sim.mode': 'Simulação',
|
||||
'sim.reset': 'Limpar palpites',
|
||||
'sim.hint': 'Simulação ativa — clique numa partida destacada para escolher o vencedor. Resultados reais nunca mudam.',
|
||||
'sim.title': 'Simular',
|
||||
'sim.pickWinner': 'Escolha o vencedor. Placar igual ou vazio indica pênaltis.',
|
||||
'sim.save': 'Salvar palpite',
|
||||
'sim.clear': 'Remover palpite',
|
||||
'sim.chip': 'SIM',
|
||||
'time.local': 'Hora local',
|
||||
'time.stadium': 'Hora do estádio',
|
||||
'time.toggleAria': 'Alternar entre hora local e do estádio',
|
||||
'schedule.myMatches': 'Minhas partidas',
|
||||
'fav.toggle': 'Favoritar',
|
||||
'challenge.title': 'Bolão do mata-mata',
|
||||
'challenge.correct': '{x} de {y} palpites certos',
|
||||
'share.button': 'Compartilhar palpites',
|
||||
'share.copied': 'Link copiado!',
|
||||
'share.confirm': 'Aplicar os palpites compartilhados? Seus palpites atuais serão substituídos.',
|
||||
'modal.addCalendar': 'Adicionar à agenda',
|
||||
'footer.note': 'Hub estático feito por fãs — todos os dados vivem em arquivos JSON.',
|
||||
},
|
||||
};
|
||||
|
||||
let lang = 'en';
|
||||
|
||||
export function initI18n() {
|
||||
const saved = getPrefs().lang;
|
||||
lang = saved ?? (navigator.language?.toLowerCase().startsWith('pt') ? 'pt' : 'en');
|
||||
document.documentElement.lang = getLocale();
|
||||
applyI18n();
|
||||
}
|
||||
|
||||
export function t(key) {
|
||||
return dicts[lang][key] ?? dicts.en[key] ?? key;
|
||||
}
|
||||
|
||||
export function getLang() {
|
||||
return lang;
|
||||
}
|
||||
|
||||
export function getLocale() {
|
||||
return lang === 'pt' ? 'pt-BR' : 'en-US';
|
||||
}
|
||||
|
||||
export function setLang(next) {
|
||||
if (next === lang || !dicts[next]) return;
|
||||
lang = next;
|
||||
setPref('lang', next);
|
||||
document.documentElement.lang = getLocale();
|
||||
applyI18n();
|
||||
document.dispatchEvent(new CustomEvent('langchange'));
|
||||
}
|
||||
|
||||
export function applyI18n(root = document) {
|
||||
for (const el of root.querySelectorAll('[data-i18n]')) {
|
||||
el.textContent = t(el.dataset.i18n);
|
||||
}
|
||||
for (const el of root.querySelectorAll('[data-i18n-aria]')) {
|
||||
el.setAttribute('aria-label', t(el.dataset.i18nAria));
|
||||
}
|
||||
}
|
||||
|
||||
// Phase labels come from matches.json in English ("Group A", "Round of 32"…);
|
||||
// translate the known ones, pass anything else through untouched.
|
||||
const PHASE_KEYS = {
|
||||
'Round of 32': 'phase.r32',
|
||||
'Round of 16': 'phase.r16',
|
||||
Quarterfinals: 'phase.qf',
|
||||
Semifinals: 'phase.sf',
|
||||
'Third Place': 'phase.third',
|
||||
Final: 'phase.final',
|
||||
};
|
||||
|
||||
export function translatePhase(phase) {
|
||||
if (phase.startsWith('Group ')) return `${t('phase.group')} ${phase.slice(6)}`;
|
||||
const key = PHASE_KEYS[phase];
|
||||
return key ? t(key) : phase;
|
||||
}
|
||||
126
assets/js/modal.js
Normal file
126
assets/js/modal.js
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
// modal.js — match detail modal built on the native <dialog> element
|
||||
// (focus trap, Esc-to-close and ::backdrop for free). Shows teams, time,
|
||||
// stadium, city, capacity, result (+penalties) and a placeholder section for
|
||||
// future stats (possession, shots, cards). "Add to calendar" lands in step 12.
|
||||
|
||||
import { getData, formatMatchTime, flagSrc } from './app.js';
|
||||
import { t, getLocale, translatePhase } from './i18n.js';
|
||||
import { resolveBracketTeams } from './bracket.js';
|
||||
import { getFavorites } from './storage.js';
|
||||
import { exportMatchToICS } from './calendar.js';
|
||||
|
||||
let dialog = null;
|
||||
let currentMatchId = null;
|
||||
let lastFocused = null;
|
||||
|
||||
export function initModal() {
|
||||
document.getElementById('modal-root').innerHTML =
|
||||
'<dialog class="match-modal" id="match-dialog"></dialog>';
|
||||
dialog = document.getElementById('match-dialog');
|
||||
|
||||
// a click on the dialog element itself (not its padded content) = backdrop
|
||||
dialog.addEventListener('click', (event) => {
|
||||
if (event.target === dialog) dialog.close();
|
||||
});
|
||||
dialog.addEventListener('close', () => {
|
||||
currentMatchId = null;
|
||||
lastFocused?.focus?.();
|
||||
});
|
||||
const rerenderIfOpen = () => {
|
||||
if (dialog.open && currentMatchId) renderContent(currentMatchId);
|
||||
};
|
||||
document.addEventListener('langchange', rerenderIfOpen);
|
||||
document.addEventListener('favchange', rerenderIfOpen);
|
||||
document.addEventListener('timemodechange', rerenderIfOpen);
|
||||
}
|
||||
|
||||
export function openMatchModal(matchId) {
|
||||
lastFocused = document.activeElement;
|
||||
currentMatchId = matchId;
|
||||
renderContent(matchId);
|
||||
dialog.showModal();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- render
|
||||
|
||||
function renderContent(matchId) {
|
||||
const { matches, resultByMatchId, stadiumByName } = getData();
|
||||
const match = matches.find((m) => m.id === matchId);
|
||||
if (!match) return;
|
||||
const result = resultByMatchId.get(matchId);
|
||||
const status = result?.status ?? 'scheduled';
|
||||
const stadium = stadiumByName.get(match.stadium);
|
||||
const slots = resolveBracketTeams(match);
|
||||
const numberFmt = new Intl.NumberFormat(getLocale());
|
||||
|
||||
const statusChip =
|
||||
status === 'live' ? `<span class="match-status live pulse">● ${t('hero.live')}</span>`
|
||||
: status === 'finished' ? `<span class="match-status finished">${t('status.finished')}</span>`
|
||||
: `<span class="match-status">${t('status.scheduled')}</span>`;
|
||||
|
||||
let center;
|
||||
if (status === 'finished' || status === 'live') {
|
||||
const pens = result.penalties
|
||||
? `<span class="match-pens">(${result.penalties.home}–${result.penalties.away} ${t('status.pens')})</span>`
|
||||
: '';
|
||||
center = `<div class="modal-score">${result.homeScore}<span class="match-score-sep">–</span>${result.awayScore}${pens}</div>`;
|
||||
} else {
|
||||
center = `<span class="hero-vs">${t('hero.vs')}</span>`;
|
||||
}
|
||||
|
||||
dialog.innerHTML = `
|
||||
<div class="modal-content slide-up" role="document">
|
||||
<header class="modal-top">
|
||||
<p class="modal-phase">${translatePhase(match.phase)} ${statusChip}</p>
|
||||
<button class="modal-close" data-close aria-label="${t('modal.close')}">✕</button>
|
||||
</header>
|
||||
<div class="modal-matchup">
|
||||
${teamHTML(slots.home)}
|
||||
${center}
|
||||
${teamHTML(slots.away)}
|
||||
</div>
|
||||
<dl class="modal-info">
|
||||
<div><dt>${t('modal.date')}</dt><dd>${formatMatchTime(match, stadium)}</dd></div>
|
||||
<div><dt>${t('modal.stadium')}</dt><dd>${match.stadium}</dd></div>
|
||||
<div><dt>${t('modal.city')}</dt><dd>${match.city}</dd></div>
|
||||
<div><dt>${t('stadiums.capacity')}</dt><dd>${stadium ? numberFmt.format(stadium.capacity) : '—'}</dd></div>
|
||||
</dl>
|
||||
<section class="modal-stats" aria-label="${t('modal.stats')}">
|
||||
<h3>${t('modal.stats')}</h3>
|
||||
${statRow(t('modal.possession'))}
|
||||
${statRow(t('modal.shots'))}
|
||||
${statRow(t('modal.cards'))}
|
||||
<p class="modal-stats-note">${t('modal.statsSoon')}</p>
|
||||
</section>
|
||||
<div class="modal-actions">
|
||||
<button class="btn-primary" id="modal-ics">${t('modal.addCalendar')}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
dialog.setAttribute('aria-label', `${slots.home.label} ${t('hero.vs')} ${slots.away.label} — ${translatePhase(match.phase)}`);
|
||||
dialog.querySelector('[data-close]').addEventListener('click', () => dialog.close());
|
||||
dialog.querySelector('#modal-ics').addEventListener('click', () => exportMatchToICS(match, stadium));
|
||||
}
|
||||
|
||||
function teamHTML(slot) {
|
||||
if (!slot.team) return `<div class="hero-team"><span class="match-team-name tbd">${slot.label}</span></div>`;
|
||||
const fav = getFavorites().includes(slot.team.id);
|
||||
return `
|
||||
<div class="hero-team">
|
||||
<img class="flag" src="${flagSrc(slot.team)}" alt="" width="48" height="32">
|
||||
<span class="modal-team-name">${slot.team.name}
|
||||
<button class="fav-btn ${fav ? 'active' : ''}" data-fav="${slot.team.id}"
|
||||
aria-pressed="${fav}" aria-label="${t('fav.toggle')} ${slot.team.name}">${fav ? '★' : '☆'}</button>
|
||||
</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// placeholder row — real values will replace the dashes when stats data exists
|
||||
function statRow(label) {
|
||||
return `
|
||||
<div class="modal-stat-row">
|
||||
<span>—</span>
|
||||
<span class="modal-stat-label">${label}</span>
|
||||
<span>—</span>
|
||||
</div>`;
|
||||
}
|
||||
239
assets/js/schedule.js
Normal file
239
assets/js/schedule.js
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
// schedule.js — match schedule: list cards, filters (date, group, phase,
|
||||
// team, stadium), diacritic-insensitive search, date sort.
|
||||
// Knockout team names show as TBD until resolveBracketTeams() lands (step 7).
|
||||
|
||||
import { getData, formatMatchTime, matchDateUTC, flagSrc } from './app.js';
|
||||
import { t, translatePhase } from './i18n.js';
|
||||
import { openMatchModal } from './modal.js';
|
||||
import { resolveBracketTeams, getFavoriteMatches } from './bracket.js';
|
||||
import { getFavorites } from './storage.js';
|
||||
|
||||
const KNOCKOUT_PHASES = ['Round of 32', 'Round of 16', 'Quarterfinals', 'Semifinals', 'Third Place', 'Final'];
|
||||
|
||||
const state = { search: '', date: '', group: '', phase: '', team: '', stadium: '', sort: 'asc', favOnly: false };
|
||||
|
||||
export function initSchedule() {
|
||||
renderToolbar();
|
||||
renderList();
|
||||
document.addEventListener('langchange', () => {
|
||||
renderToolbar();
|
||||
renderList();
|
||||
});
|
||||
// simulated picks change resolved knockout teams shown on cards
|
||||
document.addEventListener('simchange', renderList);
|
||||
document.addEventListener('favchange', renderList);
|
||||
document.addEventListener('timemodechange', renderList);
|
||||
|
||||
// delegation on the panel root — survives every list re-render
|
||||
const root = document.getElementById('schedule-root');
|
||||
root.addEventListener('click', (event) => {
|
||||
if (event.target.closest('.fav-btn')) return; // star toggles, never opens
|
||||
const card = event.target.closest('.match-card');
|
||||
if (card) openMatchModal(Number(card.dataset.matchId));
|
||||
});
|
||||
root.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
if (event.target.closest('.fav-btn')) return;
|
||||
const card = event.target.closest('.match-card');
|
||||
if (card) {
|
||||
event.preventDefault();
|
||||
openMatchModal(Number(card.dataset.matchId));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- toolbar
|
||||
|
||||
function renderToolbar() {
|
||||
const { teams, groups, stadiums } = getData();
|
||||
const root = document.getElementById('schedule-root');
|
||||
|
||||
const teamOptions = [...teams]
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((team) => `<option value="${team.id}">${team.name}</option>`).join('');
|
||||
const groupOptions = Object.keys(groups)
|
||||
.map((letter) => `<option value="${letter}">${t('phase.group')} ${letter}</option>`).join('');
|
||||
const phaseOptions = KNOCKOUT_PHASES
|
||||
.map((phase) => `<option value="${phase}">${translatePhase(phase)}</option>`).join('');
|
||||
const stadiumOptions = stadiums
|
||||
.map((s) => `<option value="${s.name}">${s.name}</option>`).join('');
|
||||
|
||||
root.innerHTML = `
|
||||
<div class="schedule-toolbar glass">
|
||||
<input id="sched-search" class="schedule-search" type="search"
|
||||
placeholder="${t('schedule.searchPlaceholder')}" aria-label="${t('schedule.searchPlaceholder')}">
|
||||
<div class="filter-row">
|
||||
<input id="sched-date" class="filter-control" type="date"
|
||||
min="2026-06-11" max="2026-07-19" aria-label="${t('schedule.dateFilter')}">
|
||||
<select id="sched-group" class="filter-control" aria-label="${t('schedule.allGroups')}">
|
||||
<option value="">${t('schedule.allGroups')}</option>${groupOptions}
|
||||
</select>
|
||||
<select id="sched-phase" class="filter-control" aria-label="${t('schedule.allPhases')}">
|
||||
<option value="">${t('schedule.allPhases')}</option>
|
||||
<option value="groups">${t('schedule.groupStage')}</option>${phaseOptions}
|
||||
</select>
|
||||
<select id="sched-team" class="filter-control" aria-label="${t('schedule.allTeams')}">
|
||||
<option value="">${t('schedule.allTeams')}</option>${teamOptions}
|
||||
</select>
|
||||
<select id="sched-stadium" class="filter-control" aria-label="${t('schedule.allStadiums')}">
|
||||
<option value="">${t('schedule.allStadiums')}</option>${stadiumOptions}
|
||||
</select>
|
||||
<button id="sched-sort" class="filter-control sort-btn"></button>
|
||||
<button id="sched-fav" class="filter-control fav-filter ${state.favOnly ? 'active' : ''}"
|
||||
aria-pressed="${state.favOnly}">★ ${t('schedule.myMatches')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="schedule-count">
|
||||
<span id="sched-count" aria-live="polite"></span>
|
||||
<button id="sched-clear" class="link-btn">${t('schedule.clear')}</button>
|
||||
</p>
|
||||
<div class="match-grid" id="sched-list"></div>`;
|
||||
|
||||
// restore current filter values after a rebuild (e.g. language change)
|
||||
byId('sched-search').value = state.search;
|
||||
byId('sched-date').value = state.date;
|
||||
byId('sched-group').value = state.group;
|
||||
byId('sched-phase').value = state.phase;
|
||||
byId('sched-team').value = state.team;
|
||||
byId('sched-stadium').value = state.stadium;
|
||||
syncSortLabel();
|
||||
|
||||
byId('sched-search').addEventListener('input', (e) => setFilter('search', e.target.value));
|
||||
byId('sched-date').addEventListener('change', (e) => setFilter('date', e.target.value));
|
||||
byId('sched-group').addEventListener('change', (e) => setFilter('group', e.target.value));
|
||||
byId('sched-phase').addEventListener('change', (e) => setFilter('phase', e.target.value));
|
||||
byId('sched-team').addEventListener('change', (e) => setFilter('team', e.target.value));
|
||||
byId('sched-stadium').addEventListener('change', (e) => setFilter('stadium', e.target.value));
|
||||
byId('sched-sort').addEventListener('click', () => {
|
||||
state.sort = state.sort === 'asc' ? 'desc' : 'asc';
|
||||
syncSortLabel();
|
||||
renderList();
|
||||
});
|
||||
byId('sched-fav').addEventListener('click', () => {
|
||||
state.favOnly = !state.favOnly;
|
||||
const btn = byId('sched-fav');
|
||||
btn.classList.toggle('active', state.favOnly);
|
||||
btn.setAttribute('aria-pressed', String(state.favOnly));
|
||||
renderList();
|
||||
});
|
||||
byId('sched-clear').addEventListener('click', () => {
|
||||
Object.assign(state, { search: '', date: '', group: '', phase: '', team: '', stadium: '', sort: 'asc', favOnly: false });
|
||||
renderToolbar();
|
||||
renderList();
|
||||
});
|
||||
}
|
||||
|
||||
function byId(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
// external entry point (stadiums page) — show only matches at one stadium
|
||||
export function setStadiumFilter(stadiumName) {
|
||||
Object.assign(state, { search: '', date: '', group: '', phase: '', team: '', stadium: stadiumName, favOnly: false });
|
||||
renderToolbar();
|
||||
renderList();
|
||||
}
|
||||
|
||||
function setFilter(key, value) {
|
||||
state[key] = value;
|
||||
renderList();
|
||||
}
|
||||
|
||||
function syncSortLabel() {
|
||||
byId('sched-sort').textContent = t(state.sort === 'asc' ? 'schedule.sortAsc' : 'schedule.sortDesc');
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ filtering
|
||||
|
||||
function normalize(text) {
|
||||
return text.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
|
||||
}
|
||||
|
||||
function matchesFilters(match) {
|
||||
if (state.date && match.date !== state.date) return false;
|
||||
if (state.group && match.phase !== `Group ${state.group}`) return false;
|
||||
if (state.phase === 'groups' && !match.phase.startsWith('Group')) return false;
|
||||
if (state.phase && state.phase !== 'groups' && match.phase !== state.phase) return false;
|
||||
if (state.stadium && match.stadium !== state.stadium) return false;
|
||||
if (state.team || state.search) {
|
||||
// resolved teams, so knockout matches are searchable once known
|
||||
const slots = resolveBracketTeams(match);
|
||||
if (state.team && slots.home.team?.id !== state.team && slots.away.team?.id !== state.team) return false;
|
||||
if (state.search) {
|
||||
const haystack = normalize(`${slots.home.label} ${slots.away.label} ${match.city} ${match.stadium}`);
|
||||
if (!haystack.includes(normalize(state.search))) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- render
|
||||
|
||||
function renderList() {
|
||||
const { matches } = getData();
|
||||
const direction = state.sort === 'asc' ? 1 : -1;
|
||||
const favIds = state.favOnly
|
||||
? new Set(getFavoriteMatches(matches, getFavorites()).map((m) => m.id))
|
||||
: null;
|
||||
const filtered = matches
|
||||
.filter((m) => (!favIds || favIds.has(m.id)) && matchesFilters(m))
|
||||
.sort((a, b) => direction * (matchDateUTC(a) - matchDateUTC(b) || a.id - b.id));
|
||||
|
||||
byId('sched-count').textContent =
|
||||
`${filtered.length} ${filtered.length === 1 ? t('schedule.match') : t('schedule.matches')}`;
|
||||
byId('sched-list').innerHTML = filtered.length
|
||||
? filtered.map(matchCardHTML).join('')
|
||||
: `<p class="placeholder glass">${t('schedule.noResults')}</p>`;
|
||||
}
|
||||
|
||||
function teamColumnHTML(slot) {
|
||||
if (!slot.team) return `<div class="match-team"><span class="match-team-name tbd">${slot.label}</span></div>`;
|
||||
const fav = getFavorites().includes(slot.team.id);
|
||||
return `
|
||||
<div class="match-team">
|
||||
<img class="flag" src="${flagSrc(slot.team)}" alt="" width="34" height="23" loading="lazy">
|
||||
<span class="match-team-name">${slot.team.name}
|
||||
<button class="fav-btn ${fav ? 'active' : ''}" data-fav="${slot.team.id}"
|
||||
aria-pressed="${fav}" aria-label="${t('fav.toggle')} ${slot.team.name}">${fav ? '★' : '☆'}</button>
|
||||
</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function matchCardHTML(match) {
|
||||
const { resultByMatchId, stadiumByName } = getData();
|
||||
const result = resultByMatchId.get(match.id);
|
||||
const status = result?.status ?? 'scheduled';
|
||||
const stadium = stadiumByName.get(match.stadium);
|
||||
|
||||
let statusChip = '';
|
||||
if (status === 'live') statusChip = `<span class="match-status live pulse">● ${t('hero.live')}</span>`;
|
||||
else if (status === 'finished') statusChip = `<span class="match-status finished">${t('status.finished')}</span>`;
|
||||
|
||||
let center;
|
||||
if (status === 'finished' || status === 'live') {
|
||||
const pens = result.penalties
|
||||
? `<span class="match-pens">(${result.penalties.home}–${result.penalties.away} ${t('status.pens')})</span>`
|
||||
: '';
|
||||
center = `<div class="match-score">${result.homeScore}<span class="match-score-sep">–</span>${result.awayScore}${pens}</div>`;
|
||||
} else {
|
||||
center = `<span class="match-vs">${t('hero.vs')}</span>`;
|
||||
}
|
||||
|
||||
const slots = resolveBracketTeams(match);
|
||||
const favorites = getFavorites();
|
||||
const hasFav = favorites.includes(slots.home.team?.id) || favorites.includes(slots.away.team?.id);
|
||||
return `
|
||||
<article class="match-card glass hover-glow ${hasFav ? 'has-fav' : ''}" data-match-id="${match.id}"
|
||||
tabindex="0" role="button" aria-label="${slots.home.label} ${t('hero.vs')} ${slots.away.label} — ${translatePhase(match.phase)}">
|
||||
<header class="match-card-top">
|
||||
<span class="match-phase">${translatePhase(match.phase)}</span>
|
||||
${statusChip}
|
||||
</header>
|
||||
<div class="match-teams">
|
||||
${teamColumnHTML(slots.home)}
|
||||
${center}
|
||||
${teamColumnHTML(slots.away)}
|
||||
</div>
|
||||
<footer class="match-meta">${formatMatchTime(match, stadium)} · ${match.stadium}, ${match.city}</footer>
|
||||
</article>`;
|
||||
}
|
||||
52
assets/js/stadiums.js
Normal file
52
assets/js/stadiums.js
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
// stadiums.js — stadium cards: image, name, city, capacity, matches held.
|
||||
// "View matches" jumps to the schedule pre-filtered by stadium.
|
||||
|
||||
import { getData, navigateTo } from './app.js';
|
||||
import { t, getLocale } from './i18n.js';
|
||||
import { setStadiumFilter } from './schedule.js';
|
||||
|
||||
export function initStadiums() {
|
||||
render();
|
||||
document.addEventListener('langchange', render);
|
||||
}
|
||||
|
||||
function render() {
|
||||
const { stadiums, matches } = getData();
|
||||
const root = document.getElementById('stadiums-root');
|
||||
|
||||
const matchCounts = new Map();
|
||||
for (const match of matches) {
|
||||
matchCounts.set(match.stadium, (matchCounts.get(match.stadium) ?? 0) + 1);
|
||||
}
|
||||
const numberFmt = new Intl.NumberFormat(getLocale());
|
||||
|
||||
root.innerHTML = `
|
||||
<div class="stadiums-grid">
|
||||
${stadiums.map((stadium) => stadiumCardHTML(stadium, matchCounts.get(stadium.name) ?? 0, numberFmt)).join('')}
|
||||
</div>`;
|
||||
|
||||
for (const btn of root.querySelectorAll('[data-stadium]')) {
|
||||
btn.addEventListener('click', () => {
|
||||
setStadiumFilter(btn.dataset.stadium);
|
||||
navigateTo('matches');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function stadiumCardHTML(stadium, matchCount, numberFmt) {
|
||||
const matchWord = matchCount === 1 ? t('schedule.match') : t('schedule.matches');
|
||||
return `
|
||||
<article class="stadium-card glass hover-glow">
|
||||
<img class="stadium-img" src="assets/images/${stadium.image}" alt="${stadium.name}"
|
||||
width="400" height="225" loading="lazy">
|
||||
<div class="stadium-body">
|
||||
<h3 class="stadium-name">${stadium.name}</h3>
|
||||
<p class="stadium-city">${stadium.city}</p>
|
||||
<p class="stadium-stats">
|
||||
<span>${t('stadiums.capacity')}: <strong>${numberFmt.format(stadium.capacity)}</strong></span>
|
||||
<span class="stadium-match-count">${matchCount} ${matchWord}</span>
|
||||
</p>
|
||||
<button class="link-btn" data-stadium="${stadium.name}">${t('stadiums.viewMatches')} →</button>
|
||||
</div>
|
||||
</article>`;
|
||||
}
|
||||
44
assets/js/storage.js
Normal file
44
assets/js/storage.js
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
// storage.js — thin localStorage wrapper. All keys are prefixed wc2026_ and
|
||||
// values are JSON-serialized. Never touch localStorage outside this module.
|
||||
|
||||
const PREFIX = 'wc2026_';
|
||||
|
||||
export function get(key, fallback = null) {
|
||||
try {
|
||||
const raw = localStorage.getItem(PREFIX + key);
|
||||
return raw === null ? fallback : JSON.parse(raw);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function set(key, value) {
|
||||
try {
|
||||
localStorage.setItem(PREFIX + key, JSON.stringify(value));
|
||||
} catch {
|
||||
// storage full or unavailable (private mode) — app keeps working without persistence
|
||||
}
|
||||
}
|
||||
|
||||
export function getPrefs() {
|
||||
return get('prefs', {});
|
||||
}
|
||||
|
||||
export function setPref(name, value) {
|
||||
const prefs = getPrefs();
|
||||
prefs[name] = value;
|
||||
set('prefs', prefs);
|
||||
}
|
||||
|
||||
export function getFavorites() {
|
||||
return get('favorites', []);
|
||||
}
|
||||
|
||||
export function toggleFavorite(teamId) {
|
||||
const favorites = getFavorites();
|
||||
const index = favorites.indexOf(teamId);
|
||||
if (index >= 0) favorites.splice(index, 1);
|
||||
else favorites.push(teamId);
|
||||
set('favorites', favorites);
|
||||
return favorites;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue