diff --git a/server/src/db/db.ts b/server/src/db/db.ts index f90549b..61b9ed2 100644 --- a/server/src/db/db.ts +++ b/server/src/db/db.ts @@ -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; diff --git a/server/src/index.ts b/server/src/index.ts index d8200fb..85ce7c7 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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(); - for (const f of state.allFixtures()) { - if (f.status === 'scheduled') continue; - const ext = getMatchExt(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 () => { diff --git a/server/src/ingest/scheduler.ts b/server/src/ingest/scheduler.ts index b3e98d7..dab035b 100644 --- a/server/src/ingest/scheduler.ts +++ b/server/src/ingest/scheduler.ts @@ -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 => { + 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 | 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 => { 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); }; diff --git a/server/src/ingest/statsNormalize.test.ts b/server/src/ingest/statsNormalize.test.ts new file mode 100644 index 0000000..db97c1d --- /dev/null +++ b/server/src/ingest/statsNormalize.test.ts @@ -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(); + }); +}); diff --git a/server/src/ingest/statsNormalize.ts b/server/src/ingest/statsNormalize.ts new file mode 100644 index 0000000..4e61723 --- /dev/null +++ b/server/src/ingest/statsNormalize.ts @@ -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 = { + '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 = { + '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() ?? ''; +} diff --git a/server/src/ingest/tournamentStats.ts b/server/src/ingest/tournamentStats.ts new file mode 100644 index 0000000..5482d47 --- /dev/null +++ b/server/src/ingest/tournamentStats.ts @@ -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; + teams: Record; +} + +/** 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(SOURCE, 'fotmob:statstab', TAB_URL, { + ttl: 6 * 60 * 60_000, + minInterval: 2_500, + }); + const prev = getTournamentStats(); + const players: Record = { ...prev?.players }; + const teams: Record = { ...prev?.teams }; + let boards = 0; + let rows = 0; + + const harvest = async ( + items: { fetchAllUrl?: string }[] | undefined, + wanted: Record, + into: Record, + isTeam: boolean, + ): Promise => { + for (const item of items ?? []) { + const slug = boardSlug(item.fetchAllUrl); + const key = wanted[slug]; + if (!item.fetchAllUrl || !key) continue; + try { + const board = await cachedFetch(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; + } +} diff --git a/server/src/preview.ts b/server/src/preview.ts index 448c055..2ceee09 100644 --- a/server/src/preview.ts +++ b/server/src/preview.ts @@ -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), }; } diff --git a/server/src/stats.ts b/server/src/stats.ts new file mode 100644 index 0000000..d81a04c --- /dev/null +++ b/server/src/stats.ts @@ -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, +): MatchLiveEvent[] { + const hit = cache?.get(num); + if (hit) return hit; + const ext = getMatchExt(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, +): 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(); + for (const f of state.allFixtures()) { + if (f.status === 'scheduled') continue; + const ext = getMatchExt(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(); + 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(); + 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), + }; +} diff --git a/src/app/RootLayout.tsx b/src/app/RootLayout.tsx index acfa844..7918b48 100644 --- a/src/app/RootLayout.tsx +++ b/src/app/RootLayout.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; import { Link, Outlet, useRouterState } from '@tanstack/react-router'; -import { Database, Gauge, GitMerge, Languages, Menu, Moon, Radio, Scale, Search, Sparkles, Sun, Swords, Table, TrendingUp, Trophy, Users } from 'lucide-react'; +import { ChartColumn, Database, Gauge, GitMerge, Languages, Menu, Moon, Radio, Scale, Search, Sparkles, Sun, Swords, Table, TrendingUp, Trophy, Users } from 'lucide-react'; import { SearchPalette } from '@/components/SearchPalette'; import type { LucideIcon } from 'lucide-react'; import { useUiStore } from '@/stores/uiStore'; @@ -16,6 +16,7 @@ const NAV: NavItem[] = [ { to: '/groups', label: (t) => t.nav.groups, exact: false, icon: Table }, { to: '/bracket', label: (t) => t.nav.bracket, exact: false, icon: GitMerge }, { to: '/predict', label: (t) => t.nav.predict, exact: false, icon: TrendingUp }, + { to: '/stats', label: (t) => t.nav.stats, exact: false, icon: ChartColumn }, { to: '/scoreboard', label: (t) => t.nav.vsMarket, exact: false, icon: Scale }, { to: '/methodology', label: (t) => t.nav.model, exact: false, icon: Gauge }, { to: '/story', label: (t) => t.nav.story, exact: false, icon: Sparkles }, diff --git a/src/features/match/MatchPreviewPage.tsx b/src/features/match/MatchPreviewPage.tsx index bdd42d3..1daac9d 100644 --- a/src/features/match/MatchPreviewPage.tsx +++ b/src/features/match/MatchPreviewPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; import { Link, useParams } from '@tanstack/react-router'; -import { ArrowLeft, ArrowRight, Shield, Shirt } from 'lucide-react'; +import { ArrowLeft, ArrowRight, Ban, Shield, Shirt } from 'lucide-react'; import { Badge } from '@/components/ui/Badge'; import { Card, CardBody, CardHeader } from '@/components/ui/Card'; import { WinProbBar } from '@/components/WinProbBar'; @@ -15,6 +15,7 @@ import { LineupPitch } from '@/components/LineupPitch'; import { teamFlag } from '@/lib/teams'; import { fmt, useFormat, useT } from '@/lib/i18n'; import { goalEvents } from '@/lib/matchEvents'; +import { redCards } from '@/lib/discipline'; import { inPlayProbs } from '@/lib/model/inplay'; import { useTournamentStore } from '@/stores/tournamentStore'; import type { Fixture, KeyPlayer, MatchPreview, MatchRich, OddsPoint } from '@/lib/types'; @@ -147,7 +148,8 @@ export function MatchPreviewPage() { const inPlay = useMemo(() => { if (!preview?.prediction || !f || f.status !== 'live' || f.homeScore == null || f.awayScore == null) return null; - return inPlayProbs(preview.prediction.lambdaHome, preview.prediction.lambdaAway, f.minute ?? 0, f.homeScore, f.awayScore); + const reds = redCards(preview.events); + return inPlayProbs(preview.prediction.lambdaHome, preview.prediction.lambdaAway, f.minute ?? 0, f.homeScore, f.awayScore, reds.home, reds.away); }, [preview, f]); const statRows = useMemo<[string, string][]>(() => [ @@ -428,6 +430,21 @@ export function MatchPreviewPage() { {/* ── Lineups ── */} {tab === 'lineups' && (
+ {(preview.suspensions.home.length > 0 || preview.suspensions.away.length > 0) && ( + + + {([['home', preview.suspensions.home], ['away', preview.suspensions.away]] as const) + .filter(([, players]) => players.length > 0) + .map(([side, players]) => ( +
+ + {(side === 'home' ? f.home.team : f.away.team) ? teamFlag((side === 'home' ? f.home.team : f.away.team)!) : ''} + {fmt(t.match.suspended, { players: players.join(', ') })} +
+ ))} +
+
+ )} {/* Before kickoff the pitch shows FotMob's projected XIs, clearly labeled as predicted; from kickoff it's the confirmed lineup. */} {rich?.lineup ? ( diff --git a/src/features/methodology/MethodologyPage.tsx b/src/features/methodology/MethodologyPage.tsx index eaa4f68..1cb62a9 100644 --- a/src/features/methodology/MethodologyPage.tsx +++ b/src/features/methodology/MethodologyPage.tsx @@ -14,7 +14,9 @@ const STEP_ICONS = [TrendingUp, Activity, Gauge, Dices] as const; /** Transparency log: every model recompute and the title odds it shifted. */ function ModelDiary() { const { t, kickoffDay, kickoffTime } = useFormat(); - const history = useTournamentStore((s) => s.model?.history ?? []); + // Default OUTSIDE the selector — `?? []` inside returns a fresh array every + // getSnapshot while the model loads, which React 19 escalates into a loop. + const history = useTournamentStore((s) => s.model?.history) ?? []; if (history.length < 2) return null; const entries = history.slice(-9).reverse(); return ( diff --git a/src/features/stats/StatsPage.tsx b/src/features/stats/StatsPage.tsx new file mode 100644 index 0000000..a05646d --- /dev/null +++ b/src/features/stats/StatsPage.tsx @@ -0,0 +1,299 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Link } from '@tanstack/react-router'; +import { Award, Ban, ChartScatter, Shirt, Users } from 'lucide-react'; +import { PageHeader } from '@/components/ui/PageHeader'; +import { Card, CardBody, CardHeader } from '@/components/ui/Card'; +import { teamFlag } from '@/lib/teams'; +import { fmt, plural, useFormat, useT, type Dict } from '@/lib/i18n'; +import { cn } from '@/lib/cn'; +import type { StatRow, StatsHub } from '@/lib/types'; + +type CatKey = keyof Dict['stats']['cat']; + +const PLAYER_CATS: CatKey[] = ['goals', 'assists', 'goalsAssists', 'rating', 'xg', 'xgPer90', 'minutes']; +const TEAM_CATS: CatKey[] = ['xg', 'xgDiff', 'possession', 'cleanSheets', 'bigChances', 'goalsPerMatch', 'concededPerMatch', 'rating']; +const INT_CATS = new Set(['goals', 'assists', 'goalsAssists', 'minutes', 'cleanSheets', 'bigChances']); + +function fmtVal(key: CatKey, v: number): string { + if (key === 'possession') return `${v.toFixed(1)}%`; + if (key === 'xgDiff') return `${v > 0 ? '+' : ''}${v.toFixed(2)}`; + if (INT_CATS.has(key)) return String(Math.round(v)); + return v.toFixed(2); +} + +const american = (o: number) => (o > 0 ? `+${o}` : String(o)); + +function CatPicker({ cats, active, onPick }: { cats: CatKey[]; active: CatKey; onPick: (c: CatKey) => void }) { + const t = useT(); + return ( +
+ {cats.map((c) => ( + + ))} +
+ ); +} + +function BoardList({ rows, cat, linkTeams }: { rows: StatRow[]; cat: CatKey; linkTeams: boolean }) { + const t = useT(); + return ( +
    + {rows.slice(0, 15).map((r, i) => ( +
  1. + {i + 1} + {r.team ? teamFlag(r.team) : ''} + {linkTeams && r.team ? ( + {r.name} + ) : ( + {r.name} + )} + {r.matches != null && {plural(t.stats.matchesPlayed, r.matches)}} + {fmtVal(cat, r.value)} +
  2. + ))} +
+ ); +} + +/** Each team by xG created vs conceded per match — the tournament at a glance. */ +function XgScatter({ xg, xgDiff }: { xg: StatRow[]; xgDiff: StatRow[] }) { + const t = useT(); + const pts = useMemo(() => { + const diffBy = new Map(xgDiff.map((r) => [r.team, r.value])); + return xg.flatMap((r) => { + const d = diffBy.get(r.team); + const m = r.matches ?? 0; + if (d == null || m < 1) return []; + return [{ team: r.team, x: r.value / m, y: (r.value - d) / m }]; + }); + }, [xg, xgDiff]); + if (pts.length < 4) return null; + + const W = 560; + const H = 340; + const P = { l: 34, r: 14, t: 10, b: 30 }; + const xs = pts.map((p) => p.x); + const ys = pts.map((p) => p.y); + const pad = (lo: number, hi: number): [number, number] => { + const d = Math.max(0.2, (hi - lo) * 0.18); + return [lo - d, hi + d]; + }; + const [x0, x1] = pad(Math.min(...xs), Math.max(...xs)); + const [y0, y1] = pad(Math.min(...ys), Math.max(...ys)); + const px = (v: number) => P.l + ((v - x0) / (x1 - x0)) * (W - P.l - P.r); + // Low conceded sits at the BOTTOM — down means a tighter defence. + const py = (v: number) => P.t + (1 - (v - y0) / (y1 - y0)) * (H - P.t - P.b); + const mx = xs.reduce((s, v) => s + v, 0) / xs.length; + const my = ys.reduce((s, v) => s + v, 0) / ys.length; + + return ( +
+ + + + + + + {t.stats.scatter.xAxis} + {t.stats.scatter.yAxis} + + {pts.map((p) => ( + + {`${p.team} · ${p.x.toFixed(2)} / ${p.y.toFixed(2)}`} + {teamFlag(p.team)} + + ))} + +

{t.stats.scatter.note}

+
+ ); +} + +function GoldenBoot({ hub }: { hub: StatsHub }) { + const t = useT(); + const top = hub.boot.slice(0, 10); + if (!top.length) return null; + const max = top[0]?.goals ?? 1; + const hasOdds = top.some((p) => p.nextOdds != null); + return ( + + + + {t.teams.goldenBoot} + + +
    + {top.map((p, i) => ( +
  1. + {i + 1} + {p.team ? teamFlag(p.team) : ''} + {p.player} + {p.nextOdds != null && ( + + {fmt(t.stats.boot.oddsChip, { odds: american(p.nextOdds) })} + + )} +
    +
    +
    + {p.goals} +
  2. + ))} +
+

{t.teams.goldenBootNote}{hasOdds ? ` ${t.stats.boot.oddsNote}` : ''}

+
+
+ ); +} + +function Discipline({ hub }: { hub: StatsHub }) { + const t = useT(); + return ( + + + + {t.stats.discipline.title} + + + {hub.suspensions.length > 0 && ( +
+
{t.stats.discipline.suspendedHeading}
+
    + {hub.suspensions.map((p) => ( +
  • + {teamFlag(p.team)} + {p.player} + + {p.suspendedFor != null ? fmt(t.stats.discipline.suspendedFor, { num: p.suspendedFor }) : t.stats.discipline.suspendedNext} + +
  • + ))} +
+
+ )} + {hub.cards.length ? ( +
    + {hub.cards.slice(0, 15).map((p) => ( +
  1. + {teamFlag(p.team)} + {p.player} + + {Array.from({ length: p.yellows }).map((_, i) => )} + {Array.from({ length: p.reds }).map((_, i) => )} + + {!p.suspendedNext && p.pending === 1 && ( + {t.stats.discipline.atRisk} + )} +
  2. + ))} +
+ ) : ( +

{t.stats.discipline.empty}

+ )} +

{t.stats.discipline.note}

+
+
+ ); +} + +export function StatsPage() { + const { t, kickoffTime } = useFormat(); + const [hub, setHub] = useState(null); + const [playerCat, setPlayerCat] = useState('goals'); + const [teamCat, setTeamCat] = useState('xg'); + + useEffect(() => { + let alive = true; + fetch('/api/stats') + .then((r) => (r.ok ? r.json() : Promise.reject())) + .then((d: StatsHub) => alive && setHub(d)) + .catch(() => alive && setHub({ boards: null, boot: [], cards: [], suspensions: [] })); + return () => { alive = false; }; + }, []); + + const boards = hub?.boards ?? null; + const playerCats = PLAYER_CATS.filter((c) => (boards?.players[c]?.length ?? 0) > 0); + const teamCats = TEAM_CATS.filter((c) => (boards?.teams[c]?.length ?? 0) > 0); + const pCat: CatKey = playerCats.includes(playerCat) ? playerCat : playerCats[0] ?? 'goals'; + const tCat: CatKey = teamCats.includes(teamCat) ? teamCat : teamCats[0] ?? 'xg'; + + if (!hub) { + return ( +
+ +
+ {Array.from({ length: 4 }).map((_, i) =>
)} +
+
+ ); + } + + return ( +
+ + +
+ + +
+ + {playerCats.length > 0 ? ( + + + + {t.stats.playersHeading} + {boards && ( + + {fmt(t.stats.updated, { time: kickoffTime(new Date(boards.updatedAt).toISOString()) })} + + )} + + + + + + + ) : ( +

{t.stats.boardsEmpty}

+ )} + + {teamCats.length > 0 && ( +
+ + + + {t.stats.teamsHeading} + + + + + + + {(boards?.teams.xg?.length ?? 0) > 0 && (boards?.teams.xgDiff?.length ?? 0) > 0 && ( + + + + {t.stats.scatter.title} + + + + + + )} +
+ )} + +

{t.stats.attribution}

+
+ ); +} diff --git a/src/features/team/TeamProfilePage.tsx b/src/features/team/TeamProfilePage.tsx index 9d985bd..bd554ee 100644 --- a/src/features/team/TeamProfilePage.tsx +++ b/src/features/team/TeamProfilePage.tsx @@ -180,23 +180,51 @@ export function TeamProfilePage() { - - {t.team.allTimeTopScorers} - - {profile.keyPlayers.length ? ( -
    - {profile.keyPlayers.map((p, i) => ( -
  1. - {i + 1} - {p.name} - {p.goals} - {p.pens ? {fmt(t.common.pensShort, { n: p.pens })} : null} -
  2. - ))} -
- ) :

{t.common.noData}

} -
-
+
+ {profile.discipline.length > 0 && ( + + {t.team.discipline} + +
    + {profile.discipline.map((p) => ( +
  • + {p.player} + + {Array.from({ length: p.yellows }).map((_, i) => )} + {Array.from({ length: p.reds }).map((_, i) => )} + + {p.suspendedNext && ( + + {p.suspendedFor != null ? fmt(t.team.suspChipFor, { num: p.suspendedFor }) : t.team.suspChipNext} + + )} + {!p.suspendedNext && p.pending === 1 && ( + {t.team.atRiskChip} + )} +
  • + ))} +
+
+
+ )} + + {t.team.allTimeTopScorers} + + {profile.keyPlayers.length ? ( +
    + {profile.keyPlayers.map((p, i) => ( +
  1. + {i + 1} + {p.name} + {p.goals} + {p.pens ? {fmt(t.common.pensShort, { n: p.pens })} : null} +
  2. + ))} +
+ ) :

{t.common.noData}

} +
+
+
); diff --git a/src/features/teams/TeamsPage.tsx b/src/features/teams/TeamsPage.tsx index 3ec99f8..5d8f0fd 100644 --- a/src/features/teams/TeamsPage.tsx +++ b/src/features/teams/TeamsPage.tsx @@ -50,6 +50,7 @@ function GoldenBoot() { ))}

