Files
cup26/src/lib/matchEvents.ts
T
NilsBriggen 9e1f6e5cee Cleaner event panels: hide raw feed prose, one best report sentence
- the ESPN feed description and attribution line are gone from goal
  panels whenever the report narrative or shot data tells the story
  (attribution lives in a tooltip); bookings keep their reason line
- one report sentence per event, ranked by event vocabulary with a
  bonus when the player is the actor ('Quinones, who drove a low
  shot…' beats 'goals from Quinones and Jimenez gave…')
- momentum verdicts only ever applied to goals (a clear-pressure
  booking no longer claims it was 'scored against the run of play')

Routine events the report never mentions (e.g. an early booking) show
what the sources have: the reason. There is no richer feed for those.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 14:41:27 +02:00

262 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { MatchLiveEvent, RichShot } from '@/lib/types';
/**
* Turns ESPN's flat commentary events into structured timeline items.
* ESPN text is English prose with a stable shape per event type, e.g.
* "Goal! Korea Republic 1, Czechia 1. Hwang In-Beom (Korea Republic) right
* footed shot … Assisted by Lee Jae-Sung."
* "Substitution, Czechia. Adam Hlozek replaces Pavel Sulc."
* "Lee Gi-Hyuk (Korea Republic) is shown the yellow card for a bad foul."
* Anything we can't parse keeps its raw text — never an empty card.
*/
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' }
| { type: 'ownGoal' }
| { type: 'raw'; text: string };
export interface ParsedEvent {
kind: EventKind;
side: 'home' | 'away' | null;
minute: number | null;
/** Scorer / booked player / incoming sub — or the raw text when unparseable. */
title: string;
detail: EventDetail | null;
/** Running score after this event; goals only. */
score: [number, number] | null;
/** The full source commentary line — feeds the tap-to-expand context. */
raw: string;
/** Outgoing player (substitutions). */
subOut?: string;
}
/** Whole-match markers the timeline replaces with its own styled dividers. */
const MARKER_TYPES = new Set([
'kickoff', 'halftime', 'start 2nd half', 'end regular time', 'end of game',
'full time', 'match end', 'start 1st half',
]);
export function eventKind(type: string): EventKind {
const t = type.toLowerCase();
if (t.includes('goal') || t.includes('penalty - scored')) return 'goal';
if (t.includes('yellow')) return 'yellow';
if (t.includes('red')) return 'red';
if (t.includes('substitution')) return 'sub';
return 'other';
}
/** "… Korea Republic 1, Czechia 1." → [1, 1] (the score embedded in goal text). */
function parseScore(text: string): [number, number] | null {
const m = /(\d+)\s*,[^.,\d]*?(\d+)\s*\./.exec(text);
if (!m) return null;
return [Number(m[1]), Number(m[2])];
}
function parseGoal(e: MatchLiveEvent, fallbackScore: [number, number] | null): Pick<ParsedEvent, 'title' | 'detail' | 'score'> {
const text = e.text;
const score = parseScore(text) ?? fallbackScore;
const own = /Own Goal by ([^,.]+)/.exec(text);
if (own?.[1]) return { title: own[1].trim(), detail: { type: 'ownGoal' }, score };
// Scorer = first "Name (Team)" after the score sentence.
const scorer = /\.\s*([^.()]+?)\s*\(/.exec(text);
const title = scorer?.[1]?.trim() || text || e.type;
let detail: EventDetail | null = null;
const assist = /Assisted by ([^.]+?)(?:\s+(?:with|following|after)\b[^.]*)?\./.exec(text);
if (assist?.[1]) detail = { type: 'assist', name: assist[1].trim() };
else if (/penalty/i.test(text) || /penalty/i.test(e.type)) detail = { type: 'penalty' };
return { title, detail, score };
}
function parseBooking(e: MatchLiveEvent): Pick<ParsedEvent, 'title' | 'detail'> {
const m = /^([^(]+?)\s*\(/.exec(e.text);
return { title: m?.[1]?.trim() || e.text || e.type, detail: null };
}
function parseSub(e: MatchLiveEvent): Pick<ParsedEvent, 'title' | 'detail' | 'subOut'> {
const m = /\.\s*(.+?)\s+replaces\s+(.+?)\.?\s*$/.exec(e.text);
if (m?.[1] && m[2]) return { title: m[1].trim(), detail: null, subOut: m[2].trim() };
return { title: e.text || e.type, detail: null };
}
/**
* Parse, de-noise and order events for display.
* Drops whole-match markers (the component draws its own dividers) and the
* empty-text duplicate rows ESPN emits for both teams on neutral events.
*/
export function parseEvents(events: MatchLiveEvent[]): ParsedEvent[] {
const out: ParsedEvent[] = [];
let running: [number, number] = [0, 0];
for (const e of events) {
const kind = eventKind(e.type);
const type = e.type.toLowerCase();
if (MARKER_TYPES.has(type)) continue;
if (kind === 'other' && !e.text) continue; // duplicate/no-info rows
let parsed: ParsedEvent;
if (kind === 'goal') {
// Fallback when the text carries no score: count by attributed side.
const counted: [number, number] = e.team === 'away' ? [running[0], running[1] + 1] : [running[0] + 1, running[1]];
const g = parseGoal(e, e.team ? counted : null);
if (g.score) running = g.score;
parsed = { kind, side: e.team, minute: e.minute, raw: e.text, ...g };
} else if (kind === 'yellow' || kind === 'red') {
parsed = { kind, side: e.team, minute: e.minute, score: null, raw: e.text, ...parseBooking(e) };
} else if (kind === 'sub') {
parsed = { kind, side: e.team, minute: e.minute, score: null, raw: e.text, ...parseSub(e) };
} else {
// Neutral happenings (delays, VAR…) render centered regardless of team.
parsed = { kind, side: null, minute: e.minute, title: e.text || e.type, detail: null, score: null, raw: e.text };
}
out.push(parsed);
}
return out;
}
/** Goals only — feeds the Summary tab's key-moments card. */
export function goalEvents(events: MatchLiveEvent[]): ParsedEvent[] {
return parseEvents(events).filter((e) => e.kind === 'goal');
}
/**
* The "what actually happened" prose behind a timeline item, for tap-to-expand:
* for goals the shot description with the redundant score sentence stripped
* ("right footed shot from the centre of the box…"), for bookings the reason
* ("For a bad foul."). Null when the source adds nothing beyond the title.
*/
export function eventContext(e: ParsedEvent): string | null {
const raw = e.raw.trim();
if (!raw) return null;
let ctx: string | null = null;
if (e.kind === 'goal') {
// 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();
ctx = reason ? reason.charAt(0).toUpperCase() + reason.slice(1) + '.' : raw;
} else {
return null; // subs and neutral rows already show everything they have
}
// No point expanding into the exact text the row already shows.
if (!ctx || ctx === e.title || ctx.length < 4) return null;
return ctx;
}
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;
}
/**
* 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 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 (!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';
}
const KIND_WORDS: Record<string, RegExp> = {
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 = 1): 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) => {
let hits = kindRe ? (s.match(new RegExp(kindRe.source, 'gi')) ?? []).length : 0;
// The player ACTING ("Quinones, who drove a low shot") outranks the
// player merely listed ("goals from Quinones and Jimenez gave…").
if (kindRe && new RegExp(`\\b${last}\\b[^.]{0,60}?${kindRe.source}`, 'i').test(norm(s))) hits += 2;
return { s: s.trim(), hits };
});
if (!scored.length) return [];
scored.sort((a, b) => b.hits - a.hits);
return scored.slice(0, max).map((x) => x.s);
}
/** 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). */
export function halfTimeScore(parsed: ParsedEvent[]): [number, number] {
let ht: [number, number] = [0, 0];
for (const e of parsed) {
if (e.kind === 'goal' && e.score && e.minute != null && e.minute <= 45) ht = e.score;
}
return ht;
}