v2 Phase 5: incredible UI pass — deep interlinking + Teams hub

- Match cards surface the model's pre-match expectation (favoured team + win%).
- Everything is now clickable: group-table + odds-table team rows → team
  profiles; bracket matches → match pages; match ↔ team cross-links.
- New /teams hub: all 48 nations ranked by title odds, linking to profiles
  (reachable from Groups + every team link; back-links point here).
- Predict → Methodology link ('How & how accurate'); cohesive hover/transition
  polish across cards.
- 26 tests pass; build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 16:24:46 +02:00
parent adbfb5d6d1
commit 51dfe00216
9 changed files with 99 additions and 10 deletions
+3 -2
View File
@@ -1,3 +1,4 @@
import { Link } from '@tanstack/react-router';
import { cn } from '@/lib/cn';
import { teamFlag } from '@/lib/teams';
import type { StandingRow } from '@/lib/types';
@@ -30,7 +31,7 @@ export function GroupTable({ group, rows }: { group: string; rows: StandingRow[]
)}
>
<td className="px-3 py-1.5">
<span className="flex items-center gap-2">
<Link to="/team/$name" params={{ name: r.team }} className="flex items-center gap-2 hover:text-accent">
<span
className={cn(
'w-4 text-center text-[11px] font-bold tabular-nums',
@@ -41,7 +42,7 @@ export function GroupTable({ group, rows }: { group: string; rows: StandingRow[]
</span>
<span aria-hidden className="text-base leading-none">{teamFlag(r.team)}</span>
<span className="truncate text-ink">{r.team}</span>
</span>
</Link>
</td>
<td className="py-1.5 text-center tabular-nums text-muted">{r.played}</td>
<td className="py-1.5 text-center tabular-nums text-muted">
+20
View File
@@ -1,9 +1,28 @@
import { Link } from '@tanstack/react-router';
import { cn } from '@/lib/cn';
import { teamFlag } from '@/lib/teams';
import type { Fixture } from '@/lib/types';
import { kickoffTime, relativeKickoff } from '@/lib/format';
import { useTournamentStore } from '@/stores/tournamentStore';
import { TeamLabel } from './TeamLabel';
/** The model's pre-match lean for scheduled matches — the "expectation" hint. */
function ModelHint({ num }: { num: number }) {
const pred = useTournamentStore((s) => s.model?.matches.find((m) => m.num === num));
if (!pred) return null;
const { home, draw, away } = pred.probs;
const drawLikely = draw >= home && draw >= away;
const homeFav = home >= away;
const side = drawLikely ? null : homeFav ? pred.home : pred.away;
const p = drawLikely ? draw : Math.max(home, away);
return (
<div className="mt-1.5 text-center text-[11px] text-faint">
model: {side ? <>{teamFlag(side)} {side} </> : 'draw '}
<span className="font-semibold text-muted">{Math.round(p * 100)}%</span>
</div>
);
}
const STAGE_LABEL: Record<Fixture['stage'], string> = {
group: 'Group',
r32: 'Round of 32',
@@ -62,6 +81,7 @@ export function MatchCard({ f }: { f: Fixture }) {
</div>
<TeamLabel slot={f.away} align="left" />
</div>
{f.status === 'scheduled' && <ModelHint num={f.num} />}
<div className="mt-1.5 text-center text-[11px] text-faint">{f.venue}</div>
</Link>
);
+3 -2
View File
@@ -1,3 +1,4 @@
import { Link } from '@tanstack/react-router';
import { teamFlag } from '@/lib/teams';
import { cn } from '@/lib/cn';
import type { TeamOdds } from '@/lib/types';
@@ -42,10 +43,10 @@ export function OddsTable({ odds }: { odds: TeamOdds[] }) {
{odds.map((o) => (
<tr key={o.team} className="border-t border-line/60">
<td className="px-3 py-1.5">
<span className="flex items-center gap-2">
<Link to="/team/$name" params={{ name: o.team }} className="flex items-center gap-2 hover:text-accent">
<span aria-hidden className="text-base leading-none">{teamFlag(o.team)}</span>
<span className="truncate text-sm font-medium text-ink">{o.team}</span>
</span>
</Link>
</td>
{cols.map((c) => (
<Cell key={c.key} x={o[c.key]} accent={c.key === 'champion'} />
+3 -2
View File
@@ -1,4 +1,5 @@
import { useMemo } from 'react';
import { Link } from '@tanstack/react-router';
import { PageHeader } from '@/components/ui/PageHeader';
import { useTournamentStore } from '@/stores/tournamentStore';
import { teamFlag } from '@/lib/teams';
@@ -51,7 +52,7 @@ function BracketMatch({ f, probs }: { f: Fixture; probs?: MatchProbs | undefined
const decided = f.status === 'finished' && f.homeScore !== null && f.awayScore !== null;
const showProbs = !!probs && !!f.home.team && !!f.away.team && f.status !== 'finished';
return (
<div className="w-52 overflow-hidden rounded-lg border border-line bg-panel text-sm">
<Link to="/match/$num" params={{ num: String(f.num) }} className="block w-52 overflow-hidden rounded-lg border border-line bg-panel text-sm transition-colors hover:border-line-strong">
<Side slot={f.home} score={f.homeScore} winner={decided && f.homeScore! > f.awayScore!} />
<div className="h-px bg-line" />
<Side slot={f.away} score={f.awayScore} winner={decided && f.awayScore! > f.homeScore!} />
@@ -59,7 +60,7 @@ function BracketMatch({ f, probs }: { f: Fixture; probs?: MatchProbs | undefined
<div className="border-t border-line bg-surface-2 px-2.5 py-1 text-[10px] text-faint">
M{f.num} · {f.status === 'finished' ? 'FT' : `${kickoffDay(f.kickoff)} ${kickoffTime(f.kickoff)}`}
</div>
</div>
</Link>
);
}
+7
View File
@@ -1,3 +1,5 @@
import { Link } from '@tanstack/react-router';
import { Users } from 'lucide-react';
import { PageHeader } from '@/components/ui/PageHeader';
import { GroupTable } from '@/components/GroupTable';
import { useTournamentStore } from '@/stores/tournamentStore';
@@ -12,6 +14,11 @@ export function GroupsPage() {
<PageHeader
title="Groups"
subtitle="All 12 groups · top 2 advance, plus the 8 best third-placed teams."
actions={
<Link to="/teams" className="inline-flex items-center gap-1.5 rounded-md border border-line px-3 py-1.5 text-sm font-medium text-muted hover:bg-elevated hover:text-ink">
<Users size={15} /> All 48 teams
</Link>
}
/>
{groups.length ? (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
+7 -1
View File
@@ -1,5 +1,6 @@
import { lazy, Suspense, useMemo } from 'react';
import { TrendingUp, Trophy } from 'lucide-react';
import { Link } from '@tanstack/react-router';
import { Gauge, TrendingUp, Trophy } from 'lucide-react';
import { PageHeader } from '@/components/ui/PageHeader';
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
import { OddsTable } from '@/components/OddsTable';
@@ -64,6 +65,11 @@ export function PredictionsPage() {
<PageHeader
title="Predict"
subtitle={`Elo + Dixon-Coles + ${model.iterations.toLocaleString()} Monte Carlo simulations · ratings as of ${model.asOf}`}
actions={
<Link to="/methodology" className="inline-flex items-center gap-1.5 rounded-md border border-line px-3 py-1.5 text-sm font-medium text-muted hover:bg-elevated hover:text-ink">
<Gauge size={15} /> How &amp; how accurate
</Link>
}
/>
<div className="mb-6 grid gap-4 lg:grid-cols-2">
+2 -2
View File
@@ -52,8 +52,8 @@ export function TeamProfilePage() {
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 to="/teams" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-ink">
<ArrowLeft size={15} /> All teams
</Link>
<Card>
+51
View File
@@ -0,0 +1,51 @@
import { useMemo } from 'react';
import { Link } from '@tanstack/react-router';
import { PageHeader } from '@/components/ui/PageHeader';
import { teamFlag } from '@/lib/teams';
import { useTournamentStore } from '@/stores/tournamentStore';
const pct = (x: number) => (x >= 0.995 ? '>99%' : x < 0.005 ? '—' : `${(x * 100).toFixed(x < 0.1 ? 1 : 0)}%`);
export function TeamsPage() {
const model = useTournamentStore((s) => s.model);
const snapshot = useTournamentStore((s) => s.snapshot);
const teams = useMemo(() => {
const groupOf = new Map<string, string>();
for (const [g, rows] of Object.entries(snapshot?.tables ?? {})) for (const r of rows) groupOf.set(r.team, g);
return (model?.odds ?? []).map((o) => ({ ...o, group: groupOf.get(o.team) ?? null }));
}, [model, snapshot]);
return (
<div>
<PageHeader title="Teams" subtitle="All 48 nations, ranked by the model's title odds. Tap any team for its profile." />
{teams.length ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{teams.map((t, i) => (
<Link
key={t.team}
to="/team/$name"
params={{ name: t.team }}
className="flex items-center gap-3 rounded-xl border border-line bg-panel px-4 py-3 transition-colors hover:border-line-strong hover:bg-panel-2"
>
<span className="w-5 text-right text-xs font-bold tabular-nums text-faint">{i + 1}</span>
<span aria-hidden className="text-2xl leading-none">{teamFlag(t.team)}</span>
<div className="min-w-0">
<div className="truncate font-semibold text-ink">{t.team}</div>
<div className="text-[11px] text-faint">{t.group ? `Group ${t.group}` : ''}</div>
</div>
<div className="ml-auto text-right">
<div className="tnum text-sm font-bold text-gold">{pct(t.champion)}</div>
<div className="text-[11px] text-faint">advance {pct(t.qualify)}</div>
</div>
</Link>
))}
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 9 }).map((_, i) => <div key={i} className="h-16 animate-pulse rounded-xl border border-line bg-panel" />)}
</div>
)}
</div>
);
}
+3 -1
View File
@@ -9,6 +9,7 @@ import { StoryPage } from './features/story/StoryPage';
import { MatchPreviewPage } from './features/match/MatchPreviewPage';
import { TeamProfilePage } from './features/team/TeamProfilePage';
import { MethodologyPage } from './features/methodology/MethodologyPage';
import { TeamsPage } from './features/teams/TeamsPage';
const rootRoute = createRootRoute({
component: RootLayout,
@@ -23,8 +24,9 @@ const storyRoute = createRoute({ getParentRoute: () => rootRoute, path: '/story'
const matchRoute = createRoute({ getParentRoute: () => rootRoute, path: '/match/$num', component: MatchPreviewPage });
const teamRoute = createRoute({ getParentRoute: () => rootRoute, path: '/team/$name', component: TeamProfilePage });
const methodologyRoute = createRoute({ getParentRoute: () => rootRoute, path: '/methodology', component: MethodologyPage });
const teamsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/teams', component: TeamsPage });
const routeTree = rootRoute.addChildren([indexRoute, groupsRoute, bracketRoute, predictRoute, storyRoute, matchRoute, teamRoute, methodologyRoute]);
const routeTree = rootRoute.addChildren([indexRoute, groupsRoute, bracketRoute, predictRoute, storyRoute, matchRoute, teamRoute, methodologyRoute, teamsRoute]);
export const router = createRouter({ routeTree, defaultPreload: 'intent' });