Starters-on-pitch, chart movers sort, pre-filled what-if bracket, vs/@ fix

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 13:46:27 +02:00
parent f78f599ed2
commit 15d955e65c
9 changed files with 303 additions and 59 deletions
+61 -24
View File
@@ -39,9 +39,10 @@ interface Slot {
/** The starter the slot belongs to (=== player until a swap). */ /** The starter the slot belongs to (=== player until a swap). */
starter: PitchPlayer; starter: PitchPlayer;
cameOnAt: number | null; cameOnAt: number | null;
/** The occupant went off but the replacement couldn't be paired — keep the /** Minute the occupant went off (▼ marker), when he stays on the pitch. */
* dot with a ▼ marker instead of guessing. */ offAt: number | null;
unresolvedOff: boolean; /** Fade the dot — live mode only, when a replacement couldn't be paired. */
dim: boolean;
} }
interface SubPair { in: string; out: string } 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[] } { function currentXI(team: LineupTeam, pairs: SubPair[]): { slots: Slot[]; off: PitchPlayer[] } {
const off: PitchPlayer[] = []; const off: PitchPlayer[] = [];
const used = new Set<PitchPlayer>(); const used = new Set<PitchPlayer>();
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) => { const swap = (slot: Slot, repl: PitchPlayer) => {
used.add(repl); used.add(repl);
off.push(slot.player); off.push(slot.player);
slot.player = repl; slot.player = repl;
slot.cameOnAt = repl.subIn; 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++) { for (let round = 0; round < 8; round++) {
let progress = false; let progress = false;
@@ -78,10 +79,22 @@ function currentXI(team: LineupTeam, pairs: SubPair[]): { slots: Slot[]; off: Pi
} }
if (!progress) break; 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 }; 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. */ /** Last name token, capped — fits under a dot on a 68-unit-wide pitch. */
function shortName(name: string): string { function shortName(name: string): string {
const last = name.trim().split(' ').pop() ?? name; 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 gk = ny <= 0.1;
const stroke = side === 'home' ? 'var(--app-accent)' : 'var(--app-info)'; const stroke = side === 'home' ? 'var(--app-accent)' : 'var(--app-info)';
return ( return (
<g opacity={slot.unresolvedOff ? 0.55 : 1}> <g opacity={slot.dim ? 0.55 : 1}>
<circle cx={cx} cy={cy} r={3.3} fill="var(--app-panel-2)" stroke={stroke} strokeWidth={0.6} /> <circle cx={cx} cy={cy} r={3.3} fill="var(--app-panel-2)" stroke={stroke} strokeWidth={0.6} />
<text x={cx} y={cy + 1.05} textAnchor="middle" fontSize={3} fontWeight={700} fill="var(--app-ink)" className="tnum"> <text x={cx} y={cy + 1.05} textAnchor="middle" fontSize={3} fontWeight={700} fill="var(--app-ink)" className="tnum">
{p.num ?? ''} {p.num ?? ''}
@@ -147,14 +160,14 @@ function PlayerDot({ slot, side, ny }: { slot: Slot; side: 'home' | 'away'; ny:
</text> </text>
</g> </g>
)} )}
{slot.unresolvedOff && ( {slot.offAt != null && (
<g> <g>
<polygon <polygon
points={`${markX - 1},${cy - 1.4} ${markX + 1},${cy - 1.4} ${markX},${cy + 0.4}`} points={`${markX - 1},${cy - 1.4} ${markX + 1},${cy - 1.4} ${markX},${cy + 0.4}`}
fill="var(--app-loss)" fill="var(--app-loss)"
/> />
<text x={markX} y={cy + 3.2} textAnchor="middle" fontSize={1.9} fill="var(--app-loss)" className="tnum"> <text x={markX} y={cy + 3.2} textAnchor="middle" fontSize={1.9} fill="var(--app-loss)" className="tnum">
{slot.player.subOut}' {slot.offAt}'
</text> </text>
</g> </g>
)} )}
@@ -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(); const t = useT();
if (!off.length) return null; if (!players.length) return null;
return ( return (
<div className="min-w-0"> <div className="min-w-0">
<div className={`mb-1 text-[10px] font-bold uppercase tracking-wide ${tone === 'home' ? 'text-accent' : 'text-info'}`}> <div className={`mb-1 text-[10px] font-bold uppercase tracking-wide ${tone === 'home' ? 'text-accent' : 'text-info'}`}>
{t.match.subbedOff} {label}
</div> </div>
<ul className="space-y-0.5 text-xs text-muted"> <ul className="space-y-0.5 text-xs text-muted">
{off.map((p) => ( {players.map((p) => {
const minute = dir === 'on' ? p.subIn : p.subOut;
return (
<li key={p.name} className="flex items-center gap-1.5"> <li key={p.name} className="flex items-center gap-1.5">
<span aria-hidden className="text-loss"></span> <span aria-hidden className={dir === 'on' ? 'text-success' : 'text-loss'}>{dir === 'on' ? '▲' : '▼'}</span>
<span className="truncate">{p.name}</span> <span className="truncate">{p.name}</span>
{p.subOut != null && <span className="tnum shrink-0 text-faint">{fmt(t.live.minute, { n: p.subOut })}</span>} {minute != null && <span className="tnum shrink-0 text-faint">{fmt(t.live.minute, { n: minute })}</span>}
{p.rating != null && <span className="tnum ml-auto shrink-0 text-faint">{p.rating.toFixed(1)}</span>} {p.rating != null && <span className="tnum ml-auto shrink-0 text-faint">{p.rating.toFixed(1)}</span>}
</li> </li>
))} );
})}
</ul> </ul>
</div> </div>
); );
} }
export function LineupPitch({ lineup, home, away, events }: { export function LineupPitch({ lineup, home, away, events, mode = 'current' }: {
lineup: MatchLineup; lineup: MatchLineup;
home: string; home: string;
away: string; away: string;
events: MatchLiveEvent[]; 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(); const t = useT();
@@ -233,12 +252,15 @@ export function LineupPitch({ lineup, home, away, events }: {
const pairsFor = (side: 'home' | 'away'): SubPair[] => const pairsFor = (side: 'home' | 'away'): SubPair[] =>
subs.filter((e) => e.side === side).map((e) => ({ in: e.title, out: e.subOut! })); subs.filter((e) => e.side === side).map((e) => ({ in: e.title, out: e.subOut! }));
return { return {
homeXI: currentXI(lineup.home, pairsFor('home')), homeXI: mode === 'starters' ? startersXI(lineup.home) : currentXI(lineup.home, pairsFor('home')),
awayXI: currentXI(lineup.away, pairsFor('away')), awayXI: mode === 'starters' ? startersXI(lineup.away) : currentXI(lineup.away, pairsFor('away')),
homeNy: rowSpread(lineup.home.starters), homeNy: rowSpread(lineup.home.starters),
awayNy: rowSpread(lineup.away.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) => const teamLabel = (name: string, tm: LineupTeam) =>
[name, tm.formation, tm.coach].filter(Boolean).join(' · '); [name, tm.formation, tm.coach].filter(Boolean).join(' · ');
@@ -257,11 +279,26 @@ export function LineupPitch({ lineup, home, away, events }: {
{awayXI.slots.map((s) => <PlayerDot key={s.starter.name} slot={s} side="away" ny={awayNy.get(s.starter) ?? 0.5} />)} {awayXI.slots.map((s) => <PlayerDot key={s.starter.name} slot={s} side="away" ny={awayNy.get(s.starter) ?? 0.5} />)}
</svg> </svg>
<div className="mt-1.5 truncate text-center text-xs font-bold text-info">{teamLabel(away, lineup.away)}</div> <div className="mt-1.5 truncate text-center text-xs font-bold text-info">{teamLabel(away, lineup.away)}</div>
{(homeXI.off.length > 0 || awayXI.off.length > 0) && ( {mode === 'starters' ? (
<div className="mt-3 grid grid-cols-2 gap-4"> (cameOn(lineup.home).length > 0 || cameOn(lineup.away).length > 0) && (
<SubbedOffStrip off={homeXI.off} tone="home" /> <div className="mt-3 space-y-3">
<SubbedOffStrip off={awayXI.off} tone="away" /> <div className="grid grid-cols-2 gap-4">
<BenchStrip players={cameOn(lineup.home)} tone="home" label={t.match.subbedOn} dir="on" />
<BenchStrip players={cameOn(lineup.away)} tone="away" label={t.match.subbedOn} dir="on" />
</div> </div>
<div className="grid grid-cols-2 gap-4">
<BenchStrip players={homeXI.off} tone="home" label={t.match.subbedOff} dir="off" />
<BenchStrip players={awayXI.off} tone="away" label={t.match.subbedOff} dir="off" />
</div>
</div>
)
) : (
(homeXI.off.length > 0 || awayXI.off.length > 0) && (
<div className="mt-3 grid grid-cols-2 gap-4">
<BenchStrip players={homeXI.off} tone="home" label={t.match.subbedOff} dir="off" />
<BenchStrip players={awayXI.off} tone="away" label={t.match.subbedOff} dir="off" />
</div>
)
)} )}
</div> </div>
); );
+34 -4
View File
@@ -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 { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Legend } from 'recharts';
import { teamFlag } from '@/lib/teams'; import { teamFlag } from '@/lib/teams';
import { useFormat } from '@/lib/i18n'; import { useFormat, useT } from '@/lib/i18n';
import type { OddsHistoryPoint } from '@/lib/types'; import type { OddsHistoryPoint } from '@/lib/types';
const PALETTE = [ const PALETTE = [
@@ -15,11 +15,25 @@ const PALETTE = [
/** Championship odds over time — one line per top contender, a point per result /** Championship odds over time — one line per top contender, a point per result
* that moved the model. */ * that moved the model. */
type ChartSort = 'movers' | 'leaders';
export function OddsChart({ history }: { history: OddsHistoryPoint[] }) { export function OddsChart({ history }: { history: OddsHistoryPoint[] }) {
const { locale } = useFormat(); 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<ChartSort>('movers');
const { data, teams } = useMemo(() => { const { data, teams } = useMemo(() => {
const last = history[history.length - 1]; 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 tag = locale === 'de' ? 'de-DE' : 'en-GB';
const data = history.map((h, i) => { const data = history.map((h, i) => {
const day = new Date(h.t).toLocaleDateString(tag, { day: 'numeric', month: 'short' }); const day = new Date(h.t).toLocaleDateString(tag, { day: 'numeric', month: 'short' });
@@ -28,9 +42,24 @@ export function OddsChart({ history }: { history: OddsHistoryPoint[] }) {
return row; return row;
}); });
return { data, teams }; return { data, teams };
}, [history, locale]); }, [history, locale, sort]);
return ( return (
<div className="w-full">
<div className="mb-2 flex justify-end">
<div className="flex overflow-hidden rounded-lg border border-line text-xs font-medium">
{([['movers', t.predict.titleRace.sortMovers], ['leaders', t.predict.titleRace.sortLeaders]] as const).map(([v, label]) => (
<button
key={v}
type="button"
onClick={() => setSort(v)}
className={v === sort ? 'bg-accent-glow px-3 py-1 font-bold text-accent' : 'px-3 py-1 text-muted hover:bg-elevated hover:text-ink'}
>
{label}
</button>
))}
</div>
</div>
<div className="h-72 w-full"> <div className="h-72 w-full">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 8, right: 16, bottom: 4, left: -8 }}> <LineChart data={data} margin={{ top: 8, right: 16, bottom: 4, left: -8 }}>
@@ -75,5 +104,6 @@ export function OddsChart({ history }: { history: OddsHistoryPoint[] }) {
</LineChart> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
</div>
); );
} }
+31 -10
View File
@@ -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 { Link } from '@tanstack/react-router';
import { Wand2 } from 'lucide-react'; import { Wand2 } from 'lucide-react';
import { PageHeader } from '@/components/ui/PageHeader'; import { PageHeader } from '@/components/ui/PageHeader';
@@ -8,7 +8,7 @@ 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 { hostAdvantage } from '@/lib/model/hosts';
import { knockoutAdvanceProb, predictMatch, type RatingsModel } from '@/lib/model/predict'; 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'; import type { Fixture, MatchProbs, Stage, TeamSlot } from '@/lib/types';
type KnockoutStage = (typeof COLUMN_STAGES)[number]; 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(() => {}); fetch('/data/ratings.json').then((r) => r.json()).then((d: RatingsModel) => setRatings(d)).catch(() => {});
}, [whatIf, ratings]); }, [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<Seeds>(
() => (whatIf && model ? projectedSeeds(fixtures, model.odds) : {}),
[whatIf, model, fixtures],
);
/** model probability the home side advances, for a (possibly hypothetical) pairing */ const eff = useMemo(() => (whatIf ? effectiveBracket(fixtures, picks, seeds) : null), [whatIf, fixtures, picks, seeds]);
const advHomeFor = (f: Fixture, m: EffMatch): number | null => {
if (!ratings || !m.home || !m.away) return null; const advFor = (f: Fixture, home: string, away: string): number | null => {
const adv = hostAdvantage(m.home, m.away, f.venue, ratings.params.homeAdvElo); if (!ratings) return null;
const p = predictMatch(m.home, m.away, ratings, adv); 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); 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(() => { const champ = useMemo(() => {
if (!whatIf || !eff) return null; if (!whatIf || !eff) return null;
const path = championPath(fixtures, eff); const path = championPath(fixtures, eff);
@@ -189,7 +210,7 @@ export function BracketPage() {
const next: Picks = { ...prev }; const next: Picks = { ...prev };
if (next[num] === side) delete next[num]; if (next[num] === side) delete next[num];
else next[num] = side; 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 && ( {whatIf && Object.keys(picks).length > 0 && (
<button <button
type="button" type="button"
onClick={() => setPicks({})} onClick={() => setPicks(model ? modelBracket(fixtures, projectedSeeds(fixtures, model.odds), advFor) : {})}
className="rounded-md border border-line px-3 py-1.5 text-sm font-medium text-muted hover:bg-elevated hover:text-ink" 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} {t.bracket.reset}
+1 -1
View File
@@ -434,7 +434,7 @@ export function MatchPreviewPage() {
<Card> <Card>
<CardHeader className="flex items-center gap-2"><Shirt size={16} className="text-accent" /><span className="font-display font-bold text-ink">{hasScore ? t.match.lineups : t.match.predictedLineups}</span></CardHeader> <CardHeader className="flex items-center gap-2"><Shirt size={16} className="text-accent" /><span className="font-display font-bold text-ink">{hasScore ? t.match.lineups : t.match.predictedLineups}</span></CardHeader>
<CardBody> <CardBody>
<LineupPitch lineup={rich.lineup} home={homeName} away={awayName} events={preview.events} /> <LineupPitch lineup={rich.lineup} home={homeName} away={awayName} events={preview.events} mode={isLive ? 'current' : 'starters'} />
<p className="mt-2 text-xs text-faint">{hasScore ? t.match.xgAttribution : t.match.predictedLineupsNote}</p> <p className="mt-2 text-xs text-faint">{hasScore ? t.match.xgAttribution : t.match.predictedLineupsNote}</p>
</CardBody> </CardBody>
</Card> </Card>
+7 -1
View File
@@ -23,9 +23,15 @@ function FixtureRow({ f, team }: { f: Fixture; team: string }) {
return ( return (
<Link to="/match/$num" params={{ num: String(f.num) }} className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-elevated"> <Link to="/match/$num" params={{ num: String(f.num) }} className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-elevated">
<span className="w-10 text-xs text-faint">{f.group ? fmt(t.team.groupShort, { group: f.group }) : f.stage !== 'group' ? t.team.stageShort[f.stage] : f.round}</span> <span className="w-10 text-xs text-faint">{f.group ? fmt(t.team.groupShort, { group: f.group }) : f.stage !== 'group' ? t.team.stageShort[f.stage] : f.round}</span>
<span className="text-faint">{isHome ? t.common.vs : t.team.awayIndicator}</span> <span className="text-faint">{t.common.vs}</span>
<span aria-hidden className="grid w-5 place-items-center">{opp.team ? teamFlag(opp.team) : <Shield size={14} className="text-faint" />}</span> <span aria-hidden className="grid w-5 place-items-center">{opp.team ? teamFlag(opp.team) : <Shield size={14} className="text-faint" />}</span>
<span className="truncate text-ink">{opp.label}</span> <span className="truncate text-ink">{opp.label}</span>
<span
className="shrink-0 rounded bg-elevated px-1 py-px text-[10px] font-bold uppercase text-faint"
title={isHome ? t.team.homeGame : t.team.awayGame}
>
{isHome ? t.team.homeShort : t.team.awayShort}
</span>
<span className="ml-auto tnum text-faint"> <span className="ml-auto tnum text-faint">
{done && us != null ? <span className="text-ink">{us}{them}</span> : `${kickoffDay(f.kickoff)} ${kickoffTime(f.kickoff)}`} {done && us != null ? <span className="text-ink">{us}{them}</span> : `${kickoffDay(f.kickoff)} ${kickoffTime(f.kickoff)}`}
</span> </span>
+8 -2
View File
@@ -130,6 +130,7 @@ export const de: Dict = {
assist: "Vorlage · {name}", assist: "Vorlage · {name}",
noGoalsYet: "Noch keine Tore.", noGoalsYet: "Noch keine Tore.",
subbedOff: "Ausgewechselt", subbedOff: "Ausgewechselt",
subbedOn: "Eingewechselt",
predictedLineups: "Voraussichtliche Aufstellungen", predictedLineups: "Voraussichtliche Aufstellungen",
predictedLineupsNote: "Voraussichtliche Startelf laut FotMob — die offiziellen Aufstellungen kommen meist rund eine Stunde vor Anstoß.", predictedLineupsNote: "Voraussichtliche Startelf laut FotMob — die offiziellen Aufstellungen kommen meist rund eine Stunde vor Anstoß.",
predictedVsActual: "Modell-Prognose vor Anstoß: {ph}{pa} · Endstand {ah}{aa}", predictedVsActual: "Modell-Prognose vor Anstoß: {ph}{pa} · Endstand {ah}{aa}",
@@ -227,7 +228,7 @@ export const de: Dict = {
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?", 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.", whatIfHint: "Vorausgefüllt mit dem wahrscheinlichsten Turnierbaum laut Modell — tippe auf ein Team, um eine Auswahl zu ändern. Echte Ergebnisse bleiben fest; die Prozente sind die Modell-Sicht auf jede Paarung.",
reset: "Auswahl zurücksetzen", reset: "Auswahl zurücksetzen",
champion: "Dein Weltmeister: {team}", champion: "Dein Weltmeister: {team}",
pathChance: "Modell-Chance für genau diesen Lauf: {pct}", pathChance: "Modell-Chance für genau diesen Lauf: {pct}",
@@ -255,6 +256,8 @@ export const de: Dict = {
}, },
titleRace: { titleRace: {
title: "Titelrennen im Zeitverlauf", title: "Titelrennen im Zeitverlauf",
sortMovers: "Größte Bewegungen",
sortLeaders: "Titelfavoriten",
empty: "Diese Grafik füllt sich, sobald Ergebnisse reinkommen. Nach jedem beendeten Spiel werden die Teams neu bewertet und die Simulation läuft erneut.", empty: "Diese Grafik füllt sich, sobald Ergebnisse reinkommen. Nach jedem beendeten Spiel werden die Teams neu bewertet und die Simulation läuft erneut.",
}, },
fullOddsHeading: "Alle Chancen — Gruppe, K.o.-Runde & Titel", fullOddsHeading: "Alle Chancen — Gruppe, K.o.-Runde & Titel",
@@ -464,7 +467,10 @@ export const de: Dict = {
eloHistory: "Elo-Wertung im Zeitverlauf", eloHistory: "Elo-Wertung im Zeitverlauf",
allTimeTopScorers: "Beste Torschützen aller Zeiten", allTimeTopScorers: "Beste Torschützen aller Zeiten",
groupShort: "Gr. {group}", groupShort: "Gr. {group}",
awayIndicator: "bei", homeShort: "H",
awayShort: "A",
homeGame: "Heimspiel (im Gastgeberland gesetzt)",
awayGame: "Auswärtsspiel",
resultShort: { resultShort: {
win: "S", win: "S",
draw: "U", draw: "U",
+8 -2
View File
@@ -129,6 +129,7 @@ export const en = {
assist: "Assist · {name}", assist: "Assist · {name}",
noGoalsYet: "No goals yet.", noGoalsYet: "No goals yet.",
subbedOff: "Subbed off", subbedOff: "Subbed off",
subbedOn: "Subbed on",
predictedLineups: "Predicted lineups", predictedLineups: "Predicted lineups",
predictedLineupsNote: "FotMob's projected starting XIs — the official lineups usually arrive about an hour before kickoff.", predictedLineupsNote: "FotMob's projected starting XIs — the official lineups usually arrive about an hour before kickoff.",
predictedVsActual: "Model's pre-match prediction: {ph}{pa} · final score {ah}{aa}", predictedVsActual: "Model's pre-match prediction: {ph}{pa} · final score {ah}{aa}",
@@ -226,7 +227,7 @@ export const en = {
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?", 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.", whatIfHint: "Pre-filled with the model's most likely bracket — tap any team to change a pick. Real results stay fixed; percentages are the model's view of each pairing.",
reset: "Reset picks", reset: "Reset picks",
champion: "Your champion: {team}", champion: "Your champion: {team}",
pathChance: "Model chance of exactly this run: {pct}", pathChance: "Model chance of exactly this run: {pct}",
@@ -254,6 +255,8 @@ export const en = {
}, },
titleRace: { titleRace: {
title: "Title race over time", title: "Title race over time",
sortMovers: "Biggest movers",
sortLeaders: "Title favorites",
empty: "This chart fills in as results come in. After every finished match, the teams are re-rated and the simulation runs again.", empty: "This chart fills in as results come in. After every finished match, the teams are re-rated and the simulation runs again.",
}, },
fullOddsHeading: "Full odds — group, knockout & title", fullOddsHeading: "Full odds — group, knockout & title",
@@ -463,7 +466,10 @@ export const en = {
eloHistory: "Elo rating over time", eloHistory: "Elo rating over time",
allTimeTopScorers: "All-time top scorers", allTimeTopScorers: "All-time top scorers",
groupShort: "Grp {group}", groupShort: "Grp {group}",
awayIndicator: "@", homeShort: "H",
awayShort: "A",
homeGame: "Home game (host nation venue)",
awayGame: "Away game",
resultShort: { resultShort: {
win: "W", win: "W",
draw: "D", draw: "D",
+52 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { championPath, effectiveBracket, prunePicks, type Picks } from './whatif'; import { championPath, effectiveBracket, modelBracket, projectedSeeds, prunePicks, type Picks } from './whatif';
import type { Fixture } from './types'; import type { Fixture } from './types';
const slot = (team: string | null, placeholder: string | null = null) => const slot = (team: string | null, placeholder: string | null = null) =>
@@ -70,3 +70,54 @@ describe('championPath', () => {
expect(championPath(bracket(), effectiveBracket(bracket(), { 90: 'home' }))).toBeNull(); expect(championPath(bracket(), effectiveBracket(bracket(), { 90: 'home' }))).toBeNull();
}); });
}); });
describe('projectedSeeds + modelBracket', () => {
const slot = (team: string | null, placeholder: string | null) => ({ team, placeholder, label: team ?? placeholder ?? '' });
const groupFx = (num: number, group: string, home: string, away: string) => ({
num, stage: 'group' as const, group, round: '', groupRound: 1,
kickoff: '2026-06-11T00:00:00Z', venue: 'X',
home: slot(home, null), away: slot(away, null),
status: 'scheduled' as const, homeScore: null, awayScore: null, minute: null,
});
const koFx = (num: number, stage: 'r32' | 'r16', hp: string, ap: string) => ({
num, stage, group: null, round: '', groupRound: null,
kickoff: '2026-06-29T00:00:00Z', venue: 'X',
home: slot(null, hp), away: slot(null, ap),
status: 'scheduled' as const, homeScore: null, awayScore: null, minute: null,
});
const odds = (team: string, winGroup: number, qualify: number) => ({
team, winGroup, qualify, reachR16: 0, reachQF: 0, reachSF: 0, reachFinal: 0, champion: 0,
});
const fixtures = [
groupFx(1, 'A', 'Alpha', 'Beta'), groupFx(2, 'A', 'Gamma', 'Beta'),
groupFx(3, 'B', 'Delta', 'Echo'), groupFx(4, 'B', 'Foxtrot', 'Echo'),
koFx(73, 'r32', '1A', '2B'), koFx(74, 'r32', '1B', '3A/B'),
koFx(75, 'r16', 'W73', 'W74'),
];
const teamOdds = [
odds('Alpha', 0.7, 0.95), odds('Beta', 0.2, 0.6), odds('Gamma', 0.1, 0.3),
odds('Delta', 0.6, 0.9), odds('Echo', 0.3, 0.7), odds('Foxtrot', 0.1, 0.4),
];
it('seeds winners, runners-up and third-place slots from the odds', () => {
const seeds = projectedSeeds(fixtures, teamOdds);
expect(seeds['1A']).toBe('Alpha');
expect(seeds['2A']).toBe('Beta');
expect(seeds['1B']).toBe('Delta');
// best third by qualify between Gamma (0.3) and Foxtrot (0.4) → Foxtrot
expect(seeds['3A/B']).toBe('Foxtrot');
});
it('fills every resolvable pairing with the model-preferred side', () => {
const seeds = projectedSeeds(fixtures, teamOdds);
// favor the alphabetically-first team so outcomes are deterministic
const adv = (_f: unknown, home: string, away: string) => (home < away ? 0.8 : 0.2);
const picks = modelBracket(fixtures, seeds, adv);
expect(picks[73]).toBe('home'); // Alpha vs Echo → Alpha
expect(picks[74]).toBe('home'); // Delta vs Foxtrot → Delta
expect(picks[75]).toBe('home'); // Alpha vs Delta → Alpha
const eff = effectiveBracket(fixtures, picks, seeds);
expect(eff.get(75)?.winner).toBe('Alpha');
});
});
+95 -8
View File
@@ -1,7 +1,7 @@
// Pure what-if bracket logic: user picks propagate winners through the // Pure what-if bracket logic: user picks propagate winners through the
// knockout tree (placeholder codes 'W74'/'L101' reference earlier matches). // knockout tree (placeholder codes 'W74'/'L101' reference earlier matches).
// Real finished results always win over picks. // Real finished results always win over picks.
import type { Fixture } from './types'; import type { Fixture, TeamOdds } from './types';
export type PickSide = 'home' | 'away'; export type PickSide = 'home' | 'away';
export type Picks = Record<number, PickSide>; export type Picks = Record<number, PickSide>;
@@ -15,14 +15,20 @@ export interface EffMatch {
decided: boolean; decided: boolean;
} }
/** Resolve every knockout fixture's effective teams + winner under `picks`. */ /** Group-derived placeholder → team ('1A' → projected Group A winner). */
export function effectiveBracket(fixtures: Fixture[], picks: Picks): Map<number, EffMatch> { export type Seeds = Record<string, string>;
/** Resolve every knockout fixture's effective teams + winner under `picks`.
* `seeds` optionally fills group-derived placeholders ('1A', '3A/B/…') with
* projected teams so the bracket can be played out before the groups end. */
export function effectiveBracket(fixtures: Fixture[], picks: Picks, seeds: Seeds = {}): Map<number, EffMatch> {
const eff = new Map<number, EffMatch>(); const eff = new Map<number, EffMatch>();
const slotTeam = (f: Fixture, side: PickSide): string | null => { const slotTeam = (f: Fixture, side: PickSide): string | null => {
const slot = side === 'home' ? f.home : f.away; const slot = side === 'home' ? f.home : f.away;
if (slot.team) return slot.team; if (slot.team) return slot.team;
const m = /^([WL])(\d+)$/.exec(slot.placeholder ?? ''); const ph = slot.placeholder ?? '';
if (!m) return null; const m = /^([WL])(\d+)$/.exec(ph);
if (!m) return seeds[ph] ?? null;
const src = eff.get(Number(m[2])); const src = eff.get(Number(m[2]));
return src ? (m[1] === 'W' ? src.winner : src.loser) : null; return src ? (m[1] === 'W' ? src.winner : src.loser) : null;
}; };
@@ -51,9 +57,9 @@ export function effectiveBracket(fixtures: Fixture[], picks: Picks): Map<number,
/** Drop picks that no longer make sense (an upstream pick changed, so the /** 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. */ * picked team is not in this match anymore). Call after every pick change. */
export function prunePicks(fixtures: Fixture[], picks: Picks): Picks { export function prunePicks(fixtures: Fixture[], picks: Picks, seeds: Seeds = {}): Picks {
const out: Picks = {}; const out: Picks = {};
let eff = effectiveBracket(fixtures, picks); let eff = effectiveBracket(fixtures, picks, seeds);
// iterate to a fixed point: removing one stale pick can invalidate others // iterate to a fixed point: removing one stale pick can invalidate others
for (let pass = 0; pass < 6; pass++) { for (let pass = 0; pass < 6; pass++) {
let changed = false; let changed = false;
@@ -71,11 +77,92 @@ export function prunePicks(fixtures: Fixture[], picks: Picks): Picks {
} }
} }
if (!changed) break; if (!changed) break;
eff = effectiveBracket(fixtures, { ...out }); eff = effectiveBracket(fixtures, { ...out }, seeds);
} }
return out; return out;
} }
/**
* The model's projected group outcomes as bracket seeds: each group ranked by
* win-group odds (1st/2nd), the 8 best third-placed candidates by advance
* odds assigned to the third-place slots via backtracking over each slot's
* allowed-group set (FIFA's allocation constraints).
*/
export function projectedSeeds(fixtures: Fixture[], odds: TeamOdds[]): Seeds {
const groups = new Map<string, Set<string>>();
for (const f of fixtures) {
if (f.stage !== 'group' || !f.group) continue;
let set = groups.get(f.group);
if (!set) { set = new Set(); groups.set(f.group, set); }
if (f.home.team) set.add(f.home.team);
if (f.away.team) set.add(f.away.team);
}
const oddsBy = new Map(odds.map((o) => [o.team, o]));
const seeds: Seeds = {};
const thirds: { team: string; group: string; qualify: number }[] = [];
for (const [g, teams] of groups) {
const ranked = [...teams].sort((a, b) => (oddsBy.get(b)?.winGroup ?? 0) - (oddsBy.get(a)?.winGroup ?? 0));
if (ranked[0]) seeds[`1${g}`] = ranked[0];
if (ranked[1]) seeds[`2${g}`] = ranked[1];
if (ranked[2]) thirds.push({ team: ranked[2], group: g, qualify: oddsBy.get(ranked[2])?.qualify ?? 0 });
}
thirds.sort((a, b) => b.qualify - a.qualify);
const slots: { ph: string; allowed: Set<string> }[] = [];
for (const f of fixtures) {
if (f.stage === 'group') continue;
for (const slot of [f.home, f.away]) {
const ph = slot.placeholder ?? '';
if (/^3[A-L](\/[A-L])+$/.test(ph)) slots.push({ ph, allowed: new Set(ph.slice(1).split('/')) });
}
}
// Tightest slots first; per slot prefer the strongest remaining third.
const order = [...slots].sort((a, b) => a.allowed.size - b.allowed.size);
const used = new Set<string>();
const assign = new Map<string, string>();
const backtrack = (i: number): boolean => {
const s = order[i];
if (!s) return true;
for (const t of thirds) {
if (used.has(t.group) || !s.allowed.has(t.group)) continue;
used.add(t.group);
assign.set(s.ph, t.team);
if (backtrack(i + 1)) return true;
used.delete(t.group);
assign.delete(s.ph);
}
return false;
};
backtrack(0);
for (const [ph, team] of assign) seeds[ph] = team;
return seeds;
}
/**
* Fill every undecided knockout pairing with the model's preferred side —
* the "most likely bracket" baseline the user can then tap to change.
* `advHome` returns the model's chance the home slot advances (null while
* ratings load); pairings it can't judge stay unpicked.
*/
export function modelBracket(
fixtures: Fixture[],
seeds: Seeds,
advHome: (f: Fixture, home: string, away: string) => number | null,
): Picks {
const picks: Picks = {};
const ko = fixtures.filter((f) => f.stage !== 'group').sort((a, b) => a.num - b.num);
for (const f of ko) {
const eff = effectiveBracket(fixtures, picks, seeds);
const m = eff.get(f.num);
if (!m || m.decided || !m.home || !m.away) continue;
const p = advHome(f, m.home, m.away);
if (p == null) continue;
picks[f.num] = p >= 0.5 ? 'home' : 'away';
}
return picks;
}
/** The picked champion's path: the matches they were picked to win, in order. */ /** The picked champion's path: the matches they were picked to win, in order. */
export function championPath( export function championPath(
fixtures: Fixture[], fixtures: Fixture[],