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>
This commit is contained in:
2026-06-12 13:20:25 +02:00
parent 3d3267d803
commit 0e2e1dc44f
3 changed files with 137 additions and 23 deletions
+64 -18
View File
@@ -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 (
<div className={`${mobile ? 'w-full' : 'w-[300px] max-w-full'} rounded-xl border border-line bg-panel-2 p-3 ${edge}`}>
<div
{...expandable(ctx, onToggle)}
aria-expanded={ctx ? open : undefined}
className={`${mobile ? 'w-full' : 'w-[300px] max-w-full'} rounded-xl border border-line bg-panel-2 p-3 text-left ${edge} ${ctx ? 'cursor-pointer transition-colors hover:border-line-strong' : ''}`}
>
<div className="flex items-center justify-between gap-2">
<span className={`text-[10px] font-bold uppercase tracking-wide ${away ? 'text-info' : 'text-accent'}`}>
{own ? t.match.ownGoal : t.match.goal}
</span>
{e.score && (
<span className={`tnum rounded-md px-1.5 py-0.5 text-xs ${away ? 'bg-info/15 text-info' : 'bg-accent/15 text-accent'}`}>
{e.score[0]}{e.score[1]}
</span>
)}
<span className="flex shrink-0 items-center gap-1.5">
{e.score && (
<span className={`tnum rounded-md px-1.5 py-0.5 text-xs ${away ? 'bg-info/15 text-info' : 'bg-accent/15 text-accent'}`}>
{e.score[0]}{e.score[1]}
</span>
)}
{ctx && <ChevronDown size={13} className={`text-faint transition-transform ${open ? 'rotate-180' : ''}`} />}
</span>
</div>
<div className={`mt-1 text-base font-bold text-ink ${mobile ? '' : 'truncate'}`}>{e.title}</div>
{detailText && <div className={`mt-0.5 text-xs text-faint ${mobile ? '' : 'truncate'}`}>{detailText}</div>}
{ctx && open && <p className="mt-1.5 whitespace-normal text-xs leading-relaxed text-muted">{ctx}</p>}
</div>
);
}
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 (
<span className={`inline-flex max-w-full items-center gap-2 rounded-xl border border-line bg-panel px-2.5 py-1.5 ${side}`}>
<EventIcon kind={e.kind} />
<span className="truncate text-[13.5px] text-ink-soft">{e.title}</span>
{e.subOut && <span className="truncate text-[13.5px] text-faint">{e.subOut}</span>}
</span>
<div
{...expandable(ctx, onToggle)}
aria-expanded={ctx ? open : undefined}
className={`inline-flex max-w-full flex-col rounded-xl border border-line bg-panel px-2.5 py-1.5 text-left ${side} ${ctx ? 'cursor-pointer transition-colors hover:border-line-strong' : ''}`}
>
<span className="flex min-w-0 items-center gap-2">
<EventIcon kind={e.kind} />
<span className="truncate text-[13.5px] text-ink-soft">{e.title}</span>
{e.subOut && <span className="truncate text-[13.5px] text-faint">{e.subOut}</span>}
{ctx && <ChevronDown size={12} className={`shrink-0 text-faint transition-transform ${open ? 'rotate-180' : ''}`} />}
</span>
{ctx && open && <span className="mt-1 whitespace-normal text-xs leading-relaxed text-muted">{ctx}</span>}
</div>
);
}
@@ -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<ReadonlySet<number>>(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<Row[]>(() => {
const parsed = parseEvents(events);
@@ -180,7 +219,10 @@ export function MatchTimeline({ events, status, minute, homeScore, awayScore }:
</div>
);
}
const content = e.kind === 'goal' ? <GoalCard e={e} /> : <MinorChip e={e} />;
const ctx = eventContext(e);
const content = e.kind === 'goal'
? <GoalCard e={e} ctx={ctx} open={open.has(i)} onToggle={() => toggle(i)} />
: <MinorChip e={e} ctx={ctx} open={open.has(i)} onToggle={() => toggle(i)} />;
return (
<div
key={i}
@@ -211,7 +253,11 @@ export function MatchTimeline({ events, status, minute, homeScore, awayScore }:
<div key={i} className="relative z-10 my-2.5 grid grid-cols-[54px_1fr] items-center gap-2.5">
<div className="flex justify-center"><MinutePill e={e} /></div>
<div>
{e.side === null ? <NeutralChip e={e} showMinute={false} /> : e.kind === 'goal' ? <GoalCard e={e} mobile /> : <MinorChip e={e} mobile />}
{e.side === null
? <NeutralChip e={e} showMinute={false} />
: e.kind === 'goal'
? <GoalCard e={e} mobile ctx={eventContext(e)} open={open.has(i)} onToggle={() => toggle(i)} />
: <MinorChip e={e} mobile ctx={eventContext(e)} open={open.has(i)} onToggle={() => toggle(i)} />}
</div>
</div>
);
+41 -1
View File
@@ -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]);
+32 -4
View File
@@ -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 (00 when none). */
export function halfTimeScore(parsed: ParsedEvent[]): [number, number] {
let ht: [number, number] = [0, 0];