Golden Boot race from structured ESPN scoring plays

ESPN's keyEvents carry participants[].athlete for goals — no text
parsing needed. The normalizer now extracts scorer/scoring/shootout
(payload versioned, with a boot backfill that re-stores summaries
saved by the old normalizer), /api/goldenboot aggregates per-player
goals (own goals and shootout kicks excluded), and the Teams page
leads with the top-10 race. Verified against tonight's real goals.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 08:18:19 +02:00
parent 06e4835ad4
commit ce83d4dbc4
6 changed files with 126 additions and 6 deletions
+26 -1
View File
@@ -10,8 +10,9 @@ 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 } from './db/db'; import { db, healthAll, oddsFor, latestOddsAll, dbCounts, inplayHistory, recordInplay, getMatchExt } from './db/db';
import { inPlayProbs } from '../../src/lib/model/inplay'; import { inPlayProbs } from '../../src/lib/model/inplay';
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';
@@ -86,6 +87,30 @@ export function buildServer() {
const num = Number((req.params as { num: string }).num); const num = Number((req.params as { num: string }).num);
return { points: Number.isFinite(num) ? inplayHistory(num) : [] }; return { points: Number.isFinite(num) ? inplayHistory(num) : [] };
}); });
// Golden Boot: per-player goals aggregated from structured ESPN scoring
// plays across every started match (own goals + shootout kicks excluded).
app.get('/api/goldenboot', async () => {
const tally = new Map<string, { player: string; team: string; goals: number; latest: string }>();
for (const f of state.allFixtures()) {
if (f.status === 'scheduled') continue;
const ext = getMatchExt<EspnSummary>(f.num);
if (!ext) continue;
const teamName = new Map(ext.data.teams.map((tm) => [tm.id, tm.name]));
for (const e of ext.data.events) {
if (!e.scoring || e.shootout || !e.scorer) continue;
if (/own goal/i.test(e.type)) continue;
const team = (e.teamId && teamName.get(e.teamId)) ?? '';
const key = `${e.scorer}|${team}`;
const cur = tally.get(key) ?? { player: e.scorer, team, goals: 0, latest: f.kickoff };
cur.goals += 1;
if (f.kickoff > cur.latest) cur.latest = f.kickoff;
tally.set(key, cur);
}
}
const players = [...tally.values()].sort((a, b) => b.goals - a.goals || a.player.localeCompare(b.player)).slice(0, 25);
return { players };
});
app.get('/api/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 () => {
+26 -3
View File
@@ -64,8 +64,19 @@ export function normalizeScoreboard(body: { events?: EspnEvent[] }): EspnMatch[]
export interface EspnH2HGame { date: string; homeTeamId: string; awayTeamId: string; homeScore: number; awayScore: number } export interface EspnH2HGame { date: string; homeTeamId: string; awayTeamId: string; homeScore: number; awayScore: number }
export interface EspnTeamRef { id: string; name: string; homeAway: 'home' | 'away' } export interface EspnTeamRef { id: string; name: string; homeAway: 'home' | 'away' }
export interface EspnLineupPlayer { name: string; position: string | null; starter: boolean; number: number | null } export interface EspnLineupPlayer { name: string; position: string | null; starter: boolean; number: number | null }
export interface EspnTimelineEvent { minute: number | null; type: string; text: string; teamId: string | null } export interface EspnTimelineEvent {
minute: number | null;
type: string;
text: string;
teamId: string | null;
/** Scorer name for scoring plays (structured, from participants). */
scorer?: string;
scoring?: boolean;
shootout?: boolean;
}
export interface EspnSummary { export interface EspnSummary {
/** Normalizer schema version — bump to trigger the boot backfill. */
v?: number;
fetchedAt: number; fetchedAt: number;
venue: string | null; venue: string | null;
teams: EspnTeamRef[]; teams: EspnTeamRef[];
@@ -88,7 +99,15 @@ export interface RawSummary {
teams?: { team?: { id: string }; statistics?: { name: string; displayValue: string }[] }[]; teams?: { team?: { id: string }; statistics?: { name: string; displayValue: string }[] }[];
}; };
rosters?: { team?: { id: string }; roster?: { athlete?: { displayName?: string }; position?: { abbreviation?: string }; starter?: boolean; jersey?: string }[] }[]; rosters?: { team?: { id: string }; roster?: { athlete?: { displayName?: string }; position?: { abbreviation?: string }; starter?: boolean; jersey?: string }[] }[];
keyEvents?: { clock?: { displayValue?: string }; type?: { text?: string }; text?: string; team?: { id?: string } }[]; keyEvents?: {
clock?: { displayValue?: string };
type?: { text?: string };
text?: string;
team?: { id?: string };
scoringPlay?: boolean;
shootout?: boolean;
participants?: { athlete?: { displayName?: string } }[];
}[];
} }
export function normalizeSummary(raw: RawSummary): EspnSummary { export function normalizeSummary(raw: RawSummary): EspnSummary {
@@ -134,13 +153,17 @@ export function normalizeSummary(raw: RawSummary): EspnSummary {
const events: EspnTimelineEvent[] = (raw.keyEvents ?? []).map((e) => { const events: EspnTimelineEvent[] = (raw.keyEvents ?? []).map((e) => {
const m = e.clock?.displayValue?.match(/(\d+)/); const m = e.clock?.displayValue?.match(/(\d+)/);
const scorer = e.scoringPlay ? e.participants?.[0]?.athlete?.displayName : undefined;
return { return {
minute: m ? Number(m[1]) : null, minute: m ? Number(m[1]) : null,
type: e.type?.text ?? '', type: e.type?.text ?? '',
text: e.text ?? '', text: e.text ?? '',
teamId: e.team?.id ?? null, teamId: e.team?.id ?? null,
...(scorer ? { scorer } : {}),
...(e.scoringPlay ? { scoring: true } : {}),
...(e.shootout ? { shootout: true } : {}),
}; };
}); });
return { fetchedAt: Date.now(), venue: raw.gameInfo?.venue?.fullName ?? null, teams, h2h, form, lineups, stats, events }; return { v: 2, fetchedAt: Date.now(), venue: raw.gameInfo?.venue?.fullName ?? null, teams, h2h, form, lineups, stats, events };
} }
+20 -1
View File
@@ -4,7 +4,8 @@ import { fetchEspnScoreboard, fetchEspnSummary } from './espn';
import { fetchEspnOdds } from './espnOdds'; import { fetchEspnOdds } from './espnOdds';
import { fetchFootballData } from '../footballData'; import { fetchFootballData } from '../footballData';
import { fetchSofascore } from '../sofascore'; import { fetchSofascore } from '../sofascore';
import { setSourceMap, getSourceMap, setMatchExt, recordOdds, logIngest, prune, backupDb } from '../db/db'; import { setSourceMap, getSourceMap, getMatchExt, setMatchExt, recordOdds, logIngest, prune, backupDb } from '../db/db';
import type { EspnSummary } from './espnNormalize';
import type { Fixture } from '../../../src/lib/types'; import type { Fixture } from '../../../src/lib/types';
// The ingestion orchestrator. Replaces the old live loop: ESPN is the primary // The ingestion orchestrator. Replaces the old live loop: ESPN is the primary
@@ -104,6 +105,23 @@ export function startScheduler(
} }
}; };
// One-time backfill: re-store summaries saved by an older normalizer (e.g.
// before structured scorers existed), so started matches stay fully usable.
const backfillExt = async (): Promise<void> => {
for (const f of state.allFixtures()) {
if (stopped) return;
if (f.status === 'scheduled') continue;
const ext = getMatchExt<EspnSummary>(f.num);
if (ext && ext.data.v === 2) continue;
const eid = getSourceMap(f.num, 'espn');
if (!eid) continue;
try {
setMatchExt(f.num, await fetchEspnSummary(eid, true)); // live TTL → skips the stale cache
console.log(`[ingest] backfilled summary for M${f.num}`);
} catch { /* health-tracked */ }
}
};
// ---- enrichment: ESPN summary for imminent/live fixtures ---- // ---- enrichment: ESPN summary for imminent/live fixtures ----
const enrichTick = async (): Promise<void> => { const enrichTick = async (): Promise<void> => {
const now = Date.now(); const now = Date.now();
@@ -189,6 +207,7 @@ export function startScheduler(
await mapAllFixtures().catch(() => {}); await mapAllFixtures().catch(() => {});
await oddsTick().catch(() => {}); await oddsTick().catch(() => {});
await liveTick().catch(() => {}); await liveTick().catch(() => {});
await backfillExt().catch(() => {});
await enrichTick().catch(() => {}); await enrichTick().catch(() => {});
scheduleLive(); scheduleLive();
scheduleEnrich(); scheduleEnrich();
+50 -1
View File
@@ -1,12 +1,60 @@
import { useMemo } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Link } from '@tanstack/react-router'; import { Link } from '@tanstack/react-router';
import { Award } from 'lucide-react';
import { PageHeader } from '@/components/ui/PageHeader'; import { PageHeader } from '@/components/ui/PageHeader';
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
import { teamFlag } from '@/lib/teams'; import { teamFlag } from '@/lib/teams';
import { useTournamentStore } from '@/stores/tournamentStore'; import { useTournamentStore } from '@/stores/tournamentStore';
import { fmt, useT } from '@/lib/i18n'; import { fmt, useT } from '@/lib/i18n';
const pct = (x: number) => (x >= 0.995 ? '>99%' : x < 0.005 ? '—' : `${(x * 100).toFixed(x < 0.1 ? 1 : 0)}%`); const pct = (x: number) => (x >= 0.995 ? '>99%' : x < 0.005 ? '—' : `${(x * 100).toFixed(x < 0.1 ? 1 : 0)}%`);
interface BootRow { player: string; team: string; goals: number }
/** Live Golden Boot standings from structured ESPN scoring plays. */
function GoldenBoot() {
const t = useT();
const [players, setPlayers] = useState<BootRow[] | null>(null);
useEffect(() => {
let alive = true;
fetch('/api/goldenboot')
.then((r) => (r.ok ? r.json() : Promise.reject()))
.then((d: { players: BootRow[] }) => alive && setPlayers(d.players))
.catch(() => alive && setPlayers([]));
return () => { alive = false; };
}, []);
if (!players || players.length === 0) return null;
const top = players.slice(0, 10);
const max = top[0]?.goals ?? 1;
return (
<Card className="mb-5">
<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">{p.team ? teamFlag(p.team) : ''}</span>
<span className="truncate font-medium text-ink">{p.player}</span>
<div className="ml-auto flex h-2 w-24 shrink-0 overflow-hidden rounded-full bg-elevated sm:w-40">
<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}</p>
</CardBody>
</Card>
);
}
export function TeamsPage() { export function TeamsPage() {
const t = useT(); const t = useT();
const model = useTournamentStore((s) => s.model); const model = useTournamentStore((s) => s.model);
@@ -21,6 +69,7 @@ export function TeamsPage() {
return ( return (
<div> <div>
<PageHeader title={t.teams.title} subtitle={t.teams.subtitle} /> <PageHeader title={t.teams.title} subtitle={t.teams.subtitle} />
<GoldenBoot />
{teams.length ? ( {teams.length ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{teams.map((team, i) => ( {teams.map((team, i) => (
+2
View File
@@ -195,6 +195,8 @@ export const de: Dict = {
title: "Teams", title: "Teams",
subtitle: "Alle 48 Teams, sortiert nach den Titelchancen des Modells. Tipp auf ein Team für sein Profil.", subtitle: "Alle 48 Teams, sortiert nach den Titelchancen des Modells. Tipp auf ein Team für sein Profil.",
advanceShort: "Weiterkommen {pct}", advanceShort: "Weiterkommen {pct}",
goldenBoot: "Torjägerliste",
goldenBootNote: "Tore in diesem Turnier, aus dem Live-Eventfeed. Eigentore und Elfmeterschießen zählen nicht.",
}, },
outcome: { outcome: {
home: "Heimsieg", home: "Heimsieg",
+2
View File
@@ -194,6 +194,8 @@ export const en = {
title: "Teams", title: "Teams",
subtitle: "All 48 teams, ranked by the model's title odds. Tap a team to open its profile.", subtitle: "All 48 teams, ranked by the model's title odds. Tap a team to open its profile.",
advanceShort: "advance {pct}", 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.",
}, },
outcome: { outcome: {
home: "Home win", home: "Home win",