Files
cup26/server/src/ingest/espnNormalize.ts
T
NilsBriggen adbfb5d6d1 v2 Phase 4: live match center + in-play win probability
- src/lib/model/inplay.ts: in-play win probability — remaining goals modelled as
  time-scaled Poisson processes convolved with the current score; recomputed each
  tick. 4 vitest cases (locks at FT, lead grows decisive over time, sums to 1).
- ESPN summary normalizer extended with the live event timeline (keyEvents);
  preview now carries liveStats + events. Scheduler refreshes LIVE matches'
  summaries every live tick (~20s) for fresh stats/timeline.
- /match/:num is now a live match center: when live it shows in-play win
  probability (driven by the WS score/clock), match stats bars and an event
  timeline, polling while live; preview content for scheduled games, recap when
  finished. Verified with a simulated live state (94% live vs 81% pre-match).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 16:19:22 +02:00

147 lines
5.6 KiB
TypeScript

import { canonicalTeam } from '../../../src/lib/teams';
import type { LiveMatch } from '../tournament';
import type { MatchStatus } from '../../../src/lib/types';
// Pure ESPN normalizers — no network or DB, so they're unit-testable in
// isolation (and don't drag node:sqlite into the Vite test graph).
interface EspnCompetitor {
homeAway: 'home' | 'away';
team: { id: string; displayName: string; name?: string; abbreviation?: string };
score?: string;
}
interface EspnStatus { type: { state: 'pre' | 'in' | 'post'; completed?: boolean; name?: string }; displayClock?: string; period?: number }
export interface EspnEvent {
id: string;
date: string;
name: string;
competitions: { competitors: EspnCompetitor[]; venue?: { fullName?: string } }[];
status: EspnStatus;
}
export interface EspnMatch extends LiveMatch {
eventId: string;
}
function mapStatus(s: EspnStatus): MatchStatus {
if (s.type.state === 'in') return 'live';
if (s.type.state === 'post') return 'finished';
return 'scheduled';
}
function parseMinute(s: EspnStatus): number | null {
if (s.type.state !== 'in') return null;
const m = s.displayClock?.match(/(\d+)/);
return m ? Number(m[1]) : (s.period ? (s.period - 1) * 45 : null);
}
/** Scoreboard body → matches with ESPN event ids. */
export function normalizeScoreboard(body: { events?: EspnEvent[] }): EspnMatch[] {
const out: EspnMatch[] = [];
for (const e of body.events ?? []) {
const comp = e.competitions[0];
if (!comp) continue;
const home = comp.competitors.find((c) => c.homeAway === 'home');
const away = comp.competitors.find((c) => c.homeAway === 'away');
if (!home || !away) continue;
out.push({
eventId: e.id,
home: canonicalTeam(home.team.displayName),
away: canonicalTeam(away.team.displayName),
group: null,
stage: 'group',
kickoff: e.date,
homeScore: home.score != null && home.score !== '' ? Number(home.score) : null,
awayScore: away.score != null && away.score !== '' ? Number(away.score) : null,
status: mapStatus(e.status),
minute: parseMinute(e.status),
});
}
return out;
}
// ---- summary (rich enrichment) ----
export interface EspnH2HGame { date: string; homeTeamId: string; awayTeamId: string; homeScore: number; awayScore: number }
export interface EspnTeamRef { id: string; name: string; homeAway: 'home' | 'away' }
export interface EspnLineupPlayer { name: string; position: string | null; starter: boolean; number: number | null }
export interface EspnTimelineEvent { minute: number | null; type: string; text: string; teamId: string | null }
export interface EspnSummary {
fetchedAt: number;
venue: string | null;
teams: EspnTeamRef[];
h2h: EspnH2HGame[];
/** Recent results per team id, most recent first: 'W' | 'D' | 'L'. */
form: Record<string, string[]>;
lineups: Record<string, EspnLineupPlayer[]>;
/** Team match stats (possession, shots, …) once the game is live/finished. */
stats: Record<string, Record<string, string>>;
/** Live/finished event timeline (goals, cards, subs). */
events: EspnTimelineEvent[];
}
export interface RawSummary {
gameInfo?: { venue?: { fullName?: string } };
header?: { competitions?: { competitors?: { id: string; team?: { displayName?: string }; homeAway: 'home' | 'away' }[] }[] };
headToHeadGames?: { events?: { gameDate: string; homeTeamId: string; awayTeamId: string; homeTeamScore: string; awayTeamScore: string }[] }[];
boxscore?: {
form?: { team?: { id: string }; events?: { gameResult?: string }[] }[];
teams?: { team?: { id: string }; statistics?: { name: string; displayValue: string }[] }[];
};
rosters?: { team?: { id: string }; roster?: { athlete?: { displayName?: string }; position?: { abbreviation?: string }; starter?: boolean; jersey?: string }[] }[];
keyEvents?: { clock?: { displayValue?: string }; type?: { text?: string }; text?: string; team?: { id?: string } }[];
}
export function normalizeSummary(raw: RawSummary): EspnSummary {
const teams: EspnTeamRef[] = (raw.header?.competitions?.[0]?.competitors ?? []).map((c) => ({
id: c.id,
name: canonicalTeam(c.team?.displayName ?? ''),
homeAway: c.homeAway,
}));
const h2h: EspnH2HGame[] = (raw.headToHeadGames?.[0]?.events ?? []).map((g) => ({
date: g.gameDate,
homeTeamId: g.homeTeamId,
awayTeamId: g.awayTeamId,
homeScore: Number(g.homeTeamScore),
awayScore: Number(g.awayTeamScore),
}));
const form: Record<string, string[]> = {};
for (const f of raw.boxscore?.form ?? []) {
const id = f.team?.id;
if (!id) continue;
form[id] = (f.events ?? []).map((e) => (e.gameResult ?? '').toUpperCase()).filter(Boolean);
}
const lineups: Record<string, EspnLineupPlayer[]> = {};
for (const r of raw.rosters ?? []) {
const id = r.team?.id;
if (!id) continue;
lineups[id] = (r.roster ?? []).map((p) => ({
name: p.athlete?.displayName ?? '',
position: p.position?.abbreviation ?? null,
starter: !!p.starter,
number: p.jersey ? Number(p.jersey) : null,
}));
}
const stats: Record<string, Record<string, string>> = {};
for (const t of raw.boxscore?.teams ?? []) {
const id = t.team?.id;
if (!id) continue;
stats[id] = Object.fromEntries((t.statistics ?? []).map((s) => [s.name, s.displayValue]));
}
const events: EspnTimelineEvent[] = (raw.keyEvents ?? []).map((e) => {
const m = e.clock?.displayValue?.match(/(\d+)/);
return {
minute: m ? Number(m[1]) : null,
type: e.type?.text ?? '',
text: e.text ?? '',
teamId: e.team?.id ?? null,
};
});
return { fetchedAt: Date.now(), venue: raw.gameInfo?.venue?.fullName ?? null, teams, h2h, form, lineups, stats, events };
}