From ce83d4dbc464ad7183856938a5b366534ed988b4 Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Fri, 12 Jun 2026 08:18:19 +0200 Subject: [PATCH] Golden Boot race from structured ESPN scoring plays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/src/index.ts | 27 +++++++++++++++- server/src/ingest/espnNormalize.ts | 29 +++++++++++++++-- server/src/ingest/scheduler.ts | 21 +++++++++++- src/features/teams/TeamsPage.tsx | 51 +++++++++++++++++++++++++++++- src/lib/i18n/de.ts | 2 ++ src/lib/i18n/en.ts | 2 ++ 6 files changed, 126 insertions(+), 6 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index e2988ba..b0911cf 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -10,8 +10,9 @@ import { startScheduler } from './ingest/scheduler'; import { ModelEngine } from './model'; import { buildPreview, buildTeamProfile } from './preview'; import { buildScoreboard, snapshotCheck } from './scoreboard'; -import { db, healthAll, oddsFor, latestOddsAll, dbCounts, inplayHistory, recordInplay } from './db/db'; +import { db, healthAll, oddsFor, latestOddsAll, dbCounts, inplayHistory, recordInplay, getMatchExt } from './db/db'; import { inPlayProbs } from '../../src/lib/model/inplay'; +import type { EspnSummary } from './ingest/espnNormalize'; import { loadStatic } from './preview'; import { canonicalTeam } from '../../src/lib/teams'; import type { MatchStatus, ServerMessage } from '../../src/lib/types'; @@ -86,6 +87,30 @@ export function buildServer() { const num = Number((req.params as { num: string }).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(); + for (const f of state.allFixtures()) { + if (f.status === 'scheduled') continue; + const ext = getMatchExt(f.num); + if (!ext) continue; + const teamName = new Map(ext.data.teams.map((tm) => [tm.id, tm.name])); + for (const e of ext.data.events) { + if (!e.scoring || e.shootout || !e.scorer) continue; + if (/own goal/i.test(e.type)) continue; + const team = (e.teamId && teamName.get(e.teamId)) ?? ''; + const key = `${e.scorer}|${team}`; + const cur = tally.get(key) ?? { player: e.scorer, team, goals: 0, latest: f.kickoff }; + cur.goals += 1; + if (f.kickoff > cur.latest) cur.latest = f.kickoff; + tally.set(key, cur); + } + } + const players = [...tally.values()].sort((a, b) => b.goals - a.goals || a.player.localeCompare(b.player)).slice(0, 25); + return { players }; + }); app.get('/api/scoreboard', async () => buildScoreboard(state)); // The data lake, quantified — counters for the /data page. app.get('/api/data/stats', async () => { diff --git a/server/src/ingest/espnNormalize.ts b/server/src/ingest/espnNormalize.ts index ef07765..9557907 100644 --- a/server/src/ingest/espnNormalize.ts +++ b/server/src/ingest/espnNormalize.ts @@ -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 EspnTeamRef { id: string; name: string; homeAway: 'home' | 'away' } 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 { + /** Normalizer schema version — bump to trigger the boot backfill. */ + v?: number; fetchedAt: number; venue: string | null; teams: EspnTeamRef[]; @@ -88,7 +99,15 @@ export interface RawSummary { teams?: { team?: { id: string }; statistics?: { name: string; displayValue: 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 { @@ -134,13 +153,17 @@ export function normalizeSummary(raw: RawSummary): EspnSummary { const events: EspnTimelineEvent[] = (raw.keyEvents ?? []).map((e) => { const m = e.clock?.displayValue?.match(/(\d+)/); + const scorer = e.scoringPlay ? e.participants?.[0]?.athlete?.displayName : undefined; return { minute: m ? Number(m[1]) : null, type: e.type?.text ?? '', text: e.text ?? '', 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 }; } diff --git a/server/src/ingest/scheduler.ts b/server/src/ingest/scheduler.ts index 4363647..0a12df3 100644 --- a/server/src/ingest/scheduler.ts +++ b/server/src/ingest/scheduler.ts @@ -4,7 +4,8 @@ import { fetchEspnScoreboard, fetchEspnSummary } from './espn'; import { fetchEspnOdds } from './espnOdds'; import { fetchFootballData } from '../footballData'; 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'; // 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 => { + for (const f of state.allFixtures()) { + if (stopped) return; + if (f.status === 'scheduled') continue; + const ext = getMatchExt(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 ---- const enrichTick = async (): Promise => { const now = Date.now(); @@ -189,6 +207,7 @@ export function startScheduler( await mapAllFixtures().catch(() => {}); await oddsTick().catch(() => {}); await liveTick().catch(() => {}); + await backfillExt().catch(() => {}); await enrichTick().catch(() => {}); scheduleLive(); scheduleEnrich(); diff --git a/src/features/teams/TeamsPage.tsx b/src/features/teams/TeamsPage.tsx index e1afd57..3ec99f8 100644 --- a/src/features/teams/TeamsPage.tsx +++ b/src/features/teams/TeamsPage.tsx @@ -1,12 +1,60 @@ -import { useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Link } from '@tanstack/react-router'; +import { Award } from 'lucide-react'; import { PageHeader } from '@/components/ui/PageHeader'; +import { Card, CardBody, CardHeader } from '@/components/ui/Card'; import { teamFlag } from '@/lib/teams'; import { useTournamentStore } from '@/stores/tournamentStore'; 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)}%`); +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(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 ( + + + + {t.teams.goldenBoot} + + +
    + {top.map((p, i) => ( +
  1. + {i + 1} + {p.team ? teamFlag(p.team) : ''} + {p.player} +
    +
    +
    + {p.goals} +
  2. + ))} +
+

{t.teams.goldenBootNote}

+
+
+ ); +} + export function TeamsPage() { const t = useT(); const model = useTournamentStore((s) => s.model); @@ -21,6 +69,7 @@ export function TeamsPage() { return (
+ {teams.length ? (
{teams.map((team, i) => ( diff --git a/src/lib/i18n/de.ts b/src/lib/i18n/de.ts index d8d4de8..11cfb87 100644 --- a/src/lib/i18n/de.ts +++ b/src/lib/i18n/de.ts @@ -195,6 +195,8 @@ export const de: Dict = { title: "Teams", subtitle: "Alle 48 Teams, sortiert nach den Titelchancen des Modells. Tipp auf ein Team für sein Profil.", advanceShort: "Weiterkommen {pct}", + goldenBoot: "Torjägerliste", + goldenBootNote: "Tore in diesem Turnier, aus dem Live-Eventfeed. Eigentore und Elfmeterschießen zählen nicht.", }, outcome: { home: "Heimsieg", diff --git a/src/lib/i18n/en.ts b/src/lib/i18n/en.ts index cf394be..fdeb40f 100644 --- a/src/lib/i18n/en.ts +++ b/src/lib/i18n/en.ts @@ -194,6 +194,8 @@ export const en = { title: "Teams", subtitle: "All 48 teams, ranked by the model's title odds. Tap a team to open its profile.", 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: { home: "Home win",