Match center redesign: center-spine timeline + hero scoreboard + tabbed layout
Implements steps 1-3 of the Claude Design handoff: - MatchTimeline: vertical center spine (home left, away right, minute pills on the rail), goal cards with parsed scorer/assist/running score, sub and booking chips, synthetic kick-off/half-time/full-time dividers; single left rail under the sm breakpoint - matchEvents: pure ESPN commentary parser (scorer, assist, penalty, own goal, sub in/out, embedded score) with tests on real feed shapes - Hero scoreboard: panel gradient + accent glow, smallcaps meta, mono score, stat chips (xG / POTM / attendance / referee; possession and shots while live); weather line stays for upcoming matches - Sticky Summary/Timeline/Stats/Lineups sub-nav; all existing cards re-bucketed with no feature loss (in-play win prob, swing chart, xG race, shot map, momentum, odds movement, form, H2H, lineups) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -114,6 +114,27 @@ export const de: Dict = {
|
||||
attendanceLine: "{stadium} · {n} Zuschauer · Schiedsrichter {ref}",
|
||||
potm: "Spieler des Spiels",
|
||||
xgAttribution: "xG und Bewertungen sind FotMob-Schätzungen.",
|
||||
tabs: {
|
||||
summary: "Übersicht",
|
||||
timeline: "Spielverlauf",
|
||||
stats: "Statistik",
|
||||
lineups: "Aufstellungen",
|
||||
},
|
||||
keyMoments: "Schlüsselmomente",
|
||||
viewTimeline: "Zum kompletten Spielverlauf",
|
||||
kickOff: "Anstoß",
|
||||
halfTime: "Halbzeit",
|
||||
goal: "Tor",
|
||||
ownGoal: "Eigentor",
|
||||
penalty: "Elfmeter",
|
||||
assist: "Vorlage · {name}",
|
||||
noGoalsYet: "Noch keine Tore.",
|
||||
chips: {
|
||||
att: "Zusch.",
|
||||
ref: "SR",
|
||||
poss: "Besitz",
|
||||
shots: "Schüsse",
|
||||
},
|
||||
},
|
||||
compare: {
|
||||
title: "Vergleich",
|
||||
|
||||
@@ -113,6 +113,27 @@ export const en = {
|
||||
attendanceLine: "{stadium} · {n} spectators · referee {ref}",
|
||||
potm: "Player of the match",
|
||||
xgAttribution: "xG and ratings are FotMob estimates.",
|
||||
tabs: {
|
||||
summary: "Summary",
|
||||
timeline: "Timeline",
|
||||
stats: "Stats",
|
||||
lineups: "Lineups",
|
||||
},
|
||||
keyMoments: "Key moments",
|
||||
viewTimeline: "View full timeline",
|
||||
kickOff: "Kick-off",
|
||||
halfTime: "Half-time",
|
||||
goal: "Goal",
|
||||
ownGoal: "Own goal",
|
||||
penalty: "Penalty",
|
||||
assist: "Assist · {name}",
|
||||
noGoalsYet: "No goals yet.",
|
||||
chips: {
|
||||
att: "Att",
|
||||
ref: "Ref",
|
||||
poss: "Poss",
|
||||
shots: "Shots",
|
||||
},
|
||||
},
|
||||
compare: {
|
||||
title: "Compare",
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { goalEvents, halfTimeScore, parseEvents } from './matchEvents';
|
||||
import type { MatchLiveEvent } from './types';
|
||||
|
||||
// Real shapes from the ESPN feed (WC 2026 match 2, Korea Republic – Czechia).
|
||||
const realEvents: MatchLiveEvent[] = [
|
||||
{ minute: null, type: 'Kickoff', text: '', team: null },
|
||||
{ minute: 23, type: 'Start Delay', text: 'Delay in match for a drinks break.', team: 'home' },
|
||||
{ minute: 23, type: 'Start Delay', text: '', team: 'away' },
|
||||
{ minute: 25, type: 'End Delay', text: 'Delay over. They are ready to continue.', team: 'home' },
|
||||
{ minute: 25, type: 'End Delay', text: '', team: 'away' },
|
||||
{ minute: 45, type: 'Halftime', text: '', team: null },
|
||||
{ minute: 45, type: 'Start 2nd Half', text: '', team: null },
|
||||
{
|
||||
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.',
|
||||
},
|
||||
{ minute: 62, type: 'Substitution', text: 'Substitution, Korea Republic. Hwang Hee-Chan replaces Lee Jae-Sung.', team: 'home' },
|
||||
{
|
||||
minute: 67, type: 'Goal', team: 'home',
|
||||
text: 'Goal! Korea Republic 1, Czechia 1. Hwang In-Beom (Korea Republic) right footed shot from the centre of the box to the bottom right corner. Assisted by Lee Kang-In.',
|
||||
},
|
||||
{ minute: 90, type: 'Yellow Card', text: 'Lee Gi-Hyuk (Korea Republic) is shown the yellow card for a bad foul.', team: 'home' },
|
||||
{ minute: 90, type: 'End Regular Time', text: '', team: null },
|
||||
];
|
||||
|
||||
describe('parseEvents', () => {
|
||||
it('drops whole-match markers and empty duplicate rows', () => {
|
||||
const parsed = parseEvents(realEvents);
|
||||
expect(parsed.map((e) => e.kind)).toEqual(['other', 'other', 'goal', 'sub', 'goal', 'yellow']);
|
||||
});
|
||||
|
||||
it('parses scorer, assist and the running score from goal text', () => {
|
||||
const goals = goalEvents(realEvents);
|
||||
expect(goals[0]).toMatchObject({
|
||||
side: 'away', minute: 59, title: 'Ladislav Krejcí', score: [0, 1],
|
||||
detail: { type: 'assist', name: 'Vladimír Coufal' },
|
||||
});
|
||||
expect(goals[1]).toMatchObject({
|
||||
side: 'home', minute: 67, title: 'Hwang In-Beom', score: [1, 1],
|
||||
detail: { type: 'assist', name: 'Lee Kang-In' },
|
||||
});
|
||||
});
|
||||
|
||||
it('parses substitutions into in/out players', () => {
|
||||
const sub = parseEvents(realEvents).find((e) => e.kind === 'sub');
|
||||
expect(sub).toMatchObject({ title: 'Hwang Hee-Chan', subOut: 'Lee Jae-Sung', side: 'home' });
|
||||
});
|
||||
|
||||
it('parses the booked player from card text', () => {
|
||||
const card = parseEvents(realEvents).find((e) => e.kind === 'yellow');
|
||||
expect(card).toMatchObject({ title: 'Lee Gi-Hyuk', side: 'home' });
|
||||
});
|
||||
|
||||
it('renders neutral happenings centered (side null) with their text', () => {
|
||||
const delay = parseEvents(realEvents)[0];
|
||||
expect(delay).toMatchObject({ kind: 'other', side: null, title: 'Delay in match for a drinks break.' });
|
||||
});
|
||||
|
||||
it('handles own goals', () => {
|
||||
const [g] = parseEvents([{
|
||||
minute: 30, type: 'Own Goal', team: 'home',
|
||||
text: 'Own Goal by Joe Gomez, England. Scotland 1, England 0.',
|
||||
}]);
|
||||
expect(g).toMatchObject({ kind: 'goal', title: 'Joe Gomez', detail: { type: 'ownGoal' }, score: [1, 0] });
|
||||
});
|
||||
|
||||
it('marks converted penalties', () => {
|
||||
const [g] = parseEvents([{
|
||||
minute: 75, type: 'Penalty - Scored', team: 'away',
|
||||
text: 'Goal! Mexico 0, Brazil 2. Vinícius Júnior (Brazil) converts the penalty with a right footed shot to the bottom left corner.',
|
||||
}]);
|
||||
expect(g).toMatchObject({ kind: 'goal', title: 'Vinícius Júnior', detail: { type: 'penalty' }, score: [0, 2] });
|
||||
});
|
||||
|
||||
it('falls back to counting by side when the text has no score', () => {
|
||||
const parsed = parseEvents([
|
||||
{ minute: 10, type: 'Goal', text: 'Scrappy finish.', team: 'home' },
|
||||
{ minute: 20, type: 'Goal', text: 'Another one.', team: 'home' },
|
||||
]);
|
||||
expect(parsed[0]?.score).toEqual([1, 0]);
|
||||
expect(parsed[1]?.score).toEqual([2, 0]);
|
||||
});
|
||||
|
||||
it('never produces an empty title', () => {
|
||||
for (const e of parseEvents(realEvents)) expect(e.title.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('halfTimeScore', () => {
|
||||
it('is the last goal score at minute ≤ 45', () => {
|
||||
expect(halfTimeScore(parseEvents(realEvents))).toEqual([0, 0]);
|
||||
const withEarly = parseEvents([
|
||||
{ minute: 12, type: 'Goal', text: 'Goal! A 1, B 0. Someone (A) shot.', team: 'home' },
|
||||
{ minute: 70, type: 'Goal', text: 'Goal! A 1, B 1. Other (B) shot.', team: 'away' },
|
||||
]);
|
||||
expect(halfTimeScore(withEarly)).toEqual([1, 0]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
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;
|
||||
/** 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, ...g };
|
||||
} else if (kind === 'yellow' || kind === 'red') {
|
||||
parsed = { kind, side: e.team, minute: e.minute, score: null, ...parseBooking(e) };
|
||||
} else if (kind === 'sub') {
|
||||
parsed = { kind, side: e.team, minute: e.minute, score: null, ...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 };
|
||||
}
|
||||
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');
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
Reference in New Issue
Block a user