social overhaul

This commit is contained in:
2026-07-22 15:47:34 -07:00
parent 9db3fa697d
commit 86cda294f9
45 changed files with 3026 additions and 430 deletions
@@ -16,9 +16,10 @@ interface BracketViewProps {
};
renderMatch?: (match: Match) => React.ReactNode;
bottomOffset?: number;
nextUpMatchId?: string;
}
const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls, groupConfig, renderMatch, bottomOffset }) => {
const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls, groupConfig, renderMatch, bottomOffset, nextUpMatchId }) => {
const height = useAppShellHeight();
const viewportRef = useRef<HTMLDivElement>(null);
const hasAutoScrolled = useRef(false);
@@ -50,9 +51,10 @@ const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls, groupC
const matches = [...bracket.winners.flat(), ...bracket.losers.flat()].filter(
(match) => !match.bye
);
const target =
matches.find((match) => match.status === "started") ??
matches.find((match) => match.status === "ready");
const target = nextUpMatchId !== undefined
? matches.find((match) => match.id === nextUpMatchId)
: matches.find((match) => match.status === "started") ??
matches.find((match) => match.status === "ready");
if (!target) return;
const viewport = viewportRef.current;
@@ -78,7 +80,7 @@ const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls, groupC
? "auto"
: "smooth",
});
}, [bracket]);
}, [bracket, nextUpMatchId]);
return <Box pos="relative">
<ScrollArea
@@ -95,14 +97,14 @@ const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls, groupC
<Text fw={600} size="md" m={16}>
Winners Bracket
</Text>
<Bracket rounds={bracket.winners} orders={orders} showControls={showControls} groupConfig={groupConfig} renderMatch={renderMatch} onMatchTap={handleMatchTap} selectedMatchLid={selectedLid} />
<Bracket rounds={bracket.winners} orders={orders} showControls={showControls} groupConfig={groupConfig} renderMatch={renderMatch} onMatchTap={handleMatchTap} selectedMatchLid={selectedLid} nextUpMatchId={nextUpMatchId} />
</div>
{bracket.losers && bracket.losers.length > 0 && bracket.losers.some(round => round.length > 0) && (
<div>
<Text fw={600} size="md" m={16}>
Losers Bracket
</Text>
<Bracket rounds={bracket.losers} orders={orders} showControls={showControls} groupConfig={groupConfig} renderMatch={renderMatch} onMatchTap={handleMatchTap} selectedMatchLid={selectedLid} />
<Bracket rounds={bracket.losers} orders={orders} showControls={showControls} groupConfig={groupConfig} renderMatch={renderMatch} onMatchTap={handleMatchTap} selectedMatchLid={selectedLid} nextUpMatchId={nextUpMatchId} />
</div>
)}
{bottomOffset ? <div style={{ height: bottomOffset }} /> : null}
@@ -14,6 +14,7 @@ interface BracketProps {
renderMatch?: (match: Match) => React.ReactNode;
onMatchTap?: (match: Match) => void;
selectedMatchLid?: number | null;
nextUpMatchId?: string;
}
export const Bracket: React.FC<BracketProps> = ({
@@ -24,6 +25,7 @@ export const Bracket: React.FC<BracketProps> = ({
renderMatch,
onMatchTap,
selectedMatchLid,
nextUpMatchId,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const svgRef = useRef<SVGSVGElement>(null);
@@ -149,6 +151,7 @@ export const Bracket: React.FC<BracketProps> = ({
groupConfig={groupConfig}
onTap={onMatchTap}
selected={selectedMatchLid === match.lid}
nextUpMatchId={nextUpMatchId}
/>
)}
</div>
+169 -138
View File
@@ -1,4 +1,4 @@
import { ActionIcon, Card, Flex, Text, Indicator } from "@mantine/core";
import { ActionIcon, Card, Flex, Text, Indicator, Stack } from "@mantine/core";
import { PlayIcon, PencilIcon, SpeakerHighIcon } from "@phosphor-icons/react";
import React, { useCallback, useMemo } from "react";
import { MatchSlot } from "./match-slot";
@@ -6,6 +6,8 @@ import { Match } from "@/features/matches/types";
import { Team } from "@/features/teams/types";
import { useSheet } from "@/hooks/use-sheet";
import { MatchForm } from "./match-form";
import { MatchReport } from "./match-report";
import toast from "@/lib/sonner";
import Sheet from "@/components/sheet/sheet";
import { useServerMutation } from "@/lib/tanstack-query/hooks";
import { endMatch, startMatch } from "@/features/matches/server";
@@ -25,6 +27,7 @@ interface MatchCardProps {
};
onTap?: (match: Match) => void;
selected?: boolean;
nextUpMatchId?: string;
}
export const MatchCard: React.FC<MatchCardProps> = ({
@@ -34,6 +37,7 @@ export const MatchCard: React.FC<MatchCardProps> = ({
groupConfig,
onTap,
selected,
nextUpMatchId,
}) => {
const queryClient = useQueryClient();
const editSheet = useSheet();
@@ -41,6 +45,8 @@ export const MatchCard: React.FC<MatchCardProps> = ({
const canTap = !!(onTap && match.home && match.away);
const isNextUp = nextUpMatchId !== undefined && match.id === nextUpMatchId;
const homeSlot = useMemo(
() => ({
from: orders[match.home_from_lid],
@@ -80,7 +86,8 @@ export const MatchCard: React.FC<MatchCardProps> = ({
);
const showEditButton = useMemo(
() => showControls && match.status === "started",
() =>
showControls && (match.status === "started" || match.status === "ended"),
[showControls, match.status]
);
@@ -98,11 +105,21 @@ export const MatchCard: React.FC<MatchCardProps> = ({
const end = useServerMutation({
mutationFn: endMatch,
onSuccess: () => {
onSuccess: (data) => {
queryClient.invalidateQueries({
queryKey: tournamentKeys.details(match.tournament.id),
});
editSheet.close();
if (data?.downstreamReset) {
toast.error(
"Downstream matches were reset because the correction changed who advances."
);
}
if (data?.groupEditAfterKnockout) {
toast.error(
"Group result changed after the bracket was seeded — reseed the knockout stage if needed."
);
}
},
});
@@ -195,10 +212,8 @@ export const MatchCard: React.FC<MatchCardProps> = ({
data: match.id,
});
// Skip announcements for regional tournaments
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 {
const homeTeam = match.home as Team;
@@ -217,152 +232,168 @@ export const MatchCard: React.FC<MatchCardProps> = ({
}, [match, start, hasWalkoutData, playTeamWalkout, speak]);
return (
<Flex direction="row" align="center" justify="end" gap={8}>
<Text
c="dimmed"
fw="bolder"
px={6}
py={2}
style={{
backgroundColor: 'var(--mantine-color-body)'
}}
>
{match.order}
</Text>
<Flex align="stretch">
<Indicator
inline
processing={match.status === "started"}
color="red"
size={12}
disabled={match.status !== "started" || showEditButton}
<Stack gap={6} align="flex-end">
<Flex direction="row" align="center" justify="end" gap={8}>
<Text
c="dimmed"
fw="bolder"
px={6}
py={2}
style={{
backgroundColor: 'var(--mantine-color-body)'
}}
>
<Card
w={showToolbar || showEditButton ? 200 : 220}
withBorder
pos="relative"
className={canTap ? styles["tappable-card"] : undefined}
onClick={canTap ? () => onTap!(match) : undefined}
role={canTap ? "button" : undefined}
tabIndex={canTap ? 0 : undefined}
onKeyDown={
canTap
? (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onTap!(match);
}
}
: undefined
}
aria-label={
canTap
? `Match ${match.order}: ${match.home!.name} vs ${match.away!.name}`
: undefined
}
style={{
overflow: "visible",
backgroundColor: 'var(--mantine-color-body)',
borderColor: selected
? 'var(--mantine-primary-color-filled)'
: 'var(--mantine-color-default-border)',
boxShadow: 'var(--mantine-shadow-sm)',
}}
data-match-lid={match.lid}
{match.order}
</Text>
<Flex
align="stretch"
style={{
borderRadius: "var(--mantine-radius-default)",
boxShadow: isNextUp
? "0 0 0 1px var(--mantine-primary-color-filled), 0 0 12px var(--mantine-primary-color-light-hover)"
: undefined,
transition:
"box-shadow 200ms cubic-bezier(0.32, 0.72, 0, 1)",
}}
>
<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"
className={canTap ? styles["tappable-card"] : undefined}
onClick={canTap ? () => onTap!(match) : undefined}
role={canTap ? "button" : undefined}
tabIndex={canTap ? 0 : undefined}
onKeyDown={
canTap
? (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onTap!(match);
}
}
: undefined
}
aria-label={
canTap
? `Match ${match.order}: ${match.home!.name} vs ${match.away!.name}`
: undefined
}
style={{
overflow: "visible",
backgroundColor: 'var(--mantine-color-body)',
borderColor: selected
? 'var(--mantine-primary-color-filled)'
: 'var(--mantine-color-default-border)',
boxShadow: 'var(--mantine-shadow-sm)',
transition:
'border-color 200ms cubic-bezier(0.32, 0.72, 0, 1)',
}}
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>
)}
{match.reset && (
<Text
pos="absolute"
top={-20}
left={8}
size="xs"
c="dimmed"
fw="bold"
>
* If necessary
</Text>
)}
{showControls && match.status !== "tbd" && (
{showControls && match.status !== "tbd" && (
<ActionIcon
pos="absolute"
bottom={-2}
left={-26}
size="sm"
variant="subtle"
color="gray"
onClick={(e) => {
e.stopPropagation();
handleSpeakerClick();
}}
aria-label="Announce matchup"
>
<SpeakerHighIcon size={12} />
</ActionIcon>
)}
</Card>
</Indicator>
{showToolbar && (
<Flex direction="column" justify="center" align="center">
<ActionIcon
pos="absolute"
bottom={-2}
left={-26}
color="green"
onClick={handleStart}
loading={start.isPending}
size="sm"
variant="subtle"
color="gray"
onClick={(e) => {
e.stopPropagation();
handleSpeakerClick();
h="100%"
radius="sm"
ml={-4}
aria-label="Start match"
style={{
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
}}
aria-label="Announce matchup"
>
<SpeakerHighIcon size={12} />
<PlayIcon size={14} />
</ActionIcon>
)}
</Card>
</Indicator>
{showToolbar && (
<Flex direction="column" justify="center" align="center">
<ActionIcon
color="green"
onClick={handleStart}
loading={start.isPending}
size="sm"
h="100%"
radius="sm"
ml={-4}
aria-label="Start match"
style={{
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
}}
>
<PlayIcon size={14} />
</ActionIcon>
</Flex>
)}
</Flex>
)}
{showEditButton && (
<Flex direction="column" justify="center" align="center">
<ActionIcon
color="blue"
onClick={editSheet.open}
loading={end.isPending}
size="sm"
h="100%"
radius="sm"
ml={-4}
aria-label="Edit match score"
style={{
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
}}
>
<PencilIcon size={14} />
</ActionIcon>
</Flex>
)}
{showEditButton && (
<Flex direction="column" justify="center" align="center">
<ActionIcon
color={match.status === "ended" ? "gray" : "blue"}
onClick={editSheet.open}
loading={end.isPending}
size="sm"
h="100%"
radius="sm"
ml={-4}
aria-label="Edit match score"
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}
loading={end.isPending}
/>
</Sheet>
</Flex>
<Sheet title="Edit Match" {...editSheet.props}>
<MatchForm
match={match}
onSubmit={handleFormSubmit}
onCancel={editSheet.close}
loading={end.isPending}
/>
</Sheet>
</Flex>
{!showControls && <MatchReport match={match} compact />}
</Stack>
);
};
@@ -20,6 +20,7 @@ import TeamAvatar from "@/components/team-avatar";
import AnimatedScore from "@/features/matches/components/animated-score";
import EmojiBar from "@/features/reactions/components/emoji-bar";
import TeamHeadToHeadSheet from "@/features/matches/components/team-head-to-head-sheet";
import { MatchReport } from "./match-report";
import Sheet from "@/components/sheet/sheet";
import { useSheet } from "@/hooks/use-sheet";
@@ -149,6 +150,8 @@ const MatchDock = ({ match, onClose }: MatchDockProps) => {
ended={ended}
/>
<MatchReport match={match} />
<Group justify="space-between" wrap="nowrap" gap="sm">
<Box style={{ flex: 1, minWidth: 0 }}>
<Suspense
@@ -0,0 +1,303 @@
import { useCallback, useState } from "react";
import { Alert, Button, Flex, Group, Paper, Stack, Text } from "@mantine/core";
import { CheckIcon, LightningIcon } from "@phosphor-icons/react";
import { Match } from "@/features/matches/types";
import { useAuth } from "@/contexts/auth-context";
import {
useReportMatchScore,
useConfirmMatchScore,
useClearMatchReport,
} from "@/features/matches/queries";
import { useSheet } from "@/hooks/use-sheet";
import Sheet from "@/components/sheet/sheet";
import { MatchForm } from "./match-form";
import toast from "@/lib/sonner";
const teamId = (t: Match["home"]): string | undefined =>
!t ? undefined : typeof t === "string" ? t : t.id;
const teamPlayerIds = (t: Match["home"]): string[] =>
t && typeof t !== "string" ? (t.players ?? []).map((p) => p.id) : [];
const teamName = (t: Match["home"]): string | undefined =>
t && typeof t !== "string" ? t.name : undefined;
interface MatchReportProps {
match: Match;
compact?: boolean;
}
export const MatchReport: React.FC<MatchReportProps> = ({ match, compact }) => {
const { user } = useAuth();
const formSheet = useSheet();
const [acknowledgedReport, setAcknowledgedReport] = useState<string | null>(
null
);
const homeId = teamId(match.home);
const onHome = !!user?.id && teamPlayerIds(match.home).includes(user.id);
const onAway = !!user?.id && teamPlayerIds(match.away).includes(user.id);
const isParticipant = onHome || onAway;
const hasPending =
match.reported_by_team != null &&
match.reported_home_cups != null &&
match.reported_away_cups != null;
const reportedByHome = hasPending && match.reported_by_team === homeId;
const reportingTeam = hasPending
? reportedByHome
? match.home
: match.away
: undefined;
const opposingTeam = reportedByHome ? match.away : match.home;
const reportedByMine =
hasPending && ((reportedByHome && onHome) || (!reportedByHome && onAway));
const pendingSignature = hasPending
? [
match.reported_by_team,
match.reported_home_cups,
match.reported_away_cups,
match.reported_ot_count ?? 0,
].join(":")
: null;
const sheetTakenOver =
formSheet.isOpen &&
isParticipant &&
hasPending &&
!reportedByMine &&
pendingSignature !== acknowledgedReport;
const report = useReportMatchScore(user?.id, {
onSuccess: (data: any) => {
if (data?.finalized) {
toast.success("Score confirmed 🍻");
if (data.downstreamReset) {
toast.error(
"Downstream matches were reset because the correction changed who advances."
);
}
if (data.groupEditAfterKnockout) {
toast.error(
"Group result changed after the bracket was seeded — reseed the knockout stage if needed."
);
}
}
},
});
const confirm = useConfirmMatchScore({
onSuccess: (data: any) => {
if (data?.downstreamReset) {
toast.error(
"Downstream matches were reset because the correction changed who advances."
);
}
if (data?.groupEditAfterKnockout) {
toast.error(
"Group result changed after the bracket was seeded — reseed the knockout stage if needed."
);
}
},
});
const clear = useClearMatchReport();
const submitReport = useCallback(
(data: { home_cups: number; away_cups: number; ot_count: number }) => {
report.mutate({ data: { ...data, matchId: match.id } });
formSheet.close();
toast.success("Score submitted — waiting for the other team");
},
[report, match.id, formSheet.close]
);
const submitConfirm = useCallback(() => {
confirm.mutate({ data: { matchId: match.id } });
formSheet.close();
toast.success("Score confirmed 🍻");
}, [confirm, match.id, formSheet.close]);
const submitClear = useCallback(() => {
clear.mutate({ data: { matchId: match.id } });
toast.success("Report cleared");
}, [clear, match.id]);
const openForm = useCallback(() => {
setAcknowledgedReport(pendingSignature);
formSheet.open();
}, [pendingSignature, formSheet.open]);
const dismissTakeover = useCallback(() => {
setAcknowledgedReport(pendingSignature);
}, [pendingSignature]);
if (match.status !== "started") return null;
const scoreText = hasPending
? `${match.reported_home_cups}–${match.reported_away_cups}`
: "";
const size = compact ? "compact-xs" : "sm";
const formSheetEl = (
<Sheet
title={sheetTakenOver ? "Confirm Score" : "Report Score"}
{...formSheet.props}
>
{sheetTakenOver ? (
<Stack gap="md">
<Alert
variant="light"
color="yellow"
icon={<LightningIcon size={18} weight="fill" />}
title={`${teamName(reportingTeam) ?? "The other team"} just reported a score`}
>
Does this look right?
</Alert>
<Paper withBorder p="md">
<Group justify="center" gap="md" wrap="nowrap">
<Stack gap={2} align="center" style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={600} ta="center" lineClamp={1}>
{teamName(match.home) ?? "Home"}
</Text>
<Text fz={34} fw={700} lh={1.2}>
{match.reported_home_cups}
</Text>
</Stack>
<Text c="dimmed" fw={600}>
–
</Text>
<Stack gap={2} align="center" style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={600} ta="center" lineClamp={1}>
{teamName(match.away) ?? "Away"}
</Text>
<Text fz={34} fw={700} lh={1.2}>
{match.reported_away_cups}
</Text>
</Stack>
</Group>
</Paper>
<Stack gap="xs">
<Button
fullWidth
leftSection={<CheckIcon size={14} weight="bold" />}
onClick={submitConfirm}
loading={confirm.isPending}
>
Confirm {scoreText}
</Button>
<Button
fullWidth
variant="default"
onClick={dismissTakeover}
disabled={confirm.isPending}
>
Not right — enter our score
</Button>
</Stack>
</Stack>
) : (
<MatchForm
match={match}
onSubmit={submitReport}
onCancel={formSheet.close}
loading={report.isPending}
/>
)}
</Sheet>
);
if (isParticipant && !hasPending) {
return (
<>
<Button
size={size}
fullWidth={!compact}
onClick={openForm}
loading={report.isPending}
>
Report Score
</Button>
{formSheetEl}
</>
);
}
if (hasPending) {
if (reportedByMine) {
return (
<Flex
direction={compact ? "column" : "row"}
align={compact ? "stretch" : "center"}
justify="space-between"
gap={compact ? 4 : "xs"}
>
<Text size="xs" c="dimmed" lineClamp={2} style={{ minWidth: 0 }}>
Waiting for {teamName(opposingTeam) ?? "the other team"} to confirm{" "}
·{" "}
<Text span fw={600} c="bright">
{scoreText}
</Text>
</Text>
<Button
size={size}
variant="subtle"
color="red"
onClick={submitClear}
loading={clear.isPending}
style={{ flexShrink: 0 }}
>
Cancel
</Button>
</Flex>
);
}
if (isParticipant) {
return (
<>
<Stack gap={6}>
<Text size="xs" c="dimmed" lineClamp={2}>
{teamName(reportingTeam) ?? "Opponent"} reported{" "}
<Text span fw={600} c="bright">
{scoreText}
</Text>
</Text>
<Group gap="xs" wrap="nowrap">
<Button
size={size}
leftSection={<CheckIcon size={12} weight="bold" />}
onClick={submitConfirm}
loading={confirm.isPending}
style={{ flex: 1 }}
>
Confirm
</Button>
<Button
size={size}
variant="default"
onClick={openForm}
disabled={confirm.isPending}
>
Not right
</Button>
</Group>
</Stack>
{formSheetEl}
</>
);
}
return (
<Text size="xs" c="dimmed" lineClamp={2}>
Pending: {scoreText} · reported by {teamName(reportingTeam) ?? "a team"}
</Text>
);
}
return null;
};
+2 -8
View File
@@ -1,7 +1,7 @@
import GlitchAvatar from '@/components/glitch-avatar';
import useVisualViewportSize from '@/features/core/hooks/use-visual-viewport-size';
import { useCurrentTournament } from '@/features/tournaments/queries';
import { AppShell, Flex, Paper, em, Title, Text, Stack } from '@mantine/core';
import { AppShell, Flex, Paper, em, Title, Stack } from '@mantine/core';
import { useMediaQuery, useViewportSize } from '@mantine/hooks';
import { TrophyIcon } from '@phosphor-icons/react';
import { PropsWithChildren } from 'react';
@@ -27,7 +27,6 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
style={{ transition: 'padding-top 0.1s ease' }}
>
<Paper
shadow='md'
p='md'
w='100%'
maw='375px'
@@ -52,12 +51,7 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
>
<TrophyIcon size={32} />
</GlitchAvatar>
<Stack align='center' gap={2}>
<Title order={1} ta='center'>Welcome to FLXN</Title>
<Text size='sm' c='dimmed' fs='italic' ta='center'>
Amicus meus madidus
</Text>
</Stack>
<Title order={1} ta='center'>Welcome to FLXN</Title>
</Stack>
{children}
</Paper>
+137 -2
View File
@@ -1,5 +1,13 @@
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
import { getMatchesBetweenTeams, getMatchesBetweenPlayers } from "./server";
import { useQueryClient } from "@tanstack/react-query";
import { useServerMutation, useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
import { Match } from "@/features/matches/types";
import {
getMatchesBetweenTeams,
getMatchesBetweenPlayers,
reportMatchScore,
confirmMatchScore,
clearMatchReport,
} from "./server";
export const matchKeys = {
headToHeadTeams: (team1Id: string, team2Id: string) => ['matches', 'headToHead', 'teams', team1Id, team2Id] as const,
@@ -28,3 +36,130 @@ export const usePlayerHeadToHead = (player1Id: string, player2Id: string, enable
...matchQueries.headToHeadPlayers(player1Id, player2Id),
enabled,
});
const tournamentsRoot = { queryKey: ["tournaments"] as const };
const reportTeamId = (t: Match["home"]): string | undefined =>
!t ? undefined : typeof t === "string" ? t : t.id;
const reportTeamPlayerIds = (t: Match["home"]): string[] =>
t && typeof t !== "string" ? (t.players ?? []).map((p) => p.id) : [];
function patchTournamentsData(
data: unknown,
matchId: string,
patch: (match: Match) => Partial<Match>
): unknown {
if (!data) return data;
if (Array.isArray(data)) {
return data.map((entry) => patchTournamentsData(entry, matchId, patch));
}
const tournament = data as { matches?: Match[] };
if (!Array.isArray(tournament.matches)) return data;
let changed = false;
const matches = tournament.matches.map((m) => {
if (m.id !== matchId) return m;
changed = true;
return { ...m, ...patch(m) };
});
return changed ? { ...tournament, matches } : data;
}
type MatchVariables = { data: { matchId: string } };
type TournamentsSnapshot = [readonly unknown[], unknown][];
function useOptimisticMatchMutation<TData, TVariables extends MatchVariables>(
options: Parameters<typeof useServerMutation<TData, TVariables>>[0] & {
buildPatch: (match: Match, variables: TVariables) => Partial<Match>;
}
) {
const queryClient = useQueryClient();
const { buildPatch, ...mutationOptions } = options;
return useServerMutation<TData, TVariables>({
...mutationOptions,
onMutate: async (variables) => {
const { matchId } = variables.data;
await queryClient.cancelQueries(tournamentsRoot);
const previous = queryClient.getQueriesData(tournamentsRoot);
queryClient.setQueriesData(tournamentsRoot, (data: unknown) =>
patchTournamentsData(data, matchId, (match) => buildPatch(match, variables))
);
return { previous };
},
onError: (error, variables, onMutateResult, context) => {
if (context && typeof context === "object" && "previous" in context) {
const snapshot = (context as { previous: TournamentsSnapshot }).previous;
for (const [key, data] of snapshot) {
queryClient.setQueryData(key, data);
}
}
mutationOptions.onError?.(error, variables, onMutateResult, context);
},
onSettled: (data, error, variables, onMutateResult, context) => {
queryClient.invalidateQueries(tournamentsRoot);
mutationOptions.onSettled?.(data, error, variables, onMutateResult, context);
},
});
}
type MatchMutationOptions = {
onSuccess?: Parameters<typeof useServerMutation>[0]["onSuccess"];
};
export const useReportMatchScore = (userId?: string, options?: MatchMutationOptions) =>
useOptimisticMatchMutation({
mutationFn: reportMatchScore,
showSuccessToast: false,
...options,
buildPatch: (match, variables) => {
const onHome = !!userId && reportTeamPlayerIds(match.home).includes(userId);
const onAway = !!userId && reportTeamPlayerIds(match.away).includes(userId);
const callerTeamId = onHome
? reportTeamId(match.home)
: onAway
? reportTeamId(match.away)
: undefined;
return {
reported_home_cups: variables.data.home_cups,
reported_away_cups: variables.data.away_cups,
reported_ot_count: variables.data.ot_count,
reported_by_team: callerTeamId,
reported_by_player: userId,
};
},
});
export const useConfirmMatchScore = (options?: MatchMutationOptions) =>
useOptimisticMatchMutation({
mutationFn: confirmMatchScore,
showSuccessToast: false,
...options,
buildPatch: (match) => ({
status: "ended",
home_cups: match.reported_home_cups ?? match.home_cups,
away_cups: match.reported_away_cups ?? match.away_cups,
ot_count: match.reported_ot_count ?? match.ot_count,
reported_home_cups: undefined,
reported_away_cups: undefined,
reported_ot_count: undefined,
reported_by_team: undefined,
reported_by_player: undefined,
}),
});
export const useClearMatchReport = (options?: MatchMutationOptions) =>
useOptimisticMatchMutation({
mutationFn: clearMatchReport,
...options,
buildPatch: () => ({
reported_home_cups: undefined,
reported_away_cups: undefined,
reported_ot_count: undefined,
reported_by_team: undefined,
reported_by_player: undefined,
}),
});
+469 -62
View File
@@ -212,7 +212,6 @@ async function populateKnockoutBracketInternal(tournamentId: string, groupConfig
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,
@@ -456,6 +455,344 @@ async function populateKnockoutBracketInternal(tournamentId: string, groupConfig
logger.info('Knockout bracket populated successfully', { tournamentId });
}
const teamId = (t: Match["home"] | string | undefined): string | undefined =>
!t ? undefined : typeof t === "string" ? t : t.id;
const winnerId = (m: Match): string | undefined =>
m.home_cups > m.away_cups ? teamId(m.home) : teamId(m.away);
const loserId = (m: Match): string | undefined =>
m.home_cups > m.away_cups ? teamId(m.away) : teamId(m.home);
function assertValidScore(home_cups: number, away_cups: number, ot_count: number) {
if (home_cups === away_cups) throw new Error("A match cannot end in a tie");
if (ot_count > 0) return;
if (home_cups !== 10 && away_cups !== 10)
throw new Error("At least one team must have 10 cups");
if (home_cups === 10 && away_cups === 10)
throw new Error("Both teams cannot have 10 cups");
}
const CLEARED_RESULT = {
home_cups: 0,
away_cups: 0,
ot_count: 0,
start_time: "",
end_time: "",
reported_home_cups: null,
reported_away_cups: null,
reported_ot_count: null,
reported_by_team: null,
reported_by_player: null,
} as const;
function generateRecordId(): string {
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
let id = "";
for (let i = 0; i < 15; i++)
id += alphabet[Math.floor(Math.random() * alphabet.length)];
return id;
}
type BracketOp =
| { kind: "updateMatch"; id: string; data: Record<string, unknown> }
| { kind: "createMatch"; data: Record<string, unknown> }
| { kind: "deleteMatch"; id: string }
| { kind: "updateTournamentMatches"; tournamentId: string; matchIds: string[] };
function isBatchApiUnavailable(error: unknown): boolean {
if (!error || typeof error !== "object") return false;
const e = error as { status?: unknown; url?: unknown };
const url = typeof e.url === "string" ? e.url : "";
if (!url.includes("/api/batch")) return false;
return e.status === 403 || e.status === 404;
}
async function commitBracketWrites(ops: BracketOp[]): Promise<void> {
const batch = pbAdmin.createBatch();
for (const op of ops) {
switch (op.kind) {
case "updateMatch":
batch.collection("matches").update(op.id, op.data);
break;
case "createMatch":
batch.collection("matches").create(op.data);
break;
case "deleteMatch":
batch.collection("matches").delete(op.id);
break;
case "updateTournamentMatches":
batch
.collection("tournaments")
.update(op.tournamentId, { matches: op.matchIds });
break;
}
}
try {
await batch.send();
return;
} catch (error) {
if (!isBatchApiUnavailable(error)) throw error;
logger.warn(
"PocketBase batch API is disabled; finalizing match with sequential writes (reduced atomicity). Enable Settings > Application > batch API to restore transactional bracket updates.",
);
}
for (const op of ops) {
switch (op.kind) {
case "updateMatch":
await pbAdmin.updateMatch(op.id, op.data as Partial<MatchInput>);
break;
case "createMatch":
await pbAdmin.createMatch(op.data as unknown as MatchInput);
break;
case "deleteMatch":
await pbAdmin.deleteMatch(op.id);
break;
case "updateTournamentMatches":
await pbAdmin.updateTournamentMatches(op.tournamentId, op.matchIds);
break;
}
}
}
async function rederiveBracket(
tournamentId: string,
sourceId: string,
score: { home_cups: number; away_cups: number; ot_count: number }
): Promise<{ source: Match; downstreamReset: boolean }> {
const all = await pbAdmin.getMatchesByTournament(tournamentId);
const knockout = all.filter((m) => m.lid >= 0);
const byLid = new Map<number, Match>();
for (const m of knockout) byLid.set(m.lid, m);
const original = all.find((m) => m.id === sourceId);
if (!original) throw new Error("Match not found");
const ops: BracketOp[] = [];
let warn = false;
const nowIso = new Date().toISOString();
ops.push({
kind: "updateMatch",
id: sourceId,
data: {
end_time: nowIso,
status: "ended",
home_cups: score.home_cups,
away_cups: score.away_cups,
ot_count: score.ot_count,
reported_home_cups: null,
reported_away_cups: null,
reported_ot_count: null,
reported_by_team: null,
reported_by_player: null,
},
});
const source: Match = {
...original,
status: "ended",
home_cups: score.home_cups,
away_cups: score.away_cups,
ot_count: score.ot_count,
end_time: nowIso,
reported_home_cups: undefined,
reported_away_cups: undefined,
reported_ot_count: undefined,
reported_by_team: undefined,
reported_by_player: undefined,
};
byLid.set(source.lid, source);
const resolveFeeder = (lid: number, fromLoser: boolean): string | undefined => {
const f = byLid.get(lid);
if (!f || f.bye || f.status !== "ended") return undefined;
return fromLoser ? loserId(f) : winnerId(f);
};
const hasLosers = knockout.some((m) => m.is_losers_bracket);
const resetMatch = knockout.find((m) => m.reset);
const grandFinal = hasLosers
? knockout
.filter((m) => !m.reset && !m.bye)
.reduce<Match | undefined>(
(hi, cur) => (!hi || cur.lid > hi.lid ? cur : hi),
undefined
)
: undefined;
const ordered = knockout
.filter((m) => !m.bye && !m.reset)
.sort((a, b) => a.lid - b.lid);
for (const m of ordered) {
if (m.id === sourceId) continue;
const homeFed = m.home_from_lid >= 0;
const awayFed = m.away_from_lid >= 0;
if (!homeFed && !awayFed) continue;
const curHome = teamId(m.home);
const curAway = teamId(m.away);
const expHome = homeFed ? resolveFeeder(m.home_from_lid, m.home_from_loser) : curHome;
const expAway = awayFed ? resolveFeeder(m.away_from_lid, m.away_from_loser) : curAway;
const expStatus = expHome && expAway ? "ready" : "tbd";
const played = m.status === "started" || m.status === "ended";
const changed =
(homeFed && expHome !== curHome) || (awayFed && expAway !== curAway);
if (played && changed) {
ops.push({
kind: "updateMatch",
id: m.id,
data: {
home: expHome ?? null,
away: expAway ?? null,
status: expStatus,
...CLEARED_RESULT,
},
});
byLid.set(m.lid, { ...m, status: expStatus });
warn = true;
} else if (!played && (changed || m.status !== expStatus)) {
ops.push({
kind: "updateMatch",
id: m.id,
data: {
home: expHome ?? null,
away: expAway ?? null,
status: expStatus,
},
});
byLid.set(m.lid, { ...m, status: expStatus });
}
}
if (grandFinal) {
const gf = byLid.get(grandFinal.lid)!;
const gfEnded = gf.status === "ended";
const resetNeeded = gfEnded && winnerId(gf) === teamId(gf.away);
if (resetNeeded) {
const expHome = winnerId(gf);
const expAway = loserId(gf);
if (resetMatch && resetMatch.id !== sourceId) {
const played =
resetMatch.status === "started" || resetMatch.status === "ended";
const changed =
teamId(resetMatch.home) !== expHome || teamId(resetMatch.away) !== expAway;
if (played && changed) {
ops.push({
kind: "updateMatch",
id: resetMatch.id,
data: {
home: expHome ?? null,
away: expAway ?? null,
status: "ready",
...CLEARED_RESULT,
},
});
warn = true;
} else if (!played && (changed || resetMatch.status !== "ready")) {
ops.push({
kind: "updateMatch",
id: resetMatch.id,
data: {
home: expHome ?? null,
away: expAway ?? null,
status: "ready",
},
});
}
} else if (!resetMatch) {
const newId = generateRecordId();
ops.push({
kind: "createMatch",
data: {
id: newId,
lid: gf.lid + 1,
order: gf.order + 1,
round: gf.round + 1,
reset: true,
bye: false,
home_cups: 0,
away_cups: 0,
ot_count: 0,
home_from_lid: gf.lid,
away_from_lid: gf.lid,
home_from_loser: false,
away_from_loser: true,
is_losers_bracket: false,
status: "ready",
tournament: tournamentId,
home: expHome ?? undefined,
away: expAway ?? undefined,
},
});
ops.push({
kind: "updateTournamentMatches",
tournamentId,
matchIds: [...all.map((m) => m.id), newId],
});
}
} else if (resetMatch && resetMatch.id !== sourceId) {
if (resetMatch.status === "started" || resetMatch.status === "ended") {
warn = true;
}
ops.push({ kind: "deleteMatch", id: resetMatch.id });
}
}
await commitBracketWrites(ops);
return { source, downstreamReset: warn };
}
async function finalizeMatch(
matchId: string,
{ home_cups, away_cups, ot_count }: { home_cups: number; away_cups: number; ot_count: number }
): Promise<{ match: Match; downstreamReset: boolean; groupEditAfterKnockout: boolean }> {
assertValidScore(home_cups, away_cups, ot_count);
const existing = await pbAdmin.getMatch(matchId);
if (!existing) throw new Error("Match not found");
const tournamentId = existing.tournament.id;
if (existing.lid === -1) {
const source = await pbAdmin.updateMatch(matchId, {
end_time: new Date().toISOString(),
status: "ended",
home_cups,
away_cups,
ot_count,
reported_home_cups: null,
reported_away_cups: null,
reported_ot_count: null,
reported_by_team: null,
reported_by_player: null,
});
const all = await pbAdmin.getMatchesByTournament(tournamentId);
const groupEditAfterKnockout = all.some(
(m) => m.round >= 0 && (teamId(m.home) || teamId(m.away))
);
emitServerEvent({ type: "match", matchId: source.id, tournamentId });
return { match: source, downstreamReset: false, groupEditAfterKnockout };
}
const { source, downstreamReset } = await rederiveBracket(tournamentId, matchId, {
home_cups,
away_cups,
ot_count,
});
emitServerEvent({ type: "match", matchId: source.id, tournamentId });
return { match: source, downstreamReset, groupEditAfterKnockout: false };
}
const endMatchSchema = z.object({
matchId: z.string(),
home_cups: z.number(),
@@ -469,83 +806,153 @@ export const endMatch = createServerFn()
toServerResult(async () => {
logger.info("Ending match", matchId);
let match = await pbAdmin.getMatch(matchId);
const match = await pbAdmin.getMatch(matchId);
if (!match) {
throw new Error("Match not found");
}
match = await pbAdmin.updateMatch(matchId, {
end_time: new Date().toISOString(),
status: "ended",
home_cups,
away_cups,
ot_count,
return finalizeMatch(matchId, { home_cups, away_cups, ot_count });
})
);
const teamPlayerIds = (team: Match["home"]): string[] =>
team && typeof team !== "string" ? (team.players ?? []).map((p) => p.id) : [];
const reportScoreSchema = z.object({
matchId: z.string(),
home_cups: z.number(),
away_cups: z.number(),
ot_count: z.number(),
});
export const reportMatchScore = createServerFn()
.validator(reportScoreSchema)
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ data: { matchId, home_cups, away_cups, ot_count }, context }) =>
toServerResult(async () => {
const player = await pbAdmin.getPlayerByAuthId(context.userAuthId);
if (!player?.id) throw new Error("Player not found");
const match = await pbAdmin.getMatch(matchId);
if (!match) throw new Error("Match not found");
if (match.status !== "started")
throw new Error("Scores can only be reported once the match has started");
const onHome = teamPlayerIds(match.home).includes(player.id);
const onAway = teamPlayerIds(match.away).includes(player.id);
if (!onHome && !onAway)
throw new Error("You are not a player in this match");
if (onHome && onAway)
throw new Error(
"You are on both teams in this match and cannot report or confirm its score"
);
assertValidScore(home_cups, away_cups, ot_count);
const callerTeamId = onHome ? teamId(match.home) : teamId(match.away);
const hasPending =
match.reported_by_team != null &&
match.reported_home_cups != null &&
match.reported_away_cups != null;
const fromOppositeTeam =
hasPending && match.reported_by_team !== callerTeamId;
const identical =
hasPending &&
match.reported_home_cups === home_cups &&
match.reported_away_cups === away_cups &&
(match.reported_ot_count ?? 0) === ot_count;
if (fromOppositeTeam && identical) {
const result = await finalizeMatch(matchId, { home_cups, away_cups, ot_count });
return {
success: true as const,
finalized: true as const,
downstreamReset: result.downstreamReset,
groupEditAfterKnockout: result.groupEditAfterKnockout,
};
}
await pbAdmin.updateMatch(matchId, {
reported_home_cups: home_cups,
reported_away_cups: away_cups,
reported_ot_count: ot_count,
reported_by_team: callerTeamId,
reported_by_player: player.id,
});
if (match.lid === -1) {
emitServerEvent({
type: "match",
matchId: match.id,
tournamentId: match.tournament.id
});
return match;
}
emitServerEvent({ type: "match", matchId, tournamentId: match.tournament.id });
return { success: true as const, finalized: false as const };
})
);
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");
const matchIdSchema = z.object({ matchId: z.string() });
const { winner, loser } = await pbAdmin.getChildMatches(matchId);
export const confirmMatchScore = createServerFn()
.validator(matchIdSchema)
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ data: { matchId }, context }) =>
toServerResult(async () => {
const player = await pbAdmin.getPlayerByAuthId(context.userAuthId);
if (!player?.id) throw new Error("Player not found");
if (winner && winner.reset) {
const awayTeamWon = match.away === matchWinner;
const match = await pbAdmin.getMatch(matchId);
if (!match) throw new Error("Match not found");
if (match.status !== "started")
throw new Error("Match is not in progress");
if (!match.reported_by_team)
throw new Error("No score has been reported yet");
if (match.reported_home_cups == null || match.reported_away_cups == null)
throw new Error("The reported score is incomplete");
if (!awayTeamWon) {
logger.info("Deleting reset match", {
resetMatchId: winner.id,
currentMatchId: match.id,
reason: "not necessary",
});
const onHome = teamPlayerIds(match.home).includes(player.id);
const onAway = teamPlayerIds(match.away).includes(player.id);
if (onHome && onAway)
throw new Error(
"You are on both teams in this match and cannot report or confirm its score"
);
await pbAdmin.deleteMatch(winner.id);
emitServerEvent({
type: "match",
matchId: match.id,
tournamentId: match.tournament.id
});
return match;
}
}
const homeId = teamId(match.home);
const reportedByHome = match.reported_by_team === homeId;
const opposingTeam = reportedByHome ? match.away : match.home;
if (winner) {
await pbAdmin.updateMatch(winner.id, {
[winner.home_from_lid === match.lid ? "home" : "away"]: matchWinner.id,
status:
(winner.home_from_lid === match.lid && winner.away) ||
(winner.away_from_lid === match.lid && winner.home)
? "ready"
: "tbd",
});
}
if (!teamPlayerIds(opposingTeam).includes(player.id))
throw new Error("Only a player on the other team can confirm this score");
if (loser) {
await pbAdmin.updateMatch(loser.id, {
[loser.home_from_lid === match.lid ? "home" : "away"]: matchLoser.id,
status:
(loser.home_from_lid === match.lid && loser.away) ||
(loser.away_from_lid === match.lid && loser.home)
? "ready"
: "tbd",
});
}
return finalizeMatch(matchId, {
home_cups: match.reported_home_cups,
away_cups: match.reported_away_cups,
ot_count: match.reported_ot_count ?? 0,
});
})
);
emitServerEvent({
type: "match",
matchId: match.id,
tournamentId: match.tournament.id
export const clearMatchReport = createServerFn()
.validator(matchIdSchema)
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ data: { matchId }, context }) =>
toServerResult(async () => {
const player = await pbAdmin.getPlayerByAuthId(context.userAuthId);
if (!player?.id) throw new Error("Player not found");
const match = await pbAdmin.getMatch(matchId);
if (!match) throw new Error("Match not found");
const onHome = teamPlayerIds(match.home).includes(player.id);
const onAway = teamPlayerIds(match.away).includes(player.id);
if (!onHome && !onAway)
throw new Error("You are not a player in this match");
await pbAdmin.updateMatch(matchId, {
reported_home_cups: null,
reported_away_cups: null,
reported_ot_count: null,
reported_by_team: null,
reported_by_player: null,
});
return match;
emitServerEvent({ type: "match", matchId, tournamentId: match.tournament.id });
return { success: true };
})
);
+12 -2
View File
@@ -32,6 +32,11 @@ export interface Match {
away_seed?: number;
match_type?: MatchType;
group?: string;
reported_home_cups?: number;
reported_away_cups?: number;
reported_ot_count?: number;
reported_by_team?: string;
reported_by_player?: string;
}
export const matchInputSchema = z.object({
@@ -52,12 +57,17 @@ export const matchInputSchema = z.object({
is_losers_bracket: z.boolean().optional().default(false),
status: z.enum(["tbd", "ready", "started", "ended"]).optional().default("tbd"),
tournament: z.string().min(1),
home: z.string().min(1).optional(),
away: z.string().min(1).optional(),
home: z.string().min(1).nullable().optional(),
away: z.string().min(1).nullable().optional(),
home_seed: z.number().int().min(1).optional(),
away_seed: z.number().int().min(1).optional(),
match_type: z.enum(["group_stage", "knockout", "winners", "losers", "bracket"]).optional(),
group: z.string().optional(),
reported_home_cups: z.number().int().min(0).nullable().optional(),
reported_away_cups: z.number().int().min(0).nullable().optional(),
reported_ot_count: z.number().int().min(0).nullable().optional(),
reported_by_team: z.string().nullable().optional(),
reported_by_player: z.string().nullable().optional(),
});
export type MatchInput = z.infer<typeof matchInputSchema>;
@@ -0,0 +1,82 @@
import { Box, Title, Stack, Switch, Button, Text, Group } from "@mantine/core";
import { useWebPush } from "@/hooks/use-web-push";
import toast from "@/lib/sonner";
export function NotificationsSection() {
const { supported, configured, permission, subscribed, busy, enable, disable, sendTest } =
useWebPush();
if (!supported || !configured) {
return (
<Box
px="md"
py="sm"
style={{ borderBottom: "1px solid var(--mantine-color-default-border)" }}
>
<Title order={3}>Notifications</Title>
<Text size="sm" c="dimmed" mt="xs">
{supported
? "Notifications aren't available right now."
: "This device doesn't support push notifications. On iOS, install the app to your Home Screen first."}
</Text>
</Box>
);
}
const handleToggle = async (checked: boolean) => {
if (checked) {
const ok = await enable();
if (ok) {
toast.success("Notifications enabled on this device");
} else if (permission === "denied") {
toast.error(
"Notifications are blocked. Enable them in your browser settings."
);
} else {
toast.error("Couldn't enable notifications");
}
} else {
const ok = await disable();
if (ok) toast.success("Notifications disabled on this device");
else toast.error("Couldn't disable notifications");
}
};
const handleTest = async () => {
const ok = await sendTest();
if (ok) toast.success("Test notification sent");
else toast.error("Couldn't send test notification");
};
return (
<Box
px="md"
py="sm"
style={{ borderBottom: "1px solid var(--mantine-color-default-border)" }}
>
<Title order={3}>Notifications</Title>
<Stack mt="xs" gap="sm">
<Switch
checked={subscribed}
onChange={(e) => handleToggle(e.currentTarget.checked)}
disabled={busy}
label="Enable notifications on this device"
/>
{subscribed && (
<Group>
<Button
variant="light"
size="xs"
onClick={handleTest}
loading={busy}
>
Send test notification
</Button>
</Group>
)}
</Stack>
</Box>
);
}
export default NotificationsSection;
@@ -15,12 +15,14 @@ import AnimatedScore from "@/features/matches/components/animated-score";
interface GroupMatchCardProps {
match: Match;
showControls?: boolean;
nextUpMatchId?: string;
}
const GroupMatchCard: React.FC<GroupMatchCardProps> = ({ match, showControls }) => {
const GroupMatchCard: React.FC<GroupMatchCardProps> = ({ match, showControls, nextUpMatchId }) => {
const queryClient = useQueryClient();
const editSheet = useSheet();
const isNextUp = nextUpMatchId !== undefined && match.id === nextUpMatchId;
const isReady = match.status === "ready";
const isStarted = match.status === "started";
const isEnded = match.status === "ended";
@@ -73,7 +75,17 @@ const GroupMatchCard: React.FC<GroupMatchCardProps> = ({ match, showControls })
return (
<>
<Flex direction="row" align="stretch">
<Flex
direction="row"
align="stretch"
style={{
borderRadius: "var(--mantine-radius-md)",
boxShadow: isNextUp
? "0 0 0 1px var(--mantine-primary-color-filled), 0 0 12px var(--mantine-primary-color-light-hover)"
: undefined,
transition: "box-shadow 200ms cubic-bezier(0.32, 0.72, 0, 1)",
}}
>
<Indicator
inline
processing={isStarted}
@@ -9,6 +9,7 @@ import { useServerMutation } from "@/lib/tanstack-query/hooks/use-server-mutatio
import { populateKnockoutBracket } from "@/features/matches/server";
import { useQueryClient } from "@tanstack/react-query";
import { tournamentKeys } from "../queries";
import { computeGroupStageOrder } from "../utils/match-queue";
interface GroupStageViewProps {
groups: Group[];
@@ -18,6 +19,7 @@ interface GroupStageViewProps {
hasKnockoutBracket?: boolean;
isRegional?: boolean;
groupConfig?: GroupConfig;
nextUpMatchId?: string;
}
interface TeamStanding {
@@ -39,6 +41,7 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
hasKnockoutBracket,
isRegional,
groupConfig,
nextUpMatchId,
}) => {
const queryClient = useQueryClient();
const [expandedTeams, setExpandedTeams] = useState<Record<string, boolean>>({});
@@ -64,35 +67,22 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
populateKnockoutMutation.mutate({ data: { tournamentId } });
};
const orderMatchesWithSpacing = (matches: Match[]): Match[] => {
if (matches.length <= 1) return matches;
const globalOrderRank = useMemo(() => {
const groupMatches = matches.filter((match) => match.round === -1 && match.group);
const order = computeGroupStageOrder(
groupMatches.map((m) => ({
groupId: m.group ?? "",
teamA: m.home?.id ?? "",
teamB: m.away?.id ?? "",
})),
);
const ordered: Match[] = [];
const remaining = [...matches];
ordered.push(remaining.shift()!);
while (remaining.length > 0) {
const lastMatch = ordered[ordered.length - 1];
const lastTeams = new Set([lastMatch.home?.id, lastMatch.away?.id].filter(Boolean));
let bestMatchIndex = remaining.findIndex((match) => {
const currentTeams = new Set([match.home?.id, match.away?.id].filter(Boolean));
for (const teamId of currentTeams) {
if (lastTeams.has(teamId)) return false;
}
return true;
});
if (bestMatchIndex === -1) {
bestMatchIndex = 0;
}
ordered.push(remaining.splice(bestMatchIndex, 1)[0]);
}
return ordered;
};
const rank = new Map<string, number>();
order.forEach((matchIdx, position) => {
rank.set(groupMatches[matchIdx].id, position);
});
return rank;
}, [matches]);
const matchesByGroup = useMemo(() => {
const map = new Map<string, Match[]>();
@@ -107,11 +97,14 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
});
map.forEach((groupMatches, groupId) => {
map.set(groupId, orderMatchesWithSpacing(groupMatches));
const sorted = [...groupMatches].sort(
(a, b) => (globalOrderRank.get(a.id) ?? 0) - (globalOrderRank.get(b.id) ?? 0),
);
map.set(groupId, sorted);
});
return map;
}, [matches]);
}, [matches, globalOrderRank]);
const sortedGroups = useMemo(() => {
return [...groups].sort((a, b) => a.order - b.order);
@@ -461,6 +454,7 @@ const GroupStageView: React.FC<GroupStageViewProps> = ({
key={match.id}
match={match}
showControls={showControls}
nextUpMatchId={nextUpMatchId}
/>
))}
</SimpleGrid>
@@ -1,4 +1,4 @@
import { Group, Stack, ThemeIcon, Text, Flex } from "@mantine/core";
import { Group, Stack, ThemeIcon, Text } from "@mantine/core";
import { Tournament } from "../../types";
import { CalendarIcon, MapPinIcon, TrophyIcon } from "@phosphor-icons/react";
import { useMemo } from "react";
@@ -11,7 +11,7 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
);
return (
<Stack px="sm" align="center" gap={0}>
<Group px="md" gap="md" wrap="nowrap" align="center">
<GlitchAvatar
name={tournament.name}
src={
@@ -25,27 +25,27 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
: undefined
}
radius="md"
size={250}
size={64}
>
<TrophyIcon size={32} />
<TrophyIcon size={24} />
</GlitchAvatar>
<Flex gap="xs" direction="row" wrap="wrap" justify="space-around">
<Stack gap={6} style={{ minWidth: 0 }}>
{tournament.location && (
<Group gap="xs">
<Group gap="xs" wrap="nowrap">
<ThemeIcon size="sm" variant="light" radius="sm">
<MapPinIcon size={14} />
</ThemeIcon>
<Text size="sm" c="dimmed">
<Text size="sm" c="dimmed" lineClamp={1}>
{tournament.location}
</Text>
</Group>
)}
<Group gap="xs">
<Group gap="xs" wrap="nowrap">
<ThemeIcon size="sm" variant="light" radius="sm">
<CalendarIcon size={14} />
</ThemeIcon>
<Text size="sm" c="dimmed">
<Text size="sm" c="dimmed" lineClamp={1}>
{tournamentStart.toLocaleDateString(undefined, {
weekday: "short",
month: "short",
@@ -58,8 +58,8 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
})}
</Text>
</Group>
</Flex>
</Stack>
</Stack>
</Group>
);
};
@@ -1,50 +1,83 @@
import { useMemo } from "react";
import { Tournament } from "../../types";
import { useAuth } from "@/contexts/auth-context";
import { Box, Divider, Stack, Text, Card, Center, Group, Indicator } from "@mantine/core";
import { Carousel } from "@mantine/carousel";
import carouselClasses from "./carousel.module.css";
import ListLink from "@/components/list-link";
import { TreeStructureIcon, UsersIcon, ClockIcon, ListDashes } from "@phosphor-icons/react";
import WizardOrbIcon from "@/components/wizard-orb-icon";
import { Box, Card, Center, Divider, Stack, Text } from "@mantine/core";
import { ClockIcon } from "@phosphor-icons/react";
import { isPredictionLocked, isTournamentPredictable } from "@/features/predictions/utils";
import { predictionQueries } from "@/features/predictions/queries";
import { useServerQuery } from "@/lib/tanstack-query/hooks";
import TeamListButton from "../upcoming-tournament/team-list-button";
import RulesListButton from "../upcoming-tournament/rules-list-button";
import MatchCard from "@/features/matches/components/match-card";
import { Match } from "@/features/matches/types";
import Header from "./header";
import { Podium } from "../podium";
import SectionHeading from "./sections/section-heading";
import MatchCarousel from "./sections/match-carousel";
import ScoreReportCta from "./sections/score-report-cta";
import RecentResults from "./sections/recent-results";
import NavGrid from "./sections/nav-grid";
const teamPlayerIds = (t: Match["home"]): string[] =>
t && typeof t !== "string" ? (t.players ?? []).map((p) => p.id) : [];
const StartedTournament: React.FC<{ tournament: Tournament }> = ({
tournament,
}) => {
const { roles } = useAuth();
const { user, roles } = useAuth();
const isAdmin = useMemo(() => roles.includes("Admin"), [roles]);
const startedMatches = useMemo(() =>
tournament.matches?.filter(match => match.status === "started") || [],
[tournament.matches]
const matches = useMemo(() => tournament.matches || [], [tournament.matches]);
const liveMatches = useMemo(
() => matches.filter((m) => m.status === "started"),
[matches]
);
const upcomingMatches = useMemo(
() => matches.filter((m) => m.status === "ready" && m.home && m.away),
[matches]
);
const endedMatches = useMemo(
() =>
matches
.filter((m) => m.status === "ended")
.sort(
(a, b) =>
new Date(b.end_time).getTime() - new Date(a.end_time).getTime()
),
[matches]
);
const myLiveMatches = useMemo(
() =>
liveMatches.filter(
(m) =>
!!user?.id &&
(teamPlayerIds(m.home).includes(user.id) ||
teamPlayerIds(m.away).includes(user.id))
),
[liveMatches, user?.id]
);
const isTournamentOver = useMemo(() => {
const matches = tournament.matches || [];
if (matches.length === 0) return false;
const nonByeMatches = matches.filter((match) => !(match.status === 'tbd' && match.bye === true));
const nonByeMatches = matches.filter(
(match) => !(match.status === "tbd" && match.bye === true)
);
if (nonByeMatches.length === 0) return false;
const finalsMatch = nonByeMatches.reduce((highest, current) =>
(!highest || current.lid > highest.lid) ? current : highest
!highest || current.lid > highest.lid ? current : highest
);
return finalsMatch?.status === 'ended';
}, [tournament.matches]);
return finalsMatch?.status === "ended";
}, [matches]);
const hasGroupStage = useMemo(() => {
return tournament.matches?.some((match) => match.round === -1) || false;
}, [tournament.matches]);
const hasGroupStage = useMemo(
() => matches.some((match) => match.round === -1),
[matches]
);
const isPredictable = useMemo(
() => isTournamentPredictable(tournament),
@@ -52,8 +85,8 @@ const StartedTournament: React.FC<{ tournament: Tournament }> = ({
);
const predictionsLocked = useMemo(
() => isPredictionLocked(tournament.matches || []),
[tournament.matches]
() => isPredictionLocked(matches),
[matches]
);
const { data: myPrediction } = useServerQuery({
@@ -62,48 +95,42 @@ const StartedTournament: React.FC<{ tournament: Tournament }> = ({
});
const hasSubmitted = !!myPrediction?.prediction;
const hasNoMatchContent =
!isTournamentOver &&
liveMatches.length === 0 &&
upcomingMatches.length === 0 &&
endedMatches.length === 0;
return (
<Stack gap="lg">
<Header tournament={tournament} />
{startedMatches.length > 0 ? (
<Box>
<Group gap={10} px="md" mb={6} align="center" wrap="nowrap">
<Indicator
size={8}
color="red"
processing
position="middle-start"
offset={0}
/>
<Text size="xs" fw={700} c="dimmed" tt="uppercase" lts="0.05em">
Live Matches{startedMatches.length > 1 && ` · ${startedMatches.length}`}
</Text>
</Group>
<Carousel
slideSize="90%"
slideGap="xs"
withControls={false}
withIndicators={startedMatches.length > 1}
classNames={{
indicators: carouselClasses.indicators,
indicator: carouselClasses.indicator,
}}
>
{startedMatches.map((match, index) => (
<Carousel.Slide key={match.id}>
<Box pl={index === 0 ? "md" : undefined } pr={index === startedMatches.length - 1 ? "md" : undefined}>
<MatchCard match={match} />
</Box>
</Carousel.Slide>
))}
</Carousel>
<ScoreReportCta matches={myLiveMatches} />
{isTournamentOver && (
<Box px="lg" w="100%">
<Podium tournament={tournament} />
</Box>
) : isTournamentOver ? (
<Box px="lg" w="100%">
<Podium tournament={tournament} />
</Box>
) : (
)}
{liveMatches.length > 0 && (
<Box>
<SectionHeading label="Live Matches" live count={liveMatches.length} />
<MatchCarousel matches={liveMatches} />
</Box>
)}
{upcomingMatches.length > 0 && (
<Box>
<SectionHeading label="Upcoming" count={upcomingMatches.length} />
{/* SIDE BETS SLOT — see docs/side-bets.md */}
<MatchCarousel matches={upcomingMatches} />
</Box>
)}
<RecentResults matches={endedMatches} tournamentId={tournament.id} />
{hasNoMatchContent && (
<Card withBorder radius="lg" p="xl" mx="md">
<Center>
<Stack align="center" gap="md">
@@ -117,45 +144,18 @@ const StartedTournament: React.FC<{ tournament: Tournament }> = ({
)}
<Box>
<Divider />
{isAdmin && (
<ListLink
label={`Manage ${tournament.name}`}
to={`/admin/tournaments/${tournament.id}`}
Icon={UsersIcon}
/>
)}
{hasGroupStage && (
<ListLink
label={`View Groups`}
to={`/tournaments/${tournament.id}/groups`}
Icon={ListDashes}
/>
)}
<ListLink
label={`View Bracket`}
to={`/tournaments/${tournament.id}/bracket`}
Icon={TreeStructureIcon}
<Divider mb="sm" />
<NavGrid
tournament={tournament}
isAdmin={isAdmin}
hasGroupStage={hasGroupStage}
isPredictable={isPredictable}
predictionsLocked={predictionsLocked}
hasSubmitted={hasSubmitted}
/>
{isPredictable && !predictionsLocked && (
<ListLink
label={hasSubmitted ? `Edit Your Prediction` : `Make Your Prediction`}
to={`/tournaments/${tournament.id}/predictions/make`}
Icon={WizardOrbIcon}
/>
)}
{isPredictable && (predictionsLocked || hasSubmitted) && (
<ListLink
label={`View Predictions`}
to={`/tournaments/${tournament.id}/predictions`}
Icon={WizardOrbIcon}
/>
)}
<TeamListButton teams={tournament.teams || []} isRegional={tournament.regional} />
<RulesListButton tournamentId={tournament.id} />
</Box>
</Stack>
);
};
export default StartedTournament;
export default StartedTournament;
@@ -0,0 +1,45 @@
import { Box } from "@mantine/core";
import { Carousel } from "@mantine/carousel";
import MatchCard from "@/features/matches/components/match-card";
import { Match } from "@/features/matches/types";
import carouselClasses from "../carousel.module.css";
interface MatchCarouselProps {
matches: Match[];
}
const MatchCarousel = ({ matches }: MatchCarouselProps) => {
if (matches.length === 1) {
return (
<Box px="md">
<MatchCard match={matches[0]} />
</Box>
);
}
return (
<Carousel
slideSize="90%"
slideGap="xs"
withControls={false}
withIndicators={matches.length > 1}
classNames={{
indicators: carouselClasses.indicators,
indicator: carouselClasses.indicator,
}}
>
{matches.map((match, index) => (
<Carousel.Slide key={match.id}>
<Box
pl={index === 0 ? "md" : undefined}
pr={index === matches.length - 1 ? "md" : undefined}
>
<MatchCard match={match} />
</Box>
</Carousel.Slide>
))}
</Carousel>
);
};
export default MatchCarousel;
@@ -0,0 +1,38 @@
.tile {
-webkit-tap-highlight-color: transparent;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 14px 8px;
border-radius: var(--mantine-radius-md);
border: 1px solid var(--mantine-color-default-border);
background-color: var(--mantine-color-body);
text-align: center;
}
.icon {
color: var(--mantine-primary-color-filled);
display: flex;
}
@media (hover: hover) {
.tile:hover:not(:disabled) {
background-color: var(--mantine-color-default-hover);
}
}
.tile:active:not(:disabled) {
background-color: var(--mantine-color-default-hover);
}
@media (prefers-reduced-motion: no-preference) {
.tile {
transition: background-color 120ms ease-out, transform 120ms cubic-bezier(0.32, 0.72, 0, 1);
}
.tile:active:not(:disabled) {
transform: scale(0.97);
}
}
@@ -0,0 +1,164 @@
import { lazy, Suspense } from "react";
import {
Box,
Button,
Flex,
Loader,
SimpleGrid,
Stack,
Text,
UnstyledButton,
} from "@mantine/core";
import { Icon } from "@phosphor-icons/react";
import {
ListDashes,
ListIcon,
TreeStructureIcon,
UsersIcon,
} from "@phosphor-icons/react";
import { useNavigate } from "@tanstack/react-router";
import Sheet from "@/components/sheet/sheet";
import { useSheet } from "@/hooks/use-sheet";
import TeamList from "@/features/teams/components/team-list";
import WizardOrbIcon from "@/components/wizard-orb-icon";
import { Tournament } from "../../../types";
import classes from "./nav-grid.module.css";
const RulesContent = lazy(
() => import("../../upcoming-tournament/rules-content")
);
interface NavTileProps {
label: string;
Icon: Icon;
onClick: () => void;
}
/** A single square-ish nav tile with icon-over-label and press feedback. */
const NavTile = ({ label, Icon, onClick }: NavTileProps) => (
<UnstyledButton className={classes.tile} onClick={onClick}>
<span className={classes.icon}>
<Icon size={22} weight="bold" />
</span>
<Text size="xs" fw={600} lineClamp={2} lh={1.2}>
{label}
</Text>
</UnstyledButton>
);
interface NavGridProps {
tournament: Tournament;
isAdmin: boolean;
hasGroupStage: boolean;
isPredictable: boolean;
predictionsLocked: boolean;
hasSubmitted: boolean;
}
/**
* Consolidates the old stack of full-width ListLinks into a compact icon grid
* so it no longer dominates the page. Every original destination stays
* reachable with identical conditional visibility. Teams and Rules open sheets;
* everything else navigates.
*/
const NavGrid = ({
tournament,
isAdmin,
hasGroupStage,
isPredictable,
predictionsLocked,
hasSubmitted,
}: NavGridProps) => {
const navigate = useNavigate();
const teamsSheet = useSheet();
const rulesSheet = useSheet();
return (
<Box px="md">
<SimpleGrid cols={3} spacing="sm" verticalSpacing="sm">
{isAdmin && (
<NavTile
label="Manage"
Icon={UsersIcon}
onClick={() =>
navigate({ to: `/admin/tournaments/${tournament.id}` })
}
/>
)}
{hasGroupStage && (
<NavTile
label="Groups"
Icon={ListDashes}
onClick={() =>
navigate({ to: `/tournaments/${tournament.id}/groups` })
}
/>
)}
<NavTile
label="Bracket"
Icon={TreeStructureIcon}
onClick={() =>
navigate({ to: `/tournaments/${tournament.id}/bracket` })
}
/>
{isPredictable && !predictionsLocked && (
<NavTile
label={hasSubmitted ? "Edit Prediction" : "Make Prediction"}
Icon={WizardOrbIcon}
onClick={() =>
navigate({ to: `/tournaments/${tournament.id}/predictions/make` })
}
/>
)}
{isPredictable && (predictionsLocked || hasSubmitted) && (
<NavTile
label="Predictions"
Icon={WizardOrbIcon}
onClick={() =>
navigate({ to: `/tournaments/${tournament.id}/predictions` })
}
/>
)}
<NavTile
label={`Teams${
tournament.teams ? ` (${tournament.teams.length})` : ""
}`}
Icon={UsersIcon}
onClick={teamsSheet.open}
/>
<NavTile label="Rules" Icon={ListIcon} onClick={rulesSheet.open} />
</SimpleGrid>
<Sheet title="Enrolled Teams" {...teamsSheet.props}>
<TeamList
teams={tournament.teams || []}
isRegional={tournament.regional}
/>
</Sheet>
<Sheet title="Tournament Rules" {...rulesSheet.props}>
<Stack gap="xs">
<Suspense
fallback={
<Flex
justify="center"
align="center"
w="100%"
style={{ minHeight: "25vh" }}
>
<Loader size="lg" />
</Flex>
}
>
<RulesContent content={tournament.rules || ""} />
</Suspense>
<Button variant="subtle" c="red" onClick={rulesSheet.close}>
Close
</Button>
</Stack>
</Sheet>
</Box>
);
};
export default NavGrid;
@@ -0,0 +1,61 @@
import { Anchor, Box, Group, Stack } from "@mantine/core";
import { CaretRightIcon } from "@phosphor-icons/react";
import { useNavigate } from "@tanstack/react-router";
import MatchCard from "@/features/matches/components/match-card";
import { Match } from "@/features/matches/types";
import SectionHeading from "./section-heading";
interface RecentResultsProps {
/** Ended matches, already sorted most-recent-first. */
matches: Match[];
tournamentId: string;
/** How many to show inline before offering "View all results". */
max?: number;
}
/**
* Recently finished matches, newest on top, so people can look back and react
* (each MatchCard carries its own reaction bar). Caps the inline list and links
* to the full bracket when there are more results than fit.
*/
const RecentResults = ({ matches, tournamentId, max = 5 }: RecentResultsProps) => {
const navigate = useNavigate();
if (matches.length === 0) return null;
const shown = matches.slice(0, max);
const hasMore = matches.length > max;
return (
<Box>
<SectionHeading
label="Recently finished"
action={
hasMore ? (
<Anchor
component="button"
type="button"
size="xs"
fw={600}
onClick={() =>
navigate({ to: `/tournaments/${tournamentId}/bracket` })
}
>
<Group gap={2} align="center" wrap="nowrap">
View all results
<CaretRightIcon size={12} weight="bold" />
</Group>
</Anchor>
) : undefined
}
/>
<Stack gap="sm" px="md">
{shown.map((match) => (
<MatchCard key={match.id} match={match} />
))}
</Stack>
</Box>
);
};
export default RecentResults;
@@ -0,0 +1,86 @@
import { Box, Group, Paper, Stack, Text } from "@mantine/core";
import { Match } from "@/features/matches/types";
import { MatchReport } from "@/features/bracket/components/match-report";
import TeamAvatar from "@/components/team-avatar";
import SectionHeading from "./section-heading";
interface ScoreReportCtaProps {
/** Started matches the current user is playing in. */
matches: Match[];
}
const teamName = (t: Match["home"]): string =>
t && typeof t !== "string" ? t.name : "TBD";
/**
* Top-of-home call-to-action for a participant whose match is live. Composes
* the existing <MatchReport> so reporting/confirming a score is one tap from
* the home screen instead of buried in the bracket. Renders nothing when the
* user isn't playing in a started match.
*/
const ScoreReportCta = ({ matches }: ScoreReportCtaProps) => {
if (matches.length === 0) return null;
return (
<Box>
<SectionHeading
label="Your match is live"
live
count={matches.length}
/>
<Stack gap="sm" px="md">
{matches.map((match) => (
<Paper key={match.id} withBorder p="md">
<Stack gap="sm">
<Group gap="xs" wrap="nowrap">
<Group gap={8} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
{match.home && typeof match.home !== "string" && (
<TeamAvatar
team={match.home}
size={28}
radius="sm"
isRegional={match.tournament.regional === true}
/>
)}
<Text size="sm" fw={600} lineClamp={1} style={{ minWidth: 0 }}>
{teamName(match.home)}
</Text>
</Group>
<Text size="xs" c="dimmed" fw={600} style={{ flexShrink: 0 }}>
vs
</Text>
<Group
gap={8}
wrap="nowrap"
justify="flex-end"
style={{ flex: 1, minWidth: 0 }}
>
<Text
size="sm"
fw={600}
lineClamp={1}
ta="right"
style={{ minWidth: 0 }}
>
{teamName(match.away)}
</Text>
{match.away && typeof match.away !== "string" && (
<TeamAvatar
team={match.away}
size={28}
radius="sm"
isRegional={match.tournament.regional === true}
/>
)}
</Group>
</Group>
<MatchReport match={match} />
</Stack>
</Paper>
))}
</Stack>
</Box>
);
};
export default ScoreReportCta;
@@ -0,0 +1,40 @@
import { Group, Indicator, Text } from "@mantine/core";
interface SectionHeadingProps {
label: string;
/** Show a pulsing red "live" dot before the label. */
live?: boolean;
/** Appended as "· N" after the label when greater than 1. */
count?: number;
/** Optional right-aligned action (e.g. a "View all" link). */
action?: React.ReactNode;
}
/**
* Compact, uppercase section label used across the started-tournament home
* surfaces. Mirrors the original "Live Matches" heading treatment.
*/
const SectionHeading = ({ label, live, count, action }: SectionHeadingProps) => {
return (
<Group gap={10} px="md" mb={6} align="center" wrap="nowrap" justify="space-between">
<Group gap={10} align="center" wrap="nowrap" style={{ minWidth: 0 }}>
{live && (
<Indicator
size={8}
color="red"
processing
position="middle-start"
offset={0}
/>
)}
<Text size="xs" fw={700} c="dimmed" tt="uppercase" lts="0.05em" lineClamp={1}>
{label}
{count && count > 1 ? ` · ${count}` : ""}
</Text>
</Group>
{action}
</Group>
);
};
export default SectionHeading;
@@ -1,70 +1,78 @@
import { Box, Card, Center, Divider, Group, Skeleton, Stack } from "@mantine/core";
import { Box, Card, Divider, Group, SimpleGrid, Skeleton, Stack } from "@mantine/core";
const MatchCardSkeleton = ({ minWidth }: { minWidth: string }) => (
<Card
withBorder
radius="md"
p="md"
style={{ minWidth, flex: "0 0 auto" }}
>
<Stack gap="md">
<Group justify="space-between">
<Skeleton height={14} width="30%" />
<Skeleton height={20} width={40} radius="xl" />
</Group>
<Stack gap="sm">
<Group>
<Skeleton height={40} width={40} radius="sm" />
<Skeleton height={16} width="40%" />
<Box ml="auto">
<Skeleton height={24} width={30} />
</Box>
</Group>
<Group>
<Skeleton height={40} width={40} radius="sm" />
<Skeleton height={16} width="40%" />
<Box ml="auto">
<Skeleton height={24} width={30} />
</Box>
</Group>
</Stack>
</Stack>
</Card>
);
const StartedTournamentSkeleton = () => {
return (
<Stack gap="lg">
{/* Header skeleton */}
<Stack px="md" align="center" gap="xs">
<Skeleton height={268} width={268} radius="lg" />
<Skeleton height={16} width="55%" />
</Stack>
{/* Compact header skeleton */}
<Group px="md" gap="md" wrap="nowrap" align="center">
<Skeleton height={64} width={64} radius="md" />
<Stack gap={8} style={{ flex: 1 }}>
<Skeleton height={14} width="55%" />
<Skeleton height={14} width="40%" />
</Stack>
</Group>
{/* Match carousel skeleton */}
{/* Live carousel skeleton */}
<Box>
<Group gap="xs" px="xl">
{Array.from({ length: 2 }).map((_, index) => (
<Card
key={index}
withBorder
radius="lg"
p="lg"
style={{ minWidth: "95%", flex: "0 0 auto" }}
>
<Stack gap="md">
{/* Match header */}
<Group justify="space-between">
<Skeleton height={14} width="30%" />
<Skeleton height={20} width={60} radius="xl" />
</Group>
{/* Teams */}
<Stack gap="sm">
<Group>
<Skeleton height={32} width={32} radius="sm" />
<Skeleton height={16} width="40%" />
<Box ml="auto">
<Skeleton height={24} width={30} />
</Box>
</Group>
<Center>
<Skeleton height={14} width={20} />
</Center>
<Group>
<Skeleton height={32} width={32} radius="sm" />
<Skeleton height={16} width="40%" />
<Box ml="auto">
<Skeleton height={24} width={30} />
</Box>
</Group>
</Stack>
</Stack>
</Card>
))}
<Skeleton height={12} width={90} ml="md" mb={10} />
<Group gap="xs" pl="md" wrap="nowrap" style={{ overflow: "hidden" }}>
<MatchCardSkeleton minWidth="88%" />
<MatchCardSkeleton minWidth="88%" />
</Group>
</Box>
{/* Actions section skeleton */}
{/* Recently finished skeleton */}
<Box>
<Divider />
<Stack gap={0}>
<Skeleton height={48} width="100%" />
<Skeleton height={48} width="100%" />
<Skeleton height={48} width="100%" />
<Skeleton height={48} width="100%" />
<Skeleton height={12} width={120} ml="md" mb={10} />
<Stack gap="sm" px="md">
<MatchCardSkeleton minWidth="100%" />
<MatchCardSkeleton minWidth="100%" />
</Stack>
</Box>
{/* Nav grid skeleton */}
<Box px="md">
<Divider mb="sm" />
<SimpleGrid cols={3} spacing="sm" verticalSpacing="sm">
{Array.from({ length: 6 }).map((_, index) => (
<Skeleton key={index} height={72} radius="md" />
))}
</SimpleGrid>
</Box>
</Stack>
);
};
export default StartedTournamentSkeleton;
export default StartedTournamentSkeleton;
+11 -7
View File
@@ -10,6 +10,7 @@ import { emitServerEvent } from "@/lib/events/emitter";
import brackets from "@/features/bracket/utils";
import { MatchInput } from "@/features/matches/types";
import { generateSingleEliminationBracket } from "./utils/bracket-generator";
import { orderGroupStageMatches } from "./utils/match-queue";
export const listTournaments = createServerFn()
.middleware([superTokensFunctionMiddleware])
@@ -69,12 +70,6 @@ export const enrollTeam = createServerFn()
const team = await pbAdmin.getTeam(teamId);
if (!team) { throw new Error('Team not found'); }
//const isPlayerOnTeam = team.players?.some(player => player.id === userId);
//if (!isPlayerOnTeam && !isAdmin) {
// throw new Error('You do not have permission to enroll this team');
//}
const freeAgents = await pbAdmin.getFreeAgents(tournamentId);
for (const player of team.players || []) {
const isFreeAgent = freeAgents.some(fa => fa.player?.id === player.id);
@@ -816,7 +811,8 @@ export const generateGroupStage = createServerFn()
groupStageMatches.push({
lid: -1,
round: -1,
order: groupStageMatches.length + 1,
order: 0,
reset: false,
bye: false,
home: teamIds[i],
@@ -838,6 +834,14 @@ export const generateGroupStage = createServerFn()
}
}
const orderedGroupMatches = orderGroupStageMatches(
groupStageMatches,
(m) => ({ groupId: m.group, teamA: m.home, teamB: m.away })
);
orderedGroupMatches.forEach((match, index) => {
match.order = index + 1;
});
const knockoutTeamCount = data.groupConfig.num_groups * data.groupConfig.advance_per_group;
const nextPowerOf2 = Math.pow(2, Math.ceil(Math.log2(knockoutTeamCount)));
@@ -0,0 +1,135 @@
export interface OrderableMatch {
groupId: string;
teamA: string;
teamB: string;
}
const BYE = "__BYE__";
function pairKey(a: string, b: string): string {
return a < b ? `${a}|${b}` : `${b}|${a}`;
}
function circleMethodRoundIndex(teams: string[]): Map<string, number> {
const sorted = [...teams].sort();
const arr = [...sorted];
if (arr.length % 2 === 1) arr.push(BYE);
const n = arr.length;
const result = new Map<string, number>();
if (n < 2) return result;
const rounds = n - 1;
const half = n / 2;
let list = [...arr];
for (let r = 0; r < rounds; r++) {
for (let i = 0; i < half; i++) {
const a = list[i];
const b = list[n - 1 - i];
if (a !== BYE && b !== BYE) {
result.set(pairKey(a, b), r);
}
}
list = [list[0], list[n - 1], ...list.slice(1, n - 1)];
}
return result;
}
interface AnnotatedMatch {
idx: number;
groupId: string;
teamA: string;
teamB: string;
roundIndex: number;
key: string;
}
function isBetter(a: number[], aKey: string, b: number[], bKey: string): boolean {
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return a[i] > b[i];
}
return aKey < bKey;
}
export function computeGroupStageOrder(matches: OrderableMatch[]): number[] {
const n = matches.length;
if (n <= 1) return matches.map((_, i) => i);
const groupTeams = new Map<string, Set<string>>();
for (const m of matches) {
let set = groupTeams.get(m.groupId);
if (!set) {
set = new Set<string>();
groupTeams.set(m.groupId, set);
}
set.add(m.teamA);
set.add(m.teamB);
}
const roundIndexByGroup = new Map<string, Map<string, number>>();
for (const [groupId, teams] of groupTeams) {
roundIndexByGroup.set(groupId, circleMethodRoundIndex([...teams]));
}
const items: AnnotatedMatch[] = matches.map((m, idx) => {
const round = roundIndexByGroup.get(m.groupId)?.get(pairKey(m.teamA, m.teamB));
return {
idx,
groupId: m.groupId,
teamA: m.teamA,
teamB: m.teamB,
roundIndex: round ?? Number.MAX_SAFE_INTEGER,
key: `${m.groupId}|${pairKey(m.teamA, m.teamB)}`,
};
});
const remaining = new Set<number>(items.map((it) => it.idx));
const itemByIdx = new Map<number, AnnotatedMatch>(items.map((it) => [it.idx, it]));
const order: number[] = [];
const lastPlayed = new Map<string, number>();
const NEVER = -1 - n;
let prevTeams = new Set<string>();
let prevGroup: string | null = null;
for (let pos = 0; pos < n; pos++) {
let best: AnnotatedMatch | null = null;
let bestScore: number[] | null = null;
for (const idx of remaining) {
const it = itemByIdx.get(idx)!;
const adjacent = prevTeams.has(it.teamA) || prevTeams.has(it.teamB) ? 1 : 0;
const gapA = pos - (lastPlayed.get(it.teamA) ?? NEVER);
const gapB = pos - (lastPlayed.get(it.teamB) ?? NEVER);
const gap = Math.min(gapA, gapB);
const sameGroup = prevGroup !== null && it.groupId === prevGroup ? 1 : 0;
const score = [-adjacent, gap, -sameGroup, -it.roundIndex];
if (best === null || isBetter(score, it.key, bestScore!, best.key)) {
best = it;
bestScore = score;
}
}
order.push(best!.idx);
remaining.delete(best!.idx);
lastPlayed.set(best!.teamA, pos);
lastPlayed.set(best!.teamB, pos);
prevTeams = new Set([best!.teamA, best!.teamB]);
prevGroup = best!.groupId;
}
return order;
}
export function orderGroupStageMatches<T>(
items: T[],
accessor: (item: T) => OrderableMatch,
): T[] {
const orderable = items.map(accessor);
const order = computeGroupStageOrder(orderable);
return order.map((idx) => items[idx]);
}