diff --git a/src/features/groups/GroupsPage.tsx b/src/features/groups/GroupsPage.tsx index 676c96c..72ea4c5 100644 --- a/src/features/groups/GroupsPage.tsx +++ b/src/features/groups/GroupsPage.tsx @@ -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 ( +
+
{t.groups.scenarios}
+ +
+ ); +} 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 = {}; + 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 (
{groups.length ? ( -
+
{groups.map((g) => ( - +
+ + {scenarioByGroup[g] && } +
))}
) : ( @@ -36,13 +91,16 @@ export function GroupsPage() {
)} -
- - {t.groups.legendTop2} - - - {t.groups.legendThird} - +
+
+ + {t.groups.legendTop2} + + + {t.groups.legendThird} + +
+ {anyScenarios &&

{t.groups.scNote}

}
); diff --git a/src/lib/i18n/de.ts b/src/lib/i18n/de.ts index 1dc2455..d3449a6 100644 --- a/src/lib/i18n/de.ts +++ b/src/lib/i18n/de.ts @@ -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", diff --git a/src/lib/i18n/en.ts b/src/lib/i18n/en.ts index 1900bc2..78e23ee 100644 --- a/src/lib/i18n/en.ts +++ b/src/lib/i18n/en.ts @@ -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", diff --git a/src/lib/scenarios.test.ts b/src/lib/scenarios.test.ts new file mode 100644 index 0000000..2af81d8 --- /dev/null +++ b/src/lib/scenarios.test.ts @@ -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); + }); +}); diff --git a/src/lib/scenarios.ts b/src/lib/scenarios.ts new file mode 100644 index 0000000..84ca46b --- /dev/null +++ b/src/lib/scenarios.ts @@ -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(teams.map((t) => [t, 0])); + const winCombos = new Map(teams.map((t) => [t, { total: 0, top2: 0 }])); + + for (let combo = 0; combo < total; combo++) { + const assumed: Result[] = []; + const wonBy = new Set(); + const lostOrDrew = new Set(); + 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, + }; + }); +}