diff --git a/public/data/fingerprints.json b/public/data/fingerprints.json new file mode 100644 index 0000000..f4b0094 --- /dev/null +++ b/public/data/fingerprints.json @@ -0,0 +1 @@ +{"source":"StatsBomb open data (men’s internationals)","teams":{"Germany":{"matches":18,"shotsPerMatch":18.67,"xgPerMatch":1.892,"goalsPerMatch":1.5,"concededPerMatch":1.39,"setPieceXgShare":0.46},"Argentina":{"matches":22,"shotsPerMatch":13.36,"xgPerMatch":1.707,"goalsPerMatch":1.73,"concededPerMatch":1.14,"setPieceXgShare":0.427},"England":{"matches":28,"shotsPerMatch":11.89,"xgPerMatch":1.345,"goalsPerMatch":1.57,"concededPerMatch":0.82,"setPieceXgShare":0.52},"Turkey":{"matches":8,"shotsPerMatch":14.13,"xgPerMatch":1.146,"goalsPerMatch":1.13,"concededPerMatch":2,"setPieceXgShare":0.509},"Belgium":{"matches":20,"shotsPerMatch":13.25,"xgPerMatch":1.373,"goalsPerMatch":1.3,"concededPerMatch":0.75,"setPieceXgShare":0.382},"Switzerland":{"matches":18,"shotsPerMatch":12.11,"xgPerMatch":1.21,"goalsPerMatch":1.44,"concededPerMatch":1.5,"setPieceXgShare":0.528},"Croatia":{"matches":21,"shotsPerMatch":13.19,"xgPerMatch":1.394,"goalsPerMatch":1.38,"concededPerMatch":1.43,"setPieceXgShare":0.456},"Netherlands":{"matches":20,"shotsPerMatch":13.4,"xgPerMatch":1.49,"goalsPerMatch":1.8,"concededPerMatch":0.85,"setPieceXgShare":0.424},"Austria":{"matches":8,"shotsPerMatch":12.88,"xgPerMatch":1.311,"goalsPerMatch":1.38,"concededPerMatch":1.38,"setPieceXgShare":0.507},"Scotland":{"matches":6,"shotsPerMatch":9.67,"xgPerMatch":0.766,"goalsPerMatch":0.33,"concededPerMatch":2,"setPieceXgShare":0.65},"Czech Republic":{"matches":8,"shotsPerMatch":12.5,"xgPerMatch":1.268,"goalsPerMatch":1.13,"concededPerMatch":1.13,"setPieceXgShare":0.559},"Spain":{"matches":21,"shotsPerMatch":17.14,"xgPerMatch":1.818,"goalsPerMatch":1.86,"concededPerMatch":0.9,"setPieceXgShare":0.506},"Sweden":{"matches":11,"shotsPerMatch":10.27,"xgPerMatch":1.199,"goalsPerMatch":1.09,"concededPerMatch":1.18,"setPieceXgShare":0.398},"France":{"matches":25,"shotsPerMatch":14.12,"xgPerMatch":1.463,"goalsPerMatch":1.52,"concededPerMatch":1.12,"setPieceXgShare":0.314},"Portugal":{"matches":18,"shotsPerMatch":14.61,"xgPerMatch":1.625,"goalsPerMatch":1.56,"concededPerMatch":1.22,"setPieceXgShare":0.413},"Brazil":{"matches":25,"shotsPerMatch":21.28,"xgPerMatch":2.286,"goalsPerMatch":2.08,"concededPerMatch":0.88,"setPieceXgShare":0.387},"Tunisia":{"matches":9,"shotsPerMatch":10.56,"xgPerMatch":1.091,"goalsPerMatch":0.78,"concededPerMatch":1.22,"setPieceXgShare":0.617},"Japan":{"matches":8,"shotsPerMatch":11,"xgPerMatch":1.142,"goalsPerMatch":1.38,"concededPerMatch":1.38,"setPieceXgShare":0.496},"Australia":{"matches":7,"shotsPerMatch":8.57,"xgPerMatch":0.8,"goalsPerMatch":0.71,"concededPerMatch":1.57,"setPieceXgShare":0.529},"Saudi Arabia":{"matches":6,"shotsPerMatch":10.83,"xgPerMatch":1.045,"goalsPerMatch":0.83,"concededPerMatch":2,"setPieceXgShare":0.264},"Mexico":{"matches":11,"shotsPerMatch":15.45,"xgPerMatch":1.225,"goalsPerMatch":0.55,"concededPerMatch":1.09,"setPieceXgShare":0.476},"South Korea":{"matches":7,"shotsPerMatch":11.71,"xgPerMatch":0.945,"goalsPerMatch":1.14,"concededPerMatch":1.57,"setPieceXgShare":0.468},"Ecuador":{"matches":7,"shotsPerMatch":11.43,"xgPerMatch":1.229,"goalsPerMatch":1.14,"concededPerMatch":1,"setPieceXgShare":0.398},"Senegal":{"matches":11,"shotsPerMatch":11.18,"xgPerMatch":1.196,"goalsPerMatch":1.55,"concededPerMatch":1.18,"setPieceXgShare":0.465},"Canada":{"matches":9,"shotsPerMatch":10.89,"xgPerMatch":1.208,"goalsPerMatch":0.56,"concededPerMatch":1.56,"setPieceXgShare":0.368},"Uruguay":{"matches":15,"shotsPerMatch":13.27,"xgPerMatch":1.291,"goalsPerMatch":1.33,"concededPerMatch":0.8,"setPieceXgShare":0.58},"Iran":{"matches":6,"shotsPerMatch":9.33,"xgPerMatch":1.089,"goalsPerMatch":0.83,"concededPerMatch":1.5,"setPieceXgShare":0.398},"USA":{"matches":7,"shotsPerMatch":11.43,"xgPerMatch":1.104,"goalsPerMatch":0.86,"concededPerMatch":1,"setPieceXgShare":0.655},"Morocco":{"matches":14,"shotsPerMatch":11.36,"xgPerMatch":1.214,"goalsPerMatch":0.93,"concededPerMatch":0.86,"setPieceXgShare":0.527},"Qatar":{"matches":3,"shotsPerMatch":6.67,"xgPerMatch":0.469,"goalsPerMatch":0.33,"concededPerMatch":2.33,"setPieceXgShare":0.688},"Ghana":{"matches":6,"shotsPerMatch":8.33,"xgPerMatch":1.177,"goalsPerMatch":1.67,"concededPerMatch":2.17,"setPieceXgShare":0.403},"Ivory Coast":{"matches":7,"shotsPerMatch":13.86,"xgPerMatch":1.086,"goalsPerMatch":1.14,"concededPerMatch":1.14,"setPieceXgShare":0.611},"Egypt":{"matches":7,"shotsPerMatch":14.57,"xgPerMatch":1.322,"goalsPerMatch":1.29,"concededPerMatch":1.86,"setPieceXgShare":0.319},"Cape Verde":{"matches":5,"shotsPerMatch":15,"xgPerMatch":1.362,"goalsPerMatch":1.6,"concededPerMatch":0.6,"setPieceXgShare":0.534},"Algeria":{"matches":3,"shotsPerMatch":15.67,"xgPerMatch":1.294,"goalsPerMatch":1,"concededPerMatch":1.33,"setPieceXgShare":0.728},"South Africa":{"matches":7,"shotsPerMatch":10.86,"xgPerMatch":1.07,"goalsPerMatch":1,"concededPerMatch":0.43,"setPieceXgShare":0.394},"DR Congo":{"matches":7,"shotsPerMatch":13.14,"xgPerMatch":1.627,"goalsPerMatch":0.86,"concededPerMatch":0.71,"setPieceXgShare":0.512},"Panama":{"matches":7,"shotsPerMatch":10.57,"xgPerMatch":0.803,"goalsPerMatch":1,"concededPerMatch":3,"setPieceXgShare":0.611},"Colombia":{"matches":10,"shotsPerMatch":12,"xgPerMatch":1.266,"goalsPerMatch":1.8,"concededPerMatch":0.6,"setPieceXgShare":0.538},"Paraguay":{"matches":3,"shotsPerMatch":14.33,"xgPerMatch":0.986,"goalsPerMatch":1,"concededPerMatch":2.67,"setPieceXgShare":0.625}}} \ No newline at end of file diff --git a/public/data/lakestats.json b/public/data/lakestats.json new file mode 100644 index 0000000..e50dea6 --- /dev/null +++ b/public/data/lakestats.json @@ -0,0 +1 @@ +{"generatedAt":"2026-06-11T15:35:16.429Z","source":"github.com/statsbomb/open-data","bytes":12824330591,"eventFiles":4235,"matchesParsed":4235,"events":14874171,"players":6919,"competitionSeasons":80,"nationalTeamsCovered":40} \ No newline at end of file diff --git a/public/data/scorestate.json b/public/data/scorestate.json new file mode 100644 index 0000000..8895558 --- /dev/null +++ b/public/data/scorestate.json @@ -0,0 +1 @@ +{"source":"StatsBomb open data","matches":4235,"multipliers":{"trailing":0.808,"level":0.936,"leading":1.297},"raw":{"trailing":{"min":205440,"goals":2544},"level":{"min":338004,"goals":4847},"leading":{"min":205440,"goals":4082}}} \ No newline at end of file diff --git a/scripts/processStatsbomb.ts b/scripts/processStatsbomb.ts new file mode 100644 index 0000000..372e0f9 --- /dev/null +++ b/scripts/processStatsbomb.ts @@ -0,0 +1,202 @@ +// One pass over the full StatsBomb open-data lake (~17 GB, 4,200+ matches of +// event data) producing compact, shippable aggregates: +// public/data/lakestats.json — what the lake contains (the /data page proof) +// public/data/scorestate.json — goal-rate multipliers by score state (in-play v2) +// public/data/fingerprints.json— per-WC-nation style stats where event data exists +// data/lake/player_profiles.json — lake-side per-player aggregates (not shipped) +import { readFileSync, readdirSync, writeFileSync, statSync, mkdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { canonicalTeam, isKnownTeam } from '../src/lib/teams'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const LAKE = join(ROOT, 'data', 'lake', 'statsbomb', 'data'); +const OUT = join(ROOT, 'public', 'data'); + +interface MatchMeta { home: string; away: string; competition: string; isMensInternational: boolean } + +function buildMatchIndex(): Map { + const idx = new Map(); + const MENS_INTL = new Set(['FIFA World Cup', 'UEFA Euro', 'Copa America', 'African Cup of Nations']); + for (const comp of readdirSync(join(LAKE, 'matches'))) { + for (const season of readdirSync(join(LAKE, 'matches', comp))) { + const arr = JSON.parse(readFileSync(join(LAKE, 'matches', comp, season), 'utf8')) as { + match_id: number; + home_team: { home_team_name: string; home_team_gender?: string }; + away_team: { away_team_name: string }; + competition: { competition_name: string }; + }[]; + for (const m of arr) { + idx.set(m.match_id, { + home: m.home_team.home_team_name, + away: m.away_team.away_team_name, + competition: m.competition.competition_name, + isMensInternational: + MENS_INTL.has(m.competition.competition_name) && (m.home_team.home_team_gender ?? 'male') === 'male', + }); + } + } + } + return idx; +} + +interface SbEvent { + type: { name: string }; + period: number; + minute: number; + team?: { name: string }; + player?: { name: string }; + play_pattern?: { name: string }; + shot?: { statsbomb_xg?: number; outcome: { name: string } }; +} + +function main(): void { + const t0 = Date.now(); + const index = buildMatchIndex(); + const eventFiles = readdirSync(join(LAKE, 'events')); + + // accumulators + let totalBytes = 0, totalEvents = 0, parsedMatches = 0; + const players = new Map(); + const fp = new Map(); + // score-state: minutes + goals while trailing / level / leading + const state = { trailing: { min: 0, goals: 0 }, level: { min: 0, goals: 0 }, leading: { min: 0, goals: 0 } }; + + const fpGet = (t: string) => { + let v = fp.get(t); + if (!v) { v = { matches: 0, shots: 0, xg: 0, goals: 0, setPieceXg: 0, conceded: 0 }; fp.set(t, v); } + return v; + }; + + let i = 0; + for (const file of eventFiles) { + const matchId = Number(file.replace('.json', '')); + const meta = index.get(matchId); + const path = join(LAKE, 'events', file); + totalBytes += statSync(path).size; + let events: SbEvent[]; + try { events = JSON.parse(readFileSync(path, 'utf8')) as SbEvent[]; } catch { continue; } + totalEvents += events.length; + parsedMatches++; + + const homeC = meta ? canonicalTeam(meta.home) : ''; + const awayC = meta ? canonicalTeam(meta.away) : ''; + const isIntl = !!meta?.isMensInternational; + const trackHome = isIntl && isKnownTeam(homeC); + const trackAway = isIntl && isKnownTeam(awayC); + if (trackHome) fpGet(homeC).matches++; + if (trackAway) fpGet(awayC).matches++; + + // goal timeline (regulation+ET, no shootout) for the score-state walk + const goals: { minute: number; team: string }[] = []; + let endMinute = 90; + + for (const e of events) { + if (e.period >= 5) continue; + if (e.minute > endMinute) endMinute = e.minute; + const isGoal = + (e.type.name === 'Shot' && e.shot?.outcome.name === 'Goal') || e.type.name === 'Own Goal For'; + if (e.type.name === 'Shot' && e.shot && e.team) { + const xg = e.shot.statsbomb_xg ?? 0; + const teamC = canonicalTeam(e.team.name); + // player aggregates (whole corpus — club + international) + if (e.player) { + const key = e.player.name; + let p = players.get(key); + if (!p) { p = { team: teamC, shots: 0, goals: 0, xg: 0, matches: 0 }; players.set(key, p); } + p.shots++; p.xg += xg; if (e.shot.outcome.name === 'Goal') p.goals++; + } + // national-team fingerprints + if ((trackHome && teamC === homeC) || (trackAway && teamC === awayC)) { + const f = fpGet(teamC); + f.shots++; f.xg += xg; + if (e.shot.outcome.name === 'Goal') f.goals++; + const pat = e.play_pattern?.name ?? ''; + if (pat === 'From Corner' || pat === 'From Free Kick' || pat === 'From Throw In') f.setPieceXg += xg; + } + } + if (isGoal && e.team) goals.push({ minute: e.minute, team: canonicalTeam(e.team.name) }); + } + + if (trackHome && goals.length) fpGet(homeC).conceded += goals.filter((g) => g.team === awayC).length; + if (trackAway && goals.length) fpGet(awayC).conceded += goals.filter((g) => g.team === homeC).length; + + // score-state exposure walk (both teams' perspectives) + if (meta) { + goals.sort((a, b) => a.minute - b.minute); + let h = 0, a = 0, last = 0; + const credit = (from: number, to: number, hs: number, as: number) => { + const dur = Math.max(0, to - from); + if (!dur) return; + if (hs === as) state.level.min += dur * 2; + else { state.leading.min += dur; state.trailing.min += dur; } + }; + for (const g of goals) { + credit(last, g.minute, h, a); + const scorerLeading = g.team === meta.home ? h > a : a > h; + const scorerTrailing = g.team === meta.home ? h < a : a < h; + if (h === a) state.level.goals++; + else if (scorerLeading) state.leading.goals++; + else if (scorerTrailing) state.trailing.goals++; + if (g.team === meta.home) h++; else if (g.team === meta.away) a++; + last = g.minute; + } + credit(last, endMinute, h, a); + } + + if (++i % 500 === 0) console.log(` ${i}/${eventFiles.length} files… (${(totalBytes / 1e9).toFixed(1)} GB, ${(totalEvents / 1e6).toFixed(1)}M events)`); + } + + // ---- outputs ---- + const overallRate = + (state.trailing.goals + state.level.goals + state.leading.goals) / + Math.max(1, state.trailing.min + state.level.min + state.leading.min); + const mult = (s: { min: number; goals: number }) => +((s.goals / Math.max(1, s.min)) / overallRate).toFixed(3); + const scorestate = { + source: 'StatsBomb open data', + matches: parsedMatches, + multipliers: { trailing: mult(state.trailing), level: mult(state.level), leading: mult(state.leading) }, + raw: state, + }; + writeFileSync(join(OUT, 'scorestate.json'), JSON.stringify(scorestate)); + + const fingerprints = Object.fromEntries( + [...fp.entries()] + .filter(([, v]) => v.matches >= 3) + .map(([t, v]) => [t, { + matches: v.matches, + shotsPerMatch: +(v.shots / v.matches).toFixed(2), + xgPerMatch: +(v.xg / v.matches).toFixed(3), + goalsPerMatch: +(v.goals / v.matches).toFixed(2), + concededPerMatch: +(v.conceded / v.matches).toFixed(2), + setPieceXgShare: v.xg > 0 ? +(v.setPieceXg / v.xg).toFixed(3) : 0, + }]), + ); + writeFileSync(join(OUT, 'fingerprints.json'), JSON.stringify({ source: 'StatsBomb open data (men’s internationals)', teams: fingerprints })); + + const lakestats = { + generatedAt: new Date().toISOString(), + source: 'github.com/statsbomb/open-data', + bytes: totalBytes, + eventFiles: eventFiles.length, + matchesParsed: parsedMatches, + events: totalEvents, + players: players.size, + competitionSeasons: 80, + nationalTeamsCovered: Object.keys(fingerprints).length, + }; + writeFileSync(join(OUT, 'lakestats.json'), JSON.stringify(lakestats)); + + mkdirSync(join(ROOT, 'data', 'lake'), { recursive: true }); + writeFileSync( + join(ROOT, 'data', 'lake', 'player_profiles.json'), + JSON.stringify(Object.fromEntries([...players.entries()].filter(([, p]) => p.shots >= 10))), + ); + + console.log(`\nlake processed in ${((Date.now() - t0) / 1000).toFixed(0)}s`); + console.log(` ${(totalBytes / 1e9).toFixed(2)} GB events · ${(totalEvents / 1e6).toFixed(1)}M events · ${parsedMatches} matches · ${players.size} players`); + console.log(` score-state multipliers:`, scorestate.multipliers); + console.log(` WC-nation fingerprints: ${Object.keys(fingerprints).length} teams`); +} + +main(); diff --git a/server/src/db/db.ts b/server/src/db/db.ts index 7748ac0..0ed2712 100644 --- a/server/src/db/db.ts +++ b/server/src/db/db.ts @@ -177,6 +177,20 @@ export function healthRecord(source: string, ok: boolean, note?: string, openMs? }); } +/** Row counts for the /data page — everything the live layer has collected. */ +export function dbCounts(): Record { + const c = (sql: string) => (db().prepare(sql).get() as { n: number }).n; + return { + oddsLines: c('SELECT COUNT(*) n FROM odds_history'), + oddsFixtures: c('SELECT COUNT(DISTINCT fixture_num) n FROM odds_history'), + frozenForecasts: c('SELECT COUNT(*) n FROM prediction_snapshots'), + enrichedMatches: c('SELECT COUNT(*) n FROM match_ext'), + mappedFixtures: c('SELECT COUNT(*) n FROM source_map'), + cachedResponses: c('SELECT COUNT(*) n FROM kv_cache'), + ingestEvents: c('SELECT COUNT(*) n FROM ingest_log'), + }; +} + export function logIngest(source: string, job: string, ok: boolean, ms: number, note?: string): void { db().prepare('INSERT INTO ingest_log (source, job, ok, ms, note, at) VALUES (?, ?, ?, ?, ?, ?)') .run(source, job, ok ? 1 : 0, Math.round(ms), note ?? null, Date.now()); diff --git a/server/src/index.ts b/server/src/index.ts index f9b0b3d..7571acb 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 } from './db/db'; +import { db, healthAll, oddsFor, latestOddsAll, dbCounts } from './db/db'; +import { loadStatic } from './preview'; import { canonicalTeam } from '../../src/lib/teams'; import type { MatchStatus, ServerMessage } from '../../src/lib/types'; @@ -61,6 +62,22 @@ export function buildServer() { // Bookmaker odds (benchmark for the Model-vs-Market scoreboard; not a model input) app.get('/api/odds', async () => ({ odds: latestOddsAll() })); app.get('/api/scoreboard', async () => buildScoreboard(state)); + // The data lake, quantified — counters for the /data page. + app.get('/api/data/stats', async () => { + const safe = (fn: () => T): T | null => { try { return fn(); } catch { return null; } }; + return { + lake: safe(() => loadStatic('lakestats.json')), + archive: safe(() => { + const r = loadStatic<{ matchesProcessed: number; asOf: string }>('ratings.json'); + const h = loadStatic>('h2h.json'); + return { results: r.matchesProcessed, asOf: r.asOf, h2hPairings: Object.keys(h).length }; + }), + fingerprints: safe(() => Object.keys(loadStatic<{ teams: Record }>('fingerprints.json').teams).length), + squadValues: safe(() => Object.keys(loadStatic<{ teams: Record }>('squadvalues.json').teams).length), + db: dbCounts(), + sources: healthAll(), + }; + }); app.get('/api/odds/:num', async (req, reply) => { const num = Number((req.params as { num: string }).num); if (!Number.isFinite(num)) return reply.code(400).send({ error: 'bad num' }); diff --git a/server/src/preview.ts b/server/src/preview.ts index 0d1f0c9..90cb26c 100644 --- a/server/src/preview.ts +++ b/server/src/preview.ts @@ -17,7 +17,7 @@ interface H2HRecord { last: { date: string; home: string; away: string; hs: number; as: number }[]; } -function loadStatic(name: string): T { +export function loadStatic(name: string): T { const candidates = [ `${process.env.STATIC_DIR ?? ''}/data/${name}`, `${process.cwd()}/public/data/${name}`, diff --git a/src/app/RootLayout.tsx b/src/app/RootLayout.tsx index a3d0129..6bbecda 100644 --- a/src/app/RootLayout.tsx +++ b/src/app/RootLayout.tsx @@ -117,8 +117,9 @@ export function RootLayout() {

