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 { Link } from '@tanstack/react-router';
|
||||
import { Scale } from 'lucide-react';
|
||||
import { Scale, Zap } from 'lucide-react';
|
||||
import { PageHeader } from '@/components/ui/PageHeader';
|
||||
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
|
||||
import { Term } from '@/components/ui/Term';
|
||||
@@ -142,6 +142,8 @@ export function ScoreboardPage() {
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Upsets rows={data?.rows ?? []} />
|
||||
|
||||
{data ? (
|
||||
<div className="space-y-3">
|
||||
{data.rows.length === 0 && (
|
||||
@@ -155,3 +157,41 @@ export function ScoreboardPage() {
|
||||
</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",
|
||||
},
|
||||
emptyRows: "Noch keine eingefrorenen Prognosen — der erste Snapshot entsteht {minutes} Minuten vor Anstoß.",
|
||||
upsets: {
|
||||
title: "Größte Überraschungen",
|
||||
chance: "{pct} wahrscheinlich",
|
||||
},
|
||||
},
|
||||
methodology: {
|
||||
title: "Methodik",
|
||||
|
||||
@@ -244,6 +244,10 @@ export const en = {
|
||||
marketCloser: "market closer",
|
||||
},
|
||||
emptyRows: "No frozen forecasts yet — the first snapshot is taken {minutes} minutes before kickoff.",
|
||||
upsets: {
|
||||
title: "Biggest surprises",
|
||||
chance: "{pct} likely",
|
||||
},
|
||||
},
|
||||
methodology: {
|
||||
title: "Methodology",
|
||||
|
||||
Reference in New Issue
Block a user