From b9e16e2b64e7ec87d137f8a8f711ed79edb00385 Mon Sep 17 00:00:00 2001 From: yohlo Date: Sat, 21 Feb 2026 23:12:21 -0600 Subject: [PATCH] regionals enrollments --- pb_migrations/1771294794_created_groups.js | 109 ++++++++ .../1771294861_updated_tournaments.js | 52 ++++ .../1771294883_updated_tournaments.js | 25 ++ .../1771294898_updated_tournaments.js | 29 ++ pb_migrations/1771295070_updated_matches.js | 47 ++++ src/app/routeTree.gen.ts | 23 ++ .../admin/tournaments/$id/assign-partners.tsx | 167 ++++++++++++ src/features/matches/types.ts | 5 + src/features/teams/types.ts | 1 + .../components/edit-enrolled-players.tsx | 162 +++++++++++ .../components/manage-tournament.tsx | 30 +- .../components/team-assignment-preview.tsx | 49 ++++ .../upcoming-tournament/enroll-free-agent.tsx | 39 ++- .../enrolled-free-agent.tsx | 101 ++++--- .../enrolled-players-list-button.tsx | 46 ++++ .../components/upcoming-tournament/index.tsx | 32 ++- .../unenroll-free-agent.tsx | 18 +- .../hooks/use-admin-enroll-player.ts | 21 ++ .../hooks/use-admin-unenroll-player.ts | 21 ++ .../hooks/use-confirm-team-assignments.ts | 9 + .../hooks/use-enroll-free-agent.ts | 4 +- .../hooks/use-generate-random-teams.ts | 8 + .../hooks/use-unenroll-free-agent.ts | 4 +- src/features/tournaments/server.ts | 257 ++++++++++++++++++ src/features/tournaments/types.ts | 25 ++ src/lib/pocketbase/client.ts | 2 - src/lib/pocketbase/services/teams.ts | 9 + 27 files changed, 1212 insertions(+), 83 deletions(-) create mode 100644 pb_migrations/1771294794_created_groups.js create mode 100644 pb_migrations/1771294861_updated_tournaments.js create mode 100644 pb_migrations/1771294883_updated_tournaments.js create mode 100644 pb_migrations/1771294898_updated_tournaments.js create mode 100644 pb_migrations/1771295070_updated_matches.js create mode 100644 src/app/routes/_authed/admin/tournaments/$id/assign-partners.tsx create mode 100644 src/features/tournaments/components/edit-enrolled-players.tsx create mode 100644 src/features/tournaments/components/team-assignment-preview.tsx create mode 100644 src/features/tournaments/components/upcoming-tournament/enrolled-players-list-button.tsx create mode 100644 src/features/tournaments/hooks/use-admin-enroll-player.ts create mode 100644 src/features/tournaments/hooks/use-admin-unenroll-player.ts create mode 100644 src/features/tournaments/hooks/use-confirm-team-assignments.ts create mode 100644 src/features/tournaments/hooks/use-generate-random-teams.ts diff --git a/pb_migrations/1771294794_created_groups.js b/pb_migrations/1771294794_created_groups.js new file mode 100644 index 0000000..b6da40e --- /dev/null +++ b/pb_migrations/1771294794_created_groups.js @@ -0,0 +1,109 @@ +/// +migrate((app) => { + const collection = new Collection({ + "createRule": null, + "deleteRule": null, + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3208210256", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_340646327", + "hidden": false, + "id": "relation3177167065", + "maxSelect": 1, + "minSelect": 0, + "name": "tournament", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "autogeneratePattern": "", + "hidden": false, + "id": "text1579384326", + "max": 0, + "min": 0, + "name": "name", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + }, + { + "hidden": false, + "id": "number4113142680", + "max": null, + "min": null, + "name": "order", + "onlyInt": false, + "presentable": false, + "required": false, + "system": false, + "type": "number" + }, + { + "cascadeDelete": false, + "collectionId": "pbc_1568971955", + "hidden": false, + "id": "relation2529305176", + "maxSelect": 999, + "minSelect": 0, + "name": "teams", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + }, + { + "hidden": false, + "id": "autodate2990389176", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate3332085495", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "id": "pbc_3346940990", + "indexes": [], + "listRule": null, + "name": "groups", + "system": false, + "type": "base", + "updateRule": null, + "viewRule": null + }); + + return app.save(collection); +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_3346940990"); + + return app.delete(collection); +}) diff --git a/pb_migrations/1771294861_updated_tournaments.js b/pb_migrations/1771294861_updated_tournaments.js new file mode 100644 index 0000000..184c034 --- /dev/null +++ b/pb_migrations/1771294861_updated_tournaments.js @@ -0,0 +1,52 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_340646327") + + // remove field + collection.fields.removeById("select3736761055") + + // add field + collection.fields.addAt(13, new Field({ + "autogeneratePattern": "", + "hidden": false, + "id": "text3736761055", + "max": 0, + "min": 0, + "name": "format", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_340646327") + + // add field + collection.fields.addAt(13, new Field({ + "hidden": false, + "id": "select3736761055", + "maxSelect": 1, + "name": "format", + "presentable": false, + "required": false, + "system": false, + "type": "select", + "values": [ + "single_elim", + "double_elim", + "groups", + "swiss", + "swiss_bracket", + "round_robin" + ] + })) + + // remove field + collection.fields.removeById("text3736761055") + + return app.save(collection) +}) diff --git a/pb_migrations/1771294883_updated_tournaments.js b/pb_migrations/1771294883_updated_tournaments.js new file mode 100644 index 0000000..56627f0 --- /dev/null +++ b/pb_migrations/1771294883_updated_tournaments.js @@ -0,0 +1,25 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_340646327") + + // add field + collection.fields.addAt(14, new Field({ + "hidden": false, + "id": "json118290348", + "maxSize": 0, + "name": "group_config", + "presentable": false, + "required": false, + "system": false, + "type": "json" + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_340646327") + + // remove field + collection.fields.removeById("json118290348") + + return app.save(collection) +}) diff --git a/pb_migrations/1771294898_updated_tournaments.js b/pb_migrations/1771294898_updated_tournaments.js new file mode 100644 index 0000000..069c1ce --- /dev/null +++ b/pb_migrations/1771294898_updated_tournaments.js @@ -0,0 +1,29 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_340646327") + + // add field + collection.fields.addAt(15, new Field({ + "autogeneratePattern": "", + "hidden": false, + "id": "text2982008523", + "max": 0, + "min": 0, + "name": "phase", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_340646327") + + // remove field + collection.fields.removeById("text2982008523") + + return app.save(collection) +}) diff --git a/pb_migrations/1771295070_updated_matches.js b/pb_migrations/1771295070_updated_matches.js new file mode 100644 index 0000000..a78f5ec --- /dev/null +++ b/pb_migrations/1771295070_updated_matches.js @@ -0,0 +1,47 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("pbc_2541054544") + + // add field + collection.fields.addAt(22, new Field({ + "cascadeDelete": false, + "collectionId": "pbc_3346940990", + "hidden": false, + "id": "relation1841317061", + "maxSelect": 1, + "minSelect": 0, + "name": "group", + "presentable": false, + "required": false, + "system": false, + "type": "relation" + })) + + // add field + collection.fields.addAt(23, new Field({ + "autogeneratePattern": "", + "hidden": false, + "id": "text3987859035", + "max": 0, + "min": 0, + "name": "match_type", + "pattern": "", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "text" + })) + + return app.save(collection) +}, (app) => { + const collection = app.findCollectionByNameOrId("pbc_2541054544") + + // remove field + collection.fields.removeById("relation1841317061") + + // remove field + collection.fields.removeById("text3987859035") + + return app.save(collection) +}) diff --git a/src/app/routeTree.gen.ts b/src/app/routeTree.gen.ts index 39d6270..2732ee2 100644 --- a/src/app/routeTree.gen.ts +++ b/src/app/routeTree.gen.ts @@ -43,6 +43,7 @@ import { Route as AuthedAdminTournamentsIdIndexRouteImport } from './routes/_aut import { Route as ApiFilesCollectionRecordIdFileRouteImport } from './routes/api/files/$collection/$recordId/$file' import { Route as AuthedAdminTournamentsRunIdRouteImport } from './routes/_authed/admin/tournaments/run.$id' import { Route as AuthedAdminTournamentsIdTeamsRouteImport } from './routes/_authed/admin/tournaments/$id/teams' +import { Route as AuthedAdminTournamentsIdAssignPartnersRouteImport } from './routes/_authed/admin/tournaments/$id/assign-partners' const RefreshSessionRoute = RefreshSessionRouteImport.update({ id: '/refresh-session', @@ -221,6 +222,12 @@ const AuthedAdminTournamentsIdTeamsRoute = path: '/tournaments/$id/teams', getParentRoute: () => AuthedAdminRoute, } as any) +const AuthedAdminTournamentsIdAssignPartnersRoute = + AuthedAdminTournamentsIdAssignPartnersRouteImport.update({ + id: '/tournaments/$id/assign-partners', + path: '/tournaments/$id/assign-partners', + getParentRoute: () => AuthedAdminRoute, + } as any) export interface FileRoutesByFullPath { '/': typeof AuthedIndexRoute @@ -252,6 +259,7 @@ export interface FileRoutesByFullPath { '/tournaments/': typeof AuthedTournamentsIndexRoute '/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute '/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute + '/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute '/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute '/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute '/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute @@ -286,6 +294,7 @@ export interface FileRoutesByTo { '/tournaments': typeof AuthedTournamentsIndexRoute '/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute '/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute + '/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute '/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute '/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute '/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute @@ -323,6 +332,7 @@ export interface FileRoutesById { '/_authed/tournaments/': typeof AuthedTournamentsIndexRoute '/_authed/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute '/_authed/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute + '/_authed/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute '/_authed/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute '/_authed/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute '/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute @@ -360,6 +370,7 @@ export interface FileRouteTypes { | '/tournaments/' | '/tournaments/$id/bracket' | '/admin/tournaments/' + | '/admin/tournaments/$id/assign-partners' | '/admin/tournaments/$id/teams' | '/admin/tournaments/run/$id' | '/api/files/$collection/$recordId/$file' @@ -394,6 +405,7 @@ export interface FileRouteTypes { | '/tournaments' | '/tournaments/$id/bracket' | '/admin/tournaments' + | '/admin/tournaments/$id/assign-partners' | '/admin/tournaments/$id/teams' | '/admin/tournaments/run/$id' | '/api/files/$collection/$recordId/$file' @@ -430,6 +442,7 @@ export interface FileRouteTypes { | '/_authed/tournaments/' | '/_authed/tournaments/$id/bracket' | '/_authed/admin/tournaments/' + | '/_authed/admin/tournaments/$id/assign-partners' | '/_authed/admin/tournaments/$id/teams' | '/_authed/admin/tournaments/run/$id' | '/api/files/$collection/$recordId/$file' @@ -695,6 +708,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthedAdminTournamentsIdTeamsRouteImport parentRoute: typeof AuthedAdminRoute } + '/_authed/admin/tournaments/$id/assign-partners': { + id: '/_authed/admin/tournaments/$id/assign-partners' + path: '/tournaments/$id/assign-partners' + fullPath: '/admin/tournaments/$id/assign-partners' + preLoaderRoute: typeof AuthedAdminTournamentsIdAssignPartnersRouteImport + parentRoute: typeof AuthedAdminRoute + } } } @@ -704,6 +724,7 @@ interface AuthedAdminRouteChildren { AuthedAdminPreviewRoute: typeof AuthedAdminPreviewRoute AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute AuthedAdminTournamentsIndexRoute: typeof AuthedAdminTournamentsIndexRoute + AuthedAdminTournamentsIdAssignPartnersRoute: typeof AuthedAdminTournamentsIdAssignPartnersRoute AuthedAdminTournamentsIdTeamsRoute: typeof AuthedAdminTournamentsIdTeamsRoute AuthedAdminTournamentsRunIdRoute: typeof AuthedAdminTournamentsRunIdRoute AuthedAdminTournamentsIdIndexRoute: typeof AuthedAdminTournamentsIdIndexRoute @@ -715,6 +736,8 @@ const AuthedAdminRouteChildren: AuthedAdminRouteChildren = { AuthedAdminPreviewRoute: AuthedAdminPreviewRoute, AuthedAdminIndexRoute: AuthedAdminIndexRoute, AuthedAdminTournamentsIndexRoute: AuthedAdminTournamentsIndexRoute, + AuthedAdminTournamentsIdAssignPartnersRoute: + AuthedAdminTournamentsIdAssignPartnersRoute, AuthedAdminTournamentsIdTeamsRoute: AuthedAdminTournamentsIdTeamsRoute, AuthedAdminTournamentsRunIdRoute: AuthedAdminTournamentsRunIdRoute, AuthedAdminTournamentsIdIndexRoute: AuthedAdminTournamentsIdIndexRoute, diff --git a/src/app/routes/_authed/admin/tournaments/$id/assign-partners.tsx b/src/app/routes/_authed/admin/tournaments/$id/assign-partners.tsx new file mode 100644 index 0000000..a6f02cd --- /dev/null +++ b/src/app/routes/_authed/admin/tournaments/$id/assign-partners.tsx @@ -0,0 +1,167 @@ +import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; +import { tournamentQueries, useFreeAgents, useTournament } from "@/features/tournaments/queries"; +import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; +import { Stack, Text, Button, Alert, LoadingOverlay, Group } from "@mantine/core"; +import { useState } from "react"; +import useGenerateRandomTeams from "@/features/tournaments/hooks/use-generate-random-teams"; +import useConfirmTeamAssignments from "@/features/tournaments/hooks/use-confirm-team-assignments"; +import TeamAssignmentPreview from "@/features/tournaments/components/team-assignment-preview"; +import { WarningCircleIcon, ShuffleIcon, CheckCircleIcon } from "@phosphor-icons/react"; +import { PlayerInfo } from "@/features/players/types"; +import { useQueryClient } from "@tanstack/react-query"; + +export const Route = createFileRoute("/_authed/admin/tournaments/$id/assign-partners")({ + beforeLoad: async ({ context, params }) => { + const { queryClient } = context; + const tournament = await ensureServerQueryData( + queryClient, + tournamentQueries.details(params.id) + ); + if (!tournament) throw redirect({ to: "/admin/tournaments" }); + return { tournament }; + }, + loader: ({ context }) => ({ + header: { + withBackButton: true, + title: `Manage ${context.tournament.name}`, + }, + }), + component: RouteComponent, +}); + +interface TeamAssignment { + player1: PlayerInfo; + player2: PlayerInfo; + teamName: string; +} + +function RouteComponent() { + const { id } = Route.useParams(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { data: freeAgents } = useFreeAgents(id); + const [assignments, setAssignments] = useState(null); + const [currentSeed, setCurrentSeed] = useState(undefined); + + const generateMutation = useGenerateRandomTeams(); + const confirmMutation = useConfirmTeamAssignments(); + + const hasOddPlayers = freeAgents.length % 2 !== 0; + const hasEnoughPlayers = freeAgents.length >= 2; + + const handleGenerate = () => { + generateMutation.mutate( + { data: { tournamentId: id, seed: currentSeed } }, + { + onSuccess: (result) => { + setAssignments(result.assignments); + setCurrentSeed(result.seed); + }, + } + ); + }; + + const handleReroll = () => { + if (currentSeed === undefined) return; + generateMutation.mutate( + { data: { tournamentId: id, seed: currentSeed + 1 } }, + { + onSuccess: (result) => { + setAssignments(result.assignments); + setCurrentSeed(result.seed); + }, + } + ); + }; + + const handleConfirm = () => { + if (!assignments) return; + + const formattedAssignments = assignments.map((a) => ({ + player1Id: a.player1.id, + player2Id: a.player2.id, + teamName: a.teamName, + })); + + confirmMutation.mutate( + { data: { tournamentId: id, assignments: formattedAssignments } }, + { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: tournamentQueries.details(id).queryKey }); + queryClient.invalidateQueries({ queryKey: tournamentQueries.free_agents(id).queryKey }); + navigate({ to: "/admin/tournaments/$id", params: { id } }); + }, + } + ); + }; + + return ( + + + + + + + {freeAgents.length} + + + {freeAgents.length === 1 ? "player enrolled" : "players enrolled"} + + + + {!hasEnoughPlayers && ( + }> + Need at least 2 players to create teams + + )} + + {hasOddPlayers && ( + }> + Cannot create teams with an odd number of players. Please have one player unenroll. + + )} + + {!assignments && hasEnoughPlayers && !hasOddPlayers && ( + + )} + + + {assignments && ( + + + + Partner Assignments + + + + + + + + + + )} + + ); +} diff --git a/src/features/matches/types.ts b/src/features/matches/types.ts index 9100340..7e15b25 100644 --- a/src/features/matches/types.ts +++ b/src/features/matches/types.ts @@ -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; diff --git a/src/features/teams/types.ts b/src/features/teams/types.ts index 4b7cafa..eeed6e5 100644 --- a/src/features/teams/types.ts +++ b/src/features/teams/types.ts @@ -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( diff --git a/src/features/tournaments/components/edit-enrolled-players.tsx b/src/features/tournaments/components/edit-enrolled-players.tsx new file mode 100644 index 0000000..170f055 --- /dev/null +++ b/src/features/tournaments/components/edit-enrolled-players.tsx @@ -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 ( + + + + + {player.first_name} {player.last_name} + + + onRemove(player.id)} + disabled={disabled} + size="sm" + > + + + + ); +}); + +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) => { + 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[]> => { + 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) => { + const player = option.data; + return ( + + + + {player.first_name} {player.last_name} + + + ); + }; + + const formatPlayer = (option: TypeaheadOption) => { + return `${option.data.first_name} ${option.data.last_name}`; + }; + + return ( + + + + Add Player + + + + + + + + Enrolled Players + + + {enrolledPlayers.length} players + + + + {isLoading ? ( + + + + ) : !hasEnrolledPlayers ? ( + + No players enrolled yet + + ) : ( + + {enrolledPlayers.map((player) => ( + + ))} + + )} + + + ); +}; + +export default EditEnrolledPlayers; diff --git a/src/features/tournaments/components/manage-tournament.tsx b/src/features/tournaments/components/manage-tournament.tsx index cc06644..4a601dd 100644 --- a/src/features/tournaments/components/manage-tournament.tsx +++ b/src/features/tournaments/components/manage-tournament.tsx @@ -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} /> - + {tournament.regional && ( + + )} + {!tournament.regional && ( + + )} { - + {tournament.regional === true ? ( + + ) : ( + + )} ); diff --git a/src/features/tournaments/components/team-assignment-preview.tsx b/src/features/tournaments/components/team-assignment-preview.tsx new file mode 100644 index 0000000..04a207a --- /dev/null +++ b/src/features/tournaments/components/team-assignment-preview.tsx @@ -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 = ({ assignments }) => { + return ( + + {assignments.map((assignment, index) => ( + + + + #{index + 1} + + + + + + {assignment.player1.first_name} {assignment.player1.last_name} + + + + + & + + + + + + {assignment.player2.first_name} {assignment.player2.last_name} + + + + + ))} + + ); +}; + +export default TeamAssignmentPreview; diff --git a/src/features/tournaments/components/upcoming-tournament/enroll-free-agent.tsx b/src/features/tournaments/components/upcoming-tournament/enroll-free-agent.tsx index 75b5c74..6fccd1d 100644 --- a/src/features/tournaments/components/upcoming-tournament/enroll-free-agent.tsx +++ b/src/features/tournaments/components/upcoming-tournament/enroll-free-agent.tsx @@ -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 ( <> - + - - Enrolling as a free agent adds you to a pool of players looking for teammates. - - - Once enrolled, you can view other free agents and their phone number in order to coordinate teams and walkout songs. - - - 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. - - + {isRegional ? ( + <> + + Enroll in this regional tournament to be assigned a random partner. + + + ) : ( + <> + + Enrolling as a free agent adds you to a pool of players looking for teammates. + + + Once enrolled, you can view other free agents and their phone number in order to coordinate teams and walkout songs. + + + 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. + + + )} + diff --git a/src/features/tournaments/components/upcoming-tournament/enrolled-free-agent.tsx b/src/features/tournaments/components/upcoming-tournament/enrolled-free-agent.tsx index 7b2283b..804d5a0 100644 --- a/src/features/tournaments/components/upcoming-tournament/enrolled-free-agent.tsx +++ b/src/features/tournaments/components/upcoming-tournament/enrolled-free-agent.tsx @@ -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 ( + + + + + + + + {user?.first_name} {user?.last_name} + + + + + Enrolled + + + + + + + + + Partners will be randomly assigned when enrollment closes + + + + + ); + } + return ( - - - - - - Enrolled as Free Agent - - - + + + ✓ Enrolled as Free Agent + - You're on the free agent list. Other free agents looking for teams: + Other players looking for teammates: {freeAgents.length > 1 ? ( - + + + + Free Agents + + + {freeAgents.length} + + + {freeAgents .filter(agent => agent.player) .map((agent) => ( - - - - {agent.player?.first_name} {agent.player?.last_name} - - + + + {agent.player?.first_name} {agent.player?.last_name} + {agent.phone && ( = ({ )} ))} - - {freeAgents.length > 1 && ( - - {freeAgents.length} free agents total - - )} - + ) : ( - - - You're the only free agent so far - - + + You're the first free agent! + )} - + + ); }; diff --git a/src/features/tournaments/components/upcoming-tournament/enrolled-players-list-button.tsx b/src/features/tournaments/components/upcoming-tournament/enrolled-players-list-button.tsx new file mode 100644 index 0000000..a0226dc --- /dev/null +++ b/src/features/tournaments/components/upcoming-tournament/enrolled-players-list-button.tsx @@ -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 = ({ 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 ( + <> + + + + {count === 0 ? ( + + No players enrolled yet + + ) : ( + + )} + + + ); +}; + +export default EnrolledPlayersListButton; diff --git a/src/features/tournaments/components/upcoming-tournament/index.tsx b/src/features/tournaments/components/upcoming-tournament/index.tsx index e9d514a..c8f31f7 100644 --- a/src/features/tournaments/components/upcoming-tournament/index.tsx +++ b/src/features/tournaments/components/upcoming-tournament/index.tsx @@ -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 && ( <> - + + + + )} + - - )} @@ -107,7 +114,10 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({ { isFreeAgent && isEnrollmentOpen && ( - + ) } @@ -130,7 +140,11 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({ Icon={TreeStructureIcon} disabled /> - + {tournament.regional === true ? ( + + ) : ( + + )} diff --git a/src/features/tournaments/components/upcoming-tournament/unenroll-free-agent.tsx b/src/features/tournaments/components/upcoming-tournament/unenroll-free-agent.tsx index f08065e..3afae1f 100644 --- a/src/features/tournaments/components/upcoming-tournament/unenroll-free-agent.tsx +++ b/src/features/tournaments/components/upcoming-tournament/unenroll-free-agent.tsx @@ -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 ( <> - - + - 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."} - - + + diff --git a/src/features/tournaments/hooks/use-admin-enroll-player.ts b/src/features/tournaments/hooks/use-admin-enroll-player.ts new file mode 100644 index 0000000..873929c --- /dev/null +++ b/src/features/tournaments/hooks/use-admin-enroll-player.ts @@ -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; diff --git a/src/features/tournaments/hooks/use-admin-unenroll-player.ts b/src/features/tournaments/hooks/use-admin-unenroll-player.ts new file mode 100644 index 0000000..a5d82af --- /dev/null +++ b/src/features/tournaments/hooks/use-admin-unenroll-player.ts @@ -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; diff --git a/src/features/tournaments/hooks/use-confirm-team-assignments.ts b/src/features/tournaments/hooks/use-confirm-team-assignments.ts new file mode 100644 index 0000000..bbe57e0 --- /dev/null +++ b/src/features/tournaments/hooks/use-confirm-team-assignments.ts @@ -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!", + }); +} diff --git a/src/features/tournaments/hooks/use-enroll-free-agent.ts b/src/features/tournaments/hooks/use-enroll-free-agent.ts index db7fd53..c5e892d 100644 --- a/src/features/tournaments/hooks/use-enroll-free-agent.ts +++ b/src/features/tournaments/hooks/use-enroll-free-agent.ts @@ -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!", }); }; diff --git a/src/features/tournaments/hooks/use-generate-random-teams.ts b/src/features/tournaments/hooks/use-generate-random-teams.ts new file mode 100644 index 0000000..1fb88fc --- /dev/null +++ b/src/features/tournaments/hooks/use-generate-random-teams.ts @@ -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, + }); +} diff --git a/src/features/tournaments/hooks/use-unenroll-free-agent.ts b/src/features/tournaments/hooks/use-unenroll-free-agent.ts index b216d9a..21e4c73 100644 --- a/src/features/tournaments/hooks/use-unenroll-free-agent.ts +++ b/src/features/tournaments/hooks/use-unenroll-free-agent.ts @@ -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.", }); }; diff --git a/src/features/tournaments/server.ts b/src/features/tournaments/server.ts index 841167b..5accbeb 100644 --- a/src/features/tournaments/server.ts +++ b/src/features/tournaments/server.ts @@ -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(); + const mostRecentRegionalPartners = new Map(); + + 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(); + 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 }); + }) + ); diff --git a/src/features/tournaments/types.ts b/src/features/tournaments/types.ts index f0594c7..cd3a6e5 100644 --- a/src/features/tournaments/types.ts +++ b/src/features/tournaments/types.ts @@ -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({ diff --git a/src/lib/pocketbase/client.ts b/src/lib/pocketbase/client.ts index a8fce1b..d7545f3 100644 --- a/src/lib/pocketbase/client.ts +++ b/src/lib/pocketbase/client.ts @@ -6,8 +6,6 @@ import { createMatchesService } from "./services/matches"; import { createReactionsService } from "./services/reactions"; import { createActivitiesService } from "./services/activities"; import { createBadgesService } from "./services/badges"; -import dotenv from 'dotenv'; -dotenv.config(); class PocketBaseAdminClient { private pb: PocketBase; diff --git a/src/lib/pocketbase/services/teams.ts b/src/lib/pocketbase/services/teams.ts index 4b1aac5..6353050 100644 --- a/src/lib/pocketbase/services/teams.ts +++ b/src/lib/pocketbase/services/teams.ts @@ -110,5 +110,14 @@ export function createTeamsService(pb: PocketBase) { return result.map((match) => transformMatch(match)); }, + + async getTeamsWithFilter(filter: string, expand?: string): Promise { + logger.info("PocketBase | Getting teams with filter", { filter, expand }); + const result = await pb.collection("teams").getFullList({ + filter, + expand, + }); + return result; + }, }; }