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 ( +
{t.match.inPlayHelp}