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 { 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 { const m = /^([^(]+?)\s*\(/.exec(e.text); return { title: m?.[1]?.trim() || e.text || e.type, detail: null }; } function parseSub(e: MatchLiveEvent): Pick { 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 (0–0 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; }