@@ -1,4 +1,4 @@
import { useEffect , useState } from 'react' ;
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' ;
@@ -6,175 +6,63 @@ import { WinProbBar } from '@/components/WinProbBar';
import { FormChips } from '@/components/FormChips' ;
import { teamFlag } from '@/lib/teams' ;
import { kickoffDay , kickoffTime } from '@/lib/format' ;
import type { KeyPlayer , MatchPreview } from '@/lib/types ' ;
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 >
< 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 ? < span className = "text-faint/70" > ( { p . pens } p ) < / span > : null }
< / 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 >
) ;
}
export function MatchPreviewPage() {
const { num } = useParams ( { strict : false } ) as { num : string } ;
const [ preview , setPreview ] = useState < MatchPreview | null > ( null ) ;
const [ failed , setFailed ] = useState ( false ) ;
useEffect ( ( ) = > {
let alive = true ;
setPreview ( null ) ;
setFailed ( false ) ;
fetch ( ` /api/preview/ ${ num } ` )
. then ( ( r ) = > ( r . ok ? r . json ( ) : Promise . reject ( ) ) )
. then ( ( d : MatchPreview ) = > alive && setPreview ( d ) )
. catch ( ( ) = > alive && setFailed ( true ) ) ;
return ( ) = > { alive = false ; } ;
} , [ num ] ) ;
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 ) return < div className = "h-96 animate-pulse rounded-xl border border-line bg-panel" / > ;
const f = preview . fixture ;
const hasScore = f . status === 'live' || f . status === 'finished' ;
const homeName = f . home . label ;
const awayName = f . away . label ;
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 >
< 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 >
) }
{ f . status === 'live' && < div className = "text-[11px] font-bold uppercase text-live" > { f . minute ? ` ${ f . minute } ' ` : 'Live' } < / 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 >
{ /* model expectation */ }
{ preview . prediction && (
< Card >
< CardHeader > < span className = "font-display font-bold text-ink" > 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 */ }
< 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 >
) : < p className = "text-xs text-faint" > no data < / p > }
< / div >
) ;
}
@@ -197,3 +85,175 @@ function LineupCol({ team, players }: { team: string; players: { name: string; p
< / 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 ( ) ; } , 20 _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 >
) ;
}