regionals
This commit is contained in:
@@ -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() {
|
||||
<Container size="md" px={0}>
|
||||
{ isAdmin && !tournament.regional && <SpotifyControlsBar />}
|
||||
{tournament.matches?.length ? (
|
||||
isGroupStage ? (
|
||||
hasGroupStage && hasKnockout ? (
|
||||
<Stack gap="xl">
|
||||
<GroupStageView
|
||||
groups={tournament.groups || []}
|
||||
matches={tournament.matches}
|
||||
showControls
|
||||
tournamentId={tournament.id}
|
||||
hasKnockoutBracket={knockoutBracketPopulated}
|
||||
/>
|
||||
<Divider />
|
||||
<div>
|
||||
<Title order={3} ta="center" mb="md">Knockout Bracket</Title>
|
||||
<BracketView bracket={bracket} showControls groupConfig={tournament.group_config} />
|
||||
</div>
|
||||
</Stack>
|
||||
) : hasGroupStage ? (
|
||||
<GroupStageView
|
||||
groups={tournament.groups || []}
|
||||
matches={tournament.matches}
|
||||
showControls
|
||||
tournamentId={tournament.id}
|
||||
hasKnockoutBracket={knockoutBracketPopulated}
|
||||
/>
|
||||
) : (
|
||||
<BracketView bracket={bracket} showControls />
|
||||
<BracketView bracket={bracket} showControls groupConfig={tournament.group_config} />
|
||||
)
|
||||
) : (
|
||||
tournament.regional === true ? (
|
||||
|
||||
@@ -74,7 +74,7 @@ function RouteComponent() {
|
||||
|
||||
return (
|
||||
<Container size="md" px={0}>
|
||||
<BracketView bracket={bracket} />
|
||||
<BracketView bracket={bracket} groupConfig={tournament.group_config} />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<BracketViewProps> = ({ bracket, showControls }) => {
|
||||
const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls, groupConfig }) => {
|
||||
const height = useAppShellHeight();
|
||||
const orders = useMemo(() => {
|
||||
const map: Record<number, number> = {};
|
||||
@@ -32,14 +36,14 @@ const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls }) => {
|
||||
<Text fw={600} size="md" m={16}>
|
||||
Winners Bracket
|
||||
</Text>
|
||||
<Bracket rounds={bracket.winners} orders={orders} showControls={showControls} />
|
||||
<Bracket rounds={bracket.winners} orders={orders} showControls={showControls} groupConfig={groupConfig} />
|
||||
</div>
|
||||
{bracket.losers && (
|
||||
<div>
|
||||
<Text fw={600} size="md" m={16}>
|
||||
Losers Bracket
|
||||
</Text>
|
||||
<Bracket rounds={bracket.losers} orders={orders} showControls={showControls} />
|
||||
<Bracket rounds={bracket.losers} orders={orders} showControls={showControls} groupConfig={groupConfig} />
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
@@ -7,12 +7,17 @@ interface BracketProps {
|
||||
rounds: Match[][];
|
||||
orders: Record<number, number>;
|
||||
showControls?: boolean;
|
||||
groupConfig?: {
|
||||
num_groups: number;
|
||||
advance_per_group: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const Bracket: React.FC<BracketProps> = ({
|
||||
rounds,
|
||||
orders,
|
||||
showControls,
|
||||
groupConfig,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
@@ -132,6 +137,7 @@ export const Bracket: React.FC<BracketProps> = ({
|
||||
match={match}
|
||||
orders={orders}
|
||||
showControls={showControls}
|
||||
groupConfig={groupConfig}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -17,16 +17,38 @@ interface MatchCardProps {
|
||||
match: Match;
|
||||
orders: Record<number, number>;
|
||||
showControls?: boolean;
|
||||
groupConfig?: {
|
||||
num_groups: number;
|
||||
advance_per_group: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const MatchCard: React.FC<MatchCardProps> = ({
|
||||
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<MatchCardProps> = ({
|
||||
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<MatchCardProps> = ({
|
||||
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<MatchCardProps> = ({
|
||||
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;
|
||||
|
||||
@@ -11,6 +11,7 @@ interface MatchSlotProps {
|
||||
seed?: number;
|
||||
cups?: number;
|
||||
isWinner?: boolean;
|
||||
groupLabel?: string;
|
||||
}
|
||||
|
||||
export const MatchSlot: React.FC<MatchSlotProps> = ({
|
||||
@@ -19,7 +20,8 @@ export const MatchSlot: React.FC<MatchSlotProps> = ({
|
||||
team,
|
||||
seed,
|
||||
cups,
|
||||
isWinner
|
||||
isWinner,
|
||||
groupLabel
|
||||
}) => (
|
||||
<Flex
|
||||
align="stretch"
|
||||
@@ -34,7 +36,7 @@ export const MatchSlot: React.FC<MatchSlotProps> = ({
|
||||
<Flex align="center" gap={4} flex={1}>
|
||||
{team ? (
|
||||
<>
|
||||
<Text
|
||||
<Text
|
||||
size={team.name.length > 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<MatchSlotProps> = ({
|
||||
</Text>
|
||||
{isWinner && (
|
||||
<CrownIcon
|
||||
size={14}
|
||||
size={14}
|
||||
weight="fill"
|
||||
style={{
|
||||
style={{
|
||||
color: 'gold',
|
||||
marginLeft: '2px',
|
||||
marginTop: '-1px',
|
||||
filter: 'drop-shadow(0 1px 1px rgba(0,0,0,0.3))',
|
||||
flexShrink: 0
|
||||
}}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : groupLabel ? (
|
||||
<Text c="dimmed" size="xs" truncate style={{ minWidth: 0, flex: 1 }}>
|
||||
{groupLabel}
|
||||
</Text>
|
||||
) : from ? (
|
||||
<Text c="dimmed" size="xs" truncate style={{ minWidth: 0, flex: 1 }}>
|
||||
{from_loser ? "Loser" : "Winner"} of Match {from}
|
||||
|
||||
@@ -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<string, { teamId: string; wins: number; losses: number; cups_for: number; cups_against: number; cup_differential: number }>();
|
||||
|
||||
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<number, string>();
|
||||
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,
|
||||
|
||||
@@ -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<GroupStageViewProps> = ({
|
||||
groups,
|
||||
matches,
|
||||
showControls,
|
||||
tournamentId,
|
||||
hasKnockoutBracket,
|
||||
}) => {
|
||||
const queryClient = useQueryClient();
|
||||
const [expandedTeams, setExpandedTeams] = useState<Record<string, boolean>>({});
|
||||
|
||||
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<GroupStageViewProps> = ({
|
||||
team: team,
|
||||
wins: 0,
|
||||
losses: 0,
|
||||
cupsFor: 0,
|
||||
cupsAgainst: 0,
|
||||
cupDifference: 0,
|
||||
});
|
||||
});
|
||||
@@ -119,6 +153,11 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
|
||||
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<GroupStageViewProps> = ({
|
||||
|
||||
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<GroupStageViewProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (sortedGroups.length === 1) {
|
||||
const group = sortedGroups[0];
|
||||
const groupMatches = matchesByGroup.get(group.id) || [];
|
||||
const standings = getTeamStandings(group.id, group.teams || []);
|
||||
|
||||
return (
|
||||
<Box p="md">
|
||||
<Stack gap="md">
|
||||
<Card withBorder radius="md" p={0}>
|
||||
<MantineGroup
|
||||
justify="space-between"
|
||||
p="sm"
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
backgroundColor: 'var(--mantine-color-default-hover)',
|
||||
}}
|
||||
onClick={() => toggleTeams(group.id)}
|
||||
>
|
||||
<Text fw={600} size="sm">
|
||||
Standings ({standings.length})
|
||||
</Text>
|
||||
<ActionIcon variant="subtle" size="sm">
|
||||
{expandedTeams[group.id] ? <CaretCircleUp size={16} /> : <CaretCircleDown size={16} />}
|
||||
</ActionIcon>
|
||||
</MantineGroup>
|
||||
<Collapse in={expandedTeams[group.id]}>
|
||||
<Stack gap={0}>
|
||||
{standings.length > 0 ? (
|
||||
standings.map((standing, index) => (
|
||||
<MantineGroup
|
||||
key={standing.teamId}
|
||||
gap="sm"
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
px="md"
|
||||
py="xs"
|
||||
style={{
|
||||
borderTop: index > 0 ? '1px solid var(--mantine-color-default-border)' : 'none',
|
||||
}}
|
||||
>
|
||||
<Text size="sm" fw={700} c="dimmed" w={24} ta="center">
|
||||
{index + 1}
|
||||
</Text>
|
||||
<TeamAvatar team={standing.team} size={28} radius="sm" />
|
||||
<Text size="sm" fw={500} style={{ flex: 1 }} lineClamp={1}>
|
||||
{standing.teamName}
|
||||
</Text>
|
||||
<MantineGroup gap="xs" wrap="nowrap">
|
||||
<Text size="xs" c="dimmed" fw={500}>
|
||||
{standing.wins}-{standing.losses}
|
||||
</Text>
|
||||
<Text
|
||||
size="xs"
|
||||
c={standing.cupDifference > 0 ? "green" : standing.cupDifference < 0 ? "red" : "dimmed"}
|
||||
fw={600}
|
||||
>
|
||||
{standing.cupDifference > 0 ? '+' : ''}{standing.cupDifference}
|
||||
</Text>
|
||||
</MantineGroup>
|
||||
</MantineGroup>
|
||||
))
|
||||
) : (
|
||||
<Text size="sm" c="dimmed" ta="center" py="md">
|
||||
No teams assigned
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Card>
|
||||
|
||||
{groupMatches.length === 0 ? (
|
||||
<Card withBorder radius="md" p="xl">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
No matches scheduled
|
||||
</Text>
|
||||
</Card>
|
||||
) : (
|
||||
<SimpleGrid
|
||||
cols={{ base: 1, sm: 2, lg: 3 }}
|
||||
spacing="md"
|
||||
>
|
||||
{groupMatches.map((match) => (
|
||||
<GroupMatchCard
|
||||
key={match.id}
|
||||
match={match}
|
||||
showControls={showControls}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
const showGenerateKnockoutButton = showControls && tournamentId && !hasKnockoutBracket && allGroupMatchesCompleted;
|
||||
|
||||
return (
|
||||
<Box p="md">
|
||||
<Tabs defaultValue={sortedGroups[0]?.id}>
|
||||
<Stack gap="md">
|
||||
{showGenerateKnockoutButton && (
|
||||
<Alert color="blue" title="Group Stage Complete" icon={<CaretCircleUpIcon size={20} />}>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm">All group matches are finished! Populate the knockout bracket to advance qualified teams.</Text>
|
||||
<Button
|
||||
onClick={handlePopulateKnockout}
|
||||
loading={populateKnockoutMutation.isPending}
|
||||
size="sm"
|
||||
>
|
||||
Populate Knockout Bracket
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
<Tabs defaultValue={sortedGroups[0]?.id}>
|
||||
<Tabs.List mb="md" grow>
|
||||
{sortedGroups.map((group) => {
|
||||
const groupMatches = matchesByGroup.get(group.id) || [];
|
||||
@@ -291,7 +253,7 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
|
||||
Standings ({standings.length})
|
||||
</Text>
|
||||
<ActionIcon variant="subtle" size="sm">
|
||||
{expandedTeams[group.id] ? <CaretCircleUp size={16} /> : <CaretCircleDown size={16} />}
|
||||
{expandedTeams[group.id] ? <CaretCircleUpIcon size={16} /> : <CaretCircleDownIcon size={16} />}
|
||||
</ActionIcon>
|
||||
</MantineGroup>
|
||||
<Collapse in={expandedTeams[group.id]}>
|
||||
@@ -317,16 +279,21 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
|
||||
{standing.teamName}
|
||||
</Text>
|
||||
<MantineGroup gap="xs" wrap="nowrap">
|
||||
<Text size="xs" c="dimmed" fw={500}>
|
||||
<Text size="xs" c="dimmed" fw={500} miw={35} ta="center">
|
||||
{standing.wins}-{standing.losses}
|
||||
</Text>
|
||||
<Text
|
||||
size="xs"
|
||||
c={standing.cupDifference > 0 ? "green" : standing.cupDifference < 0 ? "red" : "dimmed"}
|
||||
fw={600}
|
||||
miw={30}
|
||||
ta="center"
|
||||
>
|
||||
{standing.cupDifference > 0 ? '+' : ''}{standing.cupDifference}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" fw={400} miw={40} ta="center">
|
||||
{standing.cupsFor}/{standing.cupsAgainst}
|
||||
</Text>
|
||||
</MantineGroup>
|
||||
</MantineGroup>
|
||||
))
|
||||
@@ -339,7 +306,6 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
|
||||
</Collapse>
|
||||
</Card>
|
||||
|
||||
{/* Matches Grid */}
|
||||
{groupMatches.length === 0 ? (
|
||||
<Card withBorder radius="md" p="xl">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
@@ -365,6 +331,7 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
|
||||
);
|
||||
})}
|
||||
</Tabs>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<SetupGroupStageProps> = ({
|
||||
}));
|
||||
}, [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<number, string> = {};
|
||||
|
||||
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<number, number> = {};
|
||||
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<SetupGroupStageProps> = ({
|
||||
</Stack>
|
||||
|
||||
<div style={{ flex: 1, overflow: "auto", maxHeight: "80vh" }}>
|
||||
{groupAssignments.length > 0 && (
|
||||
<GroupPreview groups={groupAssignments} />
|
||||
)}
|
||||
<Stack gap="xl">
|
||||
{groupAssignments.length > 0 && (
|
||||
<GroupPreview groups={groupAssignments} />
|
||||
)}
|
||||
|
||||
{bracketPreview && knockoutTeamCount > 0 && (
|
||||
<Box>
|
||||
<Divider mb="lg" />
|
||||
<Title order={3} ta="center" mb="md">
|
||||
Knockout Bracket Preview ({knockoutTeamCount} 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
|
||||
</Text>
|
||||
<Box
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(circle, var(--mantine-color-default-border) 1px, transparent 1px)`,
|
||||
backgroundSize: "16px 16px",
|
||||
backgroundPosition: "0 0, 8px 8px",
|
||||
padding: "1rem",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
>
|
||||
<Bracket
|
||||
rounds={bracketPreview.matches}
|
||||
orders={bracketPreview.orders}
|
||||
showControls={false}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<GroupStanding[]> {
|
||||
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<string, GroupStanding>();
|
||||
|
||||
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<number, string>();
|
||||
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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
103
src/features/tournaments/utils/bracket-generator.ts
Normal file
103
src/features/tournaments/utils/bracket-generator.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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<Group> {
|
||||
logger.info("PocketBase | Getting group", { groupId });
|
||||
const result = await pb.collection("groups").getOne(groupId, {
|
||||
expand: "teams,teams.players"
|
||||
});
|
||||
return result as unknown as Group;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -141,5 +141,17 @@ export function createMatchesService(pb: PocketBase) {
|
||||
|
||||
return results.map(match => transformMatch(match));
|
||||
},
|
||||
|
||||
async getMatchesByGroup(groupId: string): Promise<Match[]> {
|
||||
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));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user