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:
2026-06-12 11:19:16 +02:00
parent b823bef2d0
commit b9b79df51e
9 changed files with 888 additions and 201 deletions
+121
View File
@@ -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();