better brackets, info types
This commit is contained in:
44
pb_migrations/1757211840_updated_matches.js
Normal file
44
pb_migrations/1757211840_updated_matches.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_2541054544")
|
||||
|
||||
// add field
|
||||
collection.fields.addAt(18, new Field({
|
||||
"hidden": false,
|
||||
"id": "number1705071305",
|
||||
"max": null,
|
||||
"min": null,
|
||||
"name": "home_seed",
|
||||
"onlyInt": false,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
}))
|
||||
|
||||
// add field
|
||||
collection.fields.addAt(19, new Field({
|
||||
"hidden": false,
|
||||
"id": "number3588777624",
|
||||
"max": null,
|
||||
"min": null,
|
||||
"name": "away_seed",
|
||||
"onlyInt": false,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
}))
|
||||
|
||||
return app.save(collection)
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_2541054544")
|
||||
|
||||
// remove field
|
||||
collection.fields.removeById("number1705071305")
|
||||
|
||||
// remove field
|
||||
collection.fields.removeById("number3588777624")
|
||||
|
||||
return app.save(collection)
|
||||
})
|
||||
28
pb_migrations/1757211934_updated_tournaments.js
Normal file
28
pb_migrations/1757211934_updated_tournaments.js
Normal file
@@ -0,0 +1,28 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_340646327")
|
||||
|
||||
// add field
|
||||
collection.fields.addAt(10, new Field({
|
||||
"cascadeDelete": false,
|
||||
"collectionId": "pbc_2541054544",
|
||||
"hidden": false,
|
||||
"id": "relation103159226",
|
||||
"maxSelect": 999,
|
||||
"minSelect": 0,
|
||||
"name": "matches",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
}))
|
||||
|
||||
return app.save(collection)
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_340646327")
|
||||
|
||||
// remove field
|
||||
collection.fields.removeById("relation103159226")
|
||||
|
||||
return app.save(collection)
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import BracketPreview from "@/features/bracket/components/preview";
|
||||
import BracketPreview from "@/features/admin/components/preview";
|
||||
import { NumberInput } from "@mantine/core";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
import { createFileRoute, redirect, useRouter } from '@tanstack/react-router'
|
||||
import { tournamentQueries } from '@/features/tournaments/queries'
|
||||
import { ensureServerQueryData } from '@/lib/tanstack-query/utils/ensure'
|
||||
import SeedTournament from '@/features/tournaments/components/seed-tournament'
|
||||
import { Container, Alert, Text } from '@mantine/core'
|
||||
import { Info } from '@phosphor-icons/react'
|
||||
|
||||
export const Route = createFileRoute('/_authed/admin/tournaments/run/$id')({
|
||||
beforeLoad: async ({ context, params }) => {
|
||||
@@ -25,6 +28,30 @@ export const Route = createFileRoute('/_authed/admin/tournaments/run/$id')({
|
||||
})
|
||||
|
||||
function RouteComponent() {
|
||||
const { id } = Route.useParams()
|
||||
return <p>Run tournament</p>
|
||||
const { tournament } = Route.useRouteContext()
|
||||
const router = useRouter()
|
||||
|
||||
const handleSuccess = () => {
|
||||
router.navigate({
|
||||
to: '/admin/tournaments/$id',
|
||||
params: { id: tournament.id }
|
||||
})
|
||||
}
|
||||
|
||||
console.log('Tournament:', tournament)
|
||||
|
||||
return (
|
||||
<Container size="md">
|
||||
{
|
||||
tournament.matches?.length ?
|
||||
<p>Matches</p>
|
||||
: (
|
||||
<SeedTournament
|
||||
tournamentId={tournament.id}
|
||||
teams={tournament.teams || []}
|
||||
onSuccess={handleSuccess}
|
||||
/>)
|
||||
}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { Flex, Text } from '@mantine/core';
|
||||
import React from 'react';
|
||||
import { MatchCard } from './match-card';
|
||||
import { Match } from '../types';
|
||||
|
||||
interface BracketRoundProps {
|
||||
matches: Match[];
|
||||
roundIndex: number;
|
||||
getParentMatchOrder: (parentLid: number) => number | string;
|
||||
onAnnounce?: (teamOne: any, teamTwo: any) => void;
|
||||
}
|
||||
|
||||
export const BracketRound: React.FC<BracketRoundProps> = ({
|
||||
matches,
|
||||
roundIndex,
|
||||
getParentMatchOrder,
|
||||
onAnnounce,
|
||||
}) => {
|
||||
const isBye = (type: string) => type?.toLowerCase() === 'bye';
|
||||
|
||||
return (
|
||||
<Flex direction="column" key={roundIndex} gap={24} justify="space-around">
|
||||
{matches.map((match, matchIndex) => {
|
||||
if (!match) return null;
|
||||
if (isBye(match.type)) return <></>; // for spacing
|
||||
|
||||
return (
|
||||
<Flex
|
||||
direction="row"
|
||||
key={matchIndex}
|
||||
align="center"
|
||||
justify="end"
|
||||
gap={8}
|
||||
>
|
||||
<Text c="dimmed" fw="bolder">
|
||||
{match.order}
|
||||
</Text>
|
||||
<MatchCard
|
||||
match={match}
|
||||
getParentMatchOrder={getParentMatchOrder}
|
||||
onAnnounce={onAnnounce}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -1,46 +0,0 @@
|
||||
import { Flex } from '@mantine/core';
|
||||
import React, { useCallback } from 'react';
|
||||
import { BracketMaps } from '../utils/bracket-maps';
|
||||
import { BracketRound } from './bracket-round';
|
||||
import { Match } from '../types';
|
||||
|
||||
interface BracketViewProps {
|
||||
bracket: Match[][];
|
||||
bracketMaps: BracketMaps;
|
||||
onAnnounce?: (teamOne: any, teamTwo: any) => void;
|
||||
}
|
||||
|
||||
const BracketView: React.FC<BracketViewProps> = ({
|
||||
bracket,
|
||||
bracketMaps,
|
||||
onAnnounce,
|
||||
}) => {
|
||||
|
||||
const getParentMatchOrder = useCallback((parentLid: number): number | string => {
|
||||
const parentMatch = bracketMaps.matchByLid.get(parentLid);
|
||||
if (
|
||||
parentMatch &&
|
||||
parentMatch.order !== null &&
|
||||
parentMatch.order !== undefined
|
||||
) {
|
||||
return parentMatch.order;
|
||||
}
|
||||
return `Match ${parentLid}`;
|
||||
}, [bracketMaps]);
|
||||
|
||||
return (
|
||||
<Flex direction="row" gap={24} justify="left" pos="relative" p="xl">
|
||||
{bracket.map((round, roundIndex) => (
|
||||
<BracketRound
|
||||
key={roundIndex}
|
||||
matches={round}
|
||||
roundIndex={roundIndex}
|
||||
getParentMatchOrder={getParentMatchOrder}
|
||||
onAnnounce={onAnnounce}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default BracketView;
|
||||
@@ -1,46 +0,0 @@
|
||||
import { ScrollArea, Text } from "@mantine/core";
|
||||
import BracketView from "./bracket-view";
|
||||
import useAppShellHeight from "@/hooks/use-appshell-height";
|
||||
import { BracketMaps } from "../utils/bracket-maps";
|
||||
import { Match } from "@/features/matches/types";
|
||||
|
||||
interface BracketProps {
|
||||
winners: Match[][];
|
||||
losers?: Match[][];
|
||||
bracketMaps: BracketMaps | null;
|
||||
}
|
||||
|
||||
const Bracket: React.FC<BracketProps> = ({ winners, losers, bracketMaps }) => {
|
||||
const height = useAppShellHeight();
|
||||
|
||||
if (!bracketMaps) return <p>Bracket not available.</p>;
|
||||
|
||||
return (
|
||||
<ScrollArea
|
||||
h={`calc(${height} - 4rem)`}
|
||||
className="bracket-container"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(circle, var(--mantine-color-default-border) 1px, transparent 1px)`,
|
||||
backgroundSize: "16px 16px",
|
||||
backgroundPosition: "0 0, 8px 8px",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Text fw={600} size="md" m={16}>
|
||||
Winners Bracket
|
||||
</Text>
|
||||
<BracketView bracket={winners} bracketMaps={bracketMaps} />
|
||||
</div>
|
||||
{losers && (
|
||||
<div>
|
||||
<Text fw={600} size="md" m={16}>
|
||||
Losers Bracket
|
||||
</Text>
|
||||
<BracketView bracket={losers} bracketMaps={bracketMaps} />
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
|
||||
export default Bracket;
|
||||
@@ -1,72 +0,0 @@
|
||||
import { ActionIcon, Card, Text } from '@mantine/core';
|
||||
import { PlayIcon } from '@phosphor-icons/react';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { MatchSlot } from './match-slot';
|
||||
import { Match } from '../types';
|
||||
|
||||
interface MatchCardProps {
|
||||
match: Match;
|
||||
getParentMatchOrder: (parentLid: number) => number | string;
|
||||
onAnnounce?: (teamOne: any, teamTwo: any) => void;
|
||||
}
|
||||
|
||||
export const MatchCard: React.FC<MatchCardProps> = ({
|
||||
match,
|
||||
getParentMatchOrder,
|
||||
onAnnounce
|
||||
}) => {
|
||||
|
||||
const showAnnounce = useMemo(() =>
|
||||
onAnnounce && match.home.team && match.away.team,
|
||||
[onAnnounce, match.home.team, match.away.team]);
|
||||
|
||||
const handleAnnounce = useCallback(() =>
|
||||
onAnnounce?.(match.home.team, match.away.team), [match.home.team, match.away.team]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
withBorder
|
||||
pos="relative"
|
||||
w={200}
|
||||
style={{ overflow: 'visible' }}
|
||||
data-match-lid={match.lid}
|
||||
>
|
||||
<Card.Section withBorder p={0}>
|
||||
<MatchSlot slot={match.home} getParentMatchOrder={getParentMatchOrder} />
|
||||
</Card.Section>
|
||||
|
||||
<Card.Section p={0} mb={-16}>
|
||||
<MatchSlot slot={match.away} getParentMatchOrder={getParentMatchOrder} />
|
||||
</Card.Section>
|
||||
|
||||
{match.reset && (
|
||||
<Text
|
||||
pos="absolute"
|
||||
top={-20}
|
||||
left={8}
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
fw="bold"
|
||||
>
|
||||
* If necessary
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{showAnnounce && (
|
||||
<ActionIcon
|
||||
pos="absolute"
|
||||
variant="filled"
|
||||
color="green"
|
||||
top={-20}
|
||||
right={-12}
|
||||
onClick={handleAnnounce}
|
||||
bd="none"
|
||||
style={{ boxShadow: 'none' }}
|
||||
size="xs"
|
||||
>
|
||||
<PlayIcon size={12} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,51 +0,0 @@
|
||||
import { Flex, Text } from "@mantine/core";
|
||||
import React from "react";
|
||||
import { SeedBadge } from "./seed-badge";
|
||||
|
||||
interface MatchSlotProps {
|
||||
slot: any;
|
||||
getParentMatchOrder: (parentLid: number) => number | string;
|
||||
}
|
||||
|
||||
export const MatchSlot: React.FC<MatchSlotProps> = ({
|
||||
slot,
|
||||
getParentMatchOrder,
|
||||
}) => {
|
||||
const renderSlotContent = () => {
|
||||
if (slot?.seed) {
|
||||
return slot.team ? (
|
||||
<Text size="xs">{slot.team.name}</Text>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed">
|
||||
Team {slot.seed}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (slot?.parent_lid !== null && slot?.parent_lid !== undefined) {
|
||||
return (
|
||||
<Text c="dimmed" size="xs">
|
||||
{slot.loser ? "Loser" : "Winner"} of Match{" "}
|
||||
{getParentMatchOrder(slot.parent_lid)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (slot) {
|
||||
return (
|
||||
<Text c="dimmed" size="xs" fs="italic">
|
||||
TBD
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex align="stretch">
|
||||
{slot?.seed && <SeedBadge seed={slot.seed} />}
|
||||
<div style={{ flex: 1, padding: "4px 8px" }}>{renderSlotContent()}</div>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -1,120 +0,0 @@
|
||||
import {
|
||||
Text,
|
||||
Container,
|
||||
Flex,
|
||||
NumberInput,
|
||||
Group,
|
||||
Loader,
|
||||
} from "@mantine/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import { bracketQueries, useBracketPreview } from "../queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createBracketMaps, BracketMaps } from "../utils/bracket-maps";
|
||||
import { BracketData, Match } from "../types";
|
||||
import Bracket from "./bracket";
|
||||
import "./styles.module.css";
|
||||
|
||||
interface PreviewTeam {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const PreviewBracket: React.FC = () => {
|
||||
const [teamCount, setTeamCount] = useState(20);
|
||||
const { data, isLoading, error } = useBracketPreview(teamCount);
|
||||
|
||||
const [teams, setTeams] = useState<PreviewTeam[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setTeams(
|
||||
Array.from({ length: teamCount }, (_, i) => ({
|
||||
id: `team-${i + 1}`,
|
||||
name: `Team ${i + 1}`,
|
||||
}))
|
||||
);
|
||||
}, [teamCount]);
|
||||
|
||||
const [seededWinnersBracket, setSeededWinnersBracket] = useState<Match[][]>(
|
||||
[]
|
||||
);
|
||||
const [seededLosersBracket, setSeededLosersBracket] = useState<Match[][]>([]);
|
||||
const [bracketMaps, setBracketMaps] = useState<BracketMaps | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || teams.length === 0) return;
|
||||
|
||||
const maps = createBracketMaps(data);
|
||||
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 teamIndex = match.home.seed - 1;
|
||||
if (teams[teamIndex]) {
|
||||
mappedMatch.home = {
|
||||
...match.home,
|
||||
team: teams[teamIndex],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (match.away?.seed && match.away.seed > 0) {
|
||||
const teamIndex = match.away.seed - 1;
|
||||
if (teams[teamIndex]) {
|
||||
mappedMatch.away = {
|
||||
...match.away,
|
||||
team: teams[teamIndex],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return mappedMatch;
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const bracketData = data as BracketData;
|
||||
setSeededWinnersBracket(mapBracket(bracketData.winners));
|
||||
setSeededLosersBracket(mapBracket(bracketData.losers));
|
||||
}, [teams, data]);
|
||||
|
||||
if (error) return <p>Error loading bracket</p>;
|
||||
|
||||
return (
|
||||
<Container p={0} w="100%" style={{ userSelect: "none" }}>
|
||||
<Flex w="100%" justify="space-between" align="center" h="3rem">
|
||||
<Group gap="sm" mx="auto">
|
||||
<Text size="sm" c="dimmed">
|
||||
Teams:
|
||||
</Text>
|
||||
<NumberInput
|
||||
value={teamCount}
|
||||
onChange={(value) => setTeamCount(Number(value) || 12)}
|
||||
min={12}
|
||||
max={20}
|
||||
size="sm"
|
||||
w={80}
|
||||
allowDecimal={false}
|
||||
clampBehavior="strict"
|
||||
/>
|
||||
</Group>
|
||||
</Flex>
|
||||
<Flex w="100%" gap={24}>
|
||||
{isLoading ? (
|
||||
<Flex justify="center" align="center" h="20vh" w="100%">
|
||||
<Loader size="xl" />
|
||||
</Flex>
|
||||
) : (
|
||||
<Bracket
|
||||
winners={seededWinnersBracket}
|
||||
losers={seededLosersBracket}
|
||||
bracketMaps={bracketMaps}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
@@ -1,29 +0,0 @@
|
||||
import { Text } from "@mantine/core";
|
||||
import React from "react";
|
||||
|
||||
interface SeedBadgeProps {
|
||||
seed: number;
|
||||
}
|
||||
|
||||
export const SeedBadge: React.FC<SeedBadgeProps> = ({ seed }) => {
|
||||
return (
|
||||
<Text
|
||||
size="xs"
|
||||
fw="bold"
|
||||
py="4"
|
||||
bg="var(--mantine-color-default-hover)"
|
||||
style={{
|
||||
width: "32px",
|
||||
textAlign: "center",
|
||||
color: "var(--mantine-color-text)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderTopLeftRadius: "var(--mantine-radius-default)",
|
||||
borderBottomLeftRadius: "var(--mantine-radius-default)",
|
||||
}}
|
||||
>
|
||||
{seed}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
@@ -1,48 +0,0 @@
|
||||
import { Flex, Text, Select, Card } from "@mantine/core";
|
||||
|
||||
interface Team {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface SeedListProps {
|
||||
teams: Team[];
|
||||
onSeedChange: (currentIndex: number, newIndex: number) => void;
|
||||
}
|
||||
|
||||
export function SeedList({ teams, onSeedChange }: SeedListProps) {
|
||||
const seedOptions = teams.map((_, index) => ({
|
||||
value: index.toString(),
|
||||
label: `Seed ${index + 1}`,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap={8}>
|
||||
{teams.map((team, index) => (
|
||||
<Card key={team.id} withBorder p="xs">
|
||||
<Flex align="center" gap="xs" justify="space-between">
|
||||
<Flex align="center" gap="xs">
|
||||
<Select
|
||||
value={index.toString()}
|
||||
data={seedOptions}
|
||||
onChange={(value) => {
|
||||
if (value !== null) {
|
||||
const newIndex = parseInt(value);
|
||||
if (newIndex !== index) {
|
||||
onSeedChange(index, newIndex);
|
||||
}
|
||||
}
|
||||
}}
|
||||
size="xs"
|
||||
w={100}
|
||||
/>
|
||||
<Text size="sm" fw={500}>
|
||||
{team.name}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Card>
|
||||
))}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
.bracket-container::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.bracket-container {
|
||||
box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { previewBracket } from "./server";
|
||||
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
|
||||
import { BracketData } from "./types";
|
||||
|
||||
const bracketKeys = {
|
||||
preview: (teams: number) => ["bracket", "preview", teams] as const,
|
||||
};
|
||||
|
||||
export const bracketQueries = {
|
||||
preview: (teams: number) => ({
|
||||
queryKey: bracketKeys.preview(teams),
|
||||
queryFn: () => previewBracket({ data: teams }),
|
||||
}),
|
||||
};
|
||||
|
||||
export const useBracketPreview = (teams: number) =>
|
||||
useServerSuspenseQuery<BracketData>(bracketQueries.preview(teams));
|
||||
@@ -1,21 +0,0 @@
|
||||
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { z } from "zod";
|
||||
import { Logger } from "@/lib/logger";
|
||||
import brackets from "./utils";
|
||||
import { BracketData } from "./types";
|
||||
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||
|
||||
const logger = new Logger("Bracket Generation");
|
||||
|
||||
export const previewBracket = createServerFn()
|
||||
.validator(z.number())
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data: teams }) =>
|
||||
toServerResult(async () => {
|
||||
logger.info("Generating bracket", teams);
|
||||
if (!Object.keys(brackets).includes(teams.toString()))
|
||||
throw Error("Bracket not available");
|
||||
return brackets[teams as keyof typeof brackets] as BracketData;
|
||||
})
|
||||
);
|
||||
@@ -1,20 +0,0 @@
|
||||
/*export interface Slot {
|
||||
seed?: number;
|
||||
team?: any;
|
||||
}
|
||||
|
||||
export interface Match {
|
||||
lid: number;
|
||||
round: number;
|
||||
order: number | null;
|
||||
type: string;
|
||||
home: Slot;
|
||||
away: Slot;
|
||||
reset?: boolean;
|
||||
}*/
|
||||
import { Match } from "../matches/types";
|
||||
|
||||
export interface BracketData {
|
||||
winners: Match[][];
|
||||
losers: Match[][];
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { BracketData, Match } from "../types";
|
||||
|
||||
export interface BracketMaps {
|
||||
matchByLid: Map<number, Match>;
|
||||
matchByOrder: Map<number, Match>;
|
||||
allMatches: Match[];
|
||||
}
|
||||
|
||||
export function createBracketMaps(bracketData: BracketData): BracketMaps {
|
||||
const matchByLid = new Map<number, Match>();
|
||||
const matchByOrder = new Map<number, Match>();
|
||||
const allMatches: Match[] = [];
|
||||
|
||||
[...bracketData.winners, ...bracketData.losers].forEach((round) => {
|
||||
round.forEach((match) => {
|
||||
matchByLid.set(match.lid, match);
|
||||
|
||||
if (match.order !== null && match.order !== undefined) {
|
||||
matchByOrder.set(match.order, match);
|
||||
}
|
||||
|
||||
allMatches.push(match);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
matchByLid,
|
||||
matchByOrder,
|
||||
allMatches,
|
||||
};
|
||||
}
|
||||
|
||||
export function getMatchByLid(
|
||||
maps: BracketMaps,
|
||||
lid: number
|
||||
): Match | undefined {
|
||||
return maps.matchByLid.get(lid);
|
||||
}
|
||||
|
||||
export function getMatchByOrder(
|
||||
maps: BracketMaps,
|
||||
order: number
|
||||
): Match | undefined {
|
||||
return maps.matchByOrder.get(order);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
/**
|
||||
* Imports saved json dumps of bracket generation from a python script that I didn't prioritize converting to TS
|
||||
*/
|
||||
import b12 from "../../../../scripts/brackets/12.json";
|
||||
import b13 from "../../../../scripts/brackets/13.json";
|
||||
import b14 from "../../../../scripts/brackets/14.json";
|
||||
import b15 from "../../../../scripts/brackets/15.json";
|
||||
import b16 from "../../../../scripts/brackets/16.json";
|
||||
import b17 from "../../../../scripts/brackets/17.json";
|
||||
import b18 from "../../../../scripts/brackets/18.json";
|
||||
import b19 from "../../../../scripts/brackets/19.json";
|
||||
import b20 from "../../../../scripts/brackets/20.json";
|
||||
|
||||
export default {
|
||||
12: b12,
|
||||
13: b13,
|
||||
14: b14,
|
||||
15: b15,
|
||||
16: b16,
|
||||
17: b17,
|
||||
18: b18,
|
||||
19: b19,
|
||||
20: b20,
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useBracketPreview } from "../queries";
|
||||
import BracketView from "./bracket-view";
|
||||
import { useBracketPreview } from "../../bracket/queries";
|
||||
import BracketView from "../../bracket/components/bracket-view";
|
||||
|
||||
interface BracketPreviewProps {
|
||||
n: number;
|
||||
136
src/features/matches/server.ts
Normal file
136
src/features/matches/server.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { superTokensAdminFunctionMiddleware } from "@/utils/supertokens";
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { z } from "zod";
|
||||
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||
import brackets from "@/features/bracket/utils";
|
||||
import { MatchInput } from "@/features/matches/types";
|
||||
|
||||
const orderedTeamsSchema = z.object({
|
||||
tournamentId: z.string(),
|
||||
orderedTeamIds: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const generateTournamentBracket = createServerFn()
|
||||
.validator(orderedTeamsSchema)
|
||||
.middleware([superTokensAdminFunctionMiddleware])
|
||||
.handler(async ({ data: { tournamentId, orderedTeamIds } }) =>
|
||||
toServerResult(async () => {
|
||||
logger.info('Generating tournament bracket', { tournamentId, teamCount: orderedTeamIds.length });
|
||||
|
||||
// Get tournament with teams
|
||||
const tournament = await pbAdmin.getTournament(tournamentId);
|
||||
if (!tournament) {
|
||||
throw new Error('Tournament not found');
|
||||
}
|
||||
|
||||
// Check if tournament already has matches
|
||||
if (tournament.matches && tournament.matches.length > 0) {
|
||||
throw new Error('Tournament already has matches generated');
|
||||
}
|
||||
|
||||
// Get bracket template based on team count
|
||||
const teamCount = orderedTeamIds.length;
|
||||
if (!Object.keys(brackets).includes(teamCount.toString())) {
|
||||
throw new Error(`Bracket not available for ${teamCount} teams`);
|
||||
}
|
||||
|
||||
const bracketTemplate = brackets[teamCount as keyof typeof brackets] as any;
|
||||
|
||||
// Create seed to team mapping (index + 1 = seed)
|
||||
const seedToTeamId = new Map<number, string>();
|
||||
orderedTeamIds.forEach((teamId, index) => {
|
||||
seedToTeamId.set(index + 1, teamId);
|
||||
});
|
||||
|
||||
// Convert bracket template to match records
|
||||
const matchInputs: MatchInput[] = [];
|
||||
|
||||
// Process winners bracket
|
||||
bracketTemplate.winners.forEach((round: any[]) => {
|
||||
round.forEach((match: any) => {
|
||||
const matchInput: MatchInput = {
|
||||
lid: match.lid,
|
||||
round: match.round,
|
||||
order: match.order || 0,
|
||||
reset: match.reset || false,
|
||||
bye: match.bye || false,
|
||||
home_cups: 0,
|
||||
away_cups: 0,
|
||||
ot_count: 0,
|
||||
home_from_lid: match.home_from_lid,
|
||||
away_from_lid: match.away_from_lid,
|
||||
home_from_loser: match.home_from_loser || false,
|
||||
away_from_loser: match.away_from_loser || false,
|
||||
is_losers_bracket: false,
|
||||
tournament: tournamentId,
|
||||
};
|
||||
|
||||
// Assign teams based on seeds
|
||||
if (match.home_seed) {
|
||||
const teamId = seedToTeamId.get(match.home_seed);
|
||||
if (teamId) {
|
||||
matchInput.home = teamId;
|
||||
matchInput.home_seed = match.home_seed;
|
||||
}
|
||||
}
|
||||
|
||||
if (match.away_seed) {
|
||||
const teamId = seedToTeamId.get(match.away_seed);
|
||||
if (teamId) {
|
||||
matchInput.away = teamId;
|
||||
matchInput.away_seed = match.away_seed;
|
||||
}
|
||||
}
|
||||
|
||||
matchInputs.push(matchInput);
|
||||
});
|
||||
});
|
||||
|
||||
// Process losers bracket
|
||||
bracketTemplate.losers.forEach((round: any[]) => {
|
||||
round.forEach((match: any) => {
|
||||
const matchInput: MatchInput = {
|
||||
lid: match.lid,
|
||||
round: match.round,
|
||||
order: match.order || 0,
|
||||
reset: match.reset || false,
|
||||
bye: match.bye || false,
|
||||
home_cups: 0,
|
||||
away_cups: 0,
|
||||
ot_count: 0,
|
||||
home_from_lid: match.home_from_lid,
|
||||
away_from_lid: match.away_from_lid,
|
||||
home_from_loser: match.home_from_loser || false,
|
||||
away_from_loser: match.away_from_loser || false,
|
||||
is_losers_bracket: true,
|
||||
tournament: tournamentId,
|
||||
};
|
||||
|
||||
// Losers bracket matches don't start with teams
|
||||
// Teams come from winners bracket losses
|
||||
|
||||
matchInputs.push(matchInput);
|
||||
});
|
||||
});
|
||||
|
||||
// Create all matches
|
||||
const createdMatches = await pbAdmin.createMatches(matchInputs);
|
||||
|
||||
// Update tournament to include all match IDs in the matches relation
|
||||
const matchIds = createdMatches.map(match => match.id);
|
||||
await pbAdmin.updateTournamentMatches(tournamentId, matchIds);
|
||||
|
||||
logger.info('Tournament bracket generated', {
|
||||
tournamentId,
|
||||
matchCount: createdMatches.length
|
||||
});
|
||||
|
||||
return {
|
||||
tournament,
|
||||
matchCount: createdMatches.length,
|
||||
matches: createdMatches,
|
||||
};
|
||||
})
|
||||
);
|
||||
@@ -1,4 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { TeamInfo } from "../teams/types";
|
||||
import { TournamentInfo } from "../tournaments/types";
|
||||
|
||||
/**
|
||||
* class TMatchSlot(BaseModel):
|
||||
@@ -50,9 +52,9 @@ export interface Match {
|
||||
home_from_loser: boolean;
|
||||
away_from_loser: boolean;
|
||||
is_losers_bracket: boolean;
|
||||
tournament: string;
|
||||
home: string;
|
||||
away: string;
|
||||
tournament: TournamentInfo;
|
||||
home?: TeamInfo;
|
||||
away?: TeamInfo;
|
||||
created: string;
|
||||
updated: string;
|
||||
home_seed?: number;
|
||||
@@ -78,7 +80,8 @@ export const matchInputSchema = z.object({
|
||||
tournament: z.string().min(1),
|
||||
home: z.string().min(1).optional(),
|
||||
away: z.string().min(1).optional(),
|
||||
seed: z.number().int().min(1).optional(),
|
||||
home_seed: z.number().int().min(1).optional(),
|
||||
away_seed: z.number().int().min(1).optional(),
|
||||
});
|
||||
|
||||
export type MatchInput = z.infer<typeof matchInputSchema>;
|
||||
|
||||
@@ -3,6 +3,7 @@ import Header from "./header";
|
||||
import { Player } from "@/features/players/types";
|
||||
import SwipeableTabs from "@/components/swipeable-tabs";
|
||||
import { usePlayer } from "../../queries";
|
||||
import TeamList from "@/features/teams/components/team-list";
|
||||
|
||||
interface ProfileProps {
|
||||
id: string;
|
||||
@@ -13,24 +14,26 @@ const Profile = ({ id }: ProfileProps) => {
|
||||
const tabs = [
|
||||
{
|
||||
label: "Overview",
|
||||
content: <Text p="md">Stats/Badges will go here</Text>
|
||||
content: <Text p="md">Stats/Badges will go here</Text>,
|
||||
},
|
||||
{
|
||||
label: "Matches",
|
||||
content: <Text p="md">Matches feed will go here</Text>
|
||||
content: <Text p="md">Matches feed will go here</Text>,
|
||||
},
|
||||
{
|
||||
label: "Teams",
|
||||
content: <Text p="md">Teams will go here</Text>
|
||||
}
|
||||
content: <TeamList teams={player.teams || []} />,
|
||||
},
|
||||
];
|
||||
|
||||
return <>
|
||||
<Header player={player} />
|
||||
<Box m='sm' mt='lg'>
|
||||
<SwipeableTabs tabs={tabs} />
|
||||
</Box>
|
||||
</>;
|
||||
return (
|
||||
<>
|
||||
<Header player={player} />
|
||||
<Box m="sm" mt="lg">
|
||||
<SwipeableTabs tabs={tabs} />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Profile;
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { Team } from "@/features/teams/types";
|
||||
import { TeamInfo } from "@/features/teams/types";
|
||||
import { z } from 'zod';
|
||||
|
||||
export interface PlayerInfo {
|
||||
id: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
}
|
||||
|
||||
export interface Player {
|
||||
id: string;
|
||||
auth_id?: string;
|
||||
@@ -8,7 +14,7 @@ export interface Player {
|
||||
last_name?: string;
|
||||
created?: string;
|
||||
updated?: string;
|
||||
teams?: Team[];
|
||||
teams?: TeamInfo[];
|
||||
}
|
||||
|
||||
export const playerInputSchema = z.object({
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { List, ListItem, Skeleton, Stack, Text } from "@mantine/core";
|
||||
import Avatar from "@/components/avatar";
|
||||
import { Team } from "@/features/teams/types";
|
||||
import { TeamInfo } from "@/features/teams/types";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import React from "react";
|
||||
|
||||
interface TeamListItemProps {
|
||||
team: Team;
|
||||
team: TeamInfo;
|
||||
}
|
||||
const TeamListItem = React.memo(({ team }: TeamListItemProps) => {
|
||||
const playerNames = useMemo(
|
||||
@@ -29,7 +29,7 @@ const TeamListItem = React.memo(({ team }: TeamListItemProps) => {
|
||||
});
|
||||
|
||||
interface TeamListProps {
|
||||
teams: Team[];
|
||||
teams: TeamInfo[];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Player } from "@/features/players/types";
|
||||
import { PlayerInfo } from "@/features/players/types";
|
||||
import { z } from "zod";
|
||||
import { Tournament } from "../tournaments/types";
|
||||
import { TournamentInfo } from "../tournaments/types";
|
||||
|
||||
export interface Team {
|
||||
id: string;
|
||||
@@ -18,8 +18,8 @@ export interface Team {
|
||||
song_image_url: string;
|
||||
created: string;
|
||||
updated: string;
|
||||
players: Player[];
|
||||
tournaments: Tournament[];
|
||||
players: PlayerInfo[];
|
||||
tournaments: TournamentInfo[];
|
||||
}
|
||||
|
||||
export interface TeamInfo {
|
||||
@@ -27,6 +27,7 @@ export interface TeamInfo {
|
||||
name: string;
|
||||
primary_color: string;
|
||||
accent_color: string;
|
||||
players: PlayerInfo[];
|
||||
}
|
||||
|
||||
export const teamInputSchema = z
|
||||
|
||||
@@ -12,14 +12,14 @@ import { useTournament, useUnenrolledTeams } from "../queries";
|
||||
import useEnrollTeam from "../hooks/use-enroll-team";
|
||||
import useUnenrollTeam from "../hooks/use-unenroll-team";
|
||||
import Avatar from "@/components/avatar";
|
||||
import { Team } from "@/features/teams/types";
|
||||
import { Team, TeamInfo } from "@/features/teams/types";
|
||||
|
||||
interface EditEnrolledTeamsProps {
|
||||
tournamentId: string;
|
||||
}
|
||||
|
||||
interface TeamItemProps {
|
||||
team: Team;
|
||||
team: TeamInfo;
|
||||
onUnenroll: (teamId: string) => void;
|
||||
disabled: boolean;
|
||||
}
|
||||
@@ -142,7 +142,7 @@ const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="xs" w="100%">
|
||||
{enrolledTeams.map((team: Team) => (
|
||||
{enrolledTeams.map((team: TeamInfo) => (
|
||||
<TeamItem
|
||||
key={team.id}
|
||||
team={team}
|
||||
|
||||
205
src/features/tournaments/components/seed-tournament.tsx
Normal file
205
src/features/tournaments/components/seed-tournament.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import {
|
||||
Stack,
|
||||
Text,
|
||||
Group,
|
||||
ActionIcon,
|
||||
Button,
|
||||
NumberInput,
|
||||
LoadingOverlay,
|
||||
} from "@mantine/core";
|
||||
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
|
||||
import { DotsNineIcon } from "@phosphor-icons/react";
|
||||
import { useServerMutation } from "@/lib/tanstack-query/hooks/use-server-mutation";
|
||||
import { generateTournamentBracket } from "../../matches/server";
|
||||
import { TeamInfo } from "@/features/teams/types";
|
||||
import Avatar from "@/components/avatar";
|
||||
import { useBracketPreview } from "@/features/bracket/queries";
|
||||
import { BracketData } from "@/features/bracket/types";
|
||||
import BracketView from "@/features/bracket/components/bracket-view";
|
||||
|
||||
interface SeedTournamentProps {
|
||||
tournamentId: string;
|
||||
teams: TeamInfo[];
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
const SeedTournament: React.FC<SeedTournamentProps> = ({
|
||||
tournamentId,
|
||||
teams,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const [orderedTeams, setOrderedTeams] = useState<TeamInfo[]>(teams);
|
||||
const { data: bracketPreview } = useBracketPreview(teams.length);
|
||||
|
||||
const bracket: BracketData = useMemo(
|
||||
() => ({
|
||||
winners: bracketPreview.winners.map((round) =>
|
||||
round.map((match) => ({
|
||||
...match,
|
||||
away:
|
||||
match.away_seed !== undefined
|
||||
? orderedTeams[match.away_seed - 1]
|
||||
: undefined,
|
||||
home:
|
||||
match.home_seed !== undefined
|
||||
? orderedTeams[match.home_seed - 1]
|
||||
: undefined,
|
||||
}))
|
||||
),
|
||||
losers: bracketPreview.losers
|
||||
}),
|
||||
[bracketPreview, orderedTeams]
|
||||
);
|
||||
|
||||
const generateBracket = useServerMutation({
|
||||
mutationFn: generateTournamentBracket,
|
||||
successMessage: "Tournament bracket generated successfully!",
|
||||
onSuccess: () => {
|
||||
onSuccess?.();
|
||||
},
|
||||
});
|
||||
|
||||
const handleDragEnd = (result: any) => {
|
||||
if (!result.destination) return;
|
||||
|
||||
const items = Array.from(orderedTeams);
|
||||
const [reorderedItem] = items.splice(result.source.index, 1);
|
||||
items.splice(result.destination.index, 0, reorderedItem);
|
||||
|
||||
setOrderedTeams(items);
|
||||
};
|
||||
|
||||
const handleSeedChange = (teamId: string, newSeed: number) => {
|
||||
if (newSeed < 1 || newSeed > orderedTeams.length) return;
|
||||
|
||||
const currentIndex = orderedTeams.findIndex((t) => t.id === teamId);
|
||||
if (currentIndex === -1) return;
|
||||
|
||||
const targetIndex = newSeed - 1;
|
||||
const items = Array.from(orderedTeams);
|
||||
const [movedTeam] = items.splice(currentIndex, 1);
|
||||
items.splice(targetIndex, 0, movedTeam);
|
||||
|
||||
setOrderedTeams(items);
|
||||
};
|
||||
|
||||
const handleGenerateBracket = () => {
|
||||
const orderedTeamIds = orderedTeams.map((team) => team.id);
|
||||
|
||||
generateBracket.mutate({
|
||||
data: {
|
||||
tournamentId,
|
||||
orderedTeamIds,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '2rem', alignItems: 'flex-start' }}>
|
||||
<Stack gap="lg" style={{ flexShrink: 0 }}>
|
||||
<Stack gap={0} pos="relative" w={400}>
|
||||
<LoadingOverlay visible={generateBracket.isPending} />
|
||||
|
||||
<Group gap="xs" p="md" pb="sm" align="center">
|
||||
<Text fw={600} size="lg">
|
||||
Team Seeding
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" ml="auto">
|
||||
{orderedTeams.length} teams
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
w={400}
|
||||
onClick={handleGenerateBracket}
|
||||
loading={generateBracket.isPending}
|
||||
disabled={orderedTeams.length === 0}
|
||||
>
|
||||
Confirm Seeding
|
||||
</Button>
|
||||
|
||||
<DragDropContext onDragEnd={handleDragEnd}>
|
||||
<Droppable droppableId="teams">
|
||||
{(provided) => (
|
||||
<div {...provided.droppableProps} ref={provided.innerRef}>
|
||||
{orderedTeams.map((team, index) => (
|
||||
<Draggable
|
||||
key={team.id}
|
||||
draggableId={team.id}
|
||||
index={index}
|
||||
>
|
||||
{(provided) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
style={{
|
||||
...provided.draggableProps.style,
|
||||
borderBottom:
|
||||
"1px solid var(--mantine-color-dimmed)",
|
||||
}}
|
||||
>
|
||||
<Group align="center" gap="sm" p="sm" px="md">
|
||||
<ActionIcon
|
||||
{...provided.dragHandleProps}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="xs"
|
||||
>
|
||||
<DotsNineIcon size={14} />
|
||||
</ActionIcon>
|
||||
|
||||
<NumberInput
|
||||
value={index + 1}
|
||||
onChange={(value) =>
|
||||
handleSeedChange(team.id, Number(value) || 1)
|
||||
}
|
||||
min={1}
|
||||
max={orderedTeams.length}
|
||||
size="xs"
|
||||
w={50}
|
||||
styles={{
|
||||
input: {
|
||||
textAlign: "center",
|
||||
fontWeight: 600,
|
||||
height: 28,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Avatar size={24} radius="sm" name={team.name} />
|
||||
|
||||
<Text fw={500} size="sm" style={{ flex: 1 }}>
|
||||
{team.name}
|
||||
</Text>
|
||||
</Group>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
</Stack>
|
||||
<Button
|
||||
size="sm"
|
||||
w={400}
|
||||
onClick={handleGenerateBracket}
|
||||
loading={generateBracket.isPending}
|
||||
disabled={orderedTeams.length === 0}
|
||||
>
|
||||
Confirm Seeding
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
<BracketView bracket={bracket} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SeedTournament;
|
||||
@@ -1,11 +1,11 @@
|
||||
import { List, ListItem, Skeleton, Text } from "@mantine/core";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import Avatar from "@/components/avatar";
|
||||
import { Tournament } from "../types";
|
||||
import { TournamentInfo } from "../types";
|
||||
import { useCallback } from "react";
|
||||
|
||||
interface TournamentListProps {
|
||||
tournaments: Tournament[];
|
||||
tournaments: TournamentInfo[];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { Team } from "@/features/teams/types";
|
||||
import { TeamInfo } from "@/features/teams/types";
|
||||
import { Match } from "@/features/matches/types";
|
||||
import { z } from "zod";
|
||||
|
||||
export interface TournamentInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
location?: string;
|
||||
start_time?: string;
|
||||
end_time?: string;
|
||||
logo?: string;
|
||||
}
|
||||
|
||||
export interface Tournament {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -13,7 +23,8 @@ export interface Tournament {
|
||||
end_time?: string;
|
||||
created: string;
|
||||
updated: string;
|
||||
teams?: Team[];
|
||||
teams?: TeamInfo[];
|
||||
matches?: Match[];
|
||||
}
|
||||
|
||||
export const tournamentInputSchema = z.object({
|
||||
|
||||
@@ -2,6 +2,7 @@ import PocketBase from "pocketbase";
|
||||
import { createPlayersService } from "./services/players";
|
||||
import { createTournamentsService } from "./services/tournaments";
|
||||
import { createTeamsService } from "./services/teams";
|
||||
import { createMatchesService } from "./services/matches";
|
||||
|
||||
class PocketBaseAdminClient {
|
||||
private pb: PocketBase;
|
||||
@@ -29,6 +30,7 @@ class PocketBaseAdminClient {
|
||||
Object.assign(this, createPlayersService(this.pb));
|
||||
Object.assign(this, createTeamsService(this.pb));
|
||||
Object.assign(this, createTournamentsService(this.pb));
|
||||
Object.assign(this, createMatchesService(this.pb));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -46,7 +48,8 @@ interface AdminClient
|
||||
extends PocketBaseAdminClient,
|
||||
ReturnType<typeof createPlayersService>,
|
||||
ReturnType<typeof createTeamsService>,
|
||||
ReturnType<typeof createTournamentsService> {
|
||||
ReturnType<typeof createTournamentsService>,
|
||||
ReturnType<typeof createMatchesService> {
|
||||
authPromise: Promise<void>;
|
||||
}
|
||||
|
||||
|
||||
4
src/lib/pocketbase/services/base.ts
Normal file
4
src/lib/pocketbase/services/base.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface DataFetchOptions {
|
||||
includeRelations?: boolean;
|
||||
expand?: string;
|
||||
}
|
||||
39
src/lib/pocketbase/services/matches.ts
Normal file
39
src/lib/pocketbase/services/matches.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { logger } from "@/lib/logger";
|
||||
import type { Match, MatchInput } from "@/features/matches/types";
|
||||
import type PocketBase from "pocketbase";
|
||||
|
||||
export function createMatchesService(pb: PocketBase) {
|
||||
return {
|
||||
async createMatch(data: MatchInput): Promise<Match> {
|
||||
logger.info("PocketBase | Creating match", data);
|
||||
const result = await pb.collection("matches").create<Match>(data);
|
||||
return result;
|
||||
},
|
||||
|
||||
async createMatches(matches: MatchInput[]): Promise<Match[]> {
|
||||
logger.info("PocketBase | Creating multiple matches", { count: matches.length });
|
||||
const results = await Promise.all(
|
||||
matches.map(match => pb.collection("matches").create<Match>(match))
|
||||
);
|
||||
return results;
|
||||
},
|
||||
|
||||
async updateMatch(id: string, data: Partial<MatchInput>): Promise<Match> {
|
||||
logger.info("PocketBase | Updating match", { id, data });
|
||||
const result = await pb.collection("matches").update<Match>(id, data);
|
||||
return result;
|
||||
},
|
||||
|
||||
async deleteMatchesByTournament(tournamentId: string): Promise<void> {
|
||||
logger.info("PocketBase | Deleting matches for tournament", tournamentId);
|
||||
const matches = await pb.collection("matches").getFullList({
|
||||
filter: `tournament = "${tournamentId}"`,
|
||||
fields: "id",
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
matches.map(match => pb.collection("matches").delete(match.id))
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,13 +1,28 @@
|
||||
import type {
|
||||
Player,
|
||||
PlayerInfo,
|
||||
PlayerInput,
|
||||
PlayerUpdateInput,
|
||||
} from "@/features/players/types";
|
||||
import { transformPlayer } from "@/lib/pocketbase/util/transform-types";
|
||||
import { transformPlayer, transformPlayerInfo } from "@/lib/pocketbase/util/transform-types";
|
||||
import PocketBase from "pocketbase";
|
||||
import { DataFetchOptions } from "./base";
|
||||
|
||||
export function createPlayersService(pb: PocketBase) {
|
||||
return {
|
||||
async getPlayerInfo(id: string): Promise<PlayerInfo> {
|
||||
const result = await pb.collection("players").getOne(id, {
|
||||
fields: "id,first_name,last_name"
|
||||
});
|
||||
return transformPlayerInfo(result);
|
||||
},
|
||||
|
||||
async listPlayerInfos(): Promise<PlayerInfo[]> {
|
||||
const result = await pb.collection("players").getFullList({
|
||||
fields: "id,first_name,last_name",
|
||||
});
|
||||
return result.map(transformPlayerInfo);
|
||||
},
|
||||
async getPlayerByAuthId(authId: string): Promise<Player | null> {
|
||||
const result = await pb.collection("players").getList<Player>(1, 1, {
|
||||
filter: `auth_id = "${authId}"`,
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
import { logger } from "@/lib/logger";
|
||||
import PocketBase from "pocketbase";
|
||||
import { transformTeam } from "@/lib/pocketbase/util/transform-types";
|
||||
import { Team } from "@/features/teams/types";
|
||||
import { transformTeam, transformTeamInfo } from "@/lib/pocketbase/util/transform-types";
|
||||
import { Team, TeamInfo } from "@/features/teams/types";
|
||||
import { DataFetchOptions } from "./base";
|
||||
|
||||
export function createTeamsService(pb: PocketBase) {
|
||||
return {
|
||||
async getTeamInfo(id: string): Promise<TeamInfo> {
|
||||
logger.info("PocketBase | Getting team info", id);
|
||||
const result = await pb.collection("teams").getOne(id, {
|
||||
fields: "id,name,primary_color,accent_color"
|
||||
});
|
||||
return transformTeamInfo(result);
|
||||
},
|
||||
|
||||
async listTeamInfos(): Promise<TeamInfo[]> {
|
||||
logger.info("PocketBase | Listing team infos");
|
||||
const result = await pb.collection("teams").getFullList({
|
||||
fields: "id,name,primary_color,accent_color"
|
||||
});
|
||||
return result.map(transformTeamInfo);
|
||||
},
|
||||
async getTeam(id: string): Promise<Team | null> {
|
||||
logger.info("PocketBase | Getting team", id);
|
||||
const result = await pb.collection("teams").getOne(id, {
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
import { logger } from "@/lib/logger";
|
||||
import type {
|
||||
Tournament,
|
||||
TournamentInfo,
|
||||
TournamentInput,
|
||||
TournamentUpdateInput,
|
||||
} from "@/features/tournaments/types";
|
||||
import type { Team } from "@/features/teams/types";
|
||||
import PocketBase from "pocketbase";
|
||||
import { transformTournament } from "@/lib/pocketbase/util/transform-types";
|
||||
import { transformTournament, transformTournamentInfo } from "@/lib/pocketbase/util/transform-types";
|
||||
import { transformTeam } from "@/lib/pocketbase/util/transform-types";
|
||||
|
||||
export function createTournamentsService(pb: PocketBase) {
|
||||
return {
|
||||
async getTournament(id: string): Promise<Tournament | null> {
|
||||
logger.info("PocketBase | Getting tournament", id);
|
||||
async getTournament(id: string): Promise<Tournament> {
|
||||
const result = await pb.collection("tournaments").getOne(id, {
|
||||
expand: "teams, teams.players",
|
||||
expand: "teams, teams.players, matches, matches.tournament",
|
||||
});
|
||||
return transformTournament(result);
|
||||
},
|
||||
async listTournaments(): Promise<Tournament[]> {
|
||||
async listTournaments(): Promise<TournamentInfo[]> {
|
||||
const result = await pb
|
||||
.collection("tournaments")
|
||||
.getFullList<Tournament>({
|
||||
fields: "id,name,start_time,end_time,logo,created",
|
||||
.getFullList({
|
||||
fields: "id,name,location,start_time,end_time,logo",
|
||||
sort: "-created",
|
||||
});
|
||||
|
||||
return result.map(transformTournament);
|
||||
return result.map(transformTournamentInfo);
|
||||
},
|
||||
async createTournament(data: TournamentInput): Promise<Tournament> {
|
||||
const result = await pb
|
||||
@@ -79,6 +79,18 @@ export function createTournamentsService(pb: PocketBase) {
|
||||
|
||||
return transformTournament(result);
|
||||
},
|
||||
async updateTournamentMatches(
|
||||
tournamentId: string,
|
||||
matchIds: string[]
|
||||
): Promise<Tournament> {
|
||||
logger.info("PocketBase | Updating tournament matches", { tournamentId, matchCount: matchIds.length });
|
||||
const result = await pb
|
||||
.collection("tournaments")
|
||||
.update<Tournament>(tournamentId, {
|
||||
matches: matchIds
|
||||
});
|
||||
return transformTournament(result);
|
||||
},
|
||||
async getUnenrolledTeams(tournamentId: string): Promise<Team[]> {
|
||||
try {
|
||||
logger.info(
|
||||
|
||||
@@ -1,18 +1,75 @@
|
||||
import { Player } from "@/features/players/types";
|
||||
import { Team } from "@/features/teams/types";
|
||||
import { Tournament } from "@/features/tournaments/types";
|
||||
import { Match } from "@/features/matches/types";
|
||||
import { Player, PlayerInfo } from "@/features/players/types";
|
||||
import { Team, TeamInfo } from "@/features/teams/types";
|
||||
import { Tournament, TournamentInfo } from "@/features/tournaments/types";
|
||||
|
||||
// pocketbase does this weird thing with relations where it puts them under a seperate "expand" field
|
||||
// this file transforms raw pocketbase results to our types
|
||||
|
||||
export function transformPlayerInfo(record: any): PlayerInfo {
|
||||
return {
|
||||
id: record.id,
|
||||
first_name: record.first_name,
|
||||
last_name: record.last_name,
|
||||
};
|
||||
}
|
||||
|
||||
export function transformTeamInfo(record: any): TeamInfo {
|
||||
const players = record.expand?.players?.map(transformPlayerInfo) ?? [];
|
||||
return {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
primary_color: record.primary_color,
|
||||
accent_color: record.accent_color,
|
||||
players,
|
||||
};
|
||||
}
|
||||
|
||||
export const transformMatch = (record: any): Match => {
|
||||
return {
|
||||
id: record.id,
|
||||
order: record.name,
|
||||
lid: record.lid,
|
||||
reset: record.reset,
|
||||
round: record.round,
|
||||
home_cups: record.home_cups,
|
||||
away_cups: record.away_cups,
|
||||
ot_count: record.ot_count,
|
||||
start_time: record.start_time,
|
||||
end_time: record.end_time,
|
||||
bye: record.bye,
|
||||
home_from_lid: record.home_from_lid,
|
||||
away_from_lid: record.away_from_lid,
|
||||
home_from_loser: record.home_from_loser,
|
||||
away_from_loser: record.away_from_loser,
|
||||
is_losers_bracket: record.is_losers_bracket,
|
||||
tournament: transformTournamentInfo(record.expand?.tournament),
|
||||
home: record.expand?.home ? transformTeamInfo(record.expand.home) : undefined,
|
||||
away: record.expand?.away ? transformTeamInfo(record.expand.away) : undefined,
|
||||
created: record.created,
|
||||
updated: record.updated,
|
||||
home_seed: record.home_seed,
|
||||
away_seed: record.away_seed,
|
||||
};
|
||||
}
|
||||
|
||||
export const transformTournamentInfo = (record: any): TournamentInfo => {
|
||||
return {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
location: record.location,
|
||||
start_time: record.start_time,
|
||||
logo: record.logo,
|
||||
};
|
||||
}
|
||||
|
||||
export function transformPlayer(record: any): Player {
|
||||
const sadf: string[] = [];
|
||||
const teams =
|
||||
record.expand?.teams
|
||||
?.sort((a: Team, b: Team) =>
|
||||
?.sort((a: any, b: any) =>
|
||||
new Date(a.created) < new Date(b.created) ? -1 : 0
|
||||
)
|
||||
?.map(transformTeam) ?? [];
|
||||
?.map(transformTeamInfo) ?? [];
|
||||
|
||||
return {
|
||||
id: record.id!,
|
||||
@@ -28,16 +85,16 @@ export function transformPlayer(record: any): Player {
|
||||
export function transformTeam(record: any): Team {
|
||||
const players =
|
||||
record.expand?.players
|
||||
?.sort((a: Player, b: Player) =>
|
||||
?.sort((a: any, b: any) =>
|
||||
new Date(a.created!) < new Date(b.created!) ? -1 : 0
|
||||
)
|
||||
?.map(transformPlayer) ?? [];
|
||||
?.map(transformPlayerInfo) ?? [];
|
||||
const tournaments =
|
||||
record.expand?.tournaments
|
||||
?.sort((a: Tournament, b: Tournament) =>
|
||||
?.sort((a: any, b: any) =>
|
||||
new Date(a.created!) < new Date(b.created!) ? -1 : 0
|
||||
)
|
||||
?.map(transformTournament) ?? [];
|
||||
?.map(transformTournamentInfo) ?? [];
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
@@ -63,10 +120,17 @@ export function transformTeam(record: any): Team {
|
||||
export function transformTournament(record: any): Tournament {
|
||||
const teams =
|
||||
record.expand?.teams
|
||||
?.sort((a: Team, b: Team) =>
|
||||
?.sort((a: any, b: any) =>
|
||||
new Date(a.created) < new Date(b.created) ? -1 : 0
|
||||
)
|
||||
?.map(transformTeam) ?? [];
|
||||
?.map(transformTeamInfo) ?? [];
|
||||
|
||||
const matches =
|
||||
record.expand?.matches
|
||||
?.sort((a: any, b: any) =>
|
||||
a.lid - b.lid ? -1 : 0
|
||||
)
|
||||
?.map(transformMatch) ?? [];
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
@@ -81,5 +145,6 @@ export function transformTournament(record: any): Tournament {
|
||||
created: record.created,
|
||||
updated: record.updated,
|
||||
teams,
|
||||
matches
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user