init matches, tournament runner

This commit is contained in:
2025-08-30 23:58:50 -05:00
parent c37e8e8eb7
commit d2e4f0ca3f
11 changed files with 499 additions and 7 deletions

View File

@@ -0,0 +1,247 @@
/// <reference path="../pb_data/types.d.ts" />
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"
},
{
"hidden": false,
"id": "number4113142680",
"max": null,
"min": null,
"name": "order",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "number1080860409",
"max": null,
"min": null,
"name": "lid",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "bool1352515405",
"name": "reset",
"presentable": false,
"required": false,
"system": false,
"type": "bool"
},
{
"hidden": false,
"id": "number2650326517",
"max": null,
"min": null,
"name": "home_cups",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "number766636452",
"max": null,
"min": null,
"name": "away_cups",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "number3404566555",
"max": null,
"min": null,
"name": "ot_count",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "date1345189255",
"max": "",
"min": "",
"name": "start_time",
"presentable": false,
"required": false,
"system": false,
"type": "date"
},
{
"hidden": false,
"id": "date1096160257",
"max": "",
"min": "",
"name": "end_time",
"presentable": false,
"required": false,
"system": false,
"type": "date"
},
{
"hidden": false,
"id": "bool2000130356",
"name": "bye",
"presentable": false,
"required": false,
"system": false,
"type": "bool"
},
{
"hidden": false,
"id": "number3642169398",
"max": null,
"min": null,
"name": "home_from_lid",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "number1662941821",
"max": null,
"min": null,
"name": "away_from_lid",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "bool1093314320",
"name": "home_from_loser",
"presentable": false,
"required": false,
"system": false,
"type": "bool"
},
{
"hidden": false,
"id": "bool4045114275",
"name": "away_from_loser",
"presentable": false,
"required": false,
"system": false,
"type": "bool"
},
{
"hidden": false,
"id": "bool1628031220",
"name": "is_losers_bracket",
"presentable": false,
"required": false,
"system": false,
"type": "bool"
},
{
"cascadeDelete": false,
"collectionId": "pbc_340646327",
"hidden": false,
"id": "relation3177167065",
"maxSelect": 1,
"minSelect": 0,
"name": "tournament",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"cascadeDelete": false,
"collectionId": "pbc_1568971955",
"hidden": false,
"id": "relation1909853392",
"maxSelect": 1,
"minSelect": 0,
"name": "home",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"cascadeDelete": false,
"collectionId": "pbc_1568971955",
"hidden": false,
"id": "relation2791285457",
"maxSelect": 1,
"minSelect": 0,
"name": "away",
"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_2541054544",
"indexes": [],
"listRule": null,
"name": "matches",
"system": false,
"type": "base",
"updateRule": null,
"viewRule": null
});
return app.save(collection);
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_2541054544");
return app.delete(collection);
})

View File