{t.teams.goldenBootNote}

+ {t.teams.fullStats} → ); diff --git a/src/lib/discipline.test.ts b/src/lib/discipline.test.ts new file mode 100644 index 0000000..783914e --- /dev/null +++ b/src/lib/discipline.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest'; +import { redCards, teamDiscipline, type DisciplineFixture } from './discipline'; +import type { MatchLiveEvent } from './types'; + +const yellow = (player: string, team: 'home' | 'away', minute = 30): MatchLiveEvent => ({ + minute, type: 'Yellow Card', team, + text: `${player} (Somewhere) is shown the yellow card for a bad foul.`, +}); +const red = (player: string, team: 'home' | 'away', minute = 50): MatchLiveEvent => ({ + minute, type: 'Red Card', team, + text: `${player} (Somewhere) is shown the red card.`, +}); + +const fx = (num: number, side: 'home' | 'away', events: MatchLiveEvent[], finished = true, stage: DisciplineFixture['stage'] = 'group'): DisciplineFixture => + ({ num, stage, finished, side, events }); + +describe('teamDiscipline', () => { + it('a straight red bans the player for the next match', () => { + const out = teamDiscipline('South Africa', [ + fx(1, 'away', [red('Yaya Sithole', 'away', 49)]), + fx(25, 'home', [], false), + fx(40, 'away', [], false), + ]); + expect(out[0]).toMatchObject({ player: 'Yaya Sithole', reds: 1, suspendedNext: true, suspendedFor: 25 }); + }); + + it('a served ban clears itself once the next match finished', () => { + const out = teamDiscipline('South Africa', [ + fx(1, 'away', [red('Yaya Sithole', 'away')]), + fx(25, 'home', [], true), + fx(40, 'away', [], false), + ]); + expect(out[0]).toMatchObject({ reds: 1, suspendedNext: false, suspendedFor: null }); + }); + + it('two yellows across matches accumulate into a ban and spend the pair', () => { + const out = teamDiscipline('Mexico', [ + fx(1, 'home', [yellow('Edson Alvarez', 'home')]), + fx(2, 'home', [yellow('Edson Alvarez', 'home')]), + fx(3, 'home', [], false), + ]); + expect(out[0]).toMatchObject({ yellows: 2, pending: 0, suspendedNext: true, suspendedFor: 3 }); + }); + + it('one yellow leaves the player a booking from a ban', () => { + const out = teamDiscipline('Mexico', [fx(1, 'home', [yellow('Erik Lira', 'home')]), fx(2, 'home', [], false)]); + expect(out[0]).toMatchObject({ yellows: 1, pending: 1, suspendedNext: false }); + }); + + it('a second yellow in one match counts as a sending-off, pair spent', () => { + const out = teamDiscipline('Mexico', [ + fx(1, 'home', [yellow('Cesar Montes', 'home', 30), yellow('Cesar Montes', 'home', 70), red('Cesar Montes', 'home', 70)]), + fx(2, 'home', [], false), + ]); + expect(out[0]).toMatchObject({ yellows: 2, reds: 1, pending: 0, suspendedNext: true, suspendedFor: 2 }); + }); + + it('ignores the opponent’s cards and unparseable lines', () => { + const out = teamDiscipline('Mexico', [ + fx(1, 'home', [yellow('Their Guy', 'away'), { minute: 12, type: 'Yellow Card', team: 'home', text: '' }]), + ]); + expect(out).toHaveLength(0); + }); + + it('wipes single yellows once past the quarter-finals', () => { + const out = teamDiscipline('Mexico', [ + fx(60, 'home', [yellow('Erik Lira', 'home')], true, 'qf'), + fx(101, 'home', [yellow('Erik Lira', 'home')], true, 'sf'), + fx(104, 'home', [], false, 'final'), + ]); + // The QF booking is wiped; only the SF one stands — no accumulation ban. + expect(out[0]).toMatchObject({ yellows: 2, pending: 1, suspendedNext: false }); + }); + + it('an unresolved next fixture still flags the suspension', () => { + const out = teamDiscipline('Mexico', [fx(72, 'home', [red('Johan Vasquez', 'home')], true)]); + expect(out[0]).toMatchObject({ suspendedNext: true, suspendedFor: null }); + }); +}); + +describe('redCards', () => { + it('counts explicit reds per side', () => { + expect(redCards([red('A', 'home'), red('B', 'away'), red('C', 'away')])).toEqual({ home: 1, away: 2 }); + }); + + it('infers a red from two yellows when the feed lacks the red row', () => { + expect(redCards([yellow('A', 'home', 30), yellow('A', 'home', 75)])).toEqual({ home: 1, away: 0 }); + }); + + it('does not double-count a second yellow that did emit a red row', () => { + expect(redCards([yellow('A', 'home', 30), yellow('A', 'home', 75), red('A', 'home', 75)])).toEqual({ home: 1, away: 0 }); + }); +}); diff --git a/src/lib/discipline.ts b/src/lib/discipline.ts new file mode 100644 index 0000000..35859fb --- /dev/null +++ b/src/lib/discipline.ts @@ -0,0 +1,130 @@ +import { parseEvents, norm } from './matchEvents'; +import type { MatchLiveEvent, PlayerDiscipline, Stage } from './types'; + +/** + * FIFA World Cup discipline, folded from the parsed event timelines: + * - a red card (straight or second yellow) bans the player from his team's + * next match (longer bans are committee decisions we can't see in the feed); + * - two single yellows in different matches also ban for one match, and the + * pair is then spent; + * - single yellows are wiped once the quarter-finals are over, so + * accumulation alone can never rule a player out of the final. + * The fold always runs over current data, so served bans clear themselves. + */ + +const ORDER: Record = { group: 0, r32: 1, r16: 2, qf: 3, sf: 4, third: 5, final: 6 }; +const WIPE_AFTER = ORDER.qf; + +/** One of the team's fixtures, oldest first, with its (possibly empty) events. */ +export interface DisciplineFixture { + num: number; + stage: Stage; + finished: boolean; + /** Which side the team under analysis played. */ + side: 'home' | 'away'; + events: MatchLiveEvent[]; +} + +export function teamDiscipline(team: string, fixtures: DisciplineFixture[]): PlayerDiscipline[] { + const players = new Map(); + const get = (name: string): PlayerDiscipline => { + const key = norm(name); + let p = players.get(key); + if (!p) { + p = { player: name, team, yellows: 0, reds: 0, pending: 0, suspendedNext: false, suspendedFor: null }; + players.set(key, p); + } + return p; + }; + const trigger = (p: PlayerDiscipline, next: DisciplineFixture | undefined): void => { + p.suspendedNext = !next || !next.finished; + p.suspendedFor = next && !next.finished ? next.num : null; + }; + + let wiped = false; + fixtures.forEach((f, i) => { + // Crossing into the semi-finals wipes the single-yellow slate. + if (!wiped && ORDER[f.stage] > WIPE_AFTER) { + for (const p of players.values()) p.pending = 0; + wiped = true; + } + if (!f.events.length) return; + const cards = parseEvents(f.events).filter( + (e) => + (e.kind === 'yellow' || e.kind === 'red') && + e.side === f.side && + // Only properly parsed names — an unparseable or empty line keeps its + // raw text / type as the title, which must not become a phantom player. + e.raw.trim().length > 0 && + e.title !== e.raw.trim() && + e.title.length <= 40, + ); + if (!cards.length) return; + const byPlayer = new Map(); + for (const c of cards) { + const key = norm(c.title); + const cur = byPlayer.get(key) ?? { yellows: 0, reds: 0, name: c.title }; + if (c.kind === 'yellow') cur.yellows++; + else cur.reds++; + byPlayer.set(key, cur); + } + const next = fixtures[i + 1]; + for (const { yellows, reds, name } of byPlayer.values()) { + const p = get(name); + p.yellows += yellows; + p.reds += reds; + // Two yellows in one match = sent off, even when the feed lacks the red row. + const secondYellow = yellows >= 2; + if (reds > 0 || secondYellow) { + // The pair behind a second yellow is spent; a lone yellow before a + // straight red still counts toward accumulation. + if (!secondYellow) p.pending += yellows; + if (p.pending >= 2) p.pending -= 2; // an accumulation ban due now merges into the same suspension + trigger(p, next); + } else if (yellows > 0) { + p.pending += yellows; + if (p.pending >= 2) { + p.pending -= 2; + trigger(p, next); + } + } + } + }); + + // A ban pointing at a now-finished fixture has been served. + const byNum = new Map(fixtures.map((f) => [f.num, f])); + for (const p of players.values()) { + if (p.suspendedFor != null && byNum.get(p.suspendedFor)?.finished) { + p.suspendedFor = null; + p.suspendedNext = false; + } + } + + return [...players.values()].sort( + (a, b) => b.reds - a.reds || b.yellows - a.yellows || a.player.localeCompare(b.player), + ); +} + +/** Red cards per side so far — including second yellows the feed never turned + * into an explicit red row. Feeds the card-aware in-play win probability. */ +export function redCards(events: MatchLiveEvent[]): { home: number; away: number } { + const out = { home: 0, away: 0 }; + const yellowsBy = new Map(); + for (const e of parseEvents(events)) { + if (!e.side || !e.raw.trim() || e.title === e.raw.trim()) continue; + if (e.kind === 'red') { + out[e.side]++; + const y = yellowsBy.get(norm(e.title)); + if (y) y.hasRed = true; + else yellowsBy.set(norm(e.title), { side: e.side, n: 0, hasRed: true }); + } else if (e.kind === 'yellow') { + const y = yellowsBy.get(norm(e.title)) ?? { side: e.side, n: 0, hasRed: false }; + y.n++; + yellowsBy.set(norm(e.title), y); + } + } + for (const y of yellowsBy.values()) { + if (y.n >= 2 && !y.hasRed) out[y.side]++; + } + return out; +} diff --git a/src/lib/i18n/de.ts b/src/lib/i18n/de.ts index c68738e..e6ee017 100644 --- a/src/lib/i18n/de.ts +++ b/src/lib/i18n/de.ts @@ -7,6 +7,7 @@ export const de: Dict = { groups: "Gruppen", bracket: "Turnierbaum", predict: "Prognose", + stats: "Statistik", vsMarket: "vs. Markt", model: "Modell", story: "Story", @@ -80,7 +81,7 @@ export const de: Dict = { loadError: "Dieses Spiel konnte nicht geladen werden.", backToLive: "Zurück zu Live", liveWinProbability: "Live-Siegwahrscheinlichkeit", - inPlayHelp: "Aktualisiert sich mit Spielstand und Spielminute. Die noch zu erwartenden Tore werden aus der Vor-Anstoß-Prognose beider Teams geschätzt.", + inPlayHelp: "Aktualisiert sich mit Spielstand und Spielminute. Die noch zu erwartenden Tore werden aus der Vor-Anstoß-Prognose beider Teams geschätzt — eine Rote Karte verschiebt sie: Zu zehnt trifft man seltener und kassiert öfter.", statsTitle: "Spielstatistik", stats: { possessionPct: "Ballbesitz", @@ -105,6 +106,7 @@ export const de: Dict = { recentMeetings: "Letzte Duelle", }, lineups: "Aufstellungen", + suspended: "Für dieses Spiel gesperrt: {players}", keyPlayers: "Schlüsselspieler (beste Torschützen aller Zeiten)", xgRace: "xG-Rennen", shotMap: "Schusskarte", @@ -257,6 +259,54 @@ export const de: Dict = { advanceShort: "Weiterkommen {pct}", goldenBoot: "Torjägerliste", goldenBootNote: "Tore in diesem Turnier, aus dem Live-Eventfeed. Eigentore und Elfmeterschießen zählen nicht.", + fullStats: "Alle Turnier-Statistiken", + }, + stats: { + title: "Statistik", + subtitle: "Turnierweite Bestenlisten — Tore, Kreativität, erwartete Tore und Karten, laufend aktualisiert.", + playersHeading: "Spieler-Bestenlisten", + teamsHeading: "Team-Bestenlisten", + updated: "Bestenlisten aktualisiert {time}", + boardsEmpty: "Die Bestenlisten füllen sich, sobald die ersten Spiele gespielt sind.", + matchesPlayed: { + one: "{n} Spiel", + other: "{n} Spiele", + }, + cat: { + goals: "Tore", + assists: "Vorlagen", + goalsAssists: "Tore + Vorlagen", + rating: "Bewertung", + xg: "xG", + xgPer90: "xG pro 90", + minutes: "Minuten", + xgDiff: "xG-Differenz", + possession: "Ballbesitz", + cleanSheets: "Zu-null-Spiele", + bigChances: "Großchancen", + goalsPerMatch: "Tore pro Spiel", + concededPerMatch: "Gegentore pro Spiel", + }, + scatter: { + title: "Angriff vs. Abwehr", + note: "Jedes Team nach erspielten und zugelassenen erwarteten Toren pro Spiel. Rechts heißt mehr Kreativität, unten heißt stabilere Abwehr — die kompletten Teams stehen rechts unten.", + xAxis: "erspielte xG pro Spiel", + yAxis: "zugelassene xG pro Spiel", + }, + discipline: { + title: "Karten & Sperren", + suspendedHeading: "Gesperrt fürs nächste Spiel", + suspendedFor: "fehlt in Sp. {num}", + suspendedNext: "fehlt im nächsten Spiel", + atRisk: "eine Gelbe vor einer Sperre", + empty: "Noch keine Karten — ein bemerkenswert faires Turnier.", + note: "FIFA-Regeln: Eine Rote Karte oder zwei Gelbe in verschiedenen Spielen bedeuten ein Spiel Sperre. Einzelne Gelbe verfallen nach dem Viertelfinale.", + }, + boot: { + oddsChip: "{odds} nächstes Spiel", + oddsNote: "Quote: die DraftKings-Linie (trifft jederzeit) fürs nächste Spiel des Spielers — nur ein Vergleichswert, nie ein Modell-Input.", + }, + attribution: "Spieler- und Team-Bestenlisten sind FotMob-Schätzungen. Karten und Tore stammen aus dem Live-Eventfeed.", }, outcome: { home: "Heimsieg", @@ -482,6 +532,10 @@ export const de: Dict = { standingLine: "#{rank} · {points} Pkt. · {won}-{drawn}-{lost} · TD {gd}", fixtures: "Spielplan", eloHistory: "Elo-Wertung im Zeitverlauf", + discipline: "Karten & Sperren", + suspChipNext: "fehlt im nächsten Spiel", + suspChipFor: "fehlt in Sp. {num}", + atRiskChip: "eine Gelbe vor einer Sperre", allTimeTopScorers: "Beste Torschützen aller Zeiten", groupShort: "Gr. {group}", homeShort: "H", diff --git a/src/lib/i18n/en.ts b/src/lib/i18n/en.ts index 5524dd8..d6b64e9 100644 --- a/src/lib/i18n/en.ts +++ b/src/lib/i18n/en.ts @@ -6,6 +6,7 @@ export const en = { groups: "Groups", bracket: "Bracket", predict: "Predict", + stats: "Stats", vsMarket: "vs Market", model: "Model", story: "Story", @@ -79,7 +80,7 @@ export const en = { loadError: "Couldn't load that match.", backToLive: "Back to Live", liveWinProbability: "Live win probability", - inPlayHelp: "Updates with the score and the clock. The goals still to come are estimated from each team's pre-match expectation.", + inPlayHelp: "Updates with the score and the clock. The goals still to come are estimated from each team's pre-match expectation — and a red card tilts it: ten men score less and concede more.", statsTitle: "Match stats", stats: { possessionPct: "Possession", @@ -104,6 +105,7 @@ export const en = { recentMeetings: "Recent meetings", }, lineups: "Lineups", + suspended: "Suspended for this match: {players}", keyPlayers: "Key players (all-time top scorers)", xgRace: "xG race", shotMap: "Shot map", @@ -256,6 +258,54 @@ export const en = { advanceShort: "advance {pct}", goldenBoot: "Golden Boot race", goldenBootNote: "Goals in this tournament, from the live event feed. Own goals and shootout kicks don't count.", + fullStats: "All tournament stats", + }, + stats: { + title: "Stats", + subtitle: "Tournament-wide leaderboards — goals, creativity, expected goals and discipline, updated as the matches run.", + playersHeading: "Player leaderboards", + teamsHeading: "Team leaderboards", + updated: "Boards updated {time}", + boardsEmpty: "The stat boards fill in once the first round of matches is in the books.", + matchesPlayed: { + one: "{n} match", + other: "{n} matches", + }, + cat: { + goals: "Goals", + assists: "Assists", + goalsAssists: "Goals + assists", + rating: "Rating", + xg: "xG", + xgPer90: "xG per 90", + minutes: "Minutes", + xgDiff: "xG difference", + possession: "Possession", + cleanSheets: "Clean sheets", + bigChances: "Big chances", + goalsPerMatch: "Goals per match", + concededPerMatch: "Conceded per match", + }, + scatter: { + title: "Attack vs defence", + note: "Each team by expected goals created and conceded per match. Right means more creation, down means a tighter defence — the complete teams live bottom-right.", + xAxis: "xG created per match", + yAxis: "xG conceded per match", + }, + discipline: { + title: "Cards & suspensions", + suspendedHeading: "Suspended for the next match", + suspendedFor: "out for M{num}", + suspendedNext: "out next match", + atRisk: "one yellow from a ban", + empty: "No cards yet — a remarkably clean tournament.", + note: "FIFA rules: a red card or two yellows in different matches mean a one-match ban. Single yellows are wiped after the quarter-finals.", + }, + boot: { + oddsChip: "{odds} next match", + oddsNote: "Odds: the DraftKings anytime-scorer line for the player's next match — a benchmark, never a model input.", + }, + attribution: "Player and team boards are FotMob estimates. Cards and goals come from the live event feed.", }, outcome: { home: "Home win", @@ -481,6 +531,10 @@ export const en = { standingLine: "#{rank} · {points} pts · {won}-{drawn}-{lost} · GD {gd}", fixtures: "Fixtures", eloHistory: "Elo rating over time", + discipline: "Cards & suspensions", + suspChipNext: "out next match", + suspChipFor: "out for M{num}", + atRiskChip: "one yellow from a ban", allTimeTopScorers: "All-time top scorers", groupShort: "Grp {group}", homeShort: "H", diff --git a/src/lib/model/inplay.test.ts b/src/lib/model/inplay.test.ts index 4e10b1b..7d00c3d 100644 --- a/src/lib/model/inplay.test.ts +++ b/src/lib/model/inplay.test.ts @@ -27,4 +27,24 @@ describe('inPlayProbs', () => { expect(trailingEarly).toBeGreaterThan(trailingLate); expect(trailingLate).toBeLessThan(0.1); }); + + it('omitted red counts keep the output bit-identical (golden)', () => { + expect(inPlayProbs(1.8, 0.9, 33, 1, 0)).toEqual(inPlayProbs(1.8, 0.9, 33, 1, 0, 0, 0)); + }); + + it('a red card swings the live probabilities toward the full side', () => { + const base = inPlayProbs(1.4, 1.4, 55, 0, 0); + const tenMenAway = inPlayProbs(1.4, 1.4, 55, 0, 0, 0, 1); + expect(tenMenAway.home).toBeGreaterThan(base.home); + expect(tenMenAway.away).toBeLessThan(base.away); + // Symmetric the other way around. + const tenMenHome = inPlayProbs(1.4, 1.4, 55, 0, 0, 1, 0); + expect(tenMenHome.away).toBeCloseTo(tenMenAway.home, 6); + }); + + it('two reds hit harder than one', () => { + const one = inPlayProbs(1.4, 1.4, 50, 0, 0, 0, 1); + const two = inPlayProbs(1.4, 1.4, 50, 0, 0, 0, 2); + expect(two.home).toBeGreaterThan(one.home); + }); }); diff --git a/src/lib/model/inplay.ts b/src/lib/model/inplay.ts index 66dab63..38cf2d9 100644 --- a/src/lib/model/inplay.ts +++ b/src/lib/model/inplay.ts @@ -8,12 +8,20 @@ import { poissonPmf, type OutcomeProbs } from './poisson'; const REG_MINUTES = 90; const MAX_GOALS = 12; +// A side down to ten men scores at roughly two-thirds of its rate while the +// opponent's rate rises ~15% — in-play studies of red-card effects converge +// around these magnitudes. Applied per card to the REMAINING goal expectation. +const RED_SELF = 0.67; +const RED_OPP = 1.15; + export function inPlayProbs( lambdaHome: number, lambdaAway: number, minute: number, scoreHome: number, scoreAway: number, + redsHome = 0, + redsAway = 0, ): OutcomeProbs { const remaining = Math.max(0, Math.min(1, (REG_MINUTES - minute) / REG_MINUTES)); @@ -23,8 +31,8 @@ export function inPlayProbs( return { home: 0, draw: 1, away: 0 }; } - const lh = lambdaHome * remaining; - const la = lambdaAway * remaining; + const lh = lambdaHome * remaining * RED_SELF ** redsHome * RED_OPP ** redsAway; + const la = lambdaAway * remaining * RED_SELF ** redsAway * RED_OPP ** redsHome; const ph = Array.from({ length: MAX_GOALS + 1 }, (_, k) => poissonPmf(lh, k)); const pa = Array.from({ length: MAX_GOALS + 1 }, (_, k) => poissonPmf(la, k)); diff --git a/src/lib/types.ts b/src/lib/types.ts index 685da19..c8ca284 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -163,6 +163,8 @@ export interface MatchPreview { commentary: { minute: number | null; text: string }[]; /** Post-match report — the narrative source for event context. */ report: { headline: string; story: string } | null; + /** Players banned for THIS match (red card / yellow accumulation). */ + suspensions: { home: string[]; away: string[] }; /** Honest note on which data layers are populated yet. */ dataNote: string; updatedAt: number | null; @@ -228,6 +230,54 @@ export interface TeamProfile { keyPlayers: KeyPlayer[]; championOdds: number | null; qualifyOdds: number | null; + /** Card ledger + suspension status for this squad (carded players only). */ + discipline: PlayerDiscipline[]; +} + +// ---- tournament stats hub (/api/stats) ---- + +/** One row on a stat leaderboard (player or team board). */ +export interface StatRow { + /** Player name, or the canonical team name on team boards. */ + name: string; + /** Canonical team (equals name on team boards). */ + team: string; + value: number; + matches: number | null; + minutes: number | null; +} + +/** Per-player card ledger + FIFA suspension status, folded from event feeds. */ +export interface PlayerDiscipline { + player: string; + team: string; + yellows: number; + reds: number; + /** Single yellows accumulated toward the next ban (1 = one booking away). */ + pending: number; + /** A one-match ban is due in the team's next match. */ + suspendedNext: boolean; + /** The fixture the player must sit out, when the schedule already names it. */ + suspendedFor: number | null; +} + +export interface GoldenBootRow { + player: string; + team: string; + goals: number; + /** Latest DraftKings anytime-scorer line for the player's NEXT match + * (American odds). A benchmark only — never a model input. */ + nextOdds: number | null; +} + +export interface StatsHub { + /** FotMob tournament boards, keyed by category; null until first capture. */ + boards: { updatedAt: number; players: Record; teams: Record } | null; + boot: GoldenBootRow[]; + /** Carded players, reds first. */ + cards: PlayerDiscipline[]; + /** Players due to sit out their team's next match. */ + suspensions: PlayerDiscipline[]; } // ---- Model-vs-Market scoreboard ---- diff --git a/src/router.tsx b/src/router.tsx index 4e616fc..dfb413b 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -19,6 +19,7 @@ const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', com const groupsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/groups', component: GroupsPage }); const bracketRoute = createRoute({ getParentRoute: () => rootRoute, path: '/bracket', component: BracketPage }); const predictRoute = createRoute({ getParentRoute: () => rootRoute, path: '/predict', component: PredictionsPage }); +const statsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/stats', component: lazyRouteComponent(() => import('./features/stats/StatsPage'), 'StatsPage') }); const storyRoute = createRoute({ getParentRoute: () => rootRoute, path: '/story', component: lazyRouteComponent(() => import('./features/story/StoryPage'), 'StoryPage') }); const matchRoute = createRoute({ getParentRoute: () => rootRoute, path: '/match/$num', component: MatchPreviewPage }); const teamRoute = createRoute({ getParentRoute: () => rootRoute, path: '/team/$name', component: TeamProfilePage }); @@ -28,7 +29,7 @@ const scoreboardRoute = createRoute({ getParentRoute: () => rootRoute, path: '/s const dataRoute = createRoute({ getParentRoute: () => rootRoute, path: '/data', component: lazyRouteComponent(() => import('./features/data/DataPage'), 'DataPage') }); const compareRoute = createRoute({ getParentRoute: () => rootRoute, path: '/compare', component: lazyRouteComponent(() => import('./features/compare/ComparePage'), 'ComparePage') }); -const routeTree = rootRoute.addChildren([indexRoute, groupsRoute, bracketRoute, predictRoute, storyRoute, matchRoute, teamRoute, methodologyRoute, teamsRoute, scoreboardRoute, dataRoute, compareRoute]); +const routeTree = rootRoute.addChildren([indexRoute, groupsRoute, bracketRoute, predictRoute, statsRoute, storyRoute, matchRoute, teamRoute, methodologyRoute, teamsRoute, scoreboardRoute, dataRoute, compareRoute]); export const router = createRouter({ routeTree, defaultPreload: 'intent' });