adbfb5d6d1
- 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>
147 lines
5.6 KiB
TypeScript
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 };
|
|
}
|