Files
cup26/src/lib/matchEvents.ts
T
NilsBriggen 0e2e1dc44f Tap-to-expand context on timeline events
Goals and bookings on the match timeline now expand on tap/click (or
Enter/Space) to show what actually happened, parsed from the live
feed's commentary: the shot description for goals ('header from very
close range to the bottom right corner…') and the reason for cards
('For a bad foul.'). A chevron marks expandable rows; substitutions
and neutral rows carry nothing extra so they stay inert. Works on both
the center-spine and the mobile rail layouts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 13:20:25 +02:00

156 lines
6.5 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 } 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';
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;
} 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;
}
/** 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;
}