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:
@@ -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
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user