From 6e9e014fcc2167c70476c204893abbb7534d411d Mon Sep 17 00:00:00 2001 From: yohlo Date: Sat, 14 Feb 2026 12:59:01 -0600 Subject: [PATCH 01/18] cookie and pwa stuff --- k8s/base/app-deployment.yaml | 5 ++ k8s/overlays/dev/configmap.yaml | 1 + k8s/overlays/prod/configmap.yaml | 1 + public/site.webmanifest | 19 +++++-- src/app/routes/__root.tsx | 8 +++ src/components/ios-install-prompt.tsx | 59 ++++++++++++++++++++ src/components/session-monitor.tsx | 6 +- src/hooks/use-is-pwa.ts | 15 +++++ src/lib/logger/index.ts | 55 +++++++++++------- src/lib/supertokens/recipes/start-session.ts | 34 +++++++++++ src/lib/supertokens/server.ts | 1 + 11 files changed, 180 insertions(+), 24 deletions(-) create mode 100644 src/components/ios-install-prompt.tsx create mode 100644 src/hooks/use-is-pwa.ts 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/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/src/app/routes/__root.tsx b/src/app/routes/__root.tsx index 03b49f4..64f63ff 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: [ { @@ -128,6 +135,7 @@ function RootComponent() { + 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/hooks/use-is-pwa.ts b/src/hooks/use-is-pwa.ts new file mode 100644 index 0000000..a46d545 --- /dev/null +++ b/src/hooks/use-is-pwa.ts @@ -0,0 +1,15 @@ +import { useEffect, useState } from 'react' + +export function useIsPWA(): boolean { + const [isPWA, setIsPWA] = useState(false) + + useEffect(() => { + const isStandalone = window.matchMedia('(display-mode: standalone)').matches + + const isIOSStandalone = 'standalone' in window.navigator && (window.navigator as any).standalone + + setIsPWA(isStandalone || isIOSStandalone) + }, []) + + return isPWA +} diff --git a/src/lib/logger/index.ts b/src/lib/logger/index.ts index f9067fe..d9453f2 100644 --- a/src/lib/logger/index.ts +++ b/src/lib/logger/index.ts @@ -46,7 +46,7 @@ class Logger { constructor(context?: string, options: LoggerOptions = {}) { this.context = context; this.options = { - enabled: import.meta.env.NODE_ENV !== "production", + enabled: true, showTimestamp: true, collapsed: true, colors: true, @@ -75,27 +75,44 @@ class Logger { const groupLabel = `${timestamp}${style.label}${context} │ ${label}`; - const group = this.options.collapsed - ? console.groupCollapsed - : console.group; + // In server environment (no window), use simple console.log instead of groups + const isServer = typeof window === "undefined"; - if (this.options.colors && typeof window !== "undefined") { - group(`%c${groupLabel}`, `color: ${style.color}; font-weight: bold;`); - } else { - group(groupLabel); - } - - if (data !== undefined) { - console.log(data); - } - - if (rest.length > 0) { - for (const item of rest) { - console.log(item); + if (isServer) { + // Server-side: Simple formatted output (no console.group in Node.js) + console.log(groupLabel); + if (data !== undefined) { + console.log(JSON.stringify(data, null, 2)); } - } + if (rest.length > 0) { + for (const item of rest) { + console.log(JSON.stringify(item, null, 2)); + } + } + } else { + // Browser: Use console.group with colors + const group = this.options.collapsed + ? console.groupCollapsed + : console.group; - console.groupEnd(); + if (this.options.colors) { + group(`%c${groupLabel}`, `color: ${style.color}; font-weight: bold;`); + } else { + group(groupLabel); + } + + if (data !== undefined) { + console.log(data); + } + + if (rest.length > 0) { + for (const item of rest) { + console.log(item); + } + } + + console.groupEnd(); + } } info(label: string, data?: any, ...rest: any[]): void { diff --git a/src/lib/supertokens/recipes/start-session.ts b/src/lib/supertokens/recipes/start-session.ts index f2c6bf1..66d7b5b 100644 --- a/src/lib/supertokens/recipes/start-session.ts +++ b/src/lib/supertokens/recipes/start-session.ts @@ -5,6 +5,40 @@ import SuperTokens from "supertokens-node"; export async function getSessionForStart(request: Request, options?: { sessionRequired?: boolean }) { ensureSuperTokensBackend(); + + const cookieHeader = request.headers.get('cookie'); + if (cookieHeader) { + const tokens = cookieHeader.match(/sAccessToken=([^;]+)/g); + if (tokens && tokens.length > 1) { + logger.warn(`Detected ${tokens.length} duplicate sAccessToken cookies, cleaning up`); + + const parsedTokens = tokens.map(tokenStr => { + const token = tokenStr.replace('sAccessToken=', ''); + try { + const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()); + return { token, exp: payload.exp, iat: payload.iat }; + } catch (e) { + logger.error('Failed to parse token', e); + return { token, exp: 0, iat: 0 }; + } + }); + + parsedTokens.sort((a, b) => b.exp - a.exp); + const freshestToken = parsedTokens[0]; + + logger.info(`Using freshest token: exp=${freshestToken.exp}, iat=${freshestToken.iat}`); + + const cleanedCookie = cookieHeader + .split(';') + .filter(c => !c.trim().startsWith('sAccessToken=')) + .join(';') + `; sAccessToken=${freshestToken.token}`; + + const cleanedHeaders = new Headers(request.headers); + cleanedHeaders.set('cookie', cleanedCookie); + request = new Request(request, { headers: cleanedHeaders }); + } + } + try { const session = await getSessionForSSR(request); diff --git a/src/lib/supertokens/server.ts b/src/lib/supertokens/server.ts index be2d736..f5119ab 100644 --- a/src/lib/supertokens/server.ts +++ b/src/lib/supertokens/server.ts @@ -25,6 +25,7 @@ export const backendConfig = (): TypeInput => { cookieSameSite: "lax", cookieSecure: process.env.NODE_ENV === "production", cookieDomain: process.env.COOKIE_DOMAIN || undefined, + olderCookieDomain: undefined, antiCsrf: process.env.NODE_ENV === "production" ? "VIA_TOKEN" : "NONE", // Debug only From 7f60b4d2005b7ca7634bb459c7586004da077d8a Mon Sep 17 00:00:00 2001 From: yohlo Date: Sat, 14 Feb 2026 15:46:31 -0600 Subject: [PATCH 02/18] idk --- src/app/routes/__root.tsx | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/src/app/routes/__root.tsx b/src/app/routes/__root.tsx index 64f63ff..c8d541a 100644 --- a/src/app/routes/__root.tsx +++ b/src/app/routes/__root.tsx @@ -103,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 {}; } }, From b9e16e2b64e7ec87d137f8a8f711ed79edb00385 Mon Sep 17 00:00:00 2001 From: yohlo Date: Sat, 21 Feb 2026 23:12:21 -0600 Subject: [PATCH 03/18] 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; + }, }; } From 2dd3e5b1700aacc82f63367474f7aa82a98dbd7f Mon Sep 17 00:00:00 2001 From: yohlo Date: Sat, 21 Feb 2026 23:30:58 -0600 Subject: [PATCH 04/18] enrollment polish --- .../teams/components/regional-team-card.tsx | 58 +++++++++++++++++++ .../components/upcoming-tournament/index.tsx | 31 +++++++--- 2 files changed, 80 insertions(+), 9 deletions(-) create mode 100644 src/features/teams/components/regional-team-card.tsx 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..1a5156c --- /dev/null +++ b/src/features/teams/components/regional-team-card.tsx @@ -0,0 +1,58 @@ +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/tournaments/components/upcoming-tournament/index.tsx b/src/features/tournaments/components/upcoming-tournament/index.tsx index c8f31f7..e050fcf 100644 --- a/src/features/tournaments/components/upcoming-tournament/index.tsx +++ b/src/features/tournaments/components/upcoming-tournament/index.tsx @@ -12,6 +12,7 @@ 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"; +import RegionalTeamCard from "@/features/teams/components/regional-team-card"; import UpdateTeam from "./update-team"; import UnenrollTeam from "./unenroll-team"; import { useQueryClient } from "@tanstack/react-query"; @@ -99,15 +100,23 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({ {isUserEnrolled && ( <> }> - + {tournament.regional === true ? ( + + ) : ( + + )} - - {isEnrollmentOpen && ( - + {tournament.regional !== true && ( + <> + + {isEnrollmentOpen && ( + + )} + )} )} @@ -141,7 +150,11 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({ disabled /> {tournament.regional === true ? ( - + (tournament.teams && tournament.teams.length > 0) ? ( + + ) : ( + + ) ) : ( )} From f83a7d69c8a8999226331a384da8aae4af4e5af4 Mon Sep 17 00:00:00 2001 From: yohlo Date: Wed, 25 Feb 2026 19:54:51 -0600 Subject: [PATCH 05/18] groups init --- src/app/routeTree.gen.ts | 22 ++ .../_authed/admin/tournaments/run.$id.tsx | 36 +- .../_authed/tournaments/$id.bracket.tsx | 7 +- .../routes/_authed/tournaments/$id.groups.tsx | 45 +++ .../components/group-match-card.tsx | 201 ++++++++++ .../tournaments/components/group-preview.tsx | 50 +++ .../components/group-stage-view.tsx | 372 ++++++++++++++++++ .../components/setup-group-stage.tsx | 182 +++++++++ .../components/started-tournament/index.tsx | 13 +- .../components/tournament-stats.tsx | 13 +- src/features/tournaments/server.ts | 131 +++++- src/features/tournaments/types.ts | 9 + .../tournaments/utils/group-config.ts | 167 ++++++++ src/lib/pocketbase/client.ts | 5 +- src/lib/pocketbase/services/groups.ts | 46 +++ src/lib/pocketbase/services/tournaments.ts | 8 +- src/lib/pocketbase/util/transform-types.ts | 16 + 17 files changed, 1306 insertions(+), 17 deletions(-) create mode 100644 src/app/routes/_authed/tournaments/$id.groups.tsx create mode 100644 src/features/tournaments/components/group-match-card.tsx create mode 100644 src/features/tournaments/components/group-preview.tsx create mode 100644 src/features/tournaments/components/group-stage-view.tsx create mode 100644 src/features/tournaments/components/setup-group-stage.tsx create mode 100644 src/features/tournaments/utils/group-config.ts create mode 100644 src/lib/pocketbase/services/groups.ts diff --git a/src/app/routeTree.gen.ts b/src/app/routeTree.gen.ts index 2732ee2..6ea2f8c 100644 --- a/src/app/routeTree.gen.ts +++ b/src/app/routeTree.gen.ts @@ -38,6 +38,7 @@ import { Route as AuthedAdminPreviewRouteImport } from './routes/_authed/admin/p import { Route as AuthedAdminBadgesRouteImport } from './routes/_authed/admin/badges' import { Route as AuthedAdminActivitiesRouteImport } from './routes/_authed/admin/activities' import { Route as AuthedAdminTournamentsIndexRouteImport } from './routes/_authed/admin/tournaments/index' +import { Route as AuthedTournamentsIdGroupsRouteImport } from './routes/_authed/tournaments/$id.groups' import { Route as AuthedTournamentsIdBracketRouteImport } from './routes/_authed/tournaments/$id.bracket' import { Route as AuthedAdminTournamentsIdIndexRouteImport } from './routes/_authed/admin/tournaments/$id/index' import { Route as ApiFilesCollectionRecordIdFileRouteImport } from './routes/api/files/$collection/$recordId/$file' @@ -192,6 +193,12 @@ const AuthedAdminTournamentsIndexRoute = path: '/tournaments/', getParentRoute: () => AuthedAdminRoute, } as any) +const AuthedTournamentsIdGroupsRoute = + AuthedTournamentsIdGroupsRouteImport.update({ + id: '/tournaments/$id/groups', + path: '/tournaments/$id/groups', + getParentRoute: () => AuthedRoute, + } as any) const AuthedTournamentsIdBracketRoute = AuthedTournamentsIdBracketRouteImport.update({ id: '/tournaments/$id/bracket', @@ -258,6 +265,7 @@ export interface FileRoutesByFullPath { '/admin/': typeof AuthedAdminIndexRoute '/tournaments/': typeof AuthedTournamentsIndexRoute '/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute + '/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute '/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute '/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute '/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute @@ -293,6 +301,7 @@ export interface FileRoutesByTo { '/admin': typeof AuthedAdminIndexRoute '/tournaments': typeof AuthedTournamentsIndexRoute '/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute + '/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute '/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute '/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute '/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute @@ -331,6 +340,7 @@ export interface FileRoutesById { '/_authed/admin/': typeof AuthedAdminIndexRoute '/_authed/tournaments/': typeof AuthedTournamentsIndexRoute '/_authed/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute + '/_authed/tournaments/$id/groups': typeof AuthedTournamentsIdGroupsRoute '/_authed/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute '/_authed/admin/tournaments/$id/assign-partners': typeof AuthedAdminTournamentsIdAssignPartnersRoute '/_authed/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute @@ -369,6 +379,7 @@ export interface FileRouteTypes { | '/admin/' | '/tournaments/' | '/tournaments/$id/bracket' + | '/tournaments/$id/groups' | '/admin/tournaments/' | '/admin/tournaments/$id/assign-partners' | '/admin/tournaments/$id/teams' @@ -404,6 +415,7 @@ export interface FileRouteTypes { | '/admin' | '/tournaments' | '/tournaments/$id/bracket' + | '/tournaments/$id/groups' | '/admin/tournaments' | '/admin/tournaments/$id/assign-partners' | '/admin/tournaments/$id/teams' @@ -441,6 +453,7 @@ export interface FileRouteTypes { | '/_authed/admin/' | '/_authed/tournaments/' | '/_authed/tournaments/$id/bracket' + | '/_authed/tournaments/$id/groups' | '/_authed/admin/tournaments/' | '/_authed/admin/tournaments/$id/assign-partners' | '/_authed/admin/tournaments/$id/teams' @@ -673,6 +686,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthedAdminTournamentsIndexRouteImport parentRoute: typeof AuthedAdminRoute } + '/_authed/tournaments/$id/groups': { + id: '/_authed/tournaments/$id/groups' + path: '/tournaments/$id/groups' + fullPath: '/tournaments/$id/groups' + preLoaderRoute: typeof AuthedTournamentsIdGroupsRouteImport + parentRoute: typeof AuthedRoute + } '/_authed/tournaments/$id/bracket': { id: '/_authed/tournaments/$id/bracket' path: '/tournaments/$id/bracket' @@ -758,6 +778,7 @@ interface AuthedRouteChildren { AuthedTournamentsTournamentIdRoute: typeof AuthedTournamentsTournamentIdRoute AuthedTournamentsIndexRoute: typeof AuthedTournamentsIndexRoute AuthedTournamentsIdBracketRoute: typeof AuthedTournamentsIdBracketRoute + AuthedTournamentsIdGroupsRoute: typeof AuthedTournamentsIdGroupsRoute } const AuthedRouteChildren: AuthedRouteChildren = { @@ -771,6 +792,7 @@ const AuthedRouteChildren: AuthedRouteChildren = { AuthedTournamentsTournamentIdRoute: AuthedTournamentsTournamentIdRoute, AuthedTournamentsIndexRoute: AuthedTournamentsIndexRoute, AuthedTournamentsIdBracketRoute: AuthedTournamentsIdBracketRoute, + AuthedTournamentsIdGroupsRoute: AuthedTournamentsIdGroupsRoute, } const AuthedRouteWithChildren = diff --git a/src/app/routes/_authed/admin/tournaments/run.$id.tsx b/src/app/routes/_authed/admin/tournaments/run.$id.tsx index 3de4ccf..d7e937b 100644 --- a/src/app/routes/_authed/admin/tournaments/run.$id.tsx +++ b/src/app/routes/_authed/admin/tournaments/run.$id.tsx @@ -5,6 +5,8 @@ import { } from "@/features/tournaments/queries"; import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; import SeedTournament from "@/features/tournaments/components/seed-tournament"; +import SetupGroupStage from "@/features/tournaments/components/setup-group-stage"; +import GroupStageView from "@/features/tournaments/components/group-stage-view"; import { Container } from "@mantine/core"; import { useMemo } from "react"; import { BracketData } from "@/features/bracket/types"; @@ -43,6 +45,10 @@ function RouteComponent() { const { roles } = useAuth(); const isAdmin = roles?.includes('Admin') || false; + const isGroupStage = useMemo(() => { + return tournament.matches?.some((match) => match.round === -1) || false; + }, [tournament.matches]); + const bracket: BracketData = useMemo(() => { if (!tournament.matches || tournament.matches.length === 0) { return { winners: [], losers: [] }; @@ -52,6 +58,7 @@ function RouteComponent() { const losersMap = new Map(); tournament.matches + .filter((match) => match.round !== -1) .sort((a, b) => a.lid - b.lid) .forEach((match) => { if (!match.is_losers_bracket) { @@ -79,15 +86,30 @@ function RouteComponent() { return ( - { isAdmin && } + { isAdmin && !tournament.regional && } {tournament.matches?.length ? ( - + isGroupStage ? ( + + ) : ( + + ) ) : ( - + 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..a562102 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,10 +25,9 @@ export const Route = createFileRoute("/_authed/tournaments/$id/bracket")({ loader: ({ context }) => ({ fullWidth: true, withPadding: false, - showSpotifyPanel: true, header: { withBackButton: true, - title: `${context.tournament.name}`, + title: `${context.tournament.name} - Bracket`, }, }), component: RouteComponent, @@ -48,6 +46,7 @@ function RouteComponent() { const losersMap = new Map(); tournament.matches + .filter((match) => match.round !== -1) .sort((a, b) => a.lid - b.lid) .forEach((match) => { if (!match.is_losers_bracket) { 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..194406e --- /dev/null +++ b/src/app/routes/_authed/tournaments/$id.groups.tsx @@ -0,0 +1,45 @@ +import { createFileRoute, redirect } from "@tanstack/react-router"; +import { + tournamentQueries, + useTournament, +} from "@/features/tournaments/queries"; +import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; +import GroupStageView from "@/features/tournaments/components/group-stage-view"; +import { Container } from "@mantine/core"; + +export const Route = createFileRoute("/_authed/tournaments/$id/groups")({ + beforeLoad: async ({ context, params }) => { + const { queryClient } = context; + const tournament = await ensureServerQueryData( + queryClient, + tournamentQueries.details(params.id) + ); + if (!tournament) throw redirect({ to: "/tournaments" }); + return { + tournament, + }; + }, + loader: ({ context }) => ({ + fullWidth: true, + withPadding: false, + header: { + withBackButton: true, + title: `${context.tournament.name} - Groups`, + }, + }), + component: RouteComponent, +}); + +function RouteComponent() { + const { id } = Route.useParams(); + const { data: tournament } = useTournament(id); + + return ( + + + + ); +} 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..37f2eb5 --- /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..9e13876 --- /dev/null +++ b/src/features/tournaments/components/group-stage-view.tsx @@ -0,0 +1,372 @@ +import React, { useMemo, useState } from "react"; +import { Stack, Text, Card, Group as MantineGroup, Box, SimpleGrid, Tabs, Collapse, ActionIcon } from "@mantine/core"; +import { CaretCircleDown, CaretCircleUp } from "@phosphor-icons/react"; +import { Match } from "@/features/matches/types"; +import { Group } from "../types"; +import GroupMatchCard from "./group-match-card"; +import TeamAvatar from "@/components/team-avatar"; + +interface GroupStageViewProps { + groups: Group[]; + matches: Match[]; + showControls?: boolean; +} + +interface TeamStanding { + teamId: string; + teamName: string; + team: any; + wins: number; + losses: number; + cupDifference: number; +} + +const GroupStageView: React.FC = ({ + groups, + matches, + showControls, +}) => { + const [expandedTeams, setExpandedTeams] = useState>({}); + + 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, + cupDifference: 0, + }); + }); + + groupMatches + .filter((match) => match.status === "ended") + .forEach((match) => { + const homeId = match.home?.id; + const awayId = match.away?.id; + + if (!homeId || !awayId) return; + + const homeStanding = standings.get(homeId); + const awayStanding = standings.get(awayId); + + if (!homeStanding || !awayStanding) return; + + const homeCups = match.home_cups || 0; + const awayCups = match.away_cups || 0; + + homeStanding.cupDifference += homeCups - awayCups; + awayStanding.cupDifference += awayCups - homeCups; + + if (homeCups > awayCups) { + homeStanding.wins++; + awayStanding.losses++; + } else if (awayCups > homeCups) { + awayStanding.wins++; + homeStanding.losses++; + } + }); + + return Array.from(standings.values()).sort((a, b) => { + if (b.wins !== a.wins) return b.wins - a.wins; + return b.cupDifference - a.cupDifference; + }); + }; + + if (sortedGroups.length === 0) { + return ( + + + No groups have been created yet + + + ); + } + + if (sortedGroups.length === 1) { + const group = sortedGroups[0]; + 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} + > + {standing.cupDifference > 0 ? '+' : ''}{standing.cupDifference} + + + + )) + ) : ( + + No teams assigned + + )} + + + + + {groupMatches.length === 0 ? ( + + + No matches scheduled + + + ) : ( + + {groupMatches.map((match) => ( + + ))} + + )} + + + ); + } + + return ( + + + + {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} + > + {standing.cupDifference > 0 ? '+' : ''}{standing.cupDifference} + + + + )) + ) : ( + + No teams assigned + + )} + + + + + {/* Matches Grid */} + {groupMatches.length === 0 ? ( + + + No matches scheduled + + + ) : ( + + {groupMatches.map((match) => ( + + ))} + + )} + + + ); + })} + + + ); +}; + +export default GroupStageView; 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..42b2295 --- /dev/null +++ b/src/features/tournaments/components/setup-group-stage.tsx @@ -0,0 +1,182 @@ +import React, { useState, useMemo } from "react"; +import { + Stack, + Text, + Group, + Button, + Select, + LoadingOverlay, + Alert, +} from "@mantine/core"; +import { InfoIcon } from "@phosphor-icons/react"; +import { useServerMutation } from "@/lib/tanstack-query/hooks/use-server-mutation"; +import { generateGroupStage } from "../server"; +import { TeamInfo } from "@/features/teams/types"; +import { + calculateGroupConfigurations, + assignTeamsToGroups, + getGroupName, + GroupConfigOption, +} from "../utils/group-config"; +import GroupPreview from "./group-preview"; +import { useQueryClient } from "@tanstack/react-query"; +import { tournamentKeys } from "../queries"; + +interface SetupGroupStageProps { + tournamentId: string; + teams: TeamInfo[]; +} + +const SetupGroupStage: React.FC = ({ + 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 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 + +