From b2b0940bb5b09cbac5dbc6e5a2807ea82c830582 Mon Sep 17 00:00:00 2001 From: Yannick Lyn Fatt Date: Sun, 19 Jul 2026 10:10:33 -0400 Subject: [PATCH 1/2] Rank best Nth-placed teams across groups (World Cup style) Adds the first phase of stage-to-stage advancement: a cross-group ranking of the teams finishing just below the automatic qualification spots (e.g. the best third-placed teams), opt-in per stage via config.best_placed_count. - BestPlacedCalculator ranks each group's Nth-placed row across groups by points, goal difference, then goals for (h2h is meaningless across groups, matching FIFA/UEFA cross-group rules) - Stage page shows the ranking with a qualifying cut-line when enabled - Create/Edit stage forms gain "Advance per group" and "Best-placed qualifiers" fields for grouped formats - Ranked position derives from advances_count + 1 (default 3rd) Co-Authored-By: Claude Opus 4.8 --- app/Domain/Standings/BestPlacedCalculator.php | 64 +++++++ app/Domain/Standings/BestPlacedRow.php | 31 +++ app/Http/Controllers/StagesController.php | 36 +++- app/Http/Requests/StoreStageRequest.php | 19 +- app/Http/Requests/UpdateStageRequest.php | 9 + resources/js/components/BestPlacedTable.vue | 85 +++++++++ resources/js/pages/Stages/Create.vue | 21 +++ resources/js/pages/Stages/Edit.vue | 24 +++ resources/js/pages/Stages/Show.vue | 37 ++++ tests/Feature/Stages/StageBestPlacedTest.php | 177 ++++++++++++++++++ .../Standings/BestPlacedCalculatorTest.php | 153 +++++++++++++++ 11 files changed, 651 insertions(+), 5 deletions(-) create mode 100644 app/Domain/Standings/BestPlacedCalculator.php create mode 100644 app/Domain/Standings/BestPlacedRow.php create mode 100644 resources/js/components/BestPlacedTable.vue create mode 100644 tests/Feature/Stages/StageBestPlacedTest.php create mode 100644 tests/Unit/Standings/BestPlacedCalculatorTest.php diff --git a/app/Domain/Standings/BestPlacedCalculator.php b/app/Domain/Standings/BestPlacedCalculator.php new file mode 100644 index 0000000..31719b2 --- /dev/null +++ b/app/Domain/Standings/BestPlacedCalculator.php @@ -0,0 +1,64 @@ + + */ + public function calculate(Stage $stage, int $position): Collection + { + if (! $stage->format->hasGroups()) { + throw new DomainException( + "Stage [{$stage->id}] has format [{$stage->format->value}], which has no groups to rank best-placed teams across." + ); + } + + if ($position < 1) { + throw new DomainException("Best-placed position must be at least 1, got [{$position}]."); + } + + $calculator = $this->registry->for($stage->format); + + return $stage->groups + ->map(function (Group $group) use ($stage, $calculator, $position): ?BestPlacedRow { + $row = $calculator->calculate($stage, $group)->get($position - 1); + + return $row === null ? null : new BestPlacedRow($group->id, $group->name, $row); + }) + ->filter() + ->sort(fn (BestPlacedRow $a, BestPlacedRow $b): int => ($b->row->points <=> $a->row->points) + ?: ($b->row->goal_difference <=> $a->row->goal_difference) + ?: ($b->row->goals_for <=> $a->row->goals_for) + ?: strcasecmp($a->row->team_name, $b->row->team_name)) + ->values(); + } +} diff --git a/app/Domain/Standings/BestPlacedRow.php b/app/Domain/Standings/BestPlacedRow.php new file mode 100644 index 0000000..c68f2c7 --- /dev/null +++ b/app/Domain/Standings/BestPlacedRow.php @@ -0,0 +1,31 @@ + + */ + public function toArray(): array + { + return [ + 'group_id' => $this->group_id, + 'group_name' => $this->group_name, + ...$this->row->toArray(), + ]; + } +} diff --git a/app/Http/Controllers/StagesController.php b/app/Http/Controllers/StagesController.php index 846a43c..3263b2c 100644 --- a/app/Http/Controllers/StagesController.php +++ b/app/Http/Controllers/StagesController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App\Actions\GenerateFixtures; +use App\Domain\Standings\BestPlacedCalculator; use App\Domain\Standings\StandingsRegistry; use App\Enums\StageFormat; use App\Http\Requests\StoreStageRequest; @@ -42,7 +43,7 @@ public function store(StoreStageRequest $request, League $league, Season $season ->with('status', "Stage \"{$stage->name}\" created."); } - public function show(League $league, Season $season, Stage $stage, StandingsRegistry $standings): Response + public function show(League $league, Season $season, Stage $stage, StandingsRegistry $standings, BestPlacedCalculator $bestPlaced): Response { $this->ensureSeasonInLeague($league, $season); $this->ensureStageInSeason($season, $stage); @@ -63,6 +64,7 @@ public function show(League $league, Season $season, Stage $stage, StandingsRegi 'season' => $season->only(['id', 'name']), 'stage' => $stage, 'standings' => $this->buildStandings($stage, $standings), + 'bestPlaced' => $this->buildBestPlaced($stage, $bestPlaced), 'bracket' => $stage->format->isBracket() ? $this->buildBracket($stage) : null, 'can' => [ 'update' => request()->user()?->can('update', $stage) ?? false, @@ -111,6 +113,38 @@ private function buildStandings(Stage $stage, StandingsRegistry $standings): ?ar ]; } + /** + * Cross-group ranking of the teams finishing just below the automatic + * qualification spots (position = advances-per-group + 1, so with the + * default 2 qualifiers per group this is the third-placed table). Only + * built when the stage opts in via config.best_placed_count — the number + * of these teams that qualify for the next stage. + * + * @return null|array{position: int, qualify_count: int, rows: array>} + */ + private function buildBestPlaced(Stage $stage, BestPlacedCalculator $calculator): ?array + { + if (! $stage->format->hasGroups() || $stage->groups->isEmpty()) { + return null; + } + + $qualifyCount = (int) ($stage->config['best_placed_count'] ?? 0); + + if ($qualifyCount < 1) { + return null; + } + + $position = ($stage->advances_count ?? 2) + 1; + + return [ + 'position' => $position, + 'qualify_count' => $qualifyCount, + 'rows' => $calculator->calculate($stage, $position) + ->map(fn ($row) => $row->toArray()) + ->all(), + ]; + } + /** * Shape the knockout games into ordered rounds for the bracket view. Each * round carries a human label (Final / Semifinals / …) derived from its diff --git a/app/Http/Requests/StoreStageRequest.php b/app/Http/Requests/StoreStageRequest.php index 46db672..c37fae8 100644 --- a/app/Http/Requests/StoreStageRequest.php +++ b/app/Http/Requests/StoreStageRequest.php @@ -41,6 +41,7 @@ public function rules(): array 'advances_count' => ['nullable', 'integer', 'min:1'], 'config' => ['nullable', 'array'], 'config.legs_per_group' => ['sometimes', 'integer', 'in:1,2'], + 'config.best_placed_count' => ['sometimes', 'integer', 'min:1', 'max:16'], ]; } @@ -50,20 +51,28 @@ protected function prepareForValidation(): void 'order' => $this->input('order', 0), ]); - // Coerce legs_per_group from string to int when it comes from a +

