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,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