@@ -25,6 +25,7 @@ import { Route as AuthedProfilePlayerIdRouteImport } from './routes/_authed/prof
import { Route as AuthedAdminPreviewRouteImport } from './routes/_authed/admin/preview' import { Route as AuthedAdminPreviewRouteImport } from './routes/_authed/admin/preview'
import { Route as AuthedAdminTournamentsIndexRouteImport } from './routes/_authed/admin/tournaments/index' import { Route as AuthedAdminTournamentsIndexRouteImport } from './routes/_authed/admin/tournaments/index'
import { Route as AuthedAdminTournamentsIdRouteImport } from './routes/_authed/admin/tournaments/$id' import { Route as AuthedAdminTournamentsIdRouteImport } from './routes/_authed/admin/tournaments/$id'
import { Route as AuthedAdminTournamentsRunIdRouteImport } from './routes/_authed/admin/tournaments/run.$id'
import { ServerRoute as ApiTournamentsUploadLogoServerRouteImport } from './routes/api/tournaments/upload-logo' import { ServerRoute as ApiTournamentsUploadLogoServerRouteImport } from './routes/api/tournaments/upload-logo'
import { ServerRoute as ApiEventsSplatServerRouteImport } from './routes/api/events.$' import { ServerRoute as ApiEventsSplatServerRouteImport } from './routes/api/events.$'
import { ServerRoute as ApiAuthSplatServerRouteImport } from './routes/api/auth.$' import { ServerRoute as ApiAuthSplatServerRouteImport } from './routes/api/auth.$'
@@ -104,6 +105,12 @@ const AuthedAdminTournamentsIdRoute =
path: '/tournaments/$id', path: '/tournaments/$id',
getParentRoute: () => AuthedAdminRoute, getParentRoute: () => AuthedAdminRoute,
} as any) } as any)
const AuthedAdminTournamentsRunIdRoute =
AuthedAdminTournamentsRunIdRouteImport.update({
id: '/tournaments/run/$id',
path: '/tournaments/run/$id',
getParentRoute: () => AuthedAdminRoute,
} as any)
const ApiTournamentsUploadLogoServerRoute = const ApiTournamentsUploadLogoServerRoute =
ApiTournamentsUploadLogoServerRouteImport.update({ ApiTournamentsUploadLogoServerRouteImport.update({
id: '/api/tournaments/upload-logo', id: '/api/tournaments/upload-logo',
@@ -141,6 +148,7 @@ export interface FileRoutesByFullPath {
'/tournaments': typeof AuthedTournamentsIndexRoute '/tournaments': typeof AuthedTournamentsIndexRoute
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdRoute '/admin/tournaments/$id': typeof AuthedAdminTournamentsIdRoute
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute '/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/login': typeof LoginRoute '/login': typeof LoginRoute
@@ -155,6 +163,7 @@ export interface FileRoutesByTo {
'/tournaments': typeof AuthedTournamentsIndexRoute '/tournaments': typeof AuthedTournamentsIndexRoute
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdRoute '/admin/tournaments/$id': typeof AuthedAdminTournamentsIdRoute
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute '/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
@@ -172,6 +181,7 @@ export interface FileRoutesById {
'/_authed/tournaments/': typeof AuthedTournamentsIndexRoute '/_authed/tournaments/': typeof AuthedTournamentsIndexRoute
'/_authed/admin/tournaments/$id': typeof AuthedAdminTournamentsIdRoute '/_authed/admin/tournaments/$id': typeof AuthedAdminTournamentsIdRoute
'/_authed/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute '/_authed/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
'/_authed/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
@@ -189,6 +199,7 @@ export interface FileRouteTypes {
| '/tournaments' | '/tournaments'
| '/admin/tournaments/$id' | '/admin/tournaments/$id'
| '/admin/tournaments' | '/admin/tournaments'
| '/admin/tournaments/run/$id'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
| '/login' | '/login'
@@ -203,6 +214,7 @@ export interface FileRouteTypes {
| '/tournaments' | '/tournaments'
| '/admin/tournaments/$id' | '/admin/tournaments/$id'
| '/admin/tournaments' | '/admin/tournaments'
| '/admin/tournaments/run/$id'
id: id:
| '__root__' | '__root__'
| '/_authed' | '/_authed'
@@ -219,6 +231,7 @@ export interface FileRouteTypes {
| '/_authed/tournaments/' | '/_authed/tournaments/'
| '/_authed/admin/tournaments/$id' | '/_authed/admin/tournaments/$id'
| '/_authed/admin/tournaments/' | '/_authed/admin/tournaments/'
| '/_authed/admin/tournaments/run/$id'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
@@ -373,6 +386,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedAdminTournamentsIdRouteImport preLoaderRoute: typeof AuthedAdminTournamentsIdRouteImport
parentRoute: typeof AuthedAdminRoute parentRoute: typeof AuthedAdminRoute
} }
'/_authed/admin/tournaments/run/$id': {
id: '/_authed/admin/tournaments/run/$id'
path: '/tournaments/run/$id'
fullPath: '/admin/tournaments/run/$id'
preLoaderRoute: typeof AuthedAdminTournamentsRunIdRouteImport
parentRoute: typeof AuthedAdminRoute
}
} }
} }
declare module '@tanstack/react-start/server' { declare module '@tanstack/react-start/server' {
@@ -413,6 +433,7 @@ interface AuthedAdminRouteChildren {
AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute
AuthedAdminTournamentsIdRoute: typeof AuthedAdminTournamentsIdRoute AuthedAdminTournamentsIdRoute: typeof AuthedAdminTournamentsIdRoute
AuthedAdminTournamentsIndexRoute: typeof AuthedAdminTournamentsIndexRoute AuthedAdminTournamentsIndexRoute: typeof AuthedAdminTournamentsIndexRoute
AuthedAdminTournamentsRunIdRoute: typeof AuthedAdminTournamentsRunIdRoute
} }
const AuthedAdminRouteChildren: AuthedAdminRouteChildren = { const AuthedAdminRouteChildren: AuthedAdminRouteChildren = {
@@ -420,6 +441,7 @@ const AuthedAdminRouteChildren: AuthedAdminRouteChildren = {
AuthedAdminIndexRoute: AuthedAdminIndexRoute, AuthedAdminIndexRoute: AuthedAdminIndexRoute,
AuthedAdminTournamentsIdRoute: AuthedAdminTournamentsIdRoute, AuthedAdminTournamentsIdRoute: AuthedAdminTournamentsIdRoute,
AuthedAdminTournamentsIndexRoute: AuthedAdminTournamentsIndexRoute, AuthedAdminTournamentsIndexRoute: AuthedAdminTournamentsIndexRoute,
AuthedAdminTournamentsRunIdRoute: AuthedAdminTournamentsRunIdRoute,
} }
const AuthedAdminRouteWithChildren = AuthedAdminRoute._addFileChildren( const AuthedAdminRouteWithChildren = AuthedAdminRoute._addFileChildren(

View File

@@ -27,6 +27,7 @@ export function createRouter() {
header: defaultHeaderConfig, header: defaultHeaderConfig,
refresh: [], refresh: [],
withPadding: true, withPadding: true,
fullWidth: false,
}, },
defaultPreload: "intent", defaultPreload: "intent",
defaultErrorComponent: DefaultCatchBoundary, defaultErrorComponent: DefaultCatchBoundary,

View File

@@ -26,6 +26,7 @@ export const Route = createRootRouteWithContext<{
header: HeaderConfig; header: HeaderConfig;
refresh: string[]; refresh: string[];
withPadding: boolean; withPadding: boolean;
fullWidth: boolean;
}>()({ }>()({
head: () => ({ head: () => ({
meta: [ meta: [

View File

@@ -0,0 +1,31 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
import { tournamentQueries } from '@/features/tournaments/queries'
import { ensureServerQueryData } from '@/lib/tanstack-query/utils/ensure'
import RunTournament from '@/features/tournaments/components/run-tournament'
export const Route = createFileRoute('/_authed/admin/tournaments/run/$id')({
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 }) => ({
fullWidth: true,
header: {
withBackButton: true,
title: `Run ${context.tournament.name}`,
},
}),
component: RouteComponent,
})
function RouteComponent() {
const { id } = Route.useParams()
return <RunTournament tournamentId={id} />
}

View File

@@ -3,9 +3,10 @@ import useRouterConfig from "@/features/core/hooks/use-router-config";
interface PageProps extends ContainerProps, React.PropsWithChildren { interface PageProps extends ContainerProps, React.PropsWithChildren {
noPadding?: boolean; noPadding?: boolean;
fullWidth?: boolean;
} }
const Page = ({ children, noPadding, ...props }: PageProps) => { const Page = ({ children, noPadding, fullWidth, ...props }: PageProps) => {
const { header } = useRouterConfig(); const { header } = useRouterConfig();
return ( return (
<Container <Container
@@ -13,7 +14,7 @@ const Page = ({ children, noPadding, ...props }: PageProps) => {
pt={header.collapsed ? 60 : 0} pt={header.collapsed ? 60 : 0}
pb={20} pb={20}
m={0} m={0}
maw={600} maw={fullWidth ? '100%' : 600}
mx="auto" mx="auto"
{...props} {...props}
> >

View File

@@ -11,7 +11,7 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
const { header } = useRouterConfig(); const { header } = useRouterConfig();
const viewport = useVisualViewportSize(); const viewport = useVisualViewportSize();
const [scrollPosition, setScrollPosition] = useState({ x: 0, y: 0 }); const [scrollPosition, setScrollPosition] = useState({ x: 0, y: 0 });
const { withPadding } = useRouterConfig(); const { withPadding, fullWidth } = useRouterConfig();
return ( return (
<AppShell <AppShell
@@ -43,7 +43,7 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
style={{ transition: 'none' }} style={{ transition: 'none' }}
> >
<Pullable scrollPosition={scrollPosition} onScrollPositionChange={setScrollPosition}> <Pullable scrollPosition={scrollPosition} onScrollPositionChange={setScrollPosition}>
<Page noPadding={!withPadding}> <Page noPadding={!withPadding} fullWidth={fullWidth}>
{children} {children}
</Page> </Page>
</Pullable> </Pullable>

View File

@@ -33,7 +33,8 @@ const useRouterConfig = () => {
return { return {
header: headerConfig, header: headerConfig,
refresh: current && typeof current === 'object' && 'refresh' in current ? current.refresh : [], refresh: current && typeof current === 'object' && 'refresh' in current ? current.refresh : [],
withPadding: current && typeof current === 'object' && 'withPadding' in current ? current.withPadding : true withPadding: current && typeof current === 'object' && 'withPadding' in current ? current.withPadding : true,
fullWidth: current && typeof current === 'object' && 'fullWidth' in current ? current.fullWidth : false,
}; };
} }

View File

@@ -16,7 +16,7 @@ export interface Match {
away_from_lid: number; away_from_lid: number;
home_from_loser: boolean; home_from_loser: boolean;
away_from_loser: boolean; away_from_loser: boolean;
bracket_type: 'winners' | 'losers'; is_losers_bracket: 'winners' | 'losers';
tournament_id: string; tournament_id: string;
home_id: string; home_id: string;
away_id: string; away_id: string;
@@ -39,7 +39,7 @@ export const matchInputSchema = z.object({
away_from_lid: z.number().int().min(1).optional(), away_from_lid: z.number().int().min(1).optional(),
home_from_loser: z.boolean().optional().default(false), home_from_loser: z.boolean().optional().default(false),
away_from_loser: z.boolean().optional().default(false), away_from_loser: z.boolean().optional().default(false),
losers_bracket: z.boolean().optional().default(false), is_losers_bracket: z.boolean().optional().default(false),
tournament_id: z.string().min(1), tournament_id: z.string().min(1),
home_id: z.string().min(1).optional(), home_id: z.string().min(1).optional(),
away_id: z.string().min(1).optional(), away_id: z.string().min(1).optional(),

View File

@@ -6,10 +6,12 @@ import TournamentForm from "./tournament-form";
import { import {
HardDrivesIcon, HardDrivesIcon,
PencilLineIcon, PencilLineIcon,
TreeStructureIcon,
UsersThreeIcon, UsersThreeIcon,
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
import { useSheet } from "@/hooks/use-sheet"; import { useSheet } from "@/hooks/use-sheet";
import EditEnrolledTeams from "./edit-enrolled-teams"; import EditEnrolledTeams from "./edit-enrolled-teams";
import ListLink from "@/components/list-link";
interface ManageTournamentProps { interface ManageTournamentProps {
tournamentId: string; tournamentId: string;
@@ -53,6 +55,11 @@ const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
Icon={UsersThreeIcon} Icon={UsersThreeIcon}
onClick={openEditTeams} onClick={openEditTeams}
/> />
<ListLink
label="Run Tournament"
Icon={TreeStructureIcon}
to={`/admin/tournaments/run/${tournamentId}`}
/>
</List> </List>
<Sheet <Sheet

View File

@@ -0,0 +1,181 @@
import { useTournament } from '../queries'
import { Box, Grid, NumberInput, Stack, Text, Title, Group, Flex, Divider, ScrollArea, Button } from '@mantine/core'
import { useState, useMemo, useEffect } from 'react'
import Avatar from '@/components/avatar'
import { useBracketPreview } from '@/features/bracket/queries'
import Bracket from '@/features/bracket/components/bracket'
import { createBracketMaps, BracketMaps } from '@/features/bracket/utils/bracket-maps'
import { Match, BracketData } from '@/features/bracket/types'
interface RunTournamentProps {
tournamentId: string
}
interface TeamWithSeed {
id: string
name: string
seed: number
}
const RunTournament = ({ tournamentId }: RunTournamentProps) => {
const { data: tournament } = useTournament(tournamentId)
const teamCount = tournament?.teams?.length || 0
const { data: bracketData, isLoading } = useBracketPreview(teamCount)
const [teamSeeds, setTeamSeeds] = useState<Record<string, number>>(() => {
if (!tournament?.teams) return {}
return tournament.teams.reduce((acc, team, index) => {
acc[team.id] = index + 1
return acc
}, {} as Record<string, number>)
})
const [seededWinnersBracket, setSeededWinnersBracket] = useState<Match[][]>([])
const [seededLosersBracket, setSeededLosersBracket] = useState<Match[][]>([])
const [bracketMaps, setBracketMaps] = useState<BracketMaps | null>(null)
const sortedTeams = useMemo(() => {
if (!tournament?.teams) return []
return tournament.teams
.map(team => ({
...team,
seed: teamSeeds[team.id] || 1,
}))
.sort((a, b) => a.seed - b.seed)
}, [tournament?.teams, teamSeeds])
const handleSeedChange = (teamId: string, newSeed: number) => {
if (newSeed < 1 || !tournament?.teams) return
setTeamSeeds(prev => {
const currSeed = prev[teamId]
const newSeeds = { ...prev }
const otherTeams = tournament.teams!.filter(team => team.id !== teamId)
if (newSeed !== currSeed) {
const currTeam = otherTeams.find(team => prev[team.id] === newSeed)
if (currTeam) {
const affectedTeams = otherTeams.filter(team => {
const teamSeed = prev[team.id]
return newSeed < currSeed
? teamSeed >= newSeed && teamSeed < currSeed
: teamSeed > currSeed && teamSeed <= newSeed
})
affectedTeams.forEach(team => {
const teamSeed = prev[team.id]
if (newSeed < currSeed) {
newSeeds[team.id] = teamSeed + 1
} else {
newSeeds[team.id] = teamSeed - 1
}
})
}
newSeeds[teamId] = newSeed
}
return newSeeds
})
}
useEffect(() => {
if (!bracketData || !tournament?.teams || sortedTeams.length === 0) return
const maps = createBracketMaps(bracketData)
setBracketMaps(maps)
const mapBracket = (bracket: Match[][]) => {
return bracket.map((round) =>
round.map((match) => {
const mappedMatch = { ...match }
if (match.home?.seed && match.home.seed > 0) {
const team = sortedTeams.find(t => t.seed === match.home.seed)
if (team) {
mappedMatch.home = {
...match.home,
team: team,
}
}
}
if (match.away?.seed && match.away.seed > 0) {
const team = sortedTeams.find(t => t.seed === match.away.seed)
if (team) {
mappedMatch.away = {
...match.away,
team: team,
}
}
}
return mappedMatch
})
)
}
setSeededWinnersBracket(mapBracket(bracketData.winners))
setSeededLosersBracket(mapBracket(bracketData.losers))
}, [bracketData, sortedTeams])
if (!tournament) throw new Error('Tournament not found.')
return (
<Grid>
<Grid.Col span={3}>
<ScrollArea offsetScrollbars type='always' h={700}>
<Title order={3}>Team Seeds</Title>
{sortedTeams.map((team) => (
<>
<Group
key={team.id}
justify="space-between"
p={4}
>
<Group gap="xs" style={{ flex: 1 }}>
<Avatar size={24} name={team.name} />
<Text fw={500} size="sm" truncate>{team.name}</Text>
</Group>
<NumberInput
value={teamSeeds[team.id]}
onChange={(value) => handleSeedChange(team.id, Number(value) || 1)}
min={1}
max={tournament.teams?.length || 1}
size="xs"
w={50}
styles={{ input: { textAlign: 'center' } }}
step={-1}
/>
</Group>
<Divider />
</>
))}
</ScrollArea>
<Button fullWidth mt='md' onClick={() => console.log(sortedTeams)}>Start Tournament</Button>
</Grid.Col>
<Grid.Col span={9}>
<Stack gap="md">
<Title order={3}>Tournament Bracket</Title>
{isLoading ? (
<Flex justify="center" align="center" h="400px">
<Text c="dimmed">Loading bracket...</Text>
</Flex>
) : (
<Bracket
winners={seededWinnersBracket}
losers={seededLosersBracket}
bracketMaps={bracketMaps}
/>
)}
</Stack>
</Grid.Col>
</Grid>
)
}
export default RunTournament