regionals

This commit is contained in:
yohlo
2026-03-01 16:21:27 -06:00
parent f83a7d69c8
commit 6199afc687
14 changed files with 849 additions and 137 deletions

View File

@@ -1,15 +1,21 @@
import React, { useMemo, useState } from "react";
import { Stack, Text, Card, Group as MantineGroup, Box, SimpleGrid, Tabs, Collapse, ActionIcon } from "@mantine/core";
import { CaretCircleDown, CaretCircleUp } from "@phosphor-icons/react";
import { Stack, Text, Card, Group as MantineGroup, Box, SimpleGrid, Tabs, Collapse, ActionIcon, Button, Alert } from "@mantine/core";
import { CaretCircleDownIcon, CaretCircleUpIcon } from "@phosphor-icons/react";
import { Match } from "@/features/matches/types";
import { Group } from "../types";
import GroupMatchCard from "./group-match-card";
import TeamAvatar from "@/components/team-avatar";
import { useServerMutation } from "@/lib/tanstack-query/hooks/use-server-mutation";
import { populateKnockoutBracket } from "@/features/matches/server";
import { useQueryClient } from "@tanstack/react-query";
import { tournamentKeys } from "../queries";
interface GroupStageViewProps {
groups: Group[];
matches: Match[];
showControls?: boolean;
tournamentId?: string;
hasKnockoutBracket?: boolean;
}
interface TeamStanding {
@@ -18,6 +24,8 @@ interface TeamStanding {
team: any;
wins: number;
losses: number;
cupsFor: number;
cupsAgainst: number;
cupDifference: number;
}
@@ -25,9 +33,33 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
groups,
matches,
showControls,
tournamentId,
hasKnockoutBracket,
}) => {
const queryClient = useQueryClient();
const [expandedTeams, setExpandedTeams] = useState<Record<string, boolean>>({});
const populateKnockoutMutation = useServerMutation({
mutationFn: populateKnockoutBracket,
successMessage: "Knockout bracket populated successfully!",
onSuccess: () => {
if (tournamentId) {
queryClient.invalidateQueries({ queryKey: tournamentKeys.details(tournamentId) });
}
},
});
const allGroupMatchesCompleted = useMemo(() => {
const groupMatches = matches.filter((match) => match.round === -1);
if (groupMatches.length === 0) return false;
return groupMatches.every((match) => match.status === "ended");
}, [matches]);
const handlePopulateKnockout = () => {
if (!tournamentId) return;
populateKnockoutMutation.mutate({ data: { tournamentId } });
};
const orderMatchesWithSpacing = (matches: Match[]): Match[] => {
if (matches.length <= 1) return matches;
@@ -99,6 +131,8 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
team: team,
wins: 0,
losses: 0,
cupsFor: 0,
cupsAgainst: 0,
cupDifference: 0,
});
});
@@ -119,6 +153,11 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
const homeCups = match.home_cups || 0;
const awayCups = match.away_cups || 0;
homeStanding.cupsFor += homeCups;
homeStanding.cupsAgainst += awayCups;
awayStanding.cupsFor += awayCups;
awayStanding.cupsAgainst += homeCups;
homeStanding.cupDifference += homeCups - awayCups;
awayStanding.cupDifference += awayCups - homeCups;
@@ -133,7 +172,8 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
return Array.from(standings.values()).sort((a, b) => {
if (b.wins !== a.wins) return b.wins - a.wins;
return b.cupDifference - a.cupDifference;
if (b.cupDifference !== a.cupDifference) return b.cupDifference - a.cupDifference;
return b.cupsFor - a.cupsFor;
});
};
@@ -147,104 +187,26 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
);
}
if (sortedGroups.length === 1) {
const group = sortedGroups[0];
const groupMatches = matchesByGroup.get(group.id) || [];
const standings = getTeamStandings(group.id, group.teams || []);
return (
<Box p="md">
<Stack gap="md">
<Card withBorder radius="md" p={0}>
<MantineGroup
justify="space-between"
p="sm"
style={{
cursor: 'pointer',
backgroundColor: 'var(--mantine-color-default-hover)',
}}
onClick={() => toggleTeams(group.id)}
>
<Text fw={600} size="sm">
Standings ({standings.length})
</Text>
<ActionIcon variant="subtle" size="sm">
{expandedTeams[group.id] ? <CaretCircleUp size={16} /> : <CaretCircleDown size={16} />}
</ActionIcon>
</MantineGroup>
<Collapse in={expandedTeams[group.id]}>
<Stack gap={0}>
{standings.length > 0 ? (
standings.map((standing, index) => (
<MantineGroup
key={standing.teamId}
gap="sm"
align="center"
wrap="nowrap"
px="md"
py="xs"
style={{
borderTop: index > 0 ? '1px solid var(--mantine-color-default-border)' : 'none',
}}
>
<Text size="sm" fw={700} c="dimmed" w={24} ta="center">
{index + 1}
</Text>
<TeamAvatar team={standing.team} size={28} radius="sm" />
<Text size="sm" fw={500} style={{ flex: 1 }} lineClamp={1}>
{standing.teamName}
</Text>
<MantineGroup gap="xs" wrap="nowrap">
<Text size="xs" c="dimmed" fw={500}>
{standing.wins}-{standing.losses}
</Text>
<Text
size="xs"
c={standing.cupDifference > 0 ? "green" : standing.cupDifference < 0 ? "red" : "dimmed"}
fw={600}
>
{standing.cupDifference > 0 ? '+' : ''}{standing.cupDifference}
</Text>
</MantineGroup>
</MantineGroup>
))
) : (
<Text size="sm" c="dimmed" ta="center" py="md">
No teams assigned
</Text>
)}
</Stack>
</Collapse>
</Card>
{groupMatches.length === 0 ? (
<Card withBorder radius="md" p="xl">
<Text size="sm" c="dimmed" ta="center">
No matches scheduled
</Text>
</Card>
) : (
<SimpleGrid
cols={{ base: 1, sm: 2, lg: 3 }}
spacing="md"
>
{groupMatches.map((match) => (
<GroupMatchCard
key={match.id}
match={match}
showControls={showControls}
/>
))}
</SimpleGrid>
)}
</Stack>
</Box>
);
}
const showGenerateKnockoutButton = showControls && tournamentId && !hasKnockoutBracket && allGroupMatchesCompleted;
return (
<Box p="md">
<Tabs defaultValue={sortedGroups[0]?.id}>
<Stack gap="md">
{showGenerateKnockoutButton && (
<Alert color="blue" title="Group Stage Complete" icon={<CaretCircleUpIcon size={20} />}>
<Stack gap="xs">
<Text size="sm">All group matches are finished! Populate the knockout bracket to advance qualified teams.</Text>
<Button
onClick={handlePopulateKnockout}
loading={populateKnockoutMutation.isPending}
size="sm"
>
Populate Knockout Bracket
</Button>
</Stack>
</Alert>
)}
<Tabs defaultValue={sortedGroups[0]?.id}>
<Tabs.List mb="md" grow>
{sortedGroups.map((group) => {
const groupMatches = matchesByGroup.get(group.id) || [];
@@ -291,7 +253,7 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
Standings ({standings.length})
</Text>
<ActionIcon variant="subtle" size="sm">
{expandedTeams[group.id] ? <CaretCircleUp size={16} /> : <CaretCircleDown size={16} />}
{expandedTeams[group.id] ? <CaretCircleUpIcon size={16} /> : <CaretCircleDownIcon size={16} />}
</ActionIcon>
</MantineGroup>
<Collapse in={expandedTeams[group.id]}>
@@ -317,16 +279,21 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
{standing.teamName}
</Text>
<MantineGroup gap="xs" wrap="nowrap">
<Text size="xs" c="dimmed" fw={500}>
<Text size="xs" c="dimmed" fw={500} miw={35} ta="center">
{standing.wins}-{standing.losses}
</Text>
<Text
size="xs"
c={standing.cupDifference > 0 ? "green" : standing.cupDifference < 0 ? "red" : "dimmed"}
fw={600}
miw={30}
ta="center"
>
{standing.cupDifference > 0 ? '+' : ''}{standing.cupDifference}
</Text>
<Text size="xs" c="dimmed" fw={400} miw={40} ta="center">
{standing.cupsFor}/{standing.cupsAgainst}
</Text>
</MantineGroup>
</MantineGroup>
))
@@ -339,7 +306,6 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
</Collapse>
</Card>
{/* Matches Grid */}
{groupMatches.length === 0 ? (
<Card withBorder radius="md" p="xl">
<Text size="sm" c="dimmed" ta="center">
@@ -365,6 +331,7 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
);
})}
</Tabs>
</Stack>
</Box>
);
};

