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 { 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 () => {
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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) => (
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user