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:
@@ -74,6 +74,11 @@ export interface EspnTimelineEvent {
|
|||||||
scoring?: boolean;
|
scoring?: boolean;
|
||||||
shootout?: 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 {
|
export interface EspnSummary {
|
||||||
/** Normalizer schema version — bump to trigger the boot backfill. */
|
/** Normalizer schema version — bump to trigger the boot backfill. */
|
||||||
v?: number;
|
v?: number;
|
||||||
@@ -88,10 +93,13 @@ export interface EspnSummary {
|
|||||||
stats: Record<string, Record<string, string>>;
|
stats: Record<string, Record<string, string>>;
|
||||||
/** Live/finished event timeline (goals, cards, subs). */
|
/** Live/finished event timeline (goals, cards, subs). */
|
||||||
events: EspnTimelineEvent[];
|
events: EspnTimelineEvent[];
|
||||||
|
/** Full play-by-play feed (attempts, fouls, corners, VAR…), kickoff first. */
|
||||||
|
commentary?: EspnCommentaryLine[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RawSummary {
|
export interface RawSummary {
|
||||||
gameInfo?: { venue?: { fullName?: string } };
|
gameInfo?: { venue?: { fullName?: string } };
|
||||||
|
commentary?: { sequence?: number | string; time?: { displayValue?: string }; text?: string }[];
|
||||||
header?: { competitions?: { competitors?: { id: string; team?: { displayName?: string }; homeAway: 'home' | 'away' }[] }[] };
|
header?: { competitions?: { competitors?: { id: string; team?: { displayName?: string }; homeAway: 'home' | 'away' }[] }[] };
|
||||||
headToHeadGames?: { events?: { gameDate: string; homeTeamId: string; awayTeamId: string; homeTeamScore: string; awayTeamScore: string }[] }[];
|
headToHeadGames?: { events?: { gameDate: string; homeTeamId: string; awayTeamId: string; homeTeamScore: string; awayTeamScore: string }[] }[];
|
||||||
boxscore?: {
|
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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { fetchFifaCalendar, fetchFifaLive, fetchFifaTimeline } from './fifa';
|
|||||||
import { fetchMatchWeather } from './weather';
|
import { fetchMatchWeather } from './weather';
|
||||||
import { fetchScorerProps } from './espnProps';
|
import { fetchScorerProps } from './espnProps';
|
||||||
import type { EspnSummary } from './espnNormalize';
|
import type { EspnSummary } from './espnNormalize';
|
||||||
|
import { SUMMARY_V } from './espnNormalize';
|
||||||
import type { Fixture } from '../../../src/lib/types';
|
import type { Fixture } from '../../../src/lib/types';
|
||||||
|
|
||||||
// The ingestion orchestrator. Replaces the old live loop: ESPN is the primary
|
// The ingestion orchestrator. Replaces the old live loop: ESPN is the primary
|
||||||
@@ -150,7 +151,7 @@ export function startScheduler(
|
|||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
if (f.status === 'scheduled') continue;
|
if (f.status === 'scheduled') continue;
|
||||||
const ext = getMatchExt<EspnSummary>(f.num);
|
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');
|
const eid = getSourceMap(f.num, 'espn');
|
||||||
if (!eid) continue;
|
if (!eid) continue;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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)
|
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] ?? {} }
|
? { home: summary!.stats[eh.id] ?? {}, away: summary!.stats[ea.id] ?? {} }
|
||||||
: null,
|
: null,
|
||||||
|
commentary: summary?.commentary ?? [],
|
||||||
events: (summary?.events ?? []).map((e) => ({
|
events: (summary?.events ?? []).map((e) => ({
|
||||||
minute: e.minute,
|
minute: e.minute,
|
||||||
type: e.type,
|
type: e.type,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useMemo, useState, type KeyboardEvent } from 'react';
|
import { useMemo, useState, type KeyboardEvent } from 'react';
|
||||||
import { ArrowRightLeft, ChevronDown, CircleDot, Volleyball } from 'lucide-react';
|
import { ArrowRightLeft, ChevronDown, CircleDot, Volleyball } from 'lucide-react';
|
||||||
import { fmt, useT } from '@/lib/i18n';
|
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';
|
import type { MatchLiveEvent } from '@/lib/types';
|
||||||
|
|
||||||
/** Crisp event markers instead of emoji: ball icon for goals, card-shaped
|
/** 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}' </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 t = useT();
|
||||||
const away = e.side === 'away';
|
const away = e.side === 'away';
|
||||||
// Side-colored edge faces the spine on desktop; always left on mobile.
|
// 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>
|
||||||
<div className={`mt-1 text-base font-bold text-ink ${mobile ? '' : 'truncate'}`}>{e.title}</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>}
|
{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>
|
</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
|
const side = mobile
|
||||||
? e.side === 'away' ? 'border-l-[3px] border-l-info' : 'border-l-[3px] border-l-accent'
|
? 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>}
|
{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' : ''}`} />}
|
{ctx && <ChevronDown size={12} className={`shrink-0 text-faint transition-transform ${open ? 'rotate-180' : ''}`} />}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -143,13 +160,15 @@ function DividerPill({ row }: { row: Extract<Row, { t: 'divider' }> }) {
|
|||||||
|
|
||||||
export interface MatchTimelineProps {
|
export interface MatchTimelineProps {
|
||||||
events: MatchLiveEvent[];
|
events: MatchLiveEvent[];
|
||||||
|
/** Full play-by-play feed — buildup lines for expanded events. */
|
||||||
|
commentary?: CommentaryLine[];
|
||||||
status: 'scheduled' | 'live' | 'finished';
|
status: 'scheduled' | 'live' | 'finished';
|
||||||
minute?: number | null;
|
minute?: number | null;
|
||||||
homeScore?: number | null;
|
homeScore?: number | null;
|
||||||
awayScore?: 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();
|
const t = useT();
|
||||||
// Tap-to-expand context per row (multiple rows can stay open).
|
// Tap-to-expand context per row (multiple rows can stay open).
|
||||||
const [open, setOpen] = useState<ReadonlySet<number>>(new Set());
|
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 ctx = eventContext(e);
|
||||||
|
const pre = open.has(i) ? buildup(e, commentary) : [];
|
||||||
const content = e.kind === 'goal'
|
const content = e.kind === 'goal'
|
||||||
? <GoalCard 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} open={open.has(i)} onToggle={() => toggle(i)} />;
|
: <MinorChip e={e} ctx={ctx} pre={pre} open={open.has(i)} onToggle={() => toggle(i)} />;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
@@ -256,8 +276,8 @@ export function MatchTimeline({ events, status, minute, homeScore, awayScore }:
|
|||||||
{e.side === null
|
{e.side === null
|
||||||
? <NeutralChip e={e} showMinute={false} />
|
? <NeutralChip e={e} showMinute={false} />
|
||||||
: e.kind === 'goal'
|
: e.kind === 'goal'
|
||||||
? <GoalCard 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)} 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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -370,7 +370,7 @@ export function MatchPreviewPage() {
|
|||||||
{tab === 'timeline' && preview.events.length > 0 && (
|
{tab === 'timeline' && preview.events.length > 0 && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardBody className="px-2 sm:px-4">
|
<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>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
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';
|
import type { MatchLiveEvent } from './types';
|
||||||
|
|
||||||
// Real shapes from the ESPN feed (WC 2026 match 2, Korea Republic – Czechia).
|
// 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', () => {
|
describe('halfTimeScore', () => {
|
||||||
it('is the last goal score at minute ≤ 45', () => {
|
it('is the last goal score at minute ≤ 45', () => {
|
||||||
expect(halfTimeScore(parseEvents(realEvents))).toEqual([0, 0]);
|
expect(halfTimeScore(parseEvents(realEvents))).toEqual([0, 0]);
|
||||||
|
|||||||
@@ -145,6 +145,35 @@ export function eventContext(e: ParsedEvent): string | null {
|
|||||||
return ctx;
|
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). */
|
/** Score at half-time: last goal score at minute ≤ 45 (0–0 when none). */
|
||||||
export function halfTimeScore(parsed: ParsedEvent[]): [number, number] {
|
export function halfTimeScore(parsed: ParsedEvent[]): [number, number] {
|
||||||
let ht: [number, number] = [0, 0];
|
let ht: [number, number] = [0, 0];
|
||||||
|
|||||||
@@ -159,6 +159,8 @@ export interface MatchPreview {
|
|||||||
liveStats: { home: Record<string, string>; away: Record<string, string> } | null;
|
liveStats: { home: Record<string, string>; away: Record<string, string> } | null;
|
||||||
/** Live/finished event timeline (goals, cards, subs). */
|
/** Live/finished event timeline (goals, cards, subs). */
|
||||||
events: MatchLiveEvent[];
|
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. */
|
/** Honest note on which data layers are populated yet. */
|
||||||
dataNote: string;
|
dataNote: string;
|
||||||
updatedAt: number | null;
|
updatedAt: number | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user