From f58d6566fbd35a65c1049dee8350fbc338a2f4a6 Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Fri, 12 Jun 2026 01:10:02 +0200 Subject: [PATCH] Upset meter: the results the model found least likely MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/features/scoreboard/ScoreboardPage.tsx | 42 +++++++++++++++++++++- src/lib/i18n/de.ts | 4 +++ src/lib/i18n/en.ts | 4 +++ 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/features/scoreboard/ScoreboardPage.tsx b/src/features/scoreboard/ScoreboardPage.tsx index 7de1c15..cff66fb 100644 --- a/src/features/scoreboard/ScoreboardPage.tsx +++ b/src/features/scoreboard/ScoreboardPage.tsx @@ -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() { + + {data ? (
{data.rows.length === 0 && ( @@ -155,3 +157,41 @@ export function ScoreboardPage() {
); } + +/** 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 ( + + + + {t.scoreboard.upsets.title} + + +
    + {upsets.map(({ r, p }) => ( +
  • + + {teamFlag(r.home)} + {r.home} + {r.homeScore}–{r.awayScore} + {r.away} + {teamFlag(r.away)} + + {fmt(t.scoreboard.upsets.chance, { pct: `${Math.max(1, Math.round(p * 100))}%` })} + + +
  • + ))} +
+
+
+ ); +} diff --git a/src/lib/i18n/de.ts b/src/lib/i18n/de.ts index d3449a6..e3b12db 100644 --- a/src/lib/i18n/de.ts +++ b/src/lib/i18n/de.ts @@ -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", diff --git a/src/lib/i18n/en.ts b/src/lib/i18n/en.ts index 78e23ee..ffc8790 100644 --- a/src/lib/i18n/en.ts +++ b/src/lib/i18n/en.ts @@ -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",