v2 Phase 2: match previews + team profiles

- buildPreviewData.ts: precomputes from 150y of results → h2h.json (825 WC-team
  pairings, full records + recent meetings) + scorers.json (top-10 all-time
  scorers per team). Committed goalscorers.csv.
- server/src/preview.ts: assembles MatchPreview / TeamProfile from three layers —
  historical (h2h, scorers), DB enrichment (ESPN form/lineups/venue), live model
  (W/D/L, xG, odds, Elo). /api/preview/:num + /api/team/:name.
- Client: rich /match/:num page (model expectation, recent form chips, H2H record
  + recent meetings, lineups-or-key-players) and /team/:name profile (Elo, odds,
  standing, fixtures, all-time scorers). Match cards now link to previews;
  cross-linking between matches and teams.
- Verified: Mexico-SA preview (81% W, 2-0, form, H2H 2-1-1, Borgetti 37) and
  Argentina profile (Messi 63) render; 22 tests pass; build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 15:34:54 +02:00
parent b6f62679c9
commit 7f4838d032
12 changed files with 48216 additions and 5 deletions
+26
View File
@@ -0,0 +1,26 @@
import { cn } from '@/lib/cn';
const tone: Record<string, string> = {
W: 'bg-win/20 text-win border-win/40',
D: 'bg-draw/20 text-draw border-draw/40',
L: 'bg-loss/20 text-loss border-loss/40',
};
/** Recent W/D/L results as coloured chips. Input is most-recent-first; we render
* oldest → newest (left to right) like a standard form guide. */
export function FormChips({ form, className }: { form: string[]; className?: string }) {
if (!form.length) return <span className="text-xs text-faint">no recent data</span>;
return (
<span className={cn('inline-flex gap-1', className)}>
{[...form].reverse().map((r, i) => (
<span
key={i}
className={cn('grid h-5 w-5 place-items-center rounded border text-[11px] font-bold', tone[r] ?? 'bg-elevated text-faint border-line')}
title={r === 'W' ? 'Win' : r === 'D' ? 'Draw' : r === 'L' ? 'Loss' : r}
>
{r}
</span>
))}
</span>
);
}
+6 -3
View File
@@ -1,3 +1,4 @@
import { Link } from '@tanstack/react-router';
import { cn } from '@/lib/cn';
import type { Fixture } from '@/lib/types';
import { kickoffTime, relativeKickoff } from '@/lib/format';
@@ -36,9 +37,11 @@ function StatusPill({ f }: { f: Fixture }) {
export function MatchCard({ f }: { f: Fixture }) {
const hasScore = f.status === 'live' || f.status === 'finished';
return (
<div
<Link
to="/match/$num"
params={{ num: String(f.num) }}
className={cn(
'rounded-xl border border-line bg-panel px-4 py-3 transition-colors',
'block rounded-xl border border-line bg-panel px-4 py-3 transition-colors hover:border-line-strong hover:bg-panel-2',
f.status === 'live' && 'border-live/40 ring-1 ring-live/30',
)}
>
@@ -60,6 +63,6 @@ export function MatchCard({ f }: { f: Fixture }) {
<TeamLabel slot={f.away} align="left" />
</div>
<div className="mt-1.5 text-center text-[11px] text-faint">{f.venue}</div>
</div>
</Link>
);
}
+199
View File
@@ -0,0 +1,199 @@
import { useEffect, useState } from 'react';
import { Link, useParams } from '@tanstack/react-router';
import { ArrowLeft, Shirt } from 'lucide-react';
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
import { WinProbBar } from '@/components/WinProbBar';
import { FormChips } from '@/components/FormChips';
import { teamFlag } from '@/lib/teams';
import { kickoffDay, kickoffTime } from '@/lib/format';
import type { KeyPlayer, MatchPreview } from '@/lib/types';
const STAGE: Record<string, string> = {
group: 'Group', r32: 'Round of 32', r16: 'Round of 16', qf: 'Quarter-final', sf: 'Semi-final', third: 'Third place', final: 'Final',
};
function ScorerList({ team, players }: { team: string; players: KeyPlayer[] }) {
return (
<div>
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-ink">
<span aria-hidden>{teamFlag(team)}</span> {team}
</div>
{players.length ? (
<ul className="space-y-1">
{players.slice(0, 6).map((p) => (
<li key={p.name} className="flex items-center justify-between text-sm">
<span className="truncate text-ink-soft">{p.name}</span>
<span className="tnum shrink-0 text-faint">
{p.goals}{p.pens ? <span className="text-faint/70"> ({p.pens}p)</span> : null}
</span>
</li>
))}
</ul>
) : (
<p className="text-xs text-faint">no data</p>
)}
</div>
);
}
export function MatchPreviewPage() {
const { num } = useParams({ strict: false }) as { num: string };
const [preview, setPreview] = useState<MatchPreview | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
let alive = true;
setPreview(null);
setFailed(false);
fetch(`/api/preview/${num}`)
.then((r) => (r.ok ? r.json() : Promise.reject()))
.then((d: MatchPreview) => alive && setPreview(d))
.catch(() => alive && setFailed(true));
return () => { alive = false; };
}, [num]);
if (failed) {
return (
<div className="py-16 text-center text-muted">
Couldn&apos;t load that match. <Link to="/" className="text-accent">Back to Live</Link>
</div>
);
}
if (!preview) return <div className="h-96 animate-pulse rounded-xl border border-line bg-panel" />;
const f = preview.fixture;
const hasScore = f.status === 'live' || f.status === 'finished';
const homeName = f.home.label;
const awayName = f.away.label;
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} /> Back
</Link>
{/* hero */}
<Card>
<CardBody>
<div className="mb-3 text-center text-xs uppercase tracking-wide text-faint">
{f.group ? `Group ${f.group}` : STAGE[f.stage]} · {kickoffDay(f.kickoff)} · {kickoffTime(f.kickoff)} · {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="text-4xl">{f.home.team ? teamFlag(f.home.team) : '⚽'}</span>
<span className="text-center text-sm font-semibold text-ink">{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>
)}
{f.status === 'live' && <div className="text-[11px] font-bold uppercase text-live">{f.minute ? `${f.minute}'` : 'Live'}</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="text-4xl">{f.away.team ? teamFlag(f.away.team) : '⚽'}</span>
<span className="text-center text-sm font-semibold text-ink">{awayName}</span>
</Link>
</div>
</CardBody>
</Card>
{/* model expectation */}
{preview.prediction && (
<Card>
<CardHeader><span className="font-display font-bold text-ink">Model expectation</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">
Expected goals: {preview.prediction.lambdaHome.toFixed(2)} {preview.prediction.lambdaAway.toFixed(2)} ·
model odds, not betting advice
</p>
</CardBody>
</Card>
)}
{/* form */}
<Card>
<CardHeader><span className="font-display font-bold text-ink">Recent form</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">Head to head</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">{homeName} wins</div></div>
<div><div className="tnum text-2xl font-extrabold text-muted">{preview.h2h.draws}</div><div className="text-[11px] text-faint">draws · {preview.h2h.games} total</div></div>
<div><div className="tnum text-2xl font-extrabold text-info">{preview.h2h.awayWins}</div><div className="text-[11px] text-faint">{awayName} wins</div></div>
</div>
<div className="border-t border-line pt-2">
<div className="mb-1 text-[11px] uppercase tracking-wide text-faint">Recent meetings</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 ? 'Lineups' : 'Key players (all-time scorers)'}</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>
<p className="text-center text-xs text-faint">{preview.dataNote}</p>
</div>
);
}
function LineupCol({ team, players }: { team: string; players: { name: string; position: string | null; number: number | null; starter: boolean }[] }) {
const starters = players.filter((p) => p.starter);
const list = starters.length ? starters : players;
return (
<div>
<div className="mb-2 text-sm font-semibold text-ink">{team}</div>
<ul className="space-y-0.5 text-sm">
{list.map((p) => (
<li key={p.name} className="flex items-center gap-2 text-ink-soft">
<span className="tnum w-5 text-right text-xs text-faint">{p.number ?? ''}</span>
<span className="truncate">{p.name}</span>
<span className="ml-auto text-[11px] text-faint">{p.position}</span>
</li>
))}
</ul>
</div>
);
}
+108
View File
@@ -0,0 +1,108 @@
import { useEffect, useState } from 'react';
import { Link, useParams } from '@tanstack/react-router';
import { ArrowLeft } from 'lucide-react';
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { teamFlag } from '@/lib/teams';
import { kickoffDay, kickoffTime } from '@/lib/format';
import type { Fixture, TeamProfile } from '@/lib/types';
const pct = (x: number | null) => (x == null ? '—' : `${(x * 100).toFixed(1)}%`);
function FixtureRow({ f, team }: { f: Fixture; team: string }) {
const isHome = f.home.team === team;
const opp = isHome ? f.away : f.home;
const done = f.status === 'finished' || f.status === 'live';
const us = isHome ? f.homeScore : f.awayScore;
const them = isHome ? f.awayScore : f.homeScore;
const res = done && us != null && them != null ? (us > them ? 'W' : us < them ? 'L' : 'D') : null;
return (
<Link to="/match/$num" params={{ num: String(f.num) }} className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-elevated">
<span className="w-10 text-xs text-faint">{f.group ? `Grp ${f.group}` : f.round.replace('Round of', 'R')}</span>
<span className="text-faint">{isHome ? 'vs' : '@'}</span>
<span aria-hidden>{opp.team ? teamFlag(opp.team) : '⚽'}</span>
<span className="truncate text-ink">{opp.label}</span>
<span className="ml-auto tnum text-faint">
{done && us != null ? <span className="text-ink">{us}{them}</span> : `${kickoffDay(f.kickoff)} ${kickoffTime(f.kickoff)}`}
</span>
{res && <span className={`grid h-5 w-5 place-items-center rounded text-[11px] font-bold ${res === 'W' ? 'bg-win/20 text-win' : res === 'L' ? 'bg-loss/20 text-loss' : 'bg-draw/20 text-draw'}`}>{res}</span>}
</Link>
);
}
export function TeamProfilePage() {
const { name } = useParams({ strict: false }) as { name: string };
const [profile, setProfile] = useState<TeamProfile | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
let alive = true;
setProfile(null);
setFailed(false);
fetch(`/api/team/${encodeURIComponent(name)}`)
.then((r) => (r.ok ? r.json() : Promise.reject()))
.then((d: TeamProfile) => alive && setProfile(d))
.catch(() => alive && setFailed(true));
return () => { alive = false; };
}, [name]);
if (failed) return <div className="py-16 text-center text-muted">Unknown team. <Link to="/groups" className="text-accent">All teams</Link></div>;
if (!profile) return <div className="h-80 animate-pulse rounded-xl border border-line bg-panel" />;
const s = profile.standing;
return (
<div className="space-y-5">
<Link to="/groups" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-ink">
<ArrowLeft size={15} /> Groups
</Link>
<Card>
<CardBody className="flex flex-wrap items-center gap-4">
<span aria-hidden className="text-5xl">{teamFlag(profile.team)}</span>
<div>
<h1 className="font-display text-2xl font-extrabold text-ink">{profile.team}</h1>
<div className="mt-1 flex flex-wrap items-center gap-2 text-sm">
{profile.group && <Badge tone="neutral">Group {profile.group}</Badge>}
<Badge tone="muted">Elo {profile.elo}</Badge>
<Badge tone="gold">Title {pct(profile.championOdds)}</Badge>
<Badge tone="accent">Advance {pct(profile.qualifyOdds)}</Badge>
</div>
</div>
{s && (
<div className="ml-auto text-right text-sm">
<div className="text-faint text-xs">Group standing</div>
<div className="tnum text-ink">#{s.rank} · {s.points} pts · {s.won}-{s.drawn}-{s.lost} · GD {s.gd > 0 ? `+${s.gd}` : s.gd}</div>
</div>
)}
</CardBody>
</Card>
<div className="grid gap-5 md:grid-cols-[1.3fr_1fr]">
<Card>
<CardHeader><span className="font-display font-bold text-ink">Fixtures</span></CardHeader>
<CardBody className="space-y-0.5">
{profile.fixtures.map((f) => <FixtureRow key={f.num} f={f} team={profile.team} />)}
</CardBody>
</Card>
<Card>
<CardHeader><span className="font-display font-bold text-ink">All-time top scorers</span></CardHeader>
<CardBody>
{profile.keyPlayers.length ? (
<ol className="space-y-1.5">
{profile.keyPlayers.map((p, i) => (
<li key={p.name} className="flex items-center gap-2 text-sm">
<span className="w-4 text-right text-xs font-bold text-faint">{i + 1}</span>
<span className="truncate text-ink-soft">{p.name}</span>
<span className="tnum ml-auto font-semibold text-ink">{p.goals}</span>
{p.pens ? <span className="text-[11px] text-faint">{p.pens}p</span> : null}
</li>
))}
</ol>
) : <p className="text-sm text-faint">No data.</p>}
</CardBody>
</Card>
</div>
</div>
);
}
+43
View File
@@ -117,6 +117,49 @@ export interface ModelSnapshot {
history: OddsHistoryPoint[];
}
// ---- match previews / team profiles ----
export interface H2HMeeting { date: string; home: string; away: string; homeScore: number; awayScore: number }
export interface H2HSummary {
games: number;
/** From THIS fixture's home/away perspective. */
homeWins: number;
awayWins: number;
draws: number;
homeGoals: number;
awayGoals: number;
last: H2HMeeting[];
}
export interface KeyPlayer { name: string; goals: number; pens: number }
export interface LineupPlayer { name: string; position: string | null; starter: boolean; number: number | null }
export interface MatchPreview {
num: number;
fixture: Fixture;
venue: string | null;
prediction: MatchPrediction | null;
/** Recent results per side, most recent first ('W'|'D'|'L'). */
form: { home: string[]; away: string[] };
h2h: H2HSummary | null;
lineups: { home: LineupPlayer[]; away: LineupPlayer[] } | null;
keyPlayers: { home: KeyPlayer[]; away: KeyPlayer[] };
/** Honest note on which data layers are populated yet. */
dataNote: string;
updatedAt: number | null;
}
export interface TeamProfile {
team: string;
elo: number;
group: string | null;
standing: StandingRow | null;
fixtures: Fixture[];
keyPlayers: KeyPlayer[];
championOdds: number | null;
qualifyOdds: number | null;
}
// ---- data-story viz (StatsBomb open data, a fixed historical match) ----
export interface VizShot {
+5 -1
View File
@@ -6,6 +6,8 @@ import { GroupsPage } from './features/groups/GroupsPage';
import { BracketPage } from './features/bracket/BracketPage';
import { PredictionsPage } from './features/predictions/PredictionsPage';
import { StoryPage } from './features/story/StoryPage';
import { MatchPreviewPage } from './features/match/MatchPreviewPage';
import { TeamProfilePage } from './features/team/TeamProfilePage';
const rootRoute = createRootRoute({
component: RootLayout,
@@ -17,8 +19,10 @@ const groupsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/group
const bracketRoute = createRoute({ getParentRoute: () => rootRoute, path: '/bracket', component: BracketPage });
const predictRoute = createRoute({ getParentRoute: () => rootRoute, path: '/predict', component: PredictionsPage });
const storyRoute = createRoute({ getParentRoute: () => rootRoute, path: '/story', component: StoryPage });
const matchRoute = createRoute({ getParentRoute: () => rootRoute, path: '/match/$num', component: MatchPreviewPage });
const teamRoute = createRoute({ getParentRoute: () => rootRoute, path: '/team/$name', component: TeamProfilePage });
const routeTree = rootRoute.addChildren([indexRoute, groupsRoute, bracketRoute, predictRoute, storyRoute]);
const routeTree = rootRoute.addChildren([indexRoute, groupsRoute, bracketRoute, predictRoute, storyRoute, matchRoute, teamRoute]);
export const router = createRouter({ routeTree, defaultPreload: 'intent' });