From f78f599ed24f62347c73c4835dfeccded083799d Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Fri, 12 Jun 2026 13:30:27 +0200 Subject: [PATCH] Richer event context: buildup lines from the full play-by-play feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit '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 --- server/src/ingest/espnNormalize.ts | 19 +++++++++++- server/src/ingest/scheduler.ts | 3 +- server/src/preview.ts | 1 + src/components/MatchTimeline.tsx | 40 ++++++++++++++++++------- src/features/match/MatchPreviewPage.tsx | 2 +- src/lib/matchEvents.test.ts | 36 +++++++++++++++++++++- src/lib/matchEvents.ts | 29 ++++++++++++++++++ src/lib/types.ts | 2 ++ 8 files changed, 118 insertions(+), 14 deletions(-) diff --git a/server/src/ingest/espnNormalize.ts b/server/src/ingest/espnNormalize.ts index 9557907..9f71561 100644 --- a/server/src/ingest/espnNormalize.ts +++ b/server/src/ingest/espnNormalize.ts @@ -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>; /** 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 }; } diff --git a/server/src/ingest/scheduler.ts b/server/src/ingest/scheduler.ts index 587274e..b3e98d7 100644 --- a/server/src/ingest/scheduler.ts +++ b/server/src/ingest/scheduler.ts @@ -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(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 { diff --git a/server/src/preview.ts b/server/src/preview.ts index b2ee155..983c5f2 100644 --- a/server/src/preview.ts +++ b/server/src/preview.ts @@ -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, diff --git a/src/components/MatchTimeline.tsx b/src/components/MatchTimeline.tsx index 522899f..b841957 100644 --- a/src/components/MatchTimeline.tsx +++ b/src/components/MatchTimeline.tsx @@ -1,7 +1,7 @@ import { useMemo, useState, type KeyboardEvent } from 'react'; import { ArrowRightLeft, ChevronDown, CircleDot, Volleyball } from 'lucide-react'; import { fmt, useT } from '@/lib/i18n'; -import { eventContext, parseEvents, halfTimeScore, type EventKind, type ParsedEvent } from '@/lib/matchEvents'; +import { buildup, eventContext, parseEvents, halfTimeScore, type CommentaryLine, type EventKind, type ParsedEvent } from '@/lib/matchEvents'; import type { MatchLiveEvent } from '@/lib/types'; /** Crisp event markers instead of emoji: ball icon for goals, card-shaped @@ -56,7 +56,24 @@ function expandable(ctx: string | null, onToggle: () => void) { }; } -function GoalCard({ e, mobile, ctx, open, onToggle }: { e: ParsedEvent; mobile?: boolean; ctx: string | null; open: boolean; onToggle: () => void }) { + +/** The expanded story: feed lines leading into the moment, then the event's + * own description. */ +function ContextPanel({ pre, ctx }: { pre: CommentaryLine[]; ctx: string }) { + return ( +
+ {pre.map((c, j) => ( +

+ {c.minute != null && {c.minute}' } + {c.text} +

+ ))} +

{ctx}

+
+ ); +} + +function GoalCard({ e, mobile, ctx, pre, open, onToggle }: { e: ParsedEvent; mobile?: boolean; ctx: string | null; pre: CommentaryLine[]; open: boolean; onToggle: () => void }) { const t = useT(); const away = e.side === 'away'; // Side-colored edge faces the spine on desktop; always left on mobile. @@ -90,12 +107,12 @@ function GoalCard({ e, mobile, ctx, open, onToggle }: { e: ParsedEvent; mobile?:
{e.title}
{detailText &&
{detailText}
} - {ctx && open &&

{ctx}

} + {ctx && open && } ); } -function MinorChip({ e, mobile, ctx, open, onToggle }: { e: ParsedEvent; mobile?: boolean; ctx: string | null; open: boolean; onToggle: () => void }) { +function MinorChip({ e, mobile, ctx, pre, open, onToggle }: { e: ParsedEvent; mobile?: boolean; ctx: string | null; pre: CommentaryLine[]; open: boolean; onToggle: () => void }) { const side = mobile ? e.side === 'away' ? 'border-l-[3px] border-l-info' : 'border-l-[3px] border-l-accent' : ''; @@ -111,7 +128,7 @@ function MinorChip({ e, mobile, ctx, open, onToggle }: { e: ParsedEvent; mobile? {e.subOut && {e.subOut}} {ctx && } - {ctx && open && {ctx}} + {ctx && open && } ); } @@ -143,13 +160,15 @@ function DividerPill({ row }: { row: Extract }) { export interface MatchTimelineProps { events: MatchLiveEvent[]; + /** Full play-by-play feed — buildup lines for expanded events. */ + commentary?: CommentaryLine[]; status: 'scheduled' | 'live' | 'finished'; minute?: number | null; homeScore?: number | null; awayScore?: number | null; } -export function MatchTimeline({ events, status, minute, homeScore, awayScore }: MatchTimelineProps) { +export function MatchTimeline({ events, commentary = [], status, minute, homeScore, awayScore }: MatchTimelineProps) { const t = useT(); // Tap-to-expand context per row (multiple rows can stay open). const [open, setOpen] = useState>(new Set()); @@ -220,9 +239,10 @@ export function MatchTimeline({ events, status, minute, homeScore, awayScore }: ); } const ctx = eventContext(e); + const pre = open.has(i) ? buildup(e, commentary) : []; const content = e.kind === 'goal' - ? toggle(i)} /> - : toggle(i)} />; + ? toggle(i)} /> + : toggle(i)} />; return (
: e.kind === 'goal' - ? toggle(i)} /> - : toggle(i)} />} + ? toggle(i)} /> + : toggle(i)} />}
); diff --git a/src/features/match/MatchPreviewPage.tsx b/src/features/match/MatchPreviewPage.tsx index 48be6d9..7e4bf2c 100644 --- a/src/features/match/MatchPreviewPage.tsx +++ b/src/features/match/MatchPreviewPage.tsx @@ -370,7 +370,7 @@ export function MatchPreviewPage() { {tab === 'timeline' && preview.events.length > 0 && ( - + )} diff --git a/src/lib/matchEvents.test.ts b/src/lib/matchEvents.test.ts index 985cfb8..9994c4e 100644 --- a/src/lib/matchEvents.test.ts +++ b/src/lib/matchEvents.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { eventContext, goalEvents, halfTimeScore, parseEvents } from './matchEvents'; +import { buildup, eventContext, goalEvents, halfTimeScore, parseEvents } from './matchEvents'; import type { MatchLiveEvent } from './types'; // Real shapes from the ESPN feed (WC 2026 match 2, Korea Republic – Czechia). @@ -127,6 +127,40 @@ describe('eventContext', () => { }); }); +describe('buildup', () => { + const goalText = 'Goal! Korea Republic 0, Czechia 1. Ladislav Krejcí (Czechia) header from very close range to the bottom right corner. Assisted by Vladimír Coufal.'; + const commentary = [ + { minute: null, text: 'First Half begins.' }, + { minute: 53, text: 'Foul by Tomás Soucek (Czechia).' }, + { minute: 56, text: 'Attempt saved. Son Heung-Min (Korea Republic) left footed shot is saved.' }, + { minute: 56, text: 'Corner, Korea Republic. Conceded by Matej Kovár.' }, + { minute: 59, text: goalText }, + { minute: 61, text: 'Attempt missed. Lee Jae-Sung (Korea Republic) header misses to the left.' }, + ]; + const [goal] = goalEvents([{ minute: 59, type: 'Goal - Header', team: 'away', text: goalText }]); + + it('returns the lines leading into the event, oldest first', () => { + expect(buildup(goal!, commentary).map((c) => c.minute)).toEqual([56, 56]); + }); + + it('ignores lines older than three minutes before the event', () => { + const lines = buildup(goal!, commentary); + expect(lines.some((c) => c.minute === 53)).toBe(false); + }); + + it('caps the number of lines', () => { + const noisy = [ + ...Array.from({ length: 8 }, (_, i) => ({ minute: 58, text: `Pass ${i}.` })), + { minute: 59, text: goalText }, + ]; + expect(buildup(goal!, noisy, 3)).toHaveLength(3); + }); + + it('returns nothing when the event opens the feed', () => { + expect(buildup(goal!, [{ minute: 59, text: goalText }])).toEqual([]); + }); +}); + describe('halfTimeScore', () => { it('is the last goal score at minute ≤ 45', () => { expect(halfTimeScore(parseEvents(realEvents))).toEqual([0, 0]); diff --git a/src/lib/matchEvents.ts b/src/lib/matchEvents.ts index 2614f3a..127d8be 100644 --- a/src/lib/matchEvents.ts +++ b/src/lib/matchEvents.ts @@ -145,6 +145,35 @@ export function eventContext(e: ParsedEvent): string | null { return ctx; } +export interface CommentaryLine { minute: number | null; text: string } + +/** + * The feed lines leading into an event — the buildup story shown when a + * timeline item is expanded (the saved attempt before a counter-goal, the + * foul before a card, a VAR check). Anchored on the entry that IS the event + * (same text), else the last entry at the event's minute; returns up to + * `max` preceding lines no older than three minutes. + */ +export function buildup(e: ParsedEvent, commentary: CommentaryLine[], max = 4): CommentaryLine[] { + if (e.minute == null || !commentary.length) return []; + let anchor = commentary.findIndex((c) => c.text === e.raw); + if (anchor === -1) { + for (let i = 0; i < commentary.length; i++) { + const m = commentary[i]!.minute; + if (m != null && m <= e.minute) anchor = i; + if (m != null && m > e.minute) break; + } + } + if (anchor <= 0) return []; + const out: CommentaryLine[] = []; + for (let i = anchor - 1; i >= 0 && out.length < max; i--) { + const c = commentary[i]!; + if (c.minute == null || c.minute < e.minute - 3) break; + out.unshift(c); + } + return out; +} + /** Score at half-time: last goal score at minute ≤ 45 (0–0 when none). */ export function halfTimeScore(parsed: ParsedEvent[]): [number, number] { let ht: [number, number] = [0, 0]; diff --git a/src/lib/types.ts b/src/lib/types.ts index 18cdf08..4211a23 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -159,6 +159,8 @@ export interface MatchPreview { liveStats: { home: Record; away: Record } | null; /** Live/finished event timeline (goals, cards, subs). */ events: MatchLiveEvent[]; + /** Full play-by-play feed, kickoff first — buildup context for events. */ + commentary: { minute: number | null; text: string }[]; /** Honest note on which data layers are populated yet. */ dataNote: string; updatedAt: number | null;