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:
+3
-24
@@ -48,30 +48,6 @@ CREATE TABLE IF NOT EXISTS injuries (
|
|||||||
PRIMARY KEY (team, player)
|
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 (
|
CREATE TABLE IF NOT EXISTS odds_history (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
fixture_num INTEGER NOT NULL,
|
fixture_num INTEGER NOT NULL,
|
||||||
@@ -168,6 +144,9 @@ export function db(): DatabaseSync {
|
|||||||
d.exec('PRAGMA journal_mode = WAL;');
|
d.exec('PRAGMA journal_mode = WAL;');
|
||||||
d.exec('PRAGMA busy_timeout = 4000;');
|
d.exec('PRAGMA busy_timeout = 4000;');
|
||||||
d.exec(SCHEMA);
|
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;
|
_db = d;
|
||||||
console.log(`[db] sqlite ready → ${file}`);
|
console.log(`[db] sqlite ready → ${file}`);
|
||||||
return d;
|
return d;
|
||||||
|
|||||||
+11
-25
@@ -10,10 +10,11 @@ import { startScheduler } from './ingest/scheduler';
|
|||||||
import { ModelEngine } from './model';
|
import { ModelEngine } from './model';
|
||||||
import { buildPreview, buildTeamProfile } from './preview';
|
import { buildPreview, buildTeamProfile } from './preview';
|
||||||
import { buildScoreboard, snapshotCheck } from './scoreboard';
|
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 { 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 { PushNotifier, pushEnabled, subscribe as pushSubscribe, unsubscribe as pushUnsubscribe, vapidPublicKey } from './push';
|
||||||
import type { EspnSummary } from './ingest/espnNormalize';
|
|
||||||
import { loadStatic } from './preview';
|
import { loadStatic } from './preview';
|
||||||
import { canonicalTeam } from '../../src/lib/teams';
|
import { canonicalTeam } from '../../src/lib/teams';
|
||||||
import type { MatchStatus, ServerMessage } from '../../src/lib/types';
|
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 —
|
// Record the in-play win probability per game state while matches run —
|
||||||
// the momentum curve on match pages. Lambdas are the frozen pre-match
|
// 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 recordInplayTick = (): void => {
|
||||||
const preds = new Map(model.current().matches.map((m) => [m.num, m]));
|
const preds = new Map(model.current().matches.map((m) => [m.num, m]));
|
||||||
for (const f of state.allFixtures()) {
|
for (const f of state.allFixtures()) {
|
||||||
@@ -80,7 +82,8 @@ export function buildServer() {
|
|||||||
const pred = preds.get(f.num);
|
const pred = preds.get(f.num);
|
||||||
if (!pred) continue;
|
if (!pred) continue;
|
||||||
const minute = f.minute ?? 0;
|
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, {
|
recordInplay(f.num, {
|
||||||
minute, home_score: f.homeScore, away_score: f.awayScore,
|
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),
|
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
|
// Golden Boot: per-player goals aggregated from structured ESPN scoring
|
||||||
// plays across every started match (own goals + shootout kicks excluded).
|
// plays across every started match (own goals + shootout kicks excluded).
|
||||||
app.get('/api/goldenboot', async () => {
|
app.get('/api/goldenboot', async () => ({ players: goldenBootRows(state) }));
|
||||||
const tally = new Map<string, { player: string; team: string; goals: number; latest: string }>();
|
// The tournament stats hub: FotMob boards (stored by the scheduler), the
|
||||||
for (const f of state.allFixtures()) {
|
// Golden Boot race + bookmaker benchmark, cards and suspensions.
|
||||||
if (f.status === 'scheduled') continue;
|
app.get('/api/stats', async () => buildStatsHub(state));
|
||||||
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/scoreboard', async () => buildScoreboard(state));
|
app.get('/api/scoreboard', async () => buildScoreboard(state));
|
||||||
// The data lake, quantified — counters for the /data page.
|
// The data lake, quantified — counters for the /data page.
|
||||||
app.get('/api/data/stats', async () => {
|
app.get('/api/data/stats', async () => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { fetchFootballData } from '../footballData';
|
|||||||
import { fetchSofascore } from '../sofascore';
|
import { fetchSofascore } from '../sofascore';
|
||||||
import { setSourceMap, getSourceMap, getMatchExt, setMatchExt, setMatchProvider, getMatchProvider, recordOdds, recordScorerProp, logIngest, prune, backupDb, saveFixtureResult } from '../db/db';
|
import { setSourceMap, getSourceMap, getMatchExt, setMatchExt, setMatchProvider, getMatchProvider, recordOdds, recordScorerProp, logIngest, prune, backupDb, saveFixtureResult } from '../db/db';
|
||||||
import { fetchFotmobDetails, fetchFotmobMatches } from './fotmob';
|
import { fetchFotmobDetails, fetchFotmobMatches } from './fotmob';
|
||||||
|
import { refreshTournamentStats } from './tournamentStats';
|
||||||
import { fetchFifaCalendar, fetchFifaLive, fetchFifaTimeline } from './fifa';
|
import { fetchFifaCalendar, fetchFifaLive, fetchFifaTimeline } from './fifa';
|
||||||
import { fetchMatchWeather } from './weather';
|
import { fetchMatchWeather } from './weather';
|
||||||
import { fetchScorerProps } from './espnProps';
|
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);
|
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.
|
// ---- FIFA official: confirmed lineups, XY timelines, attendance/officials.
|
||||||
const fifaTick = async (): Promise<void> => {
|
const fifaTick = async (): Promise<void> => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -382,6 +401,8 @@ export function startScheduler(
|
|||||||
}
|
}
|
||||||
await fotmobTick().catch(() => {}); // capture-first: unofficial source, harvest while it lasts
|
await fotmobTick().catch(() => {}); // capture-first: unofficial source, harvest while it lasts
|
||||||
scheduleFotmob();
|
scheduleFotmob();
|
||||||
|
await statsTick().catch(() => {});
|
||||||
|
scheduleStats();
|
||||||
await fifaTick().catch(() => {});
|
await fifaTick().catch(() => {});
|
||||||
scheduleFifa();
|
scheduleFifa();
|
||||||
await oddsTick().catch(() => {});
|
await oddsTick().catch(() => {});
|
||||||
@@ -398,6 +419,7 @@ export function startScheduler(
|
|||||||
if (enrichTimer) clearTimeout(enrichTimer);
|
if (enrichTimer) clearTimeout(enrichTimer);
|
||||||
if (oddsTimer) clearTimeout(oddsTimer);
|
if (oddsTimer) clearTimeout(oddsTimer);
|
||||||
if (fotmobTimer) clearTimeout(fotmobTimer);
|
if (fotmobTimer) clearTimeout(fotmobTimer);
|
||||||
|
if (statsTimer) clearTimeout(statsTimer);
|
||||||
if (fifaTimer) clearTimeout(fifaTimer);
|
if (fifaTimer) clearTimeout(fifaTimer);
|
||||||
clearInterval(pruneTimer);
|
clearInterval(pruneTimer);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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() ?? '';
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { readFileSync, existsSync } from 'node:fs';
|
import { readFileSync, existsSync } from 'node:fs';
|
||||||
import { getMatchExt, snapshotFor } from './db/db';
|
import { getMatchExt, snapshotFor } from './db/db';
|
||||||
|
import { disciplineFor } from './stats';
|
||||||
import type { TournamentState } from './tournament';
|
import type { TournamentState } from './tournament';
|
||||||
import type { ModelEngine } from './model';
|
import type { ModelEngine } from './model';
|
||||||
import type { EspnSummary } from './ingest/espnNormalize';
|
import type { EspnSummary } from './ingest/espnNormalize';
|
||||||
@@ -138,6 +139,10 @@ export function buildPreview(num: number, state: TournamentState, model: ModelEn
|
|||||||
text: e.text,
|
text: e.text,
|
||||||
team: e.teamId === eh?.id ? 'home' : e.teamId === ea?.id ? 'away' : null,
|
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
|
dataNote: !home || !away
|
||||||
? 'Knockout participants are decided once the bracket resolves.'
|
? 'Knockout participants are decided once the bracket resolves.'
|
||||||
: ext
|
: ext
|
||||||
@@ -166,5 +171,6 @@ export function buildTeamProfile(teamRaw: string, state: TournamentState, model:
|
|||||||
keyPlayers: scorersData()[teamRaw] ?? [],
|
keyPlayers: scorersData()[teamRaw] ?? [],
|
||||||
championOdds: odds?.champion ?? null,
|
championOdds: odds?.champion ?? null,
|
||||||
qualifyOdds: odds?.qualify ?? null,
|
qualifyOdds: odds?.qualify ?? null,
|
||||||
|
discipline: disciplineFor(teamRaw, state).filter((p) => p.yellows + p.reds > 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link, Outlet, useRouterState } from '@tanstack/react-router';
|
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 { SearchPalette } from '@/components/SearchPalette';
|
||||||
import type { LucideIcon } from 'lucide-react';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
import { useUiStore } from '@/stores/uiStore';
|
import { useUiStore } from '@/stores/uiStore';
|
||||||
@@ -16,6 +16,7 @@ const NAV: NavItem[] = [
|
|||||||
{ to: '/groups', label: (t) => t.nav.groups, exact: false, icon: Table },
|
{ to: '/groups', label: (t) => t.nav.groups, exact: false, icon: Table },
|
||||||
{ to: '/bracket', label: (t) => t.nav.bracket, exact: false, icon: GitMerge },
|
{ to: '/bracket', label: (t) => t.nav.bracket, exact: false, icon: GitMerge },
|
||||||
{ to: '/predict', label: (t) => t.nav.predict, exact: false, icon: TrendingUp },
|
{ 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: '/scoreboard', label: (t) => t.nav.vsMarket, exact: false, icon: Scale },
|
||||||
{ to: '/methodology', label: (t) => t.nav.model, exact: false, icon: Gauge },
|
{ to: '/methodology', label: (t) => t.nav.model, exact: false, icon: Gauge },
|
||||||
{ to: '/story', label: (t) => t.nav.story, exact: false, icon: Sparkles },
|
{ to: '/story', label: (t) => t.nav.story, exact: false, icon: Sparkles },
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Link, useParams } from '@tanstack/react-router';
|
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 { Badge } from '@/components/ui/Badge';
|
||||||
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
|
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
|
||||||
import { WinProbBar } from '@/components/WinProbBar';
|
import { WinProbBar } from '@/components/WinProbBar';
|
||||||
@@ -15,6 +15,7 @@ import { LineupPitch } from '@/components/LineupPitch';
|
|||||||
import { teamFlag } from '@/lib/teams';
|
import { teamFlag } from '@/lib/teams';
|
||||||
import { fmt, useFormat, useT } from '@/lib/i18n';
|
import { fmt, useFormat, useT } from '@/lib/i18n';
|
||||||
import { goalEvents } from '@/lib/matchEvents';
|
import { goalEvents } from '@/lib/matchEvents';
|
||||||
|
import { redCards } from '@/lib/discipline';
|
||||||
import { inPlayProbs } from '@/lib/model/inplay';
|
import { inPlayProbs } from '@/lib/model/inplay';
|
||||||
import { useTournamentStore } from '@/stores/tournamentStore';
|
import { useTournamentStore } from '@/stores/tournamentStore';
|
||||||
import type { Fixture, KeyPlayer, MatchPreview, MatchRich, OddsPoint } from '@/lib/types';
|
import type { Fixture, KeyPlayer, MatchPreview, MatchRich, OddsPoint } from '@/lib/types';
|
||||||
@@ -147,7 +148,8 @@ export function MatchPreviewPage() {
|
|||||||
|
|
||||||
const inPlay = useMemo(() => {
|
const inPlay = useMemo(() => {
|
||||||
if (!preview?.prediction || !f || f.status !== 'live' || f.homeScore == null || f.awayScore == null) return null;
|
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]);
|
}, [preview, f]);
|
||||||
|
|
||||||
const statRows = useMemo<[string, string][]>(() => [
|
const statRows = useMemo<[string, string][]>(() => [
|
||||||
@@ -428,6 +430,21 @@ export function MatchPreviewPage() {
|
|||||||
{/* ── Lineups ── */}
|
{/* ── Lineups ── */}
|
||||||
{tab === 'lineups' && (
|
{tab === 'lineups' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{(preview.suspensions.home.length > 0 || preview.suspensions.away.length > 0) && (
|
||||||
|
<Card className="border-loss/30">
|
||||||
|
<CardBody className="space-y-1.5 text-sm">
|
||||||
|
{([['home', preview.suspensions.home], ['away', preview.suspensions.away]] as const)
|
||||||
|
.filter(([, players]) => players.length > 0)
|
||||||
|
.map(([side, players]) => (
|
||||||
|
<div key={side} className="flex items-center gap-2 text-ink-soft">
|
||||||
|
<Ban size={14} className="shrink-0 text-loss" />
|
||||||
|
<span aria-hidden>{(side === 'home' ? f.home.team : f.away.team) ? teamFlag((side === 'home' ? f.home.team : f.away.team)!) : ''}</span>
|
||||||
|
<span>{fmt(t.match.suspended, { players: players.join(', ') })}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
{/* Before kickoff the pitch shows FotMob's projected XIs, clearly
|
{/* Before kickoff the pitch shows FotMob's projected XIs, clearly
|
||||||
labeled as predicted; from kickoff it's the confirmed lineup. */}
|
labeled as predicted; from kickoff it's the confirmed lineup. */}
|
||||||
{rich?.lineup ? (
|
{rich?.lineup ? (
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ const STEP_ICONS = [TrendingUp, Activity, Gauge, Dices] as const;
|
|||||||
/** Transparency log: every model recompute and the title odds it shifted. */
|
/** Transparency log: every model recompute and the title odds it shifted. */
|
||||||
function ModelDiary() {
|
function ModelDiary() {
|
||||||
const { t, kickoffDay, kickoffTime } = useFormat();
|
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;
|
if (history.length < 2) return null;
|
||||||
const entries = history.slice(-9).reverse();
|
const entries = history.slice(-9).reverse();
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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<CatKey>(['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 (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{cats.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPick(c)}
|
||||||
|
className={cn(
|
||||||
|
'rounded-full border px-2.5 py-1 text-xs font-semibold transition-colors',
|
||||||
|
active === c ? 'border-accent-deep/40 bg-accent-glow text-accent' : 'border-line text-muted hover:text-ink',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t.stats.cat[c]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BoardList({ rows, cat, linkTeams }: { rows: StatRow[]; cat: CatKey; linkTeams: boolean }) {
|
||||||
|
const t = useT();
|
||||||
|
return (
|
||||||
|
<ol className="space-y-1.5">
|
||||||
|
{rows.slice(0, 15).map((r, i) => (
|
||||||
|
<li key={`${r.name}|${r.team}`} className="flex items-center gap-2.5 text-sm">
|
||||||
|
<span className="w-4 shrink-0 text-right text-xs font-bold tabular-nums text-faint">{i + 1}</span>
|
||||||
|
<span aria-hidden className="shrink-0" title={r.team}>{r.team ? teamFlag(r.team) : ''}</span>
|
||||||
|
{linkTeams && r.team ? (
|
||||||
|
<Link to="/team/$name" params={{ name: r.team }} className="truncate font-medium text-ink hover:text-accent">{r.name}</Link>
|
||||||
|
) : (
|
||||||
|
<span className="truncate font-medium text-ink" title={r.team}>{r.name}</span>
|
||||||
|
)}
|
||||||
|
{r.matches != null && <span className="hidden shrink-0 text-[11px] text-faint sm:inline">{plural(t.stats.matchesPlayed, r.matches)}</span>}
|
||||||
|
<span className="tnum ml-auto shrink-0 font-bold text-ink">{fmtVal(cat, r.value)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 (
|
||||||
|
<div>
|
||||||
|
<svg viewBox={`0 0 ${W} ${H}`} className="w-full" role="img" aria-label={t.stats.scatter.title}>
|
||||||
|
<g className="text-line" stroke="currentColor" strokeDasharray="4 4">
|
||||||
|
<line x1={px(mx)} y1={P.t} x2={px(mx)} y2={H - P.b} />
|
||||||
|
<line x1={P.l} y1={py(my)} x2={W - P.r} y2={py(my)} />
|
||||||
|
</g>
|
||||||
|
<g className="text-faint" fill="currentColor" fontSize="10">
|
||||||
|
<text x={(P.l + W - P.r) / 2} y={H - 8} textAnchor="middle">{t.stats.scatter.xAxis}</text>
|
||||||
|
<text x={12} y={(P.t + H - P.b) / 2} textAnchor="middle" transform={`rotate(-90 12 ${(P.t + H - P.b) / 2})`}>{t.stats.scatter.yAxis}</text>
|
||||||
|
</g>
|
||||||
|
{pts.map((p) => (
|
||||||
|
<text key={p.team} x={px(p.x)} y={py(p.y)} textAnchor="middle" dominantBaseline="central" fontSize="15">
|
||||||
|
<title>{`${p.team} · ${p.x.toFixed(2)} / ${p.y.toFixed(2)}`}</title>
|
||||||
|
{teamFlag(p.team)}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
<p className="mt-1 text-xs text-faint">{t.stats.scatter.note}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex items-center gap-2">
|
||||||
|
<Award size={16} className="text-gold" />
|
||||||
|
<span className="font-display font-bold text-ink">{t.teams.goldenBoot}</span>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<ol className="space-y-1.5">
|
||||||
|
{top.map((p, i) => (
|
||||||
|
<li key={`${p.player}|${p.team}`} className="flex items-center gap-2.5 text-sm">
|
||||||
|
<span className="w-4 shrink-0 text-right text-xs font-bold tabular-nums text-faint">{i + 1}</span>
|
||||||
|
<span aria-hidden className="shrink-0" title={p.team}>{p.team ? teamFlag(p.team) : ''}</span>
|
||||||
|
<span className="truncate font-medium text-ink">{p.player}</span>
|
||||||
|
{p.nextOdds != null && (
|
||||||
|
<span className="hidden shrink-0 rounded-full bg-elevated px-2 py-0.5 text-[10px] text-faint sm:inline" title={t.stats.boot.oddsNote}>
|
||||||
|
{fmt(t.stats.boot.oddsChip, { odds: american(p.nextOdds) })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="ml-auto flex h-2 w-20 shrink-0 overflow-hidden rounded-full bg-elevated sm:w-36">
|
||||||
|
<div className="bg-gradient-to-r from-gold/60 to-gold" style={{ width: `${(p.goals / max) * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="tnum w-5 shrink-0 text-right font-bold text-ink">{p.goals}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
<p className="mt-2 text-[11px] text-faint">{t.teams.goldenBootNote}{hasOdds ? ` ${t.stats.boot.oddsNote}` : ''}</p>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Discipline({ hub }: { hub: StatsHub }) {
|
||||||
|
const t = useT();
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex items-center gap-2">
|
||||||
|
<Ban size={16} className="text-loss" />
|
||||||
|
<span className="font-display font-bold text-ink">{t.stats.discipline.title}</span>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
{hub.suspensions.length > 0 && (
|
||||||
|
<div className="mb-3 rounded-lg border border-loss/30 bg-loss/5 p-3">
|
||||||
|
<div className="mb-1.5 text-[11px] font-bold uppercase tracking-wide text-loss">{t.stats.discipline.suspendedHeading}</div>
|
||||||
|
<ul className="space-y-1 text-sm">
|
||||||
|
{hub.suspensions.map((p) => (
|
||||||
|
<li key={`${p.player}|${p.team}`} className="flex items-center gap-2 text-ink-soft">
|
||||||
|
<span aria-hidden title={p.team}>{teamFlag(p.team)}</span>
|
||||||
|
<span className="truncate font-medium text-ink">{p.player}</span>
|
||||||
|
<span className="ml-auto shrink-0 text-[11px] text-faint">
|
||||||
|
{p.suspendedFor != null ? fmt(t.stats.discipline.suspendedFor, { num: p.suspendedFor }) : t.stats.discipline.suspendedNext}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hub.cards.length ? (
|
||||||
|
<ol className="space-y-1.5">
|
||||||
|
{hub.cards.slice(0, 15).map((p) => (
|
||||||
|
<li key={`${p.player}|${p.team}`} className="flex flex-wrap items-center gap-2 text-sm">
|
||||||
|
<span aria-hidden className="shrink-0" title={p.team}>{teamFlag(p.team)}</span>
|
||||||
|
<span className="truncate text-ink-soft">{p.player}</span>
|
||||||
|
<span className="flex items-center gap-0.5" aria-hidden>
|
||||||
|
{Array.from({ length: p.yellows }).map((_, i) => <span key={`y${i}`} className="inline-block h-3.5 w-2.5 rounded-[2px] bg-gold" />)}
|
||||||
|
{Array.from({ length: p.reds }).map((_, i) => <span key={`r${i}`} className="inline-block h-3.5 w-2.5 rounded-[2px] bg-loss" />)}
|
||||||
|
</span>
|
||||||
|
{!p.suspendedNext && p.pending === 1 && (
|
||||||
|
<span className="ml-auto shrink-0 rounded-full bg-gold/15 px-2 py-0.5 text-[10px] font-semibold text-gold">{t.stats.discipline.atRisk}</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-faint">{t.stats.discipline.empty}</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-2 text-[11px] text-faint">{t.stats.discipline.note}</p>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatsPage() {
|
||||||
|
const { t, kickoffTime } = useFormat();
|
||||||
|
const [hub, setHub] = useState<StatsHub | null>(null);
|
||||||
|
const [playerCat, setPlayerCat] = useState<CatKey>('goals');
|
||||||
|
const [teamCat, setTeamCat] = useState<CatKey>('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 (
|
||||||
|
<div>
|
||||||
|
<PageHeader title={t.stats.title} subtitle={t.stats.subtitle} />
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => <div key={i} className="h-64 animate-pulse rounded-xl border border-line bg-panel" />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<PageHeader title={t.stats.title} subtitle={t.stats.subtitle} />
|
||||||
|
|
||||||
|
<div className="grid items-start gap-4 md:grid-cols-2">
|
||||||
|
<GoldenBoot hub={hub} />
|
||||||
|
<Discipline hub={hub} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{playerCats.length > 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-wrap items-center gap-2">
|
||||||
|
<Shirt size={16} className="text-accent" />
|
||||||
|
<span className="font-display font-bold text-ink">{t.stats.playersHeading}</span>
|
||||||
|
{boards && (
|
||||||
|
<span className="ml-auto text-[11px] text-faint">
|
||||||
|
{fmt(t.stats.updated, { time: kickoffTime(new Date(boards.updatedAt).toISOString()) })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody className="space-y-3">
|
||||||
|
<CatPicker cats={playerCats} active={pCat} onPick={setPlayerCat} />
|
||||||
|
<BoardList rows={boards?.players[pCat] ?? []} cat={pCat} linkTeams={false} />
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card><CardBody><p className="text-sm text-faint">{t.stats.boardsEmpty}</p></CardBody></Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{teamCats.length > 0 && (
|
||||||
|
<div className="grid items-start gap-4 md:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex items-center gap-2">
|
||||||
|
<Users size={16} className="text-accent" />
|
||||||
|
<span className="font-display font-bold text-ink">{t.stats.teamsHeading}</span>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody className="space-y-3">
|
||||||
|
<CatPicker cats={teamCats} active={tCat} onPick={setTeamCat} />
|
||||||
|
<BoardList rows={boards?.teams[tCat] ?? []} cat={tCat} linkTeams />
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
{(boards?.teams.xg?.length ?? 0) > 0 && (boards?.teams.xgDiff?.length ?? 0) > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex items-center gap-2">
|
||||||
|
<ChartScatter size={16} className="text-accent" />
|
||||||
|
<span className="font-display font-bold text-ink">{t.stats.scatter.title}</span>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<XgScatter xg={boards?.teams.xg ?? []} xgDiff={boards?.teams.xgDiff ?? []} />
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-center text-xs text-faint">{t.stats.attribution}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -180,23 +180,51 @@ export function TeamProfilePage() {
|
|||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<div className="space-y-5">
|
||||||
<CardHeader><span className="font-display font-bold text-ink">{t.team.allTimeTopScorers}</span></CardHeader>
|
{profile.discipline.length > 0 && (
|
||||||
<CardBody>
|
<Card>
|
||||||
{profile.keyPlayers.length ? (
|
<CardHeader><span className="font-display font-bold text-ink">{t.team.discipline}</span></CardHeader>
|
||||||
<ol className="space-y-1.5">
|
<CardBody>
|
||||||
{profile.keyPlayers.map((p, i) => (
|
<ul className="space-y-1.5">
|
||||||
<li key={p.name} className="flex items-center gap-2 text-sm">
|
{profile.discipline.map((p) => (
|
||||||
<span className="w-4 text-right text-xs font-bold text-faint">{i + 1}</span>
|
<li key={p.player} className="flex flex-wrap items-center gap-2 text-sm">
|
||||||
<span className="truncate text-ink-soft">{p.name}</span>
|
<span className="truncate text-ink-soft">{p.player}</span>
|
||||||
<span className="tnum ml-auto font-semibold text-ink">{p.goals}</span>
|
<span className="flex items-center gap-0.5" aria-hidden>
|
||||||
{p.pens ? <span className="text-[11px] text-faint">{fmt(t.common.pensShort, { n: p.pens })}</span> : null}
|
{Array.from({ length: p.yellows }).map((_, i) => <span key={`y${i}`} className="inline-block h-3.5 w-2.5 rounded-[2px] bg-gold" />)}
|
||||||
</li>
|
{Array.from({ length: p.reds }).map((_, i) => <span key={`r${i}`} className="inline-block h-3.5 w-2.5 rounded-[2px] bg-loss" />)}
|
||||||
))}
|
</span>
|
||||||
</ol>
|
{p.suspendedNext && (
|
||||||
) : <p className="text-sm text-faint">{t.common.noData}</p>}
|
<span className="ml-auto rounded-full bg-loss/15 px-2 py-0.5 text-[11px] font-semibold text-loss">
|
||||||
</CardBody>
|
{p.suspendedFor != null ? fmt(t.team.suspChipFor, { num: p.suspendedFor }) : t.team.suspChipNext}
|
||||||
</Card>
|
</span>
|
||||||
|
)}
|
||||||
|
{!p.suspendedNext && p.pending === 1 && (
|
||||||
|
<span className="ml-auto rounded-full bg-gold/15 px-2 py-0.5 text-[11px] font-semibold text-gold">{t.team.atRiskChip}</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
<Card>
|
||||||
|
<CardHeader><span className="font-display font-bold text-ink">{t.team.allTimeTopScorers}</span></CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
{profile.keyPlayers.length ? (
|
||||||
|
<ol className="space-y-1.5">
|
||||||
|
{profile.keyPlayers.map((p, i) => (
|
||||||
|
<li key={p.name} className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="w-4 text-right text-xs font-bold text-faint">{i + 1}</span>
|
||||||
|
<span className="truncate text-ink-soft">{p.name}</span>
|
||||||
|
<span className="tnum ml-auto font-semibold text-ink">{p.goals}</span>
|
||||||
|
{p.pens ? <span className="text-[11px] text-faint">{fmt(t.common.pensShort, { n: p.pens })}</span> : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
) : <p className="text-sm text-faint">{t.common.noData}</p>}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ function GoldenBoot() {
|
|||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
<p className="mt-2 text-[11px] text-faint">{t.teams.goldenBootNote}</p>
|
<p className="mt-2 text-[11px] text-faint">{t.teams.goldenBootNote}</p>
|
||||||
|
<Link to="/stats" className="mt-2 inline-block text-sm font-semibold text-accent hover:underline">{t.teams.fullStats} →</Link>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<Stage, number> = { 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<string, PlayerDiscipline>();
|
||||||
|
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<string, { yellows: number; reds: number; name: string }>();
|
||||||
|
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<string, { side: 'home' | 'away'; n: number; hasRed: boolean }>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
+55
-1
@@ -7,6 +7,7 @@ export const de: Dict = {
|
|||||||
groups: "Gruppen",
|
groups: "Gruppen",
|
||||||
bracket: "Turnierbaum",
|
bracket: "Turnierbaum",
|
||||||
predict: "Prognose",
|
predict: "Prognose",
|
||||||
|
stats: "Statistik",
|
||||||
vsMarket: "vs. Markt",
|
vsMarket: "vs. Markt",
|
||||||
model: "Modell",
|
model: "Modell",
|
||||||
story: "Story",
|
story: "Story",
|
||||||
@@ -80,7 +81,7 @@ export const de: Dict = {
|
|||||||
loadError: "Dieses Spiel konnte nicht geladen werden.",
|
loadError: "Dieses Spiel konnte nicht geladen werden.",
|
||||||
backToLive: "Zurück zu Live",
|
backToLive: "Zurück zu Live",
|
||||||
liveWinProbability: "Live-Siegwahrscheinlichkeit",
|
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",
|
statsTitle: "Spielstatistik",
|
||||||
stats: {
|
stats: {
|
||||||
possessionPct: "Ballbesitz",
|
possessionPct: "Ballbesitz",
|
||||||
@@ -105,6 +106,7 @@ export const de: Dict = {
|
|||||||
recentMeetings: "Letzte Duelle",
|
recentMeetings: "Letzte Duelle",
|
||||||
},
|
},
|
||||||
lineups: "Aufstellungen",
|
lineups: "Aufstellungen",
|
||||||
|
suspended: "Für dieses Spiel gesperrt: {players}",
|
||||||
keyPlayers: "Schlüsselspieler (beste Torschützen aller Zeiten)",
|
keyPlayers: "Schlüsselspieler (beste Torschützen aller Zeiten)",
|
||||||
xgRace: "xG-Rennen",
|
xgRace: "xG-Rennen",
|
||||||
shotMap: "Schusskarte",
|
shotMap: "Schusskarte",
|
||||||
@@ -257,6 +259,54 @@ export const de: Dict = {
|
|||||||
advanceShort: "Weiterkommen {pct}",
|
advanceShort: "Weiterkommen {pct}",
|
||||||
goldenBoot: "Torjägerliste",
|
goldenBoot: "Torjägerliste",
|
||||||
goldenBootNote: "Tore in diesem Turnier, aus dem Live-Eventfeed. Eigentore und Elfmeterschießen zählen nicht.",
|
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: {
|
outcome: {
|
||||||
home: "Heimsieg",
|
home: "Heimsieg",
|
||||||
@@ -482,6 +532,10 @@ export const de: Dict = {
|
|||||||
standingLine: "#{rank} · {points} Pkt. · {won}-{drawn}-{lost} · TD {gd}",
|
standingLine: "#{rank} · {points} Pkt. · {won}-{drawn}-{lost} · TD {gd}",
|
||||||
fixtures: "Spielplan",
|
fixtures: "Spielplan",
|
||||||
eloHistory: "Elo-Wertung im Zeitverlauf",
|
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",
|
allTimeTopScorers: "Beste Torschützen aller Zeiten",
|
||||||
groupShort: "Gr. {group}",
|
groupShort: "Gr. {group}",
|
||||||
homeShort: "H",
|
homeShort: "H",
|
||||||
|
|||||||
+55
-1
@@ -6,6 +6,7 @@ export const en = {
|
|||||||
groups: "Groups",
|
groups: "Groups",
|
||||||
bracket: "Bracket",
|
bracket: "Bracket",
|
||||||
predict: "Predict",
|
predict: "Predict",
|
||||||
|
stats: "Stats",
|
||||||
vsMarket: "vs Market",
|
vsMarket: "vs Market",
|
||||||
model: "Model",
|
model: "Model",
|
||||||
story: "Story",
|
story: "Story",
|
||||||
@@ -79,7 +80,7 @@ export const en = {
|
|||||||
loadError: "Couldn't load that match.",
|
loadError: "Couldn't load that match.",
|
||||||
backToLive: "Back to Live",
|
backToLive: "Back to Live",
|
||||||
liveWinProbability: "Live win probability",
|
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",
|
statsTitle: "Match stats",
|
||||||
stats: {
|
stats: {
|
||||||
possessionPct: "Possession",
|
possessionPct: "Possession",
|
||||||
@@ -104,6 +105,7 @@ export const en = {
|
|||||||
recentMeetings: "Recent meetings",
|
recentMeetings: "Recent meetings",
|
||||||
},
|
},
|
||||||
lineups: "Lineups",
|
lineups: "Lineups",
|
||||||
|
suspended: "Suspended for this match: {players}",
|
||||||
keyPlayers: "Key players (all-time top scorers)",
|
keyPlayers: "Key players (all-time top scorers)",
|
||||||
xgRace: "xG race",
|
xgRace: "xG race",
|
||||||
shotMap: "Shot map",
|
shotMap: "Shot map",
|
||||||
@@ -256,6 +258,54 @@ export const en = {
|
|||||||
advanceShort: "advance {pct}",
|
advanceShort: "advance {pct}",
|
||||||
goldenBoot: "Golden Boot race",
|
goldenBoot: "Golden Boot race",
|
||||||
goldenBootNote: "Goals in this tournament, from the live event feed. Own goals and shootout kicks don't count.",
|
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: {
|
outcome: {
|
||||||
home: "Home win",
|
home: "Home win",
|
||||||
@@ -481,6 +531,10 @@ export const en = {
|
|||||||
standingLine: "#{rank} · {points} pts · {won}-{drawn}-{lost} · GD {gd}",
|
standingLine: "#{rank} · {points} pts · {won}-{drawn}-{lost} · GD {gd}",
|
||||||
fixtures: "Fixtures",
|
fixtures: "Fixtures",
|
||||||
eloHistory: "Elo rating over time",
|
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",
|
allTimeTopScorers: "All-time top scorers",
|
||||||
groupShort: "Grp {group}",
|
groupShort: "Grp {group}",
|
||||||
homeShort: "H",
|
homeShort: "H",
|
||||||
|
|||||||
@@ -27,4 +27,24 @@ describe('inPlayProbs', () => {
|
|||||||
expect(trailingEarly).toBeGreaterThan(trailingLate);
|
expect(trailingEarly).toBeGreaterThan(trailingLate);
|
||||||
expect(trailingLate).toBeLessThan(0.1);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+10
-2
@@ -8,12 +8,20 @@ import { poissonPmf, type OutcomeProbs } from './poisson';
|
|||||||
const REG_MINUTES = 90;
|
const REG_MINUTES = 90;
|
||||||
const MAX_GOALS = 12;
|
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(
|
export function inPlayProbs(
|
||||||
lambdaHome: number,
|
lambdaHome: number,
|
||||||
lambdaAway: number,
|
lambdaAway: number,
|
||||||
minute: number,
|
minute: number,
|
||||||
scoreHome: number,
|
scoreHome: number,
|
||||||
scoreAway: number,
|
scoreAway: number,
|
||||||
|
redsHome = 0,
|
||||||
|
redsAway = 0,
|
||||||
): OutcomeProbs {
|
): OutcomeProbs {
|
||||||
const remaining = Math.max(0, Math.min(1, (REG_MINUTES - minute) / REG_MINUTES));
|
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 };
|
return { home: 0, draw: 1, away: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const lh = lambdaHome * remaining;
|
const lh = lambdaHome * remaining * RED_SELF ** redsHome * RED_OPP ** redsAway;
|
||||||
const la = lambdaAway * remaining;
|
const la = lambdaAway * remaining * RED_SELF ** redsAway * RED_OPP ** redsHome;
|
||||||
const ph = Array.from({ length: MAX_GOALS + 1 }, (_, k) => poissonPmf(lh, k));
|
const ph = Array.from({ length: MAX_GOALS + 1 }, (_, k) => poissonPmf(lh, k));
|
||||||
const pa = Array.from({ length: MAX_GOALS + 1 }, (_, k) => poissonPmf(la, k));
|
const pa = Array.from({ length: MAX_GOALS + 1 }, (_, k) => poissonPmf(la, k));
|
||||||
|
|
||||||
|
|||||||
@@ -163,6 +163,8 @@ export interface MatchPreview {
|
|||||||
commentary: { minute: number | null; text: string }[];
|
commentary: { minute: number | null; text: string }[];
|
||||||
/** Post-match report — the narrative source for event context. */
|
/** Post-match report — the narrative source for event context. */
|
||||||
report: { headline: string; story: string } | null;
|
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. */
|
/** Honest note on which data layers are populated yet. */
|
||||||
dataNote: string;
|
dataNote: string;
|
||||||
updatedAt: number | null;
|
updatedAt: number | null;
|
||||||
@@ -228,6 +230,54 @@ export interface TeamProfile {
|
|||||||
keyPlayers: KeyPlayer[];
|
keyPlayers: KeyPlayer[];
|
||||||
championOdds: number | null;
|
championOdds: number | null;
|
||||||
qualifyOdds: 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<string, StatRow[]>; teams: Record<string, StatRow[]> } | 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 ----
|
// ---- Model-vs-Market scoreboard ----
|
||||||
|
|||||||
+2
-1
@@ -19,6 +19,7 @@ const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', com
|
|||||||
const groupsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/groups', component: GroupsPage });
|
const groupsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/groups', component: GroupsPage });
|
||||||
const bracketRoute = createRoute({ getParentRoute: () => rootRoute, path: '/bracket', component: BracketPage });
|
const bracketRoute = createRoute({ getParentRoute: () => rootRoute, path: '/bracket', component: BracketPage });
|
||||||
const predictRoute = createRoute({ getParentRoute: () => rootRoute, path: '/predict', component: PredictionsPage });
|
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 storyRoute = createRoute({ getParentRoute: () => rootRoute, path: '/story', component: lazyRouteComponent(() => import('./features/story/StoryPage'), 'StoryPage') });
|
||||||
const matchRoute = createRoute({ getParentRoute: () => rootRoute, path: '/match/$num', component: MatchPreviewPage });
|
const matchRoute = createRoute({ getParentRoute: () => rootRoute, path: '/match/$num', component: MatchPreviewPage });
|
||||||
const teamRoute = createRoute({ getParentRoute: () => rootRoute, path: '/team/$name', component: TeamProfilePage });
|
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 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 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' });
|
export const router = createRouter({ routeTree, defaultPreload: 'intent' });
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user