diff --git a/k8s/base/app-deployment.yaml b/k8s/base/app-deployment.yaml index 455e093..f3fec21 100644 --- a/k8s/base/app-deployment.yaml +++ b/k8s/base/app-deployment.yaml @@ -96,6 +96,11 @@ spec: configMapKeyRef: name: flxn-config key: vite_spotify_redirect_uri + - name: COOKIE_DOMAIN + valueFrom: + configMapKeyRef: + name: flxn-config + key: cookie_domain resources: requests: diff --git a/k8s/overlays/dev/configmap.yaml b/k8s/overlays/dev/configmap.yaml index 4a45b99..04db0ba 100644 --- a/k8s/overlays/dev/configmap.yaml +++ b/k8s/overlays/dev/configmap.yaml @@ -12,3 +12,4 @@ data: vite_spotify_redirect_uri: "https://dev.flexxon.app/api/spotify/callback" s3_endpoint: "https://s3.yohler.net" s3_bucket: "flxn-dev" + cookie_domain: "dev.flexxon.app" diff --git a/k8s/overlays/prod/configmap.yaml b/k8s/overlays/prod/configmap.yaml index faf5cb5..1aa7fc3 100644 --- a/k8s/overlays/prod/configmap.yaml +++ b/k8s/overlays/prod/configmap.yaml @@ -12,3 +12,4 @@ data: vite_spotify_redirect_uri: "https://flexxon.app/api/spotify/callback" s3_endpoint: "https://s3.yohler.net" s3_bucket: "flxn-prod" + cookie_domain: "flexxon.app" 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/public/site.webmanifest b/public/site.webmanifest index 5b6c80f..642a4c5 100644 --- a/public/site.webmanifest +++ b/public/site.webmanifest @@ -1,17 +1,28 @@ { - "name": "FLXN IX", "short_name": "FLXN", + "name": "FLXN", + "description": "Register for FLXN and view FLXN stats", "icons": [ { "src": "/favicon.png", + "type": "image/png", "sizes": "192x192", - "type": "image/png" + "purpose": "any maskable" }, { "src": "/favicon.png", + "type": "image/png", "sizes": "512x512", - "type": "image/png" + "purpose": "any maskable" } ], - "display": "standalone" + "start_url": "/", + "display": "standalone", + "theme_color": "#1e293b", + "background_color": "#0f172a", + "orientation": "portrait-primary", + "scope": "/", + "categories": ["games", "social", "beer pong"], + "prefer_related_applications": false, + "shortcuts": [] } diff --git a/public/static/img/duncer_cap_badge.png b/public/static/img/duncer_cap_badge.png new file mode 100644 index 0000000..84e3357 Binary files /dev/null and b/public/static/img/duncer_cap_badge.png differ diff --git a/public/static/img/flip_cup_badge.png b/public/static/img/flip_cup_badge.png new file mode 100644 index 0000000..6c2bb93 Binary files /dev/null and b/public/static/img/flip_cup_badge.png differ diff --git a/public/static/img/gets_around_badge.png b/public/static/img/gets_around_badge.png new file mode 100644 index 0000000..4be822c Binary files /dev/null and b/public/static/img/gets_around_badge.png differ diff --git a/public/static/img/king_of_the_hill_badge.png b/public/static/img/king_of_the_hill_badge.png new file mode 100644 index 0000000..3586fcd Binary files /dev/null and b/public/static/img/king_of_the_hill_badge.png differ diff --git a/public/static/img/one_up_badge.png b/public/static/img/one_up_badge.png new file mode 100644 index 0000000..38b7613 Binary files /dev/null and b/public/static/img/one_up_badge.png differ diff --git a/src/app/routeTree.gen.ts b/src/app/routeTree.gen.ts index 39d6270..6ea2f8c 100644 --- a/src/app/routeTree.gen.ts +++ b/src/app/routeTree.gen.ts @@ -38,11 +38,13 @@ 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' 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', @@ -191,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', @@ -221,6 +229,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 @@ -251,7 +265,9 @@ 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 '/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute '/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute @@ -285,7 +301,9 @@ 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 '/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute '/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute @@ -322,7 +340,9 @@ 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 '/_authed/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute '/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute @@ -359,7 +379,9 @@ export interface FileRouteTypes { | '/admin/' | '/tournaments/' | '/tournaments/$id/bracket' + | '/tournaments/$id/groups' | '/admin/tournaments/' + | '/admin/tournaments/$id/assign-partners' | '/admin/tournaments/$id/teams' | '/admin/tournaments/run/$id' | '/api/files/$collection/$recordId/$file' @@ -393,7 +415,9 @@ export interface FileRouteTypes { | '/admin' | '/tournaments' | '/tournaments/$id/bracket' + | '/tournaments/$id/groups' | '/admin/tournaments' + | '/admin/tournaments/$id/assign-partners' | '/admin/tournaments/$id/teams' | '/admin/tournaments/run/$id' | '/api/files/$collection/$recordId/$file' @@ -429,7 +453,9 @@ 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' | '/_authed/admin/tournaments/run/$id' | '/api/files/$collection/$recordId/$file' @@ -660,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' @@ -695,6 +728,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 +744,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 +756,8 @@ const AuthedAdminRouteChildren: AuthedAdminRouteChildren = { AuthedAdminPreviewRoute: AuthedAdminPreviewRoute, AuthedAdminIndexRoute: AuthedAdminIndexRoute, AuthedAdminTournamentsIndexRoute: AuthedAdminTournamentsIndexRoute, + AuthedAdminTournamentsIdAssignPartnersRoute: + AuthedAdminTournamentsIdAssignPartnersRoute, AuthedAdminTournamentsIdTeamsRoute: AuthedAdminTournamentsIdTeamsRoute, AuthedAdminTournamentsRunIdRoute: AuthedAdminTournamentsRunIdRoute, AuthedAdminTournamentsIdIndexRoute: AuthedAdminTournamentsIdIndexRoute, @@ -735,6 +778,7 @@ interface AuthedRouteChildren { AuthedTournamentsTournamentIdRoute: typeof AuthedTournamentsTournamentIdRoute AuthedTournamentsIndexRoute: typeof AuthedTournamentsIndexRoute AuthedTournamentsIdBracketRoute: typeof AuthedTournamentsIdBracketRoute + AuthedTournamentsIdGroupsRoute: typeof AuthedTournamentsIdGroupsRoute } const AuthedRouteChildren: AuthedRouteChildren = { @@ -748,6 +792,7 @@ const AuthedRouteChildren: AuthedRouteChildren = { AuthedTournamentsTournamentIdRoute: AuthedTournamentsTournamentIdRoute, AuthedTournamentsIndexRoute: AuthedTournamentsIndexRoute, AuthedTournamentsIdBracketRoute: AuthedTournamentsIdBracketRoute, + AuthedTournamentsIdGroupsRoute: AuthedTournamentsIdGroupsRoute, } const AuthedRouteWithChildren = diff --git a/src/app/routes/__root.tsx b/src/app/routes/__root.tsx index 03b49f4..c8d541a 100644 --- a/src/app/routes/__root.tsx +++ b/src/app/routes/__root.tsx @@ -12,6 +12,7 @@ import { ensureSuperTokensFrontend } from "@/lib/supertokens/client"; import { AuthContextType } from "@/contexts/auth-context"; import Providers from "@/features/core/components/providers"; import { SessionMonitor } from "@/components/session-monitor"; +import { IOSInstallPrompt } from "@/components/ios-install-prompt"; import { ColorSchemeScript, mantineHtmlProps } from "@mantine/core"; import { HeaderConfig } from "@/features/core/types/header-config"; import { playerQueries } from "@/features/players/queries"; @@ -47,6 +48,12 @@ export const Route = createRootRouteWithContext<{ { property: 'og:type', content: 'website' }, { property: 'og:site_name', content: 'FLXN IX' }, { property: 'og:image', content: 'https://flexxon.app/favicon.png' }, + { property: 'og:image:width', content: '512' }, + { property: 'og:image:height', content: '512' }, + { name: 'mobile-web-app-capable', content: 'yes' }, + { name: 'apple-mobile-web-app-capable', content: 'yes' }, + { name: 'apple-mobile-web-app-status-bar-style', content: 'black-translucent' }, + { name: 'apple-mobile-web-app-title', content: 'FLXN' }, ], links: [ { @@ -96,23 +103,35 @@ export const Route = createRootRouteWithContext<{ component: RootComponent, notFoundComponent: () => , beforeLoad: async ({ context, location }) => { - // Skip auth check for refresh-session route to avoid infinite loops - if (location.pathname === '/refresh-session') { - return {}; - } - - if (location.pathname === '/login' || location.pathname === '/logout') { + const publicRoutes = ['/login', '/logout', '/refresh-session']; + if (publicRoutes.some(route => location.pathname.startsWith(route))) { return {}; } try { - // https://github.com/TanStack/router/discussions/3531 const auth = await ensureServerQueryData( context.queryClient, playerQueries.auth() ); return { auth }; - } catch (error) { + } catch (error: any) { + if (typeof window !== 'undefined') { + const { doesSessionExist, attemptRefreshingSession } = await import('supertokens-web-js/recipe/session'); + + const sessionExists = await doesSessionExist(); + if (sessionExists) { + try { + await attemptRefreshingSession(); + const auth = await ensureServerQueryData( + context.queryClient, + playerQueries.auth() + ); + return { auth }; + } catch { + return {}; + } + } + } return {}; } }, @@ -128,6 +147,7 @@ function RootComponent() { + 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/app/routes/_authed/admin/tournaments/run.$id.tsx b/src/app/routes/_authed/admin/tournaments/run.$id.tsx index 3de4ccf..0635e6a 100644 --- a/src/app/routes/_authed/admin/tournaments/run.$id.tsx +++ b/src/app/routes/_authed/admin/tournaments/run.$id.tsx @@ -5,7 +5,9 @@ import { } 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 SetupGroupStage from "@/features/tournaments/components/setup-group-stage"; +import GroupStageView from "@/features/tournaments/components/group-stage-view"; +import { Container, Stack, Divider, Title } from "@mantine/core"; import { useMemo } from "react"; import { BracketData } from "@/features/bracket/types"; import { Match } from "@/features/matches/types"; @@ -43,6 +45,20 @@ function RouteComponent() { const { roles } = useAuth(); const isAdmin = roles?.includes('Admin') || false; + const hasGroupStage = useMemo(() => { + return tournament.matches?.some((match) => match.round === -1) || false; + }, [tournament.matches]); + + const hasKnockout = useMemo(() => { + return tournament.matches?.some((match) => match.round !== -1) || false; + }, [tournament.matches]); + + const knockoutBracketPopulated = useMemo(() => { + return tournament.matches?.some((match) => + match.round === 0 && match.lid >= 0 && (match.home || match.away) + ) || false; + }, [tournament.matches]); + const bracket: BracketData = useMemo(() => { if (!tournament.matches || tournament.matches.length === 0) { return { winners: [], losers: [] }; @@ -52,6 +68,7 @@ function RouteComponent() { const losersMap = new Map(); tournament.matches + .filter((match) => match.round !== -1) .sort((a, b) => a.lid - b.lid) .forEach((match) => { if (!match.is_losers_bracket) { @@ -79,15 +96,49 @@ function RouteComponent() { return ( - { isAdmin && } + { isAdmin && !tournament.regional && } {tournament.matches?.length ? ( - + hasGroupStage && hasKnockout ? ( + + + +
+ Knockout Bracket + +
+
+ ) : hasGroupStage ? ( + + ) : ( + + ) ) : ( - + tournament.regional === true ? ( + + ) : ( + + ) )}
); diff --git a/src/app/routes/_authed/tournaments/$id.bracket.tsx b/src/app/routes/_authed/tournaments/$id.bracket.tsx index 094c39d..38a18bc 100644 --- a/src/app/routes/_authed/tournaments/$id.bracket.tsx +++ b/src/app/routes/_authed/tournaments/$id.bracket.tsx @@ -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,7 +25,6 @@ export const Route = createFileRoute("/_authed/tournaments/$id/bracket")({ loader: ({ context }) => ({ fullWidth: true, withPadding: false, - showSpotifyPanel: true, header: { withBackButton: true, title: `${context.tournament.name}`, @@ -48,6 +46,7 @@ function RouteComponent() { const losersMap = new Map(); tournament.matches + .filter((match) => match.round !== -1) .sort((a, b) => a.lid - b.lid) .forEach((match) => { if (!match.is_losers_bracket) { @@ -75,7 +74,7 @@ function RouteComponent() { return ( - + ); } diff --git a/src/app/routes/_authed/tournaments/$id.groups.tsx b/src/app/routes/_authed/tournaments/$id.groups.tsx new file mode 100644 index 0000000..f0ea172 --- /dev/null +++ b/src/app/routes/_authed/tournaments/$id.groups.tsx @@ -0,0 +1,46 @@ +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}`, + }, + }), + component: RouteComponent, +}); + +function RouteComponent() { + const { id } = Route.useParams(); + const { data: tournament } = useTournament(id); + + return ( + + + + ); +} diff --git a/src/components/ios-install-prompt.tsx b/src/components/ios-install-prompt.tsx new file mode 100644 index 0000000..7d4a140 --- /dev/null +++ b/src/components/ios-install-prompt.tsx @@ -0,0 +1,59 @@ +import { useEffect, useState } from 'react' +import { Box, Paper, Group, Text, ActionIcon } from '@mantine/core' +import { DownloadIcon, XIcon } from '@phosphor-icons/react' + +export function IOSInstallPrompt() { + const [show, setShow] = useState(false) + const [platform, setPlatform] = useState<'ios' | 'android' | null>(null) + + useEffect(() => { + const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) + const isAndroid = /Android/.test(navigator.userAgent) + + const isInStandaloneMode = + window.matchMedia('(display-mode: standalone)').matches || + ('standalone' in window.navigator && (window.navigator as any).standalone) + + const hasBeenDismissed = localStorage.getItem('pwa-install-prompt-dismissed') === 'true' + + if ((isIOS || isAndroid) && !isInStandaloneMode && !hasBeenDismissed) { + setPlatform(isIOS ? 'ios' : 'android') + const timer = setTimeout(() => setShow(true), 3000) + return () => clearTimeout(timer) + } + }, []) + + const handleDismiss = () => { + localStorage.setItem('pwa-install-prompt-dismissed', 'true') + setShow(false) + } + + if (!show || !platform) return null + + const instructions = platform === 'ios' + ? 'Tap Share → Add to Home Screen' + : 'Tap Menu (⋮) → Add to Home screen' + + return ( + + + + + + + + Please install FLXN • This will save me Twilio credits as you won't be signed out! + + + {instructions} + + + + + + + + + + ) +} diff --git a/src/components/session-monitor.tsx b/src/components/session-monitor.tsx index ee4e3ec..9fc8963 100644 --- a/src/components/session-monitor.tsx +++ b/src/components/session-monitor.tsx @@ -1,10 +1,12 @@ import { useEffect, useRef } from 'react'; +import { useNavigate } from '@tanstack/react-router'; import { doesSessionExist } from 'supertokens-web-js/recipe/session'; import { getOrCreateRefreshPromise } from '@/lib/supertokens/client'; import { attemptRefreshingSession } from 'supertokens-web-js/recipe/session'; import { logger } from '@/lib/supertokens'; export function SessionMonitor() { + const navigate = useNavigate(); const lastRefreshTimeRef = useRef(0); const REFRESH_COOLDOWN = 30 * 1000; @@ -49,12 +51,14 @@ export function SessionMonitor() { } }; + handleVisibilityChange(); + document.addEventListener('visibilitychange', handleVisibilityChange); return () => { document.removeEventListener('visibilitychange', handleVisibilityChange); }; - }, []); + }, [navigate]); return null; } diff --git a/src/components/team-avatar.tsx b/src/components/team-avatar.tsx index a639936..4a69d39 100644 --- a/src/components/team-avatar.tsx +++ b/src/components/team-avatar.tsx @@ -30,17 +30,7 @@ const TeamAvatar = ({ const hasNoLogo = !team.logo; const hasTwoPlayers = team.players?.length === 2; - let shouldShowPlayerAvatars = false; - - if (isRegional !== undefined) { - shouldShowPlayerAvatars = isRegional && hasTwoPlayers && hasNoLogo; - } else { - const tournaments = (team as any).tournaments; - const hasTournaments = tournaments && tournaments.length > 0; - const allTournamentsAreRegional = hasTournaments && tournaments.every((t: any) => t.regional === true); - - shouldShowPlayerAvatars = hasTwoPlayers && hasNoLogo && (allTournamentsAreRegional || !hasTournaments); - } + const shouldShowPlayerAvatars = isRegional === true && hasTwoPlayers && hasNoLogo; if (shouldShowPlayerAvatars && team.players?.length === 2) { const playerSize = size * 0.6; diff --git a/src/features/bracket/components/bracket-view.tsx b/src/features/bracket/components/bracket-view.tsx index 309a531..2046094 100644 --- a/src/features/bracket/components/bracket-view.tsx +++ b/src/features/bracket/components/bracket-view.tsx @@ -7,10 +7,14 @@ import { Match } from "@/features/matches/types"; interface BracketViewProps { bracket: BracketData; - showControls?: boolean + showControls?: boolean; + groupConfig?: { + num_groups: number; + advance_per_group: number; + }; } -const BracketView: React.FC = ({ bracket, showControls }) => { +const BracketView: React.FC = ({ bracket, showControls, groupConfig }) => { const height = useAppShellHeight(); const orders = useMemo(() => { const map: Record = {}; @@ -32,14 +36,14 @@ const BracketView: React.FC = ({ bracket, showControls }) => { Winners Bracket - + {bracket.losers && (
Losers Bracket - +
)} diff --git a/src/features/bracket/components/bracket.tsx b/src/features/bracket/components/bracket.tsx index a3689f4..e038482 100644 --- a/src/features/bracket/components/bracket.tsx +++ b/src/features/bracket/components/bracket.tsx @@ -7,12 +7,17 @@ interface BracketProps { rounds: Match[][]; orders: Record; showControls?: boolean; + groupConfig?: { + num_groups: number; + advance_per_group: number; + }; } export const Bracket: React.FC = ({ rounds, orders, showControls, + groupConfig, }) => { const containerRef = useRef(null); const svgRef = useRef(null); @@ -132,6 +137,7 @@ export const Bracket: React.FC = ({ match={match} orders={orders} showControls={showControls} + groupConfig={groupConfig} /> ) diff --git a/src/features/bracket/components/match-card.tsx b/src/features/bracket/components/match-card.tsx index 3695513..97baa5b 100644 --- a/src/features/bracket/components/match-card.tsx +++ b/src/features/bracket/components/match-card.tsx @@ -17,16 +17,53 @@ interface MatchCardProps { match: Match; orders: Record; showControls?: boolean; + groupConfig?: { + num_groups: number; + advance_per_group: number; + }; } export const MatchCard: React.FC = ({ match, orders, showControls, + groupConfig, }) => { const queryClient = useQueryClient(); const editSheet = useSheet(); const { playTrack, pause } = useSpotifyPlayback(); + + const getGroupLabel = useCallback((seed: number | undefined) => { + if (!seed || !groupConfig) return undefined; + + const groupNames = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']; + const numGroups = groupConfig.num_groups; + const advancePerGroup = groupConfig.advance_per_group; + + const pairIndex = Math.floor((seed - 1) / 2); + const isFirstInPair = (seed - 1) % 2 === 0; + + if (isFirstInPair) { + const groupIndex = pairIndex % numGroups; + const rankIndex = Math.floor(pairIndex / numGroups); + + const rank = rankIndex + 1; + const groupName = groupNames[groupIndex] || `${groupIndex + 1}`; + const rankSuffix = rank === 1 ? '1st' : rank === 2 ? '2nd' : rank === 3 ? '3rd' : `${rank}th`; + + return `${groupName} ${rankSuffix}`; + } else { + const groupIndex = (pairIndex + 1) % numGroups; + const rankIndex = advancePerGroup - 1 - Math.floor(pairIndex / numGroups); + + const rank = rankIndex + 1; + const groupName = groupNames[groupIndex] || `${groupIndex + 1}`; + const rankSuffix = rank === 1 ? '1st' : rank === 2 ? '2nd' : rank === 3 ? '3rd' : `${rank}th`; + + return `${groupName} ${rankSuffix}`; + } + }, [groupConfig]); + const homeSlot = useMemo( () => ({ from: orders[match.home_from_lid], @@ -39,8 +76,9 @@ export const MatchCard: React.FC = ({ match.home_cups !== undefined && match.away_cups !== undefined && match.home_cups > match.away_cups, + groupLabel: !match.home && match.home_seed ? getGroupLabel(match.home_seed) : undefined, }), - [match] + [match, getGroupLabel] ); const awaySlot = useMemo( () => ({ @@ -54,8 +92,9 @@ export const MatchCard: React.FC = ({ match.away_cups !== undefined && match.home_cups !== undefined && match.away_cups > match.home_cups, + groupLabel: !match.away && match.away_seed ? getGroupLabel(match.away_seed) : undefined, }), - [match] + [match, getGroupLabel] ); const showToolbar = useMemo( @@ -179,8 +218,11 @@ export const MatchCard: React.FC = ({ data: match.id, }); - // Play walkout sequence after starting the match - if (hasWalkoutData && match.home?.name && match.away?.name) { + // Skip announcements for regional tournaments + const isRegional = match.tournament?.regional === true; + + // Play walkout sequence after starting the match (only for non-regional tournaments) + if (!isRegional && hasWalkoutData && match.home?.name && match.away?.name) { try { const homeTeam = match.home as Team; const awayTeam = match.away as Team; diff --git a/src/features/bracket/components/match-slot.tsx b/src/features/bracket/components/match-slot.tsx index ba27457..e584be5 100644 --- a/src/features/bracket/components/match-slot.tsx +++ b/src/features/bracket/components/match-slot.tsx @@ -11,6 +11,7 @@ interface MatchSlotProps { seed?: number; cups?: number; isWinner?: boolean; + groupLabel?: string; } export const MatchSlot: React.FC = ({ @@ -19,7 +20,8 @@ export const MatchSlot: React.FC = ({ team, seed, cups, - isWinner + isWinner, + groupLabel }) => ( = ({ {team ? ( <> - 12 ? (team.name.length > 18 ? '10px' : '11px') : 'xs'} truncate style={{ minWidth: 0, flex: 1, lineHeight: "12px" }} @@ -43,18 +45,22 @@ export const MatchSlot: React.FC = ({ {isWinner && ( )} + ) : groupLabel ? ( + + {groupLabel} + ) : from ? ( {from_loser ? "Loser" : "Winner"} of Match {from} diff --git a/src/features/matches/server.ts b/src/features/matches/server.ts index a230d3f..ab06cf7 100644 --- a/src/features/matches/server.ts +++ b/src/features/matches/server.ts @@ -5,7 +5,7 @@ import { logger } from "@/lib/logger"; import { z } from "zod"; import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result"; import brackets from "@/features/bracket/utils"; -import { MatchInput } from "@/features/matches/types"; +import { Match, MatchInput } from "@/features/matches/types"; import { serverEvents } from "@/lib/events/emitter"; import { superTokensFunctionMiddleware } from "@/utils/supertokens"; import { PlayerInfo } from "../players/types"; @@ -164,6 +164,189 @@ export const startMatch = createServerFn() }) ); +export const populateKnockoutBracket = createServerFn() + .inputValidator(z.object({ + tournamentId: z.string(), + })) + .middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware]) + .handler(async ({ data: { tournamentId } }) => + toServerResult(async () => { + const tournament = await pbAdmin.getTournament(tournamentId); + if (!tournament) { + throw new Error("Tournament not found"); + } + + if (!tournament.group_config) { + throw new Error("Tournament must have group_config"); + } + + return await populateKnockoutBracketInternal(tournamentId, tournament.group_config); + }) + ); + +async function populateKnockoutBracketInternal(tournamentId: string, groupConfig: { num_groups: number; advance_per_group: number }) { + logger.info('Populating knockout bracket', { tournamentId }); + + const groups = await pbAdmin.getGroupsByTournament(tournamentId); + if (!groups || groups.length === 0) { + throw new Error("No groups found for tournament"); + } + + const qualifiedTeams: { teamId: string; groupOrder: number; rank: number }[] = []; + + for (const group of groups) { + logger.info('Processing group', { + groupId: group.id, + groupOrder: group.order, + teamsCount: group.teams?.length, + teams: group.teams + }); + + const groupMatches = await pbAdmin.getMatchesByGroup(group.id); + const completedMatches = groupMatches.filter(m => m.status === "ended"); + + const standings = new Map(); + + for (const team of group.teams || []) { + // group.teams can be either team objects or just team ID strings + const teamId = typeof team === 'string' ? team : team.id; + standings.set(teamId, { + teamId, + wins: 0, + losses: 0, + cups_for: 0, + cups_against: 0, + cup_differential: 0, + }); + } + + for (const match of completedMatches) { + if (!match.home || !match.away) continue; + + const homeStanding = standings.get(match.home.id); + const awayStanding = standings.get(match.away.id); + + if (homeStanding && awayStanding) { + homeStanding.cups_for += match.home_cups; + homeStanding.cups_against += match.away_cups; + awayStanding.cups_for += match.away_cups; + awayStanding.cups_against += match.home_cups; + + if (match.home_cups > match.away_cups) { + homeStanding.wins++; + awayStanding.losses++; + } else { + awayStanding.wins++; + homeStanding.losses++; + } + + homeStanding.cup_differential = homeStanding.cups_for - homeStanding.cups_against; + awayStanding.cup_differential = awayStanding.cups_for - awayStanding.cups_against; + } + } + + const sortedStandings = Array.from(standings.values()).sort((a, b) => { + if (b.wins !== a.wins) return b.wins - a.wins; + if (b.cup_differential !== a.cup_differential) return b.cup_differential - a.cup_differential; + return b.cups_for - a.cups_for; + }); + + const topTeams = sortedStandings.slice(0, groupConfig.advance_per_group); + logger.info('Top teams from group', { + groupId: group.id, + topTeams: topTeams.map(t => ({ teamId: t.teamId, wins: t.wins, cupDiff: t.cup_differential })) + }); + + topTeams.forEach((standing, index) => { + qualifiedTeams.push({ + teamId: standing.teamId, + groupOrder: group.order, + rank: index + 1, + }); + }); + } + + logger.info('Qualified teams', { qualifiedTeams }); + + const orderedTeamIds: string[] = []; + const maxRank = groupConfig.advance_per_group; + + for (let rank = 1; rank <= maxRank; rank++) { + const teamsAtRank = qualifiedTeams + .filter(t => t.rank === rank) + .sort((a, b) => a.groupOrder - b.groupOrder); + orderedTeamIds.push(...teamsAtRank.map(t => t.teamId)); + } + + logger.info('Ordered team IDs', { orderedTeamIds }); + + const tournament = await pbAdmin.getTournament(tournamentId); + const knockoutMatches = (tournament.matches || []) + .filter((m: Match) => m.round >= 0 && m.lid >= 0) + .sort((a: Match, b: Match) => a.lid - b.lid); + + const seedToTeamId = new Map(); + orderedTeamIds.forEach((teamId, index) => { + seedToTeamId.set(index + 1, teamId); + }); + + logger.info('Seed to team mapping', { + seedToTeamId: Array.from(seedToTeamId.entries()), + orderedTeamIds + }); + + let updatedCount = 0; + for (const match of knockoutMatches) { + if (match.round === 0) { + const updates: any = {}; + + if (match.home_seed) { + const teamId = seedToTeamId.get(match.home_seed); + logger.info('Looking up home seed', { + matchId: match.id, + home_seed: match.home_seed, + teamId + }); + if (teamId) { + updates.home = teamId; + } + } + + if (match.away_seed) { + const teamId = seedToTeamId.get(match.away_seed); + logger.info('Looking up away seed', { + matchId: match.id, + away_seed: match.away_seed, + teamId + }); + if (teamId) { + updates.away = teamId; + } + } + + if (updates.home && updates.away) { + updates.status = "ready"; + } else if (updates.home || updates.away) { + updates.status = "tbd"; + } + + if (Object.keys(updates).length > 0) { + logger.info('Updating match', { matchId: match.id, updates }); + await pbAdmin.updateMatch(match.id, updates); + updatedCount++; + } + } + } + + logger.info('Updated matches', { updatedCount, totalKnockoutMatches: knockoutMatches.length }); + + await pbAdmin.updateTournament(tournamentId, { + phase: "knockout" + }); + + logger.info('Knockout bracket populated successfully', { tournamentId }); +} + const endMatchSchema = z.object({ matchId: z.string(), home_cups: z.number(), @@ -190,19 +373,25 @@ export const endMatch = createServerFn() ot_count, }); + if (match.lid === -1) { + serverEvents.emit("match", { + type: "match", + matchId: match.id, + tournamentId: match.tournament.id + }); + return match; + } + const matchWinner = home_cups > away_cups ? match.home : match.away; const matchLoser = home_cups < away_cups ? match.home : match.away; if (!matchWinner || !matchLoser) throw new Error("Something went wrong"); - // winner -> where to send match winner to, loser same const { winner, loser } = await pbAdmin.getChildMatches(matchId); - // reset match check if (winner && winner.reset) { const awayTeamWon = match.away === matchWinner; if (!awayTeamWon) { - // Reset match is not necessary logger.info("Deleting reset match", { resetMatchId: winner.id, currentMatchId: match.id, @@ -214,7 +403,6 @@ export const endMatch = createServerFn() } } - // advance bracket if (winner) { await pbAdmin.updateMatch(winner.id, { [winner.home_from_lid === match.lid ? "home" : "away"]: matchWinner.id, 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/components/regional-team-card.tsx b/src/features/teams/components/regional-team-card.tsx new file mode 100644 index 0000000..8ad7372 --- /dev/null +++ b/src/features/teams/components/regional-team-card.tsx @@ -0,0 +1,59 @@ +import { + Paper, + Text, + Group, + Box, + Title +} from "@mantine/core"; +import { useTeam } from "../queries"; +import TeamAvatar from "@/components/team-avatar"; + +interface RegionalTeamCardProps { + teamId: string; +} + +const RegionalTeamCard = ({ teamId }: RegionalTeamCardProps) => { + const { data: team, error } = useTeam(teamId); + + if (error || !team) { + return ( + + + Failed to load team + + + ); + } + + return ( + + + + + + {team.name} + + + {team.players?.map(p => `${p.first_name} ${p.last_name}`).join(', ')} + + + + + ); +}; + +export default RegionalTeamCard; diff --git a/src/features/teams/components/team-list.tsx b/src/features/teams/components/team-list.tsx index 068062c..622282d 100644 --- a/src/features/teams/components/team-list.tsx +++ b/src/features/teams/components/team-list.tsx @@ -49,9 +49,10 @@ interface TeamListProps { teams: TeamInfo[]; loading?: boolean; onTeamClick?: (teamId: string) => void; + isRegional?: boolean; } -const TeamList = ({ teams, loading = false, onTeamClick }: TeamListProps) => { +const TeamList = ({ teams, loading = false, onTeamClick, isRegional }: TeamListProps) => { const navigate = useNavigate(); const handleClick = useCallback( @@ -92,6 +93,7 @@ const TeamList = ({ teams, loading = false, onTeamClick }: TeamListProps) => { team={team} radius="sm" size={40} + isRegional={isRegional} /> } style={{ cursor: "pointer" }} 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/group-match-card.tsx b/src/features/tournaments/components/group-match-card.tsx new file mode 100644 index 0000000..1b202a3 --- /dev/null +++ b/src/features/tournaments/components/group-match-card.tsx @@ -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 = ({ 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 ( + <> + + + + + + + + + {match.home?.name || "TBD"} + + + {isEnded && match.home_cups !== undefined && ( + + {match.home_cups} + + )} + + + + + + + + + {match.away?.name || "TBD"} + + + {isEnded && match.away_cups !== undefined && ( + + {match.away_cups} + + )} + + + + + + {showStartButton && ( + + + + )} + + {showEditButton && ( + + + + )} + + + {showControls && ( + + + + )} + + ); +}; + +export default GroupMatchCard; diff --git a/src/features/tournaments/components/group-preview.tsx b/src/features/tournaments/components/group-preview.tsx new file mode 100644 index 0000000..afb944a --- /dev/null +++ b/src/features/tournaments/components/group-preview.tsx @@ -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 = ({ groups }) => { + return ( + + {groups.map((group) => ( + + + + + Group {group.groupName} + + + ({group.teams.length} teams) + + + + + {group.teams.map((team, index) => ( + + + {index + 1} + + + + {team.name} + + + ))} + + + + ))} + + ); +}; + +export default GroupPreview; diff --git a/src/features/tournaments/components/group-stage-view.tsx b/src/features/tournaments/components/group-stage-view.tsx new file mode 100644 index 0000000..b008d61 --- /dev/null +++ b/src/features/tournaments/components/group-stage-view.tsx @@ -0,0 +1,401 @@ +import React, { useMemo, useState } from "react"; +import { Stack, Text, Card, Group as MantineGroup, Box, SimpleGrid, Tabs, Collapse, ActionIcon, Button, Alert } from "@mantine/core"; +import { CaretCircleDownIcon, CaretCircleUpIcon } from "@phosphor-icons/react"; +import { Match } from "@/features/matches/types"; +import { Group } from "../types"; +import GroupMatchCard from "./group-match-card"; +import TeamAvatar from "@/components/team-avatar"; +import { useServerMutation } from "@/lib/tanstack-query/hooks/use-server-mutation"; +import { populateKnockoutBracket } from "@/features/matches/server"; +import { useQueryClient } from "@tanstack/react-query"; +import { tournamentKeys } from "../queries"; + +interface GroupStageViewProps { + groups: Group[]; + matches: Match[]; + showControls?: boolean; + tournamentId?: string; + hasKnockoutBracket?: boolean; + isRegional?: boolean; +} + +interface TeamStanding { + teamId: string; + teamName: string; + team: any; + wins: number; + losses: number; + cupsFor: number; + cupsAgainst: number; + cupDifference: number; +} + +const GroupStageView: React.FC = ({ + groups, + matches, + showControls, + tournamentId, + hasKnockoutBracket, + isRegional, +}) => { + const queryClient = useQueryClient(); + const [expandedTeams, setExpandedTeams] = useState>({}); + + const populateKnockoutMutation = useServerMutation({ + mutationFn: populateKnockoutBracket, + successMessage: "Knockout bracket populated successfully!", + onSuccess: () => { + if (tournamentId) { + queryClient.invalidateQueries({ queryKey: tournamentKeys.details(tournamentId) }); + } + }, + }); + + const allGroupMatchesCompleted = useMemo(() => { + const groupMatches = matches.filter((match) => match.round === -1); + if (groupMatches.length === 0) return false; + return groupMatches.every((match) => match.status === "ended"); + }, [matches]); + + const handlePopulateKnockout = () => { + if (!tournamentId) return; + populateKnockoutMutation.mutate({ data: { tournamentId } }); + }; + + const orderMatchesWithSpacing = (matches: Match[]): Match[] => { + if (matches.length <= 1) return matches; + + 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(); + + 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 = new Map(); + + teams.forEach((team) => { + standings.set(team.id, { + teamId: team.id, + teamName: team.name, + team: team, + wins: 0, + losses: 0, + cupsFor: 0, + cupsAgainst: 0, + cupDifference: 0, + }); + }); + + const completedMatches = groupMatches.filter((match) => match.status === "ended"); + + completedMatches.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.cupsFor += homeCups; + homeStanding.cupsAgainst += awayCups; + awayStanding.cupsFor += awayCups; + awayStanding.cupsAgainst += homeCups; + + homeStanding.cupDifference += homeCups - awayCups; + awayStanding.cupDifference += awayCups - homeCups; + + if (homeCups > awayCups) { + homeStanding.wins++; + awayStanding.losses++; + } else if (awayCups > homeCups) { + awayStanding.wins++; + homeStanding.losses++; + } + }); + + const h2hRecords = new Map>(); + + completedMatches.forEach((match) => { + const homeId = match.home?.id; + const awayId = match.away?.id; + + if (!homeId || !awayId) return; + + if (!h2hRecords.has(homeId)) { + h2hRecords.set(homeId, new Map()); + } + if (!h2hRecords.has(awayId)) { + h2hRecords.set(awayId, new Map()); + } + + const homeH2H = h2hRecords.get(homeId)!; + const awayH2H = h2hRecords.get(awayId)!; + + if (!homeH2H.has(awayId)) { + homeH2H.set(awayId, { wins: 0, cupDiff: 0 }); + } + if (!awayH2H.has(homeId)) { + awayH2H.set(homeId, { wins: 0, cupDiff: 0 }); + } + + const homeRecord = homeH2H.get(awayId)!; + const awayRecord = awayH2H.get(homeId)!; + + const homeCups = match.home_cups || 0; + const awayCups = match.away_cups || 0; + + const cupDiff = homeCups - awayCups; + homeRecord.cupDiff += cupDiff; + awayRecord.cupDiff -= cupDiff; + + if (homeCups > awayCups) { + homeRecord.wins++; + } else if (awayCups > homeCups) { + awayRecord.wins++; + } + }); + + return Array.from(standings.values()).sort((a, b) => { + if (b.wins !== a.wins) return b.wins - a.wins; + + if (b.cupDifference !== a.cupDifference) return b.cupDifference - a.cupDifference; + + if (b.cupsFor !== a.cupsFor) return b.cupsFor - a.cupsFor; + + const aH2H = h2hRecords.get(a.teamId); + const bH2H = h2hRecords.get(b.teamId); + + if (aH2H && bH2H) { + const aVsB = aH2H.get(b.teamId); + const bVsA = bH2H.get(a.teamId); + + if (aVsB && bVsA) { + if (aVsB.wins !== bVsA.wins) return bVsA.wins - aVsB.wins; + + if (aVsB.cupDiff !== -bVsA.cupDiff) return aVsB.cupDiff - (-bVsA.cupDiff); + } + } + + return a.teamId.localeCompare(b.teamId); + }); + }; + + if (sortedGroups.length === 0) { + return ( + + + No groups have been created yet + + + ); + } + + const showGenerateKnockoutButton = showControls && tournamentId && !hasKnockoutBracket && allGroupMatchesCompleted; + + return ( + + + {showGenerateKnockoutButton && ( + }> + + All group matches are finished! Populate the knockout bracket to advance qualified teams. + + + + )} + + + {sortedGroups.map((group) => { + const groupMatches = matchesByGroup.get(group.id) || []; + const completedMatches = groupMatches.filter((m) => m.status === "ended").length; + const totalMatches = groupMatches.length; + + return ( + + + Group {group.name} + + ({completedMatches}/{totalMatches}) + + + + ); + })} + + + {sortedGroups.map((group) => { + const groupMatches = matchesByGroup.get(group.id) || []; + const standings = getTeamStandings(group.id, group.teams || []); + + return ( + + + + toggleTeams(group.id)} + > + + Standings ({standings.length}) + + + {expandedTeams[group.id] ? : } + + + + + {standings.length > 0 ? ( + standings.map((standing, index) => ( + 0 ? '1px solid var(--mantine-color-default-border)' : 'none', + }} + > + + {index + 1} + + + + {standing.teamName} + + + + {standing.wins}-{standing.losses} + + 0 ? "green" : standing.cupDifference < 0 ? "red" : "dimmed"} + fw={600} + miw={30} + ta="center" + > + {standing.cupDifference > 0 ? '+' : ''}{standing.cupDifference} + + + {standing.cupsFor}/{standing.cupsAgainst} + + + + )) + ) : ( + + No teams assigned + + )} + + + + + {groupMatches.length === 0 ? ( + + + No matches scheduled + + + ) : ( + + {groupMatches.map((match) => ( + + ))} + + )} + + + ); + })} + + + + ); +}; + +export default GroupStageView; 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/profile/index.tsx b/src/features/tournaments/components/profile/index.tsx index a1b1553..ca38f40 100644 --- a/src/features/tournaments/components/profile/index.tsx +++ b/src/features/tournaments/components/profile/index.tsx @@ -27,7 +27,7 @@ const Profile = ({ id }: ProfileProps) => { { label: "Teams", content: <> - + } ], [tournament]); diff --git a/src/features/tournaments/components/setup-group-stage.tsx b/src/features/tournaments/components/setup-group-stage.tsx new file mode 100644 index 0000000..928ffa7 --- /dev/null +++ b/src/features/tournaments/components/setup-group-stage.tsx @@ -0,0 +1,296 @@ +import React, { useState, useMemo } from "react"; +import { + Stack, + Text, + Group, + Button, + Select, + LoadingOverlay, + Alert, + Title, + Divider, + Box, +} from "@mantine/core"; +import { InfoIcon } from "@phosphor-icons/react"; +import { useServerMutation } from "@/lib/tanstack-query/hooks/use-server-mutation"; +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"; +import brackets from "@/features/bracket/utils"; +import { Bracket } from "@/features/bracket/components/bracket"; +import { Match } from "@/features/matches/types"; +import { generateSingleEliminationBracket } from "../utils/bracket-generator"; + +interface SetupGroupStageProps { + tournamentId: string; + teams: TeamInfo[]; +} + +const SetupGroupStage: React.FC = ({ + tournamentId, + teams, +}) => { + const queryClient = useQueryClient(); + + const [selectedConfigIndex, setSelectedConfigIndex] = useState("0"); + const [seed, setSeed] = useState(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 knockoutTeamCount = useMemo(() => { + if (!selectedConfig) return 0; + return selectedConfig.num_groups * selectedConfig.advance_per_group; + }, [selectedConfig]); + + const bracketPreview = useMemo(() => { + if (!knockoutTeamCount || !selectedConfig) { + return null; + } + + let bracketTemplate: any; + if (Object.keys(brackets).includes(knockoutTeamCount.toString())) { + bracketTemplate = brackets[knockoutTeamCount as keyof typeof brackets]; + } else { + try { + bracketTemplate = generateSingleEliminationBracket(knockoutTeamCount); + } catch (error) { + return null; + } + } + + const groupNames = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']; + const seedLabels: Record = {}; + + const totalTeams = selectedConfig.num_groups * selectedConfig.advance_per_group; + let seedIndex = 1; + + for (let i = 0; i < totalTeams / 2; i++) { + const group1 = i % selectedConfig.num_groups; + const rankIndex1 = Math.floor(i / selectedConfig.num_groups); + const rank1 = rankIndex1 + 1; + const groupName1 = groupNames[group1] || `Group ${group1 + 1}`; + const rankSuffix1 = rank1 === 1 ? '1st' : rank1 === 2 ? '2nd' : rank1 === 3 ? '3rd' : `${rank1}th`; + seedLabels[seedIndex++] = `${groupName1} ${rankSuffix1}`; + + const group2 = (i + 1) % selectedConfig.num_groups; + const rankIndex2 = selectedConfig.advance_per_group - 1 - rankIndex1; + const rank2 = rankIndex2 + 1; + const groupName2 = groupNames[group2] || `Group ${group2 + 1}`; + const rankSuffix2 = rank2 === 1 ? '1st' : rank2 === 2 ? '2nd' : rank2 === 3 ? '3rd' : `${rank2}th`; + seedLabels[seedIndex++] = `${groupName2} ${rankSuffix2}`; + } + + const ordersMap: Record = {}; + bracketTemplate.winners.forEach((round: any[]) => { + round.forEach((match: any) => { + ordersMap[match.lid] = match.order; + }); + }); + + const placeholderMatches: Match[][] = bracketTemplate.winners.map((round: any[], roundIndex: number) => + round.map((match: any) => { + const matchData: any = { + ...match, + id: `preview-${match.lid}`, + home_from_lid: match.home_from_lid !== null && match.home_from_lid !== undefined ? match.home_from_lid : -1, + away_from_lid: match.away_from_lid !== null && match.away_from_lid !== undefined ? match.away_from_lid : -1, + home_cups: 0, + away_cups: 0, + status: "tbd" as const, + tournament: { id: "", name: "" }, + }; + + if (roundIndex === 0) { + matchData.home = match.home_seed && !match.bye ? { id: `seed-${match.home_seed}`, name: seedLabels[match.home_seed] } : null; + matchData.away = match.away_seed ? { id: `seed-${match.away_seed}`, name: seedLabels[match.away_seed] } : null; + } else { + matchData.home = null; + matchData.away = null; + } + + return matchData; + }) + ); + + return { matches: placeholderMatches, orders: ordersMap }; + }, [knockoutTeamCount, selectedConfig]); + + const generateGroups = useServerMutation({ + mutationFn: generateGroupStage, + successMessage: "Group stage generated successfully!", + 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 ( + }> + Need at least 4 teams to create a group stage format. Current team count: {teams.length} + + ); + } + + return ( +
+ + + + + + + Group Stage Setup + + + {teams.length} teams + + + + + + + Group Configuration + +