diff --git a/server/src/ingest/espnNormalize.ts b/server/src/ingest/espnNormalize.ts index 9f71561..a2dd156 100644 --- a/server/src/ingest/espnNormalize.ts +++ b/server/src/ingest/espnNormalize.ts @@ -75,7 +75,7 @@ export interface EspnTimelineEvent { shootout?: boolean; } /** Current normalizer schema — bump to make the boot backfill re-store. */ -export const SUMMARY_V = 3; +export const SUMMARY_V = 4; export interface EspnCommentaryLine { minute: number | null; text: string } @@ -95,11 +95,15 @@ export interface EspnSummary { events: EspnTimelineEvent[]; /** Full play-by-play feed (attempts, fouls, corners, VAR…), kickoff first. */ commentary?: EspnCommentaryLine[]; + /** Post-match report article (the narrative — why cards were shown, how + * goals came about). Plain text, appears around full time. */ + report?: { headline: string; story: string } | null; } export interface RawSummary { gameInfo?: { venue?: { fullName?: string } }; commentary?: { sequence?: number | string; time?: { displayValue?: string }; text?: string }[]; + article?: { headline?: string; story?: 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?: { @@ -182,5 +186,12 @@ export function normalizeSummary(raw: RawSummary): EspnSummary { }) .filter((c) => c.text); - return { v: SUMMARY_V, fetchedAt: Date.now(), venue: raw.gameInfo?.venue?.fullName ?? null, teams, h2h, form, lineups, stats, events, commentary }; + const story = (raw.article?.story ?? '') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 8000); + const report = story ? { headline: raw.article?.headline ?? '', story } : null; + + return { v: SUMMARY_V, fetchedAt: Date.now(), venue: raw.gameInfo?.venue?.fullName ?? null, teams, h2h, form, lineups, stats, events, commentary, report }; } diff --git a/server/src/preview.ts b/server/src/preview.ts index 983c5f2..448c055 100644 --- a/server/src/preview.ts +++ b/server/src/preview.ts @@ -131,6 +131,7 @@ export function buildPreview(num: number, state: TournamentState, model: ModelEn ? { home: summary!.stats[eh.id] ?? {}, away: summary!.stats[ea.id] ?? {} } : null, commentary: summary?.commentary ?? [], + report: summary?.report ?? null, events: (summary?.events ?? []).map((e) => ({ minute: e.minute, type: e.type, diff --git a/src/components/MatchTimeline.tsx b/src/components/MatchTimeline.tsx index b6b1d6d..5dd72f2 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, goalShot, momentumVerdict, parseEvents, halfTimeScore, varLines, type CommentaryLine, type EventKind, type ParsedEvent } from '@/lib/matchEvents'; +import { eventContext, goalShot, momentumVerdict, parseEvents, halfTimeScore, reportSentences, varLines, type CommentaryLine, type EventKind, type ParsedEvent } from '@/lib/matchEvents'; import type { MatchLiveEvent, RichShot } from '@/lib/types'; /** Crisp event markers instead of emoji: ball icon for goals, card-shaped @@ -61,22 +61,28 @@ interface RichContext { shots: RichShot[]; momentum: { minute: number; value: number }[]; commentary: CommentaryLine[]; + report: string; } /** The expanded story, from data we trust: the chance behind a goal (xG, * situation, distance), whether it came against the run of play, any VAR * line for the decision — then the feed's own description of the action. */ -function ContextPanel({ e, ctx, shots, momentum, commentary }: { +function ContextPanel({ e, ctx, shots, momentum, commentary, report }: { e: ParsedEvent; ctx: string; shots: RichShot[]; momentum: { minute: number; value: number }[]; commentary: CommentaryLine[]; + report: string; }) { const t = useT(); const shot = goalShot(e, shots); const verdict = momentumVerdict(e, momentum); const vars = varLines(e, commentary); + const story = reportSentences(e, report); + // A booking's ctx falls back to the raw "is shown the red card" line when + // the feed gives no reason — pure noise once the report explains it. + const showCtx = !(story.length > 0 && (e.kind === 'yellow' || e.kind === 'red') && ctx === e.raw.trim()); const situations = t.match.situations as Record; const chance = shot && shot.xg != null ? fmt(shot.distM != null ? t.match.insight.chance : t.match.insight.chanceNoDist, { @@ -88,10 +94,12 @@ function ContextPanel({ e, ctx, shots, momentum, commentary }: { : null; return (
- {chance &&

{chance}

} + {story.map((s, j) =>

{s}

)} + {chance &&

{chance}

} {verdict &&

{verdict === 'against' ? t.match.insight.againstRun : t.match.insight.withRun}

} {vars.map((v, j) =>

{v}

)} -

{ctx}

+ {showCtx &&

{ctx}

} + {story.length > 0 &&

{t.match.reportAttribution}

}
); } @@ -189,13 +197,15 @@ export interface MatchTimelineProps { shots?: RichShot[]; /** Attack-momentum curve (positive = home pressure). */ momentum?: { minute: number; value: number }[]; + /** Post-match report text — narrative context per event. */ + report?: string; status: 'scheduled' | 'live' | 'finished'; minute?: number | null; homeScore?: number | null; awayScore?: number | null; } -export function MatchTimeline({ events, commentary = [], shots = [], momentum = [], status, minute, homeScore, awayScore }: MatchTimelineProps) { +export function MatchTimeline({ events, commentary = [], shots = [], momentum = [], report = '', 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()); @@ -266,7 +276,7 @@ export function MatchTimeline({ events, commentary = [], shots = [], momentum = ); } const ctx = eventContext(e); - const rich = { shots, momentum, commentary }; + const rich = { shots, momentum, commentary, report }; const content = e.kind === 'goal' ? toggle(i)} /> : toggle(i)} />; @@ -303,8 +313,8 @@ export function MatchTimeline({ events, commentary = [], shots = [], momentum = {e.side === null ? : 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 ff62f68..bdd42d3 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/i18n/de.ts b/src/lib/i18n/de.ts index 1e29878..c68738e 100644 --- a/src/lib/i18n/de.ts +++ b/src/lib/i18n/de.ts @@ -141,6 +141,7 @@ export const de: Dict = { IndividualPlay: "Einzelaktion", ThrowInSetPiece: "Nach einem Einwurf", }, + reportAttribution: "Aus dem ESPN-Spielbericht.", insight: { chance: "{situation} · {dist} m vor dem Tor · {xg} xG — eine {pct}-%-Chance", chanceNoDist: "{situation} · {xg} xG — eine {pct}-%-Chance", diff --git a/src/lib/i18n/en.ts b/src/lib/i18n/en.ts index 10960db..5524dd8 100644 --- a/src/lib/i18n/en.ts +++ b/src/lib/i18n/en.ts @@ -140,6 +140,7 @@ export const en = { IndividualPlay: "Individual effort", ThrowInSetPiece: "From a throw-in", }, + reportAttribution: "From the ESPN match report.", insight: { chance: "{situation} · {dist} m out · {xg} xG — a {pct}% chance", chanceNoDist: "{situation} · {xg} xG — a {pct}% chance", diff --git a/src/lib/matchEvents.test.ts b/src/lib/matchEvents.test.ts index 8c976ab..ddba025 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, goalShot, halfTimeScore, momentumVerdict, parseEvents, varLines } from './matchEvents'; +import { eventContext, goalEvents, goalShot, halfTimeScore, momentumVerdict, parseEvents, reportSentences, varLines } from './matchEvents'; import type { MatchLiveEvent } from './types'; // Real shapes from the ESPN feed (WC 2026 match 2, Korea Republic – Czechia). @@ -169,6 +169,38 @@ describe('goal insight', () => { }); }); + +describe('reportSentences', () => { + // Real sentences from the ESPN/Reuters report for Mexico 2-0 South Africa. + const story = + "They were a goal up inside 10 minutes when Sphephelo Sithole was caught in possession on the edge of his box by Erik Lira and the ball fell to Quinones, who drove a low shot through the legs of Williams to give the co-hosts a dream start in the ninth minute. " + + "Sithole's miserable afternoon was complete when he was red-carded for bringing down Gutierrez just outside the box early in the second period, but it took until midway through the half for Mexico to kill off the contest."; + + const red = parseEvents([{ minute: 49, type: 'Red Card', team: 'away', text: 'Yaya Sithole (South Africa) is shown the red card.' }])[0]!; + const goal = parseEvents([{ minute: 9, type: 'Goal', team: 'home', text: 'Goal! Mexico 1, South Africa 0. Julián Quiñones (Mexico) left footed shot from outside the box.' }])[0]!; + + it('picks the card sentence for the card, not the goal narrative', () => { + const out = reportSentences(red, story); + expect(out).toHaveLength(1); + expect(out[0]).toContain('red-carded for bringing down Gutierrez'); + }); + + it('finds the goal narrative despite missing diacritics in the report', () => { + const out = reportSentences(goal, story); + expect(out[0]).toContain('drove a low shot through the legs of Williams'); + }); + + it('returns nothing for players the report never mentions', () => { + const other = parseEvents([{ minute: 70, type: 'Yellow Card', team: 'home', text: 'John Nobody (Mexico) is shown the yellow card.' }])[0]!; + expect(reportSentences(other, story)).toEqual([]); + }); + + it('returns nothing for subs and neutral events', () => { + const sub = parseEvents([{ minute: 60, type: 'Substitution', team: 'home', text: 'Substitution, Mexico. A replaces Sithole.' }])[0]!; + expect(reportSentences(sub, story)).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 7c8b385..ed11873 100644 --- a/src/lib/matchEvents.ts +++ b/src/lib/matchEvents.ts @@ -209,6 +209,33 @@ export function momentumVerdict( return (e.side === 'home') === avg > 0 ? 'with' : 'against'; } + +const KIND_WORDS: Record = { + goal: /\b(goal|scor|header|finish|net|drove|struck|slott|tapp|convert|fired|curl|poked|volley|made it|doubled|equali[sz]|lead|winner)\w*/i, + red: /\b(red[- ]card|sent[- ]off|dismiss|marching orders|red card)\w*/i, + yellow: /\b(book|yellow|caution)\w*/i, +}; + +/** + * The match report's sentences about THIS event — the narrative "why" the + * feed lacks ("red-carded for bringing down Gutierrez just outside the + * box"). Sentences must mention the player; those matching the event kind's + * vocabulary win, so a player's card sentence beats his goal sentence. + */ +export function reportSentences(e: ParsedEvent, story: string, max = 2): string[] { + if (!story || e.kind === 'sub' || e.kind === 'other') return []; + const last = norm(e.title).split(' ').pop() ?? ''; + if (last.length < 3) return []; + const kindRe = KIND_WORDS[e.kind === 'goal' ? 'goal' : e.kind === 'red' ? 'red' : 'yellow']; + const scored = story + .split(/(?<=[.!?])\s+/) + .filter((s) => s.length > 20 && s.length < 400 && new RegExp(`\\b${last}\\b`, 'i').test(norm(s))) + .map((s) => ({ s: s.trim(), hit: kindRe ? kindRe.test(s) : false })); + if (!scored.length) return []; + const hits = scored.filter((x) => x.hit).map((x) => x.s); + return (hits.length ? hits : [scored[0]!.s]).slice(0, max); +} + /** VAR / video-review feed lines belonging to this event (±1 minute) — * the one slice of the play-by-play that genuinely explains a decision. */ export function varLines(e: ParsedEvent, commentary: CommentaryLine[]): string[] { diff --git a/src/lib/types.ts b/src/lib/types.ts index 4211a23..685da19 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -161,6 +161,8 @@ export interface MatchPreview { events: MatchLiveEvent[]; /** Full play-by-play feed, kickoff first — buildup context for events. */ commentary: { minute: number | null; text: string }[]; + /** Post-match report — the narrative source for event context. */ + report: { headline: string; story: string } | null; /** Honest note on which data layers are populated yet. */ dataNote: string; updatedAt: number | null;