Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions app/Domain/Standings/BestPlacedCalculator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

namespace App\Domain\Standings;

use App\Models\Group;
use App\Models\Stage;
use DomainException;
use Illuminate\Support\Collection;

/**
* Ranks the teams finishing in the same position across every group of a
* grouped stage — the "ranking of third-placed teams" table used by World
* Cup / Euro style tournaments where the best N of them advance alongside
* the automatic qualifiers.
*
* Each group's table is computed by the stage's regular standings
* calculator (so per-group tiebreakers, including head-to-head, still
* apply within the group). The cross-group comparison then uses only
* points → goal difference → goals for → name: head-to-head is meaningless
* across groups because the teams never met, which matches how FIFA and
* UEFA rank third-placed teams.
*
* Groups too small to have a team at the requested position are skipped
* rather than erroring, so a lopsided draw (one group of 3, others of 4)
* still produces a ranking of the teams that exist.
*/
class BestPlacedCalculator
{
public function __construct(private readonly StandingsRegistry $registry)
{
//
}

/**
* @return Collection<int, BestPlacedRow>
*/
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();
}
}
31 changes: 31 additions & 0 deletions app/Domain/Standings/BestPlacedRow.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

namespace App\Domain\Standings;

/**
* One row in a cross-group "best Nth-placed teams" ranking — a team's
* StandingRow from its own group table, tagged with the group it came from
* so the ranking can show (and later seed by) group identity.
*/
final readonly class BestPlacedRow
{
public function __construct(
public int $group_id,
public string $group_name,
public StandingRow $row,
) {
//
}

/**
* @return array<string, int|string>
*/
public function toArray(): array
{
return [
'group_id' => $this->group_id,
'group_name' => $this->group_name,
...$this->row->toArray(),
];
}
}
36 changes: 35 additions & 1 deletion app/Http/Controllers/StagesController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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,
Expand Down Expand Up @@ -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<int, array<string, mixed>>}
*/
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
Expand Down
19 changes: 15 additions & 4 deletions app/Http/Requests/StoreStageRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
];
}

Expand All @@ -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 <Select>
// payload, and only keep the key if format is GroupStage (silently
// drop it otherwise so it doesn't pollute the JSON column).
// Coerce config ints that arrive as strings from form payloads, and
// only keep each key when the format supports it (silently drop it
// otherwise so it doesn't pollute the JSON column).
$config = $this->input('config', []);
if (! is_array($config)) {
$config = [];
}

if (isset($config['legs_per_group']) && $this->input('format') === 'group_stage') {
$format = $this->input('format');

if (isset($config['legs_per_group']) && $format === 'group_stage') {
$config['legs_per_group'] = (int) $config['legs_per_group'];
} else {
unset($config['legs_per_group']);
}

if (! empty($config['best_placed_count']) && in_array($format, ['group_stage', 'conference'], true)) {
$config['best_placed_count'] = (int) $config['best_placed_count'];
} else {
unset($config['best_placed_count']);
}

$this->merge([
'config' => empty($config) ? null : $config,
]);
Expand All @@ -75,6 +84,8 @@ public function messages(): array
'name.unique' => 'A stage with that name already exists in this season.',
'ends_on.after_or_equal' => 'End date must be on or after the start date.',
'config.legs_per_group.in' => 'Legs per group must be either 1 (single round-robin) or 2 (home and away).',
'config.best_placed_count.min' => 'Best-placed qualifiers must be at least 1 (leave it blank for none).',
'config.best_placed_count.max' => 'Best-placed qualifiers cannot exceed 16.',
];
}
}
9 changes: 9 additions & 0 deletions app/Http/Requests/UpdateStageRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,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'],
];
}

Expand All @@ -62,6 +63,12 @@ protected function prepareForValidation(): void
unset($config['legs_per_group']);
}

if (! empty($config['best_placed_count']) && in_array($format, ['group_stage', 'conference'], true)) {
$config['best_placed_count'] = (int) $config['best_placed_count'];
} else {
unset($config['best_placed_count']);
}