+ How many teams from each group qualify automatically. Defaults to 2. +

+ + + +
+ + +

+ Extra spots for the best teams finishing just below the cut — e.g. 8 best third-placed teams. +

+ +
+ +
Knockout formats only generate round-1 fixtures up front. Later rounds get populated as winners advance.
diff --git a/resources/js/pages/Stages/Edit.vue b/resources/js/pages/Stages/Edit.vue index 41441d7..a297034 100644 --- a/resources/js/pages/Stages/Edit.vue +++ b/resources/js/pages/Stages/Edit.vue @@ -47,6 +47,7 @@ const props = defineProps<{ }>(); const currentLegs = (props.stage.config?.legs_per_group as 1 | 2 | undefined) ?? 1; +const currentBestPlaced = (props.stage.config?.best_placed_count as number | undefined) ?? null; const form = useForm({ name: props.stage.name, @@ -56,9 +57,12 @@ const form = useForm({ advances_count: props.stage.advances_count, config: { legs_per_group: currentLegs, + best_placed_count: currentBestPlaced, }, }); +const hasGroupedFormat = ['group_stage', 'conference'].includes(props.stage.format); + const formatLabel = props.formats.find((f) => f.value === props.stage.format)?.label ?? props.stage.format; const pageBreadcrumbs = computed(() => [ @@ -139,6 +143,26 @@ function submit() { +
+
+ + +

+ How many teams from each group qualify automatically. Defaults to 2. +

+ +
+ +
+ + +

+ Extra spots for the best teams finishing just below the cut — e.g. 8 best third-placed teams. +

+ +
+
+