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:
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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); });
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (A–L), 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
})();
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user