Compare commits

9 Commits

Author SHA1 Message Date
yohlo
e67f6b073c ready for regionals
All checks were successful
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 44s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m46s
2026-03-07 01:22:21 -06:00
569ea8833b Merge pull request 'center' (#10) from development into main
All checks were successful
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 3m28s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 11s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 49s
Reviewed-on: #10
2026-03-02 01:13:07 -06:00
aff5fa2ea4 Merge pull request 'more pwa' (#9) from development into main
All checks were successful
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 3m10s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 7s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 51s
Reviewed-on: #9
2026-03-02 01:01:17 -06:00
3f125e5761 Merge pull request 'pwa' (#8) from development into main
All checks were successful
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m28s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 9s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 44s
Reviewed-on: #8
2026-03-02 00:49:50 -06:00
5305dc37e7 Merge pull request 'center text/whitespace prewrap desc' (#7) from development into main
All checks were successful
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m42s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 47s
Reviewed-on: #7
2026-03-02 00:00:16 -06:00
e51ff24944 Merge pull request 'fix' (#6) from development into main
All checks were successful
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 3m31s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 53s
Reviewed-on: #6
2026-03-01 21:47:34 -06:00
3baec5ac0f Merge pull request 'regionals' (#5) from development into main
Some checks failed
CI/CD Pipeline / Build and Push App Docker Image (push) Failing after 2m16s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 13s
CI/CD Pipeline / Deploy to Kubernetes (push) Has been skipped
Reviewed-on: #5
2026-03-01 21:23:52 -06:00
a54a74d7de Merge pull request 'development' (#4) from development into main
All checks were successful
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m16s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 53s
Reviewed-on: #4
2026-02-10 14:03:25 -06:00
7b95998b05 Merge pull request 'new prod env' (#3) from development into main
All checks were successful
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 1m43s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 13s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 46s
Reviewed-on: #3
2026-02-09 12:59:07 -06:00
9 changed files with 353 additions and 95 deletions

View File

@@ -107,6 +107,7 @@ function RouteComponent() {
tournamentId={tournament.id}
hasKnockoutBracket={knockoutBracketPopulated}
isRegional={tournament.regional}
groupConfig={tournament.group_config}
/>
<Divider />
<div>
@@ -122,6 +123,7 @@ function RouteComponent() {
tournamentId={tournament.id}
hasKnockoutBracket={knockoutBracketPopulated}
isRegional={tournament.regional}
groupConfig={tournament.group_config}
/>
) : (
<BracketView bracket={bracket} showControls groupConfig={tournament.group_config} />

View File

@@ -40,6 +40,7 @@ function RouteComponent() {
groups={tournament.groups || []}
matches={tournament.matches || []}
isRegional={tournament.regional}
groupConfig={tournament.group_config}
/>
</Container>
);

View File

@@ -38,7 +38,7 @@ const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls, groupC
</Text>
<Bracket rounds={bracket.winners} orders={orders} showControls={showControls} groupConfig={groupConfig} />
</div>
{bracket.losers && (
{bracket.losers && bracket.losers.length > 0 && bracket.losers.some(round => round.length > 0) && (
<div>
<Text fw={600} size="md" m={16}>
Losers Bracket

View File

@@ -40,6 +40,15 @@ export const MatchCard: React.FC<MatchCardProps> = ({
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;

View File

@@ -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<string, { teamId: string; wins: number; losses: number; cups_for: number; cups_against: number; cup_differential: number }>();
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 || [])

View File

@@ -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<GroupStageViewProps> = ({
tournamentId,
hasKnockoutBracket,
isRegional,
groupConfig,
}) => {
const queryClient = useQueryClient();
const [expandedTeams, setExpandedTeams] = useState<Record<string, boolean>>({});
@@ -239,6 +241,57 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
});
};
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<string>(), wildcardTeams: new Set<string>() };
const advancePerGroup = groupConfig.advance_per_group;
const qualifiedTeams = new Set<string>();
const wildcardTeams = new Set<string>();
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 (
<Box p="md">
@@ -321,7 +374,12 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
<Collapse in={expandedTeams[group.id]}>
<Stack gap={0}>
{standings.length > 0 ? (
standings.map((standing, index) => (
standings.map((standing, index) => {
const isQualified = advancingTeams.qualifiedTeams.has(standing.teamId);
const isWildcard = advancingTeams.wildcardTeams.has(standing.teamId);
const isAdvancing = isQualified || isWildcard;
return (
<MantineGroup
key={standing.teamId}
gap="sm"
@@ -331,16 +389,30 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
py="xs"
style={{
borderTop: index > 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',
}}
>
<Text size="sm" fw={700} c="dimmed" w={24} ta="center">
{index + 1}
</Text>
<TeamAvatar team={standing.team} size={28} radius="sm" isRegional={isRegional} />
<Text size="sm" fw={500} style={{ flex: 1 }} lineClamp={1}>
<Box style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} lineClamp={1}>
{standing.teamName}
</Text>
</Box>
<MantineGroup gap="xs" wrap="nowrap">
{isWildcard && (
<Badge size="xs" color="yellow" variant="light">
WC
</Badge>
)}
{isQualified && (
<Badge size="xs" variant="light">
Q
</Badge>
)}
<Text size="xs" c="dimmed" fw={500} miw={35} ta="center">
{standing.wins}-{standing.losses}
</Text>
@@ -358,7 +430,8 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
</Text>
</MantineGroup>
</MantineGroup>
))
);
})
) : (
<Text size="sm" c="dimmed" ta="center" py="md">
No teams assigned

View File

@@ -80,12 +80,16 @@ const SetupGroupStage: React.FC<SetupGroupStageProps> = ({
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<SetupGroupStageProps> = ({
seedLabels[seedIndex++] = `${groupName2} ${rankSuffix2}`;
}
for (let i = 0; i < wildcardsNeeded; i++) {
seedLabels[seedIndex++] = `Wildcard ${i + 1}`;
}
const ordersMap: Record<number, number> = {};
bracketTemplate.winners.forEach((round: any[]) => {
round.forEach((match: any) => {
@@ -265,10 +273,11 @@ const SetupGroupStage: React.FC<SetupGroupStageProps> = ({
<Box>
<Divider mb="lg" />
<Title order={3} ta="center" mb="md">
Knockout Bracket Preview ({knockoutTeamCount} Teams)
Knockout Bracket Preview ({selectedConfig?.knockout_size} Teams)
</Title>
<Text size="sm" c="dimmed" ta="center" mb="lg">
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' : ''}` : ''}
</Text>
<Box
style={{

View File

@@ -6,7 +6,6 @@ import { logger } from ".";
import { z } from "zod";
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
import { serverFnLoggingMiddleware } from "@/utils/activities";
import { fa } from "zod/v4/locales";
import brackets from "@/features/bracket/utils";
import { MatchInput } from "@/features/matches/types";
import { generateSingleEliminationBracket } from "./utils/bracket-generator";
@@ -540,10 +539,18 @@ export const generateKnockoutBracket = createServerFn()
}
const qualifiedTeams: { teamId: string; groupOrder: number; rank: number }[] = [];
const allStandings: { standing: GroupStanding; groupOrder: number }[] = [];
for (const group of groups) {
const standings = await calculateGroupStandings(group.id);
for (let i = 0; i < standings.length; i++) {
allStandings.push({
standing: standings[i],
groupOrder: group.order,
});
}
const topTeams = standings.slice(0, tournament.group_config.advance_per_group);
for (const standing of topTeams) {
qualifiedTeams.push({
@@ -582,7 +589,42 @@ export const generateKnockoutBracket = createServerFn()
if (team2) orderedTeamIds.push(team2);
}
const teamCount = orderedTeamIds.length;
let teamCount = orderedTeamIds.length;
const nextPowerOf2 = Math.pow(2, Math.ceil(Math.log2(teamCount)));
const wildcardsNeeded = nextPowerOf2 - teamCount;
if (wildcardsNeeded > 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[]) => {

View File

@@ -26,20 +26,25 @@ 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);
const groupOptions = new Set([numGroupsFloor, numGroupsCeil]);
for (const numGroups of groupOptions) {
if (numGroups < 2) continue;
if (remainder > numGroups) continue;
const baseTeamsPerGroup = Math.floor(teamCount / numGroups);
const groupsWithExtra = teamCount % numGroups;
const groupsWithExtra = remainder;
const minGroupSize = baseTeamsPerGroup;
const maxGroupSize = baseTeamsPerGroup + (groupsWithExtra > 0 ? 1 : 0);
if (minGroupSize < 3 || maxGroupSize > 6) continue;
const groupsAtBaseSize = numGroups - groupsWithExtra;
const minGroupSize = groupsAtBaseSize > 0 ? teamsPerGroup : teamsPerGroup + 1;
const matchesGuaranteed = minGroupSize - 1;
for (let advancePerGroup = 1; advancePerGroup <= Math.min(3, teamsPerGroup - 1); advancePerGroup++) {
for (let advancePerGroup = 1; advancePerGroup <= Math.min(3, minGroupSize - 1); advancePerGroup++) {
const teamsAdvancing = numGroups * advancePerGroup;
if (teamsAdvancing < 4 || teamsAdvancing > 32) continue;
@@ -51,13 +56,13 @@ export function calculateGroupConfigurations(teamCount: number): GroupConfigOpti
let totalGroupMatches = 0;
for (let i = 0; i < numGroups; i++) {
const groupSize = teamsPerGroup + (i < groupsWithExtra ? 1 : 0);
const groupSize = baseTeamsPerGroup + (i < groupsWithExtra ? 1 : 0);
totalGroupMatches += (groupSize * (groupSize - 1)) / 2;
}
const description = generateDescription({
num_groups: numGroups,
teams_per_group: teamsPerGroup,
teams_per_group: baseTeamsPerGroup,
groups_with_extra: groupsWithExtra,
advance_per_group: advancePerGroup,
matches_guaranteed: matchesGuaranteed,
@@ -67,7 +72,7 @@ export function calculateGroupConfigurations(teamCount: number): GroupConfigOpti
configs.push({
num_groups: numGroups,
teams_per_group: teamsPerGroup,
teams_per_group: baseTeamsPerGroup,
advance_per_group: advancePerGroup,
matches_guaranteed: matchesGuaranteed,
seeding_method: "random",
@@ -79,6 +84,7 @@ export function calculateGroupConfigurations(teamCount: number): GroupConfigOpti
});
}
}
}
const uniqueConfigs = new Map<string, GroupConfigOption>();