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:
@@ -14,6 +14,8 @@ server/node_modules
|
||||
# Generated build artifacts (reproduced by `npm run data:build`)
|
||||
public/data/fixtures.json
|
||||
public/data/ratings.json
|
||||
public/data/h2h.json
|
||||
public/data/scorers.json
|
||||
public/pwa-192x192.png
|
||||
public/pwa-512x512.png
|
||||
public/favicon.svg
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -16,9 +16,10 @@
|
||||
"dev:server": "tsx watch server/src/index.ts",
|
||||
"data:fixtures": "bun scripts/buildFixtures.ts",
|
||||
"data:ratings": "bun scripts/buildRatings.ts",
|
||||
"data:preview": "bun scripts/buildPreviewData.ts",
|
||||
"data:viz": "bun scripts/buildStatsbombViz.ts",
|
||||
"data:icons": "bun scripts/make-icons.mjs",
|
||||
"data:build": "bun run data:icons && bun run data:fixtures && bun run data:ratings"
|
||||
"data:build": "bun run data:icons && bun run data:fixtures && bun run data:ratings && bun run data:preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-router": "^1.95.0",
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// international-results.csv + goalscorers.csv → public/data/{h2h.json,scorers.json}
|
||||
// Precomputes match-preview inputs that need the full historical corpus: every
|
||||
// World-Cup-team pairing's head-to-head record, and each team's top historical
|
||||
// scorers. ESPN supplies live form/lineups/venue at runtime; these are the deep
|
||||
// historical layers.
|
||||
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { canonicalTeam, ALL_TEAMS } from '../src/lib/teams';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const RESULTS = join(ROOT, 'data', 'raw', 'international-results.csv');
|
||||
const SCORERS = join(ROOT, 'data', 'raw', 'goalscorers.csv');
|
||||
const OUT_DIR = join(ROOT, 'public', 'data');
|
||||
|
||||
const WC = new Set(ALL_TEAMS);
|
||||
const pairKey = (a: string, b: string) => [a, b].sort().join('|');
|
||||
|
||||
interface H2HMeeting { date: string; home: string; away: string; hs: number; as: number }
|
||||
interface H2HRecord {
|
||||
a: string; b: string;
|
||||
games: number; aWins: number; bWins: number; draws: number; aGoals: number; bGoals: number;
|
||||
last: H2HMeeting[];
|
||||
}
|
||||
|
||||
function buildH2H(): Record<string, H2HRecord> {
|
||||
const lines = readFileSync(RESULTS, 'utf8').split('\n');
|
||||
const rec: Record<string, H2HRecord> = {};
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const c = lines[i]?.split(',');
|
||||
if (!c || c.length < 9) continue;
|
||||
const hs = Number(c[3]); const as = Number(c[4]);
|
||||
if (!Number.isFinite(hs) || !Number.isFinite(as)) continue;
|
||||
const home = canonicalTeam(c[1]!); const away = canonicalTeam(c[2]!);
|
||||
if (!WC.has(home) || !WC.has(away)) continue;
|
||||
const [a, b] = [home, away].sort() as [string, string];
|
||||
const key = pairKey(home, away);
|
||||
let r = rec[key];
|
||||
if (!r) { r = { a, b, games: 0, aWins: 0, bWins: 0, draws: 0, aGoals: 0, bGoals: 0, last: [] }; rec[key] = r; }
|
||||
// goals from a's/b's perspective
|
||||
const aIsHome = home === a;
|
||||
const aScore = aIsHome ? hs : as;
|
||||
const bScore = aIsHome ? as : hs;
|
||||
r.games++; r.aGoals += aScore; r.bGoals += bScore;
|
||||
if (aScore > bScore) r.aWins++; else if (aScore < bScore) r.bWins++; else r.draws++;
|
||||
r.last.push({ date: c[0]!, home, away, hs, as });
|
||||
}
|
||||
// keep only the 6 most recent meetings per pair
|
||||
for (const r of Object.values(rec)) {
|
||||
r.last.sort((x, y) => y.date.localeCompare(x.date));
|
||||
r.last = r.last.slice(0, 6);
|
||||
}
|
||||
return rec;
|
||||
}
|
||||
|
||||
interface Scorer { name: string; goals: number; pens: number }
|
||||
|
||||
function buildScorers(): Record<string, Scorer[]> {
|
||||
const lines = readFileSync(SCORERS, 'utf8').split('\n');
|
||||
const byTeam = new Map<string, Map<string, { goals: number; pens: number }>>();
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const c = lines[i]?.split(',');
|
||||
if (!c || c.length < 8) continue;
|
||||
const team = canonicalTeam(c[3]!);
|
||||
const scorer = c[4];
|
||||
if (!scorer || !WC.has(team)) continue;
|
||||
if ((c[6] ?? '').toUpperCase() === 'TRUE') continue; // own goal
|
||||
const pen = (c[7] ?? '').toUpperCase() === 'TRUE';
|
||||
if (!byTeam.has(team)) byTeam.set(team, new Map());
|
||||
const m = byTeam.get(team)!;
|
||||
const e = m.get(scorer) ?? { goals: 0, pens: 0 };
|
||||
e.goals++; if (pen) e.pens++;
|
||||
m.set(scorer, e);
|
||||
}
|
||||
const out: Record<string, Scorer[]> = {};
|
||||
for (const [team, m] of byTeam) {
|
||||
out[team] = [...m.entries()]
|
||||
.map(([name, v]) => ({ name, goals: v.goals, pens: v.pens }))
|
||||
.sort((x, y) => y.goals - x.goals)
|
||||
.slice(0, 10);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const h2h = buildH2H();
|
||||
const scorers = buildScorers();
|
||||
mkdirSync(OUT_DIR, { recursive: true });
|
||||
writeFileSync(join(OUT_DIR, 'h2h.json'), JSON.stringify(h2h));
|
||||
writeFileSync(join(OUT_DIR, 'scorers.json'), JSON.stringify(scorers));
|
||||
console.log(`preview data → h2h.json (${Object.keys(h2h).length} pairings), scorers.json (${Object.keys(scorers).length} teams)`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -8,7 +8,9 @@ import type { WebSocket } from 'ws';
|
||||
import { TournamentState } from './tournament';
|
||||
import { startScheduler } from './ingest/scheduler';
|
||||
import { ModelEngine } from './model';
|
||||
import { buildPreview, buildTeamProfile } from './preview';
|
||||
import { db, healthAll } from './db/db';
|
||||
import { canonicalTeam } from '../../src/lib/teams';
|
||||
import type { MatchStatus, ServerMessage } from '../../src/lib/types';
|
||||
|
||||
const PORT = Number(process.env.PORT ?? 8787);
|
||||
@@ -55,6 +57,16 @@ export function buildServer() {
|
||||
app.get('/api/snapshot', async () => state.snapshot());
|
||||
app.get('/api/predictions', async () => model.current());
|
||||
app.get('/api/sources/health', async () => ({ sources: healthAll() }));
|
||||
app.get('/api/preview/:num', async (req, reply) => {
|
||||
const num = Number((req.params as { num: string }).num);
|
||||
const preview = Number.isFinite(num) ? buildPreview(num, state, model) : null;
|
||||
return preview ?? reply.code(404).send({ error: 'no such fixture' });
|
||||
});
|
||||
app.get('/api/team/:name', async (req, reply) => {
|
||||
const name = canonicalTeam(decodeURIComponent((req.params as { name: string }).name));
|
||||
const profile = buildTeamProfile(name, state, model);
|
||||
return profile ?? reply.code(404).send({ error: 'no such team' });
|
||||
});
|
||||
app.get('/healthz', async () => ({ ok: true }));
|
||||
|
||||
// ---- dev-only: inject a score to exercise the live → standings → WS loop ----
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { getMatchExt } from './db/db';
|
||||
import type { TournamentState } from './tournament';
|
||||
import type { ModelEngine } from './model';
|
||||
import type { EspnSummary } from './ingest/espnNormalize';
|
||||
import type {
|
||||
H2HSummary, KeyPlayer, MatchPreview, StandingRow, TeamProfile,
|
||||
} from '../../src/lib/types';
|
||||
|
||||
// Assembles a MatchPreview / TeamProfile from three layers: precomputed history
|
||||
// (h2h.json, scorers.json), the DB's ESPN enrichment (form, lineups, venue), and
|
||||
// the live model (prediction, odds, ratings).
|
||||
|
||||
interface H2HRecord {
|
||||
a: string; b: string;
|
||||
games: number; aWins: number; bWins: number; draws: number; aGoals: number; bGoals: number;
|
||||
last: { date: string; home: string; away: string; hs: number; as: number }[];
|
||||
}
|
||||
|
||||
function loadStatic<T>(name: string): T {
|
||||
const candidates = [
|
||||
`${process.env.STATIC_DIR ?? ''}/data/${name}`,
|
||||
`${process.cwd()}/public/data/${name}`,
|
||||
`${process.cwd()}/dist/data/${name}`,
|
||||
];
|
||||
for (const p of candidates) if (p && existsSync(p)) return JSON.parse(readFileSync(p, 'utf8')) as T;
|
||||
throw new Error(`${name} not found — run "npm run data:preview". Looked in: ${candidates.join(', ')}`);
|
||||
}
|
||||
|
||||
let _h2h: Record<string, H2HRecord> | null = null;
|
||||
let _scorers: Record<string, KeyPlayer[]> | null = null;
|
||||
let _ratings: { ratings: Record<string, number> } | null = null;
|
||||
const h2hData = () => (_h2h ??= loadStatic<Record<string, H2HRecord>>('h2h.json'));
|
||||
const scorersData = () => (_scorers ??= loadStatic<Record<string, KeyPlayer[]>>('scorers.json'));
|
||||
const ratingsData = () => (_ratings ??= loadStatic<{ ratings: Record<string, number> }>('ratings.json'));
|
||||
|
||||
const pairKey = (a: string, b: string) => [a, b].sort().join('|');
|
||||
|
||||
function h2hSummary(home: string, away: string): H2HSummary | null {
|
||||
const rec = h2hData()[pairKey(home, away)];
|
||||
if (!rec) return null;
|
||||
const homeIsA = home === rec.a;
|
||||
return {
|
||||
games: rec.games,
|
||||
homeWins: homeIsA ? rec.aWins : rec.bWins,
|
||||
awayWins: homeIsA ? rec.bWins : rec.aWins,
|
||||
draws: rec.draws,
|
||||
homeGoals: homeIsA ? rec.aGoals : rec.bGoals,
|
||||
awayGoals: homeIsA ? rec.bGoals : rec.aGoals,
|
||||
last: rec.last.map((m) => ({ date: m.date, home: m.home, away: m.away, homeScore: m.hs, awayScore: m.as })),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPreview(num: number, state: TournamentState, model: ModelEngine): MatchPreview | null {
|
||||
const fixture = state.getFixture(num);
|
||||
if (!fixture) return null;
|
||||
|
||||
const home = fixture.home.team;
|
||||
const away = fixture.away.team;
|
||||
const ext = getMatchExt<EspnSummary>(num);
|
||||
const summary = ext?.data;
|
||||
|
||||
// map ESPN data by canonical team name (robust to home/away orientation)
|
||||
const espnFor = (team: string | null) => (team && summary ? summary.teams.find((t) => t.name === team) : undefined);
|
||||
const eh = espnFor(home);
|
||||
const ea = espnFor(away);
|
||||
|
||||
const prediction = model.current().matches.find((m) => m.num === num) ?? null;
|
||||
|
||||
return {
|
||||
num,
|
||||
fixture,
|
||||
venue: summary?.venue ?? fixture.venue,
|
||||
prediction,
|
||||
form: {
|
||||
home: eh ? summary!.form[eh.id] ?? [] : [],
|
||||
away: ea ? summary!.form[ea.id] ?? [] : [],
|
||||
},
|
||||
h2h: home && away ? h2hSummary(home, away) : null,
|
||||
lineups:
|
||||
eh && ea && (summary!.lineups[eh.id]?.length || summary!.lineups[ea.id]?.length)
|
||||
? { home: summary!.lineups[eh.id] ?? [], away: summary!.lineups[ea.id] ?? [] }
|
||||
: null,
|
||||
keyPlayers: {
|
||||
home: home ? scorersData()[home] ?? [] : [],
|
||||
away: away ? scorersData()[away] ?? [] : [],
|
||||
},
|
||||
dataNote: !home || !away
|
||||
? 'Knockout participants are decided once the bracket resolves.'
|
||||
: ext
|
||||
? 'Live form, lineups and stats from ESPN; head-to-head and key players from 150 years of results.'
|
||||
: 'Head-to-head and key players from history; live form & lineups appear closer to kickoff.',
|
||||
updatedAt: ext?.updatedAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTeamProfile(teamRaw: string, state: TournamentState, model: ModelEngine): TeamProfile | null {
|
||||
const snap = state.snapshot();
|
||||
const fixtures = snap.fixtures.filter((f) => f.home.team === teamRaw || f.away.team === teamRaw);
|
||||
if (!fixtures.length) return null;
|
||||
|
||||
const group = fixtures.find((f) => f.group)?.group ?? null;
|
||||
const standing: StandingRow | null =
|
||||
group ? snap.tables[group]?.find((r) => r.team === teamRaw) ?? null : null;
|
||||
const odds = model.current().odds.find((o) => o.team === teamRaw) ?? null;
|
||||
|
||||
return {
|
||||
team: teamRaw,
|
||||
elo: Math.round(ratingsData().ratings[teamRaw] ?? 1500),
|
||||
group,
|
||||
standing,
|
||||
fixtures,
|
||||
keyPlayers: scorersData()[teamRaw] ?? [],
|
||||
championOdds: odds?.champion ?? null,
|
||||
qualifyOdds: odds?.qualify ?? null,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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' });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user