regionals enrollments

This commit is contained in:
yohlo
2026-02-21 23:12:21 -06:00
parent 7f60b4d200
commit b9e16e2b64
27 changed files with 1212 additions and 83 deletions

View File

@@ -3,6 +3,7 @@ import { TeamInfo, Team } from "../teams/types";
import { TournamentInfo } from "../tournaments/types";
export type MatchStatus = "tbd" | "ready" | "started" | "ended";
export type MatchType = "group_stage" | "knockout" | "winners" | "losers" | "bracket";
export interface Match {
id: string;
@@ -29,6 +30,8 @@ export interface Match {
updated: string;
home_seed?: number;
away_seed?: number;
match_type?: MatchType;
group?: string;
}
export const matchInputSchema = z.object({
@@ -53,6 +56,8 @@ export const matchInputSchema = z.object({
away: z.string().min(1).optional(),
home_seed: z.number().int().min(1).optional(),
away_seed: z.number().int().min(1).optional(),
match_type: z.enum(["group_stage", "knockout", "winners", "losers", "bracket"]).optional(),
group: z.string().optional(),
});
export type MatchInput = z.infer<typeof matchInputSchema>;

View File

@@ -51,6 +51,7 @@ export const teamInputSchema = z
song_start: z.number().int().optional(),
song_end: z.number().int().optional(),
song_image_url: z.url("Invalid song image URL").optional(),
private: z.boolean().optional(),
players: z.array(z.string()).min(1, "At least one player is required").max(10, "Maximum 10 players allowed"),
})
.refine(

View File

@@ -0,0 +1,162 @@
import {
Stack,
ActionIcon,
Text,
Group,
Loader,
} from "@mantine/core";
import { TrashIcon } from "@phosphor-icons/react";
import { useCallback, memo } from "react";
import { useFreeAgents } from "../queries";
import PlayerAvatar from "@/components/player-avatar";
import { PlayerInfo, Player } from "@/features/players/types";
import Typeahead, { TypeaheadOption } from "@/components/typeahead";
import { usePlayers } from "@/features/players/queries";
import useAdminEnrollPlayer from "../hooks/use-admin-enroll-player";
import useAdminUnenrollPlayer from "../hooks/use-admin-unenroll-player";
interface EditEnrolledPlayersProps {
tournamentId: string;
}
interface PlayerItemProps {
player: PlayerInfo;
onRemove: (playerId: string) => void;
disabled: boolean;
}
const PlayerItem = memo(({ player, onRemove, disabled }: PlayerItemProps) => {
return (
<Group py="xs" px="sm" w="100%" gap="sm" align="center">
<PlayerAvatar
name={`${player.first_name} ${player.last_name}`}
size={32}
/>
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} truncate>
{player.first_name} {player.last_name}
</Text>
</Stack>
<ActionIcon
variant="subtle"
color="red"
onClick={() => onRemove(player.id)}
disabled={disabled}
size="sm"
>
<TrashIcon size={14} />
</ActionIcon>
</Group>
);
});
const EditEnrolledPlayers = ({ tournamentId }: EditEnrolledPlayersProps) => {
const { data: freeAgents = [], isLoading } = useFreeAgents(tournamentId);
const { data: allPlayers = [] } = usePlayers();
const { mutate: removeFreeAgent, isPending: isRemoving } = useAdminUnenrollPlayer();
const { mutate: enrollPlayer, isPending: isEnrolling } = useAdminEnrollPlayer();
const handleRemovePlayer = useCallback(
(playerId: string) => {
removeFreeAgent({ tournamentId, playerId });
},
[removeFreeAgent, tournamentId]
);
const handleEnrollPlayer = useCallback(
(option: TypeaheadOption<Player>) => {
enrollPlayer({ tournamentId, playerId: option.data.id });
},
[enrollPlayer, tournamentId]
);
const enrolledPlayers = freeAgents.map(agent => agent.player).filter((p): p is PlayerInfo => p !== undefined);
const enrolledPlayerIds = new Set(enrolledPlayers.map(p => p.id));
const hasEnrolledPlayers = enrolledPlayers.length > 0;
const searchPlayers = async (query: string): Promise<TypeaheadOption<Player>[]> => {
if (!query.trim()) return [];
const filtered = allPlayers.filter((player: Player) => {
const fullName = `${player.first_name} ${player.last_name}`.toLowerCase();
return fullName.includes(query.toLowerCase()) && !enrolledPlayerIds.has(player.id);
});
return filtered.map((player: Player) => ({
id: player.id,
data: player
}));
};
const renderPlayerOption = (option: TypeaheadOption<Player>) => {
const player = option.data;
return (
<Group py="xs" px="sm" gap="sm" align="center">
<PlayerAvatar
name={`${player.first_name} ${player.last_name}`}
size={32}
/>
<Text fw={500} truncate>
{player.first_name} {player.last_name}
</Text>
</Group>
);
};
const formatPlayer = (option: TypeaheadOption<Player>) => {
return `${option.data.first_name} ${option.data.last_name}`;
};
return (
<Stack gap="lg" w="100%">
<Stack gap="xs">
<Text fw={600} size="sm">
Add Player
</Text>
<Typeahead
placeholder="Search for players to enroll..."
onSelect={handleEnrollPlayer}
searchFn={searchPlayers}
renderOption={renderPlayerOption}
format={formatPlayer}
disabled={isEnrolling}
/>
</Stack>
<Stack gap="xs">
<Group justify="space-between">
<Text fw={600} size="sm">
Enrolled Players
</Text>
<Text size="xs" c="dimmed">
{enrolledPlayers.length} players
</Text>
</Group>
{isLoading ? (
<Group justify="center" py="md">
<Loader size="sm" />
</Group>
) : !hasEnrolledPlayers ? (
<Text size="sm" c="dimmed" ta="center" py="lg">
No players enrolled yet
</Text>
) : (
<Stack gap="xs" w="100%">
{enrolledPlayers.map((player) => (
<PlayerItem
key={player.id}
player={player}
onRemove={handleRemovePlayer}
disabled={isRemoving}
/>
))}
</Stack>
)}
</Stack>
</Stack>
);
};
export default EditEnrolledPlayers;

View File

@@ -9,11 +9,12 @@ import {
TreeStructureIcon,
UsersThreeIcon,
UsersIcon,
ShuffleIcon,
} from "@phosphor-icons/react";
import { useSheet } from "@/hooks/use-sheet";
import EditEnrolledTeams from "./edit-enrolled-teams";
import EditEnrolledPlayers from "./edit-enrolled-players";
import ListLink from "@/components/list-link";
import { RichTextEditor } from "@/components/rich-text-editor";
import React from "react";
import EditRules from "./edit-rules";
@@ -61,11 +62,20 @@ const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
Icon={UsersThreeIcon}
onClick={openEditTeams}
/>
<ListLink
label="Manage Team Songs/Logos"
Icon={UsersIcon}
to={`/admin/tournaments/${tournamentId}/teams`}
/>
{tournament.regional && (
<ListLink
label="Assign Partners"
Icon={ShuffleIcon}
to={`/admin/tournaments/${tournamentId}/assign-partners`}
/>
)}
{!tournament.regional && (
<ListLink
label="Manage Team Songs/Logos"
Icon={UsersIcon}
to={`/admin/tournaments/${tournamentId}/teams`}
/>
)}
<ListLink
label="Run Tournament"
Icon={TreeStructureIcon}
@@ -102,11 +112,15 @@ const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
</Sheet>
<Sheet
title="Edit Enrolled Teams"
title={tournament.regional === true ? "Manage Enrollments" : "Edit Enrolled Teams"}
opened={editTeamsOpened}
onChange={closeEditTeams}
>
<EditEnrolledTeams tournamentId={tournamentId} />
{tournament.regional === true ? (
<EditEnrolledPlayers tournamentId={tournamentId} />
) : (
<EditEnrolledTeams tournamentId={tournamentId} />
)}
</Sheet>
</>
);

