Commit Graph

24 Commits

Author SHA1 Message Date
NilsBriggen b2f680906b Match-rich API: oriented shots/xG, momentum, POTM, officials, weather
GET /api/match-rich/:num projects the captured provider data for the
match center: every shot with pitch coords and xG oriented to
home/away (FotMob payloads now carry team ids, v2 + settle-versioned
refetch), team xG sums, the momentum curve, player of the match with
rating, FIFA's official attendance/stadium/referee, and kickoff-hour
weather. Verified on real data: South Korea 2.31-0.83 Czechia on 22
oriented shots, POTM Hwang 8.9, referee Amin Mohamed Omar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 09:30:34 +02:00
NilsBriggen 94db4274a0 Results backfill: ESPN dates are US-Eastern — fetch both candidates
The first heal attempt missed both matches: a 02:00 UTC kickoff files
under the PREVIOUS day in ESPN's ?dates= param (ET-based), and the
backfill skipped today's UTC date entirely. Now any started-but-
forgotten fixture triggers fetches for its UTC date and the day
before, covering the ET offset in both directions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 09:25:45 +02:00
NilsBriggen 66cbbc3dde Self-healing results backfill: re-fetch past match days at boot
The persistence table shipped empty just as ESPN's default scoreboard
window rolled past matchday 1 — prod forgot both results. Rather than
patching the DB by hand, boot now re-fetches the date-scoped
scoreboard for any past day whose fixtures still look scheduled and
merges + persists the results. Heals fresh databases and any future
data loss the same way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 09:22:24 +02:00
NilsBriggen 517a86233a v2 Phase D: capture-first ingestion — FotMob, FIFA, weather, scorer props
Four new feeds, all verified against real tournament matches:
- FotMob (unofficial, works from the VPS again): per-shot xG/xGOT with
  pitch coords, team xG, player ratings, POTM, momentum, attacking
  zones — trimmed payloads (~87KB/match) on a gentle 90s-live/30m-idle
  cadence. Capture-first: this source could lock again any day.
- FIFA official v3: confirmed lineups with player IDs, typed XY event
  timelines (80 events for the opener), attendance + named referees,
  tactics — id-mapped via the calendar, hot while live.
- Open-Meteo: kickoff-hour weather per venue from a build-time geocoded
  venues.json (with elevation — Azteca 2,240m); fetched in the enrich
  loop inside the 48h horizon.
- DraftKings scorer props (first/last/anytime goalscorer per athlete)
  via ESPN's core propBets, insert-if-changed line history, athlete
  names resolved through a 30-day cache.

