Files
cup26/src/features/bracket/BracketPage.tsx
T
NilsBriggen 51dfe00216 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>
2026-06-11 16:24:46 +02:00

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>
);
}