diff --git a/server/src/ingest/espnNormalize.ts b/server/src/ingest/espnNormalize.ts index 770b40e..ef07765 100644 --- a/server/src/ingest/espnNormalize.ts +++ b/server/src/ingest/espnNormalize.ts @@ -64,6 +64,7 @@ export function normalizeScoreboard(body: { events?: EspnEvent[] }): EspnMatch[] export interface EspnH2HGame { date: string; homeTeamId: string; awayTeamId: string; homeScore: number; awayScore: number } export interface EspnTeamRef { id: string; name: string; homeAway: 'home' | 'away' } export interface EspnLineupPlayer { name: string; position: string | null; starter: boolean; number: number | null } +export interface EspnTimelineEvent { minute: number | null; type: string; text: string; teamId: string | null } export interface EspnSummary { fetchedAt: number; venue: string | null; @@ -74,6 +75,8 @@ export interface EspnSummary { lineups: Record; /** Team match stats (possession, shots, …) once the game is live/finished. */ stats: Record>; + /** Live/finished event timeline (goals, cards, subs). */ + events: EspnTimelineEvent[]; } export interface RawSummary { @@ -85,6 +88,7 @@ export interface RawSummary { teams?: { team?: { id: string }; statistics?: { name: string; displayValue: string }[] }[]; }; rosters?: { team?: { id: string }; roster?: { athlete?: { displayName?: string }; position?: { abbreviation?: string }; starter?: boolean; jersey?: string }[] }[]; + keyEvents?: { clock?: { displayValue?: string }; type?: { text?: string }; text?: string; team?: { id?: string } }[]; } export function normalizeSummary(raw: RawSummary): EspnSummary { @@ -128,5 +132,15 @@ export function normalizeSummary(raw: RawSummary): EspnSummary { stats[id] = Object.fromEntries((t.statistics ?? []).map((s) => [s.name, s.displayValue])); } - return { fetchedAt: Date.now(), venue: raw.gameInfo?.venue?.fullName ?? null, teams, h2h, form, lineups, stats }; + const events: EspnTimelineEvent[] = (raw.keyEvents ?? []).map((e) => { + const m = e.clock?.displayValue?.match(/(\d+)/); + return { + minute: m ? Number(m[1]) : null, + type: e.type?.text ?? '', + text: e.text ?? '', + teamId: e.team?.id ?? null, + }; + }); + + return { fetchedAt: Date.now(), venue: raw.gameInfo?.venue?.fullName ?? null, teams, h2h, form, lineups, stats, events }; } diff --git a/server/src/ingest/scheduler.ts b/server/src/ingest/scheduler.ts index 2c993f6..1723a54 100644 --- a/server/src/ingest/scheduler.ts +++ b/server/src/ingest/scheduler.ts @@ -77,6 +77,14 @@ export function startScheduler(state: TournamentState, onChange: () => void): () const changed = state.mergeLive(matches, source); if (changed.length) { console.log(`[ingest] ${changed.length} fixture(s) updated via ${source}`); onChange(); } } + + // Keep live matches' stats + event timeline fresh at the live cadence. + for (const f of state.allFixtures()) { + if (f.status !== 'live') continue; + const eid = getSourceMap(f.num, 'espn'); + if (!eid) continue; + try { setMatchExt(f.num, await fetchEspnSummary(eid)); } catch { /* logged via health */ } + } }; // ---- enrichment: ESPN summary for imminent/live fixtures ---- diff --git a/server/src/preview.ts b/server/src/preview.ts index a580428..0d1f0c9 100644 --- a/server/src/preview.ts +++ b/server/src/preview.ts @@ -85,6 +85,16 @@ export function buildPreview(num: number, state: TournamentState, model: ModelEn home: home ? scorersData()[home] ?? [] : [], away: away ? scorersData()[away] ?? [] : [], }, + liveStats: + eh && ea && (Object.keys(summary!.stats[eh.id] ?? {}).length || Object.keys(summary!.stats[ea.id] ?? {}).length) + ? { home: summary!.stats[eh.id] ?? {}, away: summary!.stats[ea.id] ?? {} } + : null, + events: (summary?.events ?? []).map((e) => ({ + minute: e.minute, + type: e.type, + text: e.text, + team: e.teamId === eh?.id ? 'home' : e.teamId === ea?.id ? 'away' : null, + })), dataNote: !home || !away ? 'Knockout participants are decided once the bracket resolves.' : ext diff --git a/src/features/match/MatchPreviewPage.tsx b/src/features/match/MatchPreviewPage.tsx index d5be2ef..8591ca3 100644 --- a/src/features/match/MatchPreviewPage.tsx +++ b/src/features/match/MatchPreviewPage.tsx @@ -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 = { 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} -
+
{teamFlag(team)} {team}
{players.length ? (
    {players.slice(0, 6).map((p) => (
  • {p.name} - - {p.goals}{p.pens ? ({p.pens}p) : null} - + {p.goals}{p.pens ? ` (${p.pens}p)` : ''}
  • ))}
- ) : ( -

no data

- )} -
- ); -} - -export function MatchPreviewPage() { - const { num } = useParams({ strict: false }) as { num: string }; - const [preview, setPreview] = useState(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 ( -
- Couldn't load that match. Back to Live -
- ); - } - if (!preview) return
; - - const f = preview.fixture; - const hasScore = f.status === 'live' || f.status === 'finished'; - const homeName = f.home.label; - const awayName = f.away.label; - - 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)}
- )} - {f.status === 'live' &&
{f.minute ? `${f.minute}'` : 'Live'}
} -
- - {f.away.team ? teamFlag(f.away.team) : '⚽'} - {awayName} - -
-
-
- - {/* model expectation */} - {preview.prediction && ( - - Model expectation - - -

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

-
-
- )} - - {/* form */} - - 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}

