From 15d955e65cc35966f96ce514cf2cc51a6f0b537d Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Fri, 12 Jun 2026 13:46:27 +0200 Subject: [PATCH] Starters-on-pitch, chart movers sort, pre-filled what-if bracket, vs/@ fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Finished matches show the STARTING XIs on the pitch (red markers with the minute on everyone subbed off) with 'Subbed on' / 'Subbed off' lists below, minutes and ratings included; live matches keep the current-XI view that swaps substitutes in - Title-race chart gets a sort toggle, defaulting to 'Biggest movers' (the teams whose odds changed most at the latest update — what's relevant now) with 'Title favorites' as the alternative - Entering what-if on the bracket now pre-fills the model's most likely tournament: group slots seeded from win-group/advance odds (best- thirds assigned to their allowed slots via backtracking), every pairing picked by the model — a complete overview to edit by tapping; Reset returns to the projection - Team fixtures drop the US 'vs/@' convention: always 'vs' plus an H/A badge with a tooltip Co-Authored-By: Claude Fable 5 --- src/components/LineupPitch.tsx | 95 +++++++++++++++------- src/components/OddsChart.tsx | 40 +++++++-- src/features/bracket/BracketPage.tsx | 41 +++++++--- src/features/match/MatchPreviewPage.tsx | 2 +- src/features/team/TeamProfilePage.tsx | 8 +- src/lib/i18n/de.ts | 10 ++- src/lib/i18n/en.ts | 10 ++- src/lib/whatif.test.ts | 53 +++++++++++- src/lib/whatif.ts | 103 ++++++++++++++++++++++-- 9 files changed, 303 insertions(+), 59 deletions(-) diff --git a/src/components/LineupPitch.tsx b/src/components/LineupPitch.tsx index f57e30b..9a58164 100644 --- a/src/components/LineupPitch.tsx +++ b/src/components/LineupPitch.tsx @@ -39,9 +39,10 @@ interface Slot { /** The starter the slot belongs to (=== player until a swap). */ starter: PitchPlayer; cameOnAt: number | null; - /** The occupant went off but the replacement couldn't be paired — keep the - * dot with a ▼ marker instead of guessing. */ - unresolvedOff: boolean; + /** Minute the occupant went off (▼ marker), when he stays on the pitch. */ + offAt: number | null; + /** Fade the dot — live mode only, when a replacement couldn't be paired. */ + dim: boolean; } interface SubPair { in: string; out: string } @@ -54,14 +55,14 @@ interface SubPair { in: string; out: string } function currentXI(team: LineupTeam, pairs: SubPair[]): { slots: Slot[]; off: PitchPlayer[] } { const off: PitchPlayer[] = []; const used = new Set(); - const slots: Slot[] = team.starters.map((starter) => ({ player: starter, starter, cameOnAt: null, unresolvedOff: false })); + const slots: Slot[] = team.starters.map((starter) => ({ player: starter, starter, cameOnAt: null, offAt: null, dim: false })); const swap = (slot: Slot, repl: PitchPlayer) => { used.add(repl); off.push(slot.player); slot.player = repl; slot.cameOnAt = repl.subIn; }; - const pending = () => slots.filter((s) => s.player.subOut != null && !s.unresolvedOff); + const pending = () => slots.filter((s) => s.player.subOut != null && s.offAt == null); for (let round = 0; round < 8; round++) { let progress = false; @@ -78,10 +79,22 @@ function currentXI(team: LineupTeam, pairs: SubPair[]): { slots: Slot[]; off: Pi } if (!progress) break; } - for (const slot of pending()) slot.unresolvedOff = true; + for (const slot of pending()) { + slot.offAt = slot.player.subOut; + slot.dim = true; + } return { slots, off }; } +/** The starting XI as kicked off — subbed-off starters keep their spot with a + * ▼ minute marker. The post-match view of how the teams lined up. */ +function startersXI(team: LineupTeam): { slots: Slot[]; off: PitchPlayer[] } { + return { + slots: team.starters.map((starter) => ({ player: starter, starter, cameOnAt: null, offAt: starter.subOut, dim: false })), + off: team.starters.filter((p) => p.subOut != null), + }; +} + /** Last name token, capped — fits under a dot on a 68-unit-wide pitch. */ function shortName(name: string): string { const last = name.trim().split(' ').pop() ?? name; @@ -104,7 +117,7 @@ function PlayerDot({ slot, side, ny }: { slot: Slot; side: 'home' | 'away'; ny: const gk = ny <= 0.1; const stroke = side === 'home' ? 'var(--app-accent)' : 'var(--app-info)'; return ( - + {p.num ?? ''} @@ -147,14 +160,14 @@ function PlayerDot({ slot, side, ny }: { slot: Slot; side: 'home' | 'away'; ny: )} - {slot.unresolvedOff && ( + {slot.offAt != null && ( - {slot.player.subOut}' + {slot.offAt}' )} @@ -198,33 +211,39 @@ function PitchLines() { ); } -function SubbedOffStrip({ off, tone }: { off: PitchPlayer[]; tone: 'home' | 'away' }) { +function BenchStrip({ players, tone, label, dir }: { players: PitchPlayer[]; tone: 'home' | 'away'; label: string; dir: 'on' | 'off' }) { const t = useT(); - if (!off.length) return null; + if (!players.length) return null; return (
- {t.match.subbedOff} + {label}
    - {off.map((p) => ( -
  • - - {p.name} - {p.subOut != null && {fmt(t.live.minute, { n: p.subOut })}} - {p.rating != null && {p.rating.toFixed(1)}} -
  • - ))} + {players.map((p) => { + const minute = dir === 'on' ? p.subIn : p.subOut; + return ( +
  • + {dir === 'on' ? '▲' : '▼'} + {p.name} + {minute != null && {fmt(t.live.minute, { n: minute })}} + {p.rating != null && {p.rating.toFixed(1)}} +
  • + ); + })}
); } -export function LineupPitch({ lineup, home, away, events }: { +export function LineupPitch({ lineup, home, away, events, mode = 'current' }: { lineup: MatchLineup; home: string; away: string; events: MatchLiveEvent[]; + /** 'current' = who is on the pitch right now (live, subs swap in); + * 'starters' = the XIs as they kicked off (finished/upcoming matches). */ + mode?: 'current' | 'starters'; }) { const t = useT(); @@ -233,12 +252,15 @@ export function LineupPitch({ lineup, home, away, events }: { const pairsFor = (side: 'home' | 'away'): SubPair[] => subs.filter((e) => e.side === side).map((e) => ({ in: e.title, out: e.subOut! })); return { - homeXI: currentXI(lineup.home, pairsFor('home')), - awayXI: currentXI(lineup.away, pairsFor('away')), + homeXI: mode === 'starters' ? startersXI(lineup.home) : currentXI(lineup.home, pairsFor('home')), + awayXI: mode === 'starters' ? startersXI(lineup.away) : currentXI(lineup.away, pairsFor('away')), homeNy: rowSpread(lineup.home.starters), awayNy: rowSpread(lineup.away.starters), }; - }, [lineup, events]); + }, [lineup, events, mode]); + + const cameOn = (tm: LineupTeam) => + tm.subs.filter((p) => p.subIn != null).sort((a, b) => (a.subIn ?? 0) - (b.subIn ?? 0)); const teamLabel = (name: string, tm: LineupTeam) => [name, tm.formation, tm.coach].filter(Boolean).join(' · '); @@ -257,11 +279,26 @@ export function LineupPitch({ lineup, home, away, events }: { {awayXI.slots.map((s) => )}
{teamLabel(away, lineup.away)}
- {(homeXI.off.length > 0 || awayXI.off.length > 0) && ( -
- - -
+ {mode === 'starters' ? ( + (cameOn(lineup.home).length > 0 || cameOn(lineup.away).length > 0) && ( +
+
+ + +
+
+ + +
+
+ ) + ) : ( + (homeXI.off.length > 0 || awayXI.off.length > 0) && ( +
+ + +
+ ) )} ); diff --git a/src/components/OddsChart.tsx b/src/components/OddsChart.tsx index c38501b..49df9e0 100644 --- a/src/components/OddsChart.tsx +++ b/src/components/OddsChart.tsx @@ -1,7 +1,7 @@ -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Legend } from 'recharts'; import { teamFlag } from '@/lib/teams'; -import { useFormat } from '@/lib/i18n'; +import { useFormat, useT } from '@/lib/i18n'; import type { OddsHistoryPoint } from '@/lib/types'; const PALETTE = [ @@ -15,11 +15,25 @@ const PALETTE = [ /** Championship odds over time — one line per top contender, a point per result * that moved the model. */ +type ChartSort = 'movers' | 'leaders'; + export function OddsChart({ history }: { history: OddsHistoryPoint[] }) { const { locale } = useFormat(); + const t = useT(); + // Default to the teams whose odds moved most at the latest update — what + // changed just now is more interesting than a static favorites list. + const [sort, setSort] = useState('movers'); const { data, teams } = useMemo(() => { const last = history[history.length - 1]; - const teams = (last?.top ?? []).slice(0, 6).map((t) => t.team); + const prev = history[history.length - 2]; + const latest = last?.top ?? []; + let picked = latest.slice(0, 6); + if (sort === 'movers' && prev) { + const before = new Map(prev.top.map((x) => [x.team, x.champion])); + const delta = (x: { team: string; champion: number }) => Math.abs(x.champion - (before.get(x.team) ?? x.champion)); + picked = [...latest].sort((a, b) => delta(b) - delta(a) || b.champion - a.champion).slice(0, 6); + } + const teams = picked.map((x) => x.team); const tag = locale === 'de' ? 'de-DE' : 'en-GB'; const data = history.map((h, i) => { const day = new Date(h.t).toLocaleDateString(tag, { day: 'numeric', month: 'short' }); @@ -28,10 +42,25 @@ export function OddsChart({ history }: { history: OddsHistoryPoint[] }) { return row; }); return { data, teams }; - }, [history, locale]); + }, [history, locale, sort]); return ( -
+
+
+
+ {([['movers', t.predict.titleRace.sortMovers], ['leaders', t.predict.titleRace.sortLeaders]] as const).map(([v, label]) => ( + + ))} +
+
+
@@ -74,6 +103,7 @@ export function OddsChart({ history }: { history: OddsHistoryPoint[] }) { ))} +
); } diff --git a/src/features/bracket/BracketPage.tsx b/src/features/bracket/BracketPage.tsx index 46a0566..19fee19 100644 --- a/src/features/bracket/BracketPage.tsx +++ b/src/features/bracket/BracketPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState, type ReactNode } from 'react'; +import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { Link } from '@tanstack/react-router'; import { Wand2 } from 'lucide-react'; import { PageHeader } from '@/components/ui/PageHeader'; @@ -8,7 +8,7 @@ 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 { championPath, effectiveBracket, modelBracket, projectedSeeds, prunePicks, type EffMatch, type Picks, type PickSide, type Seeds } from '@/lib/whatif'; import type { Fixture, MatchProbs, Stage, TeamSlot } from '@/lib/types'; type KnockoutStage = (typeof COLUMN_STAGES)[number]; @@ -158,16 +158,37 @@ export function BracketPage() { 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]); + // What-if plays out the projected bracket: group-derived slots are seeded + // with the model's most likely qualifiers so every round has teams. + const seeds = useMemo( + () => (whatIf && model ? projectedSeeds(fixtures, model.odds) : {}), + [whatIf, model, fixtures], + ); - /** 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); + const eff = useMemo(() => (whatIf ? effectiveBracket(fixtures, picks, seeds) : null), [whatIf, fixtures, picks, seeds]); + + const advFor = (f: Fixture, home: string, away: string): number | null => { + if (!ratings) return null; + const adv = hostAdvantage(home, away, f.venue, ratings.params.homeAdvElo); + const p = predictMatch(home, away, ratings, adv); return knockoutAdvanceProb(p.probs, p.eloHome, p.eloAway); }; + /** model probability the home side advances, for a (possibly hypothetical) pairing */ + const advHomeFor = (f: Fixture, m: EffMatch): number | null => + m.home && m.away ? advFor(f, m.home, m.away) : null; + + // Entering what-if pre-fills the whole bracket with the model's most + // likely run — a complete overview the user then edits by tapping. + const filled = useRef(false); + useEffect(() => { + if (!whatIf) { filled.current = false; return; } + if (!ratings || filled.current || !model) return; + filled.current = true; + setPicks(modelBracket(fixtures, projectedSeeds(fixtures, model.odds), advFor)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [whatIf, ratings, model, fixtures]); + const champ = useMemo(() => { if (!whatIf || !eff) return null; const path = championPath(fixtures, eff); @@ -189,7 +210,7 @@ export function BracketPage() { const next: Picks = { ...prev }; if (next[num] === side) delete next[num]; else next[num] = side; - return prunePicks(fixtures, next); + return prunePicks(fixtures, next, seeds); }); }; @@ -219,7 +240,7 @@ export function BracketPage() { {whatIf && Object.keys(picks).length > 0 && (