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
+77
View File
@@ -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<string, StandingRow[]>;
}
// ---- 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 };