View File

@@ -7,6 +7,9 @@ import {
Select,
LoadingOverlay,
Alert,
Title,
Divider,
Box,
} from "@mantine/core";
import { InfoIcon } from "@phosphor-icons/react";
import { useServerMutation } from "@/lib/tanstack-query/hooks/use-server-mutation";
@@ -21,6 +24,10 @@ import {
import GroupPreview from "./group-preview";
import { useQueryClient } from "@tanstack/react-query";
import { tournamentKeys } from "../queries";
import brackets from "@/features/bracket/utils";
import { Bracket } from "@/features/bracket/components/bracket";
import { Match } from "@/features/matches/types";
import { generateSingleEliminationBracket } from "../utils/bracket-generator";
interface SetupGroupStageProps {
tournamentId: string;
@@ -63,6 +70,74 @@ const SetupGroupStage: React.FC<SetupGroupStageProps> = ({
}));
}, [selectedConfig, teams, seed]);
const knockoutTeamCount = useMemo(() => {
if (!selectedConfig) return 0;
return selectedConfig.num_groups * selectedConfig.advance_per_group;
}, [selectedConfig]);
const bracketPreview = useMemo(() => {
if (!knockoutTeamCount || !selectedConfig) {
return null;
}
let bracketTemplate: any;
if (Object.keys(brackets).includes(knockoutTeamCount.toString())) {
bracketTemplate = brackets[knockoutTeamCount as keyof typeof brackets];
} else {
try {
bracketTemplate = generateSingleEliminationBracket(knockoutTeamCount);
} catch (error) {
return null;
}
}
const groupNames = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'];
const seedLabels: Record<number, string> = {};
let seedIndex = 1;
for (let rank = 1; rank <= selectedConfig.advance_per_group; rank++) {
for (let groupIdx = 0; groupIdx < selectedConfig.num_groups; groupIdx++) {
const groupName = groupNames[groupIdx] || `Group ${groupIdx + 1}`;
const rankSuffix = rank === 1 ? '1st' : rank === 2 ? '2nd' : rank === 3 ? '3rd' : `${rank}th`;
seedLabels[seedIndex++] = `${groupName} ${rankSuffix}`;
}
}
const ordersMap: Record<number, number> = {};
bracketTemplate.winners.forEach((round: any[]) => {
round.forEach((match: any) => {
ordersMap[match.lid] = match.order;
});
});
const placeholderMatches: Match[][] = bracketTemplate.winners.map((round: any[], roundIndex: number) =>
round.map((match: any) => {
const matchData: any = {
...match,
id: `preview-${match.lid}`,
home_from_lid: match.home_from_lid !== null && match.home_from_lid !== undefined ? match.home_from_lid : -1,
away_from_lid: match.away_from_lid !== null && match.away_from_lid !== undefined ? match.away_from_lid : -1,
home_cups: 0,
away_cups: 0,
status: "tbd" as const,
tournament: { id: "", name: "" },
};
if (roundIndex === 0) {
matchData.home = match.home_seed && !match.bye ? { id: `seed-${match.home_seed}`, name: seedLabels[match.home_seed] } : null;
matchData.away = match.away_seed ? { id: `seed-${match.away_seed}`, name: seedLabels[match.away_seed] } : null;
} else {
matchData.home = null;
matchData.away = null;
}
return matchData;
})
);
return { matches: placeholderMatches, orders: ordersMap };
}, [knockoutTeamCount, selectedConfig]);
const generateGroups = useServerMutation({
mutationFn: generateGroupStage,
successMessage: "Group stage generated successfully!",
@@ -171,9 +246,38 @@ const SetupGroupStage: React.FC<SetupGroupStageProps> = ({
</Stack>
<div style={{ flex: 1, overflow: "auto", maxHeight: "80vh" }}>
{groupAssignments.length > 0 && (
<GroupPreview groups={groupAssignments} />
)}
<Stack gap="xl">
{groupAssignments.length > 0 && (
<GroupPreview groups={groupAssignments} />
)}
{bracketPreview && knockoutTeamCount > 0 && (
<Box>
<Divider mb="lg" />
<Title order={3} ta="center" mb="md">
Knockout Bracket Preview ({knockoutTeamCount} Teams)
</Title>
<Text size="sm" c="dimmed" ta="center" mb="lg">
Top {selectedConfig?.advance_per_group} team{selectedConfig?.advance_per_group !== 1 ? 's' : ''} from each group will advance
</Text>
<Box
style={{
backgroundImage: `radial-gradient(circle, var(--mantine-color-default-border) 1px, transparent 1px)`,
backgroundSize: "16px 16px",
backgroundPosition: "0 0, 8px 8px",
padding: "1rem",
borderRadius: "8px",
}}
>
<Bracket
rounds={bracketPreview.matches}
orders={bracketPreview.orders}
showControls={false}
/>
</Box>
</Box>
)}
</Stack>
</div>
</div>
);