diff --git a/src/app/RootLayout.tsx b/src/app/RootLayout.tsx index c6b5fae..9c5ee59 100644 --- a/src/app/RootLayout.tsx +++ b/src/app/RootLayout.tsx @@ -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 }, ]; diff --git a/src/features/compare/ComparePage.tsx b/src/features/compare/ComparePage.tsx new file mode 100644 index 0000000..36088ca --- /dev/null +++ b/src/features/compare/ComparePage.tsx @@ -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 | null> | null = null; +const loadH2h = () => (h2hCache ??= fetch('/data/h2h.json').then((r) => (r.ok ? r.json() : null)).catch(() => null)); +let ratingsCache: Promise | 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 ( + + ); +} + +/** 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 | null>(null); + const [ratings, setRatings] = useState(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 ( +
+ + +
+ + + +
+ + {a === b && a !== '' &&

{t.compare.samePick}

} + + {matchup && ( + + {t.compare.modelMatchup} + + +

{t.compare.neutralNote}

+
+
+ )} + + {oriented && oriented.games > 0 ? ( + + {t.match.headToHead} + +
+
+
{oriented.aWins}
+
{fmt(t.match.h2h.teamWins, { team: a })}
+
+
+
{oriented.draws}
+
{fmt(t.match.h2h.drawsTotal, { games: oriented.games })}
+
+
+
{oriented.bWins}
+
{fmt(t.match.h2h.teamWins, { team: b })}
+
+
+
{fmt(t.compare.goals, { a: oriented.aGoals, b: oriented.bGoals })}
+
+
{t.match.h2h.recentMeetings}
+
    + {oriented.last.slice(0, 6).map((m2) => ( +
  • + {kickoffDay(m2.date)} + {m2.home} + {m2.hs}–{m2.as} + {m2.away} +
  • + ))} +
+
+
+
+ ) : ( + a !== b && a !== '' && h2h &&

{t.compare.neverMet}

+ )} +
+ ); +} diff --git a/src/lib/i18n/de.ts b/src/lib/i18n/de.ts index f311462..1951f18 100644 --- a/src/lib/i18n/de.ts +++ b/src/lib/i18n/de.ts @@ -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…", diff --git a/src/lib/i18n/en.ts b/src/lib/i18n/en.ts index 4c0cf24..f90477c 100644 --- a/src/lib/i18n/en.ts +++ b/src/lib/i18n/en.ts @@ -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…", diff --git a/src/router.tsx b/src/router.tsx index 84057a9..e4a8784 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -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' });