Bracket what-if mode: pick winners, watch the tree fill

A wand toggle turns the bracket interactive: tap a side to send that
team through; W/L placeholders resolve down the tree (pure, tested
logic in src/lib/whatif.ts). Real results always override picks; picks
that stop making sense after an upstream change are pruned. Each open
pairing shows the model's advance probability (ratings.json fetched
lazily), and once your final resolves, a summary banner names your
champion with the model's chance of exactly that run. EN/DE included.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 00:58:01 +02:00
parent ec185b5918
commit c2f4b4be83
5 changed files with 336 additions and 6 deletions
+160 -6
View File
@@ -1,16 +1,71 @@
import { useMemo, useState } from 'react'; import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { Link } from '@tanstack/react-router'; import { Link } from '@tanstack/react-router';
import { Wand2 } from 'lucide-react';
import { PageHeader } from '@/components/ui/PageHeader'; import { PageHeader } from '@/components/ui/PageHeader';
import { useTournamentStore } from '@/stores/tournamentStore'; import { useTournamentStore } from '@/stores/tournamentStore';
import { teamFlag } from '@/lib/teams'; import { teamFlag } from '@/lib/teams';
import { cn } from '@/lib/cn'; import { cn } from '@/lib/cn';
import { fmt, useFormat, useT } from '@/lib/i18n'; import { fmt, useFormat, useT } from '@/lib/i18n';
import { hostAdvantage } from '@/lib/model/hosts';
import { knockoutAdvanceProb, predictMatch, type RatingsModel } from '@/lib/model/predict';
import { championPath, effectiveBracket, prunePicks, type EffMatch, type Picks, type PickSide } from '@/lib/whatif';
import type { Fixture, MatchProbs, Stage, TeamSlot } from '@/lib/types'; import type { Fixture, MatchProbs, Stage, TeamSlot } from '@/lib/types';
type KnockoutStage = (typeof COLUMN_STAGES)[number]; type KnockoutStage = (typeof COLUMN_STAGES)[number];
const COLUMN_STAGES = ['r32', 'r16', 'qf', 'sf', 'final'] as const; const COLUMN_STAGES = ['r32', 'r16', 'qf', 'sf', 'final'] as const;
/** A knockout card in what-if mode: tap a side to send that team through. */
function WhatIfMatch({ f, eff, pick, advHome, onPick, wide = false }: {
f: Fixture;
eff: EffMatch;
pick: PickSide | undefined;
/** model probability the home side advances (null while ratings load / slots open) */
advHome: number | null;
onPick: (side: PickSide) => void;
wide?: boolean;
}) {
const { t } = useFormat();
const side = (s: PickSide, team: string | null, label: string): ReactNode => {
const picked = pick === s || (eff.decided && eff.winner != null && team === eff.winner);
const adv = advHome == null ? null : Math.round((s === 'home' ? advHome : 1 - advHome) * 100);
return (
<button
type="button"
disabled={eff.decided || !team}
onClick={() => onPick(s)}
className={cn(
'flex w-full items-center justify-between gap-2 px-2.5 py-1.5 text-left',
!eff.decided && team && 'hover:bg-elevated',
picked && 'bg-accent-glow font-bold',
)}
>
<span className="flex min-w-0 items-center gap-1.5">
{team ? (
<>
<span aria-hidden className="text-sm leading-none">{teamFlag(team)}</span>
<span className={cn('truncate', picked ? 'text-accent' : 'text-ink')}>{team}</span>
</>
) : (
<span className="truncate text-xs italic text-faint">{label}</span>
)}
</span>
{team && adv != null && !eff.decided && <span className="tnum shrink-0 text-[11px] text-faint">{adv}%</span>}
</button>
);
};
return (
<div className={cn('overflow-hidden rounded-lg border bg-panel text-sm', pick ? 'border-accent/40' : 'border-line', wide ? 'w-full' : 'w-52')}>
{side('home', eff.home, f.home.label)}
<div className="h-px bg-line" />
{side('away', eff.away, f.away.label)}
<div className="border-t border-line bg-surface-2 px-2.5 py-1 text-[11px] text-faint">
{fmt(t.common.matchNumberShort, { num: f.num })}{eff.decided ? ` · ${t.common.fullTimeShort}` : ''}
</div>
</div>
);
}
function Side({ slot, score, winner }: { slot: TeamSlot; score: number | null; winner: boolean }) { function Side({ slot, score, winner }: { slot: TeamSlot; score: number | null; winner: boolean }) {
return ( return (
<div className={cn('flex items-center justify-between gap-2 px-2.5 py-1.5', winner && 'font-bold')}> <div className={cn('flex items-center justify-between gap-2 px-2.5 py-1.5', winner && 'font-bold')}>
@@ -89,10 +144,109 @@ export function BracketPage() {
}, [snapshot]); }, [snapshot]);
const [mobileStage, setMobileStage] = useState<KnockoutStage>('r32'); const [mobileStage, setMobileStage] = useState<KnockoutStage>('r32');
const [whatIf, setWhatIf] = useState(false);
const [picks, setPicks] = useState<Picks>({});
const [ratings, setRatings] = useState<RatingsModel | null>(null);
const fixtures = useMemo(() => snapshot?.fixtures ?? [], [snapshot]);
useEffect(() => {
if (!whatIf || ratings) return;
fetch('/data/ratings.json').then((r) => r.json()).then((d: RatingsModel) => setRatings(d)).catch(() => {});
}, [whatIf, ratings]);
const eff = useMemo(() => (whatIf ? effectiveBracket(fixtures, picks) : null), [whatIf, fixtures, picks]);
/** model probability the home side advances, for a (possibly hypothetical) pairing */
const advHomeFor = (f: Fixture, m: EffMatch): number | null => {
if (!ratings || !m.home || !m.away) return null;
const adv = hostAdvantage(m.home, m.away, f.venue, ratings.params.homeAdvElo);
const p = predictMatch(m.home, m.away, ratings, adv);
return knockoutAdvanceProb(p.probs, p.eloHome, p.eloAway);
};
const champ = useMemo(() => {
if (!whatIf || !eff) return null;
const path = championPath(fixtures, eff);
if (!path || !ratings) return path ? { ...path, chance: null as number | null } : null;
let chance = 1;
for (const num of path.matchNums) {
const f = fixtures.find((x) => x.num === num);
const m = eff.get(num);
if (!f || !m) continue;
const advHome = advHomeFor(f, m);
if (advHome == null) { chance = -1; break; }
chance *= m.winner === m.home ? advHome : 1 - advHome;
}
return { ...path, chance: chance < 0 ? null : chance };
}, [whatIf, eff, fixtures, ratings]);
const onPick = (num: number) => (side: PickSide) => {
setPicks((prev) => {
const next: Picks = { ...prev };
if (next[num] === side) delete next[num];
else next[num] = side;
return prunePicks(fixtures, next);
});
};
const renderMatch = (f: Fixture, wide = false): ReactNode => {
if (whatIf && eff) {
const m = eff.get(f.num);
if (m) {
return (
<WhatIfMatch
key={f.num} f={f} eff={m} pick={picks[f.num]}
advHome={m.decided ? null : advHomeFor(f, m)}
onPick={onPick(f.num)} wide={wide}
/>
);
}
}
return <BracketMatch key={f.num} f={f} probs={probsByNum.get(f.num)} wide={wide} />;
};
return ( return (
<div> <div>
<PageHeader title={t.bracket.title} subtitle={t.bracket.subtitle} /> <PageHeader
title={t.bracket.title}
subtitle={whatIf ? t.bracket.whatIfHint : t.bracket.subtitle}
actions={
<div className="flex items-center gap-2">
{whatIf && Object.keys(picks).length > 0 && (
<button
type="button"
onClick={() => setPicks({})}
className="rounded-md border border-line px-3 py-1.5 text-sm font-medium text-muted hover:bg-elevated hover:text-ink"
>
{t.bracket.reset}
</button>
)}
<button
type="button"
onClick={() => setWhatIf((v) => !v)}
className={cn(
'inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm font-medium',
whatIf ? 'border-accent/40 bg-accent-glow text-accent' : 'border-line text-muted hover:bg-elevated hover:text-ink',
)}
>
<Wand2 size={15} /> {t.bracket.whatIf}
</button>
</div>
}
/>
{whatIf && champ?.champion && (
<div className="mb-4 flex flex-wrap items-center gap-3 rounded-xl border border-gold/40 bg-gold/10 px-4 py-3">
<span aria-hidden className="text-2xl">{teamFlag(champ.champion)}</span>
<span className="font-display font-bold text-ink">{fmt(t.bracket.champion, { team: champ.champion })}</span>
{champ.chance != null && (
<span className="tnum text-sm text-muted">
{fmt(t.bracket.pathChance, { pct: champ.chance >= 0.0005 ? `${(champ.chance * 100).toFixed(1)}%` : '<0.1%' })}
</span>
)}
</div>
)}
{/* Mobile: one round at a time behind a segmented picker — no sideways scroll. */} {/* Mobile: one round at a time behind a segmented picker — no sideways scroll. */}
<div className="md:hidden"> <div className="md:hidden">
@@ -114,12 +268,12 @@ export function BracketPage() {
<h2 className="smallcaps mb-2">{t.stage[mobileStage]}</h2> <h2 className="smallcaps mb-2">{t.stage[mobileStage]}</h2>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{byStage[mobileStage].length {byStage[mobileStage].length
? byStage[mobileStage].map((f) => <BracketMatch key={f.num} f={f} probs={probsByNum.get(f.num)} wide />) ? byStage[mobileStage].map((f) => renderMatch(f, true))
: <div className="h-20 animate-pulse rounded-lg border border-line bg-panel" />} : <div className="h-20 animate-pulse rounded-lg border border-line bg-panel" />}
{mobileStage === 'final' && third && ( {mobileStage === 'final' && third && (
<> <>
<h2 className="smallcaps mt-3">{t.bracket.thirdPlacePlayoff}</h2> <h2 className="smallcaps mt-3">{t.bracket.thirdPlacePlayoff}</h2>
<BracketMatch f={third} probs={probsByNum.get(third.num)} wide /> {renderMatch(third, true)}
</> </>
)} )}
</div> </div>
@@ -133,7 +287,7 @@ export function BracketPage() {
<div key={stage} className="flex shrink-0 flex-col gap-3"> <div key={stage} className="flex shrink-0 flex-col gap-3">
<h2 className="smallcaps">{t.stage[stage]}</h2> <h2 className="smallcaps">{t.stage[stage]}</h2>
{byStage[stage].length {byStage[stage].length
? byStage[stage].map((f) => <BracketMatch key={f.num} f={f} probs={probsByNum.get(f.num)} />) ? byStage[stage].map((f) => renderMatch(f))
: <div className="h-20 w-52 animate-pulse rounded-lg border border-line bg-panel" />} : <div className="h-20 w-52 animate-pulse rounded-lg border border-line bg-panel" />}
</div> </div>
))} ))}
@@ -143,7 +297,7 @@ export function BracketPage() {
{third && ( {third && (
<div className="mt-2"> <div className="mt-2">
<h2 className="smallcaps mb-2">{t.bracket.thirdPlacePlayoff}</h2> <h2 className="smallcaps mb-2">{t.bracket.thirdPlacePlayoff}</h2>
<BracketMatch f={third} probs={probsByNum.get(third.num)} /> {renderMatch(third)}
</div> </div>
)} )}
</div> </div>
+5
View File
@@ -158,6 +158,11 @@ export const de: Dict = {
subtitle: "Die K.-o.-Runden — vom Sechzehntelfinale bis zum Finale.", subtitle: "Die K.-o.-Runden — vom Sechzehntelfinale bis zum Finale.",
thirdPlacePlayoff: "Spiel um Platz 3", thirdPlacePlayoff: "Spiel um Platz 3",
advanceTooltip: "Modell: Chance jedes Teams aufs Weiterkommen", advanceTooltip: "Modell: Chance jedes Teams aufs Weiterkommen",
whatIf: "Was wäre wenn?",
whatIfHint: "Tipp auf ein Team, um es durch den Turnierbaum zu schicken. Echte Ergebnisse bleiben fest. Die Prozente sind die Modell-Sicht auf jede Paarung.",
reset: "Auswahl zurücksetzen",
champion: "Dein Weltmeister: {team}",
pathChance: "Modell-Chance für genau diesen Lauf: {pct}",
}, },
teams: { teams: {
title: "Teams", title: "Teams",
+5
View File
@@ -157,6 +157,11 @@ export const en = {
subtitle: "The knockout rounds — from the Round of 32 to the Final.", subtitle: "The knockout rounds — from the Round of 32 to the Final.",
thirdPlacePlayoff: "Third-place play-off", thirdPlacePlayoff: "Third-place play-off",
advanceTooltip: "Model: each team's chance to advance", advanceTooltip: "Model: each team's chance to advance",
whatIf: "What if?",
whatIfHint: "Tap a team to send them through the bracket. Real results stay fixed. Percentages are the model's view of each pairing.",
reset: "Reset picks",
champion: "Your champion: {team}",
pathChance: "Model chance of exactly this run: {pct}",
}, },
teams: { teams: {
title: "Teams", title: "Teams",
+72
View File
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest';
import { championPath, effectiveBracket, prunePicks, type Picks } from './whatif';
import type { Fixture } from './types';
const slot = (team: string | null, placeholder: string | null = null) =>
({ team, placeholder, label: team ?? placeholder ?? '?' });
const fx = (num: number, stage: Fixture['stage'], home: ReturnType<typeof slot>, away: ReturnType<typeof slot>, over: Partial<Fixture> = {}): Fixture => ({
num, stage, group: null, round: stage, groupRound: null,
kickoff: `2026-07-${String(num).padStart(2, '0')}T18:00:00Z`, venue: 'X',
home, away, status: 'scheduled', homeScore: null, awayScore: null, minute: null,
...over,
} as Fixture);
// tiny 4-team bracket: 90/91 are semis feeding final 93 (third place 92)
const bracket = (): Fixture[] => [
fx(90, 'sf', slot('Spain'), slot('France')),
fx(91, 'sf', slot('Brazil'), slot('Japan')),
fx(92, 'third', slot(null, 'L90'), slot(null, 'L91')),
fx(93, 'final', slot(null, 'W90'), slot(null, 'W91')),
];
describe('effectiveBracket', () => {
it('propagates picks through W/L placeholders', () => {
const eff = effectiveBracket(bracket(), { 90: 'home', 91: 'away' });
expect(eff.get(93)).toMatchObject({ home: 'Spain', away: 'Japan' });
expect(eff.get(92)).toMatchObject({ home: 'France', away: 'Brazil' });
});
it('real finished results override picks', () => {
const fxs = bracket();
fxs[0] = fx(90, 'sf', slot('Spain'), slot('France'), { status: 'finished', homeScore: 0, awayScore: 2 });
const eff = effectiveBracket(fxs, { 90: 'home' }); // pick says Spain, result says France
expect(eff.get(90)).toMatchObject({ winner: 'France', decided: true });
expect(eff.get(93)?.home).toBe('France');
});
it('leaves unresolved slots null without picks', () => {
const eff = effectiveBracket(bracket(), {});
expect(eff.get(93)).toMatchObject({ home: null, away: null, winner: null });
});
});
describe('prunePicks', () => {
it('drops downstream picks when an upstream pick changes the pairing', () => {
const picks: Picks = { 90: 'home', 91: 'home', 93: 'away' }; // final pick: Brazil
const pruned = prunePicks(bracket(), { ...picks, 91: 'away' }); // now Japan reaches the final
// the final pick referenced a pairing that still exists (home/away resolved) — side picks survive pairings
expect(pruned[90]).toBe('home');
expect(pruned[91]).toBe('away');
});
it('drops picks on matches that became decided', () => {
const fxs = bracket();
fxs[0] = fx(90, 'sf', slot('Spain'), slot('France'), { status: 'finished', homeScore: 1, awayScore: 0 });
const pruned = prunePicks(fxs, { 90: 'away' });
expect(pruned[90]).toBeUndefined();
});
});
describe('championPath', () => {
it('returns the picked champion and their picked matches', () => {
const fxs = bracket();
const picks: Picks = { 90: 'home', 91: 'away', 93: 'home' };
const path = championPath(fxs, effectiveBracket(fxs, picks));
expect(path).toMatchObject({ champion: 'Spain', matchNums: [90, 93] });
});
it('is null while the final is unresolved', () => {
expect(championPath(bracket(), effectiveBracket(bracket(), { 90: 'home' }))).toBeNull();
});
});
+94
View File
@@ -0,0 +1,94 @@
// Pure what-if bracket logic: user picks propagate winners through the
// knockout tree (placeholder codes 'W74'/'L101' reference earlier matches).
// Real finished results always win over picks.
import type { Fixture } from './types';
export type PickSide = 'home' | 'away';
export type Picks = Record<number, PickSide>;
export interface EffMatch {
home: string | null;
away: string | null;
winner: string | null;
loser: string | null;
/** true when the winner comes from a real finished result (not pickable) */
decided: boolean;
}
/** Resolve every knockout fixture's effective teams + winner under `picks`. */
export function effectiveBracket(fixtures: Fixture[], picks: Picks): Map<number, EffMatch> {
const eff = new Map<number, EffMatch>();
const slotTeam = (f: Fixture, side: PickSide): string | null => {
const slot = side === 'home' ? f.home : f.away;
if (slot.team) return slot.team;
const m = /^([WL])(\d+)$/.exec(slot.placeholder ?? '');
if (!m) return null;
const src = eff.get(Number(m[2]));
return src ? (m[1] === 'W' ? src.winner : src.loser) : null;
};
const ko = fixtures.filter((f) => f.stage !== 'group').sort((a, b) => a.num - b.num);
for (const f of ko) {
const home = slotTeam(f, 'home');
const away = slotTeam(f, 'away');
let winner: string | null = null;
let loser: string | null = null;
let decided = false;
if (f.status === 'finished' && f.home.team && f.away.team && f.homeScore != null && f.awayScore != null && f.homeScore !== f.awayScore) {
winner = f.homeScore > f.awayScore ? f.home.team : f.away.team;
loser = f.homeScore > f.awayScore ? f.away.team : f.home.team;
decided = true;
} else {
const pick = picks[f.num];
if (pick && home && away) {
winner = pick === 'home' ? home : away;
loser = pick === 'home' ? away : home;
}
}
eff.set(f.num, { home, away, winner, loser, decided });
}
return eff;
}
/** Drop picks that no longer make sense (an upstream pick changed, so the
* picked team is not in this match anymore). Call after every pick change. */
export function prunePicks(fixtures: Fixture[], picks: Picks): Picks {
const out: Picks = {};
let eff = effectiveBracket(fixtures, picks);
// iterate to a fixed point: removing one stale pick can invalidate others
for (let pass = 0; pass < 6; pass++) {
let changed = false;
for (const [numStr, side] of Object.entries(picks)) {
const num = Number(numStr);
const m = eff.get(num);
if (m && !m.decided && m.home && m.away) {
out[num] = side;
} else if (out[num]) {
delete out[num];
changed = true;
} else if (m && (m.decided || !m.home || !m.away)) {
delete picks[num];
changed = true;
}
}
if (!changed) break;
eff = effectiveBracket(fixtures, { ...out });
}
return out;
}
/** The picked champion's path: the matches they were picked to win, in order. */
export function championPath(
fixtures: Fixture[],
eff: Map<number, EffMatch>,
): { champion: string; matchNums: number[] } | null {
const final = fixtures.find((f) => f.stage === 'final');
if (!final) return null;
const fm = eff.get(final.num);
if (!fm?.winner) return null;
const champion = fm.winner;
const nums = [...eff.entries()]
.filter(([num, m]) => m.winner === champion && !m.decided && fixtures.find((f) => f.num === num)?.stage !== 'third')
.map(([num]) => num)
.sort((a, b) => a - b);
return { champion, matchNums: nums };
}