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:
@@ -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'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user