51dfe00216
- 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>
111 lines
4.6 KiB
TypeScript
111 lines
4.6 KiB
TypeScript
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';
|
|
import { kickoffDay, kickoffTime } from '@/lib/format';
|
|
import { cn } from '@/lib/cn';
|
|
import type { Fixture, MatchProbs, Stage, TeamSlot } from '@/lib/types';
|
|
|
|
const COLUMNS: { stage: Stage; label: string }[] = [
|
|
{ stage: 'r32', label: 'Round of 32' },
|
|
{ stage: 'r16', label: 'Round of 16' },
|
|
{ stage: 'qf', label: 'Quarter-finals' },
|
|
{ stage: 'sf', label: 'Semi-finals' },
|
|
{ stage: 'final', label: 'Final' },
|
|
];
|
|
|
|
function Side({ slot, score, winner }: { slot: TeamSlot; score: number | null; winner: boolean }) {
|
|
return (
|
|
<div className={cn('flex items-center justify-between gap-2 px-2.5 py-1.5', winner && 'font-bold')}>
|
|
<span className="flex min-w-0 items-center gap-1.5">
|
|
{slot.team ? (
|
|
<>
|
|
<span aria-hidden className="text-sm leading-none">{teamFlag(slot.team)}</span>
|
|
<span className="truncate text-ink">{slot.team}</span>
|
|
</>
|
|
) : (
|
|
<span className="truncate text-xs italic text-faint" title={slot.label}>{slot.label}</span>
|
|
)}
|
|
</span>
|
|
{score !== null && <span className="tnum shrink-0 text-ink">{score}</span>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AdvanceBar({ probs }: { probs: MatchProbs }) {
|
|
// Advance probability = regulation win + half of draws (shootout ≈ coin flip).
|
|
const home = Math.round((probs.home + probs.draw / 2) * 100);
|
|
return (
|
|
<div className="flex items-center gap-1.5 border-t border-line px-2.5 py-1" title="Model: chance to advance">
|
|
<span className="tnum w-7 text-[10px] text-accent">{home}%</span>
|
|
<div className="flex h-1.5 flex-1 overflow-hidden rounded-full bg-elevated">
|
|
<div className="bg-accent" style={{ width: `${home}%` }} />
|
|
<div className="bg-info" style={{ width: `${100 - home}%` }} />
|
|
</div>
|
|
<span className="tnum w-7 text-right text-[10px] text-info">{100 - home}%</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<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!} />
|
|
{showProbs && <AdvanceBar probs={probs} />}
|
|
<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>
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
export function BracketPage() {
|
|
const snapshot = useTournamentStore((s) => s.snapshot);
|
|
const model = useTournamentStore((s) => s.model);
|
|
const probsByNum = useMemo(
|
|
() => new Map((model?.matches ?? []).map((m) => [m.num, m.probs])),
|
|
[model],
|
|
);
|
|
|
|
const { byStage, third } = useMemo(() => {
|
|
const fixtures = snapshot?.fixtures ?? [];
|
|
const byStage: Record<Stage, Fixture[]> = {
|
|
group: [], r32: [], r16: [], qf: [], sf: [], third: [], final: [],
|
|
};
|
|
for (const f of fixtures) if (f.stage !== 'group') byStage[f.stage].push(f);
|
|
for (const s of Object.keys(byStage) as Stage[]) byStage[s].sort((a, b) => a.num - b.num);
|
|
return { byStage, third: byStage.third[0] };
|
|
}, [snapshot]);
|
|
|
|
return (
|
|
<div>
|
|
<PageHeader title="Bracket" subtitle="The knockout road — Round of 32 to the Final." />
|
|
|
|
<div className="overflow-x-auto pb-4">
|
|
<div className="flex gap-5">
|
|
{COLUMNS.map((col) => (
|
|
<div key={col.stage} className="flex shrink-0 flex-col gap-3">
|
|
<h2 className="smallcaps">{col.label}</h2>
|
|
{byStage[col.stage].length
|
|
? byStage[col.stage].map((f) => <BracketMatch key={f.num} f={f} probs={probsByNum.get(f.num)} />)
|
|
: <div className="h-20 w-52 animate-pulse rounded-lg border border-line bg-panel" />}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{third && (
|
|
<div className="mt-2">
|
|
<h2 className="smallcaps mb-2">Third-place play-off</h2>
|
|
<BracketMatch f={third} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|