Files
cup26/src/features/match/MatchPreviewPage.tsx
T
NilsBriggen a3f529746f Much fresher live data: 8s in-play polling, live-TTL summary fetches
The live staleness bug: liveTick refetched ESPN summaries every 20s but
the cache TTL was 5 minutes, so in-play stats/timeline barely moved.
Summaries now take a live flag (15s TTL in play), the scoreboard TTL
drops 20s->5s, and polling gets three tiers (8s in-play / 20s near
kickoff / 10m idle) via a pure, tested cadence module. The
football-data fallback is throttled to one attempt per 15s so the 8s
cadence can never breach its 10 req/min cap. Enrichment runs every 5m
(was 20m); client refetch intervals tighten to 10s (live match page)
and 30s (scoreboard).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 23:18:38 +02:00

260 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from 'react';
import { Link, useParams } from '@tanstack/react-router';
import { ArrowLeft, Shirt } from 'lucide-react';
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
import { WinProbBar } from '@/components/WinProbBar';
import { FormChips } from '@/components/FormChips';
import { teamFlag } from '@/lib/teams';
import { kickoffDay, kickoffTime } from '@/lib/format';
import { inPlayProbs } from '@/lib/model/inplay';
import { useTournamentStore } from '@/stores/tournamentStore';
import type { Fixture, KeyPlayer, MatchPreview } from '@/lib/types';
const STAGE: Record<string, string> = {
group: 'Group', r32: 'Round of 32', r16: 'Round of 16', qf: 'Quarter-final', sf: 'Semi-final', third: 'Third place', final: 'Final',
};
const STAT_ROWS: [string, string][] = [
['possessionPct', 'Possession'],
['totalShots', 'Shots'],
['shotsOnTarget', 'On target'],
['wonCorners', 'Corners'],
['foulsCommitted', 'Fouls'],
['saves', 'Saves'],
];
const num = (s: string | undefined) => (s ? Number(s.replace('%', '')) || 0 : 0);
function eventIcon(type: string): string {
const t = type.toLowerCase();
if (t.includes('goal') || t.includes('penalty - scored')) return '⚽';
if (t.includes('yellow')) return '🟨';
if (t.includes('red')) return '🟥';
if (t.includes('substitution')) return '🔁';
return '•';
}
function LiveStatRow({ label, home, away }: { label: string; home: number; away: number }) {
const total = home + away || 1;
return (
<div>
<div className="flex items-center justify-between text-sm">
<span className="tnum font-bold text-accent">{home}</span>
<span className="text-xs uppercase tracking-wide text-faint">{label}</span>
<span className="tnum font-bold text-info">{away}</span>
</div>
<div className="mt-1 flex h-1.5 overflow-hidden rounded-full bg-elevated">
<div className="bg-accent" style={{ width: `${(home / total) * 100}%` }} />
<div className="ml-auto bg-info" style={{ width: `${(away / total) * 100}%` }} />
</div>
</div>
);
}
function ScorerList({ team, players }: { team: string; players: KeyPlayer[] }) {
return (
<div>
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-ink"><span aria-hidden>{teamFlag(team)}</span> {team}</div>
{players.length ? (
<ul className="space-y-1">
{players.slice(0, 6).map((p) => (
<li key={p.name} className="flex items-center justify-between text-sm">
<span className="truncate text-ink-soft">{p.name}</span>
<span className="tnum shrink-0 text-faint">{p.goals}{p.pens ? ` (${p.pens}p)` : ''}</span>
</li>
))}
</ul>
) : <p className="text-xs text-faint">no data</p>}
</div>
);
}
function LineupCol({ team, players }: { team: string; players: { name: string; position: string | null; number: number | null; starter: boolean }[] }) {
const starters = players.filter((p) => p.starter);
const list = starters.length ? starters : players;
return (
<div>
<div className="mb-2 text-sm font-semibold text-ink">{team}</div>
<ul className="space-y-0.5 text-sm">
{list.map((p) => (
<li key={p.name} className="flex items-center gap-2 text-ink-soft">
<span className="tnum w-5 text-right text-xs text-faint">{p.number ?? ''}</span>
<span className="truncate">{p.name}</span>
<span className="ml-auto text-[11px] text-faint">{p.position}</span>
</li>
))}
</ul>
</div>
);
}
export function MatchPreviewPage() {
const { num: numParam } = useParams({ strict: false }) as { num: string };
const [preview, setPreview] = useState<MatchPreview | null>(null);
const [failed, setFailed] = useState(false);
// Live score/minute arrive over the WebSocket snapshot — overlay them on the
// (less-frequently-fetched) preview.
const liveFixture = useTournamentStore((s) => s.snapshot?.fixtures.find((f) => f.num === Number(numParam)));
const isLive = (liveFixture?.status ?? preview?.fixture.status) === 'live';
useEffect(() => {
let alive = true;
setPreview(null);
setFailed(false);
const load = () =>
fetch(`/api/preview/${numParam}`)
.then((r) => (r.ok ? r.json() : Promise.reject()))
.then((d: MatchPreview) => alive && setPreview(d))
.catch(() => alive && setFailed(true));
void load();
const id = setInterval(() => { if (useTournamentStore.getState().snapshot?.fixtures.find((f) => f.num === Number(numParam))?.status === 'live') void load(); }, 10_000);
return () => { alive = false; clearInterval(id); };
}, [numParam]);
const f: Fixture | undefined = liveFixture ?? preview?.fixture;
const inPlay = useMemo(() => {
if (!preview?.prediction || !f || f.status !== 'live' || f.homeScore == null || f.awayScore == null) return null;
return inPlayProbs(preview.prediction.lambdaHome, preview.prediction.lambdaAway, f.minute ?? 0, f.homeScore, f.awayScore);
}, [preview, f]);
if (failed) return <div className="py-16 text-center text-muted">Couldn&apos;t load that match. <Link to="/" className="text-accent">Back to Live</Link></div>;
if (!preview || !f) return <div className="h-96 animate-pulse rounded-xl border border-line bg-panel" />;
const hasScore = f.status === 'live' || f.status === 'finished';
const homeName = f.home.label;
const awayName = f.away.label;
const finished = f.status === 'finished';
return (
<div className="space-y-5">
<Link to="/" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-ink"><ArrowLeft size={15} /> Back</Link>
{/* hero */}
<Card className={isLive ? 'border-live/40 ring-1 ring-live/30' : ''}>
<CardBody>
<div className="mb-3 text-center text-xs uppercase tracking-wide text-faint">
{f.group ? `Group ${f.group}` : STAGE[f.stage]} · {kickoffDay(f.kickoff)} · {kickoffTime(f.kickoff)} · {preview.venue}
</div>
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-3">
<Link to="/team/$name" params={{ name: f.home.team ?? '' }} className="flex flex-col items-center gap-1 hover:opacity-80">
<span aria-hidden className="text-4xl">{f.home.team ? teamFlag(f.home.team) : '⚽'}</span>
<span className="text-center text-sm font-semibold text-ink">{homeName}</span>
</Link>
<div className="text-center">
{hasScore ? <div className="tnum text-3xl font-extrabold text-ink">{f.homeScore ?? 0}{f.awayScore ?? 0}</div> : <div className="tnum text-lg font-bold text-muted">{kickoffTime(f.kickoff)}</div>}
{isLive && <div className="inline-flex items-center gap-1 text-[11px] font-bold uppercase text-live"><span className="live-dot h-1.5 w-1.5 rounded-full bg-live" />{f.minute ? `${f.minute}'` : 'Live'}</div>}
{finished && <div className="text-[11px] font-bold uppercase text-faint">Full time</div>}
</div>
<Link to="/team/$name" params={{ name: f.away.team ?? '' }} className="flex flex-col items-center gap-1 hover:opacity-80">
<span aria-hidden className="text-4xl">{f.away.team ? teamFlag(f.away.team) : '⚽'}</span>
<span className="text-center text-sm font-semibold text-ink">{awayName}</span>
</Link>
</div>
</CardBody>
</Card>
{/* LIVE: in-play win probability */}
{inPlay && preview.prediction && (
<Card className="border-live/30">
<CardHeader className="flex items-center gap-2">
<span className="live-dot h-2 w-2 rounded-full bg-live" />
<span className="font-display font-bold text-ink">Live win probability</span>
</CardHeader>
<CardBody>
<WinProbBar home={homeName} away={awayName} probs={inPlay} />
<p className="mt-2 text-xs text-faint">Updates with the score and clock remaining goals modelled from each side&apos;s pre-match expectation.</p>
</CardBody>
</Card>
)}
{/* LIVE/FT: stats */}
{preview.liveStats && (
<Card>
<CardHeader><span className="font-display font-bold text-ink">Match stats</span></CardHeader>
<CardBody className="space-y-3">
{STAT_ROWS.filter(([k]) => preview.liveStats!.home[k] != null || preview.liveStats!.away[k] != null).map(([k, label]) => (
<LiveStatRow key={k} label={label} home={num(preview.liveStats!.home[k])} away={num(preview.liveStats!.away[k])} />
))}
</CardBody>
</Card>
)}
{/* LIVE/FT: timeline */}
{preview.events.length > 0 && (
<Card>
<CardHeader><span className="font-display font-bold text-ink">Timeline</span></CardHeader>
<CardBody>
<ul className="space-y-1.5">
{preview.events.map((e, i) => (
<li key={i} className={`flex items-center gap-2 text-sm ${e.team === 'away' ? 'flex-row-reverse text-right' : ''}`}>
<span className="tnum w-8 shrink-0 text-xs text-faint">{e.minute != null ? `${e.minute}'` : ''}</span>
<span aria-hidden>{eventIcon(e.type)}</span>
<span className="truncate text-ink-soft">{e.text || e.type}</span>
</li>
))}
</ul>
</CardBody>
</Card>
)}
{/* model expectation */}
{preview.prediction && (
<Card>
<CardHeader><span className="font-display font-bold text-ink">{hasScore ? 'Pre-match model' : 'Model expectation'}</span></CardHeader>
<CardBody className="space-y-2">
<WinProbBar home={preview.prediction.home} away={preview.prediction.away} probs={preview.prediction.probs} topScore={preview.prediction.topScore} />
<p className="text-xs text-faint">Expected goals: {preview.prediction.lambdaHome.toFixed(2)} {preview.prediction.lambdaAway.toFixed(2)} · model odds, not betting advice</p>
</CardBody>
</Card>
)}
{/* form */}
{(preview.form.home.length > 0 || preview.form.away.length > 0) && (
<Card>
<CardHeader><span className="font-display font-bold text-ink">Recent form</span></CardHeader>
<CardBody className="space-y-3">
<div className="flex items-center justify-between"><span className="flex items-center gap-2 text-sm font-medium text-ink">{f.home.team && teamFlag(f.home.team)} {homeName}</span><FormChips form={preview.form.home} /></div>
<div className="flex items-center justify-between"><span className="flex items-center gap-2 text-sm font-medium text-ink">{f.away.team && teamFlag(f.away.team)} {awayName}</span><FormChips form={preview.form.away} /></div>
</CardBody>
</Card>
)}
{/* head to head */}
{preview.h2h && (
<Card>
<CardHeader><span className="font-display font-bold text-ink">Head to head</span></CardHeader>
<CardBody>
<div className="mb-3 grid grid-cols-3 items-center text-center">
<div><div className="tnum text-2xl font-extrabold text-accent">{preview.h2h.homeWins}</div><div className="text-[11px] text-faint">{homeName} wins</div></div>
<div><div className="tnum text-2xl font-extrabold text-muted">{preview.h2h.draws}</div><div className="text-[11px] text-faint">draws · {preview.h2h.games} total</div></div>
<div><div className="tnum text-2xl font-extrabold text-info">{preview.h2h.awayWins}</div><div className="text-[11px] text-faint">{awayName} wins</div></div>
</div>
<div className="border-t border-line pt-2">
<div className="mb-1 text-[11px] uppercase tracking-wide text-faint">Recent meetings</div>
<ul className="space-y-1 text-sm">
{preview.h2h.last.map((m, i) => (
<li key={i} className="flex items-center justify-between text-ink-soft"><span className="text-faint">{m.date.slice(0, 10)}</span><span>{m.home} <span className="tnum font-semibold text-ink">{m.homeScore}{m.awayScore}</span> {m.away}</span></li>
))}
</ul>
</div>
</CardBody>
</Card>
)}
{/* lineups or key players */}
<Card>
<CardHeader className="flex items-center gap-2"><Shirt size={16} className="text-accent" /><span className="font-display font-bold text-ink">{preview.lineups ? 'Lineups' : 'Key players (all-time scorers)'}</span></CardHeader>
<CardBody className="grid grid-cols-2 gap-6">
{preview.lineups ? (
<><LineupCol team={homeName} players={preview.lineups.home} /><LineupCol team={awayName} players={preview.lineups.away} /></>
) : (
<><ScorerList team={homeName} players={preview.keyPlayers.home} /><ScorerList team={awayName} players={preview.keyPlayers.away} /></>
)}
</CardBody>
</Card>
<p className="text-center text-xs text-faint">{preview.dataNote}</p>
</div>
);
}