ready for regionals
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 2m46s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 44s

This commit is contained in:
yohlo
2026-03-07 01:22:21 -06:00
parent 569ea8833b
commit e67f6b073c
9 changed files with 353 additions and 95 deletions
@@ -1,8 +1,8 @@
import React, { useMemo, useState } from "react";
import { Stack, Text, Card, Group as MantineGroup, Box, SimpleGrid, Tabs, Collapse, ActionIcon, Button, Alert } from "@mantine/core";
import { Stack, Text, Card, Group as MantineGroup, Box, SimpleGrid, Tabs, Collapse, ActionIcon, Button, Alert, Badge } from "@mantine/core";
import { CaretCircleDownIcon, CaretCircleUpIcon } from "@phosphor-icons/react";
import { Match } from "@/features/matches/types";
import { Group } from "../types";
import { Group, GroupConfig } from "../types";
import GroupMatchCard from "./group-match-card";
import TeamAvatar from "@/components/team-avatar";
import { useServerMutation } from "@/lib/tanstack-query/hooks/use-server-mutation";
@@ -17,6 +17,7 @@ interface GroupStageViewProps {
tournamentId?: string;
hasKnockoutBracket?: boolean;
isRegional?: boolean;
groupConfig?: GroupConfig;
}
interface TeamStanding {
@@ -37,6 +38,7 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
tournamentId,
hasKnockoutBracket,
isRegional,
groupConfig,
}) => {
const queryClient = useQueryClient();
const [expandedTeams, setExpandedTeams] = useState<Record<string, boolean>>({});
@@ -239,6 +241,57 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
});
};
const allGroupStandings = useMemo(() => {
return sortedGroups.map((group) => ({
groupId: group.id,
groupOrder: group.order,
standings: getTeamStandings(group.id, group.teams || []),
}));
}, [sortedGroups, matchesByGroup]);
const advancingTeams = useMemo(() => {
if (!groupConfig) return { qualifiedTeams: new Set<string>(), wildcardTeams: new Set<string>() };
const advancePerGroup = groupConfig.advance_per_group;
const qualifiedTeams = new Set<string>();
const wildcardTeams = new Set<string>();
allGroupStandings.forEach(({ standings }) => {
standings.slice(0, advancePerGroup).forEach((standing) => {
qualifiedTeams.add(standing.teamId);
});
});
const totalQualified = qualifiedTeams.size;
const knockoutTeamCount = totalQualified;
const nextPowerOf2 = Math.pow(2, Math.ceil(Math.log2(knockoutTeamCount)));
const wildcardsNeeded = nextPowerOf2 - knockoutTeamCount;
if (wildcardsNeeded > 0) {
const allNonQualifiedTeams: TeamStanding[] = [];
allGroupStandings.forEach(({ standings }) => {
standings.slice(advancePerGroup).forEach((standing) => {
allNonQualifiedTeams.push(standing);
});
});
allNonQualifiedTeams.sort((a, b) => {
if (b.wins !== a.wins) return b.wins - a.wins;
if (b.cupDifference !== a.cupDifference) return b.cupDifference - a.cupDifference;
if (b.cupsFor !== a.cupsFor) return b.cupsFor - a.cupsFor;
return a.teamId.localeCompare(b.teamId);
});
allNonQualifiedTeams.slice(0, wildcardsNeeded).forEach((standing) => {
wildcardTeams.add(standing.teamId);
});
}
return { qualifiedTeams, wildcardTeams };
}, [allGroupStandings, groupConfig]);
if (sortedGroups.length === 0) {
return (
<Box p="md">
@@ -321,44 +374,64 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
<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" isRegional={isRegional} />
<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} 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}
standings.map((standing, index) => {
const isQualified = advancingTeams.qualifiedTeams.has(standing.teamId);
const isWildcard = advancingTeams.wildcardTeams.has(standing.teamId);
const isAdvancing = isQualified || isWildcard;
return (
<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',
backgroundColor: isAdvancing ? 'var(--mantine-primary-color-light)' : undefined,
borderLeft: isAdvancing ? '3px solid var(--mantine-primary-color-filled)' : '3px solid transparent',
}}
>
<Text size="sm" fw={700} c="dimmed" w={24} ta="center">
{index + 1}
</Text>
<TeamAvatar team={standing.team} size={28} radius="sm" isRegional={isRegional} />
<Box style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} lineClamp={1}>
{standing.teamName}
</Text>
</Box>
<MantineGroup gap="xs" wrap="nowrap">
{isWildcard && (
<Badge size="xs" color="yellow" variant="light">
WC
</Badge>
)}
{isQualified && (
<Badge size="xs" variant="light">
Q
</Badge>
)}
<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>
</MantineGroup>
))
);
})
) : (
<Text size="sm" c="dimmed" ta="center" py="md">
No teams assigned
@@ -80,12 +80,16 @@ const SetupGroupStage: React.FC<SetupGroupStageProps> = ({
return null;
}
const nextPowerOf2 = Math.pow(2, Math.ceil(Math.log2(knockoutTeamCount)));
const bracketSize = nextPowerOf2;
const wildcardsNeeded = bracketSize - knockoutTeamCount;
let bracketTemplate: any;
if (Object.keys(brackets).includes(knockoutTeamCount.toString())) {
bracketTemplate = brackets[knockoutTeamCount as keyof typeof brackets];
if (Object.keys(brackets).includes(bracketSize.toString())) {
bracketTemplate = brackets[bracketSize as keyof typeof brackets];
} else {
try {
bracketTemplate = generateSingleEliminationBracket(knockoutTeamCount);
bracketTemplate = generateSingleEliminationBracket(bracketSize);
} catch (error) {
return null;
}
@@ -113,6 +117,10 @@ const SetupGroupStage: React.FC<SetupGroupStageProps> = ({
seedLabels[seedIndex++] = `${groupName2} ${rankSuffix2}`;
}
for (let i = 0; i < wildcardsNeeded; i++) {
seedLabels[seedIndex++] = `Wildcard ${i + 1}`;
}
const ordersMap: Record<number, number> = {};
bracketTemplate.winners.forEach((round: any[]) => {
round.forEach((match: any) => {
@@ -265,10 +273,11 @@ const SetupGroupStage: React.FC<SetupGroupStageProps> = ({
<Box>
<Divider mb="lg" />
<Title order={3} ta="center" mb="md">
Knockout Bracket Preview ({knockoutTeamCount} Teams)
Knockout Bracket Preview ({selectedConfig?.knockout_size} 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
{selectedConfig?.wildcards_needed ? ` + ${selectedConfig.wildcards_needed} wildcard${selectedConfig.wildcards_needed > 1 ? 's' : ''}` : ''}
</Text>
<Box
style={{