+ ) :

no data

}
); } @@ -197,3 +85,175 @@ function LineupCol({ team, players }: { team: string; players: { name: string; p
); } + +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(); }, 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
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}

+
+ ); +} diff --git a/src/lib/model/inplay.test.ts b/src/lib/model/inplay.test.ts new file mode 100644 index 0000000..4e10b1b --- /dev/null +++ b/src/lib/model/inplay.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest'; +import { inPlayProbs } from './inplay'; + +describe('inPlayProbs', () => { + it('sums to 1 and roughly matches pre-match at kickoff', () => { + const p = inPlayProbs(1.8, 0.9, 0, 0, 0); + expect(p.home + p.draw + p.away).toBeCloseTo(1, 6); + expect(p.home).toBeGreaterThan(p.away); // stronger side favoured + }); + + it('locks the result at full time', () => { + expect(inPlayProbs(1.5, 1.2, 90, 2, 1)).toEqual({ home: 1, draw: 0, away: 0 }); + expect(inPlayProbs(1.5, 1.2, 95, 1, 1)).toEqual({ home: 0, draw: 1, away: 0 }); + expect(inPlayProbs(1.5, 1.2, 90, 0, 2)).toEqual({ home: 0, draw: 0, away: 1 }); + }); + + it('a lead becomes more decisive as time runs down', () => { + const early = inPlayProbs(1.4, 1.4, 20, 1, 0); + const late = inPlayProbs(1.4, 1.4, 80, 1, 0); + expect(late.home).toBeGreaterThan(early.home); + expect(late.away).toBeLessThan(early.away); + }); + + it('chasing team still has hope early but little late', () => { + const trailingEarly = inPlayProbs(1.3, 1.3, 30, 0, 1).home; + const trailingLate = inPlayProbs(1.3, 1.3, 85, 0, 1).home; + expect(trailingEarly).toBeGreaterThan(trailingLate); + expect(trailingLate).toBeLessThan(0.1); + }); +}); diff --git a/src/lib/model/inplay.ts b/src/lib/model/inplay.ts new file mode 100644 index 0000000..66dab63 --- /dev/null +++ b/src/lib/model/inplay.ts @@ -0,0 +1,44 @@ +import { poissonPmf, type OutcomeProbs } from './poisson'; + +// In-play win probability. Given each side's pre-match goal expectation, the +// current score, and the minute, the remaining goals are modelled as independent +// Poisson processes scaled to the time left — convolved with the current score to +// give live win/draw/loss. Cheap enough to recompute on every tick. + +const REG_MINUTES = 90; +const MAX_GOALS = 12; + +export function inPlayProbs( + lambdaHome: number, + lambdaAway: number, + minute: number, + scoreHome: number, + scoreAway: number, +): OutcomeProbs { + const remaining = Math.max(0, Math.min(1, (REG_MINUTES - minute) / REG_MINUTES)); + + if (remaining <= 0) { + if (scoreHome > scoreAway) return { home: 1, draw: 0, away: 0 }; + if (scoreHome < scoreAway) return { home: 0, draw: 0, away: 1 }; + return { home: 0, draw: 1, away: 0 }; + } + + const lh = lambdaHome * remaining; + const la = lambdaAway * remaining; + const ph = Array.from({ length: MAX_GOALS + 1 }, (_, k) => poissonPmf(lh, k)); + const pa = Array.from({ length: MAX_GOALS + 1 }, (_, k) => poissonPmf(la, k)); + + let home = 0, draw = 0, away = 0; + for (let i = 0; i <= MAX_GOALS; i++) { + for (let j = 0; j <= MAX_GOALS; j++) { + const p = ph[i]! * pa[j]!; + const fh = scoreHome + i; + const fa = scoreAway + j; + if (fh > fa) home += p; + else if (fh < fa) away += p; + else draw += p; + } + } + const total = home + draw + away; + return { home: home / total, draw: draw / total, away: away / total }; +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 0ede4d3..2c31512 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -133,6 +133,7 @@ export interface H2HSummary { export interface KeyPlayer { name: string; goals: number; pens: number } export interface LineupPlayer { name: string; position: string | null; starter: boolean; number: number | null } +export interface MatchLiveEvent { minute: number | null; type: string; text: string; team: 'home' | 'away' | null } export interface MatchPreview { num: number; @@ -144,6 +145,10 @@ export interface MatchPreview { h2h: H2HSummary | null; lineups: { home: LineupPlayer[]; away: LineupPlayer[] } | null; keyPlayers: { home: KeyPlayer[]; away: KeyPlayer[] }; + /** Live/finished team stats (possession, shots, …), null pre-match. */ + liveStats: { home: Record; away: Record } | null; + /** Live/finished event timeline (goals, cards, subs). */ + events: MatchLiveEvent[]; /** Honest note on which data layers are populated yet. */ dataNote: string; updatedAt: number | null;