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>
89 lines
3.3 KiB
TypeScript
89 lines
3.3 KiB
TypeScript
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',
|
||
r16: 'Round of 16',
|
||
qf: 'Quarter-final',
|
||
sf: 'Semi-final',
|
||
third: 'Third place',
|
||
final: 'Final',
|
||
};
|
||
|
||
function tag(f: Fixture): string {
|
||
if (f.stage === 'group') return `Group ${f.group}`;
|
||
return STAGE_LABEL[f.stage];
|
||
}
|
||
|
||
function StatusPill({ f }: { f: Fixture }) {
|
||
if (f.status === 'live') {
|
||
return (
|
||
<span className="inline-flex items-center gap-1.5 rounded-full border border-live/40 bg-live/15 px-2 py-0.5 text-[11px] font-bold uppercase tracking-wide text-live">
|
||
<span className="live-dot h-1.5 w-1.5 rounded-full bg-live" />
|
||
{f.minute ? `${f.minute}'` : 'Live'}
|
||
</span>
|
||
);
|
||
}
|
||
if (f.status === 'finished') {
|
||
return <span className="text-[11px] font-bold uppercase tracking-wide text-faint">FT</span>;
|
||
}
|
||
return <span className="text-[11px] font-semibold text-muted">{relativeKickoff(f.kickoff)}</span>;
|
||
}
|
||
|
||
export function MatchCard({ f }: { f: Fixture }) {
|
||
const hasScore = f.status === 'live' || f.status === 'finished';
|
||
return (
|
||
<Link
|
||
to="/match/$num"
|
||
params={{ num: String(f.num) }}
|
||
className={cn(
|
||
'block rounded-xl border border-line bg-panel px-4 py-3 transition-colors hover:border-line-strong hover:bg-panel-2',
|
||
f.status === 'live' && 'border-live/40 ring-1 ring-live/30',
|
||
)}
|
||
>
|
||
<div className="mb-2 flex items-center justify-between">
|
||
<span className="smallcaps">{tag(f)}</span>
|
||
<StatusPill f={f} />
|
||
</div>
|
||
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-3">
|
||
<TeamLabel slot={f.home} align="right" />
|
||
<div className="min-w-[64px] text-center">
|
||
{hasScore ? (
|
||
<div className="tnum text-xl font-bold text-ink">
|
||
{f.homeScore ?? 0}<span className="px-1.5 text-faint">–</span>{f.awayScore ?? 0}
|
||
</div>
|
||
) : (
|
||
<div className="tnum text-sm font-semibold text-muted">{kickoffTime(f.kickoff)}</div>
|
||
)}
|
||
</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>
|
||
);
|
||
}
|