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
+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;
}
}