Also fixes a real resilience gap found along the way: fixture results
now persist in SQLite and restore at boot — previously a restart after
ESPN's scoreboard window moved on would silently forget a match day
(standings, model and scoreboard would all regress).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 09:19:10 +02:00
NilsBriggen 27897f048f Boot order: live scores first, slow sweeps after
A restart during a live match sat behind the multi-minute odds sweep
before the first scoreboard poll. The live loop now starts immediately
after fixture mapping; odds/backfill/enrich follow on their own
rate-limit keys.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:27:02 +02:00
NilsBriggen 04dfad1a26 Refit Team-DC once more after boot ingestion settles
The boot refit fires before live state is restored, so restarts lost
this tournament's matches from the fit until the nightly timer. A
one-shot follow-up at +5 minutes closes the gap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:22:38 +02:00
NilsBriggen 03b0fa734e Web Push: goal, kickoff and full-time alerts for a followed team
Fully progressive: without VAPID keys in the environment the API says
404, the bell never renders, and the service worker addition (a push
handler via workbox importScripts — no SW strategy change) is inert.
With keys: a bell on each team page requests permission, subscribes
(one team per device) and the server diffs fixture states on every
broadcast to push Kickoff / Goal / Full time to that team's followers.
Dead endpoints (404/410) self-clean. Round-trip verified locally with
dev VAPID keys; disabled mode verified too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:21:55 +02:00
NilsBriggen ce83d4dbc4 Golden Boot race from structured ESPN scoring plays
ESPN's keyEvents carry participants[].athlete for goals — no text
parsing needed. The normalizer now extracts scorer/scoring/shootout
(payload versioned, with a boot backfill that re-stores summaries
saved by the old normalizer), /api/goldenboot aggregates per-player
goals (own goals and shootout kicks excluded), and the Teams page
leads with the top-10 race. Verified against tonight's real goals.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:18:19 +02:00
NilsBriggen 9aea49ba42 Shared links unfurl: per-route OG meta for match and team pages
Crawler-visible <title> + og:title/description are injected into the
SPA shell server-side: match links carry the score or the model's
probability split, team links the current title odds — honest framing
included. Plain shell for every other route.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 01:22:36 +02:00
NilsBriggen cbc5ff0468 In-play win-probability timeline: the momentum curve on match pages
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 <noreply@anthropic.com>
2026-06-12 00:50:56 +02:00
NilsBriggen 2c945dfd54 Snapshot via local temp + plain copy — CIFS can't host VACUUM INTO
The first prod snapshot landed as a 0-byte file with a stale journal:
SQLite needs byte-range locking on the VACUUM INTO destination, which
the CIFS storage box refuses. Now: vacuum into a local temp file, then
a plain sequential copy to the archive (always CIFS-safe), cleaning up
the temp and any leftover journal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:40:44 +02:00
NilsBriggen ac1de66e6d Nightly DB snapshots to the 1TB storage box (live DB stays local)
The 'external drive' turned out to be a CIFS-mounted Hetzner storage
box — SQLite cannot run on a network mount (WAL shared memory and
locking break), and the live DB is only ~7MB anyway. So: the hot
database stays on the local volume, and a nightly VACUUM INTO snapshot
(transactionally consistent, a plain sequential file write — perfect
for CIFS) lands in /data/archive, which compose binds to the storage
box via HOST_ARCHIVE_DIR. 14-day retention, plus a snapshot at boot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:36:05 +02:00
NilsBriggen 92fdfdf398 Storage: configurable data dir for the external drive + daily DB prune
HOST_DATA_DIR in .env can now point /data at a host path (the 1TB
external drive) instead of the named volume, so odds history, caches
and snapshots stop eating the 128GB root disk. A daily prune drops
cache entries stale for over a week and ingest-log rows older than 14
days — faster polling no longer means unbounded DB growth.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:10:54 +02:00
NilsBriggen d3e8df96ef Recency-weighted v4 model: fast-Elo blend, Team-DC majority, nightly refit
Validated on the strict 2018-2022 window and confirmed on the untouched
2022-2026 test set (RPS 0.1703 vs 0.1721 over 4,448 matches):
- the Elo member now blends 30% of a 3x-faster Elo walk, so recent
  results move ratings much harder
- ensemble weight shifts from 75/25 toward Elo to 45/55 toward the
  time-decayed Team-DC member — the recent-form model now leads
- Team-DC refits nightly (and at boot) on the 15-year window plus every
  finished 2026 match, via a new committed-at-build dcTrain.json
  (server-only, excluded from the PWA precache)
- a recent-form goal multiplier was also tested and did NOT validate;
  it ships disabled, and backtest.json records the whole decision

buildBacktest.ts grew the experiment harness: wider xi/k grids, the
fast-Elo and form variants, a pre-registered ship bar (>=0.0005
validation RPS), and a 7-day refit-cadence check. Older ratings.json
files still work — all new model fields are optional with golden
regression coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 23:34:18 +02:00
NilsBriggen a3f529746f Much fresher live data: 8s in-play polling, live-TTL summary fetches
The live staleness bug: liveTick refetched ESPN summaries every 20s but
the cache TTL was 5 minutes, so in-play stats/timeline barely moved.
Summaries now take a live flag (15s TTL in play), the scoreboard TTL
drops 20s->5s, and polling gets three tiers (8s in-play / 20s near
kickoff / 10m idle) via a pure, tested cadence module. The
football-data fallback is throttled to one attempt per 15s so the 8s
cadence can never breach its 10 req/min cap. Enrichment runs every 5m
(was 20m); client refetch intervals tighten to 10s (live match page)
and 30s (scoreboard).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 23:18:38 +02:00
NilsBriggen dda8e1ae42 v3: the lake processed + /data page + glossary terms
- scripts/processStatsbomb.ts: one pass over the 17GB StatsBomb lake (4,235
  matches) -> lakestats.json (12.82GB events / 14.9M events / 6,919 players),
  scorestate.json (goal-rate multipliers by game state — shipped as an insight,
  NOT wired into in-play: the raw effect is selection-biased toward stronger
  teams and would distort a calibrated model), fingerprints.json (40/48 nations'
  shots/xG/set-piece share from real event data). Artifacts committed; raw lake
  stays local (gitignored).
