From cbc5ff0468332409e87d58c8f57daf57923e07f8 Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Fri, 12 Jun 2026 00:50:56 +0200 Subject: [PATCH] In-play win-probability timeline: the momentum curve on match pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While a match runs, every poll records the in-play model probability per game state (insert-if-new on minute+score) into inplay_history. The match page draws it as a stacked area chart — home fills from the bottom, away from the top, the gap is the draw — with minute ticks and a 50% guide. Shows live (under the win-probability bar) and after full time as its own card. EN/DE strings included. Co-Authored-By: Claude Fable 5 --- server/src/db/db.ts | 38 ++++++++++++++ server/src/index.ts | 28 ++++++++++- src/components/WinProbTimeline.tsx | 66 +++++++++++++++++++++++++ src/features/match/MatchPreviewPage.tsx | 31 +++++++++++- src/lib/i18n/de.ts | 1 + src/lib/i18n/en.ts | 1 + 6 files changed, 163 insertions(+), 2 deletions(-) create mode 100644 src/components/WinProbTimeline.tsx diff --git a/server/src/db/db.ts b/server/src/db/db.ts index 14e6121..d9f9549 100644 --- a/server/src/db/db.ts +++ b/server/src/db/db.ts @@ -93,6 +93,18 @@ CREATE TABLE IF NOT EXISTS prediction_snapshots ( market_json TEXT ); +CREATE TABLE IF NOT EXISTS inplay_history ( + fixture_num INTEGER NOT NULL, + minute INTEGER NOT NULL, + home_score INTEGER NOT NULL, + away_score INTEGER NOT NULL, + p_home REAL NOT NULL, + p_draw REAL NOT NULL, + p_away REAL NOT NULL, + at INTEGER NOT NULL, + PRIMARY KEY (fixture_num, minute, home_score, away_score) +); + CREATE TABLE IF NOT EXISTS ingest_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL, @@ -135,6 +147,32 @@ export function cacheSet(key: string, value: string, ttl: number): void { .run(key, value, Date.now(), ttl); } +// ---- in-play win-probability history (the momentum curve on match pages) ---- +export interface InplayPoint { + minute: number; + home_score: number; + away_score: number; + p_home: number; + p_draw: number; + p_away: number; +} + +/** Insert-if-new keyed on (minute, score) — one point per game state. */ +export function recordInplay(num: number, p: InplayPoint): void { + db() + .prepare(`INSERT OR IGNORE INTO inplay_history + (fixture_num, minute, home_score, away_score, p_home, p_draw, p_away, at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`) + .run(num, p.minute, p.home_score, p.away_score, p.p_home, p.p_draw, p.p_away, Date.now()); +} + +export function inplayHistory(num: number): InplayPoint[] { + return db() + .prepare(`SELECT minute, home_score, away_score, p_home, p_draw, p_away + FROM inplay_history WHERE fixture_num = ? ORDER BY minute, home_score + away_score`) + .all(num) as unknown as InplayPoint[]; +} + /** Nightly consistent snapshot: VACUUM INTO a LOCAL temp file (SQLite needs * real locking, which the CIFS-mounted archive cannot offer), then a plain * sequential copy into the archive dir. Keeps the newest `keep` dated copies. */ diff --git a/server/src/index.ts b/server/src/index.ts index 8f1b5b9..b685a6a 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -10,7 +10,8 @@ import { startScheduler } from './ingest/scheduler'; import { ModelEngine } from './model'; import { buildPreview, buildTeamProfile } from './preview'; import { buildScoreboard, snapshotCheck } from './scoreboard'; -import { db, healthAll, oddsFor, latestOddsAll, dbCounts } from './db/db'; +import { db, healthAll, oddsFor, latestOddsAll, dbCounts, inplayHistory, recordInplay } from './db/db'; +import { inPlayProbs } from '../../src/lib/model/inplay'; import { loadStatic } from './preview'; import { canonicalTeam } from '../../src/lib/teams'; import type { MatchStatus, ServerMessage } from '../../src/lib/types'; @@ -56,12 +57,35 @@ export function buildServer() { if (model.recompute(state.allFixtures())) sendAll(predictionsMessage()); }; + // Record the in-play win probability per game state while matches run — + // the momentum curve on match pages. Lambdas are the frozen pre-match + // expectation (predictions only recompute when the finished set changes). + const recordInplayTick = (): void => { + const preds = new Map(model.current().matches.map((m) => [m.num, m])); + for (const f of state.allFixtures()) { + if (f.status !== 'live' || f.homeScore == null || f.awayScore == null) continue; + const pred = preds.get(f.num); + if (!pred) continue; + const minute = f.minute ?? 0; + const p = inPlayProbs(pred.lambdaHome, pred.lambdaAway, minute, f.homeScore, f.awayScore); + recordInplay(f.num, { + minute, home_score: f.homeScore, away_score: f.awayScore, + p_home: +p.home.toFixed(4), p_draw: +p.draw.toFixed(4), p_away: +p.away.toFixed(4), + }); + } + }; + // ---- REST: snapshot + predictions (initial load + a no-WS fallback) ---- app.get('/api/snapshot', async () => state.snapshot()); app.get('/api/predictions', async () => model.current()); app.get('/api/sources/health', async () => ({ sources: healthAll() })); // Bookmaker odds (benchmark for the Model-vs-Market scoreboard; not a model input) app.get('/api/odds', async () => ({ odds: latestOddsAll() })); + // In-play win-probability history — the momentum curve on match pages. + app.get('/api/inplay/:num', async (req) => { + const num = Number((req.params as { num: string }).num); + return { points: Number.isFinite(num) ? inplayHistory(num) : [] }; + }); app.get('/api/scoreboard', async () => buildScoreboard(state)); // The data lake, quantified — counters for the /data page. app.get('/api/data/stats', async () => { @@ -114,6 +138,7 @@ export function buildServer() { ); if (!f) return reply.code(404).send({ error: 'no such fixture' }); snapshotCheck(state, model); + recordInplayTick(); broadcast(); return { ok: true, fixture: f }; }); @@ -149,6 +174,7 @@ export function buildServer() { const stopIngest = startScheduler(state, () => broadcast(), () => { const taken = snapshotCheck(state, model); if (taken) console.log(`[scoreboard] froze ${taken} pre-kickoff forecast(s)`); + try { recordInplayTick(); } catch { /* non-fatal */ } }); app.addHook('onClose', async () => stopIngest()); diff --git a/src/components/WinProbTimeline.tsx b/src/components/WinProbTimeline.tsx new file mode 100644 index 0000000..514f4c1 --- /dev/null +++ b/src/components/WinProbTimeline.tsx @@ -0,0 +1,66 @@ +import { useMemo } from 'react'; +import { useT } from '@/lib/i18n'; + +export interface TimelinePoint { + minute: number; + p_home: number; + p_draw: number; + p_away: number; +} + +/** Stacked 100% area chart of the in-play win probability: home fills from the + * bottom (accent), away from the top (info), draw is the gap between. */ +export function WinProbTimeline({ points, home, away }: { points: TimelinePoint[]; home: string; away: string }) { + const t = useT(); + const W = 600; + const H = 180; + const PAD = { l: 6, r: 6, t: 6, b: 20 }; + + const { homePath, awayPath, ticks, maxMin } = useMemo(() => { + const pts = [...points].sort((a, b) => a.minute - b.minute); + const maxMin = Math.max(90, pts[pts.length - 1]?.minute ?? 90); + const x = (min: number) => PAD.l + (min / maxMin) * (W - PAD.l - PAD.r); + const y = (p: number) => PAD.t + (1 - p) * (H - PAD.t - PAD.b); + + // Home area: from the bottom edge up to p_home. + const homeTop = pts.map((p) => `${x(p.minute).toFixed(1)},${y(p.p_home).toFixed(1)}`); + const homePath = pts.length + ? `M ${PAD.l},${y(0)} L ${homeTop.join(' L ')} L ${x(pts[pts.length - 1]!.minute).toFixed(1)},${y(0)} Z` + : ''; + // Away area: from the top edge down to 1 − p_away. + const awayBottom = pts.map((p) => `${x(p.minute).toFixed(1)},${y(1 - p.p_away).toFixed(1)}`); + const awayPath = pts.length + ? `M ${PAD.l},${y(1)} L ${awayBottom.join(' L ')} L ${x(pts[pts.length - 1]!.minute).toFixed(1)},${y(1)} Z` + : ''; + const ticks = [0, 15, 30, 45, 60, 75, 90].filter((m) => m <= maxMin).map((m) => ({ m, px: x(m) })); + return { homePath, awayPath, ticks, maxMin }; + }, [points]); + + if (points.length < 2) return null; + + return ( +
+ + + + + {/* 50% guide */} + + {ticks.map(({ m, px }) => ( + + + {m}' + + ))} + {maxMin > 90 && ( + + )} + +
+ {home} + {t.common.draw} + {away} +
+
+ ); +} diff --git a/src/features/match/MatchPreviewPage.tsx b/src/features/match/MatchPreviewPage.tsx index fbcbc5e..402fd72 100644 --- a/src/features/match/MatchPreviewPage.tsx +++ b/src/features/match/MatchPreviewPage.tsx @@ -3,6 +3,7 @@ import { Link, useParams } from '@tanstack/react-router'; import { ArrowLeft, ArrowRightLeft, CircleDot, Shield, Shirt, Volleyball } from 'lucide-react'; import { Card, CardBody, CardHeader } from '@/components/ui/Card'; import { WinProbBar } from '@/components/WinProbBar'; +import { WinProbTimeline, type TimelinePoint } from '@/components/WinProbTimeline'; import { FormChips } from '@/components/FormChips'; import { teamFlag } from '@/lib/teams'; import { fmt, useFormat, useT } from '@/lib/i18n'; @@ -82,6 +83,7 @@ export function MatchPreviewPage() { const { num: numParam } = useParams({ strict: false }) as { num: string }; const { t, kickoffDay, kickoffTime } = useFormat(); const [preview, setPreview] = useState(null); + const [timeline, setTimeline] = useState([]); const [failed, setFailed] = useState(false); // Live score/minute arrive over the WebSocket snapshot — overlay them on the @@ -92,14 +94,26 @@ export function MatchPreviewPage() { useEffect(() => { let alive = true; setPreview(null); + setTimeline([]); 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)); + const loadTimeline = () => + fetch(`/api/inplay/${numParam}`) + .then((r) => (r.ok ? r.json() : Promise.reject())) + .then((d: { points: TimelinePoint[] }) => alive && setTimeline(d.points)) + .catch(() => {}); void load(); - const id = setInterval(() => { if (useTournamentStore.getState().snapshot?.fixtures.find((f) => f.num === Number(numParam))?.status === 'live') void load(); }, 10_000); + void loadTimeline(); + const id = setInterval(() => { + if (useTournamentStore.getState().snapshot?.fixtures.find((f) => f.num === Number(numParam))?.status === 'live') { + void load(); + void loadTimeline(); + } + }, 10_000); return () => { alive = false; clearInterval(id); }; }, [numParam]); @@ -164,11 +178,26 @@ export function MatchPreviewPage() { + {timeline.length >= 2 && ( +
+ +
+ )}