$this->merge([
'config' => empty($config) ? null : $config,
]);
Expand All @@ -86,6 +93,8 @@ public function messages(): array
'name.unique' => 'A stage with that name already exists in this season.',
'ends_on.after_or_equal' => 'End date must be on or after the start date.',
'config.legs_per_group.in' => 'Legs per group must be either 1 (single round-robin) or 2 (home and away).',
'config.best_placed_count.min' => 'Best-placed qualifiers must be at least 1 (leave it blank for none).',
'config.best_placed_count.max' => 'Best-placed qualifiers cannot exceed 16.',
];
}
}
85 changes: 85 additions & 0 deletions resources/js/components/BestPlacedTable.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<script setup lang="ts">
interface BestPlacedRow {
group_id: number;
group_name: string;
team_id: number;
team_name: string;
team_acronym: string;
played: number;
won: number;
drawn: number;
lost: number;
goals_for: number;
goals_against: number;
goal_difference: number;
points: number;
form: string;
}

const props = defineProps<{
rows: BestPlacedRow[];
qualifyCount: number;
}>();

function qualifies(index: number): boolean {
return index < props.qualifyCount;
}

function isCutLine(index: number): boolean {
return index === props.qualifyCount - 1 && index < props.rows.length - 1;
}
</script>

<template>
<div class="overflow-hidden rounded-lg border">
<table class="w-full text-sm">
<thead class="bg-primary text-xs uppercase tracking-wider text-primary-foreground">
<tr>
<th class="px-3 py-2 text-right">#</th>
<th class="px-3 py-2 text-left">Team</th>
<th class="px-2 py-2 text-left">Group</th>
<th class="hidden px-2 py-2 text-right tabular-nums sm:table-cell">Pld</th>
<th class="hidden px-2 py-2 text-right tabular-nums md:table-cell">W</th>
<th class="hidden px-2 py-2 text-right tabular-nums md:table-cell">D</th>
<th class="hidden px-2 py-2 text-right tabular-nums md:table-cell">L</th>
<th class="hidden px-2 py-2 text-right tabular-nums lg:table-cell">GF</th>
<th class="hidden px-2 py-2 text-right tabular-nums lg:table-cell">GA</th>
<th class="px-2 py-2 text-right tabular-nums">GD</th>
<th class="px-3 py-2 text-right tabular-nums font-semibold">Pts</th>
</tr>
</thead>
<tbody class="divide-y">
<tr
v-for="(row, index) in rows"
:key="row.team_id"
class="border-l-2 hover:bg-muted/30"
:class="[
qualifies(index) ? 'border-l-volt bg-volt/5' : 'border-l-transparent',
isCutLine(index) ? 'border-b-2 border-b-volt/60' : '',
]"
>
<td class="px-3 py-2 text-right font-display text-base tabular-nums" :class="qualifies(index) ? 'font-bold' : 'text-muted-foreground'">
{{ index + 1 }}
</td>
<td class="px-3 py-2">
<span class="mr-2 inline-block w-10 rounded bg-muted px-1 text-center font-display text-xs font-semibold uppercase text-muted-foreground">
{{ row.team_acronym }}
</span>
<span :class="{ 'font-semibold': qualifies(index) }">{{ row.team_name }}</span>
</td>
<td class="px-2 py-2 text-xs text-muted-foreground">{{ row.group_name }}</td>
<td class="hidden px-2 py-2 text-right tabular-nums sm:table-cell">{{ row.played }}</td>
<td class="hidden px-2 py-2 text-right tabular-nums md:table-cell">{{ row.won }}</td>
<td class="hidden px-2 py-2 text-right tabular-nums md:table-cell">{{ row.drawn }}</td>
<td class="hidden px-2 py-2 text-right tabular-nums md:table-cell">{{ row.lost }}</td>
<td class="hidden px-2 py-2 text-right tabular-nums lg:table-cell">{{ row.goals_for }}</td>
<td class="hidden px-2 py-2 text-right tabular-nums lg:table-cell">{{ row.goals_against }}</td>
<td class="px-2 py-2 text-right tabular-nums" :class="{ 'text-emerald-600 dark:text-emerald-400': row.goal_difference > 0, 'text-rose-600 dark:text-rose-400': row.goal_difference < 0 }">
{{ row.goal_difference > 0 ? '+' : '' }}{{ row.goal_difference }}
</td>
<td class="px-3 py-2 text-right font-display text-base font-bold tabular-nums">{{ row.points }}</td>
</tr>
</tbody>
</table>
</div>
</template>
Loading
Loading