diff --git a/server/src/db/db.ts b/server/src/db/db.ts index 382c6e1..f90549b 100644 --- a/server/src/db/db.ts +++ b/server/src/db/db.ts @@ -462,6 +462,10 @@ export function saveSnapshot(fixtureNum: number, late: boolean, model: unknown, .run(fixtureNum, Date.now(), late ? 1 : 0, JSON.stringify(model), market ? JSON.stringify(market) : null); } +export function snapshotFor(fixtureNum: number): SnapshotRow | null { + return (db().prepare('SELECT * FROM prediction_snapshots WHERE fixture_num = ?').get(fixtureNum) as unknown as SnapshotRow) ?? null; +} + export function allSnapshots(): SnapshotRow[] { return db() .prepare('SELECT fixture_num, taken_at, late, model_json, market_json FROM prediction_snapshots ORDER BY fixture_num') diff --git a/server/src/ingest/scheduler.ts b/server/src/ingest/scheduler.ts index 166a42e..587274e 100644 --- a/server/src/ingest/scheduler.ts +++ b/server/src/ingest/scheduler.ts @@ -215,13 +215,17 @@ export function startScheduler( logIngest('fotmob', 'map', false, Date.now() - t0, e instanceof Error ? e.message : String(e)); } } - // details: live fixtures at the live TTL; finished ones until settled + // details: live fixtures at the live TTL; finished ones until settled; + // upcoming ones inside 48h for the predicted lineups (the 1h cache makes + // the extra fetches free on the live cadence). for (const f of state.allFixtures()) { if (stopped) return; const id = getSourceMap(f.num, 'fotmob'); if (!id) continue; const live = f.status === 'live'; - if (!live && f.status !== 'finished') continue; + const k = new Date(f.kickoff).getTime(); + const upcoming = f.status === 'scheduled' && k > now && k - now < 48 * 60 * 60 * 1000; + if (!live && f.status !== 'finished' && !upcoming) continue; if (f.status === 'finished') { const row = getMatchProvider<{ v?: number }>(f.num, 'fotmob'); const settled = row && row.data.v === 2 && row.updatedAt > new Date(f.kickoff).getTime() + FOTMOB_SETTLE_MS; diff --git a/server/src/preview.ts b/server/src/preview.ts index 90cb26c..b2ee155 100644 --- a/server/src/preview.ts +++ b/server/src/preview.ts @@ -1,10 +1,10 @@ import { readFileSync, existsSync } from 'node:fs'; -import { getMatchExt } from './db/db'; +import { getMatchExt, snapshotFor } from './db/db'; import type { TournamentState } from './tournament'; import type { ModelEngine } from './model'; import type { EspnSummary } from './ingest/espnNormalize'; import type { - H2HSummary, KeyPlayer, MatchPreview, StandingRow, TeamProfile, + Fixture, H2HSummary, KeyPlayer, MatchPrediction, MatchPreview, MatchProbs, StandingRow, TeamProfile, } from '../../src/lib/types'; // Assembles a MatchPreview / TeamProfile from three layers: precomputed history @@ -51,6 +51,45 @@ function h2hSummary(home: string, away: string): H2HSummary | null { }; } + +/** Most likely score under independent Poissons — a close stand-in for the + * frozen forecast's scoreline chip (the full DC matrix isn't stored). */ +function poissonTopScore(lh: number, la: number): { home: number; away: number; p: number } { + const pmf = (l: number, k: number): number => { + let f = 1; + for (let i = 2; i <= k; i++) f *= i; + return (Math.exp(-l) * l ** k) / f; + }; + let best = { home: 0, away: 0, p: 0 }; + for (let h = 0; h <= 6; h++) { + for (let a = 0; a <= 6; a++) { + const p = pmf(lh, h) * pmf(la, a); + if (p > best.p) best = { home: h, away: a, p }; + } + } + return { ...best, p: Math.round(best.p * 1000) / 1000 }; +} + +function frozenPrediction(fixture: Fixture): MatchPrediction | null { + if (fixture.status !== 'finished' || !fixture.home.team || !fixture.away.team) return null; + const snap = snapshotFor(fixture.num); + if (!snap) return null; + try { + const m = JSON.parse(snap.model_json) as { probs: MatchProbs; lambdaHome: number; lambdaAway: number }; + return { + num: fixture.num, + home: fixture.home.team, + away: fixture.away.team, + probs: m.probs, + lambdaHome: m.lambdaHome, + lambdaAway: m.lambdaAway, + topScore: poissonTopScore(m.lambdaHome, m.lambdaAway), + }; + } catch { + return null; + } +} + export function buildPreview(num: number, state: TournamentState, model: ModelEngine): MatchPreview | null { const fixture = state.getFixture(num); if (!fixture) return null; @@ -65,7 +104,9 @@ export function buildPreview(num: number, state: TournamentState, model: ModelEn const eh = espnFor(home); const ea = espnFor(away); - const prediction = model.current().matches.find((m) => m.num === num) ?? null; + // The live model drops finished matches — fall back to the forecast frozen + // before kickoff so "how good were we" stays visible after full time. + const prediction = model.current().matches.find((m) => m.num === num) ?? frozenPrediction(fixture); return { num, diff --git a/src/components/MatchCard.tsx b/src/components/MatchCard.tsx index d69d67e..51f8923 100644 --- a/src/components/MatchCard.tsx +++ b/src/components/MatchCard.tsx @@ -20,6 +20,7 @@ function ModelHint({ num }: { num: number }) {
{t.match.modelPrefix} {side ? <>{teamFlag(side)} {side} : `${t.common.draw} `} {Math.round(p * 100)}% + · {pred.lambdaHome.toFixed(1)}–{pred.lambdaAway.toFixed(1)} {t.match.goalsShort}
); } diff --git a/src/features/match/MatchPreviewPage.tsx b/src/features/match/MatchPreviewPage.tsx index bd0e6bb..db02aa5 100644 --- a/src/features/match/MatchPreviewPage.tsx +++ b/src/features/match/MatchPreviewPage.tsx @@ -213,7 +213,16 @@ export function MatchPreviewPage() { {hasScore ? t.match.preMatchModel : t.match.modelExpectation} -

{fmt(t.match.expectedGoalsLine, { home: preview.prediction.lambdaHome.toFixed(2), away: preview.prediction.lambdaAway.toFixed(2) })}

+

+ {finished && f.homeScore != null && f.awayScore != null + ? fmt(t.match.predictedVsActual, { + ph: preview.prediction.lambdaHome.toFixed(2), + pa: preview.prediction.lambdaAway.toFixed(2), + ah: f.homeScore, + aa: f.awayScore, + }) + : fmt(t.match.expectedGoalsLine, { home: preview.prediction.lambdaHome.toFixed(2), away: preview.prediction.lambdaAway.toFixed(2) })} +

); @@ -419,14 +428,14 @@ export function MatchPreviewPage() { {/* ── Lineups ── */} {tab === 'lineups' && (
- {/* FotMob serves predicted XIs before kickoff — only draw the pitch - once the match has started or ESPN confirms the lineups. */} - {rich?.lineup && (hasScore || preview.lineups) ? ( + {/* Before kickoff the pitch shows FotMob's projected XIs, clearly + labeled as predicted; from kickoff it's the confirmed lineup. */} + {rich?.lineup ? ( - {t.match.lineups} + {hasScore ? t.match.lineups : t.match.predictedLineups} -

{t.match.xgAttribution}

+

{hasScore ? t.match.xgAttribution : t.match.predictedLineupsNote}

) : ( diff --git a/src/lib/i18n/de.ts b/src/lib/i18n/de.ts index 5979fb3..b66dccb 100644 --- a/src/lib/i18n/de.ts +++ b/src/lib/i18n/de.ts @@ -130,6 +130,10 @@ export const de: Dict = { assist: "Vorlage · {name}", noGoalsYet: "Noch keine Tore.", subbedOff: "Ausgewechselt", + predictedLineups: "Voraussichtliche Aufstellungen", + predictedLineupsNote: "Voraussichtliche Startelf laut FotMob — die offiziellen Aufstellungen kommen meist rund eine Stunde vor Anstoß.", + predictedVsActual: "Erwartete Tore des Modells vor Anstoß: {ph} – {pa} · Endstand {ah}–{aa}", + goalsShort: "Tore", chips: { att: "Zusch.", ref: "SR", diff --git a/src/lib/i18n/en.ts b/src/lib/i18n/en.ts index 165d6f5..a2b5535 100644 --- a/src/lib/i18n/en.ts +++ b/src/lib/i18n/en.ts @@ -129,6 +129,10 @@ export const en = { assist: "Assist · {name}", noGoalsYet: "No goals yet.", subbedOff: "Subbed off", + predictedLineups: "Predicted lineups", + predictedLineupsNote: "FotMob's projected starting XIs — the official lineups usually arrive about an hour before kickoff.", + predictedVsActual: "Model's pre-match expected goals: {ph} – {pa} · final score {ah}–{aa}", + goalsShort: "goals", chips: { att: "Att", ref: "Ref",