{t.match.inPlayHelp}

)} + {/* FT: how the win probability swung over the match */} + {finished && timeline.length >= 2 && ( + + {t.match.momentum} + + + + + )} + {/* LIVE/FT: stats */} {preview.liveStats && ( diff --git a/src/lib/i18n/de.ts b/src/lib/i18n/de.ts index 8ab9d83..8d32ae2 100644 --- a/src/lib/i18n/de.ts +++ b/src/lib/i18n/de.ts @@ -85,6 +85,7 @@ export const de: Dict = { saves: "Paraden", }, timeline: "Spielverlauf", + momentum: "So schwankte die Siegwahrscheinlichkeit", preMatchModel: "Modell vor Anstoß", modelExpectation: "Modell-Prognose", expectedGoalsLine: "Erwartete Tore (xG): {home} – {away} · Modell-Wahrscheinlichkeiten, keine Wettempfehlung", diff --git a/src/lib/i18n/en.ts b/src/lib/i18n/en.ts index 65673d2..debfa99 100644 --- a/src/lib/i18n/en.ts +++ b/src/lib/i18n/en.ts @@ -84,6 +84,7 @@ export const en = { saves: "Saves", }, timeline: "Timeline", + momentum: "How the win probability swung", preMatchModel: "Pre-match model", modelExpectation: "Model expectation", expectedGoalsLine: "Expected goals: {home} – {away} · model probabilities, not betting advice",