From 6199afc6875d9c61f96d924da86849e2e52763b1 Mon Sep 17 00:00:00 2001 From: yohlo Date: Sun, 1 Mar 2026 16:21:27 -0600 Subject: [PATCH] regionals --- .../_authed/admin/tournaments/run.$id.tsx | 35 ++- .../_authed/tournaments/$id.bracket.tsx | 2 +- .../bracket/components/bracket-view.tsx | 12 +- src/features/bracket/components/bracket.tsx | 6 + .../bracket/components/match-card.tsx | 35 ++- .../bracket/components/match-slot.tsx | 16 +- src/features/matches/server.ts | 198 ++++++++++++- .../components/group-stage-view.tsx | 169 +++++------ .../components/setup-group-stage.tsx | 110 +++++++- src/features/tournaments/server.ts | 266 +++++++++++++++++- src/features/tournaments/types.ts | 14 +- .../tournaments/utils/bracket-generator.ts | 103 +++++++ src/lib/pocketbase/services/groups.ts | 8 + src/lib/pocketbase/services/matches.ts | 12 + 14 files changed, 849 insertions(+), 137 deletions(-) create mode 100644 src/features/tournaments/utils/bracket-generator.ts diff --git a/src/app/routes/_authed/admin/tournaments/run.$id.tsx b/src/app/routes/_authed/admin/tournaments/run.$id.tsx index d7e937b..3572323 100644 --- a/src/app/routes/_authed/admin/tournaments/run.$id.tsx +++ b/src/app/routes/_authed/admin/tournaments/run.$id.tsx @@ -7,7 +7,7 @@ import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; import SeedTournament from "@/features/tournaments/components/seed-tournament"; import SetupGroupStage from "@/features/tournaments/components/setup-group-stage"; import GroupStageView from "@/features/tournaments/components/group-stage-view"; -import { Container } from "@mantine/core"; +import { Container, Stack, Divider, Title } from "@mantine/core"; import { useMemo } from "react"; import { BracketData } from "@/features/bracket/types"; import { Match } from "@/features/matches/types"; @@ -45,10 +45,20 @@ function RouteComponent() { const { roles } = useAuth(); const isAdmin = roles?.includes('Admin') || false; - const isGroupStage = useMemo(() => { + const hasGroupStage = useMemo(() => { return tournament.matches?.some((match) => match.round === -1) || false; }, [tournament.matches]); + const hasKnockout = useMemo(() => { + return tournament.matches?.some((match) => match.round !== -1) || false; + }, [tournament.matches]); + + const knockoutBracketPopulated = useMemo(() => { + return tournament.matches?.some((match) => + match.round === 0 && match.lid >= 0 && (match.home || match.away) + ) || false; + }, [tournament.matches]); + const bracket: BracketData = useMemo(() => { if (!tournament.matches || tournament.matches.length === 0) { return { winners: [], losers: [] }; @@ -88,14 +98,31 @@ function RouteComponent() { { isAdmin && !tournament.regional && } {tournament.matches?.length ? ( - isGroupStage ? ( + hasGroupStage && hasKnockout ? ( + + + +
+ Knockout Bracket + +
+
+ ) : hasGroupStage ? ( ) : ( - + ) ) : ( tournament.regional === true ? ( diff --git a/src/app/routes/_authed/tournaments/$id.bracket.tsx b/src/app/routes/_authed/tournaments/$id.bracket.tsx index a562102..9c0a3c2 100644 --- a/src/app/routes/_authed/tournaments/$id.bracket.tsx +++ b/src/app/routes/_authed/tournaments/$id.bracket.tsx @@ -74,7 +74,7 @@ function RouteComponent() { return ( - + ); } diff --git a/src/features/bracket/components/bracket-view.tsx b/src/features/bracket/components/bracket-view.tsx index 309a531..2046094 100644 --- a/src/features/bracket/components/bracket-view.tsx +++ b/src/features/bracket/components/bracket-view.tsx @@ -7,10 +7,14 @@ import { Match } from "@/features/matches/types"; interface BracketViewProps { bracket: BracketData; - showControls?: boolean + showControls?: boolean; + groupConfig?: { + num_groups: number; + advance_per_group: number; + }; } -const BracketView: React.FC = ({ bracket, showControls }) => { +const BracketView: React.FC = ({ bracket, showControls, groupConfig }) => { const height = useAppShellHeight(); const orders = useMemo(() => { const map: Record = {}; @@ -32,14 +36,14 @@ const BracketView: React.FC = ({ bracket, showControls }) => { Winners Bracket - + {bracket.losers && (
Losers Bracket - +
)} diff --git a/src/features/bracket/components/bracket.tsx b/src/features/bracket/components/bracket.tsx index a3689f4..e038482 100644 --- a/src/features/bracket/components/bracket.tsx +++ b/src/features/bracket/components/bracket.tsx @@ -7,12 +7,17 @@ interface BracketProps { rounds: Match[][]; orders: Record; showControls?: boolean; + groupConfig?: { + num_groups: number; + advance_per_group: number; + }; } export const Bracket: React.FC = ({ rounds, orders, showControls, + groupConfig, }) => { const containerRef = useRef(null); const svgRef = useRef(null); @@ -132,6 +137,7 @@ export const Bracket: React.FC = ({ match={match} orders={orders} showControls={showControls} + groupConfig={groupConfig} /> ) diff --git a/src/features/bracket/components/match-card.tsx b/src/features/bracket/components/match-card.tsx index 3695513..923ac17 100644 --- a/src/features/bracket/components/match-card.tsx +++ b/src/features/bracket/components/match-card.tsx @@ -17,16 +17,38 @@ interface MatchCardProps { match: Match; orders: Record; showControls?: boolean; + groupConfig?: { + num_groups: number; + advance_per_group: number; + }; } export const MatchCard: React.FC = ({ match, orders, showControls, + groupConfig, }) => { const queryClient = useQueryClient(); const editSheet = useSheet(); const { playTrack, pause } = useSpotifyPlayback(); + + const getGroupLabel = useCallback((seed: number | undefined) => { + if (!seed || !groupConfig) return undefined; + + const groupNames = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']; + const teamsPerRank = groupConfig.num_groups; + + const rankIndex = Math.floor((seed - 1) / teamsPerRank); + const groupIndex = (seed - 1) % teamsPerRank; + + const rank = rankIndex + 1; + const groupName = groupNames[groupIndex] || `${groupIndex + 1}`; + const rankSuffix = rank === 1 ? '1st' : rank === 2 ? '2nd' : rank === 3 ? '3rd' : `${rank}th`; + + return `${groupName} ${rankSuffix}`; + }, [groupConfig]); + const homeSlot = useMemo( () => ({ from: orders[match.home_from_lid], @@ -39,8 +61,9 @@ export const MatchCard: React.FC = ({ match.home_cups !== undefined && match.away_cups !== undefined && match.home_cups > match.away_cups, + groupLabel: !match.home && match.home_seed ? getGroupLabel(match.home_seed) : undefined, }), - [match] + [match, getGroupLabel] ); const awaySlot = useMemo( () => ({ @@ -54,8 +77,9 @@ export const MatchCard: React.FC = ({ match.away_cups !== undefined && match.home_cups !== undefined && match.away_cups > match.home_cups, + groupLabel: !match.away && match.away_seed ? getGroupLabel(match.away_seed) : undefined, }), - [match] + [match, getGroupLabel] ); const showToolbar = useMemo( @@ -179,8 +203,11 @@ export const MatchCard: React.FC = ({ data: match.id, }); - // Play walkout sequence after starting the match - if (hasWalkoutData && match.home?.name && match.away?.name) { + // Skip announcements for regional tournaments + const isRegional = match.tournament?.regional === true; + + // Play walkout sequence after starting the match (only for non-regional tournaments) + if (!isRegional && hasWalkoutData && match.home?.name && match.away?.name) { try { const homeTeam = match.home as Team; const awayTeam = match.away as Team; diff --git a/src/features/bracket/components/match-slot.tsx b/src/features/bracket/components/match-slot.tsx index ba27457..e584be5 100644 --- a/src/features/bracket/components/match-slot.tsx +++ b/src/features/bracket/components/match-slot.tsx @@ -11,6 +11,7 @@ interface MatchSlotProps { seed?: number; cups?: number; isWinner?: boolean; + groupLabel?: string; } export const MatchSlot: React.FC = ({ @@ -19,7 +20,8 @@ export const MatchSlot: React.FC = ({ team, seed, cups, - isWinner + isWinner, + groupLabel }) => ( = ({ {team ? ( <> - 12 ? (team.name.length > 18 ? '10px' : '11px') : 'xs'} truncate style={{ minWidth: 0, flex: 1, lineHeight: "12px" }} @@ -43,18 +45,22 @@ export const MatchSlot: React.FC = ({ {isWinner && ( )} + ) : groupLabel ? ( + + {groupLabel} + ) : from ? ( {from_loser ? "Loser" : "Winner"} of Match {from} diff --git a/src/features/matches/server.ts b/src/features/matches/server.ts index a230d3f..ab06cf7 100644 --- a/src/features/matches/server.ts +++ b/src/features/matches/server.ts @@ -5,7 +5,7 @@ import { logger } from "@/lib/logger"; import { z } from "zod"; import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result"; import brackets from "@/features/bracket/utils"; -import { MatchInput } from "@/features/matches/types"; +import { Match, MatchInput } from "@/features/matches/types"; import { serverEvents } from "@/lib/events/emitter"; import { superTokensFunctionMiddleware } from "@/utils/supertokens"; import { PlayerInfo } from "../players/types"; @@ -164,6 +164,189 @@ export const startMatch = createServerFn() }) ); +export const populateKnockoutBracket = createServerFn() + .inputValidator(z.object({ + tournamentId: z.string(), + })) + .middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware]) + .handler(async ({ data: { tournamentId } }) => + toServerResult(async () => { + const tournament = await pbAdmin.getTournament(tournamentId); + if (!tournament) { + throw new Error("Tournament not found"); + } + + if (!tournament.group_config) { + throw new Error("Tournament must have group_config"); + } + + return await populateKnockoutBracketInternal(tournamentId, tournament.group_config); + }) + ); + +async function populateKnockoutBracketInternal(tournamentId: string, groupConfig: { num_groups: number; advance_per_group: number }) { + logger.info('Populating knockout bracket', { tournamentId }); + + const groups = await pbAdmin.getGroupsByTournament(tournamentId); + if (!groups || groups.length === 0) { + throw new Error("No groups found for tournament"); + } + + const qualifiedTeams: { teamId: string; groupOrder: number; rank: number }[] = []; + + for (const group of groups) { + logger.info('Processing group', { + groupId: group.id, + groupOrder: group.order, + teamsCount: group.teams?.length, + teams: group.teams + }); + + 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 || []) { + // group.teams can be either team objects or just team ID strings + const teamId = typeof team === 'string' ? team : team.id; + 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 && awayStanding) { + homeStanding.cups_for += match.home_cups; + homeStanding.cups_against += match.away_cups; + awayStanding.cups_for += match.away_cups; + awayStanding.cups_against += match.home_cups; + + if (match.home_cups > match.away_cups) { + homeStanding.wins++; + awayStanding.losses++; + } else { + awayStanding.wins++; + homeStanding.losses++; + } + + homeStanding.cup_differential = homeStanding.cups_for - homeStanding.cups_against; + awayStanding.cup_differential = awayStanding.cups_for - awayStanding.cups_against; + } + } + + const sortedStandings = Array.from(standings.values()).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; + return b.cups_for - a.cups_for; + }); + + const topTeams = sortedStandings.slice(0, groupConfig.advance_per_group); + logger.info('Top teams from group', { + groupId: group.id, + topTeams: topTeams.map(t => ({ teamId: t.teamId, wins: t.wins, cupDiff: t.cup_differential })) + }); + + topTeams.forEach((standing, index) => { + qualifiedTeams.push({ + teamId: standing.teamId, + groupOrder: group.order, + rank: index + 1, + }); + }); + } + + logger.info('Qualified teams', { qualifiedTeams }); + + const orderedTeamIds: string[] = []; + const maxRank = groupConfig.advance_per_group; + + 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)); + } + + logger.info('Ordered team IDs', { orderedTeamIds }); + + const tournament = await pbAdmin.getTournament(tournamentId); + const knockoutMatches = (tournament.matches || []) + .filter((m: Match) => m.round >= 0 && m.lid >= 0) + .sort((a: Match, b: Match) => a.lid - b.lid); + + const seedToTeamId = new Map(); + orderedTeamIds.forEach((teamId, index) => { + seedToTeamId.set(index + 1, teamId); + }); + + logger.info('Seed to team mapping', { + seedToTeamId: Array.from(seedToTeamId.entries()), + orderedTeamIds + }); + + let updatedCount = 0; + for (const match of knockoutMatches) { + if (match.round === 0) { + const updates: any = {}; + + if (match.home_seed) { + const teamId = seedToTeamId.get(match.home_seed); + logger.info('Looking up home seed', { + matchId: match.id, + home_seed: match.home_seed, + teamId + }); + if (teamId) { + updates.home = teamId; + } + } + + if (match.away_seed) { + const teamId = seedToTeamId.get(match.away_seed); + logger.info('Looking up away seed', { + matchId: match.id, + away_seed: match.away_seed, + teamId + }); + if (teamId) { + updates.away = teamId; + } + } + + if (updates.home && updates.away) { + updates.status = "ready"; + } else if (updates.home || updates.away) { + updates.status = "tbd"; + } + + if (Object.keys(updates).length > 0) { + logger.info('Updating match', { matchId: match.id, updates }); + await pbAdmin.updateMatch(match.id, updates); + updatedCount++; + } + } + } + + logger.info('Updated matches', { updatedCount, totalKnockoutMatches: knockoutMatches.length }); + + await pbAdmin.updateTournament(tournamentId, { + phase: "knockout" + }); + + logger.info('Knockout bracket populated successfully', { tournamentId }); +} + const endMatchSchema = z.object({ matchId: z.string(), home_cups: z.number(), @@ -190,19 +373,25 @@ export const endMatch = createServerFn() ot_count, }); + if (match.lid === -1) { + serverEvents.emit("match", { + type: "match", + matchId: match.id, + tournamentId: match.tournament.id + }); + return match; + } + const matchWinner = home_cups > away_cups ? match.home : match.away; const matchLoser = home_cups < away_cups ? match.home : match.away; if (!matchWinner || !matchLoser) throw new Error("Something went wrong"); - // winner -> where to send match winner to, loser same const { winner, loser } = await pbAdmin.getChildMatches(matchId); - // reset match check if (winner && winner.reset) { const awayTeamWon = match.away === matchWinner; if (!awayTeamWon) { - // Reset match is not necessary logger.info("Deleting reset match", { resetMatchId: winner.id, currentMatchId: match.id, @@ -214,7 +403,6 @@ export const endMatch = createServerFn() } } - // advance bracket if (winner) { await pbAdmin.updateMatch(winner.id, { [winner.home_from_lid === match.lid ? "home" : "away"]: matchWinner.id, diff --git a/src/features/tournaments/components/group-stage-view.tsx b/src/features/tournaments/components/group-stage-view.tsx index 9e13876..096a953 100644 --- a/src/features/tournaments/components/group-stage-view.tsx +++ b/src/features/tournaments/components/group-stage-view.tsx @@ -1,15 +1,21 @@ import React, { useMemo, useState } from "react"; -import { Stack, Text, Card, Group as MantineGroup, Box, SimpleGrid, Tabs, Collapse, ActionIcon } from "@mantine/core"; -import { CaretCircleDown, CaretCircleUp } from "@phosphor-icons/react"; +import { Stack, Text, Card, Group as MantineGroup, Box, SimpleGrid, Tabs, Collapse, ActionIcon, Button, Alert } from "@mantine/core"; +import { CaretCircleDownIcon, CaretCircleUpIcon } from "@phosphor-icons/react"; import { Match } from "@/features/matches/types"; import { Group } from "../types"; import GroupMatchCard from "./group-match-card"; import TeamAvatar from "@/components/team-avatar"; +import { useServerMutation } from "@/lib/tanstack-query/hooks/use-server-mutation"; +import { populateKnockoutBracket } from "@/features/matches/server"; +import { useQueryClient } from "@tanstack/react-query"; +import { tournamentKeys } from "../queries"; interface GroupStageViewProps { groups: Group[]; matches: Match[]; showControls?: boolean; + tournamentId?: string; + hasKnockoutBracket?: boolean; } interface TeamStanding { @@ -18,6 +24,8 @@ interface TeamStanding { team: any; wins: number; losses: number; + cupsFor: number; + cupsAgainst: number; cupDifference: number; } @@ -25,9 +33,33 @@ const GroupStageView: React.FC = ({ groups, matches, showControls, + tournamentId, + hasKnockoutBracket, }) => { + const queryClient = useQueryClient(); const [expandedTeams, setExpandedTeams] = useState>({}); + const populateKnockoutMutation = useServerMutation({ + mutationFn: populateKnockoutBracket, + successMessage: "Knockout bracket populated successfully!", + onSuccess: () => { + if (tournamentId) { + queryClient.invalidateQueries({ queryKey: tournamentKeys.details(tournamentId) }); + } + }, + }); + + const allGroupMatchesCompleted = useMemo(() => { + const groupMatches = matches.filter((match) => match.round === -1); + if (groupMatches.length === 0) return false; + return groupMatches.every((match) => match.status === "ended"); + }, [matches]); + + const handlePopulateKnockout = () => { + if (!tournamentId) return; + populateKnockoutMutation.mutate({ data: { tournamentId } }); + }; + const orderMatchesWithSpacing = (matches: Match[]): Match[] => { if (matches.length <= 1) return matches; @@ -99,6 +131,8 @@ const GroupStageView: React.FC = ({ team: team, wins: 0, losses: 0, + cupsFor: 0, + cupsAgainst: 0, cupDifference: 0, }); }); @@ -119,6 +153,11 @@ const GroupStageView: React.FC = ({ const homeCups = match.home_cups || 0; const awayCups = match.away_cups || 0; + homeStanding.cupsFor += homeCups; + homeStanding.cupsAgainst += awayCups; + awayStanding.cupsFor += awayCups; + awayStanding.cupsAgainst += homeCups; + homeStanding.cupDifference += homeCups - awayCups; awayStanding.cupDifference += awayCups - homeCups; @@ -133,7 +172,8 @@ const GroupStageView: React.FC = ({ return Array.from(standings.values()).sort((a, b) => { if (b.wins !== a.wins) return b.wins - a.wins; - return b.cupDifference - a.cupDifference; + if (b.cupDifference !== a.cupDifference) return b.cupDifference - a.cupDifference; + return b.cupsFor - a.cupsFor; }); }; @@ -147,104 +187,26 @@ const GroupStageView: React.FC = ({ ); } - if (sortedGroups.length === 1) { - const group = sortedGroups[0]; - const groupMatches = matchesByGroup.get(group.id) || []; - const standings = getTeamStandings(group.id, group.teams || []); - - return ( - - - - toggleTeams(group.id)} - > - - Standings ({standings.length}) - - - {expandedTeams[group.id] ? : } - - - - - {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} - > - {standing.cupDifference > 0 ? '+' : ''}{standing.cupDifference} - - - - )) - ) : ( - - No teams assigned - - )} - - - - - {groupMatches.length === 0 ? ( - - - No matches scheduled - - - ) : ( - - {groupMatches.map((match) => ( - - ))} - - )} - - - ); - } + const showGenerateKnockoutButton = showControls && tournamentId && !hasKnockoutBracket && allGroupMatchesCompleted; return ( - + + {showGenerateKnockoutButton && ( + }> + + All group matches are finished! Populate the knockout bracket to advance qualified teams. + + + + )} + {sortedGroups.map((group) => { const groupMatches = matchesByGroup.get(group.id) || []; @@ -291,7 +253,7 @@ const GroupStageView: React.FC = ({ Standings ({standings.length}) - {expandedTeams[group.id] ? : } + {expandedTeams[group.id] ? : } @@ -317,16 +279,21 @@ const GroupStageView: React.FC = ({ {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} + )) @@ -339,7 +306,6 @@ const GroupStageView: React.FC = ({ - {/* Matches Grid */} {groupMatches.length === 0 ? ( @@ -365,6 +331,7 @@ const GroupStageView: React.FC = ({ ); })} + ); }; diff --git a/src/features/tournaments/components/setup-group-stage.tsx b/src/features/tournaments/components/setup-group-stage.tsx index 42b2295..fd327bf 100644 --- a/src/features/tournaments/components/setup-group-stage.tsx +++ b/src/features/tournaments/components/setup-group-stage.tsx @@ -7,6 +7,9 @@ import { Select, LoadingOverlay, Alert, + Title, + Divider, + Box, } from "@mantine/core"; import { InfoIcon } from "@phosphor-icons/react"; import { useServerMutation } from "@/lib/tanstack-query/hooks/use-server-mutation"; @@ -21,6 +24,10 @@ import { import GroupPreview from "./group-preview"; import { useQueryClient } from "@tanstack/react-query"; import { tournamentKeys } from "../queries"; +import brackets from "@/features/bracket/utils"; +import { Bracket } from "@/features/bracket/components/bracket"; +import { Match } from "@/features/matches/types"; +import { generateSingleEliminationBracket } from "../utils/bracket-generator"; interface SetupGroupStageProps { tournamentId: string; @@ -63,6 +70,74 @@ const SetupGroupStage: React.FC = ({ })); }, [selectedConfig, teams, seed]); + const knockoutTeamCount = useMemo(() => { + if (!selectedConfig) return 0; + return selectedConfig.num_groups * selectedConfig.advance_per_group; + }, [selectedConfig]); + + const bracketPreview = useMemo(() => { + if (!knockoutTeamCount || !selectedConfig) { + return null; + } + + let bracketTemplate: any; + if (Object.keys(brackets).includes(knockoutTeamCount.toString())) { + bracketTemplate = brackets[knockoutTeamCount as keyof typeof brackets]; + } else { + try { + bracketTemplate = generateSingleEliminationBracket(knockoutTeamCount); + } catch (error) { + return null; + } + } + + const groupNames = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']; + const seedLabels: Record = {}; + + let seedIndex = 1; + for (let rank = 1; rank <= selectedConfig.advance_per_group; rank++) { + for (let groupIdx = 0; groupIdx < selectedConfig.num_groups; groupIdx++) { + const groupName = groupNames[groupIdx] || `Group ${groupIdx + 1}`; + const rankSuffix = rank === 1 ? '1st' : rank === 2 ? '2nd' : rank === 3 ? '3rd' : `${rank}th`; + seedLabels[seedIndex++] = `${groupName} ${rankSuffix}`; + } + } + + const ordersMap: Record = {}; + bracketTemplate.winners.forEach((round: any[]) => { + round.forEach((match: any) => { + ordersMap[match.lid] = match.order; + }); + }); + + const placeholderMatches: Match[][] = bracketTemplate.winners.map((round: any[], roundIndex: number) => + round.map((match: any) => { + const matchData: any = { + ...match, + id: `preview-${match.lid}`, + home_from_lid: match.home_from_lid !== null && match.home_from_lid !== undefined ? match.home_from_lid : -1, + away_from_lid: match.away_from_lid !== null && match.away_from_lid !== undefined ? match.away_from_lid : -1, + home_cups: 0, + away_cups: 0, + status: "tbd" as const, + tournament: { id: "", name: "" }, + }; + + if (roundIndex === 0) { + matchData.home = match.home_seed && !match.bye ? { id: `seed-${match.home_seed}`, name: seedLabels[match.home_seed] } : null; + matchData.away = match.away_seed ? { id: `seed-${match.away_seed}`, name: seedLabels[match.away_seed] } : null; + } else { + matchData.home = null; + matchData.away = null; + } + + return matchData; + }) + ); + + return { matches: placeholderMatches, orders: ordersMap }; + }, [knockoutTeamCount, selectedConfig]); + const generateGroups = useServerMutation({ mutationFn: generateGroupStage, successMessage: "Group stage generated successfully!", @@ -171,9 +246,38 @@ const SetupGroupStage: React.FC = ({
- {groupAssignments.length > 0 && ( - - )} + + {groupAssignments.length > 0 && ( + + )} + + {bracketPreview && knockoutTeamCount > 0 && ( + + + + Knockout Bracket Preview ({knockoutTeamCount} Teams) + + + Top {selectedConfig?.advance_per_group} team{selectedConfig?.advance_per_group !== 1 ? 's' : ''} from each group will advance + + + + + + )} +
); diff --git a/src/features/tournaments/server.ts b/src/features/tournaments/server.ts index eb6c4b2..f720745 100644 --- a/src/features/tournaments/server.ts +++ b/src/features/tournaments/server.ts @@ -1,12 +1,15 @@ import { superTokensAdminFunctionMiddleware, superTokensFunctionMiddleware } from "@/utils/supertokens"; import { createServerFn } from "@tanstack/react-start"; import { pbAdmin } from "@/lib/pocketbase/client"; -import { tournamentInputSchema } from "@/features/tournaments/types"; +import { tournamentInputSchema, GroupStanding } from "@/features/tournaments/types"; 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"; export const listTournaments = createServerFn() .middleware([superTokensFunctionMiddleware]) @@ -384,6 +387,215 @@ export const confirmTeamAssignments = createServerFn() }) ); +async function calculateGroupStandings(groupId: string): Promise { + const group = await pbAdmin.getGroup(groupId); + if (!group) { + throw new Error("Group not found"); + } + + const matches = await pbAdmin.getMatchesByGroup(groupId); + const completedMatches = matches.filter(m => m.status === "ended"); + + const standings = new Map(); + + for (const team of group.teams || []) { + standings.set(team.id, { + team, + wins: 0, + losses: 0, + cups_for: 0, + cups_against: 0, + cup_differential: 0, + rank: 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 && awayStanding) { + homeStanding.cups_for += match.home_cups; + homeStanding.cups_against += match.away_cups; + awayStanding.cups_for += match.away_cups; + awayStanding.cups_against += match.home_cups; + + if (match.home_cups > match.away_cups) { + homeStanding.wins++; + awayStanding.losses++; + } else { + awayStanding.wins++; + homeStanding.losses++; + } + } + } + + for (const standing of standings.values()) { + standing.cup_differential = standing.cups_for - standing.cups_against; + } + + const sortedStandings = Array.from(standings.values()).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; + return b.cups_for - a.cups_for; + }); + + sortedStandings.forEach((standing, index) => { + standing.rank = index + 1; + }); + + return sortedStandings; +} + +export const getGroupStandings = createServerFn() + .inputValidator(z.string()) + .middleware([superTokensFunctionMiddleware]) + .handler(async ({ data: groupId }) => + toServerResult(() => calculateGroupStandings(groupId)) + ); + +export const generateKnockoutBracket = createServerFn() + .inputValidator(z.object({ + tournamentId: z.string(), + })) + .middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware]) + .handler(async ({ data }) => + toServerResult(async () => { + logger.info('Generating knockout bracket', { + tournamentId: data.tournamentId, + }); + + const tournament = await pbAdmin.getTournament(data.tournamentId); + if (!tournament) { + throw new Error("Tournament not found"); + } + + if (tournament.phase !== "group_stage") { + throw new Error("Tournament must be in group_stage phase to generate knockout bracket"); + } + + if (!tournament.group_config) { + throw new Error("Tournament must have group_config"); + } + + const groups = await pbAdmin.getGroupsByTournament(data.tournamentId); + if (!groups || groups.length === 0) { + throw new Error("No groups found for tournament"); + } + + const qualifiedTeams: { teamId: string; groupOrder: number; rank: number }[] = []; + + for (const group of groups) { + const standings = await calculateGroupStandings(group.id); + + const topTeams = standings.slice(0, tournament.group_config.advance_per_group); + for (const standing of topTeams) { + qualifiedTeams.push({ + teamId: standing.team.id, + groupOrder: group.order, + rank: standing.rank, + }); + } + } + + const orderedTeamIds: string[] = []; + const maxRank = tournament.group_config.advance_per_group; + + 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 teamCount = orderedTeamIds.length; + + let bracketTemplate: any; + if (Object.keys(brackets).includes(teamCount.toString())) { + bracketTemplate = brackets[teamCount as keyof typeof brackets]; + } else { + bracketTemplate = generateSingleEliminationBracket(teamCount); + } + + const seedToTeamId = new Map(); + orderedTeamIds.forEach((teamId, index) => { + seedToTeamId.set(index + 1, teamId); + }); + + const matchInputs: MatchInput[] = []; + let matchLid = 1000; + + bracketTemplate.winners.forEach((round: any[]) => { + round.forEach((match: any) => { + const matchInput: MatchInput = { + lid: matchLid++, + round: match.round, + order: match.order || 0, + reset: false, + bye: match.bye || false, + home_cups: 0, + away_cups: 0, + ot_count: 0, + home_from_lid: match.home_from_lid === null ? -1 : (match.home_from_lid + 1000), + away_from_lid: match.away_from_lid === null ? -1 : (match.away_from_lid + 1000), + home_from_loser: false, + away_from_loser: false, + is_losers_bracket: false, + match_type: "knockout", + status: "tbd", + tournament: data.tournamentId, + }; + + if (match.home_seed) { + const teamId = seedToTeamId.get(match.home_seed); + if (teamId) { + matchInput.home = teamId; + matchInput.home_seed = match.home_seed; + } + } + + if (match.away_seed) { + const teamId = seedToTeamId.get(match.away_seed); + if (teamId) { + matchInput.away = teamId; + matchInput.away_seed = match.away_seed; + } + } + + if (matchInput.home && matchInput.away) { + matchInput.status = "ready"; + } + + matchInputs.push(matchInput); + }); + }); + + const createdMatches = await pbAdmin.createMatches(matchInputs); + + const existingMatchIds = tournament.matches?.map(m => m.id) || []; + const newMatchIds = createdMatches.map(m => m.id); + await pbAdmin.updateTournamentMatches(data.tournamentId, [...existingMatchIds, ...newMatchIds]); + + await pbAdmin.updateTournament(data.tournamentId, { + phase: "knockout" + }); + + logger.info('Knockout bracket generated', { + tournamentId: data.tournamentId, + matchCount: createdMatches.length, + qualifiedTeamCount: qualifiedTeams.length + }); + + return { + tournament, + matchCount: createdMatches.length, + matches: createdMatches + }; + }) + ); + export const adminEnrollPlayer = createServerFn() .inputValidator(z.object({ playerId: z.string(), @@ -448,8 +660,7 @@ export const generateGroupStage = createServerFn() await pbAdmin.deleteGroupsByTournament(data.tournamentId); const createdGroups = []; - const allMatches = []; - let matchLid = 1; + const groupStageMatches = []; for (const assignment of data.teamAssignments) { const group = await pbAdmin.createGroup({ @@ -464,10 +675,10 @@ export const generateGroupStage = createServerFn() const teamIds = assignment.teamIds; for (let i = 0; i < teamIds.length; i++) { for (let j = i + 1; j < teamIds.length; j++) { - allMatches.push({ - lid: matchLid++, + groupStageMatches.push({ + lid: -1, round: -1, - order: allMatches.length, + order: groupStageMatches.length + 1, reset: false, bye: false, home: teamIds[i], @@ -489,6 +700,43 @@ export const generateGroupStage = createServerFn() } } + const knockoutTeamCount = data.groupConfig.num_groups * data.groupConfig.advance_per_group; + let bracketTemplate: any; + + if (Object.keys(brackets).includes(knockoutTeamCount.toString())) { + bracketTemplate = brackets[knockoutTeamCount as keyof typeof brackets]; + } else { + bracketTemplate = generateSingleEliminationBracket(knockoutTeamCount); + } + + const knockoutMatches: any[] = []; + + bracketTemplate.winners.forEach((round: any[]) => { + round.forEach((match: any) => { + knockoutMatches.push({ + lid: match.lid, + round: match.round, + order: match.order, + reset: false, + bye: match.bye || false, + home_seed: match.home_seed, + away_seed: match.away_seed, + home_cups: 0, + away_cups: 0, + ot_count: 0, + home_from_lid: match.home_from_lid !== null ? match.home_from_lid : -1, + away_from_lid: match.away_from_lid !== null ? match.away_from_lid : -1, + home_from_loser: false, + away_from_loser: false, + is_losers_bracket: false, + match_type: "knockout" as const, + status: "tbd" as const, + tournament: data.tournamentId, + }); + }); + }); + + const allMatches = [...groupStageMatches, ...knockoutMatches]; const createdMatches = await pbAdmin.createMatches(allMatches); const matchIds = createdMatches.map((match) => match.id); @@ -499,10 +747,12 @@ export const generateGroupStage = createServerFn() group_config: data.groupConfig }); - logger.info('Group stage generated', { + logger.info('Group stage and knockout bracket generated', { tournamentId: data.tournamentId, groupCount: createdGroups.length, - matchCount: createdMatches.length + groupMatchCount: groupStageMatches.length, + knockoutMatchCount: knockoutMatches.length, + totalMatchCount: createdMatches.length }); return { diff --git a/src/features/tournaments/types.ts b/src/features/tournaments/types.ts index 57c2980..435a99b 100644 --- a/src/features/tournaments/types.ts +++ b/src/features/tournaments/types.ts @@ -2,7 +2,7 @@ import { TeamInfo } from "@/features/teams/types"; import { Match } from "@/features/matches/types"; import { z } from "zod"; -export type TournamentFormat = "double_elim" | "group_single_elim"; +export type TournamentFormat = "single_elim" | "double_elim" | "groups" | "swiss" | "swiss_bracket" | "round_robin"; export type TournamentPhase = "seeding" | "group_stage" | "knockout" | "completed"; export interface GroupConfig { @@ -23,6 +23,16 @@ export interface Group { updated: string; } +export interface GroupStanding { + team: TeamInfo; + wins: number; + losses: number; + cups_for: number; + cups_against: number; + cup_differential: number; + rank: number; +} + export interface TournamentTeamStats { id: string; team_id: string; @@ -89,7 +99,7 @@ export const tournamentInputSchema = z.object({ start_time: z.string(), end_time: z.string().optional(), regional: z.boolean().optional().default(false), - format: z.enum(["double_elim", "group_single_elim"]).optional(), + format: z.enum(["single_elim", "double_elim", "groups", "swiss", "swiss_bracket", "round_robin"]).optional(), phase: z.enum(["seeding", "group_stage", "knockout", "completed"]).optional(), group_config: z.object({ num_groups: z.number(), diff --git a/src/features/tournaments/utils/bracket-generator.ts b/src/features/tournaments/utils/bracket-generator.ts new file mode 100644 index 0000000..979d4be --- /dev/null +++ b/src/features/tournaments/utils/bracket-generator.ts @@ -0,0 +1,103 @@ +export interface BracketMatch { + lid: number; + round: number; + order: number | null; + bye: boolean; + home_seed?: number; + away_seed?: number; + home_from_lid: number | null; + home_from_loser: boolean; + away_from_lid: number | null; + away_from_loser: boolean; + reset: boolean; +} + +export interface BracketTemplate { + winners: BracketMatch[][]; + losers: BracketMatch[][]; +} + +export function generateSingleEliminationBracket(teamCount: number): BracketTemplate { + if (teamCount < 2) { + throw new Error("Need at least 2 teams for a bracket"); + } + + const nextPowerOf2 = Math.pow(2, Math.ceil(Math.log2(teamCount))); + const totalRounds = Math.log2(nextPowerOf2); + + const byesNeeded = nextPowerOf2 - teamCount; + const firstRoundMatches = Math.floor(teamCount / 2); + + const winners: BracketMatch[][] = []; + let currentLid = 0; + let currentOrder = 1; + + for (let round = 0; round < totalRounds; round++) { + const roundMatches: BracketMatch[] = []; + const matchesInRound = Math.pow(2, totalRounds - round - 1); + + for (let matchIndex = 0; matchIndex < matchesInRound; matchIndex++) { + const match: BracketMatch = { + lid: currentLid++, + round, + order: currentOrder++, + bye: false, + home_from_lid: null, + home_from_loser: false, + away_from_lid: null, + away_from_loser: false, + reset: false, + }; + + if (round === 0) { + const homePosition = matchIndex * 2; + const awayPosition = matchIndex * 2 + 1; + + if (homePosition < teamCount && awayPosition < teamCount) { + match.home_seed = homePosition + 1; + match.away_seed = awayPosition + 1; + } else if (homePosition < teamCount) { + match.home_seed = homePosition + 1; + match.bye = true; + } else { + match.bye = true; + } + } else { + const prevRound = winners[round - 1]; + const homeFeedIndex = matchIndex * 2; + const awayFeedIndex = matchIndex * 2 + 1; + + if (homeFeedIndex < prevRound.length) { + match.home_from_lid = prevRound[homeFeedIndex].lid; + } + if (awayFeedIndex < prevRound.length) { + match.away_from_lid = prevRound[awayFeedIndex].lid; + } + } + + roundMatches.push(match); + } + + winners.push(roundMatches); + } + + return { + winners, + losers: [], + }; +} + +export function generateGroupMismatchSeeding( + numGroups: number, + teamsPerGroup: number +): number[] { + const seeding: number[] = []; + + for (let rank = 0; rank < teamsPerGroup; rank++) { + for (let group = 0; group < numGroups; group++) { + seeding.push(group * teamsPerGroup + rank); + } + } + + return seeding; +} diff --git a/src/lib/pocketbase/services/groups.ts b/src/lib/pocketbase/services/groups.ts index 97a925f..8293637 100644 --- a/src/lib/pocketbase/services/groups.ts +++ b/src/lib/pocketbase/services/groups.ts @@ -41,6 +41,14 @@ export function createGroupsService(pb: PocketBase) { for (const group of groups) { await pb.collection("groups").delete(group.id); } + }, + + async getGroup(groupId: string): Promise { + logger.info("PocketBase | Getting group", { groupId }); + const result = await pb.collection("groups").getOne(groupId, { + expand: "teams,teams.players" + }); + return result as unknown as Group; } }; } diff --git a/src/lib/pocketbase/services/matches.ts b/src/lib/pocketbase/services/matches.ts index 72c101d..6b739c4 100644 --- a/src/lib/pocketbase/services/matches.ts +++ b/src/lib/pocketbase/services/matches.ts @@ -141,5 +141,17 @@ export function createMatchesService(pb: PocketBase) { return results.map(match => transformMatch(match)); }, + + async getMatchesByGroup(groupId: string): Promise { + logger.info("PocketBase | Getting matches for group", { groupId }); + + const results = await pb.collection("matches").getFullList({ + filter: `group = "${groupId}"`, + expand: "tournament, home, away, home.players, away.players", + sort: "created", + }); + + return results.map(match => transformMatch(match)); + }, }; }