From 9a31e9f4db5d831c3736fad10513b9f23b7f7a52 Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Thu, 11 Jun 2026 13:04:31 +0200 Subject: [PATCH] =?UTF-8?q?Phase=201:=20live=20dashboard=20=E2=80=94=20fix?= =?UTF-8?q?tures,=20scores,=20group=20tables,=20bracket?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- scripts/buildFixtures.ts | 132 +++++++++++++++++++++ server/src/footballData.ts | 83 ++++++++++++++ server/src/index.ts | 116 +++++++++++++++++++ server/src/liveLoop.ts | 77 +++++++++++++ server/src/sofascore.ts | 63 ++++++++++ server/src/tournament.ts | 164 +++++++++++++++++++++++++++ src/app/RootLayout.tsx | 32 ++++++ src/components/GroupTable.tsx | 57 ++++++++++ src/components/MatchCard.tsx | 65 +++++++++++ src/components/TeamLabel.tsx | 39 +++++++ src/features/bracket/BracketPage.tsx | 90 +++++++++++++-- src/features/groups/GroupsPage.tsx | 38 ++++++- src/features/live/LivePage.tsx | 80 ++++++++++++- src/lib/format.ts | 32 ++++++ src/lib/standings.ts | 136 ++++++++++++++++++++++ src/lib/teams.ts | 117 +++++++++++++++++++ src/lib/types.ts | 77 +++++++++++++ src/stores/tournamentStore.ts | 84 ++++++++++++++ 18 files changed, 1461 insertions(+), 21 deletions(-) create mode 100644 scripts/buildFixtures.ts create mode 100644 server/src/footballData.ts create mode 100644 server/src/index.ts create mode 100644 server/src/liveLoop.ts create mode 100644 server/src/sofascore.ts create mode 100644 server/src/tournament.ts create mode 100644 src/components/GroupTable.tsx create mode 100644 src/components/MatchCard.tsx create mode 100644 src/components/TeamLabel.tsx create mode 100644 src/lib/format.ts create mode 100644 src/lib/standings.ts create mode 100644 src/lib/teams.ts create mode 100644 src/lib/types.ts create mode 100644 src/stores/tournamentStore.ts diff --git a/scripts/buildFixtures.ts b/scripts/buildFixtures.ts new file mode 100644 index 0000000..b4c3cb4 --- /dev/null +++ b/scripts/buildFixtures.ts @@ -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(); // `${group}#${num}` → 0-based index + const byGroup = new Map(); + + 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(); diff --git a/server/src/footballData.ts b/server/src/footballData.ts new file mode 100644 index 0000000..268e923 --- /dev/null +++ b/server/src/footballData.ts @@ -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 { + 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; +} diff --git a/server/src/index.ts b/server/src/index.ts new file mode 100644 index 0000000..76cecc6 --- /dev/null +++ b/server/src/index.ts @@ -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(); + + 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); }); +} diff --git a/server/src/liveLoop.ts b/server/src/liveLoop.ts new file mode 100644 index 0000000..36b253a --- /dev/null +++ b/server/src/liveLoop.ts @@ -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 | undefined; + + const tick = async (): Promise => { + 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); + }; +} diff --git a/server/src/sofascore.ts b/server/src/sofascore.ts new file mode 100644 index 0000000..50719a0 --- /dev/null +++ b/server/src/sofascore.ts @@ -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 { + 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; +} diff --git a/server/src/tournament.ts b/server/src/tournament.ts new file mode 100644 index 0000000..d1ce761 --- /dev/null +++ b/server/src/tournament.ts @@ -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; + 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; + } +} diff --git a/src/app/RootLayout.tsx b/src/app/RootLayout.tsx index 1e7af5b..4cbfb8a 100644 --- a/src/app/RootLayout.tsx +++ b/src/app/RootLayout.tsx @@ -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 ( + + + Live + + ); + } + return ( + + + {status === 'connecting' ? '…' : 'Offline'} + + ); +} + 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 (
@@ -53,6 +84,7 @@ export function RootLayout() { ))}
+
diff --git a/src/components/GroupTable.tsx b/src/components/GroupTable.tsx new file mode 100644 index 0000000..a22ac2b --- /dev/null +++ b/src/components/GroupTable.tsx @@ -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 ( +
+
+ Group {group} + Pld · Pts +
+ + + + + + + + + + + {rows.map((r) => ( + + + + + + + ))} + +
TeamPGDPts
+ + + {r.rank} + + {teamFlag(r.team)} + {r.team} + + {r.played} + {r.gd > 0 ? `+${r.gd}` : r.gd} + {r.points}
+
+ ); +} diff --git a/src/components/MatchCard.tsx b/src/components/MatchCard.tsx new file mode 100644 index 0000000..51f6956 --- /dev/null +++ b/src/components/MatchCard.tsx @@ -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 = { + 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 ( + + + {f.minute ? `${f.minute}'` : 'Live'} + + ); + } + if (f.status === 'finished') { + return FT; + } + return {relativeKickoff(f.kickoff)}; +} + +export function MatchCard({ f }: { f: Fixture }) { + const hasScore = f.status === 'live' || f.status === 'finished'; + return ( +
+
+ {tag(f)} + +
+
+ +
+ {hasScore ? ( +
+ {f.homeScore ?? 0}{f.awayScore ?? 0} +
+ ) : ( +
{kickoffTime(f.kickoff)}
+ )} +
+ +
+
{f.venue}
+
+ ); +} diff --git a/src/components/TeamLabel.tsx b/src/components/TeamLabel.tsx new file mode 100644 index 0000000..887129e --- /dev/null +++ b/src/components/TeamLabel.tsx @@ -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 ( + + {slot.label} + + ); + } + return ( + + {teamFlag(slot.team)} + {slot.team} + + ); +} diff --git a/src/features/bracket/BracketPage.tsx b/src/features/bracket/BracketPage.tsx index 9e16871..00533bd 100644 --- a/src/features/bracket/BracketPage.tsx +++ b/src/features/bracket/BracketPage.tsx @@ -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 ( -
- - }> - The knockout bracket (32 → 16 → QF → SF → Final) arrives in Phase 1, with model - win-probabilities overlaid in Phase 2. - +
+ + {slot.team ? ( + <> + {teamFlag(slot.team)} + {slot.team} + + ) : ( + {slot.label} + )} + + {score !== null && {score}} +
+ ); +} + +function BracketMatch({ f }: { f: Fixture }) { + const decided = f.status === 'finished' && f.homeScore !== null && f.awayScore !== null; + return ( +
+ f.awayScore!} /> +
+ f.homeScore!} /> +
+ M{f.num} · {f.status === 'finished' ? 'FT' : `${kickoffDay(f.kickoff)} ${kickoffTime(f.kickoff)}`} +
+
+ ); +} + +export function BracketPage() { + const snapshot = useTournamentStore((s) => s.snapshot); + + const { byStage, third } = useMemo(() => { + const fixtures = snapshot?.fixtures ?? []; + const byStage: Record = { + 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 ( +
+ + +
+
+ {COLUMNS.map((col) => ( +
+

{col.label}

+ {byStage[col.stage].length + ? byStage[col.stage].map((f) => ) + :
} +
+ ))} +
+
+ + {third && ( +
+

Third-place play-off

+ +
+ )}
); } diff --git a/src/features/groups/GroupsPage.tsx b/src/features/groups/GroupsPage.tsx index 62c238a..6391c88 100644 --- a/src/features/groups/GroupsPage.tsx +++ b/src/features/groups/GroupsPage.tsx @@ -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 (
- - }> - Standings tables for the 12 groups arrive in Phase 1. - + + {groups.length ? ( +
+ {groups.map((g) => ( + + ))} +
+ ) : ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ ))} +
+ )} + +
+ + Top 2 — advance + + + 3rd — best-third race + +
); } diff --git a/src/features/live/LivePage.tsx b/src/features/live/LivePage.tsx index 41a3bab..2f353de 100644 --- a/src/features/live/LivePage.tsx +++ b/src/features/live/LivePage.tsx @@ -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 ( +
+

{title}

+
+ {fixtures.map((f) => )} +
+
+ ); +} 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 ( +
+ +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ ))} +
+
+ ); + } + + const nothingToday = !groups.live.length && !groups.today.length; + return (
- - }> - Live match cards land in Phase 1 — fixtures from openfootball, scores polled from - football-data.org, pushed over WebSocket. - + + +
+
+ {nothingToday && ( +
+ )} +
+ + {nothingToday && !groups.nextDay.length && !groups.recent.length && ( +
+ +

The schedule is loaded — matches will appear here as the tournament unfolds.

+
+ )}
); } diff --git a/src/lib/format.ts b/src/lib/format.ts new file mode 100644 index 0000000..a3c33db --- /dev/null +++ b/src/lib/format.ts @@ -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); +} diff --git a/src/lib/standings.ts b/src/lib/standings.ts new file mode 100644 index 0000000..e9f4d98 --- /dev/null +++ b/src/lib/standings.ts @@ -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, 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 { + 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 { + const groups = new Map>(); + const results = new Map(); + + 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 = {}; + for (const [g, teamSet] of [...groups.entries()].sort(([a], [b]) => a.localeCompare(b))) { + tables[g] = rankGroup([...teamSet].sort(), results.get(g) ?? []); + } + return tables; +} diff --git a/src/lib/teams.ts b/src/lib/teams.ts new file mode 100644 index 0000000..107d52b --- /dev/null +++ b/src/lib/teams.ts @@ -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 = { + '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 = { + '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); diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 0000000..774d87e --- /dev/null +++ b/src/lib/types.ts @@ -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; +} + +// ---- 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 }; diff --git a/src/stores/tournamentStore.ts b/src/stores/tournamentStore.ts new file mode 100644 index 0000000..046cac6 --- /dev/null +++ b/src/stores/tournamentStore.ts @@ -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 { + 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((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(); + })(); + }, +}));