From 089d789e49eb07fb03355b4d5233d49ba948b2d2 Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Fri, 12 Jun 2026 14:03:54 +0200 Subject: [PATCH] Event context from data, not adjacent feed lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/components/LineupPitch.tsx | 18 +---- src/components/MatchTimeline.tsx | 73 +++++++++++++------- src/features/match/MatchPreviewPage.tsx | 2 +- src/lib/i18n/de.ts | 16 +++++ src/lib/i18n/en.ts | 16 +++++ src/lib/matchEvents.test.ts | 60 ++++++++++------- src/lib/matchEvents.ts | 90 ++++++++++++++++++------- 7 files changed, 185 insertions(+), 90 deletions(-) diff --git a/src/components/LineupPitch.tsx b/src/components/LineupPitch.tsx index 9a58164..5cf5a7a 100644 --- a/src/components/LineupPitch.tsx +++ b/src/components/LineupPitch.tsx @@ -1,6 +1,6 @@ import { useMemo } from 'react'; 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'; /** @@ -17,22 +17,6 @@ import type { LineupTeam, MatchLineup, MatchLiveEvent, PitchPlayer } from '@/lib const W = 84; 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 { /** Who currently occupies the position. */ player: PitchPlayer; diff --git a/src/components/MatchTimeline.tsx b/src/components/MatchTimeline.tsx index b841957..b6b1d6d 100644 --- a/src/components/MatchTimeline.tsx +++ b/src/components/MatchTimeline.tsx @@ -1,8 +1,8 @@ import { useMemo, useState, type KeyboardEvent } from 'react'; import { ArrowRightLeft, ChevronDown, CircleDot, Volleyball } from 'lucide-react'; import { fmt, useT } from '@/lib/i18n'; -import { buildup, eventContext, parseEvents, halfTimeScore, type CommentaryLine, type EventKind, type ParsedEvent } from '@/lib/matchEvents'; -import type { MatchLiveEvent } from '@/lib/types'; +import { eventContext, goalShot, momentumVerdict, parseEvents, halfTimeScore, 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 * 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 - * own description. */ -function ContextPanel({ pre, ctx }: { pre: CommentaryLine[]; ctx: string }) { +interface RichContext { + shots: RichShot[]; + 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; + 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 (
- {pre.map((c, j) => ( -

- {c.minute != null && {c.minute}' } - {c.text} -

- ))} -

{ctx}

+ {chance &&

{chance}

} + {verdict &&

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

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

{v}

)} +

{ctx}

); } -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 away = e.side === 'away'; // 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
{e.title}
{detailText &&
{detailText}
} - {ctx && open && } + {ctx && open && } ); } -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 ? 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 && {e.subOut}} {ctx && } - {ctx && open && } + {ctx && open && } ); } @@ -160,15 +183,19 @@ function DividerPill({ row }: { row: Extract }) { export interface MatchTimelineProps { 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[]; + /** 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'; minute?: number | null; homeScore?: 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(); // Tap-to-expand context per row (multiple rows can stay open). const [open, setOpen] = useState>(new Set()); @@ -239,10 +266,10 @@ export function MatchTimeline({ events, commentary = [], status, minute, homeSco ); } const ctx = eventContext(e); - const pre = open.has(i) ? buildup(e, commentary) : []; + const rich = { shots, momentum, commentary }; const content = e.kind === 'goal' - ? toggle(i)} /> - : toggle(i)} />; + ? toggle(i)} /> + : toggle(i)} />; return (
: 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 cd94fad..ff62f68 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 65f6bc1..1e29878 100644 --- a/src/lib/i18n/de.ts +++ b/src/lib/i18n/de.ts @@ -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}", diff --git a/src/lib/i18n/en.ts b/src/lib/i18n/en.ts index e886648..10960db 100644 --- a/src/lib/i18n/en.ts +++ b/src/lib/i18n/en.ts @@ -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}", diff --git a/src/lib/matchEvents.test.ts b/src/lib/matchEvents.test.ts index 9994c4e..8c976ab 100644 --- a/src/lib/matchEvents.test.ts +++ b/src/lib/matchEvents.test.ts @@ -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.']); }); }); diff --git a/src/lib/matchEvents.ts b/src/lib/matchEvents.ts index 127d8be..7c8b385 100644 --- a/src/lib/matchEvents.ts +++ b/src/lib/matchEvents.ts @@ -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 (0–0 when none). */