i think working bracket runner

This commit is contained in:
yohlo
2025-09-11 15:59:27 -05:00
parent 8dfff139e1
commit 3ffa6b03c7
10 changed files with 424 additions and 139 deletions

View File

@@ -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<number, Match[]>()
const losersMap = new Map<number, Match[]>()
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<number, Match[]>();
const losersMap = new Map<number, Match[]>();
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 (
<Container size="md">
{
tournament.matches?.length ?
<BracketView bracket={bracket} onStartMatch={console.log} />
: (
<SeedTournament
tournamentId={tournament.id}
teams={tournament.teams || []}
/>)
}
{tournament.matches?.length ? (
<BracketView bracket={bracket} showControls />
) : (
<SeedTournament
tournamentId={tournament.id}
teams={tournament.teams || []}
/>
)}
</Container>
)
);
}

View File

@@ -7,10 +7,10 @@ import { Match } from "@/features/matches/types";
interface BracketViewProps {
bracket: BracketData;
onStartMatch?: (match: Match) => void;
showControls?: boolean
}
const BracketView: React.FC<BracketViewProps> = ({ bracket, onStartMatch }) => {
const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls }) => {
const height = useAppShellHeight();
const orders = useMemo(() => {
const map: Record<number, number> = {};
@@ -32,14 +32,14 @@ const BracketView: React.FC<BracketViewProps> = ({ bracket, onStartMatch }) => {
<Text fw={600} size="md" m={16}>
Winners Bracket
</Text>
<Bracket rounds={bracket.winners} orders={orders} onStartMatch={onStartMatch} />
<Bracket rounds={bracket.winners} orders={orders} showControls={showControls} />
</div>
{bracket.losers && (
<div>
<Text fw={600} size="md" m={16}>
Losers Bracket
</Text>
<Bracket rounds={bracket.losers} orders={orders} onStartMatch={onStartMatch} />
<Bracket rounds={bracket.losers} orders={orders} showControls={showControls} />
</div>
)}
</ScrollArea>

View File

@@ -5,13 +5,13 @@ import { MatchCard } from "./match-card";
interface BracketProps {
rounds: Match[][];
orders: Record<number, number>;
onStartMatch?: (match: Match) => void;
showControls?: boolean;
}
export const Bracket: React.FC<BracketProps> = ({
rounds,
orders,
onStartMatch,
showControls,
}) => {
return (
<Flex direction="row" gap={24} justify="left" p="xl">
@@ -32,7 +32,7 @@ export const Bracket: React.FC<BracketProps> = ({
<MatchCard
match={match}
orders={orders}
onStartMatch={onStartMatch}
showControls={showControls}
/>
</div>
);

View File

@@ -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<number, number>;
onStartMatch?: (match: Match) => void;
showControls?: boolean;
}
export const MatchCard: React.FC<MatchCardProps> = ({
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<MatchCardProps> = ({
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 (
<Flex direction="row" align="center" justify="end" gap={8}>
<Text c="dimmed" fw="bolder">
{match.order}
</Text>
<Flex align="stretch">
<Card
withBorder
pos="relative"
w={200}
style={{ overflow: "visible" }}
data-match-lid={match.lid}
<Indicator
inline
processing={match.status === "started"}
color="red"
size={12}
disabled={match.status !== "started" || showEditButton}
>
<Card.Section withBorder p={0}>
<MatchSlot {...homeSlot} />
</Card.Section>
<Card
w={showToolbar || showEditButton ? 200 : 220}
withBorder
pos="relative"
style={{ overflow: "visible" }}
data-match-lid={match.lid}
>
<Card.Section withBorder p={0}>
<MatchSlot {...homeSlot} />
</Card.Section>
<Card.Section p={0} mb={-16}>
<MatchSlot {...awaySlot} />
</Card.Section>
<Card.Section p={0} mb={-16}>
<MatchSlot {...awaySlot} />
</Card.Section>
{match.reset && (
<Text
pos="absolute"
top={-20}
left={8}
size="xs"
c="dimmed"
fw="bold"
>
* If necessary
</Text>
)}
</Card>
{match.reset && (
<Text
pos="absolute"
top={-20}
left={8}
size="xs"
c="dimmed"
fw="bold"
>
* If necessary
</Text>
)}
</Card>
</Indicator>
{showToolbar && (
<Flex
direction="column"
justify="center"
align="center"
>
<Flex direction="column" justify="center" align="center">
<ActionIcon
color="green"
onClick={handleAnnounce}
onClick={handleStart}
size="sm"
h='100%'
radius='sm'
h="100%"
radius="sm"
ml={-4}
style={{
borderTopLeftRadius: 0,
@@ -106,7 +154,34 @@ export const MatchCard: React.FC<MatchCardProps> = ({
</ActionIcon>
</Flex>
)}
{showEditButton && (
<Flex direction="column" justify="center" align="center">
<ActionIcon
color="blue"
onClick={editSheet.open}
size="sm"
h="100%"
radius="sm"
ml={-4}
style={{
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
}}
>
<PencilIcon size={14} />
</ActionIcon>
</Flex>
)}
</Flex>
<Sheet title="Edit Match" {...editSheet.props}>
<MatchForm
match={match}
onSubmit={handleFormSubmit}
onCancel={editSheet.close}
/>
</Sheet>
</Flex>
);
};

View File

@@ -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<MatchFormProps> = ({
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 (
<form onSubmit={handleSubmit}>
<Stack gap="md">
<Flex mx='auto' direction='column' gap='md' miw={250}>
<Group gap="xs">
<Stack gap={0}>
<Text fw={500} size="sm">
{match.home?.name} Cups
</Text>
{
match.home?.players.map(p => (<Text size='xs' c='dimmed'>
{p.first_name} {p.last_name}
</Text>))
}
</Stack>
<TextInput
ml='auto'
type="number"
min={0}
w={50}
ta="center"
key={form.key("home_cups")}
{...form.getInputProps("home_cups")}
/>
</Group>
<Divider />
<Group gap="xs">
<Stack gap={0}>
<Text fw={500} size="sm">
{match.away?.name} Cups
</Text>
{
match.away?.players.map(p => (<Text size='xs' c='dimmed'>
{p.first_name} {p.last_name}
</Text>))
}
</Stack>
<TextInput
ml='auto'
ta="center"
w={50}
type="number"
min={0}
key={form.key("away_cups")}
{...form.getInputProps("away_cups")}
/>
</Group>
<Divider />
<Group gap="xs">
<Text fw={500} size="sm">
OT Count
</Text>
<TextInput
ml='auto'
ta="center"
w={50}
type="number"
min={0}
key={form.key("ot_count")}
{...form.getInputProps("ot_count")}
/>
</Group>
</Flex>
<Stack mt="md">
<Button type="submit">Update Match</Button>
<Button variant="subtle" color="red" onClick={onCancel}>
Cancel
</Button>
</Stack>
</Stack>
</form>
);
};

View File

@@ -8,6 +8,7 @@ interface MatchSlotProps {
from_loser?: boolean;
team?: TeamInfo;
seed?: number;
cups?: number;
}
export const MatchSlot: React.FC<MatchSlotProps> = ({
@@ -15,10 +16,11 @@ export const MatchSlot: React.FC<MatchSlotProps> = ({
from_loser,
team,
seed,
cups
}) => (
<Flex align="stretch">
{(seed && seed > 0) ? <SeedBadge seed={seed} /> : undefined}
<Flex p="4px 8px">
<Flex p="4px 8px" w='100%'>
{team ? (
<Text size="xs">{team.name}</Text>
) : from ? (
@@ -30,6 +32,11 @@ export const MatchSlot: React.FC<MatchSlotProps> = ({
TBD
</Text>
)}
{
cups !== undefined ? (
<Text ta='center' w={15} fw="800" ml='auto' size="xs">{cups}</Text>
) : undefined
}
</Flex>
</Flex>
);

View File

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

View File

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

View File

@@ -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<Match> {
logger.info("PocketBase | Creating match", data);
const result = await pb.collection("matches").create<Match>(data);

View File

@@ -14,7 +14,7 @@ export function createTournamentsService(pb: PocketBase) {
return {
async getTournament(id: string): Promise<Tournament> {
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);
},