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