View File

@@ -0,0 +1,49 @@
import { Card, Group, Stack, Text, Avatar } from "@mantine/core";
import { PlayerInfo } from "@/features/players/types";
import PlayerAvatar from "@/components/player-avatar";
interface TeamAssignment {
player1: PlayerInfo;
player2: PlayerInfo;
teamName: string;
}
interface TeamAssignmentPreviewProps {
assignments: TeamAssignment[];
}
const TeamAssignmentPreview: React.FC<TeamAssignmentPreviewProps> = ({ assignments }) => {
return (
<Stack gap="sm">
{assignments.map((assignment, index) => (
<Card key={index} withBorder radius="md" p="md">
<Group gap="md" align="center" wrap="nowrap">
<Text size="sm" fw={600} c="dimmed" w={40}>
#{index + 1}
</Text>
<Group gap="sm" style={{ flex: 1 }} align="center">
<PlayerAvatar name={`${assignment.player1.first_name} ${assignment.player1.last_name}`} size={32} />
<Text size="sm" fw={500}>
{assignment.player1.first_name} {assignment.player1.last_name}
</Text>
</Group>
<Text size="lg" c="dimmed">
&
</Text>
<Group gap="sm" style={{ flex: 1 }} align="center">
<PlayerAvatar name={`${assignment.player2.first_name} ${assignment.player2.last_name}`} size={32} />
<Text size="sm" fw={500}>
{assignment.player2.first_name} {assignment.player2.last_name}
</Text>
</Group>
</Group>
</Card>
))}
</Stack>
);
};
export default TeamAssignmentPreview;