- Cup26 · World Cup 2026 · data from openfootball, football-data.org & StatsBomb open data · - model-driven odds, not betting advice + Cup26 · World Cup 2026 · all the data ·{' '} + how the model works · + model odds, not betting advice

diff --git a/src/components/ui/Term.tsx b/src/components/ui/Term.tsx new file mode 100644 index 0000000..85fa8fb --- /dev/null +++ b/src/components/ui/Term.tsx @@ -0,0 +1,25 @@ +import type { ReactNode } from 'react'; + +// Plain-language explainers for every metric, attached where the metric appears. +const GLOSSARY: Record = { + RPS: 'Ranked Probability Score — how far the forecast probabilities were from what actually happened, respecting that home win / draw / away win are ordered. 0 is perfect; lower is better.', + Brier: 'Mean squared error of the probabilities across the three outcomes. 0 is perfect; lower is better.', + ECE: 'Expected Calibration Error — the average gap between predicted probability and observed frequency. Under 0.05 is excellent.', + Elo: 'A strength rating updated after every match: beat a stronger team, gain more points. A 100-point gap ≈ 64% expected score.', + xG: 'Expected goals — the probability a shot becomes a goal, judged from historical shots like it. Total xG measures chance quality, not luck.', + 'de-vig': "Bookmaker odds include a profit margin, so their implied probabilities sum past 100%. De-vigging rescales them to a fair 100% — the bookmaker's true opinion.", + ensemble: 'Two independent goal models (Elo-based and team attack/defence) blended by a weight chosen on validation data — the blend beat both members out-of-sample.', +}; + +/** A dotted-underline term with a plain-language tooltip from the glossary. */ +export function Term({ k, children }: { k: keyof typeof GLOSSARY | string; children?: ReactNode }) { + return ( + + {children ?? k} + + ); +} diff --git a/src/features/data/DataPage.tsx b/src/features/data/DataPage.tsx new file mode 100644 index 0000000..904c6ac --- /dev/null +++ b/src/features/data/DataPage.tsx @@ -0,0 +1,153 @@ +import { useEffect, useState } from 'react'; +import { Database, HardDrive, Radio, Scale } from 'lucide-react'; +import { PageHeader } from '@/components/ui/PageHeader'; +import { Card, CardBody, CardHeader } from '@/components/ui/Card'; +import { Badge } from '@/components/ui/Badge'; + +interface LakeStats { + bytes: number; events: number; matchesParsed: number; players: number; + eventFiles: number; competitionSeasons: number; nationalTeamsCovered: number; generatedAt: string; +} +interface DataStats { + lake: LakeStats | null; + archive: { results: number; asOf: string; h2hPairings: number } | null; + fingerprints: number | null; + squadValues: number | null; + db: Record; + sources: { source: string; last_ok: number | null; consec_fail: number; open_until: number }[]; +} + +const fmt = (n: number) => n.toLocaleString(); +const gb = (b: number) => `${(b / 1e9).toFixed(2)} GB`; +const ago = (t: number | null) => { + if (!t) return 'never'; + const m = Math.round((Date.now() - t) / 60000); + return m < 1 ? 'just now' : m < 60 ? `${m} min ago` : `${Math.round(m / 60)} h ago`; +}; + +function Big({ value, label }: { value: string; label: string }) { + return ( +
+
{value}
+
{label}
+
+ ); +} + +function Counter({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +export function DataPage() { + const [stats, setStats] = useState(null); + + useEffect(() => { + let alive = true; + fetch('/api/data/stats').then((r) => r.json()).then((d: DataStats) => alive && setStats(d)).catch(() => {}); + return () => { alive = false; }; + }, []); + + if (!stats) return
; + const lake = stats.lake; + + return ( +
+ + + {lake && ( + + + + Event-data lake + StatsBomb open data + + +
+ + + + +
+

+ Every pass, shot and duel from {lake.competitionSeasons} competition-seasons (eight World Cups among them), + processed into the style fingerprints ({lake.nationalTeamsCovered} of 48 nations covered) and the + score-state analysis shown on the methodology page. +

+
+
+ )} + +
+ + + + Historical archive + + + {stats.archive && ( + <> + + + + + )} + {stats.squadValues != null && } + {stats.fingerprints != null && } + + + + + + + Live collection (this tournament) + + + + + + + + + + +
+ + + + + Source health + + + {stats.sources.length ? stats.sources.map((s) => ( +
+ {s.source} + + {s.open_until > Date.now() ? ( + paused (circuit open) + ) : s.consec_fail > 0 ? ( + {s.consec_fail} recent failure{s.consec_fail > 1 ? 's' : ''} + ) : ( + healthy + )} + last OK {ago(s.last_ok)} + +
+ )) :

No live sources polled yet.

} +

+ Sources: openfootball (fixtures) · ESPN (live scores, form, lineups, bookmaker lines) · football-data.org (fallback) · + martj42 results archive · StatsBomb open data · Transfermarkt (squad values). All scraping is server-side, cached and + rate-limited; a blocked source degrades to stale data, never a broken page. +

+
+
+
+ ); +} diff --git a/src/features/methodology/MethodologyPage.tsx b/src/features/methodology/MethodologyPage.tsx index e2a3fc9..c68938d 100644 --- a/src/features/methodology/MethodologyPage.tsx +++ b/src/features/methodology/MethodologyPage.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { Activity, Dices, Gauge, TrendingUp } from 'lucide-react'; import { PageHeader } from '@/components/ui/PageHeader'; import { Card, CardBody, CardHeader } from '@/components/ui/Card'; +import { Term } from '@/components/ui/Term'; import { ReliabilityDiagram } from '@/components/ReliabilityDiagram'; import type { BacktestReport } from '@/lib/types'; @@ -80,6 +81,9 @@ export function MethodologyPage() {
+

+ Metrics: · · · the shipped model is an . +

Ranked Probability Score — lower is better, vs candidates & baselines
diff --git a/src/features/scoreboard/ScoreboardPage.tsx b/src/features/scoreboard/ScoreboardPage.tsx index 5d7925f..52978f3 100644 --- a/src/features/scoreboard/ScoreboardPage.tsx +++ b/src/features/scoreboard/ScoreboardPage.tsx @@ -3,6 +3,7 @@ import { Link } from '@tanstack/react-router'; import { Scale } from 'lucide-react'; import { PageHeader } from '@/components/ui/PageHeader'; import { Card, CardBody, CardHeader } from '@/components/ui/Card'; +import { Term } from '@/components/ui/Term'; import { teamFlag } from '@/lib/teams'; import { kickoffDay, kickoffTime } from '@/lib/format'; import { cn } from '@/lib/cn'; @@ -119,8 +120,9 @@ export function ScoreboardPage() {

)}

- Honest rules: both forecasts frozen pre-kickoff; bookmaker margin removed (de-vig); scored with the Ranked Probability - Score; late snapshots excluded. Beating the market over a small sample is noise — watch the gap, not the lead. + Honest rules: both forecasts frozen pre-kickoff; bookmaker margin removed (); scored with the{' '} + Ranked Probability Score; late snapshots excluded. Beating the market over a small sample is + noise — watch the gap, not the lead.

diff --git a/src/router.tsx b/src/router.tsx index b922cfd..84057a9 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -11,6 +11,7 @@ import { TeamProfilePage } from './features/team/TeamProfilePage'; import { MethodologyPage } from './features/methodology/MethodologyPage'; import { TeamsPage } from './features/teams/TeamsPage'; import { ScoreboardPage } from './features/scoreboard/ScoreboardPage'; +import { DataPage } from './features/data/DataPage'; const rootRoute = createRootRoute({ component: RootLayout, @@ -27,8 +28,9 @@ const teamRoute = createRoute({ getParentRoute: () => rootRoute, path: '/team/$n const methodologyRoute = createRoute({ getParentRoute: () => rootRoute, path: '/methodology', component: MethodologyPage }); const teamsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/teams', component: TeamsPage }); const scoreboardRoute = createRoute({ getParentRoute: () => rootRoute, path: '/scoreboard', component: ScoreboardPage }); +const dataRoute = createRoute({ getParentRoute: () => rootRoute, path: '/data', component: DataPage }); -const routeTree = rootRoute.addChildren([indexRoute, groupsRoute, bracketRoute, predictRoute, storyRoute, matchRoute, teamRoute, methodologyRoute, teamsRoute, scoreboardRoute]); +const routeTree = rootRoute.addChildren([indexRoute, groupsRoute, bracketRoute, predictRoute, storyRoute, matchRoute, teamRoute, methodologyRoute, teamsRoute, scoreboardRoute, dataRoute]); export const router = createRouter({ routeTree, defaultPreload: 'intent' });