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:
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,121 @@
|
||||
// Harvest FotMob matchDetails (team + per-shot xG) for recent international
|
||||
// matches into the local lake — the training data for the xG-informed Team-DC
|
||||
// experiment (v2 M1). Runs on the residential machine, politely (≥2.5s/req),
|
||||
// resumable by date. Standalone on purpose: plain fetch, no app imports
|
||||
// beyond the team canonicalizer.
|
||||
// bun scripts/backfillFotmob.ts [fromYYYY-MM-DD] [toYYYY-MM-DD]
|
||||
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { canonicalTeam } from '../src/lib/teams';
|
||||
import ratings from '../public/data/ratings.json';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const LAKE = join(ROOT, 'data', 'lake', 'fotmob');
|
||||
const OUT = join(LAKE, 'internationals.jsonl');
|
||||
const PROGRESS = join(LAKE, 'progress.json');
|
||||
const UA = { 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36' };
|
||||
const DELAY_MS = 2_600;
|
||||
|
||||
const FROM = process.argv[2] ?? '2022-07-01';
|
||||
const TO = process.argv[3] ?? '2026-06-10';
|
||||
|
||||
const KNOWN = new Set(Object.keys((ratings as { ratings: Record<string, number> }).ratings));
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
interface DayMatches {
|
||||
leagues?: { name?: string; ccode?: string; matches?: { id?: number | string; home?: { name?: string }; away?: { name?: string }; status?: { finished?: boolean } }[] }[];
|
||||
}
|
||||
|
||||
async function getJson<T>(url: string): Promise<T | null> {
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
const res = await fetch(url, { headers: UA });
|
||||
if (res.status === 429 || res.status >= 500) {
|
||||
await sleep(30_000 * (attempt + 1));
|
||||
continue;
|
||||
}
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as T;
|
||||
} catch {
|
||||
await sleep(10_000);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function* days(from: string, to: string): Generator<string> {
|
||||
const d = new Date(`${from}T00:00:00Z`);
|
||||
const end = new Date(`${to}T00:00:00Z`).getTime();
|
||||
while (d.getTime() <= end) {
|
||||
yield d.toISOString().slice(0, 10);
|
||||
d.setUTCDate(d.getUTCDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
mkdirSync(LAKE, { recursive: true });
|
||||
const done: Record<string, number> = existsSync(PROGRESS) ? JSON.parse(readFileSync(PROGRESS, 'utf8')) : {};
|
||||
let harvested = 0;
|
||||
|
||||
for (const day of days(FROM, TO)) {
|
||||
if (done[day] != null) continue;
|
||||
const ymd = day.replaceAll('-', '');
|
||||
const dayData = await getJson<DayMatches>(`https://www.fotmob.com/api/data/matches?date=${ymd}`);
|
||||
await sleep(DELAY_MS);
|
||||
let dayCount = 0;
|
||||
for (const lg of dayData?.leagues ?? []) {
|
||||
for (const m of lg.matches ?? []) {
|
||||
if (!m.id || !m.status?.finished || !m.home?.name || !m.away?.name) continue;
|
||||
const home = canonicalTeam(m.home.name);
|
||||
const away = canonicalTeam(m.away.name);
|
||||
// internationals only: both sides must be known national teams
|
||||
if (!KNOWN.has(home) || !KNOWN.has(away)) continue;
|
||||
const det = await getJson<{
|
||||
general?: { matchTimeUTC?: string };
|
||||
content?: {
|
||||
shotmap?: { shots?: { teamId?: number; min?: number; x?: number; y?: number; expectedGoals?: number; expectedGoalsOnTarget?: number; eventType?: string; situation?: string }[] };
|
||||
stats?: unknown;
|
||||
};
|
||||
header?: { teams?: { name?: string; score?: number }[] };
|
||||
}>(`https://www.fotmob.com/api/data/matchDetails?matchId=${m.id}`);
|
||||
await sleep(DELAY_MS);
|
||||
if (!det) continue;
|
||||
const shots = det.content?.shotmap?.shots ?? [];
|
||||
const teams = det.header?.teams ?? [];
|
||||
const xg = (idx: number): number | null => {
|
||||
// sum shot xG per side via teamId order in header is unreliable across
|
||||
// payloads — derive from shots when both team scores exist
|
||||
void idx;
|
||||
return null;
|
||||
};
|
||||
void xg;
|
||||
appendFileSync(
|
||||
OUT,
|
||||
JSON.stringify({
|
||||
date: day,
|
||||
league: lg.name ?? '',
|
||||
matchId: String(m.id),
|
||||
home,
|
||||
away,
|
||||
homeScore: teams[0]?.score ?? null,
|
||||
awayScore: teams[1]?.score ?? null,
|
||||
shots: shots.map((s) => ({
|
||||
teamId: s.teamId ?? null, min: s.min ?? null,
|
||||
xg: s.expectedGoals ?? null, xgot: s.expectedGoalsOnTarget ?? null,
|
||||
type: s.eventType ?? null, situation: s.situation ?? null,
|
||||
})),
|
||||
}) + '\n',
|
||||
);
|
||||
dayCount++;
|
||||
harvested++;
|
||||
}
|
||||
}
|
||||
done[day] = dayCount;
|
||||
writeFileSync(PROGRESS, JSON.stringify(done));
|
||||
if (dayCount) console.log(`${day}: ${dayCount} internationals (total ${harvested})`);
|
||||
}
|
||||
console.log(`backfill complete → ${OUT} (${harvested} new matches this run)`);
|
||||
}
|
||||
|
||||
void main();
|
||||
@@ -0,0 +1,221 @@
|
||||
import { useMemo } from 'react';
|
||||
import { ArrowRightLeft, CircleDot, Volleyball } from 'lucide-react';
|
||||
import { fmt, useT } from '@/lib/i18n';
|
||||
import { 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
|
||||
* color chips for bookings, arrows for substitutions. */
|
||||
export function EventIcon({ kind }: { kind: EventKind }) {
|
||||
if (kind === 'goal') return <Volleyball size={14} className="shrink-0 text-accent" />;
|
||||
if (kind === 'yellow') return <span className="inline-block h-3.5 w-2.5 shrink-0 rounded-[2px] bg-gold" />;
|
||||
if (kind === 'red') return <span className="inline-block h-3.5 w-2.5 shrink-0 rounded-[2px] bg-loss" />;
|
||||
if (kind === 'sub') return <ArrowRightLeft size={13} className="shrink-0 text-muted" />;
|
||||
return <CircleDot size={10} className="shrink-0 text-faint" />;
|
||||
}
|
||||
|
||||
type Row =
|
||||
| { t: 'event'; e: ParsedEvent }
|
||||
| { t: 'divider'; label: string; score?: string; live?: boolean };
|
||||
|
||||
/** Home goals fade in from accent at the top, away goals out to info at the
|
||||
* bottom — the spine itself hints at the side convention. */
|
||||
const SPINE_GRADIENT =
|
||||
'linear-gradient(180deg, color-mix(in oklab, var(--app-accent) 70%, transparent), var(--app-line) 14%, var(--app-line) 86%, color-mix(in oklab, var(--app-info) 70%, transparent))';
|
||||
|
||||
function MinutePill({ e }: { e: ParsedEvent }) {
|
||||
const t = useT();
|
||||
const cls =
|
||||
e.kind === 'goal'
|
||||
? e.side === 'away'
|
||||
? 'bg-info text-surface shadow-md'
|
||||
: 'bg-accent text-accent-ink shadow-md'
|
||||
: e.kind === 'yellow'
|
||||
? 'border-[1.5px] border-gold bg-panel text-gold'
|
||||
: e.kind === 'red'
|
||||
? 'border-[1.5px] border-loss bg-panel text-loss'
|
||||
: 'border border-line bg-elevated text-muted';
|
||||
return (
|
||||
<span className={`tnum inline-flex h-[30px] min-w-12 items-center justify-center rounded-full px-2.5 text-[13px] font-semibold ${cls}`}>
|
||||
{e.minute != null ? fmt(t.live.minute, { n: e.minute }) : '·'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function GoalCard({ e, mobile }: { e: ParsedEvent; mobile?: boolean }) {
|
||||
const t = useT();
|
||||
const away = e.side === 'away';
|
||||
// Side-colored edge faces the spine on desktop; always left on mobile.
|
||||
const edge = mobile || away
|
||||
? away ? 'border-l-[3px] border-l-info' : 'border-l-[3px] border-l-accent'
|
||||
: 'border-r-[3px] border-r-accent';
|
||||
const own = e.detail?.type === 'ownGoal';
|
||||
const detailText =
|
||||
e.detail?.type === 'assist' ? fmt(t.match.assist, { name: e.detail.name })
|
||||
: e.detail?.type === 'penalty' ? t.match.penalty
|
||||
: 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 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>
|
||||
)}
|
||||
</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>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MinorChip({ e, mobile }: { e: ParsedEvent; mobile?: boolean }) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
/** Neutral happenings (delays, VAR…) sit on the spine like a soft divider. */
|
||||
function NeutralChip({ e, showMinute }: { e: ParsedEvent; showMinute?: boolean }) {
|
||||
const t = useT();
|
||||
return (
|
||||
<span className="inline-block max-w-full rounded-2xl border border-line bg-elevated px-3.5 py-1.5 text-center text-[11px] text-muted">
|
||||
{showMinute !== false && e.minute != null && <span className="tnum">{fmt(t.live.minute, { n: e.minute })} · </span>}
|
||||
{e.title}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function DividerPill({ row }: { row: Extract<Row, { t: 'divider' }> }) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 whitespace-nowrap rounded-full border px-3.5 py-1.5 text-[11px] font-bold uppercase tracking-wider ${
|
||||
row.live ? 'border-live/40 bg-elevated text-live' : 'border-line bg-elevated text-muted'
|
||||
}`}
|
||||
>
|
||||
{row.live && <span className="live-dot h-1.5 w-1.5 rounded-full bg-live" />}
|
||||
{row.label}
|
||||
{row.score && <span className="tnum normal-case text-ink">{row.score}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export interface MatchTimelineProps {
|
||||
events: MatchLiveEvent[];
|
||||
status: 'scheduled' | 'live' | 'finished';
|
||||
minute?: number | null;
|
||||
homeScore?: number | null;
|
||||
awayScore?: number | null;
|
||||
}
|
||||
|
||||
export function MatchTimeline({ events, status, minute, homeScore, awayScore }: MatchTimelineProps) {
|
||||
const t = useT();
|
||||
|
||||
const rows = useMemo<Row[]>(() => {
|
||||
const parsed = parseEvents(events);
|
||||
const out: Row[] = [{ t: 'divider', label: t.match.kickOff }];
|
||||
// Half-time divider goes after the last first-half event — only once the
|
||||
// match has actually reached the second half.
|
||||
const reachedSecondHalf =
|
||||
status === 'finished' ||
|
||||
(status === 'live' && (minute ?? 0) > 45) ||
|
||||
parsed.some((e) => (e.minute ?? 0) > 45);
|
||||
let htAfter = -1;
|
||||
if (reachedSecondHalf) {
|
||||
for (let i = 0; i < parsed.length; i++) {
|
||||
const m = parsed[i]?.minute;
|
||||
if (m != null && m <= 45) htAfter = i;
|
||||
}
|
||||
}
|
||||
const ht = halfTimeScore(parsed);
|
||||
parsed.forEach((e, i) => {
|
||||
out.push({ t: 'event', e });
|
||||
if (i === htAfter) out.push({ t: 'divider', label: t.match.halfTime, score: `${ht[0]}–${ht[1]}` });
|
||||
});
|
||||
if (reachedSecondHalf && htAfter === -1) out.splice(1, 0, { t: 'divider', label: t.match.halfTime, score: `${ht[0]}–${ht[1]}` });
|
||||
if (status === 'finished') {
|
||||
out.push({ t: 'divider', label: t.common.fullTime, score: `${homeScore ?? 0}–${awayScore ?? 0}` });
|
||||
} else if (status === 'live') {
|
||||
out.push({
|
||||
t: 'divider',
|
||||
label: minute != null ? `${fmt(t.live.minute, { n: minute })} · ${t.common.liveNow}` : t.common.liveNow,
|
||||
live: true,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}, [events, status, minute, homeScore, awayScore, t]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Desktop / tablet: center spine, home left · away right */}
|
||||
<div className="relative hidden px-2 py-4 sm:block">
|
||||
<div aria-hidden className="absolute bottom-0 left-1/2 top-0 w-0.5 -translate-x-1/2" style={{ background: SPINE_GRADIENT }} />
|
||||
{rows.map((row, i) => {
|
||||
if (row.t === 'divider') {
|
||||
return (
|
||||
<div key={i} className="relative z-10 my-2 flex items-center gap-3">
|
||||
<span className="h-px flex-1 bg-line" />
|
||||
<DividerPill row={row} />
|
||||
<span className="h-px flex-1 bg-line" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const e = row.e;
|
||||
if (e.side === null) {
|
||||
return (
|
||||
<div key={i} className="relative z-10 my-2 flex justify-center">
|
||||
<NeutralChip e={e} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const content = e.kind === 'goal' ? <GoalCard e={e} /> : <MinorChip e={e} />;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`relative z-10 grid grid-cols-[minmax(0,1fr)_76px_minmax(0,1fr)] items-center gap-1.5 ${e.kind === 'goal' ? 'my-3.5' : 'my-2'}`}
|
||||
>
|
||||
<div className="flex justify-end">{e.side === 'home' && content}</div>
|
||||
<div className="flex justify-center"><MinutePill e={e} /></div>
|
||||
<div className="flex justify-start">{e.side === 'away' && content}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Phones: single left rail, side shown by the colored card edge */}
|
||||
<div className="relative py-3 pl-2 pr-3.5 sm:hidden">
|
||||
<div aria-hidden className="absolute bottom-0 left-[35px] top-0 w-0.5" style={{ background: SPINE_GRADIENT }} />
|
||||
{rows.map((row, i) => {
|
||||
if (row.t === 'divider') {
|
||||
return (
|
||||
<div key={i} className="relative z-10 my-2 flex items-center gap-3">
|
||||
<DividerPill row={row} />
|
||||
<span className="h-px flex-1 bg-line" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const e = row.e;
|
||||
return (
|
||||
<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 />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useParams } from '@tanstack/react-router';
|
||||
import { ArrowLeft, ArrowRightLeft, CircleDot, Shield, Shirt, Volleyball } from 'lucide-react';
|
||||
import { ArrowLeft, ArrowRight, Shield, Shirt } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
|
||||
import { WinProbBar } from '@/components/WinProbBar';
|
||||
@@ -10,24 +10,17 @@ import { FormChips } from '@/components/FormChips';
|
||||
import { XgRace } from '@/components/XgRace';
|
||||
import { ShotMap } from '@/components/ShotMap';
|
||||
import { MomentumStrip } from '@/components/MomentumStrip';
|
||||
import { EventIcon, MatchTimeline } from '@/components/MatchTimeline';
|
||||
import { teamFlag } from '@/lib/teams';
|
||||
import { fmt, useFormat, useT } from '@/lib/i18n';
|
||||
import { goalEvents } from '@/lib/matchEvents';
|
||||
import { inPlayProbs } from '@/lib/model/inplay';
|
||||
import { useTournamentStore } from '@/stores/tournamentStore';
|
||||
import type { Fixture, KeyPlayer, MatchPreview, MatchRich, OddsPoint } from '@/lib/types';
|
||||
|
||||
const num = (s: string | undefined) => (s ? Number(s.replace('%', '')) || 0 : 0);
|
||||
|
||||
/** Crisp event markers instead of emoji: ball icon for goals, card-shaped
|
||||
* color chips for bookings, arrows for substitutions. */
|
||||
function EventIcon({ type }: { type: string }) {
|
||||
const t = type.toLowerCase();
|
||||
if (t.includes('goal') || t.includes('penalty - scored')) return <Volleyball size={14} className="shrink-0 text-accent" />;
|
||||
if (t.includes('yellow')) return <span className="inline-block h-3.5 w-2.5 shrink-0 rounded-[2px] bg-amber-400" />;
|
||||
if (t.includes('red')) return <span className="inline-block h-3.5 w-2.5 shrink-0 rounded-[2px] bg-red-500" />;
|
||||
if (t.includes('substitution')) return <ArrowRightLeft size={13} className="shrink-0 text-muted" />;
|
||||
return <CircleDot size={10} className="shrink-0 text-faint" />;
|
||||
}
|
||||
type TabKey = 'summary' | 'timeline' | 'stats' | 'lineups';
|
||||
|
||||
function LiveStatRow({ label, home, away }: { label: string; home: number; away: number }) {
|
||||
const total = home + away || 1;
|
||||
@@ -84,6 +77,16 @@ function LineupCol({ team, players }: { team: string; players: { name: string; p
|
||||
);
|
||||
}
|
||||
|
||||
/** Hero stat chip: faint label · mono value. */
|
||||
function HeroChip({ label, value, gold }: { label: string; value: string; gold?: boolean }) {
|
||||
return (
|
||||
<span className="inline-flex max-w-full items-center gap-1.5 rounded-full bg-elevated px-3 py-1 text-xs">
|
||||
<span className="shrink-0 text-faint">{label}</span>
|
||||
<span className={`tnum truncate ${gold ? 'text-gold' : 'text-ink-soft'}`}>{value}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function MatchPreviewPage() {
|
||||
const { num: numParam } = useParams({ strict: false }) as { num: string };
|
||||
const { t, locale, kickoffDay, kickoffTime } = useFormat();
|
||||
@@ -92,6 +95,7 @@ export function MatchPreviewPage() {
|
||||
const [oddsHistory, setOddsHistory] = useState<OddsPoint[]>([]);
|
||||
const [rich, setRich] = useState<MatchRich | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('summary');
|
||||
|
||||
// Live score/minute arrive over the WebSocket snapshot — overlay them on the
|
||||
// (less-frequently-fetched) preview.
|
||||
@@ -104,6 +108,7 @@ export function MatchPreviewPage() {
|
||||
setTimeline([]);
|
||||
setRich(null);
|
||||
setFailed(false);
|
||||
setActiveTab('summary');
|
||||
const load = () =>
|
||||
fetch(`/api/preview/${numParam}`)
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject()))
|
||||
@@ -153,8 +158,10 @@ export function MatchPreviewPage() {
|
||||
['saves', t.match.stats.saves],
|
||||
], [t]);
|
||||
|
||||
const goals = useMemo(() => (preview ? goalEvents(preview.events) : []), [preview]);
|
||||
|
||||
if (failed) return <div className="py-16 text-center text-muted">{t.match.loadError} <Link to="/" className="text-accent">{t.match.backToLive}</Link></div>;
|
||||
if (!preview || !f) return <div className="h-96 animate-pulse rounded-xl border border-line bg-panel" />;
|
||||
if (!preview || !f) return <div className="h-96 animate-pulse rounded-2xl border border-line bg-panel" />;
|
||||
|
||||
const hasScore = f.status === 'live' || f.status === 'finished';
|
||||
const homeName = f.home.label;
|
||||
@@ -164,43 +171,94 @@ export function MatchPreviewPage() {
|
||||
const hasOddsChart = oddsHistory.filter((o) => o.home_ml != null && o.draw_ml != null && o.away_ml != null && o.captured_at <= koT).length >= 2;
|
||||
|
||||
// Hero extras from the rich capture: kickoff-hour weather (pre-match only)
|
||||
// and the official stadium/attendance/referee line.
|
||||
// and post-match official chips (attendance, referee, player of the match).
|
||||
const wx = f.status === 'scheduled' ? rich?.weather : null;
|
||||
const weatherText = wx && wx.tempC != null && wx.precipProbPct != null && wx.windKmh != null
|
||||
? fmt(t.match.weatherLine, { temp: Math.round(wx.tempC), precip: Math.round(wx.precipProbPct), wind: Math.round(wx.windKmh) })
|
||||
: null;
|
||||
const altitudeM = wx && wx.elevationM >= 1000 ? Math.round(wx.elevationM) : null;
|
||||
const infoText = rich?.info && rich.info.stadium && rich.info.attendance != null && rich.info.referee
|
||||
const infoText = rich?.info && rich.info.stadium && rich.info.attendance != null && rich.info.referee && f.status === 'scheduled'
|
||||
? fmt(t.match.attendanceLine, { stadium: rich.info.stadium, n: rich.info.attendance.toLocaleString(locale), ref: rich.info.referee })
|
||||
: null;
|
||||
|
||||
const heroChips: { label: string; value: string; gold?: boolean }[] = [];
|
||||
if (hasScore) {
|
||||
if (rich?.xg) heroChips.push({ label: 'xG', value: `${rich.xg.home.toFixed(2)}–${rich.xg.away.toFixed(2)}` });
|
||||
if (isLive && preview.liveStats) {
|
||||
const ls = preview.liveStats;
|
||||
if (ls.home.possessionPct != null || ls.away.possessionPct != null) {
|
||||
heroChips.push({ label: t.match.chips.poss, value: `${num(ls.home.possessionPct)}%–${num(ls.away.possessionPct)}%` });
|
||||
}
|
||||
if (ls.home.totalShots != null || ls.away.totalShots != null) {
|
||||
heroChips.push({ label: t.match.chips.shots, value: `${num(ls.home.totalShots)}–${num(ls.away.totalShots)}` });
|
||||
}
|
||||
}
|
||||
if (finished) {
|
||||
if (rich?.potm) heroChips.push({ label: t.match.potm, value: rich.potm.rating != null ? `${rich.potm.name} · ${rich.potm.rating}` : rich.potm.name, gold: true });
|
||||
if (rich?.info?.attendance != null) heroChips.push({ label: t.match.chips.att, value: rich.info.attendance.toLocaleString(locale) });
|
||||
if (rich?.info?.referee) heroChips.push({ label: t.match.chips.ref, value: rich.info.referee });
|
||||
}
|
||||
}
|
||||
|
||||
// Tabs only appear when their data exists (e.g. no Timeline before kickoff).
|
||||
const tabs: TabKey[] = ['summary'];
|
||||
if (preview.events.length > 0) tabs.push('timeline');
|
||||
if (preview.liveStats || (rich && (rich.shots.length >= 3 || rich.momentum.length >= 10))) tabs.push('stats');
|
||||
tabs.push('lineups');
|
||||
const tab: TabKey = tabs.includes(activeTab) ? activeTab : 'summary';
|
||||
|
||||
const modelCard = preview.prediction && (
|
||||
<Card>
|
||||
<CardHeader><span className="font-display font-bold text-ink">{hasScore ? t.match.preMatchModel : t.match.modelExpectation}</span></CardHeader>
|
||||
<CardBody className="space-y-2">
|
||||
<WinProbBar home={preview.prediction.home} away={preview.prediction.away} probs={preview.prediction.probs} topScore={preview.prediction.topScore} />
|
||||
<p className="text-xs text-faint">{fmt(t.match.expectedGoalsLine, { home: preview.prediction.lambdaHome.toFixed(2), away: preview.prediction.lambdaAway.toFixed(2) })}</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<Link to="/" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-ink"><ArrowLeft size={15} /> {t.common.back}</Link>
|
||||
|
||||
{/* hero */}
|
||||
<Card className={isLive ? 'border-live/40 ring-1 ring-live/30' : ''}>
|
||||
<CardBody>
|
||||
<div className="mb-3 text-center text-xs uppercase tracking-wide text-faint">
|
||||
{f.group ? fmt(t.common.group, { group: f.group }) : t.stage[f.stage]} · {kickoffDay(f.kickoff)} · {kickoffTime(f.kickoff)} · {preview.venue}
|
||||
{/* hero scoreboard */}
|
||||
<div className={`relative overflow-hidden rounded-2xl border bg-gradient-to-b from-panel to-surface p-6 md:p-8 ${isLive ? 'border-live/40 ring-1 ring-live/30' : 'border-line'}`}>
|
||||
<div aria-hidden className="pointer-events-none absolute inset-0 bg-[radial-gradient(120%_90%_at_50%_-20%,var(--app-accent-glow),transparent_60%)]" />
|
||||
<div className="relative">
|
||||
<div className="smallcaps text-center">
|
||||
{f.group ? fmt(t.common.group, { group: f.group }) : t.stage[f.stage]}
|
||||
<span className="text-line-strong"> · </span>{kickoffDay(f.kickoff)}
|
||||
<span className="text-line-strong"> · </span>{kickoffTime(f.kickoff)}
|
||||
<span className="text-line-strong"> · </span>{preview.venue}
|
||||
</div>
|
||||
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-3">
|
||||
<Link to="/team/$name" params={{ name: f.home.team ?? '' }} className="flex flex-col items-center gap-1 hover:opacity-80">
|
||||
<span aria-hidden className="grid h-10 place-items-center text-4xl">{f.home.team ? teamFlag(f.home.team) : <Shield size={32} className="text-faint" />}</span>
|
||||
<span className="text-center text-sm font-semibold text-ink">{homeName}</span>
|
||||
<div className="mt-5 grid grid-cols-[1fr_auto_1fr] items-center gap-2 md:gap-6">
|
||||
<Link to="/team/$name" params={{ name: f.home.team ?? '' }} className="flex flex-col items-center gap-1.5 hover:opacity-80">
|
||||
<span aria-hidden className="text-4xl md:text-6xl">{f.home.team ? teamFlag(f.home.team) : <Shield size={40} className="text-faint" />}</span>
|
||||
<span className="text-center text-[15px] font-bold text-ink md:text-lg">{homeName}</span>
|
||||
</Link>
|
||||
<div className="text-center">
|
||||
{hasScore ? <div className="tnum text-3xl font-extrabold text-ink">{f.homeScore ?? 0}–{f.awayScore ?? 0}</div> : <div className="tnum text-lg font-bold text-muted">{kickoffTime(f.kickoff)}</div>}
|
||||
{isLive && <div className="inline-flex items-center gap-1 text-[11px] font-bold uppercase text-live"><span className="live-dot h-1.5 w-1.5 rounded-full bg-live" />{f.minute ? fmt(t.live.minute, { n: f.minute }) : t.common.liveNow}</div>}
|
||||
{finished && <div className="text-[11px] font-bold uppercase text-faint">{t.common.fullTime}</div>}
|
||||
{hasScore ? (
|
||||
<div className="tnum text-[40px] font-semibold leading-none tracking-[-0.04em] text-ink md:text-6xl">
|
||||
{f.homeScore ?? 0}<span className="text-[0.5em] text-line-strong">–</span>{f.awayScore ?? 0}
|
||||
</div>
|
||||
) : (
|
||||
<div className="tnum text-2xl font-bold text-muted md:text-3xl">{kickoffTime(f.kickoff)}</div>
|
||||
)}
|
||||
{isLive && <div className="mt-2 inline-flex items-center gap-1.5 text-[11px] font-bold uppercase text-live"><span className="live-dot h-1.5 w-1.5 rounded-full bg-live" />{f.minute ? fmt(t.live.minute, { n: f.minute }) : t.common.liveNow}</div>}
|
||||
{finished && <div className="mt-2 text-[11px] font-bold uppercase tracking-wider text-faint">{t.common.fullTime}</div>}
|
||||
</div>
|
||||
<Link to="/team/$name" params={{ name: f.away.team ?? '' }} className="flex flex-col items-center gap-1 hover:opacity-80">
|
||||
<span aria-hidden className="grid h-10 place-items-center text-4xl">{f.away.team ? teamFlag(f.away.team) : <Shield size={32} className="text-faint" />}</span>
|
||||
<span className="text-center text-sm font-semibold text-ink">{awayName}</span>
|
||||
<Link to="/team/$name" params={{ name: f.away.team ?? '' }} className="flex flex-col items-center gap-1.5 hover:opacity-80">
|
||||
<span aria-hidden className="text-4xl md:text-6xl">{f.away.team ? teamFlag(f.away.team) : <Shield size={40} className="text-faint" />}</span>
|
||||
<span className="text-center text-[15px] font-bold text-ink md:text-lg">{awayName}</span>
|
||||
</Link>
|
||||
</div>
|
||||
{heroChips.length > 0 && (
|
||||
<div className="mt-5 flex flex-wrap items-center justify-center gap-2 border-t border-line pt-4">
|
||||
{heroChips.map((c) => <HeroChip key={c.label} label={c.label} value={c.value} gold={c.gold ?? false} />)}
|
||||
</div>
|
||||
)}
|
||||
{(weatherText || infoText) && (
|
||||
<div className="mt-3 space-y-1 border-t border-line pt-2 text-center text-xs text-muted">
|
||||
<div className="mt-5 space-y-1 border-t border-line pt-3 text-center text-xs text-muted">
|
||||
{weatherText && (
|
||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||
<span>{weatherText}</span>
|
||||
@@ -210,180 +268,199 @@ export function MatchPreviewPage() {
|
||||
{infoText && <div>{infoText}</div>}
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* LIVE: in-play win probability */}
|
||||
{inPlay && preview.prediction && (
|
||||
<Card className="border-live/30">
|
||||
<CardHeader className="flex items-center gap-2">
|
||||
<span className="live-dot h-2 w-2 rounded-full bg-live" />
|
||||
<span className="font-display font-bold text-ink">{t.match.liveWinProbability}</span>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<WinProbBar home={homeName} away={awayName} probs={inPlay} />
|
||||
{timeline.length >= 2 && (
|
||||
<div className="mt-4">
|
||||
{/* sticky sub-nav */}
|
||||
<div className="sticky top-14 z-20 bg-surface/90 py-1 backdrop-blur">
|
||||
<div className="flex gap-1 overflow-x-auto rounded-xl border border-line bg-panel p-1" role="tablist">
|
||||
{tabs.map((k) => (
|
||||
<button
|
||||
key={k}
|
||||
role="tab"
|
||||
aria-selected={tab === k}
|
||||
onClick={() => setActiveTab(k)}
|
||||
className={`flex-1 whitespace-nowrap rounded-lg px-3 py-2 text-sm font-semibold transition-colors ${tab === k ? 'bg-panel-2 text-ink shadow-sm' : 'text-muted hover:text-ink-soft'}`}
|
||||
>
|
||||
{t.match.tabs[k]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Summary ── */}
|
||||
{tab === 'summary' && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid items-start gap-4 md:grid-cols-2">
|
||||
{inPlay && preview.prediction ? (
|
||||
<Card className="border-live/30">
|
||||
<CardHeader className="flex items-center gap-2">
|
||||
<span className="live-dot h-2 w-2 rounded-full bg-live" />
|
||||
<span className="font-display font-bold text-ink">{t.match.liveWinProbability}</span>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<WinProbBar home={homeName} away={awayName} probs={inPlay} />
|
||||
{timeline.length >= 2 && (
|
||||
<div className="mt-4">
|
||||
<WinProbTimeline points={timeline} home={homeName} away={awayName} />
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-faint">{t.match.inPlayHelp}</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
) : modelCard}
|
||||
{preview.events.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader><span className="font-display font-bold text-ink">{t.match.keyMoments}</span></CardHeader>
|
||||
<CardBody>
|
||||
{goals.length ? (
|
||||
<div className="space-y-2.5">
|
||||
{goals.map((g, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-sm">
|
||||
<span className="tnum w-8 shrink-0 text-xs text-faint">{g.minute != null ? fmt(t.live.minute, { n: g.minute }) : ''}</span>
|
||||
<span aria-hidden className={`h-1.5 w-1.5 shrink-0 rounded-full ${g.side === 'away' ? 'bg-info' : 'bg-accent'}`} />
|
||||
<EventIcon kind="goal" />
|
||||
<span className="truncate font-medium text-ink-soft">{g.title}</span>
|
||||
{g.score && <span className="tnum ml-auto shrink-0 text-xs text-faint">{g.score[0]}–{g.score[1]}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-faint">{t.match.noGoalsYet}</p>
|
||||
)}
|
||||
<button onClick={() => setActiveTab('timeline')} className="mt-3 inline-flex items-center gap-1 text-sm font-semibold text-accent hover:underline">
|
||||
{t.match.viewTimeline} <ArrowRight size={14} />
|
||||
</button>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
{/* live matches keep the pre-match view alongside the in-play one */}
|
||||
{inPlay && preview.prediction && modelCard}
|
||||
{finished && timeline.length >= 2 && (
|
||||
<Card>
|
||||
<CardHeader><span className="font-display font-bold text-ink">{t.match.momentum}</span></CardHeader>
|
||||
<CardBody>
|
||||
<WinProbTimeline points={timeline} home={homeName} away={awayName} />
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-faint">{t.match.inPlayHelp}</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* FT: how the win probability swung over the match */}
|
||||
{finished && timeline.length >= 2 && (
|
||||
<Card>
|
||||
<CardHeader><span className="font-display font-bold text-ink">{t.match.momentum}</span></CardHeader>
|
||||
<CardBody>
|
||||
<WinProbTimeline points={timeline} home={homeName} away={awayName} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* LIVE/FT: cumulative xG race + shot map from the captured shot list */}
|
||||
{rich && rich.shots.length >= 3 && (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader className="flex items-center justify-between">
|
||||
<span className="font-display font-bold text-ink">{t.match.xgRace}</span>
|
||||
{rich.xg && (
|
||||
<span className="tnum text-sm font-bold">
|
||||
<span className="text-accent">{rich.xg.home.toFixed(2)}</span>
|
||||
<span className="font-normal text-faint"> – </span>
|
||||
<span className="text-info">{rich.xg.away.toFixed(2)}</span>
|
||||
</span>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<XgRace shots={rich.shots} home={homeName} away={awayName} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader><span className="font-display font-bold text-ink">{t.match.shotMap}</span></CardHeader>
|
||||
<CardBody>
|
||||
<ShotMap shots={rich.shots} home={homeName} away={awayName} />
|
||||
<p className="mt-2 text-xs text-faint">{t.match.xgAttribution}</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* LIVE/FT: attack momentum */}
|
||||
{rich && rich.momentum.length >= 10 && (
|
||||
<Card>
|
||||
<CardHeader><span className="font-display font-bold text-ink">{t.match.momentumTitle}</span></CardHeader>
|
||||
<CardBody>
|
||||
<MomentumStrip points={rich.momentum} home={homeName} away={awayName} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* LIVE/FT: stats */}
|
||||
{preview.liveStats && (
|
||||
<Card>
|
||||
<CardHeader><span className="font-display font-bold text-ink">{t.match.statsTitle}</span></CardHeader>
|
||||
<CardBody className="space-y-3">
|
||||
{statRows.filter(([k]) => preview.liveStats!.home[k] != null || preview.liveStats!.away[k] != null).map(([k, label]) => (
|
||||
<LiveStatRow key={k} label={label} home={num(preview.liveStats!.home[k])} away={num(preview.liveStats!.away[k])} />
|
||||
))}
|
||||
{rich?.potm && (
|
||||
<div className="flex items-center justify-between border-t border-line pt-3">
|
||||
<span className="text-xs uppercase tracking-wide text-faint">{t.match.potm}</span>
|
||||
<span className="flex items-center gap-2 text-sm font-semibold text-ink">
|
||||
{rich.potm.name}
|
||||
{rich.potm.rating != null && <Badge tone="gold">{rich.potm.rating}</Badge>}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* LIVE/FT: timeline */}
|
||||
{preview.events.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader><span className="font-display font-bold text-ink">{t.match.timeline}</span></CardHeader>
|
||||
<CardBody>
|
||||
<ul className="space-y-1.5">
|
||||
{preview.events.map((e, i) => (
|
||||
<li key={i} className={`flex items-center gap-2 text-sm ${e.team === 'away' ? 'flex-row-reverse text-right' : ''}`}>
|
||||
<span className="tnum w-8 shrink-0 text-xs text-faint">{e.minute != null ? fmt(t.live.minute, { n: e.minute }) : ''}</span>
|
||||
<span aria-hidden className="grid w-4 shrink-0 place-items-center"><EventIcon type={e.type} /></span>
|
||||
<span className="truncate text-ink-soft">{e.text || e.type}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* model expectation */}
|
||||
{preview.prediction && (
|
||||
<Card>
|
||||
<CardHeader><span className="font-display font-bold text-ink">{hasScore ? t.match.preMatchModel : t.match.modelExpectation}</span></CardHeader>
|
||||
<CardBody className="space-y-2">
|
||||
<WinProbBar home={preview.prediction.home} away={preview.prediction.away} probs={preview.prediction.probs} topScore={preview.prediction.topScore} />
|
||||
<p className="text-xs text-faint">{fmt(t.match.expectedGoalsLine, { home: preview.prediction.lambdaHome.toFixed(2), away: preview.prediction.lambdaAway.toFixed(2) })}</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* bookmaker line movement vs the model (benchmark only) */}
|
||||
{preview.prediction && hasOddsChart && (
|
||||
<Card>
|
||||
<CardHeader><span className="font-display font-bold text-ink">{t.match.oddsMovement}</span></CardHeader>
|
||||
<CardBody>
|
||||
<OddsMovement history={oddsHistory} model={preview.prediction.probs} kickoff={f.kickoff} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* form */}
|
||||
{(preview.form.home.length > 0 || preview.form.away.length > 0) && (
|
||||
<Card>
|
||||
<CardHeader><span className="font-display font-bold text-ink">{t.match.recentForm}</span></CardHeader>
|
||||
<CardBody className="space-y-3">
|
||||
<div className="flex items-center justify-between"><span className="flex items-center gap-2 text-sm font-medium text-ink">{f.home.team && teamFlag(f.home.team)} {homeName}</span><FormChips form={preview.form.home} /></div>
|
||||
<div className="flex items-center justify-between"><span className="flex items-center gap-2 text-sm font-medium text-ink">{f.away.team && teamFlag(f.away.team)} {awayName}</span><FormChips form={preview.form.away} /></div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* head to head */}
|
||||
{preview.h2h && (
|
||||
<Card>
|
||||
<CardHeader><span className="font-display font-bold text-ink">{t.match.headToHead}</span></CardHeader>
|
||||
<CardBody>
|
||||
<div className="mb-3 grid grid-cols-3 items-center text-center">
|
||||
<div><div className="tnum text-2xl font-extrabold text-accent">{preview.h2h.homeWins}</div><div className="text-[11px] text-faint">{fmt(t.match.h2h.teamWins, { team: homeName })}</div></div>
|
||||
<div><div className="tnum text-2xl font-extrabold text-muted">{preview.h2h.draws}</div><div className="text-[11px] text-faint">{fmt(t.match.h2h.drawsTotal, { games: preview.h2h.games })}</div></div>
|
||||
<div><div className="tnum text-2xl font-extrabold text-info">{preview.h2h.awayWins}</div><div className="text-[11px] text-faint">{fmt(t.match.h2h.teamWins, { team: awayName })}</div></div>
|
||||
</div>
|
||||
<div className="border-t border-line pt-2">
|
||||
<div className="mb-1 text-[11px] uppercase tracking-wide text-faint">{t.match.h2h.recentMeetings}</div>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{preview.h2h.last.map((m, i) => (
|
||||
<li key={i} className="flex items-center justify-between text-ink-soft"><span className="text-faint">{m.date.slice(0, 10)}</span><span>{m.home} <span className="tnum font-semibold text-ink">{m.homeScore}–{m.awayScore}</span> {m.away}</span></li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* lineups or key players */}
|
||||
<Card>
|
||||
<CardHeader className="flex items-center gap-2"><Shirt size={16} className="text-accent" /><span className="font-display font-bold text-ink">{preview.lineups ? t.match.lineups : t.match.keyPlayers}</span></CardHeader>
|
||||
<CardBody className="grid grid-cols-2 gap-6">
|
||||
{preview.lineups ? (
|
||||
<><LineupCol team={homeName} players={preview.lineups.home} /><LineupCol team={awayName} players={preview.lineups.away} /></>
|
||||
) : (
|
||||
<><ScorerList team={homeName} players={preview.keyPlayers.home} /><ScorerList team={awayName} players={preview.keyPlayers.away} /></>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
{preview.prediction && hasOddsChart && (
|
||||
<Card>
|
||||
<CardHeader><span className="font-display font-bold text-ink">{t.match.oddsMovement}</span></CardHeader>
|
||||
<CardBody>
|
||||
<OddsMovement history={oddsHistory} model={preview.prediction.probs} kickoff={f.kickoff} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Timeline ── */}
|
||||
{tab === 'timeline' && preview.events.length > 0 && (
|
||||
<Card>
|
||||
<CardBody className="px-2 sm:px-4">
|
||||
<MatchTimeline events={preview.events} status={f.status} minute={f.minute} homeScore={f.homeScore} awayScore={f.awayScore} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── Stats ── */}
|
||||
{tab === 'stats' && (
|
||||
<div className="grid items-start gap-4 md:grid-cols-2">
|
||||
{preview.liveStats && (
|
||||
<Card>
|
||||
<CardHeader><span className="font-display font-bold text-ink">{t.match.statsTitle}</span></CardHeader>
|
||||
<CardBody className="space-y-3">
|
||||
{statRows.filter(([k]) => preview.liveStats!.home[k] != null || preview.liveStats!.away[k] != null).map(([k, label]) => (
|
||||
<LiveStatRow key={k} label={label} home={num(preview.liveStats!.home[k])} away={num(preview.liveStats!.away[k])} />
|
||||
))}
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
{rich && rich.momentum.length >= 10 && (
|
||||
<Card>
|
||||
<CardHeader><span className="font-display font-bold text-ink">{t.match.momentumTitle}</span></CardHeader>
|
||||
<CardBody>
|
||||
<MomentumStrip points={rich.momentum} home={homeName} away={awayName} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
{rich && rich.shots.length >= 3 && (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader className="flex items-center justify-between">
|
||||
<span className="font-display font-bold text-ink">{t.match.xgRace}</span>
|
||||
{rich.xg && (
|
||||
<span className="tnum text-sm font-bold">
|
||||
<span className="text-accent">{rich.xg.home.toFixed(2)}</span>
|
||||
<span className="font-normal text-faint"> – </span>
|
||||
<span className="text-info">{rich.xg.away.toFixed(2)}</span>
|
||||
</span>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<XgRace shots={rich.shots} home={homeName} away={awayName} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader><span className="font-display font-bold text-ink">{t.match.shotMap}</span></CardHeader>
|
||||
<CardBody>
|
||||
<ShotMap shots={rich.shots} home={homeName} away={awayName} />
|
||||
<p className="mt-2 text-xs text-faint">{t.match.xgAttribution}</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Lineups ── */}
|
||||
{tab === 'lineups' && (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader className="flex items-center gap-2"><Shirt size={16} className="text-accent" /><span className="font-display font-bold text-ink">{preview.lineups ? t.match.lineups : t.match.keyPlayers}</span></CardHeader>
|
||||
<CardBody className="grid grid-cols-2 gap-6">
|
||||
{preview.lineups ? (
|
||||
<><LineupCol team={homeName} players={preview.lineups.home} /><LineupCol team={awayName} players={preview.lineups.away} /></>
|
||||
) : (
|
||||
<><ScorerList team={homeName} players={preview.keyPlayers.home} /><ScorerList team={awayName} players={preview.keyPlayers.away} /></>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
<div className="grid items-start gap-4 md:grid-cols-2">
|
||||
{(preview.form.home.length > 0 || preview.form.away.length > 0) && (
|
||||
<Card>
|
||||
<CardHeader><span className="font-display font-bold text-ink">{t.match.recentForm}</span></CardHeader>
|
||||
<CardBody className="space-y-3">
|
||||
<div className="flex items-center justify-between"><span className="flex items-center gap-2 text-sm font-medium text-ink">{f.home.team && teamFlag(f.home.team)} {homeName}</span><FormChips form={preview.form.home} /></div>
|
||||
<div className="flex items-center justify-between"><span className="flex items-center gap-2 text-sm font-medium text-ink">{f.away.team && teamFlag(f.away.team)} {awayName}</span><FormChips form={preview.form.away} /></div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
{preview.h2h && (
|
||||
<Card>
|
||||
<CardHeader><span className="font-display font-bold text-ink">{t.match.headToHead}</span></CardHeader>
|
||||
<CardBody>
|
||||
<div className="mb-3 grid grid-cols-3 items-center text-center">
|
||||
<div><div className="tnum text-2xl font-extrabold text-accent">{preview.h2h.homeWins}</div><div className="text-[11px] text-faint">{fmt(t.match.h2h.teamWins, { team: homeName })}</div></div>
|
||||
<div><div className="tnum text-2xl font-extrabold text-muted">{preview.h2h.draws}</div><div className="text-[11px] text-faint">{fmt(t.match.h2h.drawsTotal, { games: preview.h2h.games })}</div></div>
|
||||
<div><div className="tnum text-2xl font-extrabold text-info">{preview.h2h.awayWins}</div><div className="text-[11px] text-faint">{fmt(t.match.h2h.teamWins, { team: awayName })}</div></div>
|
||||
</div>
|
||||
<div className="border-t border-line pt-2">
|
||||
<div className="mb-1 text-[11px] uppercase tracking-wide text-faint">{t.match.h2h.recentMeetings}</div>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{preview.h2h.last.map((m, i) => (
|
||||
<li key={i} className="flex items-center justify-between text-ink-soft"><span className="text-faint">{m.date.slice(0, 10)}</span><span>{m.home} <span className="tnum font-semibold text-ink">{m.homeScore}–{m.awayScore}</span> {m.away}</span></li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-center text-xs text-faint">{preview.dataNote}</p>
|
||||
</div>
|
||||
|
||||
@@ -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