View File

@@ -5,13 +5,12 @@ import { useSheet } from "@/hooks/use-sheet";
import { Stack, Text } from "@mantine/core";
import useEnrollFreeAgent from "../../hooks/use-enroll-free-agent";
const EnrollFreeAgent = ({ tournamentId }: {tournamentId: string} ) => {
const EnrollFreeAgent = ({ tournamentId, isRegional }: {tournamentId: string, isRegional?: boolean} ) => {
const { open, isOpen, toggle } = useSheet();
const { user, phone } = useAuth();
const { mutate: enrollFreeAgent, isPending: isEnrolling } = useEnrollFreeAgent();
const { mutate: enrollFreeAgent, isPending: isEnrolling } = useEnrollFreeAgent(isRegional);
const handleEnroll = () => {
console.log('enrolling...')
enrollFreeAgent({ playerId: user!.id, tournamentId, phone }, {
onSuccess: () => {
toggle();
@@ -22,21 +21,31 @@ const EnrollFreeAgent = ({ tournamentId }: {tournamentId: string} ) => {
return (
<>
<Button variant="subtle" size="sm" onClick={open}>
Enroll As Free Agent
{isRegional ? "Enroll" : "Enroll As Free Agent"}
</Button>
<Sheet title="Free Agent Enrollment" opened={isOpen} onChange={toggle}>
<Sheet title={isRegional ? "Enrollment" : "Free Agent Enrollment"} opened={isOpen} onChange={toggle}>
<Stack gap="xs">
<Text size="md">
Enrolling as a free agent adds you to a pool of players looking for teammates.
</Text>
<Text size="sm" c='dimmed'>
Once enrolled, you can view other free agents and their phone number in order to coordinate teams and walkout songs.
</Text>
<Text size="xs" c="dimmed">
Important: Enrolling as a free agent does not guarantee a tournament spot. To secure a spot, one team member must register through the app and select a walkout song.
</Text>
<Button onClick={handleEnroll}>Confirm</Button>
{isRegional ? (
<>
<Text size="md">
Enroll in this regional tournament to be assigned a random partner.
</Text>
</>
) : (
<>
<Text size="md">
Enrolling as a free agent adds you to a pool of players looking for teammates.
</Text>
<Text size="sm" c='dimmed'>
Once enrolled, you can view other free agents and their phone number in order to coordinate teams and walkout songs.
</Text>
<Text size="xs" c="dimmed">
Important: Enrolling as a free agent does not guarantee a tournament spot. To secure a spot, one team member must register through the app and select a walkout song.
</Text>
</>
)}
<Button onClick={handleEnroll} loading={isEnrolling}>Confirm</Button>
<Button variant="subtle" color="red" onClick={toggle}>Cancel</Button>
</Stack>
</Sheet>

View File

@@ -1,13 +1,17 @@
import { Group, Stack, Text, Card, Badge, Box, ActionIcon } from "@mantine/core";
import { UserIcon, PhoneIcon } from "@phosphor-icons/react";
import { PhoneIcon, CheckCircleIcon } from "@phosphor-icons/react";
import { useFreeAgents } from "../../queries";
import UnenrollFreeAgent from "./unenroll-free-agent";
import toast from "@/lib/sonner";
import { useAuth } from "@/contexts/auth-context";
import PlayerAvatar from "@/components/player-avatar";
const EnrolledFreeAgent: React.FC<{ tournamentId: string }> = ({
tournamentId
const EnrolledFreeAgent: React.FC<{ tournamentId: string, isRegional?: boolean }> = ({
tournamentId,
isRegional
}) => {
const { data: freeAgents } = useFreeAgents(tournamentId);
const { user } = useAuth();
const copyToClipboard = async (phone: string) => {
try {
@@ -38,33 +42,66 @@ const EnrolledFreeAgent: React.FC<{ tournamentId: string }> = ({
}
};
if (isRegional) {
return (
<Stack gap="sm">
<Card withBorder radius="md" p="md">
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap="md" align="center">
<PlayerAvatar name={`${user?.first_name} ${user?.last_name}`} size={48} />
<Box>
<Text size="sm" fw={600}>
{user?.first_name} {user?.last_name}
</Text>
<Group gap={4} align="center">
<CheckCircleIcon size={14} weight="fill" color="var(--mantine-color-green-6)" />
<Text size="xs" c="green" fw={500}>
Enrolled
</Text>
</Group>
</Box>
</Group>
</Group>
</Card>
<Text size="xs" c="dimmed" ta="center">
Partners will be randomly assigned when enrollment closes
</Text>
<UnenrollFreeAgent tournamentId={tournamentId} isRegional={isRegional} />
</Stack>
);
}
return (
<Stack gap="md">
<Group justify="space-between" align="center">
<Group gap="xs" align="center">
<UserIcon size={16} />
<Text size="sm" fw={500}>
Enrolled as Free Agent
</Text>
</Group>
</Group>
<Stack gap="sm">
<Text size="sm" fw={600} c="green">
Enrolled as Free Agent
</Text>
<Text size="xs" c="dimmed">
You're on the free agent list. Other free agents looking for teams:
Other players looking for teammates:
</Text>
{freeAgents.length > 1 ? (
<Card withBorder radius="md" p="sm">
<Stack gap="xs">
<Group gap="xs" align="center">
<Text size="xs" fw={500} c="dimmed">
Free Agents
</Text>
<Badge variant="light" size="xs" color="blue">
{freeAgents.length}
</Badge>
</Group>
<Stack gap="xs">
{freeAgents
.filter(agent => agent.player)
.map((agent) => (
<Group key={agent.id} justify="space-between" align="center" wrap="nowrap">
<Box style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
{agent.player?.first_name} {agent.player?.last_name}
</Text>
</Box>
<Group key={agent.id} justify="space-between" align="center" wrap="nowrap" p="xs" style={{ borderRadius: '8px', backgroundColor: 'var(--mantine-color-gray-0)' }}>
<Text size="sm" fw={500} truncate>
{agent.player?.first_name} {agent.player?.last_name}
</Text>
{agent.phone && (
<Group gap={4} align="center" style={{ flexShrink: 0 }}>
<ActionIcon
@@ -87,27 +124,15 @@ const EnrolledFreeAgent: React.FC<{ tournamentId: string }> = ({
)}
</Group>
))}
{freeAgents.length > 1 && (
<Badge
variant="light"
size="xs"
color="blue"
style={{ alignSelf: 'flex-start', marginTop: '4px' }}
>
{freeAgents.length} free agents total
</Badge>
)}
</Stack>
</Card>
</Stack>
) : (
<Card withBorder radius="md" p="sm">
<Text size="sm" c="dimmed" ta="center">
You're the only free agent so far
</Text>
</Card>
<Text size="xs" c="dimmed" py="sm">
You're the first free agent!
</Text>
)}
<UnenrollFreeAgent tournamentId={tournamentId} />
<UnenrollFreeAgent tournamentId={tournamentId} isRegional={isRegional} />
</Stack>
);
};

View File

@@ -0,0 +1,46 @@
import ListButton from "@/components/list-button";
import Sheet from "@/components/sheet/sheet";
import { useSheet } from "@/hooks/use-sheet";
import { UserListIcon } from "@phosphor-icons/react";
import { useMemo } from "react";
import { useFreeAgents } from "../../queries";
import { Text } from "@mantine/core";
import PlayerList from "@/features/players/components/player-list";
import { Player } from "@/features/players/types";
interface EnrolledPlayersListButtonProps {
tournamentId: string;
}
const EnrolledPlayersListButton: React.FC<EnrolledPlayersListButtonProps> = ({ tournamentId }) => {
const { data: freeAgents } = useFreeAgents(tournamentId);
const count = useMemo(() => freeAgents.length, [freeAgents]);
const { open, isOpen, toggle } = useSheet();
const players = useMemo(() =>
freeAgents.map(agent => agent.player).filter((player): player is Player => player !== undefined),
[freeAgents]
);
return (
<>
<ListButton
label={`View Enrolled Players (${count})`}
Icon={UserListIcon}
onClick={open}
/>
<Sheet title="Enrolled Players" opened={isOpen} onChange={toggle}>
{count === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="xl">
No players enrolled yet
</Text>
) : (
<PlayerList players={players} />
)}
</Sheet>
</>
);
};
export default EnrolledPlayersListButton;

View File

@@ -1,14 +1,14 @@
import { Suspense, useCallback, useMemo } from "react";
import { Suspense, useMemo } from "react";
import { Tournament } from "../../types";
import { useAuth } from "@/contexts/auth-context";
import { Box, Button, Card, Divider, Group, Stack, Text, Title } from "@mantine/core";
import { Box, Card, Divider, Group, Stack, Text, Title } from "@mantine/core";
import Countdown from "@/components/countdown";
import ListLink from "@/components/list-link";
import ListButton from "@/components/list-button";
import { TreeStructureIcon, UsersIcon } from "@phosphor-icons/react";
import EnrollTeam from "./enroll-team";
import EnrollFreeAgent from "./enroll-free-agent";
import TeamListButton from "./team-list-button";
import EnrolledPlayersListButton from "./enrolled-players-list-button";
import Header from "./header";
import TeamCardSkeleton from "@/features/teams/components/team-card-skeleton";
import TeamCard from "@/features/teams/components/team-card";
@@ -80,12 +80,19 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
{!isUserEnrolled && isEnrollmentOpen && !isFreeAgent && (
<>
<EnrollTeam
{!tournament.regional && (
<>
<EnrollTeam
tournamentId={tournament.id}
onSubmit={handleSubmit}
/>
<Divider my={0} label="or" />
</>
)}
<EnrollFreeAgent
tournamentId={tournament.id}
onSubmit={handleSubmit}
isRegional={tournament.regional}
/>
<Divider my={0} label="or" />
<EnrollFreeAgent tournamentId={tournament.id} />
</>
)}
@@ -107,7 +114,10 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
{
isFreeAgent && isEnrollmentOpen && (
<EnrolledFreeAgent tournamentId={tournament.id} />
<EnrolledFreeAgent
tournamentId={tournament.id}
isRegional={tournament.regional}
/>
)
}
@@ -130,7 +140,11 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
Icon={TreeStructureIcon}
disabled
/>
<TeamListButton teams={tournament.teams || []} />
{tournament.regional === true ? (
<EnrolledPlayersListButton tournamentId={tournament.id} />
) : (
<TeamListButton teams={tournament.teams || []} />
)}
<RulesListButton tournamentId={tournament.id} />
</Box>
</Stack>

View File

@@ -5,11 +5,11 @@ import { useSheet } from "@/hooks/use-sheet";
import { Stack, Text } from "@mantine/core";
import useUnenrollFreeAgent from "../../hooks/use-unenroll-free-agent";
const UnenrollFreeAgent = ({ tournamentId }: {tournamentId: string} ) => {
const UnenrollFreeAgent = ({ tournamentId, isRegional }: {tournamentId: string, isRegional?: boolean} ) => {
const { open, isOpen, toggle } = useSheet();
const { user } = useAuth();
const { mutate: unenrollFreeAgent, isPending: isEnrolling } = useUnenrollFreeAgent();
const { mutate: unenrollFreeAgent, isPending: isEnrolling } = useUnenrollFreeAgent(isRegional);
const handleUnenroll = () => {
unenrollFreeAgent({ playerId: user!.id, tournamentId }, {
onSuccess: () => {
@@ -20,17 +20,21 @@ const UnenrollFreeAgent = ({ tournamentId }: {tournamentId: string} ) => {
return (
<>
<Button variant="subtle" size="sm" onClick={open}>
<Button variant="subtle" size="sm" color="red" onClick={open}>
Unenroll
</Button>
<Sheet title="Are you sure?" opened={isOpen} onChange={toggle}>
<Sheet title="Unenroll from tournament?" opened={isOpen} onChange={toggle}>
<Stack gap="xs">
<Text size="md">
This will remove you from the free agent list.
{isRegional
? "This will remove you from the tournament enrollment."
: "This will remove you from the free agent list."}
</Text>
<Button onClick={handleUnenroll}>Confirm</Button>
<Button variant="subtle" color="red" onClick={toggle}>Cancel</Button>
<Button color="red" onClick={handleUnenroll} loading={isEnrolling}>
Confirm Unenrollment
</Button>
<Button variant="subtle" onClick={toggle}>Cancel</Button>
</Stack>
</Sheet>
</>

View File

@@ -0,0 +1,21 @@
import { useQueryClient } from "@tanstack/react-query";
import { useServerMutation } from "@/lib/tanstack-query/hooks";
import { adminEnrollPlayer } from "@/features/tournaments/server";
import { tournamentKeys } from "../queries";
const useAdminEnrollPlayer = () => {
const queryClient = useQueryClient();
return useServerMutation({
mutationFn: (data: { tournamentId: string, playerId: string }) => {
return adminEnrollPlayer({ data });
},
onSuccess: (data, { tournamentId }) => {
queryClient.invalidateQueries({ queryKey: tournamentKeys.free_agents(tournamentId) });
queryClient.invalidateQueries({ queryKey: tournamentKeys.details(tournamentId) });
},
successMessage: "Player enrolled successfully",
});
};
export default useAdminEnrollPlayer;

View File

@@ -0,0 +1,21 @@
import { useQueryClient } from "@tanstack/react-query";
import { useServerMutation } from "@/lib/tanstack-query/hooks";
import { adminUnenrollPlayer } from "@/features/tournaments/server";
import { tournamentKeys } from "../queries";
const useAdminUnenrollPlayer = () => {
const queryClient = useQueryClient();
return useServerMutation({
mutationFn: (data: { tournamentId: string, playerId: string }) => {
return adminUnenrollPlayer({ data });
},
onSuccess: (data, { tournamentId }) => {
queryClient.invalidateQueries({ queryKey: tournamentKeys.free_agents(tournamentId) });
queryClient.invalidateQueries({ queryKey: tournamentKeys.details(tournamentId) });
},
successMessage: "Player removed successfully",
});
};
export default useAdminUnenrollPlayer;

View File

@@ -0,0 +1,9 @@
import { useServerMutation } from "@/lib/tanstack-query/hooks/use-server-mutation";
import { confirmTeamAssignments } from "../server";
export default function useConfirmTeamAssignments() {
return useServerMutation({
mutationFn: confirmTeamAssignments,
successMessage: "Teams created successfully!",
});
}

View File

@@ -3,7 +3,7 @@ import { useServerMutation } from "@/lib/tanstack-query/hooks";
import { enrollFreeAgent } from "@/features/tournaments/server";
import { tournamentKeys } from "../queries";
const useEnrollFreeAgent = () => {
const useEnrollFreeAgent = (isRegional?: boolean) => {
const queryClient = useQueryClient();
return useServerMutation({
@@ -13,7 +13,7 @@ const useEnrollFreeAgent = () => {
onSuccess: (data, { tournamentId }) => {
queryClient.invalidateQueries({ queryKey: tournamentKeys.free_agents(tournamentId) });
},
successMessage: 'You\'ve been added as a free agent!',
successMessage: isRegional ? "You've enrolled in regionals!" : "You've been added as a free agent!",
});
};

View File

@@ -0,0 +1,8 @@
import { useServerMutation } from "@/lib/tanstack-query/hooks/use-server-mutation";
import { generateRandomTeams } from "../server";
export default function useGenerateRandomTeams() {
return useServerMutation({
mutationFn: generateRandomTeams,
});
}

View File

@@ -3,7 +3,7 @@ import { useServerMutation } from "@/lib/tanstack-query/hooks";
import { unenrollFreeAgent } from "@/features/tournaments/server";
import { tournamentKeys } from "../queries";
const useUnenrollFreeAgent = () => {
const useUnenrollFreeAgent = (isRegional?: boolean) => {
const queryClient = useQueryClient();
return useServerMutation({
@@ -13,7 +13,7 @@ const useUnenrollFreeAgent = () => {
onSuccess: (data, { tournamentId }) => {
queryClient.invalidateQueries({ queryKey: tournamentKeys.free_agents(tournamentId) });
},
successMessage: 'You\'ve been removed as a free agent.',
successMessage: isRegional ? "You've been removed from regionals!" : "You've been removed as a free agent.",
});
};

View File

@@ -129,3 +129,260 @@ export const unenrollFreeAgent = createServerFn()
logger.info('Player unenrolled as free agent', { playerId: player.id });
})
);
export const generateRandomTeams = createServerFn()
.inputValidator(z.object({
tournamentId: z.string(),
seed: z.number().optional()
}))
.middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data }) =>
toServerResult(async () => {
const freeAgents = await pbAdmin.getFreeAgents(data.tournamentId);
if (freeAgents.length < 2) {
throw new Error("Need at least 2 players to create teams");
}
if (freeAgents.length % 2 !== 0) {
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 allTeams = await pbAdmin.getTeamsWithFilter(
playerIds.map(id => `players.id ?= "${id}"`).join(" || "),
"players,tournaments"
);
const invalidPairings = new Set<string>();
const mostRecentRegionalPartners = new Map<string, string>();
let mostRecentRegionalDate: Date | null = null;
for (const team of allTeams) {
const teamPlayers = (team.expand?.players || []) as any[];
if (teamPlayers.length !== 2) continue;
const [p1, p2] = teamPlayers.map((p: any) => p.id).sort();
const pairKey = `${p1}|${p2}`;
const teamTournaments = (team.expand?.tournaments || []) as any[];
const hasMainlineTournament = teamTournaments.some((t: any) => !t.regional);
if (hasMainlineTournament) {
invalidPairings.add(pairKey);
} else if (team.private && teamTournaments.length > 0) {
const regionalTournaments = teamTournaments.filter((t: any) => t.regional);
for (const tournament of regionalTournaments) {
const tournamentDate = new Date(tournament.start_time);
if (!mostRecentRegionalDate || tournamentDate > mostRecentRegionalDate) {
mostRecentRegionalDate = tournamentDate;
}
}
}
}
if (mostRecentRegionalDate) {
for (const team of allTeams) {
if (!team.private) continue;
const teamPlayers = (team.expand?.players || []) as any[];
if (teamPlayers.length !== 2) continue;
const teamTournaments = (team.expand?.tournaments || []) as any[];
const regionalTournaments = teamTournaments.filter((t: any) => t.regional);
for (const tournament of regionalTournaments) {
const tournamentDate = new Date(tournament.start_time);
if (tournamentDate.getTime() === mostRecentRegionalDate.getTime()) {
const [p1Id, p2Id] = teamPlayers.map((p: any) => p.id);
mostRecentRegionalPartners.set(p1Id, p2Id);
mostRecentRegionalPartners.set(p2Id, p1Id);
}
}
}
}
function canPairPlayers(p1Id: string, p2Id: string): boolean {
const pairKey = [p1Id, p2Id].sort().join('|');
if (invalidPairings.has(pairKey)) return false;
const p1LastPartner = mostRecentRegionalPartners.get(p1Id);
if (p1LastPartner === p2Id) return false;
return true;
}
const seed = data.seed || Math.floor(Math.random() * 1000000);
function seededRandom(s: number) {
const x = Math.sin(s++) * 10000;
return x - Math.floor(x);
}
let currentSeed = seed;
const shuffled = [...freeAgents];
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]];
}
const assignments = [];
const paired = new Set<string>();
const MAX_ATTEMPTS = 1000;
let attempts = 0;
while (paired.size < shuffled.length && attempts < MAX_ATTEMPTS) {
attempts++;
for (let i = 0; i < shuffled.length; i++) {
if (paired.has(shuffled[i].player!.id)) continue;
for (let j = i + 1; j < shuffled.length; j++) {
if (paired.has(shuffled[j].player!.id)) continue;
const player1 = shuffled[i].player!;
const player2 = shuffled[j].player!;
if (canPairPlayers(player1.id, player2.id)) {
const teamName = `${player1.first_name} And ${player2.first_name}`;
assignments.push({
player1,
player2,
teamName
});
paired.add(player1.id);
paired.add(player2.id);
break;
}
}
}
if (paired.size < shuffled.length) {
currentSeed++;
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]];
}
assignments.length = 0;
paired.clear();
}
}
if (paired.size < shuffled.length) {
throw new Error("Unable to create valid pairings with current restrictions. Please manually adjust enrollments.");
}
logger.info('Generated random team assignments with restrictions', {
tournamentId: data.tournamentId,
teamCount: assignments.length,
seed,
attempts
});
return { assignments, seed };
})
);
export const confirmTeamAssignments = createServerFn()
.inputValidator(z.object({
tournamentId: z.string(),
assignments: z.array(z.object({
player1Id: z.string(),
player2Id: z.string(),
teamName: z.string()
}))
}))
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ data }) =>
toServerResult(async () => {
const createdTeams = [];
let reusedCount = 0;
for (const assignment of data.assignments) {
const existingTeams = await pbAdmin.getTeamsWithFilter(
`private = true && players.id ?= "${assignment.player1Id}" && players.id ?= "${assignment.player2Id}"`,
"players,tournaments"
);
let teamToUse = null;
for (const team of existingTeams) {
const teamPlayers = (team.expand?.players || []) as any[];
if (teamPlayers.length !== 2) continue;
const playerIds = teamPlayers.map((p: any) => p.id).sort();
const assignmentIds = [assignment.player1Id, assignment.player2Id].sort();
if (playerIds[0] !== assignmentIds[0] || playerIds[1] !== assignmentIds[1]) continue;
const teamTournaments = (team.expand?.tournaments || []) as any[];
const hasMainlineTournament = teamTournaments.some((t: any) => !t.regional);
if (!hasMainlineTournament) {
teamToUse = team;
break;
}
}
if (teamToUse) {
await pbAdmin.enrollTeam(data.tournamentId, teamToUse.id);
createdTeams.push(teamToUse);
reusedCount++;
logger.info('Reusing existing regional team', { teamId: teamToUse.id, teamName: teamToUse.name });
} else {
const team = await pbAdmin.createTeam({
name: assignment.teamName,
players: [assignment.player1Id, assignment.player2Id],
private: true
});
await pbAdmin.enrollTeam(data.tournamentId, team.id);
createdTeams.push(team);
}
}
for (const assignment of data.assignments) {
await pbAdmin.unenrollFreeAgent(assignment.player1Id, data.tournamentId);
await pbAdmin.unenrollFreeAgent(assignment.player2Id, data.tournamentId);
}
logger.info('Confirmed team assignments', {
tournamentId: data.tournamentId,
teamCount: createdTeams.length,
reusedCount,
newCount: createdTeams.length - reusedCount
});
return { teams: createdTeams };
})
);
export const adminEnrollPlayer = createServerFn()
.inputValidator(z.object({
playerId: z.string(),
tournamentId: z.string()
}))
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ data }) =>
toServerResult(async () => {
await pbAdmin.enrollFreeAgent(data.playerId, "", data.tournamentId);
logger.info('Admin enrolled player', { playerId: data.playerId, tournamentId: data.tournamentId });
})
);
export const adminUnenrollPlayer = createServerFn()
.inputValidator(z.object({
playerId: z.string(),
tournamentId: z.string()
}))
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ data }) =>
toServerResult(async () => {
await pbAdmin.unenrollFreeAgent(data.playerId, data.tournamentId);
logger.info('Admin unenrolled player', { playerId: data.playerId, tournamentId: data.tournamentId });
})
);

View File

@@ -2,6 +2,27 @@ import { TeamInfo } from "@/features/teams/types";
import { Match } from "@/features/matches/types";
import { z } from "zod";
export type TournamentFormat = "double_elim" | "group_single_elim";
export type TournamentPhase = "seeding" | "group_stage" | "knockout" | "completed";
export interface GroupConfig {
num_groups: number;
teams_per_group: number;
advance_per_group: number;
matches_guaranteed: number;
seeding_method: "random" | "ranked";
}
export interface Group {
id: string;
tournament: string;
name: string;
order: number;
teams: TeamInfo[];
created: string;
updated: string;
}
export interface TournamentTeamStats {
id: string;
team_id: string;
@@ -52,6 +73,10 @@ export interface Tournament {
third_place?: TeamInfo;
team_stats?: TournamentTeamStats[];
regional?: boolean;
format?: TournamentFormat;
group_config?: GroupConfig;
phase?: TournamentPhase;
groups?: Group[];
}
export const tournamentInputSchema = z.object({