regionals

This commit is contained in:
yohlo
2026-03-01 16:21:27 -06:00
parent f83a7d69c8
commit 6199afc687
14 changed files with 849 additions and 137 deletions

View File

@@ -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 ? (

View File

@@ -74,7 +74,7 @@ function RouteComponent() {
return (
<Container size="md" px={0}>
<BracketView bracket={bracket} />
<BracketView bracket={bracket} groupConfig={tournament.group_config} />
</Container>
);
}

View File

@@ -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>

View File

@@ -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>
)

View File

@@ -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;

View File

@@ -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}

View File

@@ -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,

View File

@@ -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>
);
};

View File

@@ -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>
);

View File

@@ -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 {

View File

@@ -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(),

View 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;
}

View File

@@ -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;
}
};
}

View File

@@ -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));
},
};
}