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,
+30 -10
View File
@@ -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 (
<div className="mt-1.5 space-y-1 whitespace-normal text-xs leading-relaxed">
{pre.map((c, j) => (
<p key={j} className="text-faint">
{c.minute != null && <span className="tnum">{c.minute}&apos; </span>}
{c.text}
</p>
))}
<p className="text-muted">{ctx}</p>
</div>
);
}
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?:
</div>
<div className={`mt-1 text-base font-bold text-ink ${mobile ? '' : 'truncate'}`}>{e.title}</div>
{detailText && <div className={`mt-0.5 text-xs text-faint ${mobile ? '' : 'truncate'}`}>{detailText}</div>}
{ctx && open && <p className="mt-1.5 whitespace-normal text-xs leading-relaxed text-muted">{ctx}</p>}
{ctx && open && <ContextPanel pre={pre} ctx={ctx} />}
</div>
);
}
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 && <span className="truncate text-[13.5px] text-faint">{e.subOut}</span>}
{ctx && <ChevronDown size={12} className={`shrink-0 text-faint transition-transform ${open ? 'rotate-180' : ''}`} />}
</span>
{ctx && open && <span className="mt-1 whitespace-normal text-xs leading-relaxed text-muted">{ctx}</span>}
{ctx && open && <ContextPanel pre={pre} ctx={ctx} />}
</div>
);
}
@@ -143,13 +160,15 @@ function DividerPill({ row }: { row: Extract<Row, { t: 'divider' }> }) {
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<ReadonlySet<number>>(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'
? <GoalCard e={e} ctx={ctx} open={open.has(i)} onToggle={() => toggle(i)} />
: <MinorChip e={e} ctx={ctx} open={open.has(i)} onToggle={() => toggle(i)} />;
? <GoalCard e={e} ctx={ctx} pre={pre} open={open.has(i)} onToggle={() => toggle(i)} />
: <MinorChip e={e} ctx={ctx} pre={pre} open={open.has(i)} onToggle={() => toggle(i)} />;
return (
<div
key={i}
@@ -256,8 +276,8 @@ export function MatchTimeline({ events, status, minute, homeScore, awayScore }:
{e.side === null
? <NeutralChip e={e} showMinute={false} />
: e.kind === 'goal'
? <GoalCard e={e} mobile ctx={eventContext(e)} open={open.has(i)} onToggle={() => toggle(i)} />
: <MinorChip e={e} mobile ctx={eventContext(e)} open={open.has(i)} onToggle={() => toggle(i)} />}
? <GoalCard e={e} mobile ctx={eventContext(e)} pre={open.has(i) ? buildup(e, commentary) : []} open={open.has(i)} onToggle={() => toggle(i)} />
: <MinorChip e={e} mobile ctx={eventContext(e)} pre={open.has(i) ? buildup(e, commentary) : []} open={open.has(i)} onToggle={() => toggle(i)} />}
</div>
</div>
);
+1 -1
View File
@@ -370,7 +370,7 @@ export function MatchPreviewPage() {
{tab === 'timeline' && preview.events.length > 0 && (
<Card>
<CardBody className="px-2 sm:px-4">
<MatchTimeline events={preview.events} status={f.status} minute={f.minute} homeScore={f.homeScore} awayScore={f.awayScore} />
<MatchTimeline events={preview.events} commentary={preview.commentary} status={f.status} minute={f.minute} homeScore={f.homeScore} awayScore={f.awayScore} />
</CardBody>
</Card>
)}
+35 -1
View File
@@ -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]);
+29
View File
@@ -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 (00 when none). */
export function halfTimeScore(parsed: ParsedEvent[]): [number, number] {
let ht: [number, number] = [0, 0];
+2
View File
@@ -159,6 +159,8 @@ export interface MatchPreview {
liveStats: { home: Record<string, string>; away: Record<string, string> } | 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;