Files
cup26/src/features/scoreboard/ScoreboardPage.tsx
T
NilsBriggen dda8e1ae42 v3: the lake processed + /data page + glossary terms
- 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>
2026-06-11 17:41:43 +02:00

143 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}