diff --git a/src/app/routes/_authed/admin/tournaments/run.$id.tsx b/src/app/routes/_authed/admin/tournaments/run.$id.tsx
index 0635e6a..06674fa 100644
--- a/src/app/routes/_authed/admin/tournaments/run.$id.tsx
+++ b/src/app/routes/_authed/admin/tournaments/run.$id.tsx
@@ -107,6 +107,7 @@ function RouteComponent() {
tournamentId={tournament.id}
hasKnockoutBracket={knockoutBracketPopulated}
isRegional={tournament.regional}
+ groupConfig={tournament.group_config}
/>
@@ -122,6 +123,7 @@ function RouteComponent() {
tournamentId={tournament.id}
hasKnockoutBracket={knockoutBracketPopulated}
isRegional={tournament.regional}
+ groupConfig={tournament.group_config}
/>
) : (
diff --git a/src/app/routes/_authed/tournaments/$id.groups.tsx b/src/app/routes/_authed/tournaments/$id.groups.tsx
index f0ea172..f95ed87 100644
--- a/src/app/routes/_authed/tournaments/$id.groups.tsx
+++ b/src/app/routes/_authed/tournaments/$id.groups.tsx
@@ -40,6 +40,7 @@ function RouteComponent() {
groups={tournament.groups || []}
matches={tournament.matches || []}
isRegional={tournament.regional}
+ groupConfig={tournament.group_config}
/>
);
diff --git a/src/features/bracket/components/bracket-view.tsx b/src/features/bracket/components/bracket-view.tsx
index 2046094..93cd177 100644
--- a/src/features/bracket/components/bracket-view.tsx
+++ b/src/features/bracket/components/bracket-view.tsx
@@ -38,7 +38,7 @@ const BracketView: React.FC = ({ bracket, showControls, groupC
- {bracket.losers && (
+ {bracket.losers && bracket.losers.length > 0 && bracket.losers.some(round => round.length > 0) && (
Losers Bracket
diff --git a/src/features/bracket/components/match-card.tsx b/src/features/bracket/components/match-card.tsx
index 97baa5b..6f17195 100644
--- a/src/features/bracket/components/match-card.tsx
+++ b/src/features/bracket/components/match-card.tsx
@@ -40,6 +40,15 @@ export const MatchCard: React.FC = ({
const numGroups = groupConfig.num_groups;
const advancePerGroup = groupConfig.advance_per_group;
+ const totalQualifiedTeams = numGroups * advancePerGroup;
+ const nextPowerOf2 = Math.pow(2, Math.ceil(Math.log2(totalQualifiedTeams)));
+ const wildcardsNeeded = nextPowerOf2 - totalQualifiedTeams;
+
+ if (seed > totalQualifiedTeams && wildcardsNeeded > 0) {
+ const wildcardNumber = seed - totalQualifiedTeams;
+ return `Wildcard ${wildcardNumber}`;
+ }
+
const pairIndex = Math.floor((seed - 1) / 2);
const isFirstInPair = (seed - 1) % 2 === 0;
diff --git a/src/features/matches/server.ts b/src/features/matches/server.ts
index ab06cf7..a131824 100644
--- a/src/features/matches/server.ts
+++ b/src/features/matches/server.ts
@@ -270,15 +270,120 @@ async function populateKnockoutBracketInternal(tournamentId: string, groupConfig
const orderedTeamIds: string[] = [];
const maxRank = groupConfig.advance_per_group;
+ const numGroups = groupConfig.num_groups;
- for (let rank = 1; rank <= maxRank; rank++) {
- const teamsAtRank = qualifiedTeams
- .filter(t => t.rank === rank)
- .sort((a, b) => a.groupOrder - b.groupOrder);
- orderedTeamIds.push(...teamsAtRank.map(t => t.teamId));
+ const teamsByGroup: string[][] = [];
+ for (let g = 0; g < numGroups; g++) {
+ teamsByGroup[g] = [];
}
- logger.info('Ordered team IDs', { orderedTeamIds });
+ for (const qualified of qualifiedTeams) {
+ teamsByGroup[qualified.groupOrder][qualified.rank - 1] = qualified.teamId;
+ }
+
+ const totalQualifiedTeams = numGroups * maxRank;
+ for (let i = 0; i < totalQualifiedTeams / 2; i++) {
+ const group1 = i % numGroups;
+ const rankIndex1 = Math.floor(i / numGroups);
+
+ const group2 = (i + 1) % numGroups;
+ const rankIndex2 = maxRank - 1 - rankIndex1;
+
+ const team1 = teamsByGroup[group1]?.[rankIndex1];
+ const team2 = teamsByGroup[group2]?.[rankIndex2];
+
+ if (team1) orderedTeamIds.push(team1);
+ if (team2) orderedTeamIds.push(team2);
+ }
+
+ const knockoutTeamCount = orderedTeamIds.length;
+ const nextPowerOf2 = Math.pow(2, Math.ceil(Math.log2(knockoutTeamCount)));
+ const wildcardsNeeded = nextPowerOf2 - knockoutTeamCount;
+
+ if (wildcardsNeeded > 0) {
+ logger.info('Wildcards needed', { knockoutTeamCount, nextPowerOf2, wildcardsNeeded });
+
+ const allNonQualifiedTeams: Array<{ teamId: string; wins: number; losses: number; cups_for: number; cups_against: number; cup_differential: number }> = [];
+ const qualifiedTeamIds = new Set(qualifiedTeams.map(t => t.teamId));
+
+ for (const group of groups) {
+ const groupMatches = await pbAdmin.getMatchesByGroup(group.id);
+ const completedMatches = groupMatches.filter(m => m.status === "ended");
+
+ const standings = new Map();
+
+ for (const team of group.teams || []) {
+ const teamId = typeof team === 'string' ? team : team.id;
+
+ if (qualifiedTeamIds.has(teamId)) continue;
+
+ standings.set(teamId, {
+ teamId,
+ wins: 0,
+ losses: 0,
+ cups_for: 0,
+ cups_against: 0,
+ cup_differential: 0,
+ });
+ }
+
+ for (const match of completedMatches) {
+ if (!match.home || !match.away) continue;
+
+ const homeStanding = standings.get(match.home.id);
+ const awayStanding = standings.get(match.away.id);
+
+ if (homeStanding) {
+ homeStanding.cups_for += match.home_cups;
+ homeStanding.cups_against += match.away_cups;
+ homeStanding.cup_differential = homeStanding.cups_for - homeStanding.cups_against;
+
+ if (match.home_cups > match.away_cups) {
+ homeStanding.wins++;
+ } else {
+ homeStanding.losses++;
+ }
+ }
+
+ if (awayStanding) {
+ awayStanding.cups_for += match.away_cups;
+ awayStanding.cups_against += match.home_cups;
+ awayStanding.cup_differential = awayStanding.cups_for - awayStanding.cups_against;
+
+ if (match.away_cups > match.home_cups) {
+ awayStanding.wins++;
+ } else {
+ awayStanding.losses++;
+ }
+ }
+ }
+
+ allNonQualifiedTeams.push(...Array.from(standings.values()));
+ }
+
+ allNonQualifiedTeams.sort((a, b) => {
+ if (b.wins !== a.wins) return b.wins - a.wins;
+ if (b.cup_differential !== a.cup_differential) return b.cup_differential - a.cup_differential;
+ if (b.cups_for !== a.cups_for) return b.cups_for - a.cups_for;
+ return a.teamId.localeCompare(b.teamId);
+ });
+
+ const wildcardTeams = allNonQualifiedTeams.slice(0, wildcardsNeeded);
+ const wildcardTeamIds = wildcardTeams.map(t => t.teamId);
+
+ orderedTeamIds.push(...wildcardTeamIds);
+
+ logger.info('Added wildcard teams', {
+ wildcardTeams: wildcardTeams.map(t => ({
+ teamId: t.teamId,
+ wins: t.wins,
+ cupDiff: t.cup_differential,
+ cupsFor: t.cups_for
+ }))
+ });
+ }
+
+ logger.info('Ordered team IDs (with wildcards)', { orderedTeamIds, totalTeams: orderedTeamIds.length });
const tournament = await pbAdmin.getTournament(tournamentId);
const knockoutMatches = (tournament.matches || [])
diff --git a/src/features/tournaments/components/group-stage-view.tsx b/src/features/tournaments/components/group-stage-view.tsx
index b008d61..8816dc4 100644
--- a/src/features/tournaments/components/group-stage-view.tsx
+++ b/src/features/tournaments/components/group-stage-view.tsx
@@ -1,8 +1,8 @@
import React, { useMemo, useState } from "react";
-import { Stack, Text, Card, Group as MantineGroup, Box, SimpleGrid, Tabs, Collapse, ActionIcon, Button, Alert } from "@mantine/core";
+import { Stack, Text, Card, Group as MantineGroup, Box, SimpleGrid, Tabs, Collapse, ActionIcon, Button, Alert, Badge } from "@mantine/core";
import { CaretCircleDownIcon, CaretCircleUpIcon } from "@phosphor-icons/react";
import { Match } from "@/features/matches/types";
-import { Group } from "../types";
+import { Group, GroupConfig } from "../types";
import GroupMatchCard from "./group-match-card";
import TeamAvatar from "@/components/team-avatar";
import { useServerMutation } from "@/lib/tanstack-query/hooks/use-server-mutation";
@@ -17,6 +17,7 @@ interface GroupStageViewProps {
tournamentId?: string;
hasKnockoutBracket?: boolean;
isRegional?: boolean;
+ groupConfig?: GroupConfig;
}
interface TeamStanding {
@@ -37,6 +38,7 @@ const GroupStageView: React.FC = ({
tournamentId,
hasKnockoutBracket,
isRegional,
+ groupConfig,
}) => {
const queryClient = useQueryClient();
const [expandedTeams, setExpandedTeams] = useState>({});
@@ -239,6 +241,57 @@ const GroupStageView: React.FC = ({
});
};
+ const allGroupStandings = useMemo(() => {
+ return sortedGroups.map((group) => ({
+ groupId: group.id,
+ groupOrder: group.order,
+ standings: getTeamStandings(group.id, group.teams || []),
+ }));
+ }, [sortedGroups, matchesByGroup]);
+
+ const advancingTeams = useMemo(() => {
+ if (!groupConfig) return { qualifiedTeams: new Set(), wildcardTeams: new Set() };
+
+ const advancePerGroup = groupConfig.advance_per_group;
+ const qualifiedTeams = new Set();
+ const wildcardTeams = new Set();
+
+ allGroupStandings.forEach(({ standings }) => {
+ standings.slice(0, advancePerGroup).forEach((standing) => {
+ qualifiedTeams.add(standing.teamId);
+ });
+ });
+
+ const totalQualified = qualifiedTeams.size;
+ const knockoutTeamCount = totalQualified;
+
+ const nextPowerOf2 = Math.pow(2, Math.ceil(Math.log2(knockoutTeamCount)));
+ const wildcardsNeeded = nextPowerOf2 - knockoutTeamCount;
+
+ if (wildcardsNeeded > 0) {
+ const allNonQualifiedTeams: TeamStanding[] = [];
+
+ allGroupStandings.forEach(({ standings }) => {
+ standings.slice(advancePerGroup).forEach((standing) => {
+ allNonQualifiedTeams.push(standing);
+ });
+ });
+
+ allNonQualifiedTeams.sort((a, b) => {
+ if (b.wins !== a.wins) return b.wins - a.wins;
+ if (b.cupDifference !== a.cupDifference) return b.cupDifference - a.cupDifference;
+ if (b.cupsFor !== a.cupsFor) return b.cupsFor - a.cupsFor;
+ return a.teamId.localeCompare(b.teamId);
+ });
+
+ allNonQualifiedTeams.slice(0, wildcardsNeeded).forEach((standing) => {
+ wildcardTeams.add(standing.teamId);
+ });
+ }
+
+ return { qualifiedTeams, wildcardTeams };
+ }, [allGroupStandings, groupConfig]);
+
if (sortedGroups.length === 0) {
return (
@@ -321,44 +374,64 @@ const GroupStageView: React.FC = ({
{standings.length > 0 ? (
- standings.map((standing, index) => (
- 0 ? '1px solid var(--mantine-color-default-border)' : 'none',
- }}
- >
-
- {index + 1}
-
-
-
- {standing.teamName}
-
-
-
- {standing.wins}-{standing.losses}
-
- 0 ? "green" : standing.cupDifference < 0 ? "red" : "dimmed"}
- fw={600}
- miw={30}
- ta="center"
- >
- {standing.cupDifference > 0 ? '+' : ''}{standing.cupDifference}
-
-
- {standing.cupsFor}/{standing.cupsAgainst}
+ standings.map((standing, index) => {
+ const isQualified = advancingTeams.qualifiedTeams.has(standing.teamId);
+ const isWildcard = advancingTeams.wildcardTeams.has(standing.teamId);
+ const isAdvancing = isQualified || isWildcard;
+
+ return (
+ 0 ? '1px solid var(--mantine-color-default-border)' : 'none',
+ backgroundColor: isAdvancing ? 'var(--mantine-primary-color-light)' : undefined,
+ borderLeft: isAdvancing ? '3px solid var(--mantine-primary-color-filled)' : '3px solid transparent',
+ }}
+ >
+
+ {index + 1}
+
+
+
+ {standing.teamName}
+
+
+
+ {isWildcard && (
+
+ WC
+
+ )}
+ {isQualified && (
+
+ Q
+
+ )}
+
+ {standing.wins}-{standing.losses}
+
+ 0 ? "green" : standing.cupDifference < 0 ? "red" : "dimmed"}
+ fw={600}
+ miw={30}
+ ta="center"
+ >
+ {standing.cupDifference > 0 ? '+' : ''}{standing.cupDifference}
+
+
+ {standing.cupsFor}/{standing.cupsAgainst}
+
+
-
- ))
+ );
+ })
) : (
No teams assigned
diff --git a/src/features/tournaments/components/setup-group-stage.tsx b/src/features/tournaments/components/setup-group-stage.tsx
index 928ffa7..f041e2b 100644
--- a/src/features/tournaments/components/setup-group-stage.tsx
+++ b/src/features/tournaments/components/setup-group-stage.tsx
@@ -80,12 +80,16 @@ const SetupGroupStage: React.FC = ({
return null;
}
+ const nextPowerOf2 = Math.pow(2, Math.ceil(Math.log2(knockoutTeamCount)));
+ const bracketSize = nextPowerOf2;
+ const wildcardsNeeded = bracketSize - knockoutTeamCount;
+
let bracketTemplate: any;
- if (Object.keys(brackets).includes(knockoutTeamCount.toString())) {
- bracketTemplate = brackets[knockoutTeamCount as keyof typeof brackets];
+ if (Object.keys(brackets).includes(bracketSize.toString())) {
+ bracketTemplate = brackets[bracketSize as keyof typeof brackets];
} else {
try {
- bracketTemplate = generateSingleEliminationBracket(knockoutTeamCount);
+ bracketTemplate = generateSingleEliminationBracket(bracketSize);
} catch (error) {
return null;
}
@@ -113,6 +117,10 @@ const SetupGroupStage: React.FC = ({
seedLabels[seedIndex++] = `${groupName2} ${rankSuffix2}`;
}
+ for (let i = 0; i < wildcardsNeeded; i++) {
+ seedLabels[seedIndex++] = `Wildcard ${i + 1}`;
+ }
+
const ordersMap: Record = {};
bracketTemplate.winners.forEach((round: any[]) => {
round.forEach((match: any) => {
@@ -265,10 +273,11 @@ const SetupGroupStage: React.FC = ({
- Knockout Bracket Preview ({knockoutTeamCount} Teams)
+ Knockout Bracket Preview ({selectedConfig?.knockout_size} Teams)
Top {selectedConfig?.advance_per_group} team{selectedConfig?.advance_per_group !== 1 ? 's' : ''} from each group will advance
+ {selectedConfig?.wildcards_needed ? ` + ${selectedConfig.wildcards_needed} wildcard${selectedConfig.wildcards_needed > 1 ? 's' : ''}` : ''}
0) {
+ const qualifiedTeamIds = new Set(qualifiedTeams.map(t => t.teamId));
+ const wildcardCandidates = allStandings
+ .filter(s => !qualifiedTeamIds.has(s.standing.team.id))
+ .map(s => s.standing);
+
+ wildcardCandidates.sort((a, b) => {
+ if (b.wins !== a.wins) return b.wins - a.wins;
+ if (b.cup_differential !== a.cup_differential) return b.cup_differential - a.cup_differential;
+ if (b.cups_for !== a.cups_for) return b.cups_for - a.cups_for;
+ return a.team.id.localeCompare(b.team.id);
+ });
+
+ const wildcardTeams = wildcardCandidates.slice(0, wildcardsNeeded);
+ const wildcardTeamIds = wildcardTeams.map(t => t.team.id);
+
+ orderedTeamIds.push(...wildcardTeamIds);
+ teamCount = orderedTeamIds.length;
+
+ logger.info('Added wildcard teams to knockout bracket', {
+ tournamentId: data.tournamentId,
+ wildcardsNeeded,
+ wildcardTeams: wildcardTeams.map(t => ({
+ id: t.team.id,
+ name: t.team.name,
+ wins: t.wins,
+ cupDiff: t.cup_differential,
+ cupsFor: t.cups_for
+ }))
+ });
+ }
let bracketTemplate: any;
if (Object.keys(brackets).includes(teamCount.toString())) {
@@ -774,14 +816,25 @@ export const generateGroupStage = createServerFn()
}
const knockoutTeamCount = data.groupConfig.num_groups * data.groupConfig.advance_per_group;
+
+ const nextPowerOf2 = Math.pow(2, Math.ceil(Math.log2(knockoutTeamCount)));
+ const bracketSize = nextPowerOf2;
+
let bracketTemplate: any;
- if (Object.keys(brackets).includes(knockoutTeamCount.toString())) {
- bracketTemplate = brackets[knockoutTeamCount as keyof typeof brackets];
+ if (Object.keys(brackets).includes(bracketSize.toString())) {
+ bracketTemplate = brackets[bracketSize as keyof typeof brackets];
} else {
- bracketTemplate = generateSingleEliminationBracket(knockoutTeamCount);
+ bracketTemplate = generateSingleEliminationBracket(bracketSize);
}
+ logger.info('Creating knockout bracket template', {
+ tournamentId: data.tournamentId,
+ knockoutTeamCount,
+ bracketSize,
+ wildcardsNeeded: bracketSize - knockoutTeamCount
+ });
+
const knockoutMatches: any[] = [];
bracketTemplate.winners.forEach((round: any[]) => {
diff --git a/src/features/tournaments/utils/group-config.ts b/src/features/tournaments/utils/group-config.ts
index 4aa1582..e3dd516 100644
--- a/src/features/tournaments/utils/group-config.ts
+++ b/src/features/tournaments/utils/group-config.ts
@@ -26,57 +26,63 @@ export function calculateGroupConfigurations(teamCount: number): GroupConfigOpti
const configs: GroupConfigOption[] = [];
for (let teamsPerGroup = 3; teamsPerGroup <= Math.min(6, teamCount); teamsPerGroup++) {
- const numGroups = Math.floor(teamCount / teamsPerGroup);
- const remainder = teamCount % teamsPerGroup;
+ const numGroupsFloor = Math.floor(teamCount / teamsPerGroup);
+ const numGroupsCeil = Math.ceil(teamCount / teamsPerGroup);
- if (numGroups < 2) continue;
+ const groupOptions = new Set([numGroupsFloor, numGroupsCeil]);
- if (remainder > numGroups) continue;
+ for (const numGroups of groupOptions) {
+ if (numGroups < 2) continue;
- const groupsWithExtra = remainder;
+ const baseTeamsPerGroup = Math.floor(teamCount / numGroups);
+ const groupsWithExtra = teamCount % numGroups;
- const groupsAtBaseSize = numGroups - groupsWithExtra;
- const minGroupSize = groupsAtBaseSize > 0 ? teamsPerGroup : teamsPerGroup + 1;
- const matchesGuaranteed = minGroupSize - 1;
+ const minGroupSize = baseTeamsPerGroup;
+ const maxGroupSize = baseTeamsPerGroup + (groupsWithExtra > 0 ? 1 : 0);
- for (let advancePerGroup = 1; advancePerGroup <= Math.min(3, teamsPerGroup - 1); advancePerGroup++) {
- const teamsAdvancing = numGroups * advancePerGroup;
+ if (minGroupSize < 3 || maxGroupSize > 6) continue;
- if (teamsAdvancing < 4 || teamsAdvancing > 32) continue;
+ const matchesGuaranteed = minGroupSize - 1;
- const knockoutSize = getNextPowerOfTwo(teamsAdvancing);
- const wildcardsNeeded = knockoutSize - teamsAdvancing;
+ for (let advancePerGroup = 1; advancePerGroup <= Math.min(3, minGroupSize - 1); advancePerGroup++) {
+ const teamsAdvancing = numGroups * advancePerGroup;
- if (wildcardsNeeded > teamsAdvancing / 2) continue;
+ if (teamsAdvancing < 4 || teamsAdvancing > 32) continue;
- let totalGroupMatches = 0;
- for (let i = 0; i < numGroups; i++) {
- const groupSize = teamsPerGroup + (i < groupsWithExtra ? 1 : 0);
- totalGroupMatches += (groupSize * (groupSize - 1)) / 2;
+ const knockoutSize = getNextPowerOfTwo(teamsAdvancing);
+ const wildcardsNeeded = knockoutSize - teamsAdvancing;
+
+ if (wildcardsNeeded > teamsAdvancing / 2) continue;
+
+ let totalGroupMatches = 0;
+ for (let i = 0; i < numGroups; i++) {
+ const groupSize = baseTeamsPerGroup + (i < groupsWithExtra ? 1 : 0);
+ totalGroupMatches += (groupSize * (groupSize - 1)) / 2;
+ }
+
+ const description = generateDescription({
+ num_groups: numGroups,
+ teams_per_group: baseTeamsPerGroup,
+ groups_with_extra: groupsWithExtra,
+ advance_per_group: advancePerGroup,
+ matches_guaranteed: matchesGuaranteed,
+ knockout_size: knockoutSize,
+ wildcards_needed: wildcardsNeeded,
+ });
+
+ configs.push({
+ num_groups: numGroups,
+ teams_per_group: baseTeamsPerGroup,
+ advance_per_group: advancePerGroup,
+ matches_guaranteed: matchesGuaranteed,
+ seeding_method: "random",
+ groups_with_extra: groupsWithExtra,
+ knockout_size: knockoutSize,
+ wildcards_needed: wildcardsNeeded,
+ total_group_matches: totalGroupMatches,
+ description,
+ });
}
-
- const description = generateDescription({
- num_groups: numGroups,
- teams_per_group: teamsPerGroup,
- groups_with_extra: groupsWithExtra,
- advance_per_group: advancePerGroup,
- matches_guaranteed: matchesGuaranteed,
- knockout_size: knockoutSize,
- wildcards_needed: wildcardsNeeded,
- });
-
- configs.push({
- num_groups: numGroups,
- teams_per_group: teamsPerGroup,
- advance_per_group: advancePerGroup,
- matches_guaranteed: matchesGuaranteed,
- seeding_method: "random",
- groups_with_extra: groupsWithExtra,
- knockout_size: knockoutSize,
- wildcards_needed: wildcardsNeeded,
- total_group_matches: totalGroupMatches,
- description,
- });
}
}