Tournament stats hub, suspension tracker, card-aware live probabilities

- /stats: FotMob tournament boards (goals/assists/rating/xG player+team
  categories from the data.fotmob.com CDN, capture-first via scheduler),
  Golden Boot race with DraftKings anytime-line benchmark from the
  already-captured scorer props, and an attack-vs-defence xG scatter.
- Discipline engine (src/lib/discipline.ts): FIFA suspension rules folded
  from stored event feeds — red/second-yellow bans, two-yellow accumulation,
  post-QF wipe. Surfaces on /stats, team profiles and match lineups tabs.
- In-play win probability now accounts for red cards (10 men: own rate
  x0.67, opponent x1.15 per card) on the server tick and the match page.
- Fix ModelDiary infinite render loop (unstable zustand snapshot while the
  model loads crashed the Methodology route under React 19).
- Drop dead tables squad + goalscorers; scorer_props is now read.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 15:42:00 +02:00
parent 9e1f6e5cee
commit f2203ed4a4
22 changed files with 1148 additions and 75 deletions
+3 -24
View File
@@ -48,30 +48,6 @@ CREATE TABLE IF NOT EXISTS injuries (
PRIMARY KEY (team, player)
);
CREATE TABLE IF NOT EXISTS squad (
team TEXT NOT NULL,
player TEXT NOT NULL,
position TEXT,
number INTEGER,
market_value INTEGER,
age INTEGER,
updated_at INTEGER NOT NULL,
PRIMARY KEY (team, player)
);
CREATE TABLE IF NOT EXISTS goalscorers (
date TEXT NOT NULL,
home TEXT NOT NULL,
away TEXT NOT NULL,
team TEXT NOT NULL,
scorer TEXT,
minute INTEGER,
own_goal INTEGER NOT NULL DEFAULT 0,
penalty INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_goalscorers_scorer ON goalscorers(scorer);
CREATE INDEX IF NOT EXISTS idx_goalscorers_team ON goalscorers(team);
CREATE TABLE IF NOT EXISTS odds_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
fixture_num INTEGER NOT NULL,
@@ -168,6 +144,9 @@ export function db(): DatabaseSync {
d.exec('PRAGMA journal_mode = WAL;');
d.exec('PRAGMA busy_timeout = 4000;');
d.exec(SCHEMA);
// Dead tables from abandoned plans — never written or read. (`injuries`
// stays: the planned API-Football sweep will fill it.)
d.exec('DROP TABLE IF EXISTS squad; DROP TABLE IF EXISTS goalscorers;');
_db = d;
console.log(`[db] sqlite ready → ${file}`);
return d;
+11 -25
View File
@@ -10,10 +10,11 @@ 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, allModelHistory, saveModelHistory } from './db/db';
import { buildStatsHub, fixtureEvents, goldenBootRows } from './stats';
import { db, healthAll, oddsFor, latestOddsAll, dbCounts, inplayHistory, recordInplay, getMatchProvider, allFixtureResults, saveFixtureResult, allModelHistory, saveModelHistory } from './db/db';
import { inPlayProbs } from '../../src/lib/model/inplay';
import { redCards } from '../../src/lib/discipline';
import { PushNotifier, pushEnabled, subscribe as pushSubscribe, unsubscribe as pushUnsubscribe, vapidPublicKey } from './push';
import type { EspnSummary } from './ingest/espnNormalize';
import { loadStatic } from './preview';
import { canonicalTeam } from '../../src/lib/teams';
import type { MatchStatus, ServerMessage } from '../../src/lib/types';
@@ -72,7 +73,8 @@ export function buildServer() {
// Record the in-play win probability per game state while matches run —
// the momentum curve on match pages. Lambdas are the frozen pre-match
// expectation (predictions only recompute when the finished set changes).
// expectation (predictions only recompute when the finished set changes);
// red cards from the stored event feed scale the remaining expectation.
const recordInplayTick = (): void => {
const preds = new Map(model.current().matches.map((m) => [m.num, m]));
for (const f of state.allFixtures()) {
@@ -80,7 +82,8 @@ export function buildServer() {
const pred = preds.get(f.num);
if (!pred) continue;
const minute = f.minute ?? 0;
const p = inPlayProbs(pred.lambdaHome, pred.lambdaAway, minute, f.homeScore, f.awayScore);
const reds = redCards(fixtureEvents(f.num, f.home.team, f.away.team));
const p = inPlayProbs(pred.lambdaHome, pred.lambdaAway, minute, f.homeScore, f.awayScore, reds.home, reds.away);
recordInplay(f.num, {
minute, home_score: f.homeScore, away_score: f.awayScore,
p_home: +p.home.toFixed(4), p_draw: +p.draw.toFixed(4), p_away: +p.away.toFixed(4),
@@ -212,27 +215,10 @@ export function buildServer() {
// Golden Boot: per-player goals aggregated from structured ESPN scoring
// plays across every started match (own goals + shootout kicks excluded).
app.get('/api/goldenboot', async () => {
const tally = new Map<string, { player: string; team: string; goals: number; latest: string }>();
for (const f of state.allFixtures()) {
if (f.status === 'scheduled') continue;
const ext = getMatchExt<EspnSummary>(f.num);
if (!ext) continue;
const teamName = new Map(ext.data.teams.map((tm) => [tm.id, tm.name]));
for (const e of ext.data.events) {
if (!e.scoring || e.shootout || !e.scorer) continue;
if (/own goal/i.test(e.type)) continue;
const team = (e.teamId && teamName.get(e.teamId)) ?? '';
const key = `${e.scorer}|${team}`;
const cur = tally.get(key) ?? { player: e.scorer, team, goals: 0, latest: f.kickoff };
cur.goals += 1;
if (f.kickoff > cur.latest) cur.latest = f.kickoff;
tally.set(key, cur);
}
}
const players = [...tally.values()].sort((a, b) => b.goals - a.goals || a.player.localeCompare(b.player)).slice(0, 25);
return { players };
});
app.get('/api/goldenboot', async () => ({ players: goldenBootRows(state) }));
// The tournament stats hub: FotMob boards (stored by the scheduler), the
// Golden Boot race + bookmaker benchmark, cards and suspensions.
app.get('/api/stats', async () => buildStatsHub(state));
app.get('/api/scoreboard', async () => buildScoreboard(state));
// The data lake, quantified — counters for the /data page.
app.get('/api/data/stats', async () => {
+22
View File
@@ -6,6 +6,7 @@ import { fetchFootballData } from '../footballData';
import { fetchSofascore } from '../sofascore';
import { setSourceMap, getSourceMap, getMatchExt, setMatchExt, setMatchProvider, getMatchProvider, recordOdds, recordScorerProp, logIngest, prune, backupDb, saveFixtureResult } from '../db/db';
import { fetchFotmobDetails, fetchFotmobMatches } from './fotmob';
import { refreshTournamentStats } from './tournamentStats';
import { fetchFifaCalendar, fetchFifaLive, fetchFifaTimeline } from './fifa';
import { fetchMatchWeather } from './weather';
import { fetchScorerProps } from './espnProps';
@@ -248,6 +249,24 @@ export function startScheduler(
fotmobTimer = setTimeout(async () => { await fotmobTick().catch(() => {}); scheduleFotmob(); }, state.hasLive() ? 90_000 : 30 * 60_000);
};
// ---- FotMob tournament stat boards (top scorers, assists, xG, team xG…).
// Static CDN files; the assembled payload is stored and served as-is, so a
// blocked source only ever means staler boards.
const statsTick = async (): Promise<void> => {
const t0 = Date.now();
try {
const { boards, rows } = await refreshTournamentStats();
logIngest('fotmob-stats', 'boards', true, Date.now() - t0, `${boards} boards, ${rows} rows`);
} catch (e) {
logIngest('fotmob-stats', 'boards', false, Date.now() - t0, e instanceof Error ? e.message : String(e));
}
};
let statsTimer: ReturnType<typeof setTimeout> | undefined;
const scheduleStats = (): void => {
if (stopped) return;
statsTimer = setTimeout(async () => { await statsTick().catch(() => {}); scheduleStats(); }, state.hasLive() ? 20 * 60_000 : 60 * 60_000);
};
// ---- FIFA official: confirmed lineups, XY timelines, attendance/officials.
const fifaTick = async (): Promise<void> => {
const now = Date.now();
@@ -382,6 +401,8 @@ export function startScheduler(
}
await fotmobTick().catch(() => {}); // capture-first: unofficial source, harvest while it lasts
scheduleFotmob();
await statsTick().catch(() => {});
scheduleStats();
await fifaTick().catch(() => {});
scheduleFifa();
await oddsTick().catch(() => {});
@@ -398,6 +419,7 @@ export function startScheduler(
if (enrichTimer) clearTimeout(enrichTimer);
if (oddsTimer) clearTimeout(oddsTimer);
if (fotmobTimer) clearTimeout(fotmobTimer);
if (statsTimer) clearTimeout(statsTimer);
if (fifaTimer) clearTimeout(fifaTimer);
clearInterval(pruneTimer);
};
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import { PLAYER_BOARDS, TEAM_BOARDS, boardSlug, normalizeStatBoard } from './statsNormalize';
// Real shape from data.fotmob.com/stats/77/season/24254/goals.json (WC 2026).
const realBoard = {
LeagueName: 'World Cup',
TopLists: [{
StatList: [
{
ParticipantName: 'Hyun-Gyu Oh', ParticiantId: 1044299, TeamId: 7804, TeamColor: '#F7383F',
StatValue: 1.0, SubStatValue: 0.0, MinutesPlayed: 21, MatchesPlayed: 1, Rank: 1,
ParticipantCountryCode: 'KOR', TeamName: 'Korea Republic', Positions: [115],
},
{ ParticipantName: 'No Value Yet', TeamName: 'Mexico' },
],
}],
};
describe('normalizeStatBoard', () => {
it('keeps the analytic fields, canonicalizes the team and drops rows without a value', () => {
expect(normalizeStatBoard(realBoard, false)).toEqual([
// canonicalTeam: FotMob's "Korea Republic" is our "South Korea"
{ name: 'Hyun-Gyu Oh', team: 'South Korea', value: 1, matches: 1, minutes: 21 },
]);
});
it('uses the canonicalized participant as the team on team boards', () => {
const rows = normalizeStatBoard(
{ TopLists: [{ StatList: [{ ParticipantName: 'Korea Republic', StatValue: 2.41, MatchesPlayed: 1 }] }] },
true,
);
expect(rows[0]).toMatchObject({ name: 'South Korea', team: 'South Korea', value: 2.41, matches: 1 });
});
it('caps the board length', () => {
const long = { TopLists: [{ StatList: Array.from({ length: 80 }, (_, i) => ({ ParticipantName: `P${i}`, TeamName: 'Mexico', StatValue: i })) }] };
expect(normalizeStatBoard(long, false)).toHaveLength(30);
});
it('survives empty or malformed boards', () => {
expect(normalizeStatBoard({}, false)).toEqual([]);
expect(normalizeStatBoard({ TopLists: [] }, true)).toEqual([]);
});
});
describe('board slugs', () => {
it('maps the CDN url to our category keys', () => {
expect(PLAYER_BOARDS[boardSlug('https://data.fotmob.com/stats/77/season/24254/goals.json')]).toBe('goals');
expect(PLAYER_BOARDS[boardSlug('https://data.fotmob.com/stats/77/season/24254/expected_goals.json')]).toBe('xg');
expect(TEAM_BOARDS[boardSlug('https://data.fotmob.com/stats/77/season/24254/_xg_diff_team.json')]).toBe('xgDiff');
expect(PLAYER_BOARDS[boardSlug(undefined)]).toBeUndefined();
});
});
+69
View File
@@ -0,0 +1,69 @@
import { canonicalTeam } from '../../../src/lib/teams';
import type { StatRow } from '../../../src/lib/types';
// Pure FotMob stat-board normalizers — no network or DB, so they're
// unit-testable in isolation (same pattern as espnNormalize).
/** fetchAllUrl filename → our category key. Slugs are locale-stable where
* the human headers ("Top scorer") are not. */
export const PLAYER_BOARDS: Record<string, string> = {
'goals.json': 'goals',
'goal_assist.json': 'assists',
'_goals_and_goal_assist.json': 'goalsAssists',
'rating.json': 'rating',
'expected_goals.json': 'xg',
'expected_goals_per_90.json': 'xgPer90',
'mins_played.json': 'minutes',
};
export const TEAM_BOARDS: Record<string, string> = {
'expected_goals_team.json': 'xg',
'_xg_diff_team.json': 'xgDiff',
'possession_percentage_team.json': 'possession',
'clean_sheet_team.json': 'cleanSheets',
'big_chance_team.json': 'bigChances',
'goals_team_match.json': 'goalsPerMatch',
'goals_conceded_team_match.json': 'concededPerMatch',
'rating_team.json': 'rating',
};
/** The leagues `stats` tab — each entry points at a static CDN board. */
export interface RawStatsTab {
stats?: {
players?: { header?: string; fetchAllUrl?: string }[];
teams?: { header?: string; fetchAllUrl?: string }[];
};
}
export interface RawStatRow {
ParticipantName?: string;
TeamName?: string;
StatValue?: number;
MatchesPlayed?: number;
MinutesPlayed?: number;
}
export interface RawStatBoard { TopLists?: { StatList?: RawStatRow[] }[] }
export function normalizeStatBoard(board: RawStatBoard, isTeam: boolean, max = 30): StatRow[] {
const rows = board.TopLists?.[0]?.StatList ?? [];
const out: StatRow[] = [];
for (const r of rows.slice(0, max)) {
const team = canonicalTeam(r.TeamName ?? (isTeam ? r.ParticipantName ?? '' : ''));
const name = isTeam ? team : r.ParticipantName ?? '';
if (!name || typeof r.StatValue !== 'number' || !Number.isFinite(r.StatValue)) continue;
out.push({
name,
team,
value: r.StatValue,
matches: typeof r.MatchesPlayed === 'number' ? r.MatchesPlayed : null,
minutes: typeof r.MinutesPlayed === 'number' ? r.MinutesPlayed : null,
});
}
return out;
}
/** The board slug of a fetchAllUrl ("…/season/24254/goals.json" → "goals.json"). */
export function boardSlug(url: string | undefined): string {
return url?.split('/').pop() ?? '';
}
+85
View File
@@ -0,0 +1,85 @@
import { cachedFetch } from './fetcher';
import { cacheGet, cacheSet } from '../db/db';
import {
PLAYER_BOARDS, TEAM_BOARDS, boardSlug, normalizeStatBoard,
type RawStatBoard, type RawStatsTab,
} from './statsNormalize';
import type { StatRow } from '../../../src/lib/types';
// FotMob's tournament stat boards. The leagues "stats" tab lists per-category
// static JSONs on data.fotmob.com (a plain CDN) — top scorers, assists,
// ratings, xG, team xG difference… Same unofficial caveat as matchDetails:
// capture-first, store the assembled payload, and let the API route serve
// the stored copy so a blocked source means stale boards, never a slow or
// broken request.
const SOURCE = 'fotmob'; // the discovery call shares FotMob's politeness key
const CDN_SOURCE = 'fotmob-stats'; // data.fotmob.com gets its own breaker
const TAB_URL = 'https://www.fotmob.com/api/data/leagues?id=77&tab=stats&type=league&timeZone=UTC';
const PAYLOAD_KEY = 'tournament:stats:v1';
const PAYLOAD_TTL = 365 * 24 * 60 * 60_000; // never expires mid-tournament; refreshes overwrite
export interface StatsBoards {
updatedAt: number;
players: Record<string, StatRow[]>;
teams: Record<string, StatRow[]>;
}
/** Fetch every wanted board and store the assembled payload. Boards that
* fail keep their previous rows — partial refreshes never lose categories. */
export async function refreshTournamentStats(): Promise<{ boards: number; rows: number }> {
const tab = await cachedFetch<RawStatsTab>(SOURCE, 'fotmob:statstab', TAB_URL, {
ttl: 6 * 60 * 60_000,
minInterval: 2_500,
});
const prev = getTournamentStats();
const players: Record<string, StatRow[]> = { ...prev?.players };
const teams: Record<string, StatRow[]> = { ...prev?.teams };
let boards = 0;
let rows = 0;
const harvest = async (
items: { fetchAllUrl?: string }[] | undefined,
wanted: Record<string, string>,
into: Record<string, StatRow[]>,
isTeam: boolean,
): Promise<void> => {
for (const item of items ?? []) {
const slug = boardSlug(item.fetchAllUrl);
const key = wanted[slug];
if (!item.fetchAllUrl || !key) continue;
try {
const board = await cachedFetch<RawStatBoard>(CDN_SOURCE, `fotmob:board:${slug}`, item.fetchAllUrl, {
ttl: 25 * 60_000,
minInterval: 500,
});
const normed = normalizeStatBoard(board, isTeam);
if (normed.length) {
into[key] = normed;
boards++;
rows += normed.length;
}
} catch { /* breaker/health track it; the stored payload keeps serving */ }
}
};
await harvest(tab.stats?.players, PLAYER_BOARDS, players, false);
await harvest(tab.stats?.teams, TEAM_BOARDS, teams, true);
if (boards) {
const payload: StatsBoards = { updatedAt: Date.now(), players, teams };
cacheSet(PAYLOAD_KEY, JSON.stringify(payload), PAYLOAD_TTL);
}
return { boards, rows };
}
/** The last assembled payload — stale beats absent for a stats hub. */
export function getTournamentStats(): StatsBoards | null {
const row = cacheGet(PAYLOAD_KEY);
if (!row) return null;
try {
return JSON.parse(row.value) as StatsBoards;
} catch {
return null;
}
}
+6
View File
@@ -1,5 +1,6 @@
import { readFileSync, existsSync } from 'node:fs';
import { getMatchExt, snapshotFor } from './db/db';
import { disciplineFor } from './stats';
import type { TournamentState } from './tournament';
import type { ModelEngine } from './model';
import type { EspnSummary } from './ingest/espnNormalize';
@@ -138,6 +139,10 @@ export function buildPreview(num: number, state: TournamentState, model: ModelEn
text: e.text,
team: e.teamId === eh?.id ? 'home' : e.teamId === ea?.id ? 'away' : null,
})),
suspensions: {
home: home ? disciplineFor(home, state).filter((p) => p.suspendedFor === num).map((p) => p.player) : [],
away: away ? disciplineFor(away, state).filter((p) => p.suspendedFor === num).map((p) => p.player) : [],
},
dataNote: !home || !away
? 'Knockout participants are decided once the bracket resolves.'
: ext
@@ -166,5 +171,6 @@ export function buildTeamProfile(teamRaw: string, state: TournamentState, model:
keyPlayers: scorersData()[teamRaw] ?? [],
championOdds: odds?.champion ?? null,
qualifyOdds: odds?.qualify ?? null,
discipline: disciplineFor(teamRaw, state).filter((p) => p.yellows + p.reds > 0),
};
}
+115
View File
@@ -0,0 +1,115 @@
import { getMatchExt, scorerPropsFor } from './db/db';
import { getTournamentStats } from './ingest/tournamentStats';
import { teamDiscipline, type DisciplineFixture } from '../../src/lib/discipline';
import { nameMatch } from '../../src/lib/matchEvents';
import type { TournamentState } from './tournament';
import type { EspnSummary } from './ingest/espnNormalize';
import type { GoldenBootRow, MatchLiveEvent, PlayerDiscipline, StatsHub } from '../../src/lib/types';
// Assembles /api/stats: the FotMob tournament boards (stored by the
// scheduler), the Golden Boot race with its bookmaker benchmark, and the
// card/suspension ledger folded from our own stored event feeds.
/** A fixture's stored events in the client/event-parser shape. */
export function fixtureEvents(
num: number,
home: string | null,
away: string | null,
cache?: Map<number, MatchLiveEvent[]>,
): MatchLiveEvent[] {
const hit = cache?.get(num);
if (hit) return hit;
const ext = getMatchExt<EspnSummary>(num);
const ids = new Map((ext?.data.teams ?? []).map((t) => [t.id, t.name]));
const events: MatchLiveEvent[] = (ext?.data.events ?? []).map((e) => ({
minute: e.minute,
type: e.type,
text: e.text,
team: e.teamId ? (ids.get(e.teamId) === home ? 'home' : ids.get(e.teamId) === away ? 'away' : null) : null,
}));
cache?.set(num, events);
return events;
}
/** Card ledger + FIFA suspension status for one team's squad. */
export function disciplineFor(
team: string,
state: TournamentState,
cache?: Map<number, MatchLiveEvent[]>,
): PlayerDiscipline[] {
const fixtures = state
.allFixtures()
.filter((f) => f.home.team === team || f.away.team === team)
.sort((a, b) => a.kickoff.localeCompare(b.kickoff))
.map(
(f): DisciplineFixture => ({
num: f.num,
stage: f.stage,
finished: f.status === 'finished',
side: f.home.team === team ? 'home' : 'away',
events: f.status === 'scheduled' ? [] : fixtureEvents(f.num, f.home.team, f.away.team, cache),
}),
);
return teamDiscipline(team, fixtures);
}
/** Golden Boot tally from structured ESPN scoring plays (own goals and
* shootout kicks excluded), with the latest DraftKings anytime-scorer line
* for each player's next match attached as a benchmark. */
export function goldenBootRows(state: TournamentState, max = 25): GoldenBootRow[] {
const tally = new Map<string, { player: string; team: string; goals: number }>();
for (const f of state.allFixtures()) {
if (f.status === 'scheduled') continue;
const ext = getMatchExt<EspnSummary>(f.num);
if (!ext) continue;
const teamName = new Map(ext.data.teams.map((tm) => [tm.id, tm.name]));
for (const e of ext.data.events) {
if (!e.scoring || e.shootout || !e.scorer) continue;
if (/own goal/i.test(e.type)) continue;
const team = (e.teamId && teamName.get(e.teamId)) ?? '';
const key = `${e.scorer}|${team}`;
const cur = tally.get(key) ?? { player: e.scorer, team, goals: 0 };
cur.goals += 1;
tally.set(key, cur);
}
}
return [...tally.values()]
.sort((a, b) => b.goals - a.goals || a.player.localeCompare(b.player))
.slice(0, max)
.map((p) => ({ ...p, nextOdds: nextAnytimeOdds(p.player, p.team, state) }));
}
/** Latest captured anytime-scorer line for the player's next fixture. */
function nextAnytimeOdds(player: string, team: string, state: TournamentState): number | null {
if (!team) return null;
const next = state
.allFixtures()
.filter((f) => f.status !== 'finished' && (f.home.team === team || f.away.team === team))
.sort((a, b) => a.kickoff.localeCompare(b.kickoff))[0];
if (!next) return null;
const props = scorerPropsFor(next.num).filter((p) => p.odds != null && nameMatch(p.athlete, player));
const pick = (re: RegExp) => props.filter((p) => re.test(p.market)).sort((a, b) => b.captured_at - a.captured_at)[0];
const line = pick(/anytime/i) ?? pick(/to score(?!.*(2|two|first|last))/i);
return line?.odds ?? null;
}
export function buildStatsHub(state: TournamentState): StatsHub {
const teams = new Set<string>();
for (const f of state.allFixtures()) {
if (f.home.team) teams.add(f.home.team);
if (f.away.team) teams.add(f.away.team);
}
// One parse per fixture even though every fixture feeds two team folds.
const cache = new Map<number, MatchLiveEvent[]>();
const all: PlayerDiscipline[] = [];
for (const team of teams) all.push(...disciplineFor(team, state, cache));
const carded = all
.filter((p) => p.yellows + p.reds > 0)
.sort((a, b) => b.reds - a.reds || b.yellows - a.yellows || a.player.localeCompare(b.player));
return {
boards: getTournamentStats(),
boot: goldenBootRows(state),
cards: carded.slice(0, 25),
suspensions: carded.filter((p) => p.suspendedNext),
};
}