From 3ffa6b03c77014379cbb4d657321f56990f51383 Mon Sep 17 00:00:00 2001 From: yohlo Date: Thu, 11 Sep 2025 15:59:27 -0500 Subject: [PATCH] i think working bracket runner --- .../_authed/admin/tournaments/run.$id.tsx | 126 ++++++++------ .../bracket/components/bracket-view.tsx | 8 +- src/features/bracket/components/bracket.tsx | 6 +- .../bracket/components/match-card.tsx | 163 +++++++++++++----- .../bracket/components/match-form.tsx | 160 +++++++++++++++++ .../bracket/components/match-slot.tsx | 9 +- src/features/matches/server.ts | 34 ++++ src/features/matches/types.ts | 33 ---- src/lib/pocketbase/services/matches.ts | 22 +++ src/lib/pocketbase/services/tournaments.ts | 2 +- 10 files changed, 424 insertions(+), 139 deletions(-) create mode 100644 src/features/bracket/components/match-form.tsx diff --git a/src/app/routes/_authed/admin/tournaments/run.$id.tsx b/src/app/routes/_authed/admin/tournaments/run.$id.tsx index d00930c..e6fdfa3 100644 --- a/src/app/routes/_authed/admin/tournaments/run.$id.tsx +++ b/src/app/routes/_authed/admin/tournaments/run.$id.tsx @@ -1,25 +1,31 @@ -import { createFileRoute, redirect, useRouter } from '@tanstack/react-router' -import { tournamentQueries, useTournament } 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' -import { useMemo } from 'react' -import { BracketData } from '@/features/bracket/types' -import { Match } from '@/features/matches/types' -import BracketView from '@/features/bracket/components/bracket-view' +import { createFileRoute, redirect, useRouter } from "@tanstack/react-router"; +import { + tournamentKeys, + tournamentQueries, + useTournament, +} from "@/features/tournaments/queries"; +import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; +import SeedTournament from "@/features/tournaments/components/seed-tournament"; +import { Container } from "@mantine/core"; +import { useMemo } from "react"; +import { BracketData } from "@/features/bracket/types"; +import { Match } from "@/features/matches/types"; +import BracketView from "@/features/bracket/components/bracket-view"; +import { startMatch } from "@/features/matches/server"; +import { useServerMutation } from "@/lib/tanstack-query/hooks"; +import { useQueryClient } from "@tanstack/react-query"; -export const Route = createFileRoute('/_authed/admin/tournaments/run/$id')({ +export const Route = createFileRoute("/_authed/admin/tournaments/run/$id")({ beforeLoad: async ({ context, params }) => { - const { queryClient } = context + const { queryClient } = context; const tournament = await ensureServerQueryData( queryClient, tournamentQueries.details(params.id) - ) - if (!tournament) throw redirect({ to: '/admin/tournaments' }) + ); + if (!tournament) throw redirect({ to: "/admin/tournaments" }); return { tournament, - } + }; }, loader: ({ context }) => ({ fullWidth: true, @@ -29,61 +35,75 @@ export const Route = createFileRoute('/_authed/admin/tournaments/run/$id')({ }, }), component: RouteComponent, -}) +}); function RouteComponent() { const { id } = Route.useParams(); - const { data: tournament } = useTournament(id) + const { data: tournament } = useTournament(id); + const queryClient = useQueryClient(); + + const start = useServerMutation({ + mutationFn: startMatch, + successMessage: "Match started!", + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: tournamentKeys.details(tournament.id), + }); + }, + }); const bracket: BracketData = useMemo(() => { if (!tournament.matches || tournament.matches.length === 0) { - return { winners: [], losers: [] } + return { winners: [], losers: [] }; } - - const winnersMap = new Map() - const losersMap = new Map() - - tournament.matches.sort((a, b) => a.lid - b.lid).forEach((match) => { - if (!match.is_losers_bracket) { - if (!winnersMap.has(match.round)) { - winnersMap.set(match.round, []) + + const winnersMap = new Map(); + const losersMap = new Map(); + + tournament.matches + .sort((a, b) => a.lid - b.lid) + .forEach((match) => { + if (!match.is_losers_bracket) { + if (!winnersMap.has(match.round)) { + winnersMap.set(match.round, []); + } + winnersMap.get(match.round)!.push(match); + } else { + if (!losersMap.has(match.round)) { + losersMap.set(match.round, []); + } + losersMap.get(match.round)!.push(match); } - winnersMap.get(match.round)!.push(match) - } else { - if (!losersMap.has(match.round)) { - losersMap.set(match.round, []) - } - losersMap.get(match.round)!.push(match) - } - }) + }); const winners = Array.from(winnersMap.entries()) .sort(([a], [b]) => a - b) - .map(([, matches]) => matches) - + .map(([, matches]) => matches); + const losers = Array.from(losersMap.entries()) .sort(([a], [b]) => a - b) - .map(([, matches]) => matches) - return { winners, losers } - }, [tournament.matches]) + .map(([, matches]) => matches); + return { winners, losers }; + }, [tournament.matches]); - const handleStartMatch = (match: Match) => { + const handleStartMatch = async (match: Match) => { + await start.mutate({ + data: match.id + }) + }; - } - - console.log(tournament.matches) + console.log(tournament.matches); return ( - { - tournament.matches?.length ? - - : ( - ) - } + {tournament.matches?.length ? ( + + ) : ( + + )} - ) + ); } diff --git a/src/features/bracket/components/bracket-view.tsx b/src/features/bracket/components/bracket-view.tsx index 468511d..309a531 100644 --- a/src/features/bracket/components/bracket-view.tsx +++ b/src/features/bracket/components/bracket-view.tsx @@ -7,10 +7,10 @@ import { Match } from "@/features/matches/types"; interface BracketViewProps { bracket: BracketData; - onStartMatch?: (match: Match) => void; + showControls?: boolean } -const BracketView: React.FC = ({ bracket, onStartMatch }) => { +const BracketView: React.FC = ({ bracket, showControls }) => { const height = useAppShellHeight(); const orders = useMemo(() => { const map: Record = {}; @@ -32,14 +32,14 @@ const BracketView: React.FC = ({ bracket, onStartMatch }) => { Winners Bracket - + {bracket.losers && (
Losers Bracket - +
)} diff --git a/src/features/bracket/components/bracket.tsx b/src/features/bracket/components/bracket.tsx index bc9784c..c9b8a9a 100644 --- a/src/features/bracket/components/bracket.tsx +++ b/src/features/bracket/components/bracket.tsx @@ -5,13 +5,13 @@ import { MatchCard } from "./match-card"; interface BracketProps { rounds: Match[][]; orders: Record; - onStartMatch?: (match: Match) => void; + showControls?: boolean; } export const Bracket: React.FC = ({ rounds, orders, - onStartMatch, + showControls, }) => { return ( @@ -32,7 +32,7 @@ export const Bracket: React.FC = ({ ); diff --git a/src/features/bracket/components/match-card.tsx b/src/features/bracket/components/match-card.tsx index 403eef4..f671203 100644 --- a/src/features/bracket/components/match-card.tsx +++ b/src/features/bracket/components/match-card.tsx @@ -1,26 +1,36 @@ -import { ActionIcon, Card, Flex, Text, Stack } from "@mantine/core"; +import { ActionIcon, Card, Flex, Text, Stack, Indicator } from "@mantine/core"; import { PlayIcon, PencilIcon } from "@phosphor-icons/react"; import React, { useCallback, useMemo } from "react"; import { MatchSlot } from "./match-slot"; import { Match } from "@/features/matches/types"; +import { useSheet } from "@/hooks/use-sheet"; +import { MatchForm } from "./match-form"; +import Sheet from "@/components/sheet/sheet"; +import { useServerMutation } from "@/lib/tanstack-query/hooks"; +import { endMatch, startMatch } from "@/features/matches/server"; +import { tournamentKeys } from "@/features/tournaments/queries"; +import { useQueryClient } from "@tanstack/react-query"; interface MatchCardProps { match: Match; orders: Record; - onStartMatch?: (match: Match) => void; + showControls?: boolean; } export const MatchCard: React.FC = ({ match, orders, - onStartMatch, + showControls, }) => { + const queryClient = useQueryClient(); + const editSheet = useSheet(); const homeSlot = useMemo( () => ({ from: orders[match.home_from_lid], from_loser: match.home_from_loser, team: match.home, seed: match.home_seed, + cups: match.status === "ended" ? match.home_cups : undefined }), [match] ); @@ -30,72 +40,110 @@ export const MatchCard: React.FC = ({ from_loser: match.away_from_loser, team: match.away, seed: match.away_seed, + cups: match.status === "ended" ? match.away_cups : undefined }), [match] ); const showToolbar = useMemo( - () => match.status === "ready" && onStartMatch, - [match.status, onStartMatch] + () => match.status === "ready" && showControls, + [match.status, showControls] ); - const handleAnnounce = useCallback( - () => onStartMatch?.(match), - [onStartMatch, match] + const showEditButton = useMemo( + () => showControls && match.status === "started", + [showControls, match.status] ); - const handleEdit = useCallback(() => { - // TODO: implement edit functionality - console.log('Edit match:', match); + const start = useServerMutation({ + mutationFn: startMatch, + successMessage: "Match started!", + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: tournamentKeys.details(match.tournament.id), + }); + }, + }); + + const end = useServerMutation({ + mutationFn: endMatch, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: tournamentKeys.details(match.tournament.id), + }); + }, + }); + + const handleStart = useCallback(async () => { + await start.mutate({ + data: match.id, + }); }, [match]); + const handleFormSubmit = useCallback( + async (data: { home_cups: number; away_cups: number; ot_count: number }) => { + await end.mutate({ data: { + ...data, + matchId: match.id + }}); + editSheet.close(); + }, + [match.id, editSheet] + ); + + console.log(homeSlot, awaySlot) + return ( {match.order} - - - - + + + + - - - + + + - {match.reset && ( - - * If necessary - - )} - + {match.reset && ( + + * If necessary + + )} + + {showToolbar && ( - + = ({ )} + + {showEditButton && ( + + + + + + )} + + + + ); }; diff --git a/src/features/bracket/components/match-form.tsx b/src/features/bracket/components/match-form.tsx new file mode 100644 index 0000000..acb82bd --- /dev/null +++ b/src/features/bracket/components/match-form.tsx @@ -0,0 +1,160 @@ +import { Button, TextInput, Stack, Group, Text, Flex, Divider } from "@mantine/core"; +import { useForm } from "@mantine/form"; +import { Match } from "@/features/matches/types"; + +interface MatchFormProps { + match: Match; + onSubmit: (data: { + home_cups: number; + away_cups: number; + ot_count: number; + }) => void; + onCancel: () => void; +} + +export const MatchForm: React.FC = ({ + match, + onSubmit, + onCancel, +}) => { + const form = useForm({ + initialValues: { + home_cups: match.home_cups || 10, + away_cups: match.away_cups || 10, + ot_count: match.ot_count || 0, + }, + validate: { + home_cups: (value, values) => { + if (value === null || value === undefined) return "Home cups is required"; + if (values.ot_count > 0) return null; + + const homeCups = Number(value); + const awayCups = Number(values.away_cups); + + if (homeCups !== 10 && awayCups !== 10) { + return "At least one team must have 10 cups"; + } + + // Both teams can't have 10 cups + if (homeCups === 10 && awayCups === 10) { + return "Both teams cannot have 10 cups"; + } + + return null; + }, + away_cups: (value, values) => { + if (value === null || value === undefined) return "Away cups is required"; + if (values.ot_count > 0) return null; + + const awayCups = Number(value); + const homeCups = Number(values.home_cups); + + if (homeCups !== 10 && awayCups !== 10) { + return "At least one team must have 10 cups"; + } + + if (homeCups === 10 && awayCups === 10) { + return "Both teams cannot have 10 cups"; + } + + return null; + }, + ot_count: (value) => + value === null || value === undefined + ? "Overtime count is required" + : null, + }, + transformValues: (values) => ({ + home_cups: Number(values.home_cups), + away_cups: Number(values.away_cups), + ot_count: Number(values.ot_count), + }), + }); + + const handleSubmit = form.onSubmit(() => { + const transformedValues = form.getTransformedValues(); + onSubmit(transformedValues); + }); + + console.log(match) + + return ( +
+ + + + + + + {match.home?.name} Cups + + { + match.home?.players.map(p => ( + {p.first_name} {p.last_name} + )) + } + + + + + + + + + + {match.away?.name} Cups + + { + match.away?.players.map(p => ( + {p.first_name} {p.last_name} + )) + } + + + + + + + + + OT Count + + + + + + + + + + + +
+ ); +}; diff --git a/src/features/bracket/components/match-slot.tsx b/src/features/bracket/components/match-slot.tsx index a3ac722..20a340a 100644 --- a/src/features/bracket/components/match-slot.tsx +++ b/src/features/bracket/components/match-slot.tsx @@ -8,6 +8,7 @@ interface MatchSlotProps { from_loser?: boolean; team?: TeamInfo; seed?: number; + cups?: number; } export const MatchSlot: React.FC = ({ @@ -15,10 +16,11 @@ export const MatchSlot: React.FC = ({ from_loser, team, seed, + cups }) => ( {(seed && seed > 0) ? : undefined} - + {team ? ( {team.name} ) : from ? ( @@ -30,6 +32,11 @@ export const MatchSlot: React.FC = ({ TBD )} + { + cups !== undefined ? ( + {cups} + ) : undefined + } ); diff --git a/src/features/matches/server.ts b/src/features/matches/server.ts index d094695..773cf19 100644 --- a/src/features/matches/server.ts +++ b/src/features/matches/server.ts @@ -174,5 +174,39 @@ export const endMatch = createServerFn() away_cups, ot_count }) + + const matchWinner = home_cups > away_cups ? match.home : match.away; + const matchLoser = home_cups < away_cups ? match.home : match.away; + if (!matchWinner || !matchLoser) throw new Error("Something went wrong") + + // winner -> where to send match winner to, loser same + const { winner, loser } = await pbAdmin.getChildMatches(matchId) + + console.log('match winner:', matchWinner) + console.log('match loser:', matchLoser) + + if (winner) { + await pbAdmin.updateMatch(winner.id, { + [winner.home_from_lid === match.lid ? "home" : "away"]: matchWinner, + status: + ((winner.home_from_lid === match.lid) && winner.away) + || ((winner.away_from_lid === match.lid) && winner.home) + ? 'ready' : 'tbd' + }) + } + + if (loser) { + await pbAdmin.updateMatch(loser.id, { + [loser.home_from_lid === match.lid ? "home" : "away"]: matchLoser, + status: + ((loser.home_from_lid === match.lid) && loser.away) + || ((loser.away_from_lid === match.lid) && loser.home) + ? 'ready' : 'tbd' + }) + } + + // TODO: send SSE + + return match; } )); \ No newline at end of file diff --git a/src/features/matches/types.ts b/src/features/matches/types.ts index 341165e..cc61ec8 100644 --- a/src/features/matches/types.ts +++ b/src/features/matches/types.ts @@ -4,39 +4,6 @@ import { TournamentInfo } from "../tournaments/types"; export type MatchStatus = "tbd" | "ready" | "started" | "ended"; -/** - * class TMatchSlot(BaseModel): - pass - - -class Seed(TMatchSlot): - seed: int - - -class TBD(TMatchSlot): - parent: "TMatchBase" - loser: bool - - -class TMatchBase(BaseModel): - lid: int # local id - round: int - order: Optional[int] = None - - -class TMatch(TMatchBase): - home: Seed | TBD - away: Seed | TBD - reset: bool = False - - def __repr__(self): - return f'{self.order}' - - -class TBye(TMatchBase): - home: Seed | TBD - */ - export interface Match { id: string; order: number; diff --git a/src/lib/pocketbase/services/matches.ts b/src/lib/pocketbase/services/matches.ts index 7126cdf..3bc437c 100644 --- a/src/lib/pocketbase/services/matches.ts +++ b/src/lib/pocketbase/services/matches.ts @@ -13,6 +13,28 @@ export function createMatchesService(pb: PocketBase) { return transformMatch(result); }, + // match Ids where the current lid is home_from_lid or away_from_lid + async getChildMatches(matchId: string): Promise<{ winner: Match | undefined, loser: Match | undefined }> { + logger.info("PocketBase | Getting child matches", matchId); + const match = await this.getMatch(matchId); + if (!match) throw new Error("Match not found") + + const result = await pb.collection("matches").getFullList({ + filter: `tournament="${match.tournament.id}" && (home_from_lid = ${match.lid} || away_from_lid = ${match.lid})`, + expand: "tournament, home, away", + }); + + const winnerMatch = result.find(m => (m.home_from_lid === match.lid && !m.home_from_loser) || (m.away_from_lid === match.lid && !m.away_from_loser)); + const loserMatch = result.find(m => (m.home_from_lid === match.lid && m.home_from_loser) || (m.away_from_lid === match.lid && m.away_from_loser)); + + console.log(winnerMatch, loserMatch) + + return { + winner: winnerMatch ? transformMatch(winnerMatch) : undefined, + loser: loserMatch ? transformMatch(loserMatch) : undefined + } + }, + async createMatch(data: MatchInput): Promise { logger.info("PocketBase | Creating match", data); const result = await pb.collection("matches").create(data); diff --git a/src/lib/pocketbase/services/tournaments.ts b/src/lib/pocketbase/services/tournaments.ts index 6513d36..d4d4619 100644 --- a/src/lib/pocketbase/services/tournaments.ts +++ b/src/lib/pocketbase/services/tournaments.ts @@ -14,7 +14,7 @@ export function createTournamentsService(pb: PocketBase) { return { async getTournament(id: string): Promise { const result = await pb.collection("tournaments").getOne(id, { - expand: "teams, teams.players, matches, matches.tournament, matches.home, matches.away", + expand: "teams, teams.players, matches, matches.tournament, matches.home, matches.away, matches.home.players, matches.away.players", }); return transformTournament(result); },