better brackets, info types

This commit is contained in:
yohlo
2025-09-07 00:52:28 -05:00
parent cb83ea06fa
commit 2396464a19
36 changed files with 678 additions and 657 deletions

View 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)
})

View 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)
})

View File

@@ -1,4 +1,4 @@
import BracketPreview from "@/features/bracket/components/preview"; import BracketPreview from "@/features/admin/components/preview";
import { NumberInput } from "@mantine/core"; import { NumberInput } from "@mantine/core";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { useState } from "react"; import { useState } from "react";

View File

@@ -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 { tournamentQueries } from '@/features/tournaments/queries'
import { ensureServerQueryData } from '@/lib/tanstack-query/utils/ensure' 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')({ export const Route = createFileRoute('/_authed/admin/tournaments/run/$id')({
beforeLoad: async ({ context, params }) => { beforeLoad: async ({ context, params }) => {
@@ -25,6 +28,30 @@ export const Route = createFileRoute('/_authed/admin/tournaments/run/$id')({
}) })
function RouteComponent() { function RouteComponent() {
const { id } = Route.useParams() const { tournament } = Route.useRouteContext()
return <p>Run tournament</p> 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>
)
} }

View File

@@ -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>
);
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
}

View File

@@ -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);
}
}

View File

@@ -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));

View File

@@ -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;
})
);

View File

@@ -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[][];
}

View File

@@ -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);
}

View File

@@ -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,
};

View File

@@ -1,5 +1,5 @@
import { useBracketPreview } from "../queries"; import { useBracketPreview } from "../../bracket/queries";
import BracketView from "./bracket-view"; import BracketView from "../../bracket/components/bracket-view";
interface BracketPreviewProps { interface BracketPreviewProps {
n: number; n: number;

View 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,
};
})
);

View File

