Phase 1: live dashboard — fixtures, scores, group tables, bracket

- Shared domain model (types, team metadata/flags/aliases, FIFA-tiebreak standings)
- buildFixtures.ts: openfootball 2026 → normalized fixtures.json (104 matches,
  ISO-UTC kickoffs, pretty knockout placeholders)
- Fastify live layer: seed load, football-data.org poller + SofaScore enhancement
  (feature-flagged, fallback), dynamic live-window polling, WS snapshot push,
  REST /api/snapshot, dev /api/dev/score injection
- Client: tournament store (REST + WS + static-file fallback), MatchCard,
  TeamLabel, GroupTable, Live/Groups/Bracket pages, live connection indicator
- Verified: WS pushes a dev-injected result; standings recompute live in-browser

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 13:04:31 +02:00
parent 4e4e75a1d8
commit 9a31e9f4db
18 changed files with 1461 additions and 21 deletions
+132
View File
@@ -0,0 +1,132 @@
// openfootball worldcup-2026.json → public/data/fixtures.json
// Normalizes the 104 matches into the app's Fixture shape: canonical team names,
// ISO-UTC kickoffs, stage tags, and pretty knockout placeholder labels.
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { canonicalTeam } from '../src/lib/teams';
import type { Fixture, FixturesFile, Stage, TeamSlot } from '../src/lib/types';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const RAW = join(ROOT, 'data', 'raw', 'worldcup-2026.json');
const OUT_DIR = join(ROOT, 'public', 'data');
interface RawMatch {
round: string;
num?: number;
date: string;
time: string;
team1: string;
team2: string;
group?: string;
ground: string;
}
function stageOf(round: string): Stage {
if (round.startsWith('Matchday')) return 'group';
if (round.startsWith('Round of 32')) return 'r32';
if (round.startsWith('Round of 16')) return 'r16';
if (round.startsWith('Quarter')) return 'qf';
if (round.startsWith('Semi')) return 'sf';
if (round.startsWith('Match for third')) return 'third';
if (round.startsWith('Final')) return 'final';
throw new Error(`Unknown round: ${round}`);
}
/** "2026-06-11" + "13:00 UTC-6" → ISO 8601 UTC. */
function toIsoUtc(date: string, time: string): string {
const [y, mo, d] = date.split('-').map(Number);
const m = time.match(/^(\d{1,2}):(\d{2})\s*UTC([+-]\d{1,2})$/);
if (!m || y === undefined || mo === undefined || d === undefined) {
throw new Error(`Bad date/time: ${date} ${time}`);
}
const hh = Number(m[1]);
const mm = Number(m[2]);
const offset = Number(m[3]); // local = UTC + offset, so UTC hour = hh - offset
return new Date(Date.UTC(y, mo - 1, d, hh - offset, mm)).toISOString();
}
/** Knockout placeholder code → readable label. */
function placeholderLabel(code: string): string {
const pos = code.match(/^([123])([A-L])$/);
if (pos) {
const rank = pos[1] === '1' ? 'Winner' : pos[1] === '2' ? 'Runner-up' : '3rd';
return `${rank} Group ${pos[2]}`;
}
const third = code.match(/^3([A-L/]+)$/);
if (third) return `3rd: ${third[1]}`;
const wl = code.match(/^([WL])(\d{1,3})$/);
if (wl) return `${wl[1] === 'W' ? 'Winner' : 'Loser'} of M${wl[2]}`;
return code;
}
function slot(raw: string, isGroup: boolean): TeamSlot {
if (isGroup) {
const team = canonicalTeam(raw);
return { team, placeholder: null, label: team };
}
return { team: null, placeholder: raw, label: placeholderLabel(raw) };
}
function main(): void {
const raw = JSON.parse(readFileSync(RAW, 'utf8')) as { name: string; matches: RawMatch[] };
const matches = raw.matches;
// groupRound: order each group's matches by kickoff, 2 matches per round.
const groupOrder = new Map<string, number>(); // `${group}#${num}` → 0-based index
const byGroup = new Map<string, { num: number; iso: string }[]>();
const prelim = matches.map((m, i) => {
const stage = stageOf(m.round);
const num = m.num ?? i + 1;
const iso = toIsoUtc(m.date, m.time);
if (stage === 'group' && m.group) {
const g = m.group.replace('Group ', '');
if (!byGroup.has(g)) byGroup.set(g, []);
byGroup.get(g)!.push({ num, iso });
}
return { m, stage, num, iso };
});
for (const [g, list] of byGroup) {
list.sort((a, b) => a.iso.localeCompare(b.iso) || a.num - b.num);
list.forEach((x, idx) => groupOrder.set(`${g}#${x.num}`, idx));
}
const fixtures: Fixture[] = prelim.map(({ m, stage, num, iso }) => {
const group = stage === 'group' && m.group ? m.group.replace('Group ', '') : null;
const groupRound = group ? Math.floor((groupOrder.get(`${group}#${num}`) ?? 0) / 2) + 1 : null;
return {
num,
stage,
group,
round: m.round,
groupRound,
kickoff: iso,
venue: m.ground,
home: slot(m.team1, stage === 'group'),
away: slot(m.team2, stage === 'group'),
status: 'scheduled',
homeScore: null,
awayScore: null,
minute: null,
} satisfies Fixture;
});
fixtures.sort((a, b) => a.num - b.num);
const out: FixturesFile = {
season: raw.name,
generatedAt: new Date().toISOString(),
groups: [...new Set(fixtures.filter((f) => f.group).map((f) => f.group!))].sort(),
venues: [...new Set(fixtures.map((f) => f.venue))].sort(),
fixtures,
};
mkdirSync(OUT_DIR, { recursive: true });
writeFileSync(join(OUT_DIR, 'fixtures.json'), JSON.stringify(out));
console.log(
`fixtures → public/data/fixtures.json (${fixtures.length} matches, ${out.groups.length} groups, ${out.venues.length} venues)`,
);
}
main();
+83
View File
@@ -0,0 +1,83 @@
import type { LiveMatch } from './tournament';
import type { MatchStatus, Stage } from '../../src/lib/types';
// football-data.org v4 — the reliable (if delayed) free source.
// Free tier: World Cup is included, 10 req/min. We poll one endpoint (~1 req/min).
const BASE = 'https://api.football-data.org/v4';
const COMPETITION = 'WC';
interface FdTeam { name: string | null }
interface FdScore { home: number | null; away: number | null }
interface FdMatch {
status: string;
stage: string;
group: string | null;
utcDate: string;
minute?: number | null;
homeTeam: FdTeam;
awayTeam: FdTeam;
score: { fullTime: FdScore };
}
function mapStatus(s: string): MatchStatus | null {
switch (s) {
case 'SCHEDULED':
case 'TIMED':
return 'scheduled';
case 'IN_PLAY':
case 'PAUSED':
return 'live';
case 'FINISHED':
return 'finished';
default:
return null; // POSTPONED / SUSPENDED / CANCELLED — ignore
}
}
function mapStage(s: string): Stage | null {
switch (s) {
case 'GROUP_STAGE':
return 'group';
case 'LAST_32':
return 'r32';
case 'LAST_16':
return 'r16';
case 'QUARTER_FINALS':
return 'qf';
case 'SEMI_FINALS':
return 'sf';
case 'THIRD_PLACE':
return 'third';
case 'FINAL':
return 'final';
default:
return null;
}
}
export async function fetchFootballData(token: string): Promise<LiveMatch[]> {
const res = await fetch(`${BASE}/competitions/${COMPETITION}/matches`, {
headers: { 'X-Auth-Token': token },
signal: AbortSignal.timeout(12_000),
});
if (!res.ok) throw new Error(`football-data ${res.status}`);
const body = (await res.json()) as { matches?: FdMatch[] };
const out: LiveMatch[] = [];
for (const m of body.matches ?? []) {
const status = mapStatus(m.status);
const stage = mapStage(m.stage);
if (!status || !stage || !m.homeTeam.name || !m.awayTeam.name) continue;
out.push({
home: m.homeTeam.name,
away: m.awayTeam.name,
group: m.group ? m.group.replace('GROUP_', '') : null,
stage,
kickoff: m.utcDate,
homeScore: m.score.fullTime.home,
awayScore: m.score.fullTime.away,
status,
minute: m.minute ?? null,
});
}
return out;
}
+116
View File
@@ -0,0 +1,116 @@
import path from 'node:path';
import { existsSync } from 'node:fs';
import Fastify from 'fastify';
import type { FastifyRequest } from 'fastify';
import fastifyWebsocket from '@fastify/websocket';
import fastifyStatic from '@fastify/static';
import type { WebSocket } from 'ws';
import { TournamentState } from './tournament';
import { startLiveLoop } from './liveLoop';
import type { MatchStatus, ServerMessage } from '../../src/lib/types';
const PORT = Number(process.env.PORT ?? 8787);
const STATIC_DIR = path.resolve(process.env.STATIC_DIR ?? path.join(process.cwd(), 'dist'));
const IS_PROD = process.env.NODE_ENV === 'production';
const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS ?? '').split(',').map((s) => s.trim()).filter(Boolean);
const MAX_WS = Number(process.env.MAX_WS) || 200;
const HEARTBEAT_MS = 30_000;
export function buildServer() {
const app = Fastify({ trustProxy: true, bodyLimit: 256 * 1024 });
// Tolerate body-less requests that still set content-type: application/json.
app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => {
const s = (body as string).trim();
if (!s) return done(null, undefined);
try { done(null, JSON.parse(s)); }
catch { const err = new Error('Invalid JSON') as Error & { statusCode?: number }; err.statusCode = 400; done(err, undefined); }
});
const state = new TournamentState(STATIC_DIR);
const clients = new Set<WebSocket>();
const snapshotMessage = (): string =>
JSON.stringify({ t: 'snapshot', snapshot: state.snapshot() } satisfies ServerMessage);
const broadcast = (): void => {
const msg = snapshotMessage();
for (const ws of clients) {
try { ws.send(msg); } catch { /* closed */ }
}
};
// ---- REST: current snapshot (initial load + a no-WS fallback) ----
app.get('/api/snapshot', async () => state.snapshot());
app.get('/healthz', async () => ({ ok: true }));
// ---- dev-only: inject a score to exercise the live → standings → WS loop ----
if (!IS_PROD) {
app.post('/api/dev/score', async (req, reply) => {
const b = (req.body ?? {}) as {
num?: number; homeScore?: number; awayScore?: number; status?: MatchStatus; minute?: number;
};
if (typeof b.num !== 'number') return reply.code(400).send({ error: 'num required' });
const status: MatchStatus = b.status ?? 'finished';
const f = state.applyScore(
b.num,
b.homeScore ?? null,
b.awayScore ?? null,
status,
b.minute ?? null,
'football-data',
);
if (!f) return reply.code(404).send({ error: 'no such fixture' });
broadcast();
return { ok: true, fixture: f };
});
}
// ---- WebSocket: push the snapshot on connect and on every change ----
void app.register(fastifyWebsocket);
void app.register(async (instance) => {
instance.get('/ws', { websocket: true }, (socket: WebSocket, req: FastifyRequest) => {
const origin = req.headers.origin;
if (ALLOWED_ORIGINS.length && origin && !ALLOWED_ORIGINS.includes(origin)) {
socket.close(1008, 'origin');
return;
}
if (clients.size >= MAX_WS) { socket.close(1013, 'busy'); return; }
clients.add(socket);
try { socket.send(snapshotMessage()); } catch { /* closed */ }
let alive = true;
socket.on('pong', () => { alive = true; });
const heartbeat = setInterval(() => {
if (!alive) { try { socket.terminate(); } catch { /* gone */ } return; }
alive = false;
try { socket.ping(); } catch { /* closed */ }
}, HEARTBEAT_MS);
socket.on('close', () => { clearInterval(heartbeat); clients.delete(socket); });
socket.on('error', () => { clearInterval(heartbeat); clients.delete(socket); });
});
});
// ---- live polling loop (no-op unless a source is configured) ----
const stopLive = startLiveLoop(state, () => broadcast());
app.addHook('onClose', async () => stopLive());
// ---- static SPA (prod) with history fallback ----
if (existsSync(STATIC_DIR)) {
void app.register(fastifyStatic, { root: STATIC_DIR, wildcard: false });
app.setNotFoundHandler((req, reply) => {
if (req.raw.url?.startsWith('/ws')) return reply.code(426).send('Upgrade Required');
if (req.raw.url?.startsWith('/api')) return reply.code(404).send({ error: 'not found' });
return reply.sendFile('index.html');
});
}
return app;
}
if (process.env.NODE_ENV !== 'test') {
const app = buildServer();
app.listen({ port: PORT, host: '0.0.0.0' })
.then((addr) => console.log(`cup26 server on ${addr} (static: ${existsSync(STATIC_DIR) ? STATIC_DIR : 'dev — vite serves UI'})`))
.catch((e) => { console.error(e); process.exit(1); });
}
+77
View File
@@ -0,0 +1,77 @@
import { TournamentState, type LiveMatch, type Source } from './tournament';
import { fetchFootballData } from './footballData';
import { fetchSofascore } from './sofascore';
import type { Fixture } from '../../src/lib/types';
const LIVE_WINDOW_MS = 3 * 60 * 60 * 1000; // ±3h around a kickoff = "match window"
const POLL_LIVE_MS = 60_000; // football-data free tier is 10 req/min — 1/min is safe
const POLL_IDLE_MS = 10 * 60_000;
/**
* Poll the live sources and merge results onto the tournament state. SofaScore
* (if enabled) is tried first for freshness; football-data.org is the fallback
* and the reliable backbone. Returns a stop function.
*/
export function startLiveLoop(
state: TournamentState,
onChange: (changed: Fixture[]) => void,
): () => void {
const token = process.env.FOOTBALL_DATA_TOKEN?.trim();
const sofa = process.env.ENABLE_SOFASCORE === 'true';
if (!token && !sofa) {
console.log('[live] seed-only (set FOOTBALL_DATA_TOKEN and/or ENABLE_SOFASCORE=true to enable)');
return () => {};
}
console.log(`[live] enabled — football-data:${token ? 'on' : 'off'} sofascore:${sofa ? 'on' : 'off'}`);
let stopped = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const tick = async (): Promise<void> => {
let matches: LiveMatch[] = [];
let source: Source = 'seed';
if (sofa) {
try {
matches = await fetchSofascore();
if (matches.length) source = 'sofascore';
} catch (e) {
console.warn('[live] sofascore failed:', e instanceof Error ? e.message : e);
}
}
if (!matches.length && token) {
try {
matches = await fetchFootballData(token);
source = 'football-data';
} catch (e) {
console.warn('[live] football-data failed:', e instanceof Error ? e.message : e);
}
}
if (matches.length) {
const changed = state.mergeLive(matches, source);
if (changed.length) {
console.log(`[live] ${changed.length} fixture(s) updated via ${source}`);
onChange(changed);
}
}
};
const schedule = (): void => {
if (stopped) return;
const delay = state.hasLiveActivity(LIVE_WINDOW_MS) ? POLL_LIVE_MS : POLL_IDLE_MS;
timer = setTimeout(async () => {
await tick().catch((e) => console.warn('[live] tick error:', e));
schedule();
}, delay);
};
// Kick off immediately, then settle into the dynamic schedule.
void tick().catch((e) => console.warn('[live] initial tick error:', e)).finally(schedule);
return () => {
stopped = true;
if (timer) clearTimeout(timer);
};
}
+63
View File
@@ -0,0 +1,63 @@
import type { LiveMatch } from './tournament';
import type { MatchStatus } from '../../src/lib/types';
// SofaScore unofficial API — near-real-time, but undocumented and fragile.
// Gated behind ENABLE_SOFASCORE; always wrapped in try/catch by the caller, with
// football-data.org as the fallback. We only read the global live-events feed and
// filter to the World Cup, so no scraping of internal IDs is needed.
const LIVE_URL = 'https://api.sofascore.com/api/v1/sport/football/events/live';
interface SsTeam { name: string }
interface SsScore { current?: number }
interface SsEvent {
tournament?: { name?: string; uniqueTournament?: { name?: string }; category?: { name?: string } };
homeTeam: SsTeam;
awayTeam: SsTeam;
homeScore: SsScore;
awayScore: SsScore;
status: { type: string };
time?: { currentPeriodStartTimestamp?: number };
roundInfo?: { name?: string };
}
function mapStatus(type: string): MatchStatus | null {
if (type === 'inprogress') return 'live';
if (type === 'finished') return 'finished';
if (type === 'notstarted') return 'scheduled';
return null;
}
function isWorldCup(e: SsEvent): boolean {
const names = [
e.tournament?.uniqueTournament?.name,
e.tournament?.name,
].filter(Boolean).map((s) => s!.toLowerCase());
return names.some((n) => n.includes('world cup')) && !names.some((n) => n.includes('qualif'));
}
export async function fetchSofascore(): Promise<LiveMatch[]> {
const res = await fetch(LIVE_URL, {
headers: { accept: 'application/json', 'user-agent': 'Mozilla/5.0 cup26' },
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) throw new Error(`sofascore ${res.status}`);
const body = (await res.json()) as { events?: SsEvent[] };
const out: LiveMatch[] = [];
for (const e of body.events ?? []) {
if (!isWorldCup(e)) continue;
const status = mapStatus(e.status.type);
if (!status) continue;
out.push({
home: e.homeTeam.name,
away: e.awayTeam.name,
group: null, // inferred by name-matching against the seed
stage: 'group', // hint only; the matcher resolves by team pair
kickoff: null,
homeScore: e.homeScore.current ?? null,
awayScore: e.awayScore.current ?? null,
status,
minute: null,
});
}
return out;
}
+164
View File
@@ -0,0 +1,164 @@
import { readFileSync, existsSync } from 'node:fs';
import { canonicalTeam } from '../../src/lib/teams';
import { computeGroupTables } from '../../src/lib/standings';
import type { Fixture, FixturesFile, MatchStatus, Snapshot, Stage } from '../../src/lib/types';
/** A normalized live match from any source (football-data, sofascore, dev). */
export interface LiveMatch {
home: string;
away: string;
group: string | null;
stage: Stage;
/** ISO kickoff if the source provides it (helps match knockout fixtures). */
kickoff: string | null;
homeScore: number | null;
awayScore: number | null;
status: MatchStatus;
minute: number | null;
}
export type Source = Snapshot['source'];
/** Candidate locations for the generated fixtures.json (prod dist, then dev public). */
function loadFixturesFile(staticDir: string): FixturesFile {
const candidates = [
`${staticDir}/data/fixtures.json`,
`${process.cwd()}/public/data/fixtures.json`,
`${process.cwd()}/dist/data/fixtures.json`,
];
for (const p of candidates) {
if (existsSync(p)) return JSON.parse(readFileSync(p, 'utf8')) as FixturesFile;
}
throw new Error(`fixtures.json not found. Run "npm run data:fixtures". Looked in: ${candidates.join(', ')}`);
}
const utcDate = (iso: string): string => iso.slice(0, 10);
/** In-memory tournament: the static seed plus any live overlay. */
export class TournamentState {
readonly season: string;
private fixtures: Fixture[];
private byNum: Map<number, Fixture>;
updatedAt: string | null = null;
source: Source = 'seed';
constructor(staticDir: string) {
const file = loadFixturesFile(staticDir);
this.season = file.season;
this.fixtures = file.fixtures;
this.byNum = new Map(this.fixtures.map((f) => [f.num, f]));
}
snapshot(): Snapshot {
return {
season: this.season,
updatedAt: this.updatedAt,
source: this.source,
fixtures: this.fixtures,
tables: computeGroupTables(this.fixtures),
};
}
getFixture(num: number): Fixture | undefined {
return this.byNum.get(num);
}
/** True if any match is live or kicks off within `windowMs` of now — used to
* poll the APIs often during match windows and rarely when nothing is on. */
hasLiveActivity(windowMs: number): boolean {
const now = Date.now();
return this.fixtures.some(
(f) => f.status === 'live' || Math.abs(new Date(f.kickoff).getTime() - now) <= windowMs,
);
}
/** Directly set a fixture's score/status (used by the dev injection endpoint). */
applyScore(
num: number,
homeScore: number | null,
awayScore: number | null,
status: MatchStatus,
minute: number | null,
source: Source,
): Fixture | null {
const f = this.byNum.get(num);
if (!f) return null;
f.homeScore = homeScore;
f.awayScore = awayScore;
f.status = status;
f.minute = minute;
this.updatedAt = new Date().toISOString();
this.source = source;
return f;
}
/**
* Merge a batch of live matches onto the seed. Group matches match by group +
* unordered team pair; knockout matches by kickoff date + team overlap. Returns
* the fixtures that actually changed.
*/
mergeLive(matches: LiveMatch[], source: Source): Fixture[] {
const changed: Fixture[] = [];
for (const lm of matches) {
const f = this.findFixture(lm);
if (!f) continue;
if (this.applyLive(f, lm)) changed.push(f);
}
if (changed.length) {
this.updatedAt = new Date().toISOString();
this.source = source;
}
return changed;
}
private findFixture(lm: LiveMatch): Fixture | undefined {
const home = canonicalTeam(lm.home);
const away = canonicalTeam(lm.away);
// 1) Strongest signal: an unordered match of two known team names. Works for
// every group game and any already-resolved knockout, regardless of the
// source's stage hint (SofaScore's is unreliable).
const byPair = this.fixtures.find(
(f) =>
f.home.team && f.away.team &&
((f.home.team === home && f.away.team === away) ||
(f.home.team === away && f.away.team === home)),
);
if (byPair) return byPair;
// 2) Knockout slot not yet filled in the seed: match by stage + kickoff date,
// preferring a fixture whose slots are still unresolved.
if (lm.stage !== 'group') {
const sameStage = this.fixtures.filter(
(f) => f.stage === lm.stage && (lm.kickoff ? utcDate(f.kickoff) === utcDate(lm.kickoff) : true),
);
if (sameStage.length === 1) return sameStage[0];
return sameStage.find((f) => !f.home.team || !f.away.team) ?? sameStage[0];
}
return undefined;
}
/** Overlay a live match onto a fixture, orienting scores to the seed's home/away. */
private applyLive(f: Fixture, lm: LiveMatch): boolean {
const home = canonicalTeam(lm.home);
const away = canonicalTeam(lm.away);
// Fill concrete teams for resolved knockout slots.
if (!f.home.team && !f.away.team) {
f.home = { team: home, placeholder: f.home.placeholder, label: home };
f.away = { team: away, placeholder: f.away.placeholder, label: away };
}
const swapped = f.home.team === away && f.away.team === home;
const hs = swapped ? lm.awayScore : lm.homeScore;
const as = swapped ? lm.homeScore : lm.awayScore;
const before = `${f.status}:${f.homeScore}:${f.awayScore}:${f.minute}`;
f.homeScore = hs;
f.awayScore = as;
f.status = lm.status;
f.minute = lm.minute;
const after = `${f.status}:${f.homeScore}:${f.awayScore}:${f.minute}`;
return before !== after;
}
}
+32
View File
@@ -1,6 +1,8 @@
import { useEffect } from 'react';
import { Link, Outlet } from '@tanstack/react-router';
import { Moon, Sun, Trophy } from 'lucide-react';
import { useUiStore } from '@/stores/uiStore';
import { useTournamentStore } from '@/stores/tournamentStore';
import { cn } from '@/lib/cn';
const NAV = [
@@ -11,6 +13,31 @@ const NAV = [
{ to: '/story', label: 'Story', exact: false },
] as const;
function ConnectionStatus() {
const status = useTournamentStore((s) => s.status);
const source = useTournamentStore((s) => s.snapshot?.source);
if (status === 'live') {
return (
<span
title={source === 'sofascore' ? 'Live · SofaScore' : source === 'football-data' ? 'Live · football-data.org' : 'Connected'}
className="inline-flex items-center gap-1.5 rounded-full border border-accent-deep/40 bg-accent-glow px-2 py-0.5 text-[11px] font-bold uppercase tracking-wide text-accent"
>
<span className="live-dot h-1.5 w-1.5 rounded-full bg-accent" />
Live
</span>
);
}
return (
<span
title={status === 'connecting' ? 'Connecting…' : 'Offline — showing the schedule'}
className="inline-flex items-center gap-1.5 rounded-full border border-line px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-muted"
>
<span className="h-1.5 w-1.5 rounded-full bg-faint" />
{status === 'connecting' ? '…' : 'Offline'}
</span>
);
}
function ThemeToggle() {
const theme = useUiStore((s) => s.theme);
const toggle = useUiStore((s) => s.toggleTheme);
@@ -27,6 +54,10 @@ function ThemeToggle() {
}
export function RootLayout() {
useEffect(() => {
useTournamentStore.getState().init();
}, []);
return (
<div className="flex min-h-full flex-col">
<header className="sticky top-0 z-30 border-b border-line bg-surface/85 backdrop-blur">
@@ -53,6 +84,7 @@ export function RootLayout() {
))}
</nav>
<div className="ml-auto flex items-center gap-2">
<ConnectionStatus />
<ThemeToggle />
</div>
</div>
+57
View File
@@ -0,0 +1,57 @@
import { cn } from '@/lib/cn';
import { teamFlag } from '@/lib/teams';
import type { StandingRow } from '@/lib/types';
/** One group's standings. Top 2 qualify directly; 3rd may advance as a best-third. */
export function GroupTable({ group, rows }: { group: string; rows: StandingRow[] }) {
return (
<div className="overflow-hidden rounded-xl border border-line bg-panel">
<div className="flex items-center justify-between border-b border-line px-3 py-2">
<span className="font-display text-sm font-bold text-ink">Group {group}</span>
<span className="smallcaps">Pld · Pts</span>
</div>
<table className="w-full border-collapse text-sm">
<thead>
<tr className="text-[11px] uppercase tracking-wide text-faint">
<th className="px-3 py-1.5 text-left font-semibold">Team</th>
<th className="w-8 py-1.5 text-center font-semibold">P</th>
<th className="w-8 py-1.5 text-center font-semibold">GD</th>
<th className="w-9 py-1.5 text-center font-semibold">Pts</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr
key={r.team}
className={cn(
'border-t border-line/60',
r.rank <= 2 && 'bg-accent-glow',
r.rank === 3 && 'bg-gold/5',
)}
>
<td className="px-3 py-1.5">
<span className="flex items-center gap-2">
<span
className={cn(
'w-4 text-center text-[11px] font-bold tabular-nums',
r.rank <= 2 ? 'text-accent' : r.rank === 3 ? 'text-gold' : 'text-faint',
)}
>
{r.rank}
</span>
<span aria-hidden className="text-base leading-none">{teamFlag(r.team)}</span>
<span className="truncate text-ink">{r.team}</span>
</span>
</td>
<td className="py-1.5 text-center tabular-nums text-muted">{r.played}</td>
<td className="py-1.5 text-center tabular-nums text-muted">
{r.gd > 0 ? `+${r.gd}` : r.gd}
</td>
<td className="py-1.5 text-center font-bold tabular-nums text-ink">{r.points}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
+65
View File
@@ -0,0 +1,65 @@
import { cn } from '@/lib/cn';
import type { Fixture } from '@/lib/types';
import { kickoffTime, relativeKickoff } from '@/lib/format';
import { TeamLabel } from './TeamLabel';
const STAGE_LABEL: Record<Fixture['stage'], string> = {
group: 'Group',
r32: 'Round of 32',
r16: 'Round of 16',
qf: 'Quarter-final',
sf: 'Semi-final',
third: 'Third place',
final: 'Final',
};
function tag(f: Fixture): string {
if (f.stage === 'group') return `Group ${f.group}`;
return STAGE_LABEL[f.stage];
}
function StatusPill({ f }: { f: Fixture }) {
if (f.status === 'live') {
return (
<span className="inline-flex items-center gap-1.5 rounded-full border border-live/40 bg-live/15 px-2 py-0.5 text-[11px] font-bold uppercase tracking-wide text-live">
<span className="live-dot h-1.5 w-1.5 rounded-full bg-live" />
{f.minute ? `${f.minute}'` : 'Live'}
</span>
);
}
if (f.status === 'finished') {
return <span className="text-[11px] font-bold uppercase tracking-wide text-faint">FT</span>;
}
return <span className="text-[11px] font-semibold text-muted">{relativeKickoff(f.kickoff)}</span>;
}
export function MatchCard({ f }: { f: Fixture }) {
const hasScore = f.status === 'live' || f.status === 'finished';
return (
<div
className={cn(
'rounded-xl border border-line bg-panel px-4 py-3 transition-colors',
f.status === 'live' && 'border-live/40 ring-1 ring-live/30',
)}
>
<div className="mb-2 flex items-center justify-between">
<span className="smallcaps">{tag(f)}</span>
<StatusPill f={f} />
</div>
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-3">
<TeamLabel slot={f.home} align="right" />
<div className="min-w-[64px] text-center">
{hasScore ? (
<div className="tnum text-xl font-bold text-ink">
{f.homeScore ?? 0}<span className="px-1.5 text-faint"></span>{f.awayScore ?? 0}
</div>
) : (
<div className="tnum text-sm font-semibold text-muted">{kickoffTime(f.kickoff)}</div>
)}
</div>
<TeamLabel slot={f.away} align="left" />
</div>
<div className="mt-1.5 text-center text-[11px] text-faint">{f.venue}</div>
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { teamFlag } from '@/lib/teams';
import { cn } from '@/lib/cn';
import type { TeamSlot } from '@/lib/types';
/** A team identity (flag + name) or, for an unresolved knockout slot, a muted
* placeholder label ("Runner-up Group A"). */
export function TeamLabel({
slot,
align = 'left',
className,
}: {
slot: TeamSlot;
align?: 'left' | 'right';
className?: string;
}) {
const right = align === 'right';
if (!slot.team) {
return (
<span
className={cn('truncate text-sm italic text-faint', right && 'text-right', className)}
title={slot.label}
>
{slot.label}
</span>
);
}
return (
<span
className={cn(
'flex min-w-0 items-center gap-2',
right && 'flex-row-reverse',
className,
)}
>
<span aria-hidden className="text-lg leading-none">{teamFlag(slot.team)}</span>
<span className="truncate font-medium text-ink">{slot.team}</span>
</span>
);
}
+81 -9
View File
@@ -1,15 +1,87 @@
import { GitMerge } from 'lucide-react';
import { useMemo } from 'react';
import { PageHeader } from '@/components/ui/PageHeader';
import { Placeholder } from '@/components/ui/Placeholder';
import { useTournamentStore } from '@/stores/tournamentStore';
import { teamFlag } from '@/lib/teams';
import { kickoffDay, kickoffTime } from '@/lib/format';
import { cn } from '@/lib/cn';
import type { Fixture, Stage, TeamSlot } from '@/lib/types';
export function BracketPage() {
const COLUMNS: { stage: Stage; label: string }[] = [
{ stage: 'r32', label: 'Round of 32' },
{ stage: 'r16', label: 'Round of 16' },
{ stage: 'qf', label: 'Quarter-finals' },
{ stage: 'sf', label: 'Semi-finals' },
{ stage: 'final', label: 'Final' },
];
function Side({ slot, score, winner }: { slot: TeamSlot; score: number | null; winner: boolean }) {
return (
<div>
<PageHeader title="Bracket" subtitle="Round of 32 through the Final." />
<Placeholder icon={<GitMerge size={28} />}>
The knockout bracket (32 16 QF SF Final) arrives in Phase 1, with model
win-probabilities overlaid in Phase 2.
</Placeholder>
<div className={cn('flex items-center justify-between gap-2 px-2.5 py-1.5', winner && 'font-bold')}>
<span className="flex min-w-0 items-center gap-1.5">
{slot.team ? (
<>
<span aria-hidden className="text-sm leading-none">{teamFlag(slot.team)}</span>
<span className="truncate text-ink">{slot.team}</span>
</>
) : (
<span className="truncate text-xs italic text-faint" title={slot.label}>{slot.label}</span>
)}
</span>
{score !== null && <span className="tnum shrink-0 text-ink">{score}</span>}
</div>
);
}
function BracketMatch({ f }: { f: Fixture }) {
const decided = f.status === 'finished' && f.homeScore !== null && f.awayScore !== null;
return (
<div className="w-52 overflow-hidden rounded-lg border border-line bg-panel text-sm">
<Side slot={f.home} score={f.homeScore} winner={decided && f.homeScore! > f.awayScore!} />
<div className="h-px bg-line" />
<Side slot={f.away} score={f.awayScore} winner={decided && f.awayScore! > f.homeScore!} />
<div className="border-t border-line bg-surface-2 px-2.5 py-1 text-[10px] text-faint">
M{f.num} · {f.status === 'finished' ? 'FT' : `${kickoffDay(f.kickoff)} ${kickoffTime(f.kickoff)}`}
</div>
</div>
);
}
export function BracketPage() {
const snapshot = useTournamentStore((s) => s.snapshot);
const { byStage, third } = useMemo(() => {
const fixtures = snapshot?.fixtures ?? [];
const byStage: Record<Stage, Fixture[]> = {
group: [], r32: [], r16: [], qf: [], sf: [], third: [], final: [],
};
for (const f of fixtures) if (f.stage !== 'group') byStage[f.stage].push(f);
for (const s of Object.keys(byStage) as Stage[]) byStage[s].sort((a, b) => a.num - b.num);
return { byStage, third: byStage.third[0] };
}, [snapshot]);
return (
<div>
<PageHeader title="Bracket" subtitle="The knockout road — Round of 32 to the Final." />
<div className="overflow-x-auto pb-4">
<div className="flex gap-5">
{COLUMNS.map((col) => (
<div key={col.stage} className="flex shrink-0 flex-col gap-3">
<h2 className="smallcaps">{col.label}</h2>
{byStage[col.stage].length
? byStage[col.stage].map((f) => <BracketMatch key={f.num} f={f} />)
: <div className="h-20 w-52 animate-pulse rounded-lg border border-line bg-panel" />}
</div>
))}
</div>
</div>
{third && (
<div className="mt-2">
<h2 className="smallcaps mb-2">Third-place play-off</h2>
<BracketMatch f={third} />
</div>
)}
</div>
);
}
+32 -6
View File
@@ -1,14 +1,40 @@
import { Table } from 'lucide-react';
import { PageHeader } from '@/components/ui/PageHeader';
import { Placeholder } from '@/components/ui/Placeholder';
import { GroupTable } from '@/components/GroupTable';
import { useTournamentStore } from '@/stores/tournamentStore';
export function GroupsPage() {
const snapshot = useTournamentStore((s) => s.snapshot);
const tables = snapshot?.tables ?? {};
const groups = Object.keys(tables).sort();
return (
<div>
<PageHeader title="Groups" subtitle="All 12 groups (AL), 48 teams." />
<Placeholder icon={<Table size={28} />}>
Standings tables for the 12 groups arrive in Phase 1.
</Placeholder>
<PageHeader
title="Groups"
subtitle="All 12 groups · top 2 advance, plus the 8 best third-placed teams."
/>
{groups.length ? (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{groups.map((g) => (
<GroupTable key={g} group={g} rows={tables[g]!} />
))}
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-44 animate-pulse rounded-xl border border-line bg-panel" />
))}
</div>
)}
<div className="mt-5 flex items-center gap-4 text-xs text-faint">
<span className="inline-flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded bg-accent-glow ring-1 ring-accent-deep/40" /> Top 2 advance
</span>
<span className="inline-flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded bg-gold/20 ring-1 ring-gold/40" /> 3rd best-third race
</span>
</div>
</div>
);
}
+74 -6
View File
@@ -1,15 +1,83 @@
import { useMemo } from 'react';
import { Radio } from 'lucide-react';
import { PageHeader } from '@/components/ui/PageHeader';
import { Placeholder } from '@/components/ui/Placeholder';
import { MatchCard } from '@/components/MatchCard';
import { useTournamentStore } from '@/stores/tournamentStore';
import { dayKeyOf, isToday, kickoffDay } from '@/lib/format';
import type { Fixture } from '@/lib/types';
function Section({ title, fixtures }: { title: string; fixtures: Fixture[] }) {
if (!fixtures.length) return null;
return (
<section className="mb-7">
<h2 className="smallcaps mb-2.5">{title}</h2>
<div className="grid gap-3 sm:grid-cols-2">
{fixtures.map((f) => <MatchCard key={f.num} f={f} />)}
</div>
</section>
);
}
export function LivePage() {
const snapshot = useTournamentStore((s) => s.snapshot);
const groups = useMemo(() => {
const fixtures = snapshot?.fixtures ?? [];
const live = fixtures.filter((f) => f.status === 'live').sort((a, b) => a.kickoff.localeCompare(b.kickoff));
const today = fixtures
.filter((f) => f.status !== 'live' && isToday(f.kickoff))
.sort((a, b) => a.kickoff.localeCompare(b.kickoff));
// If nothing is on today, surface the next match day in full.
const upcoming = fixtures
.filter((f) => f.status === 'scheduled' && !isToday(f.kickoff) && new Date(f.kickoff).getTime() > Date.now())
.sort((a, b) => a.kickoff.localeCompare(b.kickoff));
const nextDayKey = upcoming[0] ? dayKeyOf(upcoming[0].kickoff) : null;
const nextDay = nextDayKey ? upcoming.filter((f) => dayKeyOf(f.kickoff) === nextDayKey) : [];
const recent = fixtures
.filter((f) => f.status === 'finished')
.sort((a, b) => b.kickoff.localeCompare(a.kickoff))
.slice(0, 6);
return { live, today, nextDay, nextDayLabel: upcoming[0] ? kickoffDay(upcoming[0].kickoff) : '', recent };
}, [snapshot]);
if (!snapshot) {
return (
<div>
<PageHeader title="Live" subtitle="Loading the tournament…" />
<div className="grid gap-3 sm:grid-cols-2">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-24 animate-pulse rounded-xl border border-line bg-panel" />
))}
</div>
</div>
);
}
const nothingToday = !groups.live.length && !groups.today.length;
return (
<div>
<PageHeader title="Live" subtitle="Today's World Cup 2026 matches, scores and what's on now." />
<Placeholder icon={<Radio size={28} />}>
Live match cards land in Phase 1 fixtures from openfootball, scores polled from
football-data.org, pushed over WebSocket.
</Placeholder>
<PageHeader
title="Live"
subtitle={`${snapshot.season} · 48 teams · 104 matches`}
/>
<Section title="Live now" fixtures={groups.live} />
<Section title={nothingToday ? '' : "Today's matches"} fixtures={groups.today} />
{nothingToday && (
<Section title={groups.nextDayLabel ? `Next up · ${groups.nextDayLabel}` : 'Next up'} fixtures={groups.nextDay} />
)}
<Section title="Latest results" fixtures={groups.recent} />
{nothingToday && !groups.nextDay.length && !groups.recent.length && (
<div className="grid place-items-center gap-2 rounded-xl border border-line bg-panel py-16 text-center text-muted">
<Radio size={28} className="text-accent" />
<p className="text-sm">The schedule is loaded matches will appear here as the tournament unfolds.</p>
</div>
)}
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
// Date/score formatting in the viewer's local timezone.
const time = new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit' });
const dayLong = new Intl.DateTimeFormat(undefined, { weekday: 'short', day: 'numeric', month: 'short' });
const dayKey = new Intl.DateTimeFormat('en-CA', { year: 'numeric', month: '2-digit', day: '2-digit' });
export function kickoffTime(iso: string): string {
return time.format(new Date(iso));
}
export function kickoffDay(iso: string): string {
return dayLong.format(new Date(iso));
}
/** Stable local-day key (YYYY-MM-DD) for grouping fixtures by date. */
export function dayKeyOf(iso: string): string {
return dayKey.format(new Date(iso));
}
export function isToday(iso: string): boolean {
return dayKeyOf(iso) === dayKey.format(new Date());
}
/** "in 2h", "live", "today 19:00", "Thu 11 Jun" — a compact when-label. */
export function relativeKickoff(iso: string): string {
const diffMin = Math.round((new Date(iso).getTime() - Date.now()) / 60000);
if (diffMin < 0) return kickoffTime(iso);
if (diffMin < 60) return `in ${diffMin}m`;
if (isToday(iso)) return `today ${kickoffTime(iso)}`;
if (diffMin < 60 * 24 * 7) return `${kickoffDay(iso)} · ${kickoffTime(iso)}`;
return kickoffDay(iso);
}
+136
View File
@@ -0,0 +1,136 @@
import type { Fixture, StandingRow } from './types';
interface Tally {
team: string;
played: number;
won: number;
drawn: number;
lost: number;
gf: number;
ga: number;
points: number;
}
function blank(team: string): Tally {
return { team, played: 0, won: 0, drawn: 0, lost: 0, gf: 0, ga: 0, points: 0 };
}
/** A single finished result between two known teams. */
export interface Result {
home: string;
away: string;
homeScore: number;
awayScore: number;
}
function applyResult(tallies: Map<string, Tally>, r: Result): void {
const h = tallies.get(r.home);
const a = tallies.get(r.away);
if (!h || !a) return;
h.played++; a.played++;
h.gf += r.homeScore; h.ga += r.awayScore;
a.gf += r.awayScore; a.ga += r.homeScore;
if (r.homeScore > r.awayScore) { h.won++; a.lost++; h.points += 3; }
else if (r.homeScore < r.awayScore) { a.won++; h.lost++; a.points += 3; }
else { h.drawn++; a.drawn++; h.points++; a.points++; }
}
/** Head-to-head sub-ranking among a set of teams that are level on pts/gd/gf. */
function headToHead(tied: string[], results: Result[]): Map<string, number> {
const sub = new Map(tied.map((t) => [t, blank(t)]));
for (const r of results) {
if (sub.has(r.home) && sub.has(r.away)) applyResult(sub, r);
}
// Rank within the mini-table by pts, gd, gf.
const ranked = [...sub.values()].sort((x, y) =>
y.points - x.points || (y.gf - y.ga) - (x.gf - x.ga) || y.gf - x.gf,
);
return new Map(ranked.map((t, i) => [t.team, i]));
}
/**
* Rank a group's teams by the FIFA criteria: points, goal difference, goals
* for, then head-to-head (points/GD/GF among the tied), then name as a stable
* final fallback (the real tournament uses fair-play points / drawing of lots).
*/
export function rankGroup(teams: string[], results: Result[]): StandingRow[] {
const tallies = new Map(teams.map((t) => [t, blank(t)]));
for (const r of results) applyResult(tallies, r);
const rows = [...tallies.values()];
rows.sort((x, y) => {
const byPts = y.points - x.points;
if (byPts) return byPts;
const byGd = (y.gf - y.ga) - (x.gf - x.ga);
if (byGd) return byGd;
const byGf = y.gf - x.gf;
if (byGf) return byGf;
return 0; // resolve ties below via head-to-head
});
// Resolve any cluster level on pts+gd+gf with a head-to-head mini-table.
const out: Tally[] = [];
let i = 0;
while (i < rows.length) {
let j = i + 1;
const a = rows[i]!;
while (
j < rows.length &&
rows[j]!.points === a.points &&
rows[j]!.gf - rows[j]!.ga === a.gf - a.ga &&
rows[j]!.gf === a.gf
) j++;
const cluster = rows.slice(i, j);
if (cluster.length > 1) {
const order = headToHead(cluster.map((t) => t.team), results);
cluster.sort(
(x, y) => (order.get(x.team) ?? 0) - (order.get(y.team) ?? 0) || x.team.localeCompare(y.team),
);
}
out.push(...cluster);
i = j;
}
return out.map((t, idx) => ({
team: t.team,
played: t.played,
won: t.won,
drawn: t.drawn,
lost: t.lost,
gf: t.gf,
ga: t.ga,
gd: t.gf - t.ga,
points: t.points,
rank: idx + 1,
}));
}
/** Build ranked standings for every group from the current fixtures. */
export function computeGroupTables(fixtures: Fixture[]): Record<string, StandingRow[]> {
const groups = new Map<string, Set<string>>();
const results = new Map<string, Result[]>();
for (const f of fixtures) {
if (f.stage !== 'group' || !f.group) continue;
const g = f.group;
if (!groups.has(g)) { groups.set(g, new Set()); results.set(g, []); }
if (f.home.team) groups.get(g)!.add(f.home.team);
if (f.away.team) groups.get(g)!.add(f.away.team);
if (
f.status === 'finished' &&
f.home.team && f.away.team &&
f.homeScore !== null && f.awayScore !== null
) {
results.get(g)!.push({
home: f.home.team, away: f.away.team,
homeScore: f.homeScore, awayScore: f.awayScore,
});
}
}
const tables: Record<string, StandingRow[]> = {};
for (const [g, teamSet] of [...groups.entries()].sort(([a], [b]) => a.localeCompare(b))) {
tables[g] = rankGroup([...teamSet].sort(), results.get(g) ?? []);
}
return tables;
}
+117
View File
@@ -0,0 +1,117 @@
// Canonical team identities: ISO code, flag, and alias normalization so the
// three data sources (openfootball, martj42 results CSV, football-data.org)
// resolve to ONE name. Names follow openfootball's spelling as canonical.
interface TeamMeta {
/** ISO 3166-1 alpha-2 (drives the flag emoji), or a special key. */
iso2: string;
/** Override flag emoji for non-ISO entities (home nations). */
flag?: string;
}
/** Canonical name → metadata. The 48 World Cup 2026 qualifiers. */
const TEAMS: Record<string, TeamMeta> = {
'Czech Republic': { iso2: 'CZ' },
Mexico: { iso2: 'MX' },
'South Africa': { iso2: 'ZA' },
'South Korea': { iso2: 'KR' },
'Bosnia & Herzegovina': { iso2: 'BA' },
Canada: { iso2: 'CA' },
Qatar: { iso2: 'QA' },
Switzerland: { iso2: 'CH' },
Brazil: { iso2: 'BR' },
Haiti: { iso2: 'HT' },
Morocco: { iso2: 'MA' },
Scotland: { iso2: 'GB', flag: '🏴\u{E0067}\u{E0062}\u{E0073}\u{E0063}\u{E0074}\u{E007F}' },
Australia: { iso2: 'AU' },
Paraguay: { iso2: 'PY' },
Turkey: { iso2: 'TR' },
USA: { iso2: 'US' },
'Curaçao': { iso2: 'CW' },
Ecuador: { iso2: 'EC' },
Germany: { iso2: 'DE' },
'Ivory Coast': { iso2: 'CI' },
Japan: { iso2: 'JP' },
Netherlands: { iso2: 'NL' },
Sweden: { iso2: 'SE' },
Tunisia: { iso2: 'TN' },
Belgium: { iso2: 'BE' },
Egypt: { iso2: 'EG' },
Iran: { iso2: 'IR' },
'New Zealand': { iso2: 'NZ' },
'Cape Verde': { iso2: 'CV' },
'Saudi Arabia': { iso2: 'SA' },
Spain: { iso2: 'ES' },
Uruguay: { iso2: 'UY' },
France: { iso2: 'FR' },
Iraq: { iso2: 'IQ' },
Norway: { iso2: 'NO' },
Senegal: { iso2: 'SN' },
Algeria: { iso2: 'DZ' },
Argentina: { iso2: 'AR' },
Austria: { iso2: 'AT' },
Jordan: { iso2: 'JO' },
Colombia: { iso2: 'CO' },
'DR Congo': { iso2: 'CD' },
Portugal: { iso2: 'PT' },
Uzbekistan: { iso2: 'UZ' },
Croatia: { iso2: 'HR' },
England: { iso2: 'GB', flag: '🏴\u{E0067}\u{E0062}\u{E0065}\u{E006E}\u{E0067}\u{E007F}' },
Ghana: { iso2: 'GH' },
Panama: { iso2: 'PA' },
};
/** Variant spelling → canonical name (lowercased keys for case-insensitivity). */
const ALIASES: Record<string, string> = {
'bosnia and herzegovina': 'Bosnia & Herzegovina',
'bosnia-herzegovina': 'Bosnia & Herzegovina',
'korea republic': 'South Korea',
'republic of korea': 'South Korea',
korea: 'South Korea',
'united states': 'USA',
'united states of america': 'USA',
'usmnt': 'USA',
'türkiye': 'Turkey',
turkiye: 'Turkey',
"côte d'ivoire": 'Ivory Coast',
'cote d ivoire': 'Ivory Coast',
'ivory coast': 'Ivory Coast',
'cape verde islands': 'Cape Verde',
'cabo verde': 'Cape Verde',
'dr congo': 'DR Congo',
'congo dr': 'DR Congo',
'democratic republic of the congo': 'DR Congo',
'czechia': 'Czech Republic',
curacao: 'Curaçao',
'iran': 'Iran',
'ir iran': 'Iran',
};
function isoToFlag(iso2: string): string {
if (iso2.length !== 2) return '🏳️';
const A = 0x1f1e6;
const cc = iso2.toUpperCase();
return String.fromCodePoint(A + (cc.charCodeAt(0) - 65), A + (cc.charCodeAt(1) - 65));
}
/** Resolve any source spelling to the canonical team name (or the input trimmed). */
export function canonicalTeam(name: string): string {
const trimmed = name.trim();
if (TEAMS[trimmed]) return trimmed;
const alias = ALIASES[trimmed.toLowerCase()];
return alias ?? trimmed;
}
/** Flag emoji for a team name, '🏳️' if unknown. */
export function teamFlag(name: string): string {
const meta = TEAMS[canonicalTeam(name)];
if (!meta) return '🏳️';
return meta.flag ?? isoToFlag(meta.iso2);
}
/** True when the name is one of the known 2026 qualifiers. */
export function isKnownTeam(name: string): boolean {
return Boolean(TEAMS[canonicalTeam(name)]);
}
export const ALL_TEAMS = Object.keys(TEAMS);
+77
View File
@@ -0,0 +1,77 @@
// Shared domain types — used by the client, the server, and the build scripts.
export type Stage = 'group' | 'r32' | 'r16' | 'qf' | 'sf' | 'third' | 'final';
export type MatchStatus = 'scheduled' | 'live' | 'finished';
/** One side of a fixture: a known team, or an unresolved knockout placeholder. */
export interface TeamSlot {
/** Canonical team name when known, else null (unresolved knockout slot). */
team: string | null;
/** Raw openfootball placeholder: '2A' | 'W74' | 'L101' | '3A/B/C/D/F' | null. */
placeholder: string | null;
/** Display label: the team name, or a pretty placeholder ("Runner-up A"). */
label: string;
}
export interface Fixture {
/** Canonical 1..104 match number (group matches 1-72, knockout 73-104). */
num: number;
stage: Stage;
/** 'A'..'L' for group stage, else null. */
group: string | null;
/** Human round label from the source ("Matchday 1", "Round of 32", …). */
round: string;
/** 1 | 2 | 3 — the team's nth group game, else null. */
groupRound: number | null;
/** Kickoff as an ISO 8601 UTC timestamp. */
kickoff: string;
venue: string;
home: TeamSlot;
away: TeamSlot;
status: MatchStatus;
homeScore: number | null;
awayScore: number | null;
/** Live match minute when status === 'live'. */
minute: number | null;
}
export interface FixturesFile {
season: string;
generatedAt: string;
groups: string[];
venues: string[];
fixtures: Fixture[];
}
export interface StandingRow {
team: string;
played: number;
won: number;
drawn: number;
lost: number;
gf: number;
ga: number;
gd: number;
points: number;
/** 1-based rank within the group after sorting. */
rank: number;
}
/** What the server pushes to clients: fixtures with any live overlay + tables. */
export interface Snapshot {
season: string;
/** When the live layer last refreshed (ISO), or null if seed-only. */
updatedAt: string | null;
/** Which live source produced the current scores. */
source: 'seed' | 'football-data' | 'sofascore';
fixtures: Fixture[];
/** Group letter → ranked standings. */
tables: Record<string, StandingRow[]>;
}
// ---- WebSocket protocol ----
// The snapshot is small (~104 fixtures), so the server simply re-pushes the full
// snapshot on every change — no diffing, no client-side merge races.
/** Server → client. */
export type ServerMessage = { t: 'snapshot'; snapshot: Snapshot };
+84
View File
@@ -0,0 +1,84 @@
import { create } from 'zustand';
import type { FixturesFile, ServerMessage, Snapshot } from '@/lib/types';
import { computeGroupTables } from '@/lib/standings';
export type ConnStatus = 'connecting' | 'live' | 'offline';
interface TournamentStore {
snapshot: Snapshot | null;
status: ConnStatus;
init: () => void;
}
let started = false;
let ws: WebSocket | null = null;
let retry = 0;
function wsUrl(): string {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
return `${proto}://${location.host}/ws`;
}
/** Last-resort fallback: build a seed snapshot straight from the static file
* (works on a plain static host with no server — degraded, no live updates). */
async function loadStaticSeed(): Promise<Snapshot | null> {
try {
const res = await fetch('/data/fixtures.json');
if (!res.ok) return null;
const file = (await res.json()) as FixturesFile;
return {
season: file.season,
updatedAt: null,
source: 'seed',
fixtures: file.fixtures,
tables: computeGroupTables(file.fixtures),
};
} catch {
return null;
}
}
export const useTournamentStore = create<TournamentStore>((set) => ({
snapshot: null,
status: 'connecting',
init: () => {
if (started) return;
started = true;
const connect = (): void => {
try {
ws = new WebSocket(wsUrl());
} catch {
set({ status: 'offline' });
return;
}
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data as string) as ServerMessage;
if (msg.t === 'snapshot') set({ snapshot: msg.snapshot, status: 'live' });
} catch { /* ignore malformed frame */ }
};
ws.onopen = () => { retry = 0; set({ status: 'live' }); };
ws.onclose = () => {
set({ status: 'offline' });
retry = Math.min(retry + 1, 6);
setTimeout(connect, 1000 * 2 ** retry); // capped exponential backoff
};
ws.onerror = () => { try { ws?.close(); } catch { /* noop */ } };
};
// Initial paint from REST (fast), then the WS takes over for live updates.
void (async () => {
try {
const res = await fetch('/api/snapshot');
if (res.ok) set({ snapshot: (await res.json()) as Snapshot });
} catch { /* server may be absent on a static host */ }
if (!useTournamentStore.getState().snapshot) {
const seed = await loadStaticSeed();
if (seed) set({ snapshot: seed, status: 'offline' });
}
connect();
})();
},
}));