- /data page + /api/data/stats: the gigabytes made visible — lake counters,
  historical archive, live-collection counts (odds lines, frozen forecasts,
  enrichment), per-source health with freshness. Footer links.
- Term component: plain-language glossary tooltips (RPS, Brier, ECE, Elo, xG,
  de-vig, ensemble) attached where metrics appear.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 17:41:43 +02:00
NilsBriggen dc52a908c0 v3: Model-vs-Market scoreboard — frozen forecasts, fair scoring, live page
- prediction_snapshots: model + de-vigged market frozen ≤15min pre-kickoff
  (or at first sight of a live match, flagged 'late' and excluded from totals).
- src/lib/model/scoring.ts: ONE shared RPS/Brier/log-loss implementation for
  the backtest and the live scoreboard.
- /api/scoreboard + /scoreboard page: per-match model-vs-bookmaker bars, RPS
  for both after FT, 'closer' badges, running head-to-head with honest rules
  printed (small-sample caveat included). Nav: 'vs Market'.
- Methodology page now shows the three-way split + ensemble bake-off variants.
- Verified end-to-end with a simulated match: snapshot froze model 80.7% and
  DraftKings 67.0% (de-vig correct), both scored at FT, late-flag honored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 17:31:09 +02:00
NilsBriggen f3b2a69b31 v3 Phase B core: Team-DC + backtested ensemble, squad values
- src/lib/model/teamDc.ts: per-team attack/defence Dixon-Coles via weighted MLE
  (Maher iterations), exponential time-decay, shrinkage for thin histories,
  learned home multiplier; log-pool of score matrices. 7 vitest cases.
- buildBacktest.ts rewritten as a strict three-way-split bake-off: params
  pre-2018, tune (xi, k, w) on 2018-2022 validation, final scores on untouched
  2022-2026 test (4,448 matches). Result: ensemble RPS 0.1721 beats Elo-DC
  0.1726 and Team-DC 0.1801; ECE stays 0.01. Tuned: xi=0.5 k=5 w=0.75.
- Runtime now IS the backtested model: ratings.json carries teamDc+ensembleW;
  matchMatrix() pools both members for predictions, Monte Carlo and advance
  probs; ModelEngine re-rating preserves ensemble params; lambdas reported as
  matrix expectations (drives in-play).
- scripts/buildSquadValues.ts: Transfermarkt FIWC participants page -> all 48
  squad market values (France 1.52bn ... Qatar 20m). Display/availability layer
  only — June-2026 values are NOT in the backtested model (would leak).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 17:23:51 +02:00
NilsBriggen d887664fce v3 Phase A (urgent): bookmaker odds capture — the Model-vs-Market benchmark
- docs/V3-GIGA-PLAN.md: the v3 plan (data lake to 7+GB, GIGA ensemble, scoreboard,
  UI clarity) with the honest reframe: perfect accuracy = perfect calibration;
  'beat the betting sites' = a live public head-to-head, scored with proper rules.
- src/lib/odds.ts: American-ml → decimal, de-vig (normalize away the margin),
  overround; 3 vitest cases incl. tonight's real opening lines.
- ESPN core odds adapter (server/src/ingest/espnOdds.ts): captures DraftKings
  1X2 moneylines + O/U + spread per fixture; follows $ref pointer items.
- odds_history table (insert-if-changed → clean line-movement history);
  scheduler odds sweep every 3h over a 14-day horizon, first sweep at boot;
  /api/odds + /api/odds/:num. Odds are a benchmark ONLY — never a model input.
- Boot speed-up: fixture↔event mapping skips dates already fully mapped.
- Verified: 54 fixtures captured with real moneylines locally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 17:03:15 +02:00
NilsBriggen adbfb5d6d1 v2 Phase 4: live match center + in-play win probability
- src/lib/model/inplay.ts: in-play win probability — remaining goals modelled as
  time-scaled Poisson processes convolved with the current score; recomputed each
  tick. 4 vitest cases (locks at FT, lead grows decisive over time, sums to 1).
- ESPN summary normalizer extended with the live event timeline (keyEvents);
  preview now carries liveStats + events. Scheduler refreshes LIVE matches'
  summaries every live tick (~20s) for fresh stats/timeline.
