groups init
This commit is contained in:
@@ -38,6 +38,7 @@ import { Route as AuthedAdminPreviewRouteImport } from './routes/_authed/admin/p
|
||||
import { Route as AuthedAdminBadgesRouteImport } from './routes/_authed/admin/badges'
|
||||
import { Route as AuthedAdminActivitiesRouteImport } from './routes/_authed/admin/activities'
|
||||
import { Route as AuthedAdminTournamentsIndexRouteImport } from './routes/_authed/admin/tournaments/index'
|
||||
import { Route as AuthedTournamentsIdGroupsRouteImport } from './routes/_authed/tournaments/$id.groups'
|
||||
import { Route as AuthedTournamentsIdBracketRouteImport } from './routes/_authed/tournaments/$id.bracket'
|
||||
import { Route as AuthedAdminTournamentsIdIndexRouteImport } from './routes/_authed/admin/tournaments/$id/index'
|
||||
import { Route as ApiFilesCollectionRecordIdFileRouteImport } from './routes/api/files/$collection/$recordId/$file'
|
||||
@@ -192,6 +193,12 @@ const AuthedAdminTournamentsIndexRoute =
|
||||
path: '/tournaments/',
|
||||
getParentRoute: () => AuthedAdminRoute,
|
||||
} as any)
|
||||
const AuthedTournamentsIdGroupsRoute =
|
||||
AuthedTournamentsIdGroupsRouteImport.update({
|
||||
id: '/tournaments/$id/groups',
|
||||
path: '/tournaments/$id/groups',
|
||||
getParentRoute: () => AuthedRoute,
|
||||
} as any)
|
||||
const AuthedTournamentsIdBracketRoute =
|
||||
AuthedTournamentsIdBracketRouteImport.update({
|
||||
id: '/tournaments/$id/bracket',
|
||||
@@ -258,6 +265,7 @@ export interface FileRoutesByFullPath {
|
||||
'/admin/': typeof AuthedAdminIndexRoute
|
||||
'/tournaments/': typeof AuthedTournamentsIndexRoute
|
||||
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
|
||||
'/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
|
||||
'/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
|
||||
'/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
|
||||
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
|
||||
@@ -293,6 +301,7 @@ export interface FileRoutesByTo {
|
||||
'/admin': typeof AuthedAdminIndexRoute
|
||||
'/tournaments': typeof AuthedTournamentsIndexRoute
|
||||
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
|
||||
'/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
|
||||
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
|
||||
'/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
|
||||
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
|
||||
@@ -331,6 +340,7 @@ export interface FileRoutesById {
|
||||
'/_authed/admin/': typeof AuthedAdminIndexRoute
|
||||
'/_authed/tournaments/': typeof AuthedTournamentsIndexRoute
|
||||
'/_authed/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
|
||||
'/_authed/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute
|
||||
'/_authed/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
|
||||
'/_authed/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute
|
||||
'/_authed/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
|
||||
@@ -369,6 +379,7 @@ export interface FileRouteTypes {
|
||||
| '/admin/'
|
||||
| '/tournaments/'
|
||||
| '/tournaments/$id/bracket'
|
||||
| '/tournaments/$id/groups'
|
||||
| '/admin/tournaments/'
|
||||
| '/admin/tournaments/$id/assign-partners'
|
||||
| '/admin/tournaments/$id/teams'
|
||||
@@ -404,6 +415,7 @@ export interface FileRouteTypes {
|
||||
| '/admin'
|
||||
| '/tournaments'
|
||||
| '/tournaments/$id/bracket'
|
||||
| '/tournaments/$id/groups'
|
||||
| '/admin/tournaments'
|
||||
| '/admin/tournaments/$id/assign-partners'
|
||||
| '/admin/tournaments/$id/teams'
|
||||
@@ -441,6 +453,7 @@ export interface FileRouteTypes {
|
||||
| '/_authed/admin/'
|
||||
| '/_authed/tournaments/'
|
||||
| '/_authed/tournaments/$id/bracket'
|
||||
| '/_authed/tournaments/$id/groups'
|
||||
| '/_authed/admin/tournaments/'
|
||||
| '/_authed/admin/tournaments/$id/assign-partners'
|
||||
| '/_authed/admin/tournaments/$id/teams'
|
||||
@@ -673,6 +686,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthedAdminTournamentsIndexRouteImport
|
||||
parentRoute: typeof AuthedAdminRoute
|
||||
}
|
||||
'/_authed/tournaments/$id/groups': {
|
||||
id: '/_authed/tournaments/$id/groups'
|
||||
path: '/tournaments/$id/groups'
|
||||
fullPath: '/tournaments/$id/groups'
|
||||
preLoaderRoute: typeof AuthedTournamentsIdGroupsRouteImport
|
||||
parentRoute: typeof AuthedRoute
|
||||
}
|
||||
'/_authed/tournaments/$id/bracket': {
|
||||
id: '/_authed/tournaments/$id/bracket'
|
||||
path: '/tournaments/$id/bracket'
|
||||
@@ -758,6 +778,7 @@ interface AuthedRouteChildren {
|
||||
AuthedTournamentsTournamentIdRoute: typeof AuthedTournamentsTournamentIdRoute
|
||||
AuthedTournamentsIndexRoute: typeof AuthedTournamentsIndexRoute
|
||||
AuthedTournamentsIdBracketRoute: typeof AuthedTournamentsIdBracketRoute
|
||||
AuthedTournamentsIdGroupsRoute: typeof AuthedTournamentsIdGroupsRoute
|
||||
}
|
||||
|
||||
const AuthedRouteChildren: AuthedRouteChildren = {
|
||||
@@ -771,6 +792,7 @@ const AuthedRouteChildren: AuthedRouteChildren = {
|
||||
AuthedTournamentsTournamentIdRoute: AuthedTournamentsTournamentIdRoute,
|
||||
AuthedTournamentsIndexRoute: AuthedTournamentsIndexRoute,
|
||||
AuthedTournamentsIdBracketRoute: AuthedTournamentsIdBracketRoute,
|
||||
AuthedTournamentsIdGroupsRoute: AuthedTournamentsIdGroupsRoute,
|
||||
}
|
||||
|
||||
const AuthedRouteWithChildren =
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
} from "@/features/tournaments/queries";
|
||||
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 { useMemo } from "react";
|
||||
import { BracketData } from "@/features/bracket/types";
|
||||
@@ -43,6 +45,10 @@ function RouteComponent() {
|
||||
const { roles } = useAuth();
|
||||
const isAdmin = roles?.includes('Admin') || false;
|
||||
|
||||
const isGroupStage = useMemo(() => {
|
||||
return tournament.matches?.some((match) => match.round === -1) || false;
|
||||
}, [tournament.matches]);
|
||||
|
||||
const bracket: BracketData = useMemo(() => {
|
||||
if (!tournament.matches || tournament.matches.length === 0) {
|
||||
return { winners: [], losers: [] };
|
||||
@@ -52,6 +58,7 @@ function RouteComponent() {
|
||||
const losersMap = new Map<number, Match[]>();
|
||||
|
||||
tournament.matches
|
||||
.filter((match) => match.round !== -1)
|
||||
.sort((a, b) => a.lid - b.lid)
|
||||
.forEach((match) => {
|
||||
if (!match.is_losers_bracket) {
|
||||
@@ -79,15 +86,30 @@ function RouteComponent() {
|
||||
|
||||
return (
|
||||
<Container size="md" px={0}>
|
||||
{ isAdmin && <SpotifyControlsBar />}
|
||||
{ isAdmin && !tournament.regional && <SpotifyControlsBar />}
|
||||
{tournament.matches?.length ? (
|
||||
<BracketView bracket={bracket} showControls />
|
||||
isGroupStage ? (
|
||||
<GroupStageView
|
||||
groups={tournament.groups || []}
|
||||
matches={tournament.matches}
|
||||
showControls
|
||||
/>
|
||||
) : (
|
||||
<BracketView bracket={bracket} showControls />
|
||||
)
|
||||
) : (
|
||||
<SeedTournament
|
||||
tournamentId={tournament.id}
|
||||
teams={tournament.teams || []}
|
||||
isRegional={tournament.regional}
|
||||
/>
|
||||
tournament.regional === true ? (
|
||||
<SetupGroupStage
|
||||
tournamentId={tournament.id}
|
||||
teams={tournament.teams || []}
|
||||
/>
|
||||
) : (
|
||||
<SeedTournament
|
||||
tournamentId={tournament.id}
|
||||
teams={tournament.teams || []}
|
||||
isRegional={tournament.regional}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
useTournament,
|
||||
} from "@/features/tournaments/queries";
|
||||
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
||||
import SeedTournament from "@/features/tournaments/components/seed-tournament";
|
||||
import { Container } from "@mantine/core";
|
||||
import { useMemo } from "react";
|
||||
import { BracketData } from "@/features/bracket/types";
|
||||
@@ -18,7 +17,7 @@ export const Route = createFileRoute("/_authed/tournaments/$id/bracket")({
|
||||
queryClient,
|
||||
tournamentQueries.details(params.id)
|
||||
);
|
||||
if (!tournament) throw redirect({ to: "/admin/tournaments" });
|
||||
if (!tournament) throw redirect({ to: "/tournaments" });
|
||||
return {
|
||||
tournament,
|
||||
};
|
||||
@@ -26,10 +25,9 @@ export const Route = createFileRoute("/_authed/tournaments/$id/bracket")({
|
||||
loader: ({ context }) => ({
|
||||
fullWidth: true,
|
||||
withPadding: false,
|
||||
showSpotifyPanel: true,
|
||||
header: {
|
||||
withBackButton: true,
|
||||
title: `${context.tournament.name}`,
|
||||
title: `${context.tournament.name} - Bracket`,
|
||||
},
|
||||
}),
|
||||
component: RouteComponent,
|
||||
@@ -48,6 +46,7 @@ function RouteComponent() {
|
||||
const losersMap = new Map<number, Match[]>();
|
||||
|
||||
tournament.matches
|
||||
.filter((match) => match.round !== -1)
|
||||
.sort((a, b) => a.lid - b.lid)
|
||||
.forEach((match) => {
|
||||
if (!match.is_losers_bracket) {
|
||||
|
||||
45
src/app/routes/_authed/tournaments/$id.groups.tsx
Normal file
45
src/app/routes/_authed/tournaments/$id.groups.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import {
|
||||
tournamentQueries,
|
||||
useTournament,
|
||||
} from "@/features/tournaments/queries";
|
||||
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
||||
import GroupStageView from "@/features/tournaments/components/group-stage-view";
|
||||
import { Container } from "@mantine/core";
|
||||
|
||||
export const Route = createFileRoute("/_authed/tournaments/$id/groups")({
|
||||
beforeLoad: async ({ context, params }) => {
|
||||
const { queryClient } = context;
|
||||
const tournament = await ensureServerQueryData(
|
||||
queryClient,
|
||||
tournamentQueries.details(params.id)
|
||||
);
|
||||
if (!tournament) throw redirect({ to: "/tournaments" });
|
||||
return {
|
||||
tournament,
|
||||
};
|
||||
},
|
||||
loader: ({ context }) => ({
|
||||
fullWidth: true,
|
||||
withPadding: false,
|
||||
header: {
|
||||
withBackButton: true,
|
||||
title: `${context.tournament.name} - Groups`,
|
||||
},
|
||||
}),
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { id } = Route.useParams();
|
||||
const { data: tournament } = useTournament(id);
|
||||
|
||||
return (
|
||||
<Container size="md" px={0}>
|
||||
<GroupStageView
|
||||
groups={tournament.groups || []}
|
||||
matches={tournament.matches || []}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
201
src/features/tournaments/components/group-match-card.tsx
Normal file
201
src/features/tournaments/components/group-match-card.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import React, { useCallback } from "react";
|
||||
import { Card, Group, Stack, Text, ActionIcon, Indicator, Flex, Box } from "@mantine/core";
|
||||
import { PlayIcon, PencilIcon } from "@phosphor-icons/react";
|
||||
import { Match } from "@/features/matches/types";
|
||||
import { useSheet } from "@/hooks/use-sheet";
|
||||
import Sheet from "@/components/sheet/sheet";
|
||||
import { useServerMutation } from "@/lib/tanstack-query/hooks";
|
||||
import { endMatch, startMatch } from "@/features/matches/server";
|
||||
import { tournamentKeys } from "@/features/tournaments/queries";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { MatchForm } from "@/features/bracket/components/match-form";
|
||||
import TeamAvatar from "@/components/team-avatar";
|
||||
|
||||
interface GroupMatchCardProps {
|
||||
match: Match;
|
||||
showControls?: boolean;
|
||||
}
|
||||
|
||||
const GroupMatchCard: React.FC<GroupMatchCardProps> = ({ match, showControls }) => {
|
||||
const queryClient = useQueryClient();
|
||||
const editSheet = useSheet();
|
||||
|
||||
const isReady = match.status === "ready";
|
||||
const isStarted = match.status === "started";
|
||||
const isEnded = match.status === "ended";
|
||||
|
||||
const homeWon = isEnded && match.home_cups !== undefined && match.away_cups !== undefined && match.home_cups > match.away_cups;
|
||||
const awayWon = isEnded && match.away_cups !== undefined && match.home_cups !== undefined && match.away_cups > match.home_cups;
|
||||
|
||||
const start = useServerMutation({
|
||||
mutationFn: startMatch,
|
||||
successMessage: "Match started!",
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: tournamentKeys.details(match.tournament.id),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const end = useServerMutation({
|
||||
mutationFn: endMatch,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: tournamentKeys.details(match.tournament.id),
|
||||
});
|
||||
editSheet.close();
|
||||
},
|
||||
});
|
||||
|
||||
const handleFormSubmit = useCallback(
|
||||
async (data: {
|
||||
home_cups: number;
|
||||
away_cups: number;
|
||||
ot_count: number;
|
||||
}) => {
|
||||
end.mutate({
|
||||
data: {
|
||||
...data,
|
||||
matchId: match.id,
|
||||
},
|
||||
});
|
||||
},
|
||||
[end, match.id]
|
||||
);
|
||||
|
||||
const handleStartMatch = () => {
|
||||
start.mutate({ data: match.id });
|
||||
};
|
||||
|
||||
const showStartButton = isReady && showControls;
|
||||
const showEditButton = isStarted && showControls;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex direction="row" align="stretch">
|
||||
<Indicator
|
||||
inline
|
||||
processing={isStarted}
|
||||
color="red"
|
||||
size={16}
|
||||
disabled={!isStarted || showEditButton}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<Card
|
||||
withBorder
|
||||
radius="md"
|
||||
p="md"
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap="sm" style={{ flex: 1, minWidth: 0 }}>
|
||||
<TeamAvatar team={match.home} size={32} radius="sm" />
|
||||
<Text
|
||||
size="sm"
|
||||
fw={homeWon ? 700 : 500}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
lineClamp={1}
|
||||
>
|
||||
{match.home?.name || "TBD"}
|
||||
</Text>
|
||||
</Group>
|
||||
{isEnded && match.home_cups !== undefined && (
|
||||
<Text
|
||||
size="xl"
|
||||
fw={700}
|
||||
c={homeWon ? "green" : "dimmed"}
|
||||
style={{ minWidth: 32, textAlign: 'center' }}
|
||||
>
|
||||
{match.home_cups}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Box
|
||||
style={{
|
||||
height: 1,
|
||||
backgroundColor: 'var(--mantine-color-default-border)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Group gap="sm" style={{ flex: 1, minWidth: 0 }}>
|
||||
<TeamAvatar team={match.away} size={32} radius="sm" />
|
||||
<Text
|
||||
size="sm"
|
||||
fw={awayWon ? 700 : 500}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
lineClamp={1}
|
||||
>
|
||||
{match.away?.name || "TBD"}
|
||||
</Text>
|
||||
</Group>
|
||||
{isEnded && match.away_cups !== undefined && (
|
||||
<Text
|
||||
size="xl"
|
||||
fw={700}
|
||||
c={awayWon ? "green" : "dimmed"}
|
||||
style={{ minWidth: 32, textAlign: 'center' }}
|
||||
>
|
||||
{match.away_cups}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Indicator>
|
||||
|
||||
{showStartButton && (
|
||||
<ActionIcon
|
||||
color="green"
|
||||
onClick={handleStartMatch}
|
||||
loading={start.isPending}
|
||||
size="md"
|
||||
h="100%"
|
||||
radius="sm"
|
||||
ml={-4}
|
||||
style={{
|
||||
borderTopLeftRadius: 0,
|
||||
borderBottomLeftRadius: 0,
|
||||
}}
|
||||
>
|
||||
<PlayIcon size={16} weight="fill" />
|
||||
</ActionIcon>
|
||||
)}
|
||||
|
||||
{showEditButton && (
|
||||
<ActionIcon
|
||||
color="blue"
|
||||
onClick={editSheet.open}
|
||||
size="md"
|
||||
h="100%"
|
||||
radius="sm"
|
||||
ml={-4}
|
||||
style={{
|
||||
borderTopLeftRadius: 0,
|
||||
borderBottomLeftRadius: 0,
|
||||
}}
|
||||
>
|
||||
<PencilIcon size={16} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
{showControls && (
|
||||
<Sheet title="End Match" opened={editSheet.isOpen} onChange={editSheet.toggle}>
|
||||
<MatchForm
|
||||
match={match}
|
||||
onSubmit={handleFormSubmit}
|
||||
isPending={end.isPending}
|
||||
/>
|
||||
</Sheet>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupMatchCard;
|
||||
50
src/features/tournaments/components/group-preview.tsx
Normal file
50
src/features/tournaments/components/group-preview.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Stack, Text, Card, Group, Box } from "@mantine/core";
|
||||
import { TeamInfo } from "@/features/teams/types";
|
||||
import TeamAvatar from "@/components/team-avatar";
|
||||
|
||||
interface GroupAssignment {
|
||||
groupIndex: number;
|
||||
groupName: string;
|
||||
teams: TeamInfo[];
|
||||
}
|
||||
|
||||
interface GroupPreviewProps {
|
||||
groups: GroupAssignment[];
|
||||
}
|
||||
|
||||
const GroupPreview: React.FC<GroupPreviewProps> = ({ groups }) => {
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{groups.map((group) => (
|
||||
<Card key={group.groupIndex} withBorder radius="md" p="md" w="fit-content">
|
||||
<Stack gap="sm">
|
||||
<Group gap="xs" align="center">
|
||||
<Text fw={600} size="sm">
|
||||
Group {group.groupName}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
({group.teams.length} teams)
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Stack gap="xs">
|
||||
{group.teams.map((team, index) => (
|
||||
<Group key={team.id} gap="sm" align="center" wrap="nowrap">
|
||||
<Text size="xs" c="dimmed" w={20} ta="right">
|
||||
{index + 1}
|
||||
</Text>
|
||||
<TeamAvatar team={team} size={24} radius="sm" isRegional />
|
||||
<Text size="sm" truncate style={{ flex: 1 }}>
|
||||
{team.name}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupPreview;
|
||||
372
src/features/tournaments/components/group-stage-view.tsx
Normal file
372
src/features/tournaments/components/group-stage-view.tsx
Normal file
@@ -0,0 +1,372 @@
|
||||
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 { Match } from "@/features/matches/types";
|
||||
import { Group } from "../types";
|
||||
import GroupMatchCard from "./group-match-card";
|
||||
import TeamAvatar from "@/components/team-avatar";
|
||||
|
||||
interface GroupStageViewProps {
|
||||
groups: Group[];
|
||||
matches: Match[];
|
||||
showControls?: boolean;
|
||||
}
|
||||
|
||||
interface TeamStanding {
|
||||
teamId: string;
|
||||
teamName: string;
|
||||
team: any;
|
||||
wins: number;
|
||||
losses: number;
|
||||
cupDifference: number;
|
||||
}
|
||||
|
||||
const GroupStageView: React.FC<GroupStageViewProps> = ({
|
||||
groups,
|
||||
matches,
|
||||
showControls,
|
||||
}) => {
|
||||
const [expandedTeams, setExpandedTeams] = useState<Record<string, boolean>>({});
|
||||
|
||||
const orderMatchesWithSpacing = (matches: Match[]): Match[] => {
|
||||
if (matches.length <= 1) return matches;
|
||||
|
||||
const ordered: Match[] = [];
|
||||
const remaining = [...matches];
|
||||
|
||||
ordered.push(remaining.shift()!);
|
||||
|
||||
while (remaining.length > 0) {
|
||||
const lastMatch = ordered[ordered.length - 1];
|
||||
const lastTeams = new Set([lastMatch.home?.id, lastMatch.away?.id].filter(Boolean));
|
||||
|
||||
let bestMatchIndex = remaining.findIndex((match) => {
|
||||
const currentTeams = new Set([match.home?.id, match.away?.id].filter(Boolean));
|
||||
for (const teamId of currentTeams) {
|
||||
if (lastTeams.has(teamId)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (bestMatchIndex === -1) {
|
||||
bestMatchIndex = 0;
|
||||
}
|
||||
|
||||
ordered.push(remaining.splice(bestMatchIndex, 1)[0]);
|
||||
}
|
||||
|
||||
return ordered;
|
||||
};
|
||||
|
||||
const matchesByGroup = useMemo(() => {
|
||||
const map = new Map<string, Match[]>();
|
||||
|
||||
matches
|
||||
.filter((match) => match.round === -1 && match.group)
|
||||
.forEach((match) => {
|
||||
if (!map.has(match.group!)) {
|
||||
map.set(match.group!, []);
|
||||
}
|
||||
map.get(match.group!)!.push(match);
|
||||
});
|
||||
|
||||
map.forEach((groupMatches, groupId) => {
|
||||
map.set(groupId, orderMatchesWithSpacing(groupMatches));
|
||||
});
|
||||
|
||||
return map;
|
||||
}, [matches]);
|
||||
|
||||
const sortedGroups = useMemo(() => {
|
||||
return [...groups].sort((a, b) => a.order - b.order);
|
||||
}, [groups]);
|
||||
|
||||
const toggleTeams = (groupId: string) => {
|
||||
setExpandedTeams((prev) => ({
|
||||
...prev,
|
||||
[groupId]: !prev[groupId],
|
||||
}));
|
||||
};
|
||||
|
||||
const getTeamStandings = (groupId: string, teams: any[]): TeamStanding[] => {
|
||||
const groupMatches = matchesByGroup.get(groupId) || [];
|
||||
const standings: Map<string, TeamStanding> = new Map();
|
||||
|
||||
teams.forEach((team) => {
|
||||
standings.set(team.id, {
|
||||
teamId: team.id,
|
||||
teamName: team.name,
|
||||
team: team,
|
||||
wins: 0,
|
||||
losses: 0,
|
||||
cupDifference: 0,
|
||||
});
|
||||
});
|
||||
|
||||
groupMatches
|
||||
.filter((match) => match.status === "ended")
|
||||
.forEach((match) => {
|
||||
const homeId = match.home?.id;
|
||||
const awayId = match.away?.id;
|
||||
|
||||
if (!homeId || !awayId) return;
|
||||
|
||||
const homeStanding = standings.get(homeId);
|
||||
const awayStanding = standings.get(awayId);
|
||||
|
||||
if (!homeStanding || !awayStanding) return;
|
||||
|
||||
const homeCups = match.home_cups || 0;
|
||||
const awayCups = match.away_cups || 0;
|
||||
|
||||
homeStanding.cupDifference += homeCups - awayCups;
|
||||
awayStanding.cupDifference += awayCups - homeCups;
|
||||
|
||||
if (homeCups > awayCups) {
|
||||
homeStanding.wins++;
|
||||
awayStanding.losses++;
|
||||
} else if (awayCups > homeCups) {
|
||||
awayStanding.wins++;
|
||||
homeStanding.losses++;
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(standings.values()).sort((a, b) => {
|
||||
if (b.wins !== a.wins) return b.wins - a.wins;
|
||||
return b.cupDifference - a.cupDifference;
|
||||
});
|
||||
};
|
||||
|
||||
if (sortedGroups.length === 0) {
|
||||
return (
|
||||
<Box p="md">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
No groups have been created yet
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box p="md">
|
||||
<Tabs defaultValue={sortedGroups[0]?.id}>
|
||||
<Tabs.List mb="md" grow>
|
||||
{sortedGroups.map((group) => {
|
||||
const groupMatches = matchesByGroup.get(group.id) || [];
|
||||
const completedMatches = groupMatches.filter((m) => m.status === "ended").length;
|
||||
const totalMatches = groupMatches.length;
|
||||
|
||||
return (
|
||||
<Tabs.Tab
|
||||
key={group.id}
|
||||
value={group.id}
|
||||
style={{
|
||||
padding: '12px 20px',
|
||||
}}
|
||||
>
|
||||
<MantineGroup gap="xs" justify="center" wrap="nowrap">
|
||||
<Text fw={600} size="sm">Group {group.name}</Text>
|
||||
<Text size="xs" c="dimmed" style={{ opacity: 0.7 }}>
|
||||
({completedMatches}/{totalMatches})
|
||||
</Text>
|
||||
</MantineGroup>
|
||||
</Tabs.Tab>
|
||||
);
|
||||
})}
|
||||
</Tabs.List>
|
||||
|
||||
{sortedGroups.map((group) => {
|
||||
const groupMatches = matchesByGroup.get(group.id) || [];
|
||||
const standings = getTeamStandings(group.id, group.teams || []);
|
||||
|
||||
return (
|
||||
<Tabs.Panel key={group.id} value={group.id}>
|
||||
<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>
|
||||
|
||||
{/* Matches Grid */}
|
||||
{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>
|
||||
</Tabs.Panel>
|
||||
);
|
||||
})}
|
||||
</Tabs>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupStageView;
|
||||
182
src/features/tournaments/components/setup-group-stage.tsx
Normal file
182
src/features/tournaments/components/setup-group-stage.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import React, { useState, useMemo } from "react";
|
||||
import {
|
||||
Stack,
|
||||
Text,
|
||||
Group,
|
||||
Button,
|
||||
Select,
|
||||
LoadingOverlay,
|
||||
Alert,
|
||||
} from "@mantine/core";
|
||||
import { InfoIcon } from "@phosphor-icons/react";
|
||||
import { useServerMutation } from "@/lib/tanstack-query/hooks/use-server-mutation";
|
||||
import { generateGroupStage } from "../server";
|
||||
import { TeamInfo } from "@/features/teams/types";
|
||||
import {
|
||||
calculateGroupConfigurations,
|
||||
assignTeamsToGroups,
|
||||
getGroupName,
|
||||
GroupConfigOption,
|
||||
} from "../utils/group-config";
|
||||
import GroupPreview from "./group-preview";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { tournamentKeys } from "../queries";
|
||||
|
||||
interface SetupGroupStageProps {
|
||||
tournamentId: string;
|
||||
teams: TeamInfo[];
|
||||
}
|
||||
|
||||
const SetupGroupStage: React.FC<SetupGroupStageProps> = ({
|
||||
tournamentId,
|
||||
teams,
|
||||
}) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [selectedConfigIndex, setSelectedConfigIndex] = useState<string>("0");
|
||||
const [seed, setSeed] = useState<number>(Date.now());
|
||||
|
||||
const configurations = useMemo(() => {
|
||||
try {
|
||||
return calculateGroupConfigurations(teams.length);
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}, [teams.length]);
|
||||
|
||||
const selectedConfig: GroupConfigOption | null = useMemo(() => {
|
||||
const index = parseInt(selectedConfigIndex);
|
||||
return configurations[index] || null;
|
||||
}, [selectedConfigIndex, configurations]);
|
||||
|
||||
const groupAssignments = useMemo(() => {
|
||||
if (!selectedConfig) return [];
|
||||
|
||||
const teamIds = teams.map((t) => t.id);
|
||||
const assignments = assignTeamsToGroups(teamIds, selectedConfig, seed);
|
||||
|
||||
return assignments.map((teamIds, index) => ({
|
||||
groupIndex: index,
|
||||
groupName: getGroupName(index),
|
||||
teams: teamIds.map((id) => teams.find((t) => t.id === id)!).filter(Boolean),
|
||||
teamIds,
|
||||
}));
|
||||
}, [selectedConfig, teams, seed]);
|
||||
|
||||
const generateGroups = useServerMutation({
|
||||
mutationFn: generateGroupStage,
|
||||
successMessage: "Group stage generated successfully!",
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: tournamentKeys.details(tournamentId),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleGenerateGroups = () => {
|
||||
if (!selectedConfig) return;
|
||||
|
||||
generateGroups.mutate({
|
||||
data: {
|
||||
tournamentId,
|
||||
groupConfig: {
|
||||
num_groups: selectedConfig.num_groups,
|
||||
teams_per_group: selectedConfig.teams_per_group,
|
||||
advance_per_group: selectedConfig.advance_per_group,
|
||||
matches_guaranteed: selectedConfig.matches_guaranteed,
|
||||
seeding_method: selectedConfig.seeding_method,
|
||||
},
|
||||
teamAssignments: groupAssignments.map((g) => ({
|
||||
groupIndex: g.groupIndex,
|
||||
groupName: g.groupName,
|
||||
teamIds: g.teamIds,
|
||||
})),
|
||||
seed,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleShuffle = () => {
|
||||
setSeed(Date.now());
|
||||
};
|
||||
|
||||
if (configurations.length === 0) {
|
||||
return (
|
||||
<Alert color="red" title="Cannot create groups" icon={<InfoIcon />}>
|
||||
Need at least 4 teams to create a group stage format. Current team count: {teams.length}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", gap: "2rem", alignItems: "flex-start" }}>
|
||||
<Stack gap="lg" style={{ flexShrink: 0, width: 400 }}>
|
||||
<Stack gap={0} pos="relative">
|
||||
<LoadingOverlay visible={generateGroups.isPending} />
|
||||
|
||||
<Group gap="xs" p="md" pb="sm" align="center">
|
||||
<Text fw={600} size="lg">
|
||||
Group Stage Setup
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" ml="auto">
|
||||
{teams.length} teams
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Stack gap="md" p="md" pt={0}>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
Group Configuration
|
||||
</Text>
|
||||
<Select
|
||||
value={selectedConfigIndex}
|
||||
onChange={(value) => setSelectedConfigIndex(value || "0")}
|
||||
data={configurations.map((config, index) => ({
|
||||
value: index.toString(),
|
||||
label: config.description,
|
||||
}))}
|
||||
styles={{
|
||||
dropdown: { maxHeight: 300, overflowY: "auto" },
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{selectedConfig && (
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" c="dimmed">
|
||||
{selectedConfig.total_group_matches} total group stage matches
|
||||
</Text>
|
||||
{selectedConfig.wildcards_needed > 0 && (
|
||||
<Text size="xs" c="yellow">
|
||||
⚠ {selectedConfig.wildcards_needed} wildcard spot{selectedConfig.wildcards_needed > 1 ? 's' : ''} needed for knockout bracket
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Button size="sm" variant="light" onClick={handleShuffle}>
|
||||
Shuffle Groups
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleGenerateGroups}
|
||||
loading={generateGroups.isPending}
|
||||
disabled={!selectedConfig}
|
||||
>
|
||||
Confirm Setup
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<div style={{ flex: 1, overflow: "auto", maxHeight: "80vh" }}>
|
||||
{groupAssignments.length > 0 && (
|
||||
<GroupPreview groups={groupAssignments} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SetupGroupStage;
|
||||
@@ -4,7 +4,7 @@ import { useAuth } from "@/contexts/auth-context";
|
||||
import { Box, Divider, Stack, Text, Card, Center } from "@mantine/core";
|
||||
import { Carousel } from "@mantine/carousel";
|
||||
import ListLink from "@/components/list-link";
|
||||
import { TreeStructureIcon, UsersIcon, ClockIcon, TrophyIcon } from "@phosphor-icons/react";
|
||||
import { TreeStructureIcon, UsersIcon, ClockIcon, ListDashes } from "@phosphor-icons/react";
|
||||
import TeamListButton from "../upcoming-tournament/team-list-button";
|
||||
import RulesListButton from "../upcoming-tournament/rules-list-button";
|
||||
import MatchCard from "@/features/matches/components/match-card";
|
||||
@@ -37,6 +37,10 @@ const StartedTournament: React.FC<{ tournament: Tournament }> = ({
|
||||
return finalsMatch?.status === 'ended';
|
||||
}, [tournament.matches]);
|
||||
|
||||
const hasGroupStage = useMemo(() => {
|
||||
return tournament.matches?.some((match) => match.round === -1) || false;
|
||||
}, [tournament.matches]);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Header tournament={tournament} />
|
||||
@@ -83,6 +87,13 @@ const StartedTournament: React.FC<{ tournament: Tournament }> = ({
|
||||
Icon={UsersIcon}
|
||||
/>
|
||||
)}
|
||||
{hasGroupStage && (
|
||||
<ListLink
|
||||
label={`View Groups`}
|
||||
to={`/tournaments/${tournament.id}/groups`}
|
||||
Icon={ListDashes}
|
||||
/>
|
||||
)}
|
||||
<ListLink
|
||||
label={`View Bracket`}
|
||||
to={`/tournaments/${tournament.id}/bracket`}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
Alert,
|
||||
} from "@mantine/core";
|
||||
import { Tournament } from "@/features/tournaments/types";
|
||||
import { CrownIcon, TreeStructureIcon, InfoIcon } from "@phosphor-icons/react";
|
||||
import { CrownIcon, TreeStructureIcon, InfoIcon, ListDashes } from "@phosphor-icons/react";
|
||||
import Avatar from "@/components/avatar";
|
||||
import ListLink from "@/components/list-link";
|
||||
import { Podium } from "./podium";
|
||||
@@ -33,6 +33,10 @@ export const TournamentStats = memo(({ tournament }: TournamentStatsProps) => {
|
||||
[nonByeMatches]
|
||||
);
|
||||
|
||||
const hasGroupStage = useMemo(() => {
|
||||
return tournament.matches?.some((match) => match.round === -1) || false;
|
||||
}, [tournament.matches]);
|
||||
|
||||
const sortedTeamStats = useMemo(() => {
|
||||
return [...(tournament.team_stats || [])].sort((a, b) => {
|
||||
if (b.wins !== a.wins) {
|
||||
@@ -163,6 +167,13 @@ export const TournamentStats = memo(({ tournament }: TournamentStatsProps) => {
|
||||
</Alert>
|
||||
)}
|
||||
{!tournament.regional && <Podium tournament={tournament} />}
|
||||
{hasGroupStage && (
|
||||
<ListLink
|
||||
label={`View Groups`}
|
||||
to={`/tournaments/${tournament.id}/groups`}
|
||||
Icon={ListDashes}
|
||||
/>
|
||||
)}
|
||||
<ListLink
|
||||
label={`View Bracket`}
|
||||
to={`/tournaments/${tournament.id}/bracket`}
|
||||
|
||||
@@ -148,7 +148,30 @@ export const generateRandomTeams = createServerFn()
|
||||
throw new Error("Need an even number of players to create teams");
|
||||
}
|
||||
|
||||
const playerIds = freeAgents.map(fa => fa.player?.id).filter(Boolean) as string[];
|
||||
const uniqueFreeAgents = Array.from(
|
||||
new Map(
|
||||
freeAgents
|
||||
.filter(fa => fa.player?.id)
|
||||
.map(fa => [fa.player!.id, fa])
|
||||
).values()
|
||||
);
|
||||
|
||||
if (uniqueFreeAgents.length !== freeAgents.length) {
|
||||
logger.warn('Duplicate free agents detected', {
|
||||
freeAgentCount: freeAgents.length,
|
||||
uniquePlayerCount: uniqueFreeAgents.length
|
||||
});
|
||||
}
|
||||
|
||||
if (uniqueFreeAgents.length < 2) {
|
||||
throw new Error("Need at least 2 unique players to create teams");
|
||||
}
|
||||
|
||||
if (uniqueFreeAgents.length % 2 !== 0) {
|
||||
throw new Error("Need an even number of unique players to create teams");
|
||||
}
|
||||
|
||||
const playerIds = uniqueFreeAgents.map(fa => fa.player!.id);
|
||||
|
||||
const allTeams = await pbAdmin.getTeamsWithFilter(
|
||||
playerIds.map(id => `players.id ?= "${id}"`).join(" || "),
|
||||
@@ -222,7 +245,7 @@ export const generateRandomTeams = createServerFn()
|
||||
}
|
||||
|
||||
let currentSeed = seed;
|
||||
const shuffled = [...freeAgents];
|
||||
const shuffled = [...uniqueFreeAgents];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(seededRandom(currentSeed++) * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
@@ -386,3 +409,107 @@ export const adminUnenrollPlayer = createServerFn()
|
||||
logger.info('Admin unenrolled player', { playerId: data.playerId, tournamentId: data.tournamentId });
|
||||
})
|
||||
);
|
||||
|
||||
export const generateGroupStage = createServerFn()
|
||||
.inputValidator(z.object({
|
||||
tournamentId: z.string(),
|
||||
groupConfig: z.object({
|
||||
num_groups: z.number(),
|
||||
teams_per_group: z.number(),
|
||||
advance_per_group: z.number(),
|
||||
matches_guaranteed: z.number(),
|
||||
seeding_method: z.enum(["random", "ranked"]),
|
||||
}),
|
||||
teamAssignments: z.array(z.object({
|
||||
groupIndex: z.number(),
|
||||
groupName: z.string(),
|
||||
teamIds: z.array(z.string())
|
||||
})),
|
||||
seed: z.number().optional()
|
||||
}))
|
||||
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.handler(async ({ data }) =>
|
||||
toServerResult(async () => {
|
||||
logger.info('Generating group stage', {
|
||||
tournamentId: data.tournamentId,
|
||||
numGroups: data.groupConfig.num_groups,
|
||||
seed: data.seed
|
||||
});
|
||||
|
||||
const tournament = await pbAdmin.getTournament(data.tournamentId);
|
||||
if (!tournament) {
|
||||
throw new Error("Tournament not found");
|
||||
}
|
||||
|
||||
if (tournament.matches && tournament.matches.length > 0) {
|
||||
throw new Error("Tournament already has matches generated");
|
||||
}
|
||||
|
||||
await pbAdmin.deleteGroupsByTournament(data.tournamentId);
|
||||
|
||||
const createdGroups = [];
|
||||
const allMatches = [];
|
||||
let matchLid = 1;
|
||||
|
||||
for (const assignment of data.teamAssignments) {
|
||||
const group = await pbAdmin.createGroup({
|
||||
tournament: data.tournamentId,
|
||||
name: assignment.groupName,
|
||||
order: assignment.groupIndex,
|
||||
teams: assignment.teamIds
|
||||
});
|
||||
|
||||
createdGroups.push(group);
|
||||
|
||||
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++,
|
||||
round: -1,
|
||||
order: allMatches.length,
|
||||
reset: false,
|
||||
bye: false,
|
||||
home: teamIds[i],
|
||||
away: teamIds[j],
|
||||
home_cups: 0,
|
||||
away_cups: 0,
|
||||
ot_count: 0,
|
||||
home_from_lid: -1,
|
||||
away_from_lid: -1,
|
||||
home_from_loser: false,
|
||||
away_from_loser: false,
|
||||
is_losers_bracket: false,
|
||||
match_type: "group_stage" as const,
|
||||
group: group.id,
|
||||
status: "ready" as const,
|
||||
tournament: data.tournamentId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const createdMatches = await pbAdmin.createMatches(allMatches);
|
||||
|
||||
const matchIds = createdMatches.map((match) => match.id);
|
||||
await pbAdmin.updateTournamentMatches(data.tournamentId, matchIds);
|
||||
|
||||
await pbAdmin.updateTournament(data.tournamentId, {
|
||||
phase: "group_stage",
|
||||
group_config: data.groupConfig
|
||||
});
|
||||
|
||||
logger.info('Group stage generated', {
|
||||
tournamentId: data.tournamentId,
|
||||
groupCount: createdGroups.length,
|
||||
matchCount: createdMatches.length
|
||||
});
|
||||
|
||||
return {
|
||||
tournament,
|
||||
groups: createdGroups,
|
||||
matchCount: createdMatches.length,
|
||||
matches: createdMatches
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
@@ -89,6 +89,15 @@ 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(),
|
||||
phase: z.enum(["seeding", "group_stage", "knockout", "completed"]).optional(),
|
||||
group_config: z.object({
|
||||
num_groups: z.number(),
|
||||
teams_per_group: z.number(),
|
||||
advance_per_group: z.number(),
|
||||
matches_guaranteed: z.number(),
|
||||
seeding_method: z.enum(["random", "ranked"]),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
export type TournamentInput = z.infer<typeof tournamentInputSchema>;
|
||||
|
||||
167
src/features/tournaments/utils/group-config.ts
Normal file
167
src/features/tournaments/utils/group-config.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { GroupConfig } from "../types";
|
||||
|
||||
function isPowerOfTwo(n: number): boolean {
|
||||
return n > 0 && (n & (n - 1)) === 0;
|
||||
}
|
||||
|
||||
function getNextPowerOfTwo(n: number): number {
|
||||
if (n <= 0) return 1;
|
||||
if (isPowerOfTwo(n)) return n;
|
||||
return Math.pow(2, Math.ceil(Math.log2(n)));
|
||||
}
|
||||
|
||||
export interface GroupConfigOption extends GroupConfig {
|
||||
groups_with_extra: number;
|
||||
knockout_size: number;
|
||||
wildcards_needed: number;
|
||||
total_group_matches: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function calculateGroupConfigurations(teamCount: number): GroupConfigOption[] {
|
||||
if (teamCount < 4) {
|
||||
throw new Error("Need at least 4 teams for group stage");
|
||||
}
|
||||
|
||||
const configs: GroupConfigOption[] = [];
|
||||
|
||||
for (let teamsPerGroup = 3; teamsPerGroup <= Math.min(6, teamCount); teamsPerGroup++) {
|
||||
const numGroups = Math.floor(teamCount / teamsPerGroup);
|
||||
const remainder = teamCount % teamsPerGroup;
|
||||
|
||||
if (numGroups < 2) continue;
|
||||
|
||||
if (remainder > numGroups) continue;
|
||||
|
||||
const groupsWithExtra = remainder;
|
||||
const matchesGuaranteed = teamsPerGroup - 1;
|
||||
|
||||
for (let advancePerGroup = 1; advancePerGroup <= Math.min(3, teamsPerGroup - 1); advancePerGroup++) {
|
||||
const teamsAdvancing = numGroups * advancePerGroup;
|
||||
|
||||
if (teamsAdvancing < 4 || teamsAdvancing > 32) continue;
|
||||
|
||||
const knockoutSize = getNextPowerOfTwo(teamsAdvancing);
|
||||
const wildcardsNeeded = knockoutSize - teamsAdvancing;
|
||||
|
||||
if (wildcardsNeeded > teamsAdvancing / 2) continue;
|
||||
|
||||
let totalGroupMatches = 0;
|
||||
for (let i = 0; i < numGroups; i++) {
|
||||
const groupSize = teamsPerGroup + (i < groupsWithExtra ? 1 : 0);
|
||||
totalGroupMatches += (groupSize * (groupSize - 1)) / 2;
|
||||
}
|
||||
|
||||
const description = generateDescription({
|
||||
num_groups: numGroups,
|
||||
teams_per_group: teamsPerGroup,
|
||||
groups_with_extra: groupsWithExtra,
|
||||
advance_per_group: advancePerGroup,
|
||||
matches_guaranteed: matchesGuaranteed,
|
||||
knockout_size: knockoutSize,
|
||||
wildcards_needed: wildcardsNeeded,
|
||||
});
|
||||
|
||||
configs.push({
|
||||
num_groups: numGroups,
|
||||
teams_per_group: teamsPerGroup,
|
||||
advance_per_group: advancePerGroup,
|
||||
matches_guaranteed: matchesGuaranteed,
|
||||
seeding_method: "random",
|
||||
groups_with_extra: groupsWithExtra,
|
||||
knockout_size: knockoutSize,
|
||||
wildcards_needed: wildcardsNeeded,
|
||||
total_group_matches: totalGroupMatches,
|
||||
description,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return configs.sort((a, b) => {
|
||||
if (a.matches_guaranteed !== b.matches_guaranteed) {
|
||||
return b.matches_guaranteed - a.matches_guaranteed;
|
||||
}
|
||||
if (a.wildcards_needed !== b.wildcards_needed) {
|
||||
return a.wildcards_needed - b.wildcards_needed;
|
||||
}
|
||||
if (a.knockout_size !== b.knockout_size) {
|
||||
return b.knockout_size - a.knockout_size;
|
||||
}
|
||||
return b.num_groups - a.num_groups;
|
||||
});
|
||||
}
|
||||
|
||||
function generateDescription(config: Partial<GroupConfigOption>): string {
|
||||
const { num_groups, teams_per_group, groups_with_extra, matches_guaranteed, advance_per_group, knockout_size, wildcards_needed } = config;
|
||||
|
||||
let desc = '';
|
||||
|
||||
if (groups_with_extra && groups_with_extra > 0) {
|
||||
const largerGroupSize = teams_per_group + 1;
|
||||
const smallerGroupCount = num_groups! - groups_with_extra;
|
||||
|
||||
if (smallerGroupCount > 0) {
|
||||
desc += `${groups_with_extra} group${groups_with_extra > 1 ? 's' : ''} of ${largerGroupSize}, `;
|
||||
desc += `${smallerGroupCount} group${smallerGroupCount > 1 ? 's' : ''} of ${teams_per_group}`;
|
||||
} else {
|
||||
desc += `${num_groups} group${num_groups! > 1 ? 's' : ''} of ${largerGroupSize}`;
|
||||
}
|
||||
} else {
|
||||
desc += `${num_groups} group${num_groups! > 1 ? 's' : ''} of ${teams_per_group}`;
|
||||
}
|
||||
|
||||
desc += ` • ${matches_guaranteed} match${matches_guaranteed! > 1 ? 'es' : ''} guaranteed`;
|
||||
desc += ` • Top ${advance_per_group} advance`;
|
||||
|
||||
if (wildcards_needed && wildcards_needed > 0) {
|
||||
desc += ` + ${wildcards_needed} wildcard${wildcards_needed > 1 ? 's' : ''}`;
|
||||
}
|
||||
|
||||
desc += ` → ${knockout_size}-team knockout`;
|
||||
|
||||
return desc;
|
||||
}
|
||||
|
||||
export function assignTeamsToGroups(
|
||||
teamIds: string[],
|
||||
config: GroupConfigOption,
|
||||
seed?: number
|
||||
): string[][] {
|
||||
const shuffled = shuffleArray([...teamIds], seed);
|
||||
|
||||
const groups: string[][] = [];
|
||||
let teamIndex = 0;
|
||||
|
||||
for (let groupIndex = 0; groupIndex < config.num_groups; groupIndex++) {
|
||||
const groupSize = config.teams_per_group + (groupIndex < config.groups_with_extra ? 1 : 0);
|
||||
const groupTeams = shuffled.slice(teamIndex, teamIndex + groupSize);
|
||||
groups.push(groupTeams);
|
||||
teamIndex += groupSize;
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
function shuffleArray<T>(array: T[], seed?: number): T[] {
|
||||
const arr = [...array];
|
||||
const random = seed !== undefined ? seededRandom(seed) : Math.random;
|
||||
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(random() * (i + 1));
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
}
|
||||
|
||||
return arr;
|
||||
}
|
||||
|
||||
function seededRandom(seed: number): () => number {
|
||||
let value = seed;
|
||||
return () => {
|
||||
value = (value * 9301 + 49297) % 233280;
|
||||
return value / 233280;
|
||||
};
|
||||
}
|
||||
|
||||
export function getGroupName(index: number): string {
|
||||
return String.fromCharCode(65 + index);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { createMatchesService } from "./services/matches";
|
||||
import { createReactionsService } from "./services/reactions";
|
||||
import { createActivitiesService } from "./services/activities";
|
||||
import { createBadgesService } from "./services/badges";
|
||||
import { createGroupsService } from "./services/groups";
|
||||
|
||||
class PocketBaseAdminClient {
|
||||
private pb: PocketBase;
|
||||
@@ -46,6 +47,7 @@ class PocketBaseAdminClient {
|
||||
Object.assign(this, createReactionsService(this.pb));
|
||||
Object.assign(this, createActivitiesService(this.pb));
|
||||
Object.assign(this, createBadgesService(this.pb));
|
||||
Object.assign(this, createGroupsService(this.pb));
|
||||
|
||||
this.authPromise = this.authenticate();
|
||||
this.authPromise.then(() => {
|
||||
@@ -123,7 +125,8 @@ interface AdminClient
|
||||
ReturnType<typeof createMatchesService>,
|
||||
ReturnType<typeof createReactionsService>,
|
||||
ReturnType<typeof createActivitiesService>,
|
||||
ReturnType<typeof createBadgesService> {
|
||||
ReturnType<typeof createBadgesService>,
|
||||
ReturnType<typeof createGroupsService> {
|
||||
authPromise: Promise<void>;
|
||||
}
|
||||
|
||||
|
||||
46
src/lib/pocketbase/services/groups.ts
Normal file
46
src/lib/pocketbase/services/groups.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { logger } from "@/lib/logger";
|
||||
import PocketBase from "pocketbase";
|
||||
import { Group } from "@/features/tournaments/types";
|
||||
|
||||
export interface GroupInput {
|
||||
tournament: string;
|
||||
name: string;
|
||||
order: number;
|
||||
teams: string[];
|
||||
}
|
||||
|
||||
export function createGroupsService(pb: PocketBase) {
|
||||
return {
|
||||
async createGroup(data: GroupInput): Promise<Group> {
|
||||
logger.info("PocketBase | Creating group", data);
|
||||
const result = await pb.collection("groups").create(data);
|
||||
return result as unknown as Group;
|
||||
},
|
||||
|
||||
async getGroupsByTournament(tournamentId: string): Promise<Group[]> {
|
||||
logger.info("PocketBase | Getting groups for tournament", { tournamentId });
|
||||
const result = await pb.collection("groups").getFullList({
|
||||
filter: `tournament = "${tournamentId}"`,
|
||||
sort: "order",
|
||||
expand: "teams,teams.players"
|
||||
});
|
||||
return result as unknown as Group[];
|
||||
},
|
||||
|
||||
async deleteGroup(groupId: string): Promise<void> {
|
||||
logger.info("PocketBase | Deleting group", { groupId });
|
||||
await pb.collection("groups").delete(groupId);
|
||||
},
|
||||
|
||||
async deleteGroupsByTournament(tournamentId: string): Promise<void> {
|
||||
logger.info("PocketBase | Deleting all groups for tournament", { tournamentId });
|
||||
const groups = await pb.collection("groups").getFullList({
|
||||
filter: `tournament = "${tournamentId}"`
|
||||
});
|
||||
|
||||
for (const group of groups) {
|
||||
await pb.collection("groups").delete(group.id);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -14,17 +14,23 @@ import { PlayerInfo } from "@/features/players/types";
|
||||
export function createTournamentsService(pb: PocketBase) {
|
||||
return {
|
||||
async getTournament(id: string, isAdmin: boolean = false): Promise<Tournament> {
|
||||
const [tournamentResult, teamStatsResult] = await Promise.all([
|
||||
const [tournamentResult, teamStatsResult, groupsResult] = await Promise.all([
|
||||
pb.collection("tournaments").getOne(id, {
|
||||
expand: "teams, teams.players, matches, matches.tournament, matches.home, matches.away, matches.home.players, matches.away.players",
|
||||
}),
|
||||
pb.collection("team_stats_per_tournament").getFullList({
|
||||
filter: `tournament_id = "${id}"`,
|
||||
sort: "-wins,-total_cups_made"
|
||||
}),
|
||||
pb.collection("groups").getFullList({
|
||||
filter: `tournament = "${id}"`,
|
||||
sort: "order",
|
||||
expand: "teams, teams.players"
|
||||
})
|
||||
]);
|
||||
|
||||
tournamentResult.team_stats = teamStatsResult;
|
||||
tournamentResult.groups = groupsResult;
|
||||
|
||||
return transformTournament(tournamentResult, isAdmin);
|
||||
},
|
||||
|
||||
@@ -55,6 +55,8 @@ export const transformMatch = (record: any, isAdmin: boolean = false): Match =>
|
||||
updated: record.updated,
|
||||
home_seed: record.home_seed,
|
||||
away_seed: record.away_seed,
|
||||
match_type: record.match_type,
|
||||
group: record.group,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -247,6 +249,16 @@ export function transformTournament(record: any, isAdmin: boolean = false): Tour
|
||||
}
|
||||
}
|
||||
|
||||
const groups = (record.groups || record.expand?.groups)?.map((group: any) => ({
|
||||
id: group.id,
|
||||
tournament: group.tournament,
|
||||
name: group.name,
|
||||
order: group.order,
|
||||
teams: group.expand?.teams?.map(transformTeamInfo) ?? [],
|
||||
created: group.created,
|
||||
updated: group.updated,
|
||||
})) ?? [];
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
@@ -261,8 +273,12 @@ export function transformTournament(record: any, isAdmin: boolean = false): Tour
|
||||
created: record.created,
|
||||
updated: record.updated,
|
||||
regional: record.regional || false,
|
||||
format: record.format,
|
||||
phase: record.phase,
|
||||
group_config: record.group_config,
|
||||
teams,
|
||||
matches,
|
||||
groups,
|
||||
first_place,
|
||||
second_place,
|
||||
third_place,
|
||||
|
||||
Reference in New Issue
Block a user