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:
+26
-1
@@ -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<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));
|
||||
// The data lake, quantified — counters for the /data page.
|
||||
app.get('/api/data/stats', async () => {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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<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 ----
|
||||
const enrichTick = async (): Promise<void> => {
|
||||
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();
|
||||
|
||||
@@ -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<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() {
|
||||
const t = useT();
|
||||
const model = useTournamentStore((s) => s.model);
|
||||
@@ -21,6 +69,7 @@ export function TeamsPage() {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title={t.teams.title} subtitle={t.teams.subtitle} />
|
||||
<GoldenBoot />
|
||||
{teams.length ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{teams.map((team, i) => (
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user