diff --git a/data/archive/cup26-2026-06-11.db b/data/archive/cup26-2026-06-11.db new file mode 100644 index 0000000..a522722 Binary files /dev/null and b/data/archive/cup26-2026-06-11.db differ diff --git a/data/archive/cup26-2026-06-12.db b/data/archive/cup26-2026-06-12.db new file mode 100644 index 0000000..f6ca052 Binary files /dev/null and b/data/archive/cup26-2026-06-12.db differ diff --git a/scripts/backfillFotmob.ts b/scripts/backfillFotmob.ts new file mode 100644 index 0000000..2407cf5 --- /dev/null +++ b/scripts/backfillFotmob.ts @@ -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 }).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(url: string): Promise { + 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 { + 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 { + mkdirSync(LAKE, { recursive: true }); + const done: Record = 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(`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(); diff --git a/src/components/MatchTimeline.tsx b/src/components/MatchTimeline.tsx new file mode 100644 index 0000000..57f8b2b --- /dev/null +++ b/src/components/MatchTimeline.tsx @@ -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 ; + if (kind === 'yellow') return ; + if (kind === 'red') return ; + if (kind === 'sub') return ; + return ; +} + +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 ( + + {e.minute != null ? fmt(t.live.minute, { n: e.minute }) : '·'} + + ); +} + +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 ( +
+
+ + {own ? t.match.ownGoal : t.match.goal} + + {e.score && ( + + {e.score[0]}–{e.score[1]} + + )} +
+
{e.title}
+ {detailText &&
{detailText}
} +
+ ); +} + +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 ( + + + {e.title} + {e.subOut && {e.subOut}} + + ); +} + +/** Neutral happenings (delays, VAR…) sit on the spine like a soft divider. */ +function NeutralChip({ e, showMinute }: { e: ParsedEvent; showMinute?: boolean }) { + const t = useT(); + return ( + + {showMinute !== false && e.minute != null && {fmt(t.live.minute, { n: e.minute })} · } + {e.title} + + ); +} + +function DividerPill({ row }: { row: Extract }) { + return ( + + {row.live && } + {row.label} + {row.score && {row.score}} + + ); +} + +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(() => { + 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 ( +
+ {/* Desktop / tablet: center spine, home left · away right */} +
+
+ {rows.map((row, i) => { + if (row.t === 'divider') { + return ( +
+ + + +
+ ); + } + const e = row.e; + if (e.side === null) { + return ( +
+ +
+ ); + } + const content = e.kind === 'goal' ? : ; + return ( +
+
{e.side === 'home' && content}
+
+
{e.side === 'away' && content}
+
+ ); + })} +
+ + {/* Phones: single left rail, side shown by the colored card edge */} +
+
+ {rows.map((row, i) => { + if (row.t === 'divider') { + return ( +
+ + +
+ ); + } + const e = row.e; + return ( +
+
+
+ {e.side === null ? : e.kind === 'goal' ? : } +
+
+ ); + })} +
+
+ ); +} diff --git a/src/features/match/MatchPreviewPage.tsx b/src/features/match/MatchPreviewPage.tsx index b853ebf..1ebde41 100644 --- a/src/features/match/MatchPreviewPage.tsx +++ b/src/features/match/MatchPreviewPage.tsx @@ -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 ; - if (t.includes('yellow')) return ; - if (t.includes('red')) return ; - if (t.includes('substitution')) return ; - return ; -} +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 ( + + {label} + {value} + + ); +} + 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([]); const [rich, setRich] = useState(null); const [failed, setFailed] = useState(false); + const [activeTab, setActiveTab] = useState('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
{t.match.loadError} {t.match.backToLive}
; - if (!preview || !f) return
; + if (!preview || !f) return
; 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 && ( + + {hasScore ? t.match.preMatchModel : t.match.modelExpectation} + + +

{fmt(t.match.expectedGoalsLine, { home: preview.prediction.lambdaHome.toFixed(2), away: preview.prediction.lambdaAway.toFixed(2) })}

+
+
+ ); + return (
{t.common.back} - {/* hero */} - - -
- {f.group ? fmt(t.common.group, { group: f.group }) : t.stage[f.stage]} · {kickoffDay(f.kickoff)} · {kickoffTime(f.kickoff)} · {preview.venue} + {/* hero scoreboard */} +
+
+
+
+ {f.group ? fmt(t.common.group, { group: f.group }) : t.stage[f.stage]} + · {kickoffDay(f.kickoff)} + · {kickoffTime(f.kickoff)} + · {preview.venue}
-
- - {f.home.team ? teamFlag(f.home.team) : } - {homeName} +
+ + {f.home.team ? teamFlag(f.home.team) : } + {homeName}
- {hasScore ?
{f.homeScore ?? 0}–{f.awayScore ?? 0}
:
{kickoffTime(f.kickoff)}
} - {isLive &&
{f.minute ? fmt(t.live.minute, { n: f.minute }) : t.common.liveNow}
} - {finished &&
{t.common.fullTime}
} + {hasScore ? ( +
+ {f.homeScore ?? 0}{f.awayScore ?? 0} +
+ ) : ( +
{kickoffTime(f.kickoff)}
+ )} + {isLive &&
{f.minute ? fmt(t.live.minute, { n: f.minute }) : t.common.liveNow}
} + {finished &&
{t.common.fullTime}
}
- - {f.away.team ? teamFlag(f.away.team) : } - {awayName} + + {f.away.team ? teamFlag(f.away.team) : } + {awayName}
+ {heroChips.length > 0 && ( +
+ {heroChips.map((c) => )} +
+ )} {(weatherText || infoText) && ( -
+
{weatherText && (
{weatherText} @@ -210,180 +268,199 @@ export function MatchPreviewPage() { {infoText &&
{infoText}
}
)} - - +
+
- {/* LIVE: in-play win probability */} - {inPlay && preview.prediction && ( - - - - {t.match.liveWinProbability} - - - - {timeline.length >= 2 && ( -
+ {/* sticky sub-nav */} +
+
+ {tabs.map((k) => ( + + ))} +
+
+ + {/* ── Summary ── */} + {tab === 'summary' && ( +
+
+ {inPlay && preview.prediction ? ( + + + + {t.match.liveWinProbability} + + + + {timeline.length >= 2 && ( +
+ +
+ )} +

{t.match.inPlayHelp}

+
+
+ ) : modelCard} + {preview.events.length > 0 && ( + + {t.match.keyMoments} + + {goals.length ? ( +
+ {goals.map((g, i) => ( +
+ {g.minute != null ? fmt(t.live.minute, { n: g.minute }) : ''} + + + {g.title} + {g.score && {g.score[0]}–{g.score[1]}} +
+ ))} +
+ ) : ( +

{t.match.noGoalsYet}

+ )} + +
+
+ )} +
+ {/* live matches keep the pre-match view alongside the in-play one */} + {inPlay && preview.prediction && modelCard} + {finished && timeline.length >= 2 && ( + + {t.match.momentum} + -
- )} -

{t.match.inPlayHelp}

- - - )} - - {/* FT: how the win probability swung over the match */} - {finished && timeline.length >= 2 && ( - - {t.match.momentum} - - - - - )} - - {/* LIVE/FT: cumulative xG race + shot map from the captured shot list */} - {rich && rich.shots.length >= 3 && ( - <> - - - {t.match.xgRace} - {rich.xg && ( - - {rich.xg.home.toFixed(2)} - - {rich.xg.away.toFixed(2)} - - )} - - - - - - - {t.match.shotMap} - - -

{t.match.xgAttribution}

-
-
- - )} - - {/* LIVE/FT: attack momentum */} - {rich && rich.momentum.length >= 10 && ( - - {t.match.momentumTitle} - - - - - )} - - {/* LIVE/FT: stats */} - {preview.liveStats && ( - - {t.match.statsTitle} - - {statRows.filter(([k]) => preview.liveStats!.home[k] != null || preview.liveStats!.away[k] != null).map(([k, label]) => ( - - ))} - {rich?.potm && ( -
- {t.match.potm} - - {rich.potm.name} - {rich.potm.rating != null && {rich.potm.rating}} - -
- )} -
-
- )} - - {/* LIVE/FT: timeline */} - {preview.events.length > 0 && ( - - {t.match.timeline} - -
    - {preview.events.map((e, i) => ( -
  • - {e.minute != null ? fmt(t.live.minute, { n: e.minute }) : ''} - - {e.text || e.type} -
  • - ))} -
-
-
- )} - - {/* model expectation */} - {preview.prediction && ( - - {hasScore ? t.match.preMatchModel : t.match.modelExpectation} - - -

{fmt(t.match.expectedGoalsLine, { home: preview.prediction.lambdaHome.toFixed(2), away: preview.prediction.lambdaAway.toFixed(2) })}

-
-
- )} - - {/* bookmaker line movement vs the model (benchmark only) */} - {preview.prediction && hasOddsChart && ( - - {t.match.oddsMovement} - - - - - )} - - {/* form */} - {(preview.form.home.length > 0 || preview.form.away.length > 0) && ( - - {t.match.recentForm} - -
{f.home.team && teamFlag(f.home.team)} {homeName}
-
{f.away.team && teamFlag(f.away.team)} {awayName}
-
-
- )} - - {/* head to head */} - {preview.h2h && ( - - {t.match.headToHead} - -
-
{preview.h2h.homeWins}
{fmt(t.match.h2h.teamWins, { team: homeName })}
-
{preview.h2h.draws}
{fmt(t.match.h2h.drawsTotal, { games: preview.h2h.games })}
-
{preview.h2h.awayWins}
{fmt(t.match.h2h.teamWins, { team: awayName })}
-
-
-
{t.match.h2h.recentMeetings}
-
    - {preview.h2h.last.map((m, i) => ( -
  • {m.date.slice(0, 10)}{m.home} {m.homeScore}–{m.awayScore} {m.away}
  • - ))} -
-
-
-
- )} - - {/* lineups or key players */} - - {preview.lineups ? t.match.lineups : t.match.keyPlayers} - - {preview.lineups ? ( - <> - ) : ( - <> + + )} - - + {preview.prediction && hasOddsChart && ( + + {t.match.oddsMovement} + + + + + )} +
+ )} + + {/* ── Timeline ── */} + {tab === 'timeline' && preview.events.length > 0 && ( + + + + + + )} + + {/* ── Stats ── */} + {tab === 'stats' && ( +
+ {preview.liveStats && ( + + {t.match.statsTitle} + + {statRows.filter(([k]) => preview.liveStats!.home[k] != null || preview.liveStats!.away[k] != null).map(([k, label]) => ( + + ))} + + + )} + {rich && rich.momentum.length >= 10 && ( + + {t.match.momentumTitle} + + + + + )} + {rich && rich.shots.length >= 3 && ( + <> + + + {t.match.xgRace} + {rich.xg && ( + + {rich.xg.home.toFixed(2)} + + {rich.xg.away.toFixed(2)} + + )} + + + + + + + {t.match.shotMap} + + +

{t.match.xgAttribution}

+
+
+ + )} +
+ )} + + {/* ── Lineups ── */} + {tab === 'lineups' && ( +
+ + {preview.lineups ? t.match.lineups : t.match.keyPlayers} + + {preview.lineups ? ( + <> + ) : ( + <> + )} + + +
+ {(preview.form.home.length > 0 || preview.form.away.length > 0) && ( + + {t.match.recentForm} + +
{f.home.team && teamFlag(f.home.team)} {homeName}
+
{f.away.team && teamFlag(f.away.team)} {awayName}
+
+
+ )} + {preview.h2h && ( + + {t.match.headToHead} + +
+
{preview.h2h.homeWins}
{fmt(t.match.h2h.teamWins, { team: homeName })}
+
{preview.h2h.draws}
{fmt(t.match.h2h.drawsTotal, { games: preview.h2h.games })}
+
{preview.h2h.awayWins}
{fmt(t.match.h2h.teamWins, { team: awayName })}
+
+
+
{t.match.h2h.recentMeetings}
+
    + {preview.h2h.last.map((m, i) => ( +
  • {m.date.slice(0, 10)}{m.home} {m.homeScore}–{m.awayScore} {m.away}
  • + ))} +
+
+
+
+ )} +
+
+ )}

{preview.dataNote}

diff --git a/src/lib/i18n/de.ts b/src/lib/i18n/de.ts index aa03679..1d73341 100644 --- a/src/lib/i18n/de.ts +++ b/src/lib/i18n/de.ts @@ -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", diff --git a/src/lib/i18n/en.ts b/src/lib/i18n/en.ts index c5682b7..cfd1f85 100644 --- a/src/lib/i18n/en.ts +++ b/src/lib/i18n/en.ts @@ -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", diff --git a/src/lib/matchEvents.test.ts b/src/lib/matchEvents.test.ts new file mode 100644 index 0000000..db80e69 --- /dev/null +++ b/src/lib/matchEvents.test.ts @@ -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]); + }); +}); diff --git a/src/lib/matchEvents.ts b/src/lib/matchEvents.ts new file mode 100644 index 0000000..2a62c51 --- /dev/null +++ b/src/lib/matchEvents.ts @@ -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 { + 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 { + const m = /^([^(]+?)\s*\(/.exec(e.text); + return { title: m?.[1]?.trim() || e.text || e.type, detail: null }; +} + +function parseSub(e: MatchLiveEvent): Pick { + 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; +}