Group qualification scenarios: who's through, who needs what

Once a group reaches its closing stretch (≤3 open matches), every
remaining win/draw/loss combination is enumerated and ranked with the
real tiebreaker logic. Under each table: Top 2 secured / Top 2 with a
win / Still possible (n of N outcomes) / Can't reach the top 2 — with
an honest footnote that tight cases hinge on goal difference and third
place runs through the cross-group best-thirds race. Pure, tested
enumeration in src/lib/scenarios.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 01:01:42 +02:00
parent 8e6c9416fa
commit 005b4d72ca
5 changed files with 231 additions and 10 deletions
+68 -10
View File
@@ -1,9 +1,45 @@
import { useMemo } from 'react';
import { Link } from '@tanstack/react-router';
import { Users } from 'lucide-react';
import { PageHeader } from '@/components/ui/PageHeader';
import { GroupTable } from '@/components/GroupTable';
import { useTournamentStore } from '@/stores/tournamentStore';
import { useT } from '@/lib/i18n';
import { groupScenarios, type TeamScenario } from '@/lib/scenarios';
import { teamFlag } from '@/lib/teams';
import { fmt, useT } from '@/lib/i18n';
import type { Dict } from '@/lib/i18n';
function scenarioLabel(s: TeamScenario, t: Dict): { text: string; tone: string } {
if (s.guaranteed) return { text: t.groups.scThrough, tone: 'text-accent' };
if (!s.alive) return { text: t.groups.scOut, tone: 'text-faint line-through' };
if (s.safeWithWin) return { text: t.groups.scWin, tone: 'text-ink' };
return { text: fmt(t.groups.scAlive, { n: s.top2Count, total: s.totalCombos }), tone: 'text-muted' };
}
/** Final-round qualification scenarios under a group's table (top-2 only —
* third place runs through the cross-group best-thirds race). */
function Scenarios({ scenarios }: { scenarios: TeamScenario[] }) {
const t = useT();
return (
<div className="rounded-b-xl border-x border-b border-line bg-surface-2 px-3 py-2">
<div className="mb-1 text-[11px] font-bold uppercase tracking-wide text-faint">{t.groups.scenarios}</div>
<ul className="space-y-0.5">
{scenarios.map((s) => {
const { text, tone } = scenarioLabel(s, t);
return (
<li key={s.team} className="flex items-center justify-between gap-2 text-xs">
<span className="flex min-w-0 items-center gap-1.5 text-ink-soft">
<span aria-hidden>{teamFlag(s.team)}</span>
<span className="truncate">{s.team}</span>
</span>
<span className={`shrink-0 ${tone}`}>{text}</span>
</li>
);
})}
</ul>
</div>
);
}
export function GroupsPage() {
const t = useT();
@@ -11,6 +47,22 @@ export function GroupsPage() {
const tables = snapshot?.tables ?? {};
const groups = Object.keys(tables).sort();
const scenarioByGroup = useMemo(() => {
const out: Record<string, TeamScenario[]> = {};
if (!snapshot) return out;
for (const g of Object.keys(tables)) {
const rows = tables[g]!;
const sc = groupScenarios(
rows.map((r) => r.team),
snapshot.fixtures.filter((f) => f.stage === 'group' && f.group === g),
);
if (sc) out[g] = sc;
}
return out;
}, [snapshot, tables]);
const anyScenarios = Object.keys(scenarioByGroup).length > 0;
return (
<div>
<PageHeader
@@ -23,9 +75,12 @@ export function GroupsPage() {
}
/>
{groups.length ? (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<div className="grid items-start gap-4 md:grid-cols-2 xl:grid-cols-3">
{groups.map((g) => (
<GroupTable key={g} group={g} rows={tables[g]!} />
<div key={g}>
<GroupTable group={g} rows={tables[g]!} />
{scenarioByGroup[g] && <Scenarios scenarios={scenarioByGroup[g]} />}
</div>
))}
</div>
) : (
@@ -36,13 +91,16 @@ export function GroupsPage() {
</div>
)}
<div className="mt-5 flex items-center gap-4 text-xs text-faint">
<span className="inline-flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded bg-accent-glow ring-1 ring-accent-deep/40" /> {t.groups.legendTop2}
</span>
<span className="inline-flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded bg-gold/20 ring-1 ring-gold/40" /> {t.groups.legendThird}
</span>
<div className="mt-5 space-y-1.5 text-xs text-faint">
<div className="flex items-center gap-4">
<span className="inline-flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded bg-accent-glow ring-1 ring-accent-deep/40" /> {t.groups.legendTop2}
</span>
<span className="inline-flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded bg-gold/20 ring-1 ring-gold/40" /> {t.groups.legendThird}
</span>
</div>
{anyScenarios && <p>{t.groups.scNote}</p>}
</div>
</div>
);
+6
View File
@@ -148,6 +148,12 @@ export const de: Dict = {
allTeams: "Alle 48 Teams",
legendTop2: "Top 2 — kommen weiter",
legendThird: "Platz 3 — im Rennen um die besten Dritten",
scenarios: "Szenarien für die letzte Runde",
scThrough: "Top 2 sicher",
scWin: "Top 2 mit einem Sieg",
scAlive: "Noch möglich ({n}/{total} Ausgänge)",
scOut: "Top 2 nicht mehr erreichbar",
scNote: "Die Szenarien zählen Sieg/Unentschieden/Niederlage der verbleibenden Gruppenspiele; knappe Fälle können trotzdem an der Tordifferenz hängen. Platz 3 läuft über den gruppenübergreifenden Vergleich der besten Dritten.",
tableHeaderPldPts: "Sp. · Pkt.",
colTeam: "Team",
colPlayed: "Sp",
+6
View File
@@ -147,6 +147,12 @@ export const en = {
allTeams: "All 48 teams",
legendTop2: "Top 2 — advance",
legendThird: "3rd — still in the race for best third",
scenarios: "Final-round scenarios",
scThrough: "Top 2 secured",
scWin: "Top 2 with a win",
scAlive: "Still possible ({n}/{total} outcomes)",
scOut: "Can't reach the top 2",
scNote: "Scenarios count win/draw/loss outcomes of the remaining group matches; tight cases can still hinge on goal difference. Third place runs through the best-thirds race across all groups.",
tableHeaderPldPts: "Pld · Pts",
colTeam: "Team",
colPlayed: "P",
+71
View File
@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest';
import { groupScenarios } from './scenarios';
import type { Fixture } from './types';
const slot = (team: string) => ({ team, placeholder: null, label: team });
const fx = (num: number, home: string, away: string, hs: number | null, as: number | null): Fixture => ({
num, stage: 'group', group: 'A', round: 'Matchday', groupRound: null,
kickoff: `2026-06-${10 + num}T18:00:00Z`, venue: 'X',
home: slot(home), away: slot(away),
status: hs == null ? 'scheduled' : 'finished', homeScore: hs, awayScore: as, minute: null,
} as Fixture);
const TEAMS = ['A', 'B', 'C', 'D'];
describe('groupScenarios', () => {
it('returns null before the closing stretch (4+ open matches)', () => {
const fxs = [fx(1, 'A', 'B', 1, 0), fx(2, 'C', 'D', null, null), fx(3, 'A', 'C', null, null), fx(4, 'B', 'D', null, null), fx(5, 'A', 'D', null, null), fx(6, 'B', 'C', null, null)];
expect(groupScenarios(TEAMS, fxs)).toBeNull();
});
it('returns null when the group is finished', () => {
const fxs = [fx(1, 'A', 'B', 1, 0), fx(2, 'C', 'D', 0, 0), fx(3, 'A', 'C', 2, 0), fx(4, 'B', 'D', 1, 1), fx(5, 'A', 'D', 1, 0), fx(6, 'B', 'C', 0, 0)];
expect(groupScenarios(TEAMS, fxs)).toBeNull();
});
it('a team that won everything is guaranteed top-2 before the last round', () => {
// A beat B and C; final matchday: A-D and B-C remain → 9 combos
const fxs = [
fx(1, 'A', 'B', 2, 0), fx(2, 'C', 'D', 1, 0),
fx(3, 'A', 'C', 1, 0), fx(4, 'B', 'D', 0, 0),
fx(5, 'A', 'D', null, null), fx(6, 'B', 'C', null, null),
];
const sc = groupScenarios(TEAMS, fxs)!;
const a = sc.find((s) => s.team === 'A')!;
expect(a.totalCombos).toBe(9);
expect(a.guaranteed).toBe(true); // 6 pts; only one team can reach 6 besides A → A is at worst 2nd
const d = sc.find((s) => s.team === 'D')!;
expect(d.alive).toBe(true); // D can win and others break right
expect(d.guaranteed).toBe(false);
});
it('safeWithWin reflects winning the remaining match', () => {
const fxs = [
fx(1, 'A', 'B', 2, 0), fx(2, 'C', 'D', 1, 0),
fx(3, 'A', 'C', 1, 0), fx(4, 'B', 'D', 0, 0),
fx(5, 'A', 'D', null, null), fx(6, 'B', 'C', null, null),
];
const sc = groupScenarios(TEAMS, fxs)!;
// C on 3 pts: beats B in the last round → 6 pts; A guaranteed 1st-or-2nd…
const c = sc.find((s) => s.team === 'C')!;
expect(c.safeWithWin).not.toBeNull();
expect(c.alive).toBe(true);
});
it('totals are consistent', () => {
const fxs = [
fx(1, 'A', 'B', 2, 0), fx(2, 'C', 'D', 1, 0),
fx(3, 'A', 'C', 1, 0), fx(4, 'B', 'D', 0, 0),
fx(5, 'A', 'D', null, null), fx(6, 'B', 'C', null, null),
];
const sc = groupScenarios(TEAMS, fxs)!;
for (const s of sc) {
expect(s.top2Count).toBeGreaterThanOrEqual(0);
expect(s.top2Count).toBeLessThanOrEqual(s.totalCombos);
expect(s.alive).toBe(s.top2Count > 0);
expect(s.guaranteed).toBe(s.top2Count === s.totalCombos);
}
// exactly 2 qualify per combo → sum of counts = 2 × combos
expect(sc.reduce((sum, s) => sum + s.top2Count, 0)).toBe(2 * 9);
});
});
+80
View File
@@ -0,0 +1,80 @@
// Group qualification scenarios: enumerate every outcome combination of the
// remaining group matches (W/D/L with representative one-goal margins) and
// rank each hypothetical table. Top-2 is decidable inside a group; third place
// runs through the cross-group best-thirds race and is NOT claimed here.
import { rankGroup, type Result } from './standings';
import type { Fixture } from './types';
export interface TeamScenario {
team: string;
/** outcome combinations in which the team finishes in the top 2 */
top2Count: number;
totalCombos: number;
guaranteed: boolean;
alive: boolean;
/** top-2 in every combination where the team wins all its remaining matches
* (null when the team has nothing left to play) */
safeWithWin: boolean | null;
}
const OUTCOMES: [number, number][] = [[1, 0], [0, 0], [0, 1]];
/** Scenarios for one group, or null when enumeration isn't meaningful yet
* (nothing left to play, or more than 3 group matches still open). */
export function groupScenarios(teams: string[], groupFixtures: Fixture[]): TeamScenario[] | null {
const played: Result[] = [];
const remaining: { home: string; away: string }[] = [];
for (const f of groupFixtures) {
if (!f.home.team || !f.away.team) return null; // group slots must be known
if (f.status === 'finished' && f.homeScore != null && f.awayScore != null) {
played.push({ home: f.home.team, away: f.away.team, homeScore: f.homeScore, awayScore: f.awayScore });
} else {
remaining.push({ home: f.home.team, away: f.away.team });
}
}
if (remaining.length === 0 || remaining.length > 3) return null;
const total = 3 ** remaining.length;
const top2 = new Map<string, number>(teams.map((t) => [t, 0]));
const winCombos = new Map<string, { total: number; top2: number }>(teams.map((t) => [t, { total: 0, top2: 0 }]));
for (let combo = 0; combo < total; combo++) {
const assumed: Result[] = [];
const wonBy = new Set<string>();
const lostOrDrew = new Set<string>();
let c = combo;
for (const m of remaining) {
const [hs, as] = OUTCOMES[c % 3]!;
c = Math.floor(c / 3);
assumed.push({ home: m.home, away: m.away, homeScore: hs, awayScore: as });
if (hs > as) { wonBy.add(m.home); lostOrDrew.add(m.away); }
else if (as > hs) { wonBy.add(m.away); lostOrDrew.add(m.home); }
else { lostOrDrew.add(m.home); lostOrDrew.add(m.away); }
}
const table = rankGroup(teams, [...played, ...assumed]);
const topTwo = new Set(table.slice(0, 2).map((r) => r.team));
for (const t of teams) {
if (topTwo.has(t)) top2.set(t, top2.get(t)! + 1);
// a combo counts toward "with a win" when the team won every remaining match it plays in
const plays = remaining.some((m) => m.home === t || m.away === t);
if (plays && wonBy.has(t) && !lostOrDrew.has(t)) {
const w = winCombos.get(t)!;
w.total++;
if (topTwo.has(t)) w.top2++;
}
}
}
return teams.map((team) => {
const n = top2.get(team)!;
const w = winCombos.get(team)!;
return {
team,
top2Count: n,
totalCombos: total,
guaranteed: n === total,
alive: n > 0,
safeWithWin: w.total === 0 ? null : w.top2 === w.total,
};
});
}