groups init

This commit is contained in:
yohlo
2026-02-25 19:54:51 -06:00
parent 2dd3e5b170
commit f83a7d69c8
17 changed files with 1306 additions and 17 deletions

View File

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

View File

@@ -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 ? (
isGroupStage ? (
<GroupStageView
groups={tournament.groups || []}
matches={tournament.matches}
showControls
/>
) : (
<BracketView bracket={bracket} showControls />
)
) : (
tournament.regional === true ? (
<SetupGroupStage
tournamentId={tournament.id}
teams={tournament.teams || []}
/>
) : (
<SeedTournament
tournamentId={tournament.id}
teams={tournament.teams || []}
isRegional={tournament.regional}
/>
)
)}
</Container>
);

View File

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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