From c2f4b4be83a5255d9c77717d9e3dc5ca8d8dc318 Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Fri, 12 Jun 2026 00:58:01 +0200 Subject: [PATCH] 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 --- src/features/bracket/BracketPage.tsx | 166 ++++++++++++++++++++++++++- src/lib/i18n/de.ts | 5 + src/lib/i18n/en.ts | 5 + src/lib/whatif.test.ts | 72 ++++++++++++ src/lib/whatif.ts | 94 +++++++++++++++ 5 files changed, 336 insertions(+), 6 deletions(-) create mode 100644 src/lib/whatif.test.ts create mode 100644 src/lib/whatif.ts diff --git a/src/features/bracket/BracketPage.tsx b/src/features/bracket/BracketPage.tsx index 56b1905..e4989fa 100644 --- a/src/features/bracket/BracketPage.tsx +++ b/src/features/bracket/BracketPage.tsx @@ -1,16 +1,71 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState, type ReactNode } from 'react'; import { Link } from '@tanstack/react-router'; +import { Wand2 } from 'lucide-react'; import { PageHeader } from '@/components/ui/PageHeader'; import { useTournamentStore } from '@/stores/tournamentStore'; import { teamFlag } from '@/lib/teams'; import { cn } from '@/lib/cn'; 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'; type KnockoutStage = (typeof COLUMN_STAGES)[number]; 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 ( + + ); + }; + return ( +
+ {side('home', eff.home, f.home.label)} +
+ {side('away', eff.away, f.away.label)} +
+ {fmt(t.common.matchNumberShort, { num: f.num })}{eff.decided ? ` · ${t.common.fullTimeShort}` : ''} +
+
+ ); +} + function Side({ slot, score, winner }: { slot: TeamSlot; score: number | null; winner: boolean }) { return (
@@ -89,10 +144,109 @@ export function BracketPage() { }, [snapshot]); const [mobileStage, setMobileStage] = useState('r32'); + const [whatIf, setWhatIf] = useState(false); + const [picks, setPicks] = useState({}); + const [ratings, setRatings] = useState(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 ( + + ); + } + } + return ; + }; return (
- + + {whatIf && Object.keys(picks).length > 0 && ( + + )} + +
+ } + /> + + {whatIf && champ?.champion && ( +
+ {teamFlag(champ.champion)} + {fmt(t.bracket.champion, { team: champ.champion })} + {champ.chance != null && ( + + {fmt(t.bracket.pathChance, { pct: champ.chance >= 0.0005 ? `${(champ.chance * 100).toFixed(1)}%` : '<0.1%' })} + + )} +
+ )} {/* Mobile: one round at a time behind a segmented picker — no sideways scroll. */}
@@ -114,12 +268,12 @@ export function BracketPage() {

{t.stage[mobileStage]}

{byStage[mobileStage].length - ? byStage[mobileStage].map((f) => ) + ? byStage[mobileStage].map((f) => renderMatch(f, true)) :
} {mobileStage === 'final' && third && ( <>

{t.bracket.thirdPlacePlayoff}

- + {renderMatch(third, true)} )}
@@ -133,7 +287,7 @@ export function BracketPage() {

{t.stage[stage]}

{byStage[stage].length - ? byStage[stage].map((f) => ) + ? byStage[stage].map((f) => renderMatch(f)) :
}
))} @@ -143,7 +297,7 @@ export function BracketPage() { {third && (

{t.bracket.thirdPlacePlayoff}

- + {renderMatch(third)}
)}
diff --git a/src/lib/i18n/de.ts b/src/lib/i18n/de.ts index fad2127..dfab195 100644 --- a/src/lib/i18n/de.ts +++ b/src/lib/i18n/de.ts @@ -158,6 +158,11 @@ export const de: Dict = { subtitle: "Die K.-o.-Runden — vom Sechzehntelfinale bis zum Finale.", thirdPlacePlayoff: "Spiel um Platz 3", 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: { title: "Teams", diff --git a/src/lib/i18n/en.ts b/src/lib/i18n/en.ts index a472df6..9c1625c 100644 --- a/src/lib/i18n/en.ts +++ b/src/lib/i18n/en.ts @@ -157,6 +157,11 @@ export const en = { subtitle: "The knockout rounds — from the Round of 32 to the Final.", thirdPlacePlayoff: "Third-place play-off", 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: { title: "Teams", diff --git a/src/lib/whatif.test.ts b/src/lib/whatif.test.ts new file mode 100644 index 0000000..e738a48 --- /dev/null +++ b/src/lib/whatif.test.ts @@ -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, away: ReturnType, over: Partial = {}): 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(); + }); +}); diff --git a/src/lib/whatif.ts b/src/lib/whatif.ts new file mode 100644 index 0000000..6e998f5 --- /dev/null +++ b/src/lib/whatif.ts @@ -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; + +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 { + const eff = new Map(); + 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, +): { 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 }; +}