- /match/:num is now a live match center: when live it shows in-play win
  probability (driven by the WS score/clock), match stats bars and an event
  timeline, polling while live; preview content for scheduled games, recap when
  finished. Verified with a simulated live state (94% live vs 81% pre-match).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 16:19:22 +02:00
NilsBriggen 7f4838d032 v2 Phase 2: match previews + team profiles
- buildPreviewData.ts: precomputes from 150y of results → h2h.json (825 WC-team
  pairings, full records + recent meetings) + scorers.json (top-10 all-time
  scorers per team). Committed goalscorers.csv.
- server/src/preview.ts: assembles MatchPreview / TeamProfile from three layers —
  historical (h2h, scorers), DB enrichment (ESPN form/lineups/venue), live model
  (W/D/L, xG, odds, Elo). /api/preview/:num + /api/team/:name.
- Client: rich /match/:num page (model expectation, recent form chips, H2H record
  + recent meetings, lineups-or-key-players) and /team/:name profile (Elo, odds,
  standing, fixtures, all-time scorers). Match cards now link to previews;
  cross-linking between matches and teams.
- Verified: Mexico-SA preview (81% W, 2-0, form, H2H 2-1-1, Borgetti 37) and
  Argentina profile (Messi 63) render; 22 tests pass; build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 15:34:54 +02:00
NilsBriggen b6f62679c9 v2 Phase 1: ingestion + SQLite foundation
- Persistence via Node's built-in node:sqlite (zero native deps) on a Docker
  volume: response cache, source health, fixture↔provider id map, per-fixture
  enrichment, ingest log. Runtime bumped to node:24-slim + --experimental-sqlite.
- Resilient fetcher: DB cache + per-source rate-limit + jittered backoff +
  circuit-breaker (blocked sources trip + skip; UI reads DB, never breaks).
- ESPN hidden API as the PRIMARY rich source (works from the VPS where SofaScore
  403s): scoreboard (live scores w/ clock) + summary (venue, H2H, recent form,
  lineups, team stats). football-data / SofaScore are fallbacks.
- Scheduler: maps all 72 group fixtures to ESPN event ids, polls live on a
  dynamic cadence, enriches imminent fixtures into match_ext. /api/sources/health.
- Verified: DB populates (real H2H + form for opening matches), 22 tests pass,
  Docker image runs node:sqlite on the volume and persists across restart.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 15:21:01 +02:00
NilsBriggen d604bb14ff Phase 2: predictive model — Elo + Dixon-Coles + Monte Carlo
- buildRatings.ts: walks 49k historical results → World-Football-Elo per team,
  data-calibrated goals model (goals-per-Elo slope, mean goals) + MLE-fit
  Dixon-Coles rho. Top: Spain/Argentina/France (the real 2026 favourites)
- src/lib/model: elo, poisson/dixon-coles, predict, host-advantage, monteCarlo
  (full 48-team sim — group sampling, best-third bipartite allocation, knockout
  advance probs). 20 vitest cases incl. exact per-round count invariants
- Server ModelEngine: live Elo re-rating after each result, per-match W/D/L,
  20k-sim odds, odds-over-time history; broadcast on finished-result changes
- Client: championship board, heat-shaded odds table, lazy-loaded title-race
  chart (Recharts split to its own chunk), match-prediction bars, bracket
  advance overlay
- Verified: odds render, chart populates as injected results re-rate teams live

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 13:25:53 +02:00
NilsBriggen 9a31e9f4db Phase 1: live dashboard — fixtures, scores, group tables, bracket
- Shared domain model (types, team metadata/flags/aliases, FIFA-tiebreak standings)
- buildFixtures.ts: openfootball 2026 → normalized fixtures.json (104 matches,
  ISO-UTC kickoffs, pretty knockout placeholders)
- Fastify live layer: seed load, football-data.org poller + SofaScore enhancement
  (feature-flagged, fallback), dynamic live-window polling, WS snapshot push,
  REST /api/snapshot, dev /api/dev/score injection
- Client: tournament store (REST + WS + static-file fallback), MatchCard,
  TeamLabel, GroupTable, Live/Groups/Bracket pages, live connection indicator
- Verified: WS pushes a dev-injected result; standings recompute live in-browser

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 13:04:31 +02:00