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:
2026-08-25 21:33:25 -07:00
parent 15aeb64b19
commit e2863c1f6c
70 changed files with 2545 additions and 275 deletions
+19 -25
View File
@@ -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() });
},
});
}
+2 -3
View File
@@ -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);
+7
View File
@@ -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);
};
+2 -1
View File
@@ -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',
+4
View File
@@ -0,0 +1,4 @@
export const ADMIN_ROLE = "Admin";
export const isAdminRole = (roles?: string[] | null): boolean =>
!!roles?.includes(ADMIN_ROLE);
+7 -8
View File
@@ -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);
+3 -4
View File
@@ -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;
+1 -2
View File
@@ -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);
+5 -6
View File
@@ -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);
+2 -3
View File
@@ -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]);
+12 -13
View File
@@ -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', {