From e67f6b073c6538ba1e87bb8ceeb45124e4bb962c Mon Sep 17 00:00:00 2001 From: yohlo Date: Sat, 7 Mar 2026 01:22:21 -0600 Subject: [PATCH] ready for regionals --- .../_authed/admin/tournaments/run.$id.tsx | 2 + .../routes/_authed/tournaments/$id.groups.tsx | 1 + .../bracket/components/bracket-view.tsx | 2 +- .../bracket/components/match-card.tsx | 9 ++ src/features/matches/server.ts | 117 +++++++++++++- .../components/group-stage-view.tsx | 149 +++++++++++++----- .../components/setup-group-stage.tsx | 17 +- src/features/tournaments/server.ts | 63 +++++++- .../tournaments/utils/group-config.ts | 88 ++++++----- 9 files changed, 353 insertions(+), 95 deletions(-) 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, - }); } }