dda8e1ae42
- scripts/processStatsbomb.ts: one pass over the 17GB StatsBomb lake (4,235 matches) -> lakestats.json (12.82GB events / 14.9M events / 6,919 players), scorestate.json (goal-rate multipliers by game state — shipped as an insight, NOT wired into in-play: the raw effect is selection-biased toward stronger teams and would distort a calibrated model), fingerprints.json (40/48 nations' shots/xG/set-piece share from real event data). Artifacts committed; raw lake stays local (gitignored). - /data page + /api/data/stats: the gigabytes made visible — lake counters, historical archive, live-collection counts (odds lines, frozen forecasts, enrichment), per-source health with freshness. Footer links. - Term component: plain-language glossary tooltips (RPS, Brier, ECE, Elo, xG, de-vig, ensemble) attached where metrics appear. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
143 lines
6.9 KiB
TypeScript
143 lines
6.9 KiB
TypeScript
import { useEffect, useState } from 'react';
|
||
import { Link } from '@tanstack/react-router';
|
||
import { Scale } from 'lucide-react';
|
||
import { PageHeader } from '@/components/ui/PageHeader';
|
||
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
|
||
import { Term } from '@/components/ui/Term';
|
||
import { teamFlag } from '@/lib/teams';
|
||
import { kickoffDay, kickoffTime } from '@/lib/format';
|
||
import { cn } from '@/lib/cn';
|
||
import type { MatchProbs, ScoreboardData, ScoreboardRow } from '@/lib/types';
|
||
|
||
const pct = (x: number) => `${Math.round(x * 100)}%`;
|
||
|
||
function ProbTriple({ label, probs, tone }: { label: string; probs: MatchProbs; tone: 'model' | 'market' }) {
|
||
const color = tone === 'model' ? 'bg-accent' : 'bg-gold';
|
||
return (
|
||
<div className="flex items-center gap-2">
|
||
<span className={cn('w-12 shrink-0 text-[10px] font-bold uppercase tracking-wide', tone === 'model' ? 'text-accent' : 'text-gold')}>{label}</span>
|
||
<div className="flex h-2 flex-1 overflow-hidden rounded-full bg-elevated">
|
||
<div className={color} style={{ width: pct(probs.home) }} />
|
||
<div className="bg-line-strong" style={{ width: pct(probs.draw) }} />
|
||
<div className="bg-info" style={{ width: pct(probs.away) }} />
|
||
</div>
|
||
<span className="tnum w-24 shrink-0 text-right text-[11px] text-muted">
|
||
{pct(probs.home)} / {pct(probs.draw)} / {pct(probs.away)}
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Row({ r }: { r: ScoreboardRow }) {
|
||
const closer =
|
||
r.scored && r.scored.marketRps != null
|
||
? r.scored.modelRps < r.scored.marketRps ? 'model' : r.scored.marketRps < r.scored.modelRps ? 'market' : 'tie'
|
||
: null;
|
||
return (
|
||
<Card className={r.late ? 'opacity-60' : ''}>
|
||
<CardBody className="space-y-2">
|
||
<div className="flex flex-wrap items-center justify-between gap-2 text-sm">
|
||
<Link to="/match/$num" params={{ num: String(r.num) }} className="flex items-center gap-2 font-medium text-ink hover:text-accent">
|
||
<span aria-hidden>{teamFlag(r.home)}</span> {r.home}
|
||
<span className="tnum text-faint">{r.status !== 'scheduled' && r.homeScore != null ? `${r.homeScore}–${r.awayScore}` : 'vs'}</span>
|
||
{r.away} <span aria-hidden>{teamFlag(r.away)}</span>
|
||
</Link>
|
||
<span className="text-[11px] text-faint">
|
||
{r.group ? `Group ${r.group} · ` : ''}{kickoffDay(r.kickoff)} {kickoffTime(r.kickoff)}
|
||
{r.late && ' · snapshot late — excluded from totals'}
|
||
</span>
|
||
</div>
|
||
<ProbTriple label="Model" probs={r.model} tone="model" />
|
||
{r.market ? (
|
||
<ProbTriple label={r.market.provider} probs={r.market.probs} tone="market" />
|
||
) : (
|
||
<p className="text-[11px] text-faint">no bookmaker line captured before kickoff</p>
|
||
)}
|
||
{r.scored && (
|
||
<div className="flex items-center gap-3 border-t border-line pt-2 text-[11px]">
|
||
<span className="text-faint">result: <span className="font-semibold text-ink">{r.scored.outcome}</span></span>
|
||
<span className="tnum text-muted">model RPS {r.scored.modelRps}</span>
|
||
{r.scored.marketRps != null && <span className="tnum text-muted">market RPS {r.scored.marketRps}</span>}
|
||
{closer && closer !== 'tie' && (
|
||
<span className={cn('ml-auto rounded-full border px-2 py-0.5 font-bold uppercase tracking-wide', closer === 'model' ? 'border-accent/40 bg-accent-glow text-accent' : 'border-gold/40 bg-gold/15 text-gold')}>
|
||
{closer === 'model' ? 'model closer' : 'market closer'}
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</CardBody>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
export function ScoreboardPage() {
|
||
const [data, setData] = useState<ScoreboardData | null>(null);
|
||
|
||
useEffect(() => {
|
||
let alive = true;
|
||
const load = () => fetch('/api/scoreboard').then((r) => r.json()).then((d: ScoreboardData) => alive && setData(d)).catch(() => {});
|
||
void load();
|
||
const id = setInterval(load, 60_000);
|
||
return () => { alive = false; clearInterval(id); };
|
||
}, []);
|
||
|
||
const t = data?.totals ?? null;
|
||
const lead = t ? (t.modelRps < t.marketRps ? 'model' : t.marketRps < t.modelRps ? 'market' : 'tie') : null;
|
||
|
||
return (
|
||
<div className="space-y-5">
|
||
<PageHeader
|
||
title="Model vs Market"
|
||
subtitle="Our forecast, frozen before kickoff, against the bookmaker's de-vigged line — scored after every match. Lower RPS = closer to what happened."
|
||
/>
|
||
|
||
<Card>
|
||
<CardHeader className="flex items-center gap-2">
|
||
<Scale size={16} className="text-accent" />
|
||
<span className="font-display font-bold text-ink">Running head-to-head</span>
|
||
</CardHeader>
|
||
<CardBody>
|
||
{t ? (
|
||
<div className="grid grid-cols-3 items-center gap-3 text-center">
|
||
<div>
|
||
<div className={cn('font-display text-3xl font-extrabold', lead === 'model' ? 'text-accent' : 'text-ink')}>{t.modelRps.toFixed(4)}</div>
|
||
<div className="text-[11px] uppercase tracking-wide text-faint">our model · RPS</div>
|
||
<div className="tnum mt-1 text-xs text-muted">closer {t.modelCloser}×</div>
|
||
</div>
|
||
<div className="text-sm text-faint">
|
||
{t.n} match{t.n === 1 ? '' : 'es'} scored
|
||
<div className="mt-1 text-xs">{lead === 'tie' ? 'dead level' : lead === 'model' ? 'model ahead' : 'market ahead'}</div>
|
||
</div>
|
||
<div>
|
||
<div className={cn('font-display text-3xl font-extrabold', lead === 'market' ? 'text-gold' : 'text-ink')}>{t.marketRps.toFixed(4)}</div>
|
||
<div className="text-[11px] uppercase tracking-wide text-faint">bookmaker · RPS</div>
|
||
<div className="tnum mt-1 text-xs text-muted">closer {t.marketCloser}×</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<p className="py-4 text-center text-sm text-muted">
|
||
The head-to-head starts with the first final whistle — forecasts are frozen before each kickoff and scored when the match ends.
|
||
</p>
|
||
)}
|
||
<p className="mt-3 border-t border-line pt-2 text-xs text-faint">
|
||
Honest rules: both forecasts frozen pre-kickoff; bookmaker margin removed (<Term k="de-vig" />); scored with the{' '}
|
||
<Term k="RPS">Ranked Probability Score</Term>; late snapshots excluded. Beating the market over a small sample is
|
||
noise — watch the gap, not the lead.
|
||
</p>
|
||
</CardBody>
|
||
</Card>
|
||
|
||
{data ? (
|
||
<div className="space-y-3">
|
||
{data.rows.length === 0 && (
|
||
<p className="py-8 text-center text-sm text-muted">No frozen forecasts yet — the first snapshots are taken 15 minutes before kickoff.</p>
|
||
)}
|
||
{data.rows.map((r) => <Row key={r.num} r={r} />)}
|
||
</div>
|
||
) : (
|
||
<div className="h-48 animate-pulse rounded-xl border border-line bg-panel" />
|
||
)}
|
||
</div>
|
||
);
|
||
}
|