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 = { 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 (
{home} {label} {away}
); } function ScorerList({ team, players }: { team: string; players: KeyPlayer[] }) { return (
{teamFlag(team)} {team}
{players.length ? (
    {players.slice(0, 6).map((p) => (
  • {p.name} {p.goals}{p.pens ? ` (${p.pens}p)` : ''}
  • ))}
) :

no data

}
); } 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 (
{team}
    {list.map((p) => (
  • {p.number ?? ''} {p.name} {p.position}
  • ))}
); } export function MatchPreviewPage() { const { num: numParam } = useParams({ strict: false }) as { num: string }; const [preview, setPreview] = useState(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
Couldn't load that match. Back to Live
; if (!preview || !f) return
; const hasScore = f.status === 'live' || f.status === 'finished'; const homeName = f.home.label; const awayName = f.away.label; const finished = f.status === 'finished'; return (
Back {/* hero */}
{f.group ? `Group ${f.group}` : STAGE[f.stage]} · {kickoffDay(f.kickoff)} · {kickoffTime(f.kickoff)} · {preview.venue}
{f.home.team ? teamFlag(f.home.team) : '⚽'} {homeName}
{hasScore ?
{f.homeScore ?? 0}–{f.awayScore ?? 0}
:
{kickoffTime(f.kickoff)}
} {isLive &&
{f.minute ? `${f.minute}'` : 'Live'}
} {finished &&
Full time
}
{f.away.team ? teamFlag(f.away.team) : '⚽'} {awayName}
{/* LIVE: in-play win probability */} {inPlay && preview.prediction && ( Live win probability

Updates with the score and clock — remaining goals modelled from each side's pre-match expectation.

)} {/* LIVE/FT: stats */} {preview.liveStats && ( Match stats {STAT_ROWS.filter(([k]) => preview.liveStats!.home[k] != null || preview.liveStats!.away[k] != null).map(([k, label]) => ( ))} )} {/* LIVE/FT: timeline */} {preview.events.length > 0 && ( Timeline
    {preview.events.map((e, i) => (
  • {e.minute != null ? `${e.minute}'` : ''} {eventIcon(e.type)} {e.text || e.type}
  • ))}
)} {/* model expectation */} {preview.prediction && ( {hasScore ? 'Pre-match model' : 'Model expectation'}

Expected goals: {preview.prediction.lambdaHome.toFixed(2)} – {preview.prediction.lambdaAway.toFixed(2)} · model odds, not betting advice

)} {/* form */} {(preview.form.home.length > 0 || preview.form.away.length > 0) && ( Recent form
{f.home.team && teamFlag(f.home.team)} {homeName}
{f.away.team && teamFlag(f.away.team)} {awayName}
)} {/* head to head */} {preview.h2h && ( Head to head
{preview.h2h.homeWins}
{homeName} wins
{preview.h2h.draws}
draws · {preview.h2h.games} total
{preview.h2h.awayWins}
{awayName} wins
Recent meetings
    {preview.h2h.last.map((m, i) => (
  • {m.date.slice(0, 10)}{m.home} {m.homeScore}–{m.awayScore} {m.away}
  • ))}
)} {/* lineups or key players */} {preview.lineups ? 'Lineups' : 'Key players (all-time scorers)'} {preview.lineups ? ( <> ) : ( <> )}

{preview.dataNote}

); }