From b552bc8ba91233a09a34b996dde81a2af20bdce9 Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Fri, 12 Jun 2026 12:26:33 +0200 Subject: [PATCH] Fix title-race chart: persist the diary, dedupe restarts, date axis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/src/db/db.ts | 31 +++++++++++++++++++++++++++++++ server/src/index.ts | 5 +++-- server/src/model.ts | Bin 6632 -> 9447 bytes src/components/OddsChart.tsx | 16 +++++++++++++--- 4 files changed, 47 insertions(+), 5 deletions(-) diff --git a/server/src/db/db.ts b/server/src/db/db.ts index 731f25b..382c6e1 100644 --- a/server/src/db/db.ts +++ b/server/src/db/db.ts @@ -139,6 +139,13 @@ CREATE TABLE IF NOT EXISTS inplay_history ( 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 ( id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL, @@ -473,3 +480,27 @@ export function getMatchExt(fixtureNum: number): { data: T; updated if (!row) return null; 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 })); +} diff --git a/server/src/index.ts b/server/src/index.ts index 1693866..d8200fb 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -10,7 +10,7 @@ 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, 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 { PushNotifier, pushEnabled, subscribe as pushSubscribe, unsubscribe as pushUnsubscribe, vapidPublicKey } from './push'; 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 (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.rebuildHistory(state.allFixtures()); // one-time diary replay when nothing is persisted yet model.recompute(state.allFixtures()); const clients = new Set(); diff --git a/server/src/model.ts b/server/src/model.ts index 94b8647a91808e8a6a609dca5c7cfc8f99e55853..7dbc256435aa782f409cb0eca29a8c468375e31a 100644 GIT binary patch delta 2546 zcmb_eU2has7>)@L+6cwO2qZ*b5~MR_ce)56y0tV$AvOpll)FvSp4~aKd+f}d$(%DS zo4Q&4fia{1!-QM^f!^z-Atw4Gyzn_6vr8$_8+*~ro_Xi}dOl|Uy7lMgkH4LouCA=m zww5IuavDjwuPBiv=@BO_bjsT$8*qvRE64Qm`S&F9ulYdpn3P12P?X8IN;xUb#%hsP zh?0_Xk_p9q5?LWjtteo5OjRy)TXO{VQyxaNvbxBh6;lKtspe&33VkUQdKo|FrNR{* zEfS@Y#a#+QTE9z2q`N3pihQ_6DeLoe{!`1MYXUgS=7)n3%L*a$F6C9$=Os;gJ8QJh z$4$tm_5@`VJK!jT99wa$nm#02YrPt8+c=OSUR#_j*1w)o%Oz*A%+oOyuIcXULwtkU zXB7iFzw=e zpAY5(kt;{*w(1%tYvL18KRf;1a_c`D^-tCdXWH-hk#OX%>KkVsPKgFGM~?<7bU3@Y z5gDw2)W!y3%_vQlBN2FFP(`GBB+6J31f|_&U@B#cpYnXDN7Sa9I~x(eHVqjJOp3`O{u$D5<-Dy$#;N4}N~E#jtXaQX_~D4> zluJ-N9};Np=f}9?Dc2MvqQnOJQff2i3X#m69@JpOGu9%Ah#4)Y5go|1%6QmCr6Ofx z;s+edNIVa4YIz`XpJ`tV_GOYFLkz+i>6DORW%5I3BI{Kt)8I6*1hOH{2BQ{21|Xs~ z-6G6qvaC_)10^(r&?Su=Q%26P(6i8-IMhbQiguTuV zAgiMbm!}SOm&$1ZXa_SFn32Yr0=Qjrl<3lZSEsk*e8VgRe=d)Uc~oTzxpIC;+gu0k z@OWE!HO-o7K$=y0$kaY)YS!*hQ_cr1E)6V-b7iqQlqG1zlw+15kyY`rUNU5~8hXQG z5O~_QU4MJ=(v+@Wr>pbUqY+a<62aC&pU{MEzyYHuN?OK{b)~wrYzS#AI$k!d*bTJc z^cmkCK)%Kp)3inGkd2QWWFoJ$zYWpQW!s7Q*QKQ8OIlIZtLH!1b#p+o(0WwzC)kl+ zX&6QzZ?O6F-jmOsZf-r=_DY_iVU}{zbpZ{W!Q)DLjYo6ZaWnlGQ(*e{>Q0I02=J&X#)1c>Oes{Wqr8zbt+7mDAh*L)aIm IF8s0Z4?xjDBLDyZ delta 130 zcmaFv`NDX^4VKBmY(1*m_v7R42RR^X99oNCv!_0NF?Xy6_+UFBqpWi zDA)q&;u0BpP^6-+4w diff --git a/src/components/OddsChart.tsx b/src/components/OddsChart.tsx index 8e014ef..c38501b 100644 --- a/src/components/OddsChart.tsx +++ b/src/components/OddsChart.tsx @@ -1,6 +1,7 @@ import { useMemo } from 'react'; import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Legend } from 'recharts'; import { teamFlag } from '@/lib/teams'; +import { useFormat } from '@/lib/i18n'; import type { OddsHistoryPoint } from '@/lib/types'; const PALETTE = [ @@ -15,23 +16,32 @@ const PALETTE = [ /** Championship odds over time — one line per top contender, a point per result * that moved the model. */ export function OddsChart({ history }: { history: OddsHistoryPoint[] }) { + const { locale } = useFormat(); const { data, teams } = useMemo(() => { const last = history[history.length - 1]; 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 row: Record = { i: i + 1, label: h.label }; + const day = new Date(h.t).toLocaleDateString(tag, { day: 'numeric', month: 'short' }); + const row: Record = { 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; return row; }); return { data, teams }; - }, [history]); + }, [history, locale]); return (
- + String(data[Number(i) - 1]?.day ?? '')} + minTickGap={28} + />