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
+52 -1
View File
@@ -1,5 +1,5 @@
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';
const slot = (team: string | null, placeholder: string | null = null) =>
@@ -70,3 +70,54 @@ describe('championPath', () => {
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');
});
});