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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 13:04:31 +02:00
parent 4e4e75a1d8
commit 9a31e9f4db
18 changed files with 1461 additions and 21 deletions
+83
View File
@@ -0,0 +1,83 @@
import type { LiveMatch } from './tournament';
import type { MatchStatus, Stage } from '../../src/lib/types';
// football-data.org v4 — the reliable (if delayed) free source.
// Free tier: World Cup is included, 10 req/min. We poll one endpoint (~1 req/min).
const BASE = 'https://api.football-data.org/v4';
const COMPETITION = 'WC';
interface FdTeam { name: string | null }
interface FdScore { home: number | null; away: number | null }
interface FdMatch {
status: string;
stage: string;
group: string | null;
utcDate: string;
minute?: number | null;
homeTeam: FdTeam;
awayTeam: FdTeam;
score: { fullTime: FdScore };
}
function mapStatus(s: string): MatchStatus | null {
switch (s) {
case 'SCHEDULED':
case 'TIMED':
return 'scheduled';
case 'IN_PLAY':
case 'PAUSED':
return 'live';
case 'FINISHED':
return 'finished';
default:
return null; // POSTPONED / SUSPENDED / CANCELLED — ignore
}
}
function mapStage(s: string): Stage | null {
switch (s) {
case 'GROUP_STAGE':
return 'group';
case 'LAST_32':
return 'r32';
case 'LAST_16':
return 'r16';
case 'QUARTER_FINALS':
return 'qf';
case 'SEMI_FINALS':
return 'sf';
case 'THIRD_PLACE':
return 'third';
case 'FINAL':
return 'final';
default:
return null;
}
}
export async function fetchFootballData(token: string): Promise<LiveMatch[]> {
const res = await fetch(`${BASE}/competitions/${COMPETITION}/matches`, {
headers: { 'X-Auth-Token': token },
signal: AbortSignal.timeout(12_000),
});
if (!res.ok) throw new Error(`football-data ${res.status}`);
const body = (await res.json()) as { matches?: FdMatch[] };
const out: LiveMatch[] = [];
for (const m of body.matches ?? []) {
const status = mapStatus(m.status);
const stage = mapStage(m.stage);
if (!status || !stage || !m.homeTeam.name || !m.awayTeam.name) continue;
out.push({
home: m.homeTeam.name,
away: m.awayTeam.name,
group: m.group ? m.group.replace('GROUP_', '') : null,
stage,
kickoff: m.utcDate,
homeScore: m.score.fullTime.home,
awayScore: m.score.fullTime.away,
status,
minute: m.minute ?? null,
});
}
return out;
}
+116
View File
@@ -0,0 +1,116 @@
import path from 'node:path';
import { existsSync } from 'node:fs';
import Fastify from 'fastify';
import type { FastifyRequest } from 'fastify';
import fastifyWebsocket from '@fastify/websocket';
import fastifyStatic from '@fastify/static';
import type { WebSocket } from 'ws';
import { TournamentState } from './tournament';
import { startLiveLoop } from './liveLoop';
import type { MatchStatus, ServerMessage } from '../../src/lib/types';
const PORT = Number(process.env.PORT ?? 8787);
const STATIC_DIR = path.resolve(process.env.STATIC_DIR ?? path.join(process.cwd(), 'dist'));
const IS_PROD = process.env.NODE_ENV === 'production';
const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS ?? '').split(',').map((s) => s.trim()).filter(Boolean);
const MAX_WS = Number(process.env.MAX_WS) || 200;
const HEARTBEAT_MS = 30_000;
export function buildServer() {
const app = Fastify({ trustProxy: true, bodyLimit: 256 * 1024 });
// Tolerate body-less requests that still set content-type: application/json.
app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => {
const s = (body as string).trim();
if (!s) return done(null, undefined);
try { done(null, JSON.parse(s)); }
catch { const err = new Error('Invalid JSON') as Error & { statusCode?: number }; err.statusCode = 400; done(err, undefined); }
});
const state = new TournamentState(STATIC_DIR);
const clients = new Set<WebSocket>();
const snapshotMessage = (): string =>
JSON.stringify({ t: 'snapshot', snapshot: state.snapshot() } satisfies ServerMessage);
const broadcast = (): void => {
const msg = snapshotMessage();
for (const ws of clients) {
try { ws.send(msg); } catch { /* closed */ }
}
};
// ---- REST: current snapshot (initial load + a no-WS fallback) ----
app.get('/api/snapshot', async () => state.snapshot());
app.get('/healthz', async () => ({ ok: true }));
// ---- dev-only: inject a score to exercise the live → standings → WS loop ----
if (!IS_PROD) {
app.post('/api/dev/score', async (req, reply) => {
const b = (req.body ?? {}) as {
num?: number; homeScore?: number; awayScore?: number; status?: MatchStatus; minute?: number;
};
if (typeof b.num !== 'number') return reply.code(400).send({ error: 'num required' });
const status: MatchStatus = b.status ?? 'finished';
const f = state.applyScore(
b.num,
b.homeScore ?? null,
b.awayScore ?? null,
status,
b.minute ?? null,
'football-data',
);
if (!f) return reply.code(404).send({ error: 'no such fixture' });
broadcast();
return { ok: true, fixture: f };
});
}
// ---- WebSocket: push the snapshot on connect and on every change ----
void app.register(fastifyWebsocket);
void app.register(async (instance) => {
instance.get('/ws', { websocket: true }, (socket: WebSocket, req: FastifyRequest) => {
const origin = req.headers.origin;
if (ALLOWED_ORIGINS.length && origin && !ALLOWED_ORIGINS.includes(origin)) {
socket.close(1008, 'origin');
return;
}
if (clients.size >= MAX_WS) { socket.close(1013, 'busy'); return; }
clients.add(socket);
try { socket.send(snapshotMessage()); } catch { /* closed */ }
let alive = true;
socket.on('pong', () => { alive = true; });
const heartbeat = setInterval(() => {
if (!alive) { try { socket.terminate(); } catch { /* gone */ } return; }
alive = false;
try { socket.ping(); } catch { /* closed */ }
}, HEARTBEAT_MS);
socket.on('close', () => { clearInterval(heartbeat); clients.delete(socket); });
socket.on('error', () => { clearInterval(heartbeat); clients.delete(socket); });
});
});
// ---- live polling loop (no-op unless a source is configured) ----
const stopLive = startLiveLoop(state, () => broadcast());
app.addHook('onClose', async () => stopLive());
// ---- static SPA (prod) with history fallback ----
if (existsSync(STATIC_DIR)) {
void app.register(fastifyStatic, { root: STATIC_DIR, wildcard: false });
app.setNotFoundHandler((req, reply) => {
if (req.raw.url?.startsWith('/ws')) return reply.code(426).send('Upgrade Required');
if (req.raw.url?.startsWith('/api')) return reply.code(404).send({ error: 'not found' });
return reply.sendFile('index.html');
});
}
return app;
}
if (process.env.NODE_ENV !== 'test') {
const app = buildServer();
app.listen({ port: PORT, host: '0.0.0.0' })
.then((addr) => console.log(`cup26 server on ${addr} (static: ${existsSync(STATIC_DIR) ? STATIC_DIR : 'dev — vite serves UI'})`))
.catch((e) => { console.error(e); process.exit(1); });
}
+77
View File
@@ -0,0 +1,77 @@
import { TournamentState, type LiveMatch, type Source } from './tournament';
import { fetchFootballData } from './footballData';
import { fetchSofascore } from './sofascore';
import type { Fixture } from '../../src/lib/types';
const LIVE_WINDOW_MS = 3 * 60 * 60 * 1000; // ±3h around a kickoff = "match window"
const POLL_LIVE_MS = 60_000; // football-data free tier is 10 req/min — 1/min is safe
const POLL_IDLE_MS = 10 * 60_000;
/**
* Poll the live sources and merge results onto the tournament state. SofaScore
* (if enabled) is tried first for freshness; football-data.org is the fallback
* and the reliable backbone. Returns a stop function.
*/
export function startLiveLoop(
state: TournamentState,
onChange: (changed: Fixture[]) => void,
): () => void {
const token = process.env.FOOTBALL_DATA_TOKEN?.trim();
const sofa = process.env.ENABLE_SOFASCORE === 'true';
if (!token && !sofa) {
console.log('[live] seed-only (set FOOTBALL_DATA_TOKEN and/or ENABLE_SOFASCORE=true to enable)');
return () => {};
}
console.log(`[live] enabled — football-data:${token ? 'on' : 'off'} sofascore:${sofa ? 'on' : 'off'}`);
let stopped = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const tick = async (): Promise<void> => {
let matches: LiveMatch[] = [];
let source: Source = 'seed';
if (sofa) {
try {
matches = await fetchSofascore();
if (matches.length) source = 'sofascore';
} catch (e) {
console.warn('[live] sofascore failed:', e instanceof Error ? e.message : e);
}
}
if (!matches.length && token) {
try {
matches = await fetchFootballData(token);
source = 'football-data';
} catch (e) {
console.warn('[live] football-data failed:', e instanceof Error ? e.message : e);
}
}
if (matches.length) {
const changed = state.mergeLive(matches, source);
if (changed.length) {
console.log(`[live] ${changed.length} fixture(s) updated via ${source}`);
onChange(changed);
}
}
};
const schedule = (): void => {
if (stopped) return;
const delay = state.hasLiveActivity(LIVE_WINDOW_MS) ? POLL_LIVE_MS : POLL_IDLE_MS;
timer = setTimeout(async () => {
await tick().catch((e) => console.warn('[live] tick error:', e));
schedule();
}, delay);
};
// Kick off immediately, then settle into the dynamic schedule.
void tick().catch((e) => console.warn('[live] initial tick error:', e)).finally(schedule);
return () => {
stopped = true;
if (timer) clearTimeout(timer);
};
}
+63
View File
@@ -0,0 +1,63 @@
import type { LiveMatch } from './tournament';
import type { MatchStatus } from '../../src/lib/types';
// SofaScore unofficial API — near-real-time, but undocumented and fragile.
// Gated behind ENABLE_SOFASCORE; always wrapped in try/catch by the caller, with
// football-data.org as the fallback. We only read the global live-events feed and
// filter to the World Cup, so no scraping of internal IDs is needed.
const LIVE_URL = 'https://api.sofascore.com/api/v1/sport/football/events/live';
interface SsTeam { name: string }
interface SsScore { current?: number }
interface SsEvent {
tournament?: { name?: string; uniqueTournament?: { name?: string }; category?: { name?: string } };
homeTeam: SsTeam;
awayTeam: SsTeam;
homeScore: SsScore;
awayScore: SsScore;
status: { type: string };
time?: { currentPeriodStartTimestamp?: number };
roundInfo?: { name?: string };
}
function mapStatus(type: string): MatchStatus | null {
if (type === 'inprogress') return 'live';
if (type === 'finished') return 'finished';
if (type === 'notstarted') return 'scheduled';
return null;
}
function isWorldCup(e: SsEvent): boolean {
const names = [
e.tournament?.uniqueTournament?.name,
e.tournament?.name,
].filter(Boolean).map((s) => s!.toLowerCase());
return names.some((n) => n.includes('world cup')) && !names.some((n) => n.includes('qualif'));
}
export async function fetchSofascore(): Promise<LiveMatch[]> {
const res = await fetch(LIVE_URL, {
headers: { accept: 'application/json', 'user-agent': 'Mozilla/5.0 cup26' },
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) throw new Error(`sofascore ${res.status}`);
const body = (await res.json()) as { events?: SsEvent[] };
const out: LiveMatch[] = [];
for (const e of body.events ?? []) {
if (!isWorldCup(e)) continue;
const status = mapStatus(e.status.type);
if (!status) continue;
out.push({
home: e.homeTeam.name,
away: e.awayTeam.name,
group: null, // inferred by name-matching against the seed
stage: 'group', // hint only; the matcher resolves by team pair
kickoff: null,
homeScore: e.homeScore.current ?? null,
awayScore: e.awayScore.current ?? null,
status,
minute: null,
});
}
return out;
}
+164
View File
@@ -0,0 +1,164 @@
import { readFileSync, existsSync } from 'node:fs';
import { canonicalTeam } from '../../src/lib/teams';
import { computeGroupTables } from '../../src/lib/standings';
import type { Fixture, FixturesFile, MatchStatus, Snapshot, Stage } from '../../src/lib/types';
/** A normalized live match from any source (football-data, sofascore, dev). */
export interface LiveMatch {
home: string;
away: string;
group: string | null;
stage: Stage;
/** ISO kickoff if the source provides it (helps match knockout fixtures). */
kickoff: string | null;
homeScore: number | null;
awayScore: number | null;
status: MatchStatus;
minute: number | null;
}
export type Source = Snapshot['source'];
/** Candidate locations for the generated fixtures.json (prod dist, then dev public). */
function loadFixturesFile(staticDir: string): FixturesFile {
const candidates = [
`${staticDir}/data/fixtures.json`,
`${process.cwd()}/public/data/fixtures.json`,
`${process.cwd()}/dist/data/fixtures.json`,
];
for (const p of candidates) {
if (existsSync(p)) return JSON.parse(readFileSync(p, 'utf8')) as FixturesFile;
}
throw new Error(`fixtures.json not found. Run "npm run data:fixtures". Looked in: ${candidates.join(', ')}`);
}
const utcDate = (iso: string): string => iso.slice(0, 10);
/** In-memory tournament: the static seed plus any live overlay. */
export class TournamentState {
readonly season: string;
private fixtures: Fixture[];
private byNum: Map<number, Fixture>;
updatedAt: string | null = null;
source: Source = 'seed';
constructor(staticDir: string) {
const file = loadFixturesFile(staticDir);
this.season = file.season;
this.fixtures = file.fixtures;
this.byNum = new Map(this.fixtures.map((f) => [f.num, f]));
}
snapshot(): Snapshot {
return {
season: this.season,
updatedAt: this.updatedAt,
source: this.source,
fixtures: this.fixtures,
tables: computeGroupTables(this.fixtures),
};
}
getFixture(num: number): Fixture | undefined {
return this.byNum.get(num);
}
/** True if any match is live or kicks off within `windowMs` of now — used to
* poll the APIs often during match windows and rarely when nothing is on. */
hasLiveActivity(windowMs: number): boolean {
const now = Date.now();
return this.fixtures.some(
(f) => f.status === 'live' || Math.abs(new Date(f.kickoff).getTime() - now) <= windowMs,
);
}
/** Directly set a fixture's score/status (used by the dev injection endpoint). */
applyScore(
num: number,
homeScore: number | null,
awayScore: number | null,
status: MatchStatus,
minute: number | null,
source: Source,
): Fixture | null {
const f = this.byNum.get(num);
if (!f) return null;
f.homeScore = homeScore;
f.awayScore = awayScore;
f.status = status;
f.minute = minute;
this.updatedAt = new Date().toISOString();
this.source = source;
return f;
}
/**
* Merge a batch of live matches onto the seed. Group matches match by group +
* unordered team pair; knockout matches by kickoff date + team overlap. Returns
* the fixtures that actually changed.
*/
mergeLive(matches: LiveMatch[], source: Source): Fixture[] {
const changed: Fixture[] = [];
for (const lm of matches) {
const f = this.findFixture(lm);
if (!f) continue;
if (this.applyLive(f, lm)) changed.push(f);
}
if (changed.length) {
this.updatedAt = new Date().toISOString();
this.source = source;
}
return changed;
}
private findFixture(lm: LiveMatch): Fixture | undefined {
const home = canonicalTeam(lm.home);
const away = canonicalTeam(lm.away);
// 1) Strongest signal: an unordered match of two known team names. Works for
// every group game and any already-resolved knockout, regardless of the
// source's stage hint (SofaScore's is unreliable).
const byPair = this.fixtures.find(
(f) =>
f.home.team && f.away.team &&
((f.home.team === home && f.away.team === away) ||
(f.home.team === away && f.away.team === home)),
);
if (byPair) return byPair;
// 2) Knockout slot not yet filled in the seed: match by stage + kickoff date,
// preferring a fixture whose slots are still unresolved.
if (lm.stage !== 'group') {
const sameStage = this.fixtures.filter(
(f) => f.stage === lm.stage && (lm.kickoff ? utcDate(f.kickoff) === utcDate(lm.kickoff) : true),
);
if (sameStage.length === 1) return sameStage[0];
return sameStage.find((f) => !f.home.team || !f.away.team) ?? sameStage[0];
}
return undefined;
}
/** Overlay a live match onto a fixture, orienting scores to the seed's home/away. */
private applyLive(f: Fixture, lm: LiveMatch): boolean {
const home = canonicalTeam(lm.home);
const away = canonicalTeam(lm.away);
// Fill concrete teams for resolved knockout slots.
if (!f.home.team && !f.away.team) {
f.home = { team: home, placeholder: f.home.placeholder, label: home };
f.away = { team: away, placeholder: f.away.placeholder, label: away };
}
const swapped = f.home.team === away && f.away.team === home;
const hs = swapped ? lm.awayScore : lm.homeScore;
const as = swapped ? lm.homeScore : lm.awayScore;
const before = `${f.status}:${f.homeScore}:${f.awayScore}:${f.minute}`;
f.homeScore = hs;
f.awayScore = as;
f.status = lm.status;
f.minute = lm.minute;
const after = `${f.status}:${f.homeScore}:${f.awayScore}:${f.minute}`;
return before !== after;
}
}