From 0e2e1dc44f7da8d0cb069f6923b4e892f7b36ca0 Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Fri, 12 Jun 2026 13:20:25 +0200 Subject: [PATCH] Tap-to-expand context on timeline events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/components/MatchTimeline.tsx | 82 +++++++++++++++++++++++++------- src/lib/matchEvents.test.ts | 42 +++++++++++++++- src/lib/matchEvents.ts | 36 ++++++++++++-- 3 files changed, 137 insertions(+), 23 deletions(-) diff --git a/src/components/MatchTimeline.tsx b/src/components/MatchTimeline.tsx index 4e9fd46..522899f 100644 --- a/src/components/MatchTimeline.tsx +++ b/src/components/MatchTimeline.tsx @@ -1,7 +1,7 @@ -import { useMemo } from 'react'; -import { ArrowRightLeft, CircleDot, Volleyball } from 'lucide-react'; +import { useMemo, useState, type KeyboardEvent } from 'react'; +import { ArrowRightLeft, ChevronDown, CircleDot, Volleyball } from 'lucide-react'; import { fmt, useT } from '@/lib/i18n'; -import { parseEvents, halfTimeScore, type EventKind, type ParsedEvent } from '@/lib/matchEvents'; +import { eventContext, parseEvents, halfTimeScore, type EventKind, type ParsedEvent } from '@/lib/matchEvents'; import type { MatchLiveEvent } from '@/lib/types'; /** Crisp event markers instead of emoji: ball icon for goals, card-shaped @@ -42,7 +42,21 @@ function MinutePill({ e }: { e: ParsedEvent }) { ); } -function GoalCard({ e, mobile }: { e: ParsedEvent; mobile?: boolean }) { + +/** Press/keyboard handlers + affordance classes for expandable rows. */ +function expandable(ctx: string | null, onToggle: () => void) { + if (!ctx) return {}; + return { + role: 'button' as const, + tabIndex: 0, + onClick: onToggle, + onKeyDown: (ev: KeyboardEvent) => { + if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); onToggle(); } + }, + }; +} + +function GoalCard({ e, mobile, ctx, open, onToggle }: { e: ParsedEvent; mobile?: boolean; ctx: string | null; open: boolean; onToggle: () => void }) { const t = useT(); const away = e.side === 'away'; // Side-colored edge faces the spine on desktop; always left on mobile. @@ -56,33 +70,49 @@ function GoalCard({ e, mobile }: { e: ParsedEvent; mobile?: boolean }) { : e.detail?.type === 'raw' ? e.detail.text : null; return ( -
+
{own ? t.match.ownGoal : t.match.goal} - {e.score && ( - - {e.score[0]}–{e.score[1]} - - )} + + {e.score && ( + + {e.score[0]}–{e.score[1]} + + )} + {ctx && } +
{e.title}
{detailText &&
{detailText}
} + {ctx && open &&

{ctx}

}
); } -function MinorChip({ e, mobile }: { e: ParsedEvent; mobile?: boolean }) { +function MinorChip({ e, mobile, ctx, open, onToggle }: { e: ParsedEvent; mobile?: boolean; ctx: string | null; open: boolean; onToggle: () => void }) { const side = mobile ? e.side === 'away' ? 'border-l-[3px] border-l-info' : 'border-l-[3px] border-l-accent' : ''; return ( - - - {e.title} - {e.subOut && {e.subOut}} - +
+ + + {e.title} + {e.subOut && {e.subOut}} + {ctx && } + + {ctx && open && {ctx}} +
); } @@ -121,6 +151,15 @@ export interface MatchTimelineProps { export function MatchTimeline({ events, 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()); + const toggle = (i: number) => + setOpen((prev) => { + const next = new Set(prev); + if (next.has(i)) next.delete(i); + else next.add(i); + return next; + }); const rows = useMemo(() => { const parsed = parseEvents(events); @@ -180,7 +219,10 @@ export function MatchTimeline({ events, status, minute, homeScore, awayScore }:
); } - const content = e.kind === 'goal' ? : ; + const ctx = eventContext(e); + const content = e.kind === 'goal' + ? toggle(i)} /> + : toggle(i)} />; return (
- {e.side === null ? : e.kind === 'goal' ? : } + {e.side === null + ? + : e.kind === 'goal' + ? toggle(i)} /> + : toggle(i)} />}
); diff --git a/src/lib/matchEvents.test.ts b/src/lib/matchEvents.test.ts index db80e69..985cfb8 100644 --- a/src/lib/matchEvents.test.ts +++ b/src/lib/matchEvents.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { goalEvents, halfTimeScore, parseEvents } from './matchEvents'; +import { eventContext, goalEvents, halfTimeScore, parseEvents } from './matchEvents'; import type { MatchLiveEvent } from './types'; // Real shapes from the ESPN feed (WC 2026 match 2, Korea Republic – Czechia). @@ -87,6 +87,46 @@ describe('parseEvents', () => { }); }); +describe('eventContext', () => { + it('strips the score sentence 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.'); + }); + + it('extracts the booking reason', () => { + const [c] = parseEvents([{ + minute: 90, type: 'Yellow Card', team: 'home', + text: 'Lee Gi-Hyuk (Korea Republic) is shown the yellow card for a bad foul.', + }]); + expect(eventContext(c!)).toBe('For a bad foul.'); + }); + + it('falls back to the full sentence when no reason is given', () => { + const [c] = parseEvents([{ + minute: 33, type: 'Red Card', team: 'away', + text: 'John Doe (Somewhere) is shown the red card.', + }]); + expect(eventContext(c!)).toBe('John Doe (Somewhere) is shown the red card.'); + }); + + it('returns null for substitutions and neutral rows', () => { + const parsed = parseEvents([ + { minute: 62, type: 'Substitution', team: 'home', text: 'Substitution, Korea Republic. Hwang Hee-Chan replaces Lee Jae-Sung.' }, + { minute: 23, type: 'Start Delay', team: 'home', text: 'Delay in match for a drinks break.' }, + ]); + expect(eventContext(parsed[0]!)).toBeNull(); + expect(eventContext(parsed[1]!)).toBeNull(); + }); + + it('never duplicates an unparseable title', () => { + const [g] = parseEvents([{ minute: 10, type: 'Goal', team: 'home', text: 'Scrappy finish.' }]); + expect(eventContext(g!)).toBeNull(); + }); +}); + describe('halfTimeScore', () => { it('is the last goal score at minute ≤ 45', () => { expect(halfTimeScore(parseEvents(realEvents))).toEqual([0, 0]); diff --git a/src/lib/matchEvents.ts b/src/lib/matchEvents.ts index 2a62c51..2614f3a 100644 --- a/src/lib/matchEvents.ts +++ b/src/lib/matchEvents.ts @@ -27,6 +27,8 @@ export interface ParsedEvent { 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; } @@ -98,14 +100,14 @@ export function parseEvents(events: MatchLiveEvent[]): ParsedEvent[] { 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, ...g }; + 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, ...parseBooking(e) }; + 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, ...parseSub(e) }; + 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 }; + parsed = { kind, side: null, minute: e.minute, title: e.text || e.type, detail: null, score: null, raw: e.text }; } out.push(parsed); } @@ -117,6 +119,32 @@ 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];