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