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
+16
View File
@@ -131,6 +131,22 @@ export const de: Dict = {
noGoalsYet: "Noch keine Tore.",
subbedOff: "Ausgewechselt",
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",
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}",
+16
View File
@@ -130,6 +130,22 @@ export const en = {
noGoalsYet: "No goals yet.",
subbedOff: "Subbed off",
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",
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}",
+34 -26
View File
@@ -1,5 +1,5 @@
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';
// Real shapes from the ESPN feed (WC 2026 match 2, Korea Republic Czechia).
@@ -88,12 +88,12 @@ describe('parseEvents', () => {
});
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([{
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.',
}]);
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', () => {
@@ -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 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 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', () => {
expect(buildup(goal!, commentary).map((c) => c.minute)).toEqual([56, 56]);
it('matches the goal to its shot despite diacritics and computes distance', () => {
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', () => {
const lines = buildup(goal!, commentary);
expect(lines.some((c) => c.minute === 53)).toBe(false);
it('falls back to the unique same-side goal shot when names diverge', () => {
const renamed = [{ ...shots[1]!, player: 'L. Krejci-Smith' }];
expect(goalShot(goal!, renamed)?.situation).toBe('ThrowInSetPiece');
});
it('caps the number of lines', () => {
const noisy = [
...Array.from({ length: 8 }, (_, i) => ({ minute: 58, text: `Pass ${i}.` })),
{ minute: 59, text: goalText },
it('strips the scorer prefix from the goal prose', () => {
expect(eventContext(goal!)).toBe('Header from very close range to the bottom right corner. Assisted by Vladimír Coufal.');
});
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);
});
it('returns nothing when the event opens the feed', () => {
expect(buildup(goal!, [{ minute: 59, text: goalText }])).toEqual([]);
expect(varLines(goal!, commentary)).toEqual(['VAR Decision: Goal Czechia — onside confirmed.']);
});
});
+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.
@@ -12,6 +12,24 @@ import type { MatchLiveEvent } from '@/lib/types';
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 =
| { type: 'assist'; name: string }
| { 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.
const m = /^(?:Goal!|Own Goal by[^.]*\.)[^.]*?\d+\s*[,.][^.]*?\.\s*(.+)$/.exec(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') {
const m = /shown the (?:yellow|red|second yellow) card\s+(.+?)\.?\s*$/.exec(raw);
const reason = m?.[1]?.trim();
@@ -147,31 +168,54 @@ export function eventContext(e: ParsedEvent): string | null {
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
* 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.
* Find the shot that IS this goal: scorer name match within ±2 minutes,
* else the only goal-shot for that side within ±1 minute (the sources
* romanize names differently, so the name can miss).
*/
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;
}
export function goalShot(e: ParsedEvent, shots: RichShot[]): GoalShotInsight | null {
if (e.kind !== 'goal' || e.minute == null) return null;
const goals = shots.filter((s) => s.type === 'Goal' && s.min != null);
let m = goals.find((s) => Math.abs(s.min! - e.minute!) <= 2 && nameMatch(s.player, e.title));
if (!m) {
const near = goals.filter((s) => Math.abs(s.min! - e.minute!) <= 1 && (e.side === null || s.isHome === (e.side === 'home')));
if (near.length === 1) m = near[0];
}
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;
if (!m) return null;
const distM = m.x != null && m.y != null ? Math.round(Math.hypot(105 - m.x, 34 - m.y)) : null;
return { xg: m.xg, xgot: m.xgot, situation: m.situation, distM };
}
/** 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. */
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). */