Richer event context: buildup lines from the full play-by-play feed

'X is shown a red card' alone isn't insight — the story is in the
surrounding plays. The ESPN summary carries a ~110-line play-by-play
feed (attempts, fouls, corners, VAR checks) we cached but never used:

- normalizer keeps a trimmed commentary list (schema v3; the boot
  backfill re-stores older matches automatically)
- previews serve it; expanding a timeline event now shows up to four
  feed lines from the three minutes leading in, then the event's own
  description — e.g. Korea's saved attempt and corner right before
  Czechia's counter-goal header, or the foul behind a booking

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 13:30:27 +02:00
parent 0e2e1dc44f
commit f78f599ed2
8 changed files with 118 additions and 14 deletions
+18 -1
View File
@@ -74,6 +74,11 @@ export interface EspnTimelineEvent {
scoring?: boolean;
shootout?: boolean;
}
/** Current normalizer schema — bump to make the boot backfill re-store. */
export const SUMMARY_V = 3;
export interface EspnCommentaryLine { minute: number | null; text: string }
export interface EspnSummary {
/** Normalizer schema version — bump to trigger the boot backfill. */
v?: number;
@@ -88,10 +93,13 @@ export interface EspnSummary {
stats: Record<string, Record<string, string>>;
/** Live/finished event timeline (goals, cards, subs). */
events: EspnTimelineEvent[];
/** Full play-by-play feed (attempts, fouls, corners, VAR…), kickoff first. */
commentary?: EspnCommentaryLine[];
}
export interface RawSummary {
gameInfo?: { venue?: { fullName?: string } };
commentary?: { sequence?: number | string; time?: { displayValue?: string }; text?: 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?: {
@@ -165,5 +173,14 @@ export function normalizeSummary(raw: RawSummary): EspnSummary {
};
});
return { v: 2, fetchedAt: Date.now(), venue: raw.gameInfo?.venue?.fullName ?? null, teams, h2h, form, lineups, stats, events };
const commentary: EspnCommentaryLine[] = (raw.commentary ?? [])
.slice()
.sort((a, b) => Number(a.sequence ?? 0) - Number(b.sequence ?? 0))
.map((c) => {
const m = /(\d+)/.exec(c.time?.displayValue ?? '');
return { minute: m ? Number(m[1]) : null, text: c.text ?? '' };
})
.filter((c) => c.text);
return { v: SUMMARY_V, fetchedAt: Date.now(), venue: raw.gameInfo?.venue?.fullName ?? null, teams, h2h, form, lineups, stats, events, commentary };
}
+2 -1
View File
@@ -10,6 +10,7 @@ import { fetchFifaCalendar, fetchFifaLive, fetchFifaTimeline } from './fifa';
import { fetchMatchWeather } from './weather';
import { fetchScorerProps } from './espnProps';
import type { EspnSummary } from './espnNormalize';
import { SUMMARY_V } from './espnNormalize';
import type { Fixture } from '../../../src/lib/types';
// The ingestion orchestrator. Replaces the old live loop: ESPN is the primary
@@ -150,7 +151,7 @@ export function startScheduler(
if (stopped) return;
if (f.status === 'scheduled') continue;
const ext = getMatchExt<EspnSummary>(f.num);
if (ext && ext.data.v === 2) continue;
if (ext && ext.data.v === SUMMARY_V) continue;
const eid = getSourceMap(f.num, 'espn');
if (!eid) continue;
try {
+1
View File
@@ -130,6 +130,7 @@ export function buildPreview(num: number, state: TournamentState, model: ModelEn
eh && ea && (Object.keys(summary!.stats[eh.id] ?? {}).length || Object.keys(summary!.stats[ea.id] ?? {}).length)
? { home: summary!.stats[eh.id] ?? {}, away: summary!.stats[ea.id] ?? {} }
: null,
commentary: summary?.commentary ?? [],
events: (summary?.events ?? []).map((e) => ({
minute: e.minute,
type: e.type,