feat(telemetry): collections, universal server-fn coverage, beacon, client tracker
- activities indexes + client_events/client_errors/rollups/alerts collections - global function middleware via createStart; reads skipped on success - actor stash, denial dedup, control-flow error guards - api route logging, auth event overrides, beacon endpoint, web vitals - centralized admin role checks, shared skeletons, award-badges fix - extracted deriveRunState with tests
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { Box, Card, Text, Select, Button, Group, Stack, Badge, Divider } from "@mantine/core";
|
||||
import { Trans, useLingui } from "@lingui/react/macro";
|
||||
import { awardManualBadge } from "@/features/badges/server";
|
||||
import useAwardManualBadge from "@/features/badges/hooks/use-award-manual-badge";
|
||||
import { useAllBadges } from "@/features/badges/queries";
|
||||
import toast from "@/lib/sonner";
|
||||
import { usePlayers } from "@/features/players/queries";
|
||||
@@ -13,35 +13,29 @@ const AwardBadges = () => {
|
||||
|
||||
const [selectedPlayerId, setSelectedPlayerId] = useState<string | null>(null);
|
||||
const [selectedBadgeId, setSelectedBadgeId] = useState<string | null>(null);
|
||||
const [isAwarding, setIsAwarding] = useState(false);
|
||||
|
||||
const awardBadge = useAwardManualBadge();
|
||||
|
||||
const manualBadges = allBadges.filter((badge) => badge.type === "manual");
|
||||
|
||||
const handleAwardBadge = async () => {
|
||||
const handleAwardBadge = () => {
|
||||
if (!selectedPlayerId || !selectedBadgeId) return;
|
||||
|
||||
setIsAwarding(true);
|
||||
try {
|
||||
await awardManualBadge({
|
||||
data: {
|
||||
playerId: selectedPlayerId,
|
||||
badgeId: selectedBadgeId,
|
||||
awardBadge.mutate(
|
||||
{ playerId: selectedPlayerId, badgeId: selectedBadgeId },
|
||||
{
|
||||
onSuccess: () => {
|
||||
const selectedPlayer = players.find((p) => p.id === selectedPlayerId);
|
||||
const playerName = selectedPlayer
|
||||
? `${selectedPlayer.first_name} ${selectedPlayer.last_name}`
|
||||
: t`Player`;
|
||||
|
||||
toast.success(t`Badge awarded to ${playerName}`);
|
||||
|
||||
setSelectedPlayerId(null);
|
||||
},
|
||||
});
|
||||
|
||||
const selectedPlayer = players.find((p) => p.id === selectedPlayerId);
|
||||
const playerName = selectedPlayer
|
||||
? `${selectedPlayer.first_name} ${selectedPlayer.last_name}`
|
||||
: t`Player`;
|
||||
|
||||
toast.success(t`Badge awarded to ${playerName}`);
|
||||
|
||||
setSelectedPlayerId(null);
|
||||
} catch (error) {
|
||||
toast.error(t`Failed to award badge`);
|
||||
} finally {
|
||||
setIsAwarding(false);
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const playerOptions = players.map((player) => ({
|
||||
@@ -98,7 +92,7 @@ const AwardBadges = () => {
|
||||
<Button
|
||||
onClick={handleAwardBadge}
|
||||
disabled={!selectedPlayerId}
|
||||
loading={isAwarding}
|
||||
loading={awardBadge.isPending}
|
||||
size="md"
|
||||
>
|
||||
<Trans>Award Badge</Trans>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useServerMutation } from "@/lib/tanstack-query/hooks/use-server-mutation";
|
||||
import { awardManualBadge } from "@/features/badges/server";
|
||||
import { badgeKeys } from "@/features/badges/queries";
|
||||
|
||||
interface AwardManualBadgeInput {
|
||||
playerId: string;
|
||||
badgeId: string;
|
||||
}
|
||||
|
||||
export default function useAwardManualBadge() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useServerMutation({
|
||||
mutationFn: (data: AwardManualBadgeInput) => awardManualBadge({ data }),
|
||||
onSuccess: (_data, { playerId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: badgeKeys.playerBadges(playerId) });
|
||||
queryClient.invalidateQueries({ queryKey: badgeKeys.allEarnedBadges() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||
import { superTokensAdminFunctionMiddleware, superTokensFunctionMiddleware } from "@/utils/supertokens";
|
||||
import { serverFnLoggingMiddleware } from "@/utils/activities";
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||
import { z } from "zod";
|
||||
@@ -15,7 +14,7 @@ export const getPlayerBadges = createServerFn()
|
||||
);
|
||||
|
||||
export const migrateBadgeProgress = createServerFn()
|
||||
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensAdminFunctionMiddleware])
|
||||
.handler(async () =>
|
||||
toServerResult(async () => startBadgeMigration())
|
||||
);
|
||||
@@ -41,7 +40,7 @@ export const awardManualBadge = createServerFn()
|
||||
playerId: z.string(),
|
||||
badgeId: z.string(),
|
||||
}))
|
||||
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensAdminFunctionMiddleware])
|
||||
.handler(async ({ data }) =>
|
||||
toServerResult(async () => {
|
||||
const result = await pbAdmin.awardManualBadge(data.playerId, data.badgeId);
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
import { isAdminRole } from "../utils/roles";
|
||||
|
||||
export const useIsAdmin = (): boolean => {
|
||||
const { roles } = useAuth();
|
||||
return isAdminRole(roles);
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { HouseIcon, RankingIcon, ShieldIcon, TrophyIcon, UserCircleIcon } from "@phosphor-icons/react";
|
||||
import { useMemo } from "react";
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
import { isAdminRole } from "../utils/roles";
|
||||
|
||||
export const useLinks = (userId: string | undefined, roles: string[]) => {
|
||||
const { t } = useLingui();
|
||||
@@ -31,7 +32,7 @@ export const useLinks = (userId: string | undefined, roles: string[]) => {
|
||||
}
|
||||
]
|
||||
|
||||
if (roles.includes('Admin')) {
|
||||
if (isAdminRole(roles)) {
|
||||
links.push({
|
||||
label: t`Admin`,
|
||||
href: '/admin',
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export const ADMIN_ROLE = "Admin";
|
||||
|
||||
export const isAdminRole = (roles?: string[] | null): boolean =>
|
||||
!!roles?.includes(ADMIN_ROLE);
|
||||
@@ -9,7 +9,6 @@ import { Match, MatchInput } from "@/features/matches/types";
|
||||
import { emitServerEvent } from "@/lib/events/emitter";
|
||||
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
|
||||
import { PlayerInfo } from "../players/types";
|
||||
import { serverFnLoggingMiddleware } from "@/utils/activities";
|
||||
|
||||
const orderedTeamsSchema = z.object({
|
||||
tournamentId: z.string(),
|
||||
@@ -18,7 +17,7 @@ const orderedTeamsSchema = z.object({
|
||||
|
||||
export const generateTournamentBracket = createServerFn()
|
||||
.validator(orderedTeamsSchema)
|
||||
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensAdminFunctionMiddleware])
|
||||
.handler(async ({ data: { tournamentId, orderedTeamIds } }) =>
|
||||
toServerResult(async () => {
|
||||
logger.info("Generating tournament bracket", {
|
||||
@@ -141,7 +140,7 @@ export const generateTournamentBracket = createServerFn()
|
||||
|
||||
export const startMatch = createServerFn()
|
||||
.validator(z.string())
|
||||
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensAdminFunctionMiddleware])
|
||||
.handler(async ({ data }) =>
|
||||
toServerResult(async () => {
|
||||
logger.info("Starting match", data);
|
||||
@@ -170,7 +169,7 @@ export const populateKnockoutBracket = createServerFn()
|
||||
.validator(z.object({
|
||||
tournamentId: z.string(),
|
||||
}))
|
||||
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensAdminFunctionMiddleware])
|
||||
.handler(async ({ data: { tournamentId } }) =>
|
||||
toServerResult(async () => {
|
||||
const tournament = await pbAdmin.getTournament(tournamentId);
|
||||
@@ -801,7 +800,7 @@ const endMatchSchema = z.object({
|
||||
});
|
||||
export const endMatch = createServerFn()
|
||||
.validator(endMatchSchema)
|
||||
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensAdminFunctionMiddleware])
|
||||
.handler(async ({ data: { matchId, home_cups, away_cups, ot_count } }) =>
|
||||
toServerResult(async () => {
|
||||
logger.info("Ending match", matchId);
|
||||
@@ -827,7 +826,7 @@ const reportScoreSchema = z.object({
|
||||
|
||||
export const reportMatchScore = createServerFn()
|
||||
.validator(reportScoreSchema)
|
||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data: { matchId, home_cups, away_cups, ot_count }, context }) =>
|
||||
toServerResult(async () => {
|
||||
const player = await pbAdmin.getPlayerByAuthId(context.userAuthId);
|
||||
@@ -890,7 +889,7 @@ const matchIdSchema = z.object({ matchId: z.string() });
|
||||
|
||||
export const confirmMatchScore = createServerFn()
|
||||
.validator(matchIdSchema)
|
||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data: { matchId }, context }) =>
|
||||
toServerResult(async () => {
|
||||
const player = await pbAdmin.getPlayerByAuthId(context.userAuthId);
|
||||
@@ -929,7 +928,7 @@ export const confirmMatchScore = createServerFn()
|
||||
|
||||
export const clearMatchReport = createServerFn()
|
||||
.validator(matchIdSchema)
|
||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data: { matchId }, context }) =>
|
||||
toServerResult(async () => {
|
||||
const player = await pbAdmin.getPlayerByAuthId(context.userAuthId);
|
||||
|
||||
@@ -7,7 +7,6 @@ import { z } from "zod";
|
||||
import { Logger } from "@/lib/logger";
|
||||
import { getRequest } from "@tanstack/react-start/server";
|
||||
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||
import { serverFnLoggingMiddleware } from "@/utils/activities";
|
||||
|
||||
const logger = new Logger('Players');
|
||||
|
||||
@@ -52,7 +51,7 @@ export const getPlayer = createServerFn()
|
||||
|
||||
export const updatePlayer = createServerFn()
|
||||
.validator(playerUpdateSchema)
|
||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ context, data }) =>
|
||||
toServerResult(async () => {
|
||||
const userAuthId = context.userAuthId;
|
||||
@@ -84,7 +83,7 @@ export const updatePlayer = createServerFn()
|
||||
|
||||
export const createPlayer = createServerFn()
|
||||
.validator(playerInputSchema)
|
||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ context, data }) =>
|
||||
toServerResult(async () => {
|
||||
const userAuthId = context.userAuthId;
|
||||
@@ -117,7 +116,7 @@ export const createPlayer = createServerFn()
|
||||
|
||||
export const associatePlayer = createServerFn()
|
||||
.validator(z.string())
|
||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ context, data }) =>
|
||||
toServerResult(async () => {
|
||||
const userAuthId = context.userAuthId;
|
||||
|
||||
@@ -4,7 +4,6 @@ import { pbAdmin } from "@/lib/pocketbase/client";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
|
||||
import { serverFnLoggingMiddleware } from "@/utils/activities";
|
||||
import { Tournament } from "@/features/tournaments/types";
|
||||
import {
|
||||
MyPrediction,
|
||||
@@ -56,7 +55,7 @@ const submitPredictionSchema = z.object({
|
||||
|
||||
export const submitPrediction = createServerFn()
|
||||
.validator(submitPredictionSchema)
|
||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data: { tournamentId, picks }, context }) =>
|
||||
toServerResult(async (): Promise<Prediction> => {
|
||||
const player = await pbAdmin.getPlayerByAuthId(context.userAuthId);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
|
||||
import { serverFnLoggingMiddleware } from "@/utils/activities";
|
||||
|
||||
// Whitelists: these values flow into Mantine's primaryColor / the
|
||||
// data-mantine-color-scheme attribute — an unknown value crash-loops or
|
||||
@@ -19,7 +18,7 @@ const COLOR_SCHEMES = ["light", "dark", "auto"];
|
||||
|
||||
export const updateUserColorScheme = createServerFn({ method: "POST" })
|
||||
.validator((data: string) => data)
|
||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ context, data }) => {
|
||||
const { userAuthId, metadata } = context;
|
||||
if (!userAuthId) return;
|
||||
@@ -43,7 +42,7 @@ export const updateUserColorScheme = createServerFn({ method: "POST" })
|
||||
|
||||
export const updateUserLocale = createServerFn({ method: "POST" })
|
||||
.validator((data: string) => data)
|
||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ context, data }) => {
|
||||
const { userAuthId, metadata } = context;
|
||||
if (!userAuthId) return;
|
||||
@@ -69,7 +68,7 @@ export const updateUserLocale = createServerFn({ method: "POST" })
|
||||
|
||||
export const updateUserTheme = createServerFn({ method: "POST" })
|
||||
.validator((data: string) => data)
|
||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ context, data }) => {
|
||||
const { userAuthId, metadata } = context;
|
||||
if (!userAuthId) return;
|
||||
@@ -96,7 +95,7 @@ export const updateUserTheme = createServerFn({ method: "POST" })
|
||||
});
|
||||
|
||||
export const updateUserSeenThemesHint = createServerFn({ method: "POST" })
|
||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ context }) => {
|
||||
const { userAuthId, metadata } = context;
|
||||
if (!userAuthId) return;
|
||||
@@ -119,7 +118,7 @@ export const updateUserSeenThemesHint = createServerFn({ method: "POST" })
|
||||
|
||||
export const updateUserAccentColor = createServerFn({ method: "POST" })
|
||||
.validator((data: string) => data)
|
||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ context, data }) => {
|
||||
const { userAuthId, metadata } = context;
|
||||
if (!userAuthId) return;
|
||||
|
||||
@@ -12,12 +12,11 @@ import {
|
||||
} from '@phosphor-icons/react';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { useSpotify } from '@/lib/spotify/hooks';
|
||||
import { useAuth } from '@/contexts/auth-context';
|
||||
import { useIsAdmin } from '@/features/core/hooks/use-is-admin';
|
||||
import SpotifySheet from './spotify-sheet';
|
||||
|
||||
const SpotifyControlsBar = () => {
|
||||
const { roles } = useAuth();
|
||||
const isAdmin = roles?.includes('Admin') || false;
|
||||
const isAdmin = useIsAdmin();
|
||||
const [sheetOpened, { open: openSheet, close: closeSheet }] = useDisclosure(false);
|
||||
const { t } = useLingui();
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from '@phosphor-icons/react';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { useSpotify } from '@/lib/spotify/hooks';
|
||||
import { useAuth } from '@/contexts/auth-context';
|
||||
import { useIsAdmin } from '@/features/core/hooks/use-is-admin';
|
||||
import Sheet from '@/components/sheet/sheet';
|
||||
|
||||
interface SpotifySheetProps {
|
||||
@@ -26,8 +26,7 @@ interface SpotifySheetProps {
|
||||
}
|
||||
|
||||
const SpotifySheet: React.FC<SpotifySheetProps> = ({ opened, onClose }) => {
|
||||
const { roles } = useAuth();
|
||||
const isAdmin = roles?.includes('Admin') || false;
|
||||
const isAdmin = useIsAdmin();
|
||||
const spotify = useSpotify();
|
||||
const { t } = useLingui();
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { SlidePanelField } from "@/components/sheet/slide-panel";
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
import { useIsAdmin } from "@/features/core/hooks/use-is-admin";
|
||||
import { useUnenrolledPlayers } from "@/features/players/queries";
|
||||
import { Autocomplete, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
@@ -15,8 +16,8 @@ interface PlayersPickerProps {
|
||||
|
||||
const PlayersPicker = ({ tournamentId, value = [], onChange, disabled, error }: PlayersPickerProps) => {
|
||||
const { t } = useLingui();
|
||||
const { roles, user } = useAuth();
|
||||
const isAdmin = roles.includes("Admin");
|
||||
const { user } = useAuth();
|
||||
const isAdmin = useIsAdmin();
|
||||
|
||||
const { data } = useUnenrolledPlayers(tournamentId);
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||
import { teamInputSchema, teamUpdateSchema } from "./types";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { Match } from "../matches/types";
|
||||
import { serverFnLoggingMiddleware } from "@/utils/activities";
|
||||
import { emitServerEvent } from "@/lib/events/emitter";
|
||||
|
||||
|
||||
@@ -32,7 +31,7 @@ export const getTeamInfo = createServerFn()
|
||||
|
||||
export const createTeam = createServerFn()
|
||||
.validator(teamInputSchema)
|
||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data, context }) =>
|
||||
toServerResult(async () => {
|
||||
const userId = context.userAuthId;
|
||||
@@ -54,7 +53,7 @@ export const updateTeam = createServerFn()
|
||||
id: z.string(),
|
||||
updates: teamUpdateSchema
|
||||
}))
|
||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data: { id, updates }, context }) =>
|
||||
toServerResult(async () => {
|
||||
const userId = context.userAuthId;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo } from "react";
|
||||
import { Tournament } from "../../types";
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
import { useIsAdmin } from "@/features/core/hooks/use-is-admin";
|
||||
import { Box, Card, Center, Divider, Stack, Text } from "@mantine/core";
|
||||
import { ClockIcon } from "@phosphor-icons/react";
|
||||
import { isPredictionLocked, isTournamentPredictable } from "@/features/predictions/utils";
|
||||
@@ -22,10 +23,10 @@ const teamPlayerIds = (t: Match["home"]): string[] =>
|
||||
const StartedTournament: React.FC<{ tournament: Tournament }> = ({
|
||||
tournament,
|
||||
}) => {
|
||||
const { user, roles } = useAuth();
|
||||
const { user } = useAuth();
|
||||
const { t } = useLingui();
|
||||
|
||||
const isAdmin = useMemo(() => roles.includes("Admin"), [roles]);
|
||||
const isAdmin = useIsAdmin();
|
||||
|
||||
const matches = useMemo(() => tournament.matches || [], [tournament.matches]);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
import { useIsAdmin } from "@/features/core/hooks/use-is-admin";
|
||||
import { useTournaments } from "../queries";
|
||||
import { useSheet } from "@/hooks/use-sheet";
|
||||
import { Button, Stack } from "@mantine/core";
|
||||
@@ -12,11 +12,11 @@ import EmptyState from "@/components/empty-state";
|
||||
const TournamentCardList = () => {
|
||||
const { t } = useLingui();
|
||||
const { data: tournaments } = useTournaments();
|
||||
const { roles } = useAuth();
|
||||
const isAdmin = useIsAdmin();
|
||||
const sheet = useSheet();
|
||||
return (
|
||||
<Stack>
|
||||
{roles?.includes("Admin") ? (
|
||||
{isAdmin ? (
|
||||
<>
|
||||
<Button
|
||||
leftSection={<PlusIcon />}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Suspense, useMemo } from "react";
|
||||
import { Trans, useLingui } from "@lingui/react/macro";
|
||||
import { Tournament } from "../../types";
|
||||
import { useAuth } from "@/contexts/auth-context";
|
||||
import { useIsAdmin } from "@/features/core/hooks/use-is-admin";
|
||||
import { Box, Divider, Paper, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import Countdown from "@/components/countdown";
|
||||
import { TreeStructureIcon, UsersIcon } from "@phosphor-icons/react";
|
||||
@@ -26,11 +27,11 @@ import EnrolledFreeAgent from "./enrolled-free-agent";
|
||||
const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
|
||||
tournament,
|
||||
}) => {
|
||||
const { user, roles } = useAuth();
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useLingui();
|
||||
|
||||
const isAdmin = useMemo(() => roles.includes("Admin"), [roles]);
|
||||
const isAdmin = useIsAdmin();
|
||||
const userTeam = useMemo(
|
||||
() =>
|
||||
tournament.teams?.find((team) =>
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { deriveRunState } from "./use-tournament-run-state";
|
||||
import { Match } from "@/features/matches/types";
|
||||
|
||||
const makeMatch = (
|
||||
overrides: Partial<Match> & Pick<Match, "lid" | "round">
|
||||
): Match => ({
|
||||
id: `match-${overrides.lid}`,
|
||||
order: overrides.lid,
|
||||
reset: false,
|
||||
home_cups: 0,
|
||||
away_cups: 0,
|
||||
ot_count: 0,
|
||||
start_time: "",
|
||||
end_time: "",
|
||||
bye: false,
|
||||
home_from_lid: 0,
|
||||
away_from_lid: 0,
|
||||
home_from_loser: false,
|
||||
away_from_loser: false,
|
||||
is_losers_bracket: false,
|
||||
status: "tbd",
|
||||
tournament: {} as Match["tournament"],
|
||||
created: "",
|
||||
updated: "",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const team = (id: string) =>
|
||||
({ id, name: id, primary_color: "#000", accent_color: "#000", players: [], private: false }) as Match["home"];
|
||||
|
||||
describe("deriveRunState", () => {
|
||||
it("returns empty state when there are no matches", () => {
|
||||
expect(deriveRunState(undefined)).toEqual({
|
||||
hasGroupStage: false,
|
||||
hasKnockout: false,
|
||||
knockoutBracketPopulated: false,
|
||||
nextUpMatchId: undefined,
|
||||
bracket: { winners: [], losers: [] },
|
||||
});
|
||||
});
|
||||
|
||||
it("detects a group-only tournament and finds a ready group match as next up", () => {
|
||||
const matches = [
|
||||
makeMatch({ lid: 1, round: -1, order: 2, status: "ended" }),
|
||||
makeMatch({
|
||||
lid: 2,
|
||||
round: -1,
|
||||
order: 1,
|
||||
status: "ready",
|
||||
home: team("a"),
|
||||
away: team("b"),
|
||||
}),
|
||||
];
|
||||
|
||||
const state = deriveRunState(matches);
|
||||
|
||||
expect(state.hasGroupStage).toBe(true);
|
||||
expect(state.hasKnockout).toBe(false);
|
||||
expect(state.knockoutBracketPopulated).toBe(false);
|
||||
expect(state.nextUpMatchId).toBe("match-2");
|
||||
expect(state.bracket).toEqual({ winners: [], losers: [] });
|
||||
});
|
||||
|
||||
it("detects a knockout-only tournament and builds the bracket", () => {
|
||||
const matches = [
|
||||
makeMatch({ lid: 1, round: 0, home: team("a"), away: team("b") }),
|
||||
makeMatch({ lid: 2, round: 1 }),
|
||||
];
|
||||
|
||||
const state = deriveRunState(matches);
|
||||
|
||||
expect(state.hasGroupStage).toBe(false);
|
||||
expect(state.hasKnockout).toBe(true);
|
||||
expect(state.knockoutBracketPopulated).toBe(true);
|
||||
expect(state.bracket.winners).toEqual([[matches[0]], [matches[1]]]);
|
||||
expect(state.bracket.losers).toEqual([]);
|
||||
});
|
||||
|
||||
it("treats a knockout round 0 with only placeholder teams as unpopulated", () => {
|
||||
const matches = [
|
||||
makeMatch({ lid: 1, round: -1, status: "ended" }),
|
||||
makeMatch({ lid: 2, round: 0 }),
|
||||
];
|
||||
|
||||
const state = deriveRunState(matches);
|
||||
|
||||
expect(state.hasGroupStage).toBe(true);
|
||||
expect(state.hasKnockout).toBe(true);
|
||||
expect(state.knockoutBracketPopulated).toBe(false);
|
||||
});
|
||||
|
||||
it("marks the knockout bracket populated once round 0 has a seeded team", () => {
|
||||
const matches = [
|
||||
makeMatch({ lid: 1, round: -1, status: "ended" }),
|
||||
makeMatch({ lid: 2, round: 0, home: team("a") }),
|
||||
];
|
||||
|
||||
const state = deriveRunState(matches);
|
||||
|
||||
expect(state.knockoutBracketPopulated).toBe(true);
|
||||
});
|
||||
|
||||
it("splits winners and losers brackets and sorts each round by lid", () => {
|
||||
const matches = [
|
||||
makeMatch({ lid: 3, round: 0, is_losers_bracket: false }),
|
||||
makeMatch({ lid: 1, round: 0, is_losers_bracket: false }),
|
||||
makeMatch({ lid: 2, round: 0, is_losers_bracket: true }),
|
||||
makeMatch({ lid: 4, round: 1, is_losers_bracket: false }),
|
||||
];
|
||||
|
||||
const state = deriveRunState(matches);
|
||||
|
||||
expect(state.bracket.winners).toEqual([
|
||||
[matches[1], matches[0]],
|
||||
[matches[3]],
|
||||
]);
|
||||
expect(state.bracket.losers).toEqual([[matches[2]]]);
|
||||
});
|
||||
|
||||
it("picks the lowest-order ready match with both teams and no bye as next up", () => {
|
||||
const matches = [
|
||||
makeMatch({ lid: 1, round: 0, order: 5, status: "ready", home: team("a"), away: team("b") }),
|
||||
makeMatch({ lid: 2, round: 0, order: 1, status: "ready", home: team("a"), bye: false }),
|
||||
makeMatch({
|
||||
lid: 3,
|
||||
round: 0,
|
||||
order: 2,
|
||||
status: "ready",
|
||||
home: team("a"),
|
||||
away: team("b"),
|
||||
bye: true,
|
||||
}),
|
||||
makeMatch({
|
||||
lid: 4,
|
||||
round: 0,
|
||||
order: 3,
|
||||
status: "ready",
|
||||
home: team("a"),
|
||||
away: team("b"),
|
||||
}),
|
||||
];
|
||||
|
||||
const state = deriveRunState(matches);
|
||||
|
||||
expect(state.nextUpMatchId).toBe("match-4");
|
||||
});
|
||||
|
||||
it("returns no next-up match when none are ready with both teams assigned", () => {
|
||||
const matches = [
|
||||
makeMatch({ lid: 1, round: 0, status: "tbd" }),
|
||||
makeMatch({ lid: 2, round: 0, status: "ready", home: team("a") }),
|
||||
];
|
||||
|
||||
const state = deriveRunState(matches);
|
||||
|
||||
expect(state.nextUpMatchId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useMemo } from "react";
|
||||
import { BracketData } from "@/features/bracket/types";
|
||||
import { Match } from "@/features/matches/types";
|
||||
|
||||
export interface TournamentRunState {
|
||||
hasGroupStage: boolean;
|
||||
hasKnockout: boolean;
|
||||
knockoutBracketPopulated: boolean;
|
||||
nextUpMatchId: string | undefined;
|
||||
bracket: BracketData;
|
||||
}
|
||||
|
||||
export const deriveRunState = (matches: Match[] | undefined): TournamentRunState => {
|
||||
const hasGroupStage = matches?.some((match) => match.round === -1) || false;
|
||||
|
||||
const hasKnockout = matches?.some((match) => match.round !== -1) || false;
|
||||
|
||||
const knockoutBracketPopulated =
|
||||
matches?.some(
|
||||
(match) => match.round === 0 && match.lid >= 0 && (match.home || match.away)
|
||||
) || false;
|
||||
|
||||
const ready = (matches ?? [])
|
||||
.filter((match) => match.status === "ready" && match.home && match.away && !match.bye)
|
||||
.sort((a, b) => a.order - b.order);
|
||||
const nextUpMatchId = ready[0]?.id;
|
||||
|
||||
const bracket: BracketData = (() => {
|
||||
if (!matches || matches.length === 0) {
|
||||
return { winners: [], losers: [] };
|
||||
}
|
||||
|
||||
const winnersMap = new Map<number, Match[]>();
|
||||
const losersMap = new Map<number, Match[]>();
|
||||
|
||||
matches
|
||||
.filter((match) => match.round !== -1)
|
||||
.sort((a, b) => a.lid - b.lid)
|
||||
.forEach((match) => {
|
||||
if (!match.is_losers_bracket) {
|
||||
if (!winnersMap.has(match.round)) {
|
||||
winnersMap.set(match.round, []);
|
||||
}
|
||||
winnersMap.get(match.round)!.push(match);
|
||||
} else {
|
||||
if (!losersMap.has(match.round)) {
|
||||
losersMap.set(match.round, []);
|
||||
}
|
||||
losersMap.get(match.round)!.push(match);
|
||||
}
|
||||
});
|
||||
|
||||
const winners = Array.from(winnersMap.entries())
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([, matches]) => matches);
|
||||
|
||||
const losers = Array.from(losersMap.entries())
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([, matches]) => matches);
|
||||
|
||||
return { winners, losers };
|
||||
})();
|
||||
|
||||
return { hasGroupStage, hasKnockout, knockoutBracketPopulated, nextUpMatchId, bracket };
|
||||
};
|
||||
|
||||
export const useTournamentRunState = (matches: Match[] | undefined): TournamentRunState =>
|
||||
useMemo(() => deriveRunState(matches), [matches]);
|
||||
@@ -5,7 +5,6 @@ import { tournamentInputSchema, GroupStanding } from "@/features/tournaments/typ
|
||||
import { logger } from ".";
|
||||
import { z } from "zod";
|
||||
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||
import { serverFnLoggingMiddleware } from "@/utils/activities";
|
||||
import { emitServerEvent } from "@/lib/events/emitter";
|
||||
import brackets from "@/features/bracket/utils";
|
||||
import { MatchInput } from "@/features/matches/types";
|
||||
@@ -20,7 +19,7 @@ export const listTournaments = createServerFn()
|
||||
|
||||
export const createTournament = createServerFn()
|
||||
.validator(tournamentInputSchema)
|
||||
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensAdminFunctionMiddleware])
|
||||
.handler(async ({ data }) =>
|
||||
toServerResult(async () => {
|
||||
const tournament = await pbAdmin.createTournament(data);
|
||||
@@ -34,7 +33,7 @@ export const updateTournament = createServerFn()
|
||||
id: z.string(),
|
||||
updates: tournamentInputSchema.partial()
|
||||
}))
|
||||
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensAdminFunctionMiddleware])
|
||||
.handler(async ({ data }) =>
|
||||
toServerResult(async () => {
|
||||
const tournament = await pbAdmin.updateTournament(data.id, data.updates);
|
||||
@@ -61,7 +60,7 @@ export const enrollTeam = createServerFn()
|
||||
tournamentId: z.string(),
|
||||
teamId: z.string()
|
||||
}))
|
||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data: { tournamentId, teamId }, context }) =>
|
||||
toServerResult(async () => {
|
||||
const userId = context.userAuthId;
|
||||
@@ -90,7 +89,7 @@ export const unenrollTeam = createServerFn()
|
||||
tournamentId: z.string(),
|
||||
teamId: z.string()
|
||||
}))
|
||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data: { tournamentId, teamId }, context }) =>
|
||||
toServerResult(async () => {
|
||||
const result = await pbAdmin.unenrollTeam(tournamentId, teamId);
|
||||
@@ -115,7 +114,7 @@ export const getFreeAgents = createServerFn()
|
||||
|
||||
export const enrollFreeAgent = createServerFn()
|
||||
.validator(z.object({ phone: z.string(), tournamentId: z.string() }))
|
||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ context, data }) =>
|
||||
toServerResult(async () => {
|
||||
const userAuthId = context.userAuthId;
|
||||
@@ -130,7 +129,7 @@ export const enrollFreeAgent = createServerFn()
|
||||
|
||||
export const unenrollFreeAgent = createServerFn()
|
||||
.validator(z.object({ tournamentId: z.string() }))
|
||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ context, data }) =>
|
||||
toServerResult(async () => {
|
||||
const userAuthId = context.userAuthId;
|
||||
@@ -148,7 +147,7 @@ export const generateRandomTeams = createServerFn()
|
||||
tournamentId: z.string(),
|
||||
seed: z.number().optional()
|
||||
}))
|
||||
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensAdminFunctionMiddleware])
|
||||
.handler(async ({ data }) =>
|
||||
toServerResult(async () => {
|
||||
const freeAgents = await pbAdmin.getFreeAgents(data.tournamentId);
|
||||
@@ -332,7 +331,7 @@ export const confirmTeamAssignments = createServerFn()
|
||||
teamName: z.string()
|
||||
}))
|
||||
}))
|
||||
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensAdminFunctionMiddleware])
|
||||
.handler(async ({ data }) =>
|
||||
toServerResult(async () => {
|
||||
const createdTeams = [];
|
||||
@@ -527,7 +526,7 @@ export const generateKnockoutBracket = createServerFn()
|
||||
.validator(z.object({
|
||||
tournamentId: z.string(),
|
||||
}))
|
||||
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensAdminFunctionMiddleware])
|
||||
.handler(async ({ data }) =>
|
||||
toServerResult(async () => {
|
||||
logger.info('Generating knockout bracket', {
|
||||
@@ -731,7 +730,7 @@ export const adminEnrollPlayer = createServerFn()
|
||||
playerId: z.string(),
|
||||
tournamentId: z.string()
|
||||
}))
|
||||
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensAdminFunctionMiddleware])
|
||||
.handler(async ({ data }) =>
|
||||
toServerResult(async () => {
|
||||
await pbAdmin.enrollFreeAgent(data.playerId, "", data.tournamentId);
|
||||
@@ -745,7 +744,7 @@ export const adminUnenrollPlayer = createServerFn()
|
||||
playerId: z.string(),
|
||||
tournamentId: z.string()
|
||||
}))
|
||||
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensAdminFunctionMiddleware])
|
||||
.handler(async ({ data }) =>
|
||||
toServerResult(async () => {
|
||||
await pbAdmin.unenrollFreeAgent(data.playerId, data.tournamentId);
|
||||
@@ -771,7 +770,7 @@ export const generateGroupStage = createServerFn()
|
||||
})),
|
||||
seed: z.number().optional()
|
||||
}))
|
||||
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.middleware([superTokensAdminFunctionMiddleware])
|
||||
.handler(async ({ data }) =>
|
||||
toServerResult(async () => {
|
||||
logger.info('Generating group stage', {
|
||||
|
||||
Reference in New Issue
Block a user