regionals
This commit is contained in:
@@ -7,7 +7,7 @@ import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
|||||||
import SeedTournament from "@/features/tournaments/components/seed-tournament";
|
import SeedTournament from "@/features/tournaments/components/seed-tournament";
|
||||||
import SetupGroupStage from "@/features/tournaments/components/setup-group-stage";
|
import SetupGroupStage from "@/features/tournaments/components/setup-group-stage";
|
||||||
import GroupStageView from "@/features/tournaments/components/group-stage-view";
|
import GroupStageView from "@/features/tournaments/components/group-stage-view";
|
||||||
import { Container } from "@mantine/core";
|
import { Container, Stack, Divider, Title } from "@mantine/core";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { BracketData } from "@/features/bracket/types";
|
import { BracketData } from "@/features/bracket/types";
|
||||||
import { Match } from "@/features/matches/types";
|
import { Match } from "@/features/matches/types";
|
||||||
@@ -45,10 +45,20 @@ function RouteComponent() {
|
|||||||
const { roles } = useAuth();
|
const { roles } = useAuth();
|
||||||
const isAdmin = roles?.includes('Admin') || false;
|
const isAdmin = roles?.includes('Admin') || false;
|
||||||
|
|
||||||
const isGroupStage = useMemo(() => {
|
const hasGroupStage = useMemo(() => {
|
||||||
return tournament.matches?.some((match) => match.round === -1) || false;
|
return tournament.matches?.some((match) => match.round === -1) || false;
|
||||||
}, [tournament.matches]);
|
}, [tournament.matches]);
|
||||||
|
|
||||||
|
const hasKnockout = useMemo(() => {
|
||||||
|
return tournament.matches?.some((match) => match.round !== -1) || false;
|
||||||
|
}, [tournament.matches]);
|
||||||
|
|
||||||
|
const knockoutBracketPopulated = useMemo(() => {
|
||||||
|
return tournament.matches?.some((match) =>
|
||||||
|
match.round === 0 && match.lid >= 0 && (match.home || match.away)
|
||||||
|
) || false;
|
||||||
|
}, [tournament.matches]);
|
||||||
|
|
||||||
const bracket: BracketData = useMemo(() => {
|
const bracket: BracketData = useMemo(() => {
|
||||||
if (!tournament.matches || tournament.matches.length === 0) {
|
if (!tournament.matches || tournament.matches.length === 0) {
|
||||||
return { winners: [], losers: [] };
|
return { winners: [], losers: [] };
|
||||||
@@ -88,14 +98,31 @@ function RouteComponent() {
|
|||||||
<Container size="md" px={0}>
|
<Container size="md" px={0}>
|
||||||
{ isAdmin && !tournament.regional && <SpotifyControlsBar />}
|
{ isAdmin && !tournament.regional && <SpotifyControlsBar />}
|
||||||
{tournament.matches?.length ? (
|
{tournament.matches?.length ? (
|
||||||
isGroupStage ? (
|
hasGroupStage && hasKnockout ? (
|
||||||
|
<Stack gap="xl">
|
||||||
|
<GroupStageView
|
||||||
|
groups={tournament.groups || []}
|
||||||
|
matches={tournament.matches}
|
||||||
|
showControls
|
||||||
|
tournamentId={tournament.id}
|
||||||
|
hasKnockoutBracket={knockoutBracketPopulated}
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
<div>
|
||||||
|
<Title order={3} ta="center" mb="md">Knockout Bracket</Title>
|
||||||
|
<BracketView bracket={bracket} showControls groupConfig={tournament.group_config} />
|
||||||
|
</div>
|
||||||
|
</Stack>
|
||||||
|
) : hasGroupStage ? (
|
||||||
<GroupStageView
|
<GroupStageView
|
||||||
groups={tournament.groups || []}
|
groups={tournament.groups || []}
|
||||||
matches={tournament.matches}
|
matches={tournament.matches}
|
||||||
showControls
|
showControls
|
||||||
|
tournamentId={tournament.id}
|
||||||
|
hasKnockoutBracket={knockoutBracketPopulated}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<BracketView bracket={bracket} showControls />
|
<BracketView bracket={bracket} showControls groupConfig={tournament.group_config} />
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
tournament.regional === true ? (
|
tournament.regional === true ? (
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ function RouteComponent() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="md" px={0}>
|
<Container size="md" px={0}>
|
||||||
<BracketView bracket={bracket} />
|
<BracketView bracket={bracket} groupConfig={tournament.group_config} />
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,14 @@ import { Match } from "@/features/matches/types";
|
|||||||
|
|
||||||
interface BracketViewProps {
|
interface BracketViewProps {
|
||||||
bracket: BracketData;
|
bracket: BracketData;
|
||||||
showControls?: boolean
|
showControls?: boolean;
|
||||||
|
groupConfig?: {
|
||||||
|
num_groups: number;
|
||||||
|
advance_per_group: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls }) => {
|
const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls, groupConfig }) => {
|
||||||
const height = useAppShellHeight();
|
const height = useAppShellHeight();
|
||||||
const orders = useMemo(() => {
|
const orders = useMemo(() => {
|
||||||
const map: Record<number, number> = {};
|
const map: Record<number, number> = {};
|
||||||
@@ -32,14 +36,14 @@ const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls }) => {
|
|||||||
<Text fw={600} size="md" m={16}>
|
<Text fw={600} size="md" m={16}>
|
||||||
Winners Bracket
|
Winners Bracket
|
||||||
</Text>
|
</Text>
|
||||||
<Bracket rounds={bracket.winners} orders={orders} showControls={showControls} />
|
<Bracket rounds={bracket.winners} orders={orders} showControls={showControls} groupConfig={groupConfig} />
|
||||||
</div>
|
</div>
|
||||||
{bracket.losers && (
|
{bracket.losers && (
|
||||||
<div>
|
<div>
|
||||||
<Text fw={600} size="md" m={16}>
|
<Text fw={600} size="md" m={16}>
|
||||||
Losers Bracket
|
Losers Bracket
|
||||||
</Text>
|
</Text>
|
||||||
<Bracket rounds={bracket.losers} orders={orders} showControls={showControls} />
|
<Bracket rounds={bracket.losers} orders={orders} showControls={showControls} groupConfig={groupConfig} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|||||||
@@ -7,12 +7,17 @@ interface BracketProps {
|
|||||||
rounds: Match[][];
|
rounds: Match[][];
|
||||||
orders: Record<number, number>;
|
orders: Record<number, number>;
|
||||||
showControls?: boolean;
|
showControls?: boolean;
|
||||||
|
groupConfig?: {
|
||||||
|
num_groups: number;
|
||||||
|
advance_per_group: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Bracket: React.FC<BracketProps> = ({
|
export const Bracket: React.FC<BracketProps> = ({
|
||||||
rounds,
|
rounds,
|
||||||
orders,
|
orders,
|
||||||
showControls,
|
showControls,
|
||||||
|
groupConfig,
|
||||||
}) => {
|
}) => {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const svgRef = useRef<SVGSVGElement>(null);
|
const svgRef = useRef<SVGSVGElement>(null);
|
||||||
@@ -132,6 +137,7 @@ export const Bracket: React.FC<BracketProps> = ({
|
|||||||
match={match}
|
match={match}
|
||||||
orders={orders}
|
orders={orders}
|
||||||
showControls={showControls}
|
showControls={showControls}
|
||||||
|
groupConfig={groupConfig}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,16 +17,38 @@ interface MatchCardProps {
|
|||||||
match: Match;
|
match: Match;
|
||||||
orders: Record<number, number>;
|
orders: Record<number, number>;
|
||||||
showControls?: boolean;
|
showControls?: boolean;
|
||||||
|
groupConfig?: {
|
||||||
|
num_groups: number;
|
||||||
|
advance_per_group: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MatchCard: React.FC<MatchCardProps> = ({
|
export const MatchCard: React.FC<MatchCardProps> = ({
|
||||||
match,
|
match,
|
||||||
orders,
|
orders,
|
||||||
showControls,
|
showControls,
|
||||||
|
groupConfig,
|
||||||
}) => {
|
}) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const editSheet = useSheet();
|
const editSheet = useSheet();
|
||||||
const { playTrack, pause } = useSpotifyPlayback();
|
const { playTrack, pause } = useSpotifyPlayback();
|
||||||
|
|
||||||
|
const getGroupLabel = useCallback((seed: number | undefined) => {
|
||||||
|
if (!seed || !groupConfig) return undefined;
|
||||||
|
|
||||||
|
const groupNames = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H'];
|
||||||
|
const teamsPerRank = groupConfig.num_groups;
|
||||||
|
|
||||||
|
const rankIndex = Math.floor((seed - 1) / teamsPerRank);
|
||||||
|
const groupIndex = (seed - 1) % teamsPerRank;
|
||||||
|
|
||||||
|
const rank = rankIndex + 1;
|
||||||
|
const groupName = groupNames[groupIndex] || `${groupIndex + 1}`;
|
||||||
|
const rankSuffix = rank === 1 ? '1st' : rank === 2 ? '2nd' : rank === 3 ? '3rd' : `${rank}th`;
|
||||||
|
|
||||||
|
return `${groupName} ${rankSuffix}`;
|
||||||
|
}, [groupConfig]);
|
||||||
|
|
||||||
const homeSlot = useMemo(
|
const homeSlot = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
from: orders[match.home_from_lid],
|
from: orders[match.home_from_lid],
|
||||||
@@ -39,8 +61,9 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
|||||||
match.home_cups !== undefined &&
|
match.home_cups !== undefined &&
|
||||||
match.away_cups !== undefined &&
|
match.away_cups !== undefined &&
|
||||||
match.home_cups > match.away_cups,
|
match.home_cups > match.away_cups,
|
||||||
|
groupLabel: !match.home && match.home_seed ? getGroupLabel(match.home_seed) : undefined,
|
||||||
}),
|
}),
|
||||||
[match]
|
[match, getGroupLabel]
|
||||||
);
|
);
|
||||||
const awaySlot = useMemo(
|
const awaySlot = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -54,8 +77,9 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
|||||||
match.away_cups !== undefined &&
|
match.away_cups !== undefined &&
|
||||||
match.home_cups !== undefined &&
|
match.home_cups !== undefined &&
|
||||||
match.away_cups > match.home_cups,
|
match.away_cups > match.home_cups,
|
||||||
|
groupLabel: !match.away && match.away_seed ? getGroupLabel(match.away_seed) : undefined,
|
||||||
}),
|
}),
|
||||||
[match]
|
[match, getGroupLabel]
|
||||||
);
|
);
|
||||||
|
|
||||||
const showToolbar = useMemo(
|
const showToolbar = useMemo(
|
||||||
@@ -179,8 +203,11 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
|||||||
data: match.id,
|
data: match.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Play walkout sequence after starting the match
|
// Skip announcements for regional tournaments
|
||||||
if (hasWalkoutData && match.home?.name && match.away?.name) {
|
const isRegional = match.tournament?.regional === true;
|
||||||
|
|
||||||
|
// Play walkout sequence after starting the match (only for non-regional tournaments)
|
||||||
|
if (!isRegional && hasWalkoutData && match.home?.name && match.away?.name) {
|
||||||
try {
|
try {
|
||||||
const homeTeam = match.home as Team;
|
const homeTeam = match.home as Team;
|
||||||
const awayTeam = match.away as Team;
|
const awayTeam = match.away as Team;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface MatchSlotProps {
|
|||||||
seed?: number;
|
seed?: number;
|
||||||
cups?: number;
|
cups?: number;
|
||||||
isWinner?: boolean;
|
isWinner?: boolean;
|
||||||
|
groupLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MatchSlot: React.FC<MatchSlotProps> = ({
|
export const MatchSlot: React.FC<MatchSlotProps> = ({
|
||||||
@@ -19,7 +20,8 @@ export const MatchSlot: React.FC<MatchSlotProps> = ({
|
|||||||
team,
|
team,
|
||||||
seed,
|
seed,
|
||||||
cups,
|
cups,
|
||||||
isWinner
|
isWinner,
|
||||||
|
groupLabel
|
||||||
}) => (
|
}) => (
|
||||||
<Flex
|
<Flex
|
||||||
align="stretch"
|
align="stretch"
|
||||||
@@ -34,7 +36,7 @@ export const MatchSlot: React.FC<MatchSlotProps> = ({
|
|||||||
<Flex align="center" gap={4} flex={1}>
|
<Flex align="center" gap={4} flex={1}>
|
||||||
{team ? (
|
{team ? (
|
||||||
<>
|
<>
|
||||||
<Text
|
<Text
|
||||||
size={team.name.length > 12 ? (team.name.length > 18 ? '10px' : '11px') : 'xs'}
|
size={team.name.length > 12 ? (team.name.length > 18 ? '10px' : '11px') : 'xs'}
|
||||||
truncate
|
truncate
|
||||||
style={{ minWidth: 0, flex: 1, lineHeight: "12px" }}
|
style={{ minWidth: 0, flex: 1, lineHeight: "12px" }}
|
||||||
@@ -43,18 +45,22 @@ export const MatchSlot: React.FC<MatchSlotProps> = ({
|
|||||||
</Text>
|
</Text>
|
||||||
{isWinner && (
|
{isWinner && (
|
||||||
<CrownIcon
|
<CrownIcon
|
||||||
size={14}
|
size={14}
|
||||||
weight="fill"
|
weight="fill"
|
||||||
style={{
|
style={{
|
||||||
color: 'gold',
|
color: 'gold',
|
||||||
marginLeft: '2px',
|
marginLeft: '2px',
|
||||||
marginTop: '-1px',
|
marginTop: '-1px',
|
||||||
filter: 'drop-shadow(0 1px 1px rgba(0,0,0,0.3))',
|
filter: 'drop-shadow(0 1px 1px rgba(0,0,0,0.3))',
|
||||||
flexShrink: 0
|
flexShrink: 0
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
) : groupLabel ? (
|
||||||
|
<Text c="dimmed" size="xs" truncate style={{ minWidth: 0, flex: 1 }}>
|
||||||
|
{groupLabel}
|
||||||
|
</Text>
|
||||||
) : from ? (
|
) : from ? (
|
||||||
<Text c="dimmed" size="xs" truncate style={{ minWidth: 0, flex: 1 }}>
|
<Text c="dimmed" size="xs" truncate style={{ minWidth: 0, flex: 1 }}>
|
||||||
{from_loser ? "Loser" : "Winner"} of Match {from}
|
{from_loser ? "Loser" : "Winner"} of Match {from}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { logger } from "@/lib/logger";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||||
import brackets from "@/features/bracket/utils";
|
import brackets from "@/features/bracket/utils";
|
||||||
import { MatchInput } from "@/features/matches/types";
|
import { Match, MatchInput } from "@/features/matches/types";
|
||||||
import { serverEvents } from "@/lib/events/emitter";
|
import { serverEvents } from "@/lib/events/emitter";
|
||||||
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
|
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
|
||||||
import { PlayerInfo } from "../players/types";
|
import { PlayerInfo } from "../players/types";
|
||||||
@@ -164,6 +164,189 @@ export const startMatch = createServerFn()
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const populateKnockoutBracket = createServerFn()
|
||||||
|
.inputValidator(z.object({
|
||||||
|
tournamentId: z.string(),
|
||||||
|
}))
|
||||||
|
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||||
|
.handler(async ({ data: { tournamentId } }) =>
|
||||||
|
toServerResult(async () => {
|
||||||
|
const tournament = await pbAdmin.getTournament(tournamentId);
|
||||||
|
if (!tournament) {
|
||||||
|
throw new Error("Tournament not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tournament.group_config) {
|
||||||
|
throw new Error("Tournament must have group_config");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await populateKnockoutBracketInternal(tournamentId, tournament.group_config);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
async function populateKnockoutBracketInternal(tournamentId: string, groupConfig: { num_groups: number; advance_per_group: number }) {
|
||||||
|
logger.info('Populating knockout bracket', { tournamentId });
|
||||||
|
|
||||||
|
const groups = await pbAdmin.getGroupsByTournament(tournamentId);
|
||||||
|
if (!groups || groups.length === 0) {
|
||||||
|
throw new Error("No groups found for tournament");
|
||||||
|
}
|
||||||
|
|
||||||
|
const qualifiedTeams: { teamId: string; groupOrder: number; rank: number }[] = [];
|
||||||
|
|
||||||
|
for (const group of groups) {
|
||||||
|
logger.info('Processing group', {
|
||||||
|
groupId: group.id,
|
||||||
|
groupOrder: group.order,
|
||||||
|
teamsCount: group.teams?.length,
|
||||||
|
teams: group.teams
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupMatches = await pbAdmin.getMatchesByGroup(group.id);
|
||||||
|
const completedMatches = groupMatches.filter(m => m.status === "ended");
|
||||||
|
|
||||||
|
const standings = new Map<string, { teamId: string; wins: number; losses: number; cups_for: number; cups_against: number; cup_differential: number }>();
|
||||||
|
|
||||||
|
for (const team of group.teams || []) {
|
||||||
|
// group.teams can be either team objects or just team ID strings
|
||||||
|
const teamId = typeof team === 'string' ? team : team.id;
|
||||||
|
standings.set(teamId, {
|
||||||
|
teamId,
|
||||||
|
wins: 0,
|
||||||
|
losses: 0,
|
||||||
|
cups_for: 0,
|
||||||
|
cups_against: 0,
|
||||||
|
cup_differential: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const match of completedMatches) {
|
||||||
|
if (!match.home || !match.away) continue;
|
||||||
|
|
||||||
|
const homeStanding = standings.get(match.home.id);
|
||||||
|
const awayStanding = standings.get(match.away.id);
|
||||||
|
|
||||||
|
if (homeStanding && awayStanding) {
|
||||||
|
homeStanding.cups_for += match.home_cups;
|
||||||
|
homeStanding.cups_against += match.away_cups;
|
||||||
|
awayStanding.cups_for += match.away_cups;
|
||||||
|
awayStanding.cups_against += match.home_cups;
|
||||||
|
|
||||||
|
if (match.home_cups > match.away_cups) {
|
||||||
|
homeStanding.wins++;
|
||||||
|
awayStanding.losses++;
|
||||||
|
} else {
|
||||||
|
awayStanding.wins++;
|
||||||
|
homeStanding.losses++;
|
||||||
|
}
|
||||||
|
|
||||||
|
homeStanding.cup_differential = homeStanding.cups_for - homeStanding.cups_against;
|
||||||
|
awayStanding.cup_differential = awayStanding.cups_for - awayStanding.cups_against;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedStandings = Array.from(standings.values()).sort((a, b) => {
|
||||||
|
if (b.wins !== a.wins) return b.wins - a.wins;
|
||||||
|
if (b.cup_differential !== a.cup_differential) return b.cup_differential - a.cup_differential;
|
||||||
|
return b.cups_for - a.cups_for;
|
||||||
|
});
|
||||||
|
|
||||||
|
const topTeams = sortedStandings.slice(0, groupConfig.advance_per_group);
|
||||||
|
logger.info('Top teams from group', {
|
||||||
|
groupId: group.id,
|
||||||
|
topTeams: topTeams.map(t => ({ teamId: t.teamId, wins: t.wins, cupDiff: t.cup_differential }))
|
||||||
|
});
|
||||||
|
|
||||||
|
topTeams.forEach((standing, index) => {
|
||||||
|
qualifiedTeams.push({
|
||||||
|
teamId: standing.teamId,
|
||||||
|
groupOrder: group.order,
|
||||||
|
rank: index + 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Qualified teams', { qualifiedTeams });
|
||||||
|
|
||||||
|
const orderedTeamIds: string[] = [];
|
||||||
|
const maxRank = groupConfig.advance_per_group;
|
||||||
|
|
||||||
|
for (let rank = 1; rank <= maxRank; rank++) {
|
||||||
|
const teamsAtRank = qualifiedTeams
|
||||||
|
.filter(t => t.rank === rank)
|
||||||
|
.sort((a, b) => a.groupOrder - b.groupOrder);
|
||||||
|
orderedTeamIds.push(...teamsAtRank.map(t => t.teamId));
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Ordered team IDs', { orderedTeamIds });
|
||||||
|
|
||||||
|
const tournament = await pbAdmin.getTournament(tournamentId);
|
||||||
|
const knockoutMatches = (tournament.matches || [])
|
||||||
|
.filter((m: Match) => m.round >= 0 && m.lid >= 0)
|
||||||
|
.sort((a: Match, b: Match) => a.lid - b.lid);
|
||||||
|
|
||||||
|
const seedToTeamId = new Map<number, string>();
|
||||||
|
orderedTeamIds.forEach((teamId, index) => {
|
||||||
|
seedToTeamId.set(index + 1, teamId);
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('Seed to team mapping', {
|
||||||
|
seedToTeamId: Array.from(seedToTeamId.entries()),
|
||||||
|
orderedTeamIds
|
||||||
|
});
|
||||||
|
|
||||||
|
let updatedCount = 0;
|
||||||
|
for (const match of knockoutMatches) {
|
||||||
|
if (match.round === 0) {
|
||||||
|
const updates: any = {};
|
||||||
|
|
||||||
|
if (match.home_seed) {
|
||||||
|
const teamId = seedToTeamId.get(match.home_seed);
|
||||||
|
logger.info('Looking up home seed', {
|
||||||
|
matchId: match.id,
|
||||||
|
home_seed: match.home_seed,
|
||||||
|
teamId
|
||||||
|
});
|
||||||
|
if (teamId) {
|
||||||
|
updates.home = teamId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match.away_seed) {
|
||||||
|
const teamId = seedToTeamId.get(match.away_seed);
|
||||||
|
logger.info('Looking up away seed', {
|
||||||
|
matchId: match.id,
|
||||||
|
away_seed: match.away_seed,
|
||||||
|
teamId
|
||||||
|
});
|
||||||
|
if (teamId) {
|
||||||
|
updates.away = teamId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.home && updates.away) {
|
||||||
|
updates.status = "ready";
|
||||||
|
} else if (updates.home || updates.away) {
|
||||||
|
updates.status = "tbd";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(updates).length > 0) {
|
||||||
|
logger.info('Updating match', { matchId: match.id, updates });
|
||||||
|
await pbAdmin.updateMatch(match.id, updates);
|
||||||
|
updatedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Updated matches', { updatedCount, totalKnockoutMatches: knockoutMatches.length });
|
||||||
|
|
||||||
|
await pbAdmin.updateTournament(tournamentId, {
|
||||||
|
phase: "knockout"
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('Knockout bracket populated successfully', { tournamentId });
|
||||||
|
}
|
||||||
|
|
||||||
const endMatchSchema = z.object({
|
const endMatchSchema = z.object({
|
||||||
matchId: z.string(),
|
matchId: z.string(),
|
||||||
home_cups: z.number(),
|
home_cups: z.number(),
|
||||||
@@ -190,19 +373,25 @@ export const endMatch = createServerFn()
|
|||||||
ot_count,
|
ot_count,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (match.lid === -1) {
|
||||||
|
serverEvents.emit("match", {
|
||||||
|
type: "match",
|
||||||
|
matchId: match.id,
|
||||||
|
tournamentId: match.tournament.id
|
||||||
|
});
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
const matchWinner = home_cups > away_cups ? match.home : match.away;
|
const matchWinner = home_cups > away_cups ? match.home : match.away;
|
||||||
const matchLoser = 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");
|
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);
|
const { winner, loser } = await pbAdmin.getChildMatches(matchId);
|
||||||
|
|
||||||
// reset match check
|
|
||||||
if (winner && winner.reset) {
|
if (winner && winner.reset) {
|
||||||
const awayTeamWon = match.away === matchWinner;
|
const awayTeamWon = match.away === matchWinner;
|
||||||
|
|
||||||
if (!awayTeamWon) {
|
if (!awayTeamWon) {
|
||||||
// Reset match is not necessary
|
|
||||||
logger.info("Deleting reset match", {
|
logger.info("Deleting reset match", {
|
||||||
resetMatchId: winner.id,
|
resetMatchId: winner.id,
|
||||||
currentMatchId: match.id,
|
currentMatchId: match.id,
|
||||||
@@ -214,7 +403,6 @@ export const endMatch = createServerFn()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// advance bracket
|
|
||||||
if (winner) {
|
if (winner) {
|
||||||
await pbAdmin.updateMatch(winner.id, {
|
await pbAdmin.updateMatch(winner.id, {
|
||||||
[winner.home_from_lid === match.lid ? "home" : "away"]: matchWinner.id,
|
[winner.home_from_lid === match.lid ? "home" : "away"]: matchWinner.id,
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
import React, { useMemo, useState } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import { Stack, Text, Card, Group as MantineGroup, Box, SimpleGrid, Tabs, Collapse, ActionIcon } from "@mantine/core";
|
import { Stack, Text, Card, Group as MantineGroup, Box, SimpleGrid, Tabs, Collapse, ActionIcon, Button, Alert } from "@mantine/core";
|
||||||
import { CaretCircleDown, CaretCircleUp } from "@phosphor-icons/react";
|
import { CaretCircleDownIcon, CaretCircleUpIcon } from "@phosphor-icons/react";
|
||||||
import { Match } from "@/features/matches/types";
|
import { Match } from "@/features/matches/types";
|
||||||
import { Group } from "../types";
|
import { Group } from "../types";
|
||||||
import GroupMatchCard from "./group-match-card";
|
import GroupMatchCard from "./group-match-card";
|
||||||
import TeamAvatar from "@/components/team-avatar";
|
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 {
|
interface GroupStageViewProps {
|
||||||
groups: Group[];
|
groups: Group[];
|
||||||
matches: Match[];
|
matches: Match[];
|
||||||
showControls?: boolean;
|
showControls?: boolean;
|
||||||
|
tournamentId?: string;
|
||||||
|
hasKnockoutBracket?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TeamStanding {
|
interface TeamStanding {
|
||||||
@@ -18,6 +24,8 @@ interface TeamStanding {
|
|||||||
team: any;
|
team: any;
|
||||||
wins: number;
|
wins: number;
|
||||||
losses: number;
|
losses: number;
|
||||||
|
cupsFor: number;
|
||||||
|
cupsAgainst: number;
|
||||||
cupDifference: number;
|
cupDifference: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,9 +33,33 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
|
|||||||
groups,
|
groups,
|
||||||
matches,
|
matches,
|
||||||
showControls,
|
showControls,
|
||||||
|
tournamentId,
|
||||||
|
hasKnockoutBracket,
|
||||||
}) => {
|
}) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [expandedTeams, setExpandedTeams] = useState<Record<string, boolean>>({});
|
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[] => {
|
const orderMatchesWithSpacing = (matches: Match[]): Match[] => {
|
||||||
if (matches.length <= 1) return matches;
|
if (matches.length <= 1) return matches;
|
||||||
|
|
||||||
@@ -99,6 +131,8 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
|
|||||||
team: team,
|
team: team,
|
||||||
wins: 0,
|
wins: 0,
|
||||||
losses: 0,
|
losses: 0,
|
||||||
|
cupsFor: 0,
|
||||||
|
cupsAgainst: 0,
|
||||||
cupDifference: 0,
|
cupDifference: 0,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -119,6 +153,11 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
|
|||||||
const homeCups = match.home_cups || 0;
|
const homeCups = match.home_cups || 0;
|
||||||
const awayCups = match.away_cups || 0;
|
const awayCups = match.away_cups || 0;
|
||||||
|
|
||||||
|
homeStanding.cupsFor += homeCups;
|
||||||
|
homeStanding.cupsAgainst += awayCups;
|
||||||
|
awayStanding.cupsFor += awayCups;
|
||||||
|
awayStanding.cupsAgainst += homeCups;
|
||||||
|
|
||||||
homeStanding.cupDifference += homeCups - awayCups;
|
homeStanding.cupDifference += homeCups - awayCups;
|
||||||
awayStanding.cupDifference += awayCups - homeCups;
|
awayStanding.cupDifference += awayCups - homeCups;
|
||||||
|
|
||||||
@@ -133,7 +172,8 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
|
|||||||
|
|
||||||
return Array.from(standings.values()).sort((a, b) => {
|
return Array.from(standings.values()).sort((a, b) => {
|
||||||
if (b.wins !== a.wins) return b.wins - a.wins;
|
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 showGenerateKnockoutButton = showControls && tournamentId && !hasKnockoutBracket && allGroupMatchesCompleted;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box p="md">
|
<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>
|
<Tabs.List mb="md" grow>
|
||||||
{sortedGroups.map((group) => {
|
{sortedGroups.map((group) => {
|
||||||
const groupMatches = matchesByGroup.get(group.id) || [];
|
const groupMatches = matchesByGroup.get(group.id) || [];
|
||||||
@@ -291,7 +253,7 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
|
|||||||
Standings ({standings.length})
|
Standings ({standings.length})
|
||||||
</Text>
|
</Text>
|
||||||
<ActionIcon variant="subtle" size="sm">
|
<ActionIcon variant="subtle" size="sm">
|
||||||
{expandedTeams[group.id] ? <CaretCircleUp size={16} /> : <CaretCircleDown size={16} />}
|
{expandedTeams[group.id] ? <CaretCircleUpIcon size={16} /> : <CaretCircleDownIcon size={16} />}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</MantineGroup>
|
</MantineGroup>
|
||||||
<Collapse in={expandedTeams[group.id]}>
|
<Collapse in={expandedTeams[group.id]}>
|
||||||
@@ -317,16 +279,21 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
|
|||||||
{standing.teamName}
|
{standing.teamName}
|
||||||
</Text>
|
</Text>
|
||||||
<MantineGroup gap="xs" wrap="nowrap">
|
<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}
|
{standing.wins}-{standing.losses}
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
size="xs"
|
size="xs"
|
||||||
c={standing.cupDifference > 0 ? "green" : standing.cupDifference < 0 ? "red" : "dimmed"}
|
c={standing.cupDifference > 0 ? "green" : standing.cupDifference < 0 ? "red" : "dimmed"}
|
||||||
fw={600}
|
fw={600}
|
||||||
|
miw={30}
|
||||||
|
ta="center"
|
||||||
>
|
>
|
||||||
{standing.cupDifference > 0 ? '+' : ''}{standing.cupDifference}
|
{standing.cupDifference > 0 ? '+' : ''}{standing.cupDifference}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" fw={400} miw={40} ta="center">
|
||||||
|
{standing.cupsFor}/{standing.cupsAgainst}
|
||||||
|
</Text>
|
||||||
</MantineGroup>
|
</MantineGroup>
|
||||||
</MantineGroup>
|
</MantineGroup>
|
||||||
))
|
))
|
||||||
@@ -339,7 +306,6 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
|
|||||||
</Collapse>
|
</Collapse>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Matches Grid */}
|
|
||||||
{groupMatches.length === 0 ? (
|
{groupMatches.length === 0 ? (
|
||||||
<Card withBorder radius="md" p="xl">
|
<Card withBorder radius="md" p="xl">
|
||||||
<Text size="sm" c="dimmed" ta="center">
|
<Text size="sm" c="dimmed" ta="center">
|
||||||
@@ -365,6 +331,7 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import {
|
|||||||
Select,
|
Select,
|
||||||
LoadingOverlay,
|
LoadingOverlay,
|
||||||
Alert,
|
Alert,
|
||||||
|
Title,
|
||||||
|
Divider,
|
||||||
|
Box,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { InfoIcon } from "@phosphor-icons/react";
|
import { InfoIcon } from "@phosphor-icons/react";
|
||||||
import { useServerMutation } from "@/lib/tanstack-query/hooks/use-server-mutation";
|
import { useServerMutation } from "@/lib/tanstack-query/hooks/use-server-mutation";
|
||||||
@@ -21,6 +24,10 @@ import {
|
|||||||
import GroupPreview from "./group-preview";
|
import GroupPreview from "./group-preview";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { tournamentKeys } from "../queries";
|
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 {
|
interface SetupGroupStageProps {
|
||||||
tournamentId: string;
|
tournamentId: string;
|
||||||
@@ -63,6 +70,74 @@ const SetupGroupStage: React.FC<SetupGroupStageProps> = ({
|
|||||||
}));
|
}));
|
||||||
}, [selectedConfig, teams, seed]);
|
}, [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({
|
const generateGroups = useServerMutation({
|
||||||
mutationFn: generateGroupStage,
|
mutationFn: generateGroupStage,
|
||||||
successMessage: "Group stage generated successfully!",
|
successMessage: "Group stage generated successfully!",
|
||||||
@@ -171,9 +246,38 @@ const SetupGroupStage: React.FC<SetupGroupStageProps> = ({
|
|||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<div style={{ flex: 1, overflow: "auto", maxHeight: "80vh" }}>
|
<div style={{ flex: 1, overflow: "auto", maxHeight: "80vh" }}>
|
||||||
{groupAssignments.length > 0 && (
|
<Stack gap="xl">
|
||||||
<GroupPreview groups={groupAssignments} />
|
{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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { superTokensAdminFunctionMiddleware, superTokensFunctionMiddleware } from "@/utils/supertokens";
|
import { superTokensAdminFunctionMiddleware, superTokensFunctionMiddleware } from "@/utils/supertokens";
|
||||||
import { createServerFn } from "@tanstack/react-start";
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
import { pbAdmin } from "@/lib/pocketbase/client";
|
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||||
import { tournamentInputSchema } from "@/features/tournaments/types";
|
import { tournamentInputSchema, GroupStanding } from "@/features/tournaments/types";
|
||||||
import { logger } from ".";
|
import { logger } from ".";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||||
import { serverFnLoggingMiddleware } from "@/utils/activities";
|
import { serverFnLoggingMiddleware } from "@/utils/activities";
|
||||||
import { fa } from "zod/v4/locales";
|
import { fa } from "zod/v4/locales";
|
||||||
|
import brackets from "@/features/bracket/utils";
|
||||||
|
import { MatchInput } from "@/features/matches/types";
|
||||||
|
import { generateSingleEliminationBracket } from "./utils/bracket-generator";
|
||||||
|
|
||||||
export const listTournaments = createServerFn()
|
export const listTournaments = createServerFn()
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
@@ -384,6 +387,215 @@ export const confirmTeamAssignments = createServerFn()
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
async function calculateGroupStandings(groupId: string): Promise<GroupStanding[]> {
|
||||||
|
const group = await pbAdmin.getGroup(groupId);
|
||||||
|
if (!group) {
|
||||||
|
throw new Error("Group not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = await pbAdmin.getMatchesByGroup(groupId);
|
||||||
|
const completedMatches = matches.filter(m => m.status === "ended");
|
||||||
|
|
||||||
|
const standings = new Map<string, GroupStanding>();
|
||||||
|
|
||||||
|
for (const team of group.teams || []) {
|
||||||
|
standings.set(team.id, {
|
||||||
|
team,
|
||||||
|
wins: 0,
|
||||||
|
losses: 0,
|
||||||
|
cups_for: 0,
|
||||||
|
cups_against: 0,
|
||||||
|
cup_differential: 0,
|
||||||
|
rank: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const match of completedMatches) {
|
||||||
|
if (!match.home || !match.away) continue;
|
||||||
|
|
||||||
|
const homeStanding = standings.get(match.home.id);
|
||||||
|
const awayStanding = standings.get(match.away.id);
|
||||||
|
|
||||||
|
if (homeStanding && awayStanding) {
|
||||||
|
homeStanding.cups_for += match.home_cups;
|
||||||
|
homeStanding.cups_against += match.away_cups;
|
||||||
|
awayStanding.cups_for += match.away_cups;
|
||||||
|
awayStanding.cups_against += match.home_cups;
|
||||||
|
|
||||||
|
if (match.home_cups > match.away_cups) {
|
||||||
|
homeStanding.wins++;
|
||||||
|
awayStanding.losses++;
|
||||||
|
} else {
|
||||||
|
awayStanding.wins++;
|
||||||
|
homeStanding.losses++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const standing of standings.values()) {
|
||||||
|
standing.cup_differential = standing.cups_for - standing.cups_against;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedStandings = Array.from(standings.values()).sort((a, b) => {
|
||||||
|
if (b.wins !== a.wins) return b.wins - a.wins;
|
||||||
|
if (b.cup_differential !== a.cup_differential) return b.cup_differential - a.cup_differential;
|
||||||
|
return b.cups_for - a.cups_for;
|
||||||
|
});
|
||||||
|
|
||||||
|
sortedStandings.forEach((standing, index) => {
|
||||||
|
standing.rank = index + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
return sortedStandings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getGroupStandings = createServerFn()
|
||||||
|
.inputValidator(z.string())
|
||||||
|
.middleware([superTokensFunctionMiddleware])
|
||||||
|
.handler(async ({ data: groupId }) =>
|
||||||
|
toServerResult(() => calculateGroupStandings(groupId))
|
||||||
|
);
|
||||||
|
|
||||||
|
export const generateKnockoutBracket = createServerFn()
|
||||||
|
.inputValidator(z.object({
|
||||||
|
tournamentId: z.string(),
|
||||||
|
}))
|
||||||
|
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||||
|
.handler(async ({ data }) =>
|
||||||
|
toServerResult(async () => {
|
||||||
|
logger.info('Generating knockout bracket', {
|
||||||
|
tournamentId: data.tournamentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const tournament = await pbAdmin.getTournament(data.tournamentId);
|
||||||
|
if (!tournament) {
|
||||||
|
throw new Error("Tournament not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tournament.phase !== "group_stage") {
|
||||||
|
throw new Error("Tournament must be in group_stage phase to generate knockout bracket");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tournament.group_config) {
|
||||||
|
throw new Error("Tournament must have group_config");
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = await pbAdmin.getGroupsByTournament(data.tournamentId);
|
||||||
|
if (!groups || groups.length === 0) {
|
||||||
|
throw new Error("No groups found for tournament");
|
||||||
|
}
|
||||||
|
|
||||||
|
const qualifiedTeams: { teamId: string; groupOrder: number; rank: number }[] = [];
|
||||||
|
|
||||||
|
for (const group of groups) {
|
||||||
|
const standings = await calculateGroupStandings(group.id);
|
||||||
|
|
||||||
|
const topTeams = standings.slice(0, tournament.group_config.advance_per_group);
|
||||||
|
for (const standing of topTeams) {
|
||||||
|
qualifiedTeams.push({
|
||||||
|
teamId: standing.team.id,
|
||||||
|
groupOrder: group.order,
|
||||||
|
rank: standing.rank,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderedTeamIds: string[] = [];
|
||||||
|
const maxRank = tournament.group_config.advance_per_group;
|
||||||
|
|
||||||
|
for (let rank = 1; rank <= maxRank; rank++) {
|
||||||
|
const teamsAtRank = qualifiedTeams
|
||||||
|
.filter(t => t.rank === rank)
|
||||||
|
.sort((a, b) => a.groupOrder - b.groupOrder);
|
||||||
|
orderedTeamIds.push(...teamsAtRank.map(t => t.teamId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamCount = orderedTeamIds.length;
|
||||||
|
|
||||||
|
let bracketTemplate: any;
|
||||||
|
if (Object.keys(brackets).includes(teamCount.toString())) {
|
||||||
|
bracketTemplate = brackets[teamCount as keyof typeof brackets];
|
||||||
|
} else {
|
||||||
|
bracketTemplate = generateSingleEliminationBracket(teamCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
const seedToTeamId = new Map<number, string>();
|
||||||
|
orderedTeamIds.forEach((teamId, index) => {
|
||||||
|
seedToTeamId.set(index + 1, teamId);
|
||||||
|
});
|
||||||
|
|
||||||
|
const matchInputs: MatchInput[] = [];
|
||||||
|
let matchLid = 1000;
|
||||||
|
|
||||||
|
bracketTemplate.winners.forEach((round: any[]) => {
|
||||||
|
round.forEach((match: any) => {
|
||||||
|
const matchInput: MatchInput = {
|
||||||
|
lid: matchLid++,
|
||||||
|
round: match.round,
|
||||||
|
order: match.order || 0,
|
||||||
|
reset: false,
|
||||||
|
bye: match.bye || false,
|
||||||
|
home_cups: 0,
|
||||||
|
away_cups: 0,
|
||||||
|
ot_count: 0,
|
||||||
|
home_from_lid: match.home_from_lid === null ? -1 : (match.home_from_lid + 1000),
|
||||||
|
away_from_lid: match.away_from_lid === null ? -1 : (match.away_from_lid + 1000),
|
||||||
|
home_from_loser: false,
|
||||||
|
away_from_loser: false,
|
||||||
|
is_losers_bracket: false,
|
||||||
|
match_type: "knockout",
|
||||||
|
status: "tbd",
|
||||||
|
tournament: data.tournamentId,
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchInput.home && matchInput.away) {
|
||||||
|
matchInput.status = "ready";
|
||||||
|
}
|
||||||
|
|
||||||
|
matchInputs.push(matchInput);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const createdMatches = await pbAdmin.createMatches(matchInputs);
|
||||||
|
|
||||||
|
const existingMatchIds = tournament.matches?.map(m => m.id) || [];
|
||||||
|
const newMatchIds = createdMatches.map(m => m.id);
|
||||||
|
await pbAdmin.updateTournamentMatches(data.tournamentId, [...existingMatchIds, ...newMatchIds]);
|
||||||
|
|
||||||
|
await pbAdmin.updateTournament(data.tournamentId, {
|
||||||
|
phase: "knockout"
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('Knockout bracket generated', {
|
||||||
|
tournamentId: data.tournamentId,
|
||||||
|
matchCount: createdMatches.length,
|
||||||
|
qualifiedTeamCount: qualifiedTeams.length
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
tournament,
|
||||||
|
matchCount: createdMatches.length,
|
||||||
|
matches: createdMatches
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
export const adminEnrollPlayer = createServerFn()
|
export const adminEnrollPlayer = createServerFn()
|
||||||
.inputValidator(z.object({
|
.inputValidator(z.object({
|
||||||
playerId: z.string(),
|
playerId: z.string(),
|
||||||
@@ -448,8 +660,7 @@ export const generateGroupStage = createServerFn()
|
|||||||
await pbAdmin.deleteGroupsByTournament(data.tournamentId);
|
await pbAdmin.deleteGroupsByTournament(data.tournamentId);
|
||||||
|
|
||||||
const createdGroups = [];
|
const createdGroups = [];
|
||||||
const allMatches = [];
|
const groupStageMatches = [];
|
||||||
let matchLid = 1;
|
|
||||||
|
|
||||||
for (const assignment of data.teamAssignments) {
|
for (const assignment of data.teamAssignments) {
|
||||||
const group = await pbAdmin.createGroup({
|
const group = await pbAdmin.createGroup({
|
||||||
@@ -464,10 +675,10 @@ export const generateGroupStage = createServerFn()
|
|||||||
const teamIds = assignment.teamIds;
|
const teamIds = assignment.teamIds;
|
||||||
for (let i = 0; i < teamIds.length; i++) {
|
for (let i = 0; i < teamIds.length; i++) {
|
||||||
for (let j = i + 1; j < teamIds.length; j++) {
|
for (let j = i + 1; j < teamIds.length; j++) {
|
||||||
allMatches.push({
|
groupStageMatches.push({
|
||||||
lid: matchLid++,
|
lid: -1,
|
||||||
round: -1,
|
round: -1,
|
||||||
order: allMatches.length,
|
order: groupStageMatches.length + 1,
|
||||||
reset: false,
|
reset: false,
|
||||||
bye: false,
|
bye: false,
|
||||||
home: teamIds[i],
|
home: teamIds[i],
|
||||||
@@ -489,6 +700,43 @@ export const generateGroupStage = createServerFn()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const knockoutTeamCount = data.groupConfig.num_groups * data.groupConfig.advance_per_group;
|
||||||
|
let bracketTemplate: any;
|
||||||
|
|
||||||
|
if (Object.keys(brackets).includes(knockoutTeamCount.toString())) {
|
||||||
|
bracketTemplate = brackets[knockoutTeamCount as keyof typeof brackets];
|
||||||
|
} else {
|
||||||
|
bracketTemplate = generateSingleEliminationBracket(knockoutTeamCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
const knockoutMatches: any[] = [];
|
||||||
|
|
||||||
|
bracketTemplate.winners.forEach((round: any[]) => {
|
||||||
|
round.forEach((match: any) => {
|
||||||
|
knockoutMatches.push({
|
||||||
|
lid: match.lid,
|
||||||
|
round: match.round,
|
||||||
|
order: match.order,
|
||||||
|
reset: false,
|
||||||
|
bye: match.bye || false,
|
||||||
|
home_seed: match.home_seed,
|
||||||
|
away_seed: match.away_seed,
|
||||||
|
home_cups: 0,
|
||||||
|
away_cups: 0,
|
||||||
|
ot_count: 0,
|
||||||
|
home_from_lid: match.home_from_lid !== null ? match.home_from_lid : -1,
|
||||||
|
away_from_lid: match.away_from_lid !== null ? match.away_from_lid : -1,
|
||||||
|
home_from_loser: false,
|
||||||
|
away_from_loser: false,
|
||||||
|
is_losers_bracket: false,
|
||||||
|
match_type: "knockout" as const,
|
||||||
|
status: "tbd" as const,
|
||||||
|
tournament: data.tournamentId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const allMatches = [...groupStageMatches, ...knockoutMatches];
|
||||||
const createdMatches = await pbAdmin.createMatches(allMatches);
|
const createdMatches = await pbAdmin.createMatches(allMatches);
|
||||||
|
|
||||||
const matchIds = createdMatches.map((match) => match.id);
|
const matchIds = createdMatches.map((match) => match.id);
|
||||||
@@ -499,10 +747,12 @@ export const generateGroupStage = createServerFn()
|
|||||||
group_config: data.groupConfig
|
group_config: data.groupConfig
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info('Group stage generated', {
|
logger.info('Group stage and knockout bracket generated', {
|
||||||
tournamentId: data.tournamentId,
|
tournamentId: data.tournamentId,
|
||||||
groupCount: createdGroups.length,
|
groupCount: createdGroups.length,
|
||||||
matchCount: createdMatches.length
|
groupMatchCount: groupStageMatches.length,
|
||||||
|
knockoutMatchCount: knockoutMatches.length,
|
||||||
|
totalMatchCount: createdMatches.length
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { TeamInfo } from "@/features/teams/types";
|
|||||||
import { Match } from "@/features/matches/types";
|
import { Match } from "@/features/matches/types";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export type TournamentFormat = "double_elim" | "group_single_elim";
|
export type TournamentFormat = "single_elim" | "double_elim" | "groups" | "swiss" | "swiss_bracket" | "round_robin";
|
||||||
export type TournamentPhase = "seeding" | "group_stage" | "knockout" | "completed";
|
export type TournamentPhase = "seeding" | "group_stage" | "knockout" | "completed";
|
||||||
|
|
||||||
export interface GroupConfig {
|
export interface GroupConfig {
|
||||||
@@ -23,6 +23,16 @@ export interface Group {
|
|||||||
updated: string;
|
updated: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GroupStanding {
|
||||||
|
team: TeamInfo;
|
||||||
|
wins: number;
|
||||||
|
losses: number;
|
||||||
|
cups_for: number;
|
||||||
|
cups_against: number;
|
||||||
|
cup_differential: number;
|
||||||
|
rank: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TournamentTeamStats {
|
export interface TournamentTeamStats {
|
||||||
id: string;
|
id: string;
|
||||||
team_id: string;
|
team_id: string;
|
||||||
@@ -89,7 +99,7 @@ export const tournamentInputSchema = z.object({
|
|||||||
start_time: z.string(),
|
start_time: z.string(),
|
||||||
end_time: z.string().optional(),
|
end_time: z.string().optional(),
|
||||||
regional: z.boolean().optional().default(false),
|
regional: z.boolean().optional().default(false),
|
||||||
format: z.enum(["double_elim", "group_single_elim"]).optional(),
|
format: z.enum(["single_elim", "double_elim", "groups", "swiss", "swiss_bracket", "round_robin"]).optional(),
|
||||||
phase: z.enum(["seeding", "group_stage", "knockout", "completed"]).optional(),
|
phase: z.enum(["seeding", "group_stage", "knockout", "completed"]).optional(),
|
||||||
group_config: z.object({
|
group_config: z.object({
|
||||||
num_groups: z.number(),
|
num_groups: z.number(),
|
||||||
|
|||||||
103
src/features/tournaments/utils/bracket-generator.ts
Normal file
103
src/features/tournaments/utils/bracket-generator.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
export interface BracketMatch {
|
||||||
|
lid: number;
|
||||||
|
round: number;
|
||||||
|
order: number | null;
|
||||||
|
bye: boolean;
|
||||||
|
home_seed?: number;
|
||||||
|
away_seed?: number;
|
||||||
|
home_from_lid: number | null;
|
||||||
|
home_from_loser: boolean;
|
||||||
|
away_from_lid: number | null;
|
||||||
|
away_from_loser: boolean;
|
||||||
|
reset: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BracketTemplate {
|
||||||
|
winners: BracketMatch[][];
|
||||||
|
losers: BracketMatch[][];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateSingleEliminationBracket(teamCount: number): BracketTemplate {
|
||||||
|
if (teamCount < 2) {
|
||||||
|
throw new Error("Need at least 2 teams for a bracket");
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextPowerOf2 = Math.pow(2, Math.ceil(Math.log2(teamCount)));
|
||||||
|
const totalRounds = Math.log2(nextPowerOf2);
|
||||||
|
|
||||||
|
const byesNeeded = nextPowerOf2 - teamCount;
|
||||||
|
const firstRoundMatches = Math.floor(teamCount / 2);
|
||||||
|
|
||||||
|
const winners: BracketMatch[][] = [];
|
||||||
|
let currentLid = 0;
|
||||||
|
let currentOrder = 1;
|
||||||
|
|
||||||
|
for (let round = 0; round < totalRounds; round++) {
|
||||||
|
const roundMatches: BracketMatch[] = [];
|
||||||
|
const matchesInRound = Math.pow(2, totalRounds - round - 1);
|
||||||
|
|
||||||
|
for (let matchIndex = 0; matchIndex < matchesInRound; matchIndex++) {
|
||||||
|
const match: BracketMatch = {
|
||||||
|
lid: currentLid++,
|
||||||
|
round,
|
||||||
|
order: currentOrder++,
|
||||||
|
bye: false,
|
||||||
|
home_from_lid: null,
|
||||||
|
home_from_loser: false,
|
||||||
|
away_from_lid: null,
|
||||||
|
away_from_loser: false,
|
||||||
|
reset: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (round === 0) {
|
||||||
|
const homePosition = matchIndex * 2;
|
||||||
|
const awayPosition = matchIndex * 2 + 1;
|
||||||
|
|
||||||
|
if (homePosition < teamCount && awayPosition < teamCount) {
|
||||||
|
match.home_seed = homePosition + 1;
|
||||||
|
match.away_seed = awayPosition + 1;
|
||||||
|
} else if (homePosition < teamCount) {
|
||||||
|
match.home_seed = homePosition + 1;
|
||||||
|
match.bye = true;
|
||||||
|
} else {
|
||||||
|
match.bye = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const prevRound = winners[round - 1];
|
||||||
|
const homeFeedIndex = matchIndex * 2;
|
||||||
|
const awayFeedIndex = matchIndex * 2 + 1;
|
||||||
|
|
||||||
|
if (homeFeedIndex < prevRound.length) {
|
||||||
|
match.home_from_lid = prevRound[homeFeedIndex].lid;
|
||||||
|
}
|
||||||
|
if (awayFeedIndex < prevRound.length) {
|
||||||
|
match.away_from_lid = prevRound[awayFeedIndex].lid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
roundMatches.push(match);
|
||||||
|
}
|
||||||
|
|
||||||
|
winners.push(roundMatches);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
winners,
|
||||||
|
losers: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateGroupMismatchSeeding(
|
||||||
|
numGroups: number,
|
||||||
|
teamsPerGroup: number
|
||||||
|
): number[] {
|
||||||
|
const seeding: number[] = [];
|
||||||
|
|
||||||
|
for (let rank = 0; rank < teamsPerGroup; rank++) {
|
||||||
|
for (let group = 0; group < numGroups; group++) {
|
||||||
|
seeding.push(group * teamsPerGroup + rank);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return seeding;
|
||||||
|
}
|
||||||
@@ -41,6 +41,14 @@ export function createGroupsService(pb: PocketBase) {
|
|||||||
for (const group of groups) {
|
for (const group of groups) {
|
||||||
await pb.collection("groups").delete(group.id);
|
await pb.collection("groups").delete(group.id);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getGroup(groupId: string): Promise<Group> {
|
||||||
|
logger.info("PocketBase | Getting group", { groupId });
|
||||||
|
const result = await pb.collection("groups").getOne(groupId, {
|
||||||
|
expand: "teams,teams.players"
|
||||||
|
});
|
||||||
|
return result as unknown as Group;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,5 +141,17 @@ export function createMatchesService(pb: PocketBase) {
|
|||||||
|
|
||||||
return results.map(match => transformMatch(match));
|
return results.map(match => transformMatch(match));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getMatchesByGroup(groupId: string): Promise<Match[]> {
|
||||||
|
logger.info("PocketBase | Getting matches for group", { groupId });
|
||||||
|
|
||||||
|
const results = await pb.collection("matches").getFullList({
|
||||||
|
filter: `group = "${groupId}"`,
|
||||||
|
expand: "tournament, home, away, home.players, away.players",
|
||||||
|
sort: "created",
|
||||||
|
});
|
||||||
|
|
||||||
|
return results.map(match => transformMatch(match));
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user