Upset meter: the results the model found least likely

The scoreboard gains a Biggest surprises card — finished matches whose
actual outcome the frozen model priced under 30%, sorted by shock
value, each linking to its match page. Pure client derivation from
data the scoreboard already had.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 01:10:02 +02:00
parent 88afbfad74
commit f58d6566fb
3 changed files with 49 additions and 1 deletions
+41 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useState, type ReactNode } from 'react'; import { useEffect, useState, type ReactNode } from 'react';
import { Link } from '@tanstack/react-router'; import { Link } from '@tanstack/react-router';
import { Scale } from 'lucide-react'; import { Scale, Zap } from 'lucide-react';
import { PageHeader } from '@/components/ui/PageHeader'; import { PageHeader } from '@/components/ui/PageHeader';
import { Card, CardBody, CardHeader } from '@/components/ui/Card'; import { Card, CardBody, CardHeader } from '@/components/ui/Card';
import { Term } from '@/components/ui/Term'; import { Term } from '@/components/ui/Term';
@@ -142,6 +142,8 @@ export function ScoreboardPage() {
</CardBody> </CardBody>
</Card> </Card>
<Upsets rows={data?.rows ?? []} />
{data ? ( {data ? (
<div className="space-y-3"> <div className="space-y-3">
{data.rows.length === 0 && ( {data.rows.length === 0 && (
@@ -155,3 +157,41 @@ export function ScoreboardPage() {
</div> </div>
); );
} }
/** The results the frozen model found least likely — the tournament's shocks. */
function Upsets({ rows }: { rows: ScoreboardRow[] }) {
const t = useT();
const upsets = rows
.filter((r) => r.scored && !r.late)
.map((r) => ({ r, p: r.model[r.scored!.outcome] }))
.filter((u) => u.p < 0.30)
.sort((a, b) => a.p - b.p)
.slice(0, 5);
if (upsets.length === 0) return null;
return (
<Card>
<CardHeader className="flex items-center gap-2">
<Zap size={16} className="text-gold" />
<span className="font-display font-bold text-ink">{t.scoreboard.upsets.title}</span>
</CardHeader>
<CardBody>
<ul className="space-y-2">
{upsets.map(({ r, p }) => (
<li key={r.num}>
<Link to="/match/$num" params={{ num: String(r.num) }} className="flex flex-wrap items-center gap-2 text-sm text-ink hover:text-accent">
<span aria-hidden>{teamFlag(r.home)}</span>
<span className="font-medium">{r.home}</span>
<span className="tnum text-faint">{r.homeScore}{r.awayScore}</span>
<span className="font-medium">{r.away}</span>
<span aria-hidden>{teamFlag(r.away)}</span>
<span className="ml-auto rounded-full border border-gold/40 bg-gold/10 px-2 py-0.5 text-[11px] font-bold text-gold">
{fmt(t.scoreboard.upsets.chance, { pct: `${Math.max(1, Math.round(p * 100))}%` })}
</span>
</Link>
</li>
))}
</ul>
</CardBody>
</Card>
);
}
+4
View File
@@ -245,6 +245,10 @@ export const de: Dict = {
marketCloser: "Markt näher dran", marketCloser: "Markt näher dran",
}, },
emptyRows: "Noch keine eingefrorenen Prognosen — der erste Snapshot entsteht {minutes} Minuten vor Anstoß.", emptyRows: "Noch keine eingefrorenen Prognosen — der erste Snapshot entsteht {minutes} Minuten vor Anstoß.",
upsets: {
title: "Größte Überraschungen",
chance: "{pct} wahrscheinlich",
},
}, },
methodology: { methodology: {
title: "Methodik", title: "Methodik",
+4
View File
@@ -244,6 +244,10 @@ export const en = {
marketCloser: "market closer", marketCloser: "market closer",
}, },
emptyRows: "No frozen forecasts yet — the first snapshot is taken {minutes} minutes before kickoff.", emptyRows: "No frozen forecasts yet — the first snapshot is taken {minutes} minutes before kickoff.",
upsets: {
title: "Biggest surprises",
chance: "{pct} likely",
},
}, },
methodology: { methodology: {
title: "Methodology", title: "Methodology",