Compare page: any two teams, model matchup + 150-year record

/compare (also in the mobile More sheet): pick any two of the 48 —
defaults to the top title favourites — and get the model's
neutral-ground matchup plus the full head-to-head from the historical
archive (wins/draws/goals and the last six meetings).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 01:18:07 +02:00
parent b61d839c8e
commit 343a3e1829
5 changed files with 160 additions and 2 deletions
+2 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { Link, Outlet, useRouterState } from '@tanstack/react-router';
import { Database, Gauge, GitMerge, Languages, Menu, Moon, Radio, Scale, Search, Sparkles, Sun, Table, TrendingUp, Trophy, Users } from 'lucide-react';
import { Database, Gauge, GitMerge, Languages, Menu, Moon, Radio, Scale, Search, Sparkles, Sun, Swords, Table, TrendingUp, Trophy, Users } from 'lucide-react';
import { SearchPalette } from '@/components/SearchPalette';
import type { LucideIcon } from 'lucide-react';
import { useUiStore } from '@/stores/uiStore';
@@ -26,6 +26,7 @@ const MOBILE_PRIMARY: NavItem[] = NAV.slice(0, 4);
const MOBILE_MORE: NavItem[] = [
...NAV.slice(4),
{ to: '/teams', label: (t) => t.nav.teams, exact: false, icon: Users },
{ to: '/compare', label: (t) => t.nav.compare, exact: false, icon: Swords },
{ to: '/data', label: (t) => t.nav.data, exact: false, icon: Database },
];
+135
View File
@@ -0,0 +1,135 @@
import { useEffect, useMemo, useState } from 'react';
import { Swords } from 'lucide-react';
import { PageHeader } from '@/components/ui/PageHeader';
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
import { WinProbBar } from '@/components/WinProbBar';
import { teamFlag } from '@/lib/teams';
import { fmt, useFormat } from '@/lib/i18n';
import { predictMatch, type RatingsModel } from '@/lib/model/predict';
import { useTournamentStore } from '@/stores/tournamentStore';
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 }[];
}
let h2hCache: Promise<Record<string, H2HRecord> | null> | null = null;
const loadH2h = () => (h2hCache ??= fetch('/data/h2h.json').then((r) => (r.ok ? r.json() : null)).catch(() => null));
let ratingsCache: Promise<RatingsModel | null> | null = null;
const loadRatings = () => (ratingsCache ??= fetch('/data/ratings.json').then((r) => (r.ok ? r.json() : null)).catch(() => null));
const pairKey = (a: string, b: string) => [a, b].sort().join('|');
function TeamSelect({ value, teams, onChange, label }: { value: string; teams: string[]; onChange: (t: string) => void; label: string }) {
return (
<label className="flex min-w-0 flex-1 flex-col gap-1 text-xs text-faint">
{label}
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="rounded-md border border-line bg-panel px-2.5 py-2 text-sm text-ink"
>
{teams.map((t2) => <option key={t2} value={t2}>{t2}</option>)}
</select>
</label>
);
}
/** Any two of the 48, side by side: model matchup + the full historical record. */
export function ComparePage() {
const { t, kickoffDay } = useFormat();
const model = useTournamentStore((s) => s.model);
const teams = useMemo(
() => (model?.odds ?? []).map((o) => o.team).sort((x, y) => x.localeCompare(y)),
[model],
);
const topTwo = useMemo(() => (model?.odds ?? []).slice(0, 2).map((o) => o.team), [model]);
const [a, setA] = useState('');
const [b, setB] = useState('');
const [h2h, setH2h] = useState<Record<string, H2HRecord> | null>(null);
const [ratings, setRatings] = useState<RatingsModel | null>(null);
useEffect(() => {
void loadH2h().then(setH2h);
void loadRatings().then(setRatings);
}, []);
useEffect(() => {
if (!a && topTwo[0]) setA(topTwo[0]);
if (!b && topTwo[1]) setB(topTwo[1]);
}, [topTwo, a, b]);
const matchup = useMemo(() => {
if (!ratings || !a || !b || a === b) return null;
return predictMatch(a, b, ratings, 0);
}, [ratings, a, b]);
const rec = a && b && a !== b ? h2h?.[pairKey(a, b)] ?? null : null;
const oriented = rec
? rec.a === a
? { games: rec.games, aWins: rec.aWins, bWins: rec.bWins, draws: rec.draws, aGoals: rec.aGoals, bGoals: rec.bGoals, last: rec.last }
: { games: rec.games, aWins: rec.bWins, bWins: rec.aWins, draws: rec.draws, aGoals: rec.bGoals, bGoals: rec.aGoals, last: rec.last }
: null;
return (
<div className="space-y-5">
<PageHeader title={t.compare.title} subtitle={t.compare.subtitle} />
<div className="flex flex-wrap items-end gap-3">
<TeamSelect value={a} teams={teams} onChange={setA} label={`${teamFlag(a)} A`} />
<Swords size={18} className="mb-2.5 shrink-0 text-faint" />
<TeamSelect value={b} teams={teams} onChange={setB} label={`${teamFlag(b)} B`} />
</div>
{a === b && a !== '' && <p className="text-sm text-faint">{t.compare.samePick}</p>}
{matchup && (
<Card>
<CardHeader><span className="font-display font-bold text-ink">{t.compare.modelMatchup}</span></CardHeader>
<CardBody className="space-y-2">
<WinProbBar home={a} away={b} probs={matchup.probs} topScore={matchup.scorelines[0] ?? { home: 0, away: 0, p: 0 }} />
<p className="text-xs text-faint">{t.compare.neutralNote}</p>
</CardBody>
</Card>
)}
{oriented && oriented.games > 0 ? (
<Card>
<CardHeader><span className="font-display font-bold text-ink">{t.match.headToHead}</span></CardHeader>
<CardBody className="space-y-4">
<div className="grid grid-cols-3 gap-3 text-center">
<div>
<div className="font-display text-2xl font-extrabold text-ink">{oriented.aWins}</div>
<div className="text-[11px] uppercase tracking-wide text-faint">{fmt(t.match.h2h.teamWins, { team: a })}</div>
</div>
<div>
<div className="font-display text-2xl font-extrabold text-muted">{oriented.draws}</div>
<div className="text-[11px] uppercase tracking-wide text-faint">{fmt(t.match.h2h.drawsTotal, { games: oriented.games })}</div>
</div>
<div>
<div className="font-display text-2xl font-extrabold text-ink">{oriented.bWins}</div>
<div className="text-[11px] uppercase tracking-wide text-faint">{fmt(t.match.h2h.teamWins, { team: b })}</div>
</div>
</div>
<div className="text-center text-xs text-faint">{fmt(t.compare.goals, { a: oriented.aGoals, b: oriented.bGoals })}</div>
<div>
<div className="mb-1.5 text-[11px] font-bold uppercase tracking-wide text-faint">{t.match.h2h.recentMeetings}</div>
<ul className="space-y-1">
{oriented.last.slice(0, 6).map((m2) => (
<li key={`${m2.date}${m2.home}`} className="flex items-center gap-2 text-sm">
<span className="w-24 shrink-0 text-xs text-faint">{kickoffDay(m2.date)}</span>
<span className="truncate text-ink-soft">{m2.home}</span>
<span className="tnum font-semibold text-ink">{m2.hs}{m2.as}</span>
<span className="truncate text-ink-soft">{m2.away}</span>
</li>
))}
</ul>
</div>
</CardBody>
</Card>
) : (
a !== b && a !== '' && h2h && <p className="text-sm text-faint">{t.compare.neverMet}</p>
)}
</div>
);
}
+10
View File
@@ -12,6 +12,7 @@ export const de: Dict = {
story: "Story",
teams: "Teams",
data: "Daten",
compare: "Vergleich",
more: "Mehr",
},
common: {
@@ -105,6 +106,15 @@ export const de: Dict = {
lineups: "Aufstellungen",
keyPlayers: "Schlüsselspieler (beste Torschützen aller Zeiten)",
},
compare: {
title: "Vergleich",
subtitle: "Zwei der 48 im direkten Duell: die Modell-Einschätzung und die komplette Geschichte.",
modelMatchup: "Modell-Duell (neutraler Boden)",
neutralNote: "Hypothetisch, auf neutralem Boden — die aktuelle Modell-Sicht auf beide Teams.",
samePick: "Wähle zwei verschiedene Teams.",
neverMet: "Diese beiden trafen in 150 Jahren Länderspielgeschichte nie aufeinander.",
goals: "Tore: {a} {b}",
},
search: {
title: "Suche",
placeholder: "Team oder Spiel…",
+10
View File
@@ -11,6 +11,7 @@ export const en = {
story: "Story",
teams: "Teams",
data: "Data",
compare: "Compare",
more: "More",
},
common: {
@@ -104,6 +105,15 @@ export const en = {
lineups: "Lineups",
keyPlayers: "Key players (all-time top scorers)",
},
compare: {
title: "Compare",
subtitle: "Any two of the 48, side by side: the model's matchup and the full historical record.",
modelMatchup: "Model matchup (neutral ground)",
neutralNote: "Hypothetical, on neutral ground — the model's current view of these two squads.",
samePick: "Pick two different teams.",
neverMet: "These two have never met in 150 years of internationals.",
goals: "Goals: {a} {b}",
},
search: {
title: "Search",
placeholder: "Team or match…",
+3 -1
View File
@@ -12,6 +12,7 @@ import { MethodologyPage } from './features/methodology/MethodologyPage';
import { TeamsPage } from './features/teams/TeamsPage';
import { ScoreboardPage } from './features/scoreboard/ScoreboardPage';
import { DataPage } from './features/data/DataPage';
import { ComparePage } from './features/compare/ComparePage';
const rootRoute = createRootRoute({
component: RootLayout,
@@ -29,8 +30,9 @@ const methodologyRoute = createRoute({ getParentRoute: () => rootRoute, path: '/
const teamsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/teams', component: TeamsPage });
const scoreboardRoute = createRoute({ getParentRoute: () => rootRoute, path: '/scoreboard', component: ScoreboardPage });
const dataRoute = createRoute({ getParentRoute: () => rootRoute, path: '/data', component: DataPage });
const compareRoute = createRoute({ getParentRoute: () => rootRoute, path: '/compare', component: ComparePage });
const routeTree = rootRoute.addChildren([indexRoute, groupsRoute, bracketRoute, predictRoute, storyRoute, matchRoute, teamRoute, methodologyRoute, teamsRoute, scoreboardRoute, dataRoute]);
const routeTree = rootRoute.addChildren([indexRoute, groupsRoute, bracketRoute, predictRoute, storyRoute, matchRoute, teamRoute, methodologyRoute, teamsRoute, scoreboardRoute, dataRoute, compareRoute]);
export const router = createRouter({ routeTree, defaultPreload: 'intent' });