a3f529746f
The live staleness bug: liveTick refetched ESPN summaries every 20s but the cache TTL was 5 minutes, so in-play stats/timeline barely moved. Summaries now take a live flag (15s TTL in play), the scoreboard TTL drops 20s->5s, and polling gets three tiers (8s in-play / 20s near kickoff / 10m idle) via a pure, tested cadence module. The football-data fallback is throttled to one attempt per 15s so the 8s cadence can never breach its 10 req/min cap. Enrichment runs every 5m (was 20m); client refetch intervals tighten to 10s (live match page) and 30s (scoreboard). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
185 lines
6.2 KiB
TypeScript
185 lines
6.2 KiB
TypeScript
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);
|
|
}
|
|
|
|
/** The live fixtures array (for the model engine). */
|
|
allFixtures(): Fixture[] {
|
|
return this.fixtures;
|
|
}
|
|
|
|
/** Which fixture number does a (home, away) pairing map to? Used by the
|
|
* ingestion layer to key provider event ids against our fixtures. */
|
|
matchFixtureNum(home: string, away: string, stage: Fixture['stage'] = 'group', kickoff: string | null = null): number | null {
|
|
const f = this.findFixture({
|
|
home, away, group: null, stage, kickoff,
|
|
homeScore: null, awayScore: null, status: 'scheduled', minute: null,
|
|
});
|
|
return f?.num ?? null;
|
|
}
|
|
|
|
/** 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,
|
|
);
|
|
}
|
|
|
|
/** True only while a match is actually in play. */
|
|
hasLive(): boolean {
|
|
return this.fixtures.some((f) => f.status === 'live');
|
|
}
|
|
|
|
/** 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;
|
|
}
|
|
}
|