Event context from data, not adjacent feed lines

The buildup timeline mostly surfaced unrelated events (a substitution
before a goal explains nothing). Replaced with the sources that
actually describe the event:

- goals match to their captured shot: situation (corner / counter /
  set piece / penalty), distance to goal, xG as a plain-language
  chance percentage, and xGOT for the finish quality
- the attack-momentum curve judges whether the goal came with or
  against the run of play (prior five minutes, only when clear-cut)
- VAR / video-review feed lines still surface on the matching event —
  the one slice of play-by-play that genuinely explains a decision
- the feed's action description stays as the de-emphasized last line,
  with the redundant scorer prefix stripped; booking reasons unchanged
- name matching shared from the lineup pitch (diacritic/order-proof),
  with a unique same-side-and-minute fallback

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 14:03:54 +02:00
parent 15d955e65c
commit 089d789e49
7 changed files with 185 additions and 90 deletions
+1 -17
View File
@@ -1,6 +1,6 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { fmt, useT } from '@/lib/i18n'; import { fmt, useT } from '@/lib/i18n';
import { parseEvents } from '@/lib/matchEvents'; import { nameMatch, parseEvents } from '@/lib/matchEvents';
import type { LineupTeam, MatchLineup, MatchLiveEvent, PitchPlayer } from '@/lib/types'; import type { LineupTeam, MatchLineup, MatchLiveEvent, PitchPlayer } from '@/lib/types';
/** /**
@@ -17,22 +17,6 @@ import type { LineupTeam, MatchLineup, MatchLiveEvent, PitchPlayer } from '@/lib
const W = 84; const W = 84;
const H = 132; const H = 132;
/** Diacritic/case/punctuation-insensitive name comparison (token sets), so
* ESPN's "Tomás Chory" pairs with FotMob's "Tomas Chorý". */
const norm = (s: string) =>
s.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[-'.]/g, ' ').replace(/\s+/g, ' ').trim();
function nameMatch(a: string, b: string): boolean {
const na = norm(a);
const nb = norm(b);
if (!na || !nb) return false;
if (na === nb) return true;
const ta = na.split(' ');
const tb = nb.split(' ');
const sa = new Set(ta);
const sb = new Set(tb);
return ta.every((x) => sb.has(x)) || tb.every((x) => sa.has(x));
}
interface Slot { interface Slot {
/** Who currently occupies the position. */ /** Who currently occupies the position. */
player: PitchPlayer; player: PitchPlayer;
+50 -23
View File
@@ -1,8 +1,8 @@
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 { buildup, eventContext, parseEvents, halfTimeScore, type CommentaryLine, type EventKind, type ParsedEvent } from '@/lib/matchEvents'; import { eventContext, goalShot, momentumVerdict, parseEvents, halfTimeScore, varLines, type CommentaryLine, type EventKind, type ParsedEvent } from '@/lib/matchEvents';
import type { MatchLiveEvent } from '@/lib/types'; import type { MatchLiveEvent, RichShot } 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
* color chips for bookings, arrows for substitutions. */ * color chips for bookings, arrows for substitutions. */
@@ -57,23 +57,46 @@ function expandable(ctx: string | null, onToggle: () => void) {
} }
/** The expanded story: feed lines leading into the moment, then the event's interface RichContext {
* own description. */ shots: RichShot[];
function ContextPanel({ pre, ctx }: { pre: CommentaryLine[]; ctx: string }) { momentum: { minute: number; value: number }[];
commentary: CommentaryLine[];
}
/** 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 }: {
e: ParsedEvent;
ctx: string;
shots: RichShot[];
momentum: { minute: number; value: number }[];
commentary: CommentaryLine[];
}) {
const t = useT();
const shot = goalShot(e, shots);
const verdict = momentumVerdict(e, momentum);
const vars = varLines(e, commentary);
const situations = t.match.situations as Record<string, string>;
const chance = shot && shot.xg != null
? fmt(shot.distM != null ? t.match.insight.chance : t.match.insight.chanceNoDist, {
situation: situations[shot.situation] ?? shot.situation,
dist: shot.distM ?? 0,
xg: shot.xg.toFixed(2),
pct: Math.round(shot.xg * 100),
}) + (shot.xgot != null ? ` · ${shot.xgot.toFixed(2)} xGOT` : '')
: null;
return ( return (
<div className="mt-1.5 space-y-1 whitespace-normal text-xs leading-relaxed"> <div className="mt-1.5 space-y-1 whitespace-normal text-xs leading-relaxed">
{pre.map((c, j) => ( {chance && <p className="font-medium text-ink-soft">{chance}</p>}
<p key={j} className="text-faint"> {verdict && <p className="text-muted">{verdict === 'against' ? t.match.insight.againstRun : t.match.insight.withRun}</p>}
{c.minute != null && <span className="tnum">{c.minute}&apos; </span>} {vars.map((v, j) => <p key={j} className="text-gold">{v}</p>)}
{c.text} <p className="text-faint">{ctx}</p>
</p>
))}
<p className="text-muted">{ctx}</p>
</div> </div>
); );
} }
function GoalCard({ e, mobile, ctx, pre, open, onToggle }: { e: ParsedEvent; mobile?: boolean; ctx: string | null; pre: CommentaryLine[]; open: boolean; onToggle: () => void }) { function GoalCard({ e, mobile, ctx, rich, open, onToggle }: { e: ParsedEvent; mobile?: boolean; ctx: string | null; rich: RichContext; 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.
@@ -107,12 +130,12 @@ function GoalCard({ e, mobile, ctx, pre, open, onToggle }: { e: ParsedEvent; mob
</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 && <ContextPanel pre={pre} ctx={ctx} />} {ctx && open && <ContextPanel e={e} ctx={ctx} {...rich} />}
</div> </div>
); );
} }
function MinorChip({ e, mobile, ctx, pre, open, onToggle }: { e: ParsedEvent; mobile?: boolean; ctx: string | null; pre: CommentaryLine[]; open: boolean; onToggle: () => void }) { function MinorChip({ e, mobile, ctx, rich, open, onToggle }: { e: ParsedEvent; mobile?: boolean; ctx: string | null; rich: RichContext; 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'
: ''; : '';
@@ -128,7 +151,7 @@ function MinorChip({ e, mobile, ctx, pre, open, onToggle }: { e: ParsedEvent; mo
{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 && <ContextPanel pre={pre} ctx={ctx} />} {ctx && open && <ContextPanel e={e} ctx={ctx} {...rich} />}
</div> </div>
); );
} }
@@ -160,15 +183,19 @@ 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. */ /** Play-by-play feed — only its VAR lines surface, on the matching event. */
commentary?: CommentaryLine[]; commentary?: CommentaryLine[];
/** Captured shots — the data story behind each goal. */
shots?: RichShot[];
/** Attack-momentum curve (positive = home pressure). */
momentum?: { minute: number; value: number }[];
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, commentary = [], status, minute, homeScore, awayScore }: MatchTimelineProps) { export function MatchTimeline({ events, commentary = [], shots = [], momentum = [], 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());
@@ -239,10 +266,10 @@ export function MatchTimeline({ events, commentary = [], status, minute, homeSco
); );
} }
const ctx = eventContext(e); const ctx = eventContext(e);
const pre = open.has(i) ? buildup(e, commentary) : []; const rich = { shots, momentum, commentary };
const content = e.kind === 'goal' const content = e.kind === 'goal'
? <GoalCard e={e} ctx={ctx} pre={pre} open={open.has(i)} onToggle={() => toggle(i)} /> ? <GoalCard e={e} ctx={ctx} rich={rich} open={open.has(i)} onToggle={() => toggle(i)} />
: <MinorChip e={e} ctx={ctx} pre={pre} open={open.has(i)} onToggle={() => toggle(i)} />; : <MinorChip e={e} ctx={ctx} rich={rich} open={open.has(i)} onToggle={() => toggle(i)} />;
return ( return (
<div <div
key={i} key={i}
@@ -276,8 +303,8 @@ export function MatchTimeline({ events, commentary = [], status, minute, homeSco
{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)} pre={open.has(i) ? buildup(e, commentary) : []} open={open.has(i)} onToggle={() => toggle(i)} /> ? <GoalCard e={e} mobile ctx={eventContext(e)} rich={{ shots, momentum, 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)} />} : <MinorChip e={e} mobile ctx={eventContext(e)} rich={{ shots, momentum, commentary }} open={open.has(i)} onToggle={() => toggle(i)} />}
</div> </div>
</div> </div>
); );
+1 -1
View File
@@ -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} commentary={preview.commentary} status={f.status} minute={f.minute} homeScore={f.homeScore} awayScore={f.awayScore} /> <MatchTimeline events={preview.events} commentary={preview.commentary} shots={rich?.shots ?? []} momentum={rich?.momentum ?? []} status={f.status} minute={f.minute} homeScore={f.homeScore} awayScore={f.awayScore} />
</CardBody> </CardBody>
</Card> </Card>
)} )}
+16
View File
@@ -131,6 +131,22 @@ export const de: Dict = {
noGoalsYet: "Noch keine Tore.", noGoalsYet: "Noch keine Tore.",
subbedOff: "Ausgewechselt", subbedOff: "Ausgewechselt",
subbedOn: "Eingewechselt", subbedOn: "Eingewechselt",
situations: {
RegularPlay: "Aus dem Spiel heraus",
FromCorner: "Nach einer Ecke",
SetPiece: "Nach einem Standard",
FreeKick: "Freistoß",
Penalty: "Elfmeter",
FastBreak: "Im Konter",
IndividualPlay: "Einzelaktion",
ThrowInSetPiece: "Nach einem Einwurf",
},
insight: {
chance: "{situation} · {dist} m vor dem Tor · {xg} xG — eine {pct}-%-Chance",
chanceNoDist: "{situation} · {xg} xG — eine {pct}-%-Chance",
withRun: "Fiel mit dem Momentum — das Team war am Drücker.",
againstRun: "Fiel gegen den Spielverlauf.",
},
predictedLineups: "Voraussichtliche Aufstellungen", predictedLineups: "Voraussichtliche Aufstellungen",
predictedLineupsNote: "Voraussichtliche Startelf laut FotMob — die offiziellen Aufstellungen kommen meist rund eine Stunde vor Anstoß.", predictedLineupsNote: "Voraussichtliche Startelf laut FotMob — die offiziellen Aufstellungen kommen meist rund eine Stunde vor Anstoß.",
predictedVsActual: "Modell-Prognose vor Anstoß: {ph}{pa} · Endstand {ah}{aa}", predictedVsActual: "Modell-Prognose vor Anstoß: {ph}{pa} · Endstand {ah}{aa}",
+16
View File
@@ -130,6 +130,22 @@ export const en = {
noGoalsYet: "No goals yet.", noGoalsYet: "No goals yet.",
subbedOff: "Subbed off", subbedOff: "Subbed off",
subbedOn: "Subbed on", subbedOn: "Subbed on",
situations: {
RegularPlay: "Open play",
FromCorner: "From a corner",
SetPiece: "From a set piece",
FreeKick: "Free kick",
Penalty: "Penalty",
FastBreak: "On the counter",
IndividualPlay: "Individual effort",
ThrowInSetPiece: "From a throw-in",
},
insight: {
chance: "{situation} · {dist} m out · {xg} xG — a {pct}% chance",
chanceNoDist: "{situation} · {xg} xG — a {pct}% chance",
withRun: "Scored with the momentum — their side had been pressing.",
againstRun: "Scored against the run of play.",
},
predictedLineups: "Predicted lineups", predictedLineups: "Predicted lineups",
predictedLineupsNote: "FotMob's projected starting XIs — the official lineups usually arrive about an hour before kickoff.", predictedLineupsNote: "FotMob's projected starting XIs — the official lineups usually arrive about an hour before kickoff.",
predictedVsActual: "Model's pre-match prediction: {ph}{pa} · final score {ah}{aa}", predictedVsActual: "Model's pre-match prediction: {ph}{pa} · final score {ah}{aa}",
+34 -26
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { buildup, eventContext, goalEvents, halfTimeScore, parseEvents } from './matchEvents'; import { eventContext, goalEvents, goalShot, halfTimeScore, momentumVerdict, parseEvents, varLines } 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).
@@ -88,12 +88,12 @@ describe('parseEvents', () => {
}); });
describe('eventContext', () => { describe('eventContext', () => {
it('strips the score sentence from goal commentary', () => { it('strips the score sentence and the scorer prefix from goal commentary', () => {
const [g] = goalEvents([{ const [g] = goalEvents([{
minute: 59, type: 'Goal - Header', team: 'away', minute: 59, type: 'Goal - Header', team: 'away',
text: 'Goal! Korea Republic 0, Czechia 1. Ladislav Krejcí (Czechia) header from very close range to the bottom right corner. Assisted by Vladimír Coufal with a cross.', text: 'Goal! Korea Republic 0, Czechia 1. Ladislav Krejcí (Czechia) header from very close range to the bottom right corner. Assisted by Vladimír Coufal with a cross.',
}]); }]);
expect(eventContext(g!)).toBe('Ladislav Krejcí (Czechia) header from very close range to the bottom right corner. Assisted by Vladimír Coufal with a cross.'); expect(eventContext(g!)).toBe('Header from very close range to the bottom right corner. Assisted by Vladimír Coufal with a cross.');
}); });
it('extracts the booking reason', () => { it('extracts the booking reason', () => {
@@ -127,37 +127,45 @@ describe('eventContext', () => {
}); });
}); });
describe('buildup', () => { describe('goal insight', () => {
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 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 }]); const [goal] = goalEvents([{ minute: 59, type: 'Goal - Header', team: 'away', text: goalText }]);
const shots = [
{ isHome: true, player: 'Heung-Min Son', x: 90, y: 30, min: 56, xg: 0.12, xgot: 0.2, type: 'AttemptSaved', situation: 'RegularPlay' },
{ isHome: false, player: 'Ladislav Krejci', x: 100.26, y: 31.1, min: 59, xg: 0.215, xgot: null, type: 'Goal', situation: 'ThrowInSetPiece' },
];
it('returns the lines leading into the event, oldest first', () => { it('matches the goal to its shot despite diacritics and computes distance', () => {
expect(buildup(goal!, commentary).map((c) => c.minute)).toEqual([56, 56]); const s = goalShot(goal!, shots);
expect(s).toMatchObject({ xg: 0.215, situation: 'ThrowInSetPiece' });
expect(s?.distM).toBe(6);
}); });
it('ignores lines older than three minutes before the event', () => { it('falls back to the unique same-side goal shot when names diverge', () => {
const lines = buildup(goal!, commentary); const renamed = [{ ...shots[1]!, player: 'L. Krejci-Smith' }];
expect(lines.some((c) => c.minute === 53)).toBe(false); expect(goalShot(goal!, renamed)?.situation).toBe('ThrowInSetPiece');
}); });
it('caps the number of lines', () => { it('strips the scorer prefix from the goal prose', () => {
const noisy = [ expect(eventContext(goal!)).toBe('Header from very close range to the bottom right corner. Assisted by Vladimír Coufal.');
...Array.from({ length: 8 }, (_, i) => ({ minute: 58, text: `Pass ${i}.` })), });
{ minute: 59, text: goalText },
it('judges momentum: away goal during home pressure = against the run', () => {
const momentum = Array.from({ length: 5 }, (_, i) => ({ minute: 54 + i, value: 60 }));
expect(momentumVerdict(goal!, momentum)).toBe('against');
const awayPressure = momentum.map((p) => ({ ...p, value: -60 }));
expect(momentumVerdict(goal!, awayPressure)).toBe('with');
const mixed = momentum.map((p, i) => ({ ...p, value: i % 2 ? 10 : -10 }));
expect(momentumVerdict(goal!, mixed)).toBeNull();
});
it('surfaces only VAR lines near the event', () => {
const commentary = [
{ minute: 58, text: 'Corner, Korea Republic.' },
{ minute: 59, text: 'VAR Decision: Goal Czechia — onside confirmed.' },
{ minute: 75, text: 'VAR Decision: something far away.' },
]; ];
expect(buildup(goal!, noisy, 3)).toHaveLength(3); expect(varLines(goal!, commentary)).toEqual(['VAR Decision: Goal Czechia — onside confirmed.']);
});
it('returns nothing when the event opens the feed', () => {
expect(buildup(goal!, [{ minute: 59, text: goalText }])).toEqual([]);
}); });
}); });
+67 -23
View File
@@ -1,4 +1,4 @@
import type { MatchLiveEvent } from '@/lib/types'; import type { MatchLiveEvent, RichShot } from '@/lib/types';
/** /**
* Turns ESPN's flat commentary events into structured timeline items. * Turns ESPN's flat commentary events into structured timeline items.
@@ -12,6 +12,24 @@ import type { MatchLiveEvent } from '@/lib/types';
export type EventKind = 'goal' | 'yellow' | 'red' | 'sub' | 'other'; export type EventKind = 'goal' | 'yellow' | 'red' | 'sub' | 'other';
/** Diacritic/case/punctuation-insensitive name comparison (token sets), so
* ESPN's "Tomás Chory" pairs with FotMob's "Tomas Chorý". */
export const norm = (s: string): string =>
s.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[-'.]/g, ' ').replace(/\s+/g, ' ').trim();
export function nameMatch(a: string, b: string): boolean {
const na = norm(a);
const nb = norm(b);
if (!na || !nb) return false;
if (na === nb) return true;
const ta = na.split(' ');
const tb = nb.split(' ');
const sa = new Set(ta);
const sb = new Set(tb);
return ta.every((x) => sb.has(x)) || tb.every((x) => sa.has(x));
}
export type EventDetail = export type EventDetail =
| { type: 'assist'; name: string } | { type: 'assist'; name: string }
| { type: 'penalty' } | { type: 'penalty' }
@@ -133,6 +151,9 @@ export function eventContext(e: ParsedEvent): string | null {
// Drop the leading "Goal! Home 1, Away 0." — the card shows the score chip. // Drop the leading "Goal! Home 1, Away 0." — the card shows the score chip.
const m = /^(?:Goal!|Own Goal by[^.]*\.)[^.]*?\d+\s*[,.][^.]*?\.\s*(.+)$/.exec(raw); const m = /^(?:Goal!|Own Goal by[^.]*\.)[^.]*?\d+\s*[,.][^.]*?\.\s*(.+)$/.exec(raw);
ctx = m?.[1]?.trim() ?? raw; ctx = m?.[1]?.trim() ?? raw;
// The card already names the scorer — drop the "Name (Team) " prefix.
const p = /^[^.()]{2,45}\(([^)]+)\)\s+(.{10,})$/.exec(ctx);
if (p?.[2]) ctx = p[2].charAt(0).toUpperCase() + p[2].slice(1);
} else if (e.kind === 'yellow' || e.kind === 'red') { } else if (e.kind === 'yellow' || e.kind === 'red') {
const m = /shown the (?:yellow|red|second yellow) card\s+(.+?)\.?\s*$/.exec(raw); const m = /shown the (?:yellow|red|second yellow) card\s+(.+?)\.?\s*$/.exec(raw);
const reason = m?.[1]?.trim(); const reason = m?.[1]?.trim();
@@ -147,31 +168,54 @@ export function eventContext(e: ParsedEvent): string | null {
export interface CommentaryLine { minute: number | null; text: string } export interface CommentaryLine { minute: number | null; text: string }
/** Data behind a goal, matched from the captured shot list. */
export interface GoalShotInsight {
xg: number | null;
xgot: number | null;
situation: string;
/** Distance to the goal center in meters (FotMob pitch coords). */
distM: number | null;
}
/** /**
* The feed lines leading into an event — the buildup story shown when a * Find the shot that IS this goal: scorer name match within ±2 minutes,
* timeline item is expanded (the saved attempt before a counter-goal, the * else the only goal-shot for that side within ±1 minute (the sources
* foul before a card, a VAR check). Anchored on the entry that IS the event * romanize names differently, so the name can miss).
* (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[] { export function goalShot(e: ParsedEvent, shots: RichShot[]): GoalShotInsight | null {
if (e.minute == null || !commentary.length) return []; if (e.kind !== 'goal' || e.minute == null) return null;
let anchor = commentary.findIndex((c) => c.text === e.raw); const goals = shots.filter((s) => s.type === 'Goal' && s.min != null);
if (anchor === -1) { let m = goals.find((s) => Math.abs(s.min! - e.minute!) <= 2 && nameMatch(s.player, e.title));
for (let i = 0; i < commentary.length; i++) { if (!m) {
const m = commentary[i]!.minute; const near = goals.filter((s) => Math.abs(s.min! - e.minute!) <= 1 && (e.side === null || s.isHome === (e.side === 'home')));
if (m != null && m <= e.minute) anchor = i; if (near.length === 1) m = near[0];
if (m != null && m > e.minute) break;
}
} }
if (anchor <= 0) return []; if (!m) return null;
const out: CommentaryLine[] = []; const distM = m.x != null && m.y != null ? Math.round(Math.hypot(105 - m.x, 34 - m.y)) : null;
for (let i = anchor - 1; i >= 0 && out.length < max; i--) { return { xg: m.xg, xgot: m.xgot, situation: m.situation, distM };
const c = commentary[i]!; }
if (c.minute == null || c.minute < e.minute - 3) break;
out.unshift(c); /** Was the goal scored with or against the momentum of the prior 5 minutes?
} * Null when the pressure was mixed or there isn't enough curve. */
return out; export function momentumVerdict(
e: ParsedEvent,
momentum: { minute: number; value: number }[],
): 'with' | 'against' | null {
if (e.minute == null || e.side == null) return null;
const window = momentum.filter((p) => p.minute >= e.minute! - 5 && p.minute < e.minute!);
if (window.length < 3) return null;
const avg = window.reduce((s, p) => s + p.value, 0) / window.length;
if (Math.abs(avg) < 25) return null;
return (e.side === 'home') === avg > 0 ? 'with' : 'against';
}
/** 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[] {
if (e.minute == null) return [];
return commentary
.filter((c) => c.minute != null && Math.abs(c.minute - e.minute!) <= 1 && /\b(VAR|video review|video assistant)\b/i.test(c.text))
.map((c) => c.text);
} }
/** Score at half-time: last goal score at minute ≤ 45 (00 when none). */ /** Score at half-time: last goal score at minute ≤ 45 (00 when none). */