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 (
{label}
{pct(probs.home)} / {pct(probs.draw)} / {pct(probs.away)}
);
}
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 (
{teamFlag(r.home)} {r.home}
{r.status !== 'scheduled' && r.homeScore != null ? `${r.homeScore}–${r.awayScore}` : 'vs'}
{r.away} {teamFlag(r.away)}
{r.group ? `Group ${r.group} · ` : ''}{kickoffDay(r.kickoff)} {kickoffTime(r.kickoff)}
{r.late && ' · snapshot late — excluded from totals'}
{r.market ? (
) : (
no bookmaker line captured before kickoff
)}
{r.scored && (
result: {r.scored.outcome}
model RPS {r.scored.modelRps}
{r.scored.marketRps != null && market RPS {r.scored.marketRps} }
{closer && closer !== 'tie' && (
{closer === 'model' ? 'model closer' : 'market closer'}
)}
)}
);
}
export function ScoreboardPage() {
const [data, setData] = useState(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 (
Running head-to-head
{t ? (
{t.modelRps.toFixed(4)}
our model · RPS
closer {t.modelCloser}×
{t.n} match{t.n === 1 ? '' : 'es'} scored
{lead === 'tie' ? 'dead level' : lead === 'model' ? 'model ahead' : 'market ahead'}
{t.marketRps.toFixed(4)}
bookmaker · RPS
closer {t.marketCloser}×
) : (
The head-to-head starts with the first final whistle — forecasts are frozen before each kickoff and scored when the match ends.
)}
Honest rules: both forecasts frozen pre-kickoff; bookmaker margin removed ( ); scored with the{' '}
Ranked Probability Score ; late snapshots excluded. Beating the market over a small sample is
noise — watch the gap, not the lead.
{data ? (
{data.rows.length === 0 && (
No frozen forecasts yet — the first snapshots are taken 15 minutes before kickoff.
)}
{data.rows.map((r) =>
|
)}
) : (
)}
);
}