@@ -1,4 +1,6 @@
import { z } from "zod"; import { z } from "zod";
import { TeamInfo } from "../teams/types";
import { TournamentInfo } from "../tournaments/types";
/** /**
* class TMatchSlot(BaseModel): * class TMatchSlot(BaseModel):
@@ -50,9 +52,9 @@ export interface Match {
home_from_loser: boolean; home_from_loser: boolean;
away_from_loser: boolean; away_from_loser: boolean;
is_losers_bracket: boolean; is_losers_bracket: boolean;
tournament: string; tournament: TournamentInfo;
home: string; home?: TeamInfo;
away: string; away?: TeamInfo;
created: string; created: string;
updated: string; updated: string;
home_seed?: number; home_seed?: number;
@@ -78,7 +80,8 @@ export const matchInputSchema = z.object({
tournament: z.string().min(1), tournament: z.string().min(1),
home: z.string().min(1).optional(), home: z.string().min(1).optional(),
away: 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>; export type MatchInput = z.infer<typeof matchInputSchema>;

View File

@@ -3,6 +3,7 @@ import Header from "./header";
import { Player } from "@/features/players/types"; import { Player } from "@/features/players/types";
import SwipeableTabs from "@/components/swipeable-tabs"; import SwipeableTabs from "@/components/swipeable-tabs";
import { usePlayer } from "../../queries"; import { usePlayer } from "../../queries";
import TeamList from "@/features/teams/components/team-list";
interface ProfileProps { interface ProfileProps {
id: string; id: string;
@@ -13,24 +14,26 @@ const Profile = ({ id }: ProfileProps) => {
const tabs = [ const tabs = [
{ {
label: "Overview", label: "Overview",
content: <Text p="md">Stats/Badges will go here</Text> content: <Text p="md">Stats/Badges will go here</Text>,
}, },
{ {
label: "Matches", label: "Matches",
content: <Text p="md">Matches feed will go here</Text> content: <Text p="md">Matches feed will go here</Text>,
}, },
{ {
label: "Teams", label: "Teams",
content: <Text p="md">Teams will go here</Text> content: <TeamList teams={player.teams || []} />,
} },
]; ];
return <> return (
<Header player={player} /> <>
<Box m='sm' mt='lg'> <Header player={player} />
<SwipeableTabs tabs={tabs} /> <Box m="sm" mt="lg">
</Box> <SwipeableTabs tabs={tabs} />
</>; </Box>
</>
);
}; };
export default Profile; export default Profile;

View File

@@ -1,6 +1,12 @@
import { Team } from "@/features/teams/types"; import { TeamInfo } from "@/features/teams/types";
import { z } from 'zod'; import { z } from 'zod';
export interface PlayerInfo {
id: string;
first_name?: string;
last_name?: string;
}
export interface Player { export interface Player {
id: string; id: string;
auth_id?: string; auth_id?: string;
@@ -8,7 +14,7 @@ export interface Player {
last_name?: string; last_name?: string;
created?: string; created?: string;
updated?: string; updated?: string;
teams?: Team[]; teams?: TeamInfo[];
} }
export const playerInputSchema = z.object({ export const playerInputSchema = z.object({

View File

@@ -1,12 +1,12 @@
import { List, ListItem, Skeleton, Stack, Text } from "@mantine/core"; import { List, ListItem, Skeleton, Stack, Text } from "@mantine/core";
import Avatar from "@/components/avatar"; import Avatar from "@/components/avatar";
import { Team } from "@/features/teams/types"; import { TeamInfo } from "@/features/teams/types";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import React from "react"; import React from "react";
interface TeamListItemProps { interface TeamListItemProps {
team: Team; team: TeamInfo;
} }
const TeamListItem = React.memo(({ team }: TeamListItemProps) => { const TeamListItem = React.memo(({ team }: TeamListItemProps) => {
const playerNames = useMemo( const playerNames = useMemo(
@@ -29,7 +29,7 @@ const TeamListItem = React.memo(({ team }: TeamListItemProps) => {
}); });
interface TeamListProps { interface TeamListProps {
teams: Team[]; teams: TeamInfo[];
loading?: boolean; loading?: boolean;
} }

View File

@@ -1,6 +1,6 @@
import { Player } from "@/features/players/types"; import { PlayerInfo } from "@/features/players/types";
import { z } from "zod"; import { z } from "zod";
import { Tournament } from "../tournaments/types"; import { TournamentInfo } from "../tournaments/types";
export interface Team { export interface Team {
id: string; id: string;
@@ -18,8 +18,8 @@ export interface Team {
song_image_url: string; song_image_url: string;
created: string; created: string;
updated: string; updated: string;
players: Player[]; players: PlayerInfo[];
tournaments: Tournament[]; tournaments: TournamentInfo[];
} }
export interface TeamInfo { export interface TeamInfo {
@@ -27,6 +27,7 @@ export interface TeamInfo {
name: string; name: string;
primary_color: string; primary_color: string;
accent_color: string; accent_color: string;
players: PlayerInfo[];
} }
export const teamInputSchema = z export const teamInputSchema = z

View File

@@ -12,14 +12,14 @@ import { useTournament, useUnenrolledTeams } from "../queries";
import useEnrollTeam from "../hooks/use-enroll-team"; import useEnrollTeam from "../hooks/use-enroll-team";
import useUnenrollTeam from "../hooks/use-unenroll-team"; import useUnenrollTeam from "../hooks/use-unenroll-team";
import Avatar from "@/components/avatar"; import Avatar from "@/components/avatar";
import { Team } from "@/features/teams/types"; import { Team, TeamInfo } from "@/features/teams/types";
interface EditEnrolledTeamsProps { interface EditEnrolledTeamsProps {
tournamentId: string; tournamentId: string;
} }
interface TeamItemProps { interface TeamItemProps {
team: Team; team: TeamInfo;
onUnenroll: (teamId: string) => void; onUnenroll: (teamId: string) => void;
disabled: boolean; disabled: boolean;
} }
@@ -142,7 +142,7 @@ const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
</Text> </Text>
) : ( ) : (
<Stack gap="xs" w="100%"> <Stack gap="xs" w="100%">
{enrolledTeams.map((team: Team) => ( {enrolledTeams.map((team: TeamInfo) => (
<TeamItem <TeamItem
key={team.id} key={team.id}
team={team} team={team}

View 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;

View File

@@ -1,11 +1,11 @@
import { List, ListItem, Skeleton, Text } from "@mantine/core"; import { List, ListItem, Skeleton, Text } from "@mantine/core";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import Avatar from "@/components/avatar"; import Avatar from "@/components/avatar";
import { Tournament } from "../types"; import { TournamentInfo } from "../types";
import { useCallback } from "react"; import { useCallback } from "react";
interface TournamentListProps { interface TournamentListProps {
tournaments: Tournament[]; tournaments: TournamentInfo[];
loading?: boolean; loading?: boolean;
} }

View File

@@ -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"; import { z } from "zod";
export interface TournamentInfo {
id: string;
name: string;
location?: string;
start_time?: string;
end_time?: string;
logo?: string;
}
export interface Tournament { export interface Tournament {
id: string; id: string;
name: string; name: string;
@@ -13,7 +23,8 @@ export interface Tournament {
end_time?: string; end_time?: string;
created: string; created: string;
updated: string; updated: string;
teams?: Team[]; teams?: TeamInfo[];
matches?: Match[];
} }
export const tournamentInputSchema = z.object({ export const tournamentInputSchema = z.object({

View File

@@ -2,6 +2,7 @@ import PocketBase from "pocketbase";
import { createPlayersService } from "./services/players"; import { createPlayersService } from "./services/players";
import { createTournamentsService } from "./services/tournaments"; import { createTournamentsService } from "./services/tournaments";
import { createTeamsService } from "./services/teams"; import { createTeamsService } from "./services/teams";
import { createMatchesService } from "./services/matches";
class PocketBaseAdminClient { class PocketBaseAdminClient {
private pb: PocketBase; private pb: PocketBase;
@@ -29,6 +30,7 @@ class PocketBaseAdminClient {
Object.assign(this, createPlayersService(this.pb)); Object.assign(this, createPlayersService(this.pb));
Object.assign(this, createTeamsService(this.pb)); Object.assign(this, createTeamsService(this.pb));
Object.assign(this, createTournamentsService(this.pb)); Object.assign(this, createTournamentsService(this.pb));
Object.assign(this, createMatchesService(this.pb));
}); });
} }
@@ -46,7 +48,8 @@ interface AdminClient
extends PocketBaseAdminClient, extends PocketBaseAdminClient,
ReturnType<typeof createPlayersService>, ReturnType<typeof createPlayersService>,
ReturnType<typeof createTeamsService>, ReturnType<typeof createTeamsService>,
ReturnType<typeof createTournamentsService> { ReturnType<typeof createTournamentsService>,
ReturnType<typeof createMatchesService> {
authPromise: Promise<void>; authPromise: Promise<void>;
} }

View File

@@ -0,0 +1,4 @@
export interface DataFetchOptions {
includeRelations?: boolean;
expand?: string;
}

View 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))
);
},
};
}

View File

@@ -1,13 +1,28 @@
import type { import type {
Player, Player,
PlayerInfo,
PlayerInput, PlayerInput,
PlayerUpdateInput, PlayerUpdateInput,
} from "@/features/players/types"; } 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 PocketBase from "pocketbase";
import { DataFetchOptions } from "./base";
export function createPlayersService(pb: PocketBase) { export function createPlayersService(pb: PocketBase) {
return { 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> { async getPlayerByAuthId(authId: string): Promise<Player | null> {
const result = await pb.collection("players").getList<Player>(1, 1, { const result = await pb.collection("players").getList<Player>(1, 1, {
filter: `auth_id = "${authId}"`, filter: `auth_id = "${authId}"`,

View File

@@ -1,10 +1,26 @@
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import PocketBase from "pocketbase"; import PocketBase from "pocketbase";
import { transformTeam } from "@/lib/pocketbase/util/transform-types"; import { transformTeam, transformTeamInfo } from "@/lib/pocketbase/util/transform-types";
import { Team } from "@/features/teams/types"; import { Team, TeamInfo } from "@/features/teams/types";
import { DataFetchOptions } from "./base";
export function createTeamsService(pb: PocketBase) { export function createTeamsService(pb: PocketBase) {
return { 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> { async getTeam(id: string): Promise<Team | null> {
logger.info("PocketBase | Getting team", id); logger.info("PocketBase | Getting team", id);
const result = await pb.collection("teams").getOne(id, { const result = await pb.collection("teams").getOne(id, {

View File

@@ -1,32 +1,32 @@
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import type { import type {
Tournament, Tournament,
TournamentInfo,
TournamentInput, TournamentInput,
TournamentUpdateInput, TournamentUpdateInput,
} from "@/features/tournaments/types"; } from "@/features/tournaments/types";
import type { Team } from "@/features/teams/types"; import type { Team } from "@/features/teams/types";
import PocketBase from "pocketbase"; 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"; import { transformTeam } from "@/lib/pocketbase/util/transform-types";
export function createTournamentsService(pb: PocketBase) { export function createTournamentsService(pb: PocketBase) {
return { return {
async getTournament(id: string): Promise<Tournament | null> { async getTournament(id: string): Promise<Tournament> {
logger.info("PocketBase | Getting tournament", id);
const result = await pb.collection("tournaments").getOne(id, { const result = await pb.collection("tournaments").getOne(id, {
expand: "teams, teams.players", expand: "teams, teams.players, matches, matches.tournament",
}); });
return transformTournament(result); return transformTournament(result);
}, },
async listTournaments(): Promise<Tournament[]> { async listTournaments(): Promise<TournamentInfo[]> {
const result = await pb const result = await pb
.collection("tournaments") .collection("tournaments")
.getFullList<Tournament>({ .getFullList({
fields: "id,name,start_time,end_time,logo,created", fields: "id,name,location,start_time,end_time,logo",
sort: "-created", sort: "-created",
}); });
return result.map(transformTournament); return result.map(transformTournamentInfo);
}, },
async createTournament(data: TournamentInput): Promise<Tournament> { async createTournament(data: TournamentInput): Promise<Tournament> {
const result = await pb const result = await pb
@@ -79,6 +79,18 @@ export function createTournamentsService(pb: PocketBase) {
return transformTournament(result); 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[]> { async getUnenrolledTeams(tournamentId: string): Promise<Team[]> {
try { try {
logger.info( logger.info(

View File

@@ -1,18 +1,75 @@
import { Player } from "@/features/players/types"; import { Match } from "@/features/matches/types";
import { Team } from "@/features/teams/types"; import { Player, PlayerInfo } from "@/features/players/types";
import { Tournament } from "@/features/tournaments/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 // 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 // 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 { export function transformPlayer(record: any): Player {
const sadf: string[] = [];
const teams = const teams =
record.expand?.teams record.expand?.teams
?.sort((a: Team, b: Team) => ?.sort((a: any, b: any) =>
new Date(a.created) < new Date(b.created) ? -1 : 0 new Date(a.created) < new Date(b.created) ? -1 : 0
) )
?.map(transformTeam) ?? []; ?.map(transformTeamInfo) ?? [];
return { return {
id: record.id!, id: record.id!,
@@ -28,16 +85,16 @@ export function transformPlayer(record: any): Player {
export function transformTeam(record: any): Team { export function transformTeam(record: any): Team {
const players = const players =
record.expand?.players record.expand?.players
?.sort((a: Player, b: Player) => ?.sort((a: any, b: any) =>
new Date(a.created!) < new Date(b.created!) ? -1 : 0 new Date(a.created!) < new Date(b.created!) ? -1 : 0
) )
?.map(transformPlayer) ?? []; ?.map(transformPlayerInfo) ?? [];
const tournaments = const tournaments =
record.expand?.tournaments record.expand?.tournaments
?.sort((a: Tournament, b: Tournament) => ?.sort((a: any, b: any) =>
new Date(a.created!) < new Date(b.created!) ? -1 : 0 new Date(a.created!) < new Date(b.created!) ? -1 : 0
) )
?.map(transformTournament) ?? []; ?.map(transformTournamentInfo) ?? [];
return { return {
id: record.id, id: record.id,
@@ -63,11 +120,18 @@ export function transformTeam(record: any): Team {
export function transformTournament(record: any): Tournament { export function transformTournament(record: any): Tournament {
const teams = const teams =
record.expand?.teams record.expand?.teams
?.sort((a: Team, b: Team) => ?.sort((a: any, b: any) =>
new Date(a.created) < new Date(b.created) ? -1 : 0 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 { return {
id: record.id, id: record.id,
name: record.name, name: record.name,
@@ -81,5 +145,6 @@ export function transformTournament(record: any): Tournament {
created: record.created, created: record.created,
updated: record.updated, updated: record.updated,
teams, teams,
matches
}; };
} }