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,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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user