Fix title-race chart: persist the diary, dedupe restarts, date axis
The chart looked broken because its data was: history lived in memory, so every deploy wiped it (yesterday's points were gone), and each boot plus the +5min refit appended a fresh point labeled with the same last result — leaving two identical flat segments on an index axis. - model_history table: every diary point persists with the finished- result signature it was computed from; restored at boot - points only append when the finished set actually changes — refit and boot recomputes update the snapshot without spamming the diary - one-time rebuild when nothing is persisted: replay finished results in kickoff order, re-running the sim after each with later results masked, so the historical points are recreated faithfully - chart x-axis shows localized dates instead of raw indices Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -139,6 +139,13 @@ CREATE TABLE IF NOT EXISTS inplay_history (
|
|||||||
PRIMARY KEY (fixture_num, minute, home_score, away_score)
|
PRIMARY KEY (fixture_num, minute, home_score, away_score)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS model_history (
|
||||||
|
t TEXT NOT NULL,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
top TEXT NOT NULL,
|
||||||
|
key TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ingest_log (
|
CREATE TABLE IF NOT EXISTS ingest_log (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
source TEXT NOT NULL,
|
source TEXT NOT NULL,
|
||||||
@@ -473,3 +480,27 @@ export function getMatchExt<T = unknown>(fixtureNum: number): { data: T; updated
|
|||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
return { data: JSON.parse(row.json) as T, updatedAt: row.updated_at };
|
return { data: JSON.parse(row.json) as T, updatedAt: row.updated_at };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- title-race diary (championship odds after each result) ----
|
||||||
|
// Persisted so the Predict chart survives restarts; `key` is the finished-
|
||||||
|
// result signature the point was computed from (dedupes boot recomputes).
|
||||||
|
|
||||||
|
export interface ModelHistoryRecord {
|
||||||
|
t: string;
|
||||||
|
label: string;
|
||||||
|
top: { team: string; champion: number }[];
|
||||||
|
key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveModelHistory(p: { t: string; label: string; top: { team: string; champion: number }[] }, key: string): void {
|
||||||
|
db()
|
||||||
|
.prepare('INSERT INTO model_history (t, label, top, key) VALUES (?, ?, ?, ?)')
|
||||||
|
.run(p.t, p.label, JSON.stringify(p.top), key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function allModelHistory(): ModelHistoryRecord[] {
|
||||||
|
const rows = db()
|
||||||
|
.prepare('SELECT t, label, top, key FROM model_history ORDER BY t DESC LIMIT 200')
|
||||||
|
.all() as unknown as { t: string; label: string; top: string; key: string }[];
|
||||||
|
return rows.reverse().map((r) => ({ t: r.t, label: r.label, top: JSON.parse(r.top) as ModelHistoryRecord['top'], key: r.key }));
|
||||||
|
}
|
||||||
|
|||||||
+3
-2
@@ -10,7 +10,7 @@ import { startScheduler } from './ingest/scheduler';
|
|||||||
import { ModelEngine } from './model';
|
import { ModelEngine } from './model';
|
||||||
import { buildPreview, buildTeamProfile } from './preview';
|
import { buildPreview, buildTeamProfile } from './preview';
|
||||||
import { buildScoreboard, snapshotCheck } from './scoreboard';
|
import { buildScoreboard, snapshotCheck } from './scoreboard';
|
||||||
import { db, healthAll, oddsFor, latestOddsAll, dbCounts, inplayHistory, recordInplay, getMatchExt, getMatchProvider, allFixtureResults, saveFixtureResult } from './db/db';
|
import { db, healthAll, oddsFor, latestOddsAll, dbCounts, inplayHistory, recordInplay, getMatchExt, getMatchProvider, allFixtureResults, saveFixtureResult, allModelHistory, saveModelHistory } from './db/db';
|
||||||
import { inPlayProbs } from '../../src/lib/model/inplay';
|
import { inPlayProbs } from '../../src/lib/model/inplay';
|
||||||
import { PushNotifier, pushEnabled, subscribe as pushSubscribe, unsubscribe as pushUnsubscribe, vapidPublicKey } from './push';
|
import { PushNotifier, pushEnabled, subscribe as pushSubscribe, unsubscribe as pushUnsubscribe, vapidPublicKey } from './push';
|
||||||
import type { EspnSummary } from './ingest/espnNormalize';
|
import type { EspnSummary } from './ingest/espnNormalize';
|
||||||
@@ -45,8 +45,9 @@ export function buildServer() {
|
|||||||
if (state.applyScore(r.fixture_num, r.home_score, r.away_score, r.status as MatchStatus, null, 'seed')) restored++;
|
if (state.applyScore(r.fixture_num, r.home_score, r.away_score, r.status as MatchStatus, null, 'seed')) restored++;
|
||||||
}
|
}
|
||||||
if (restored) console.log(`[state] restored ${restored} fixture result(s) from the DB`);
|
if (restored) console.log(`[state] restored ${restored} fixture result(s) from the DB`);
|
||||||
const model = new ModelEngine(STATIC_DIR);
|
const model = new ModelEngine(STATIC_DIR, { load: allModelHistory, save: saveModelHistory });
|
||||||
model.refitTeamDc(state.allFixtures()); // current Team-DC from day one (covers restarts mid-tournament)
|
model.refitTeamDc(state.allFixtures()); // current Team-DC from day one (covers restarts mid-tournament)
|
||||||
|
model.rebuildHistory(state.allFixtures()); // one-time diary replay when nothing is persisted yet
|
||||||
model.recompute(state.allFixtures());
|
model.recompute(state.allFixtures());
|
||||||
const clients = new Set<WebSocket>();
|
const clients = new Set<WebSocket>();
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -1,6 +1,7 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Legend } from 'recharts';
|
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Legend } from 'recharts';
|
||||||
import { teamFlag } from '@/lib/teams';
|
import { teamFlag } from '@/lib/teams';
|
||||||
|
import { useFormat } from '@/lib/i18n';
|
||||||
import type { OddsHistoryPoint } from '@/lib/types';
|
import type { OddsHistoryPoint } from '@/lib/types';
|
||||||
|
|
||||||
const PALETTE = [
|
const PALETTE = [
|
||||||
@@ -15,23 +16,32 @@ const PALETTE = [
|
|||||||
/** Championship odds over time — one line per top contender, a point per result
|
/** Championship odds over time — one line per top contender, a point per result
|
||||||
* that moved the model. */
|
* that moved the model. */
|
||||||
export function OddsChart({ history }: { history: OddsHistoryPoint[] }) {
|
export function OddsChart({ history }: { history: OddsHistoryPoint[] }) {
|
||||||
|
const { locale } = useFormat();
|
||||||
const { data, teams } = useMemo(() => {
|
const { data, teams } = useMemo(() => {
|
||||||
const last = history[history.length - 1];
|
const last = history[history.length - 1];
|
||||||
const teams = (last?.top ?? []).slice(0, 6).map((t) => t.team);
|
const teams = (last?.top ?? []).slice(0, 6).map((t) => t.team);
|
||||||
|
const tag = locale === 'de' ? 'de-DE' : 'en-GB';
|
||||||
const data = history.map((h, i) => {
|
const data = history.map((h, i) => {
|
||||||
const row: Record<string, number | string> = { i: i + 1, label: h.label };
|
const day = new Date(h.t).toLocaleDateString(tag, { day: 'numeric', month: 'short' });
|
||||||
|
const row: Record<string, number | string> = { i: i + 1, label: h.label, day };
|
||||||
for (const t of h.top) if (teams.includes(t.team)) row[t.team] = Math.round(t.champion * 1000) / 10;
|
for (const t of h.top) if (teams.includes(t.team)) row[t.team] = Math.round(t.champion * 1000) / 10;
|
||||||
return row;
|
return row;
|
||||||
});
|
});
|
||||||
return { data, teams };
|
return { data, teams };
|
||||||
}, [history]);
|
}, [history, locale]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-72 w-full">
|
<div className="h-72 w-full">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<LineChart data={data} margin={{ top: 8, right: 16, bottom: 4, left: -8 }}>
|
<LineChart data={data} margin={{ top: 8, right: 16, bottom: 4, left: -8 }}>
|
||||||
<CartesianGrid stroke="var(--app-line)" strokeDasharray="3 3" vertical={false} />
|
<CartesianGrid stroke="var(--app-line)" strokeDasharray="3 3" vertical={false} />
|
||||||
<XAxis dataKey="i" tick={{ fill: 'var(--app-faint)', fontSize: 11 }} stroke="var(--app-line)" />
|
<XAxis
|
||||||
|
dataKey="i"
|
||||||
|
tick={{ fill: 'var(--app-faint)', fontSize: 11 }}
|
||||||
|
stroke="var(--app-line)"
|
||||||
|
tickFormatter={(i) => String(data[Number(i) - 1]?.day ?? '')}
|
||||||
|
minTickGap={28}
|
||||||
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
tick={{ fill: 'var(--app-faint)', fontSize: 11 }}
|
tick={{ fill: 'var(--app-faint)', fontSize: 11 }}
|
||||||
stroke="var(--app-line)"
|
stroke="var(--app-line)"
|
||||||
|
|||||||
Reference in New Issue
Block a user