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