9a31e9f4db
- 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>
84 lines
2.2 KiB
TypeScript
84 lines
2.2 KiB
TypeScript
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;
|
|
}
|