Merge branch 'admin-telemetry-redesign' into development
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 19s
CI/CD Pipeline / i18n Catalog Check (push) Successful in 41s
CI/CD Pipeline / Build and Push App Docker Image (push) Failing after 51s
CI/CD Pipeline / Deploy to Kubernetes (push) Skipped

This commit is contained in:
2026-08-25 23:36:10 -07:00
138 changed files with 8302 additions and 1784 deletions
@@ -1,268 +1,32 @@
import { useState, useMemo, memo } from "react";
import { useState, useMemo } from "react";
import { Trans, Plural, useLingui } from "@lingui/react/macro";
import {
Text,
TextInput,
Stack,
Group,
Box,
Container,
Divider,
UnstyledButton,
Select,
Pagination,
Code,
Alert,
ThemeIcon,
Title,
} from "@mantine/core";
import {
MagnifyingGlassIcon,
CaretUpIcon,
CaretDownIcon,
CheckIcon,
XIcon,
ChecksIcon,
PulseIcon,
} from "@phosphor-icons/react";
import { Alert, Code, Container, Group, Stack, Text } from "@mantine/core";
import { CheckIcon, XIcon } from "@phosphor-icons/react";
import { Activity, ActivitySearchParams } from "../types";
import { useActivities } from "../queries";
import Sheet from "@/components/sheet/sheet";
import { usePlayers } from "@/features/players/queries";
import { useSheet } from "@/hooks/use-sheet";
import {
DetailSheet,
PaginatedList,
SearchFilterBar,
TelemetryListItem,
} from "@/features/telemetry/components/shared";
interface ActivityListItemProps {
activity: Activity;
onClick: () => void;
}
const ActivityListItem = memo(({ activity, onClick }: ActivityListItemProps) => {
const { t, i18n } = useLingui();
const playerName = typeof activity.player === "object" && activity.player
? `${activity.player.first_name} ${activity.player.last_name}`
: t`System`;
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleString(i18n.locale);
};
return (
<UnstyledButton
w="100%"
p="md"
onClick={onClick}
style={{
borderRadius: 0,
transition: "background-color 0.15s ease",
}}
styles={{
root: {
'&:hover': {
backgroundColor: 'var(--mantine-color-gray-0)',
},
},
}}
>
<Group justify="space-between" align="flex-start" w="100%">
<Stack gap={4} flex={1}>
<Group gap="xs">
<Text size="sm" fw={600}>
{activity.name}
</Text>
{activity.success ? (
<CheckIcon size={16} color="var(--mantine-color-green-6)" />
) : (
<XIcon size={16} color="var(--mantine-color-red-6)" />
)}
</Group>
<Group gap="md">
<Text size="xs" c="dimmed">
{playerName}
</Text>
<Text size="xs" c="dimmed">
<Trans>{activity.duration}ms</Trans>
</Text>
<Text size="xs" c="dimmed">
{formatDate(activity.created)}
</Text>
</Group>
{activity.error && (
<Text size="xs" c="red" lineClamp={1}>
{activity.error}
</Text>
)}
</Stack>
</Group>
</UnstyledButton>
);
});
interface ActivityDetailsSheetProps {
activity: Activity | null;
isOpen: boolean;
onClose: () => void;
}
const ActivityDetailsSheet = memo(({ activity, isOpen, onClose }: ActivityDetailsSheetProps) => {
const { t, i18n } = useLingui();
if (!activity) return null;
const playerName = typeof activity.player === "object" && activity.player
? `${activity.player.first_name} ${activity.player.last_name}`
: t`System`;
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleString(i18n.locale);
};
return (
<Sheet title={t`Activity Details`} opened={isOpen} onChange={onClose}>
<Stack gap="md" p="md">
<Stack gap="xs">
<Text size="xs" fw={700} c="dimmed">
<Trans>Function Name</Trans>
</Text>
<Text size="sm">{activity.name}</Text>
</Stack>
<Stack gap="xs">
<Text size="xs" fw={700} c="dimmed">
<Trans>Status</Trans>
</Text>
<Group gap="xs">
{activity.success ? (
<>
<CheckIcon size={16} color="var(--mantine-color-green-6)" />
<Text size="sm" c="green">
<Trans>Success</Trans>
</Text>
</>
) : (
<>
<XIcon size={16} color="var(--mantine-color-red-6)" />
<Text size="sm" c="red">
<Trans>Failed</Trans>
</Text>
</>
)}
</Group>
</Stack>
<Stack gap="xs">
<Text size="xs" fw={700} c="dimmed">
<Trans>Player</Trans>
</Text>
<Text size="sm">{playerName}</Text>
</Stack>
<Stack gap="xs">
<Text size="xs" fw={700} c="dimmed">
<Trans>Duration</Trans>
</Text>
<Text size="sm"><Trans>{activity.duration}ms</Trans></Text>
</Stack>
<Stack gap="xs">
<Text size="xs" fw={700} c="dimmed">
<Trans>Created</Trans>
</Text>
<Text size="sm">{formatDate(activity.created)}</Text>
</Stack>
{activity.user_agent && (
<Stack gap="xs">
<Text size="xs" fw={700} c="dimmed">
<Trans>User Agent</Trans>
</Text>
<Text size="xs" style={{ wordBreak: "break-word" }}>
{activity.user_agent}
</Text>
</Stack>
)}
{activity.error && (
<Stack gap="xs">
<Text size="xs" fw={700} c="dimmed">
<Trans>Error Message</Trans>
</Text>
<Alert color="red" variant="light">
<Text size="sm" style={{ wordBreak: "break-word" }}>
{activity.error}
</Text>
</Alert>
</Stack>
)}
{activity.arguments && (
<Stack gap="xs">
<Text size="xs" fw={700} c="dimmed">
<Trans>Arguments</Trans>
</Text>
<Code block style={{ fontSize: "11px" }}>
{JSON.stringify(activity.arguments, null, 2)}
</Code>
</Stack>
)}
</Stack>
</Sheet>
);
});
const ActivitiesResults = ({ searchParams, page, setPage, onActivityClick }: any) => {
const { data: result } = useActivities(searchParams);
return (
<>
<Stack gap={0}>
{result.items.map((activity: Activity, index: number) => (
<Box key={activity.id}>
<ActivityListItem
activity={activity}
onClick={() => onActivityClick(activity)}
/>
{index < result.items.length - 1 && <Divider />}
</Box>
))}
</Stack>
{result.items.length === 0 && (
<Stack align="center" gap="md" py="xl">
<ThemeIcon size="xl" variant="light" radius="md">
<PulseIcon size={32} />
</ThemeIcon>
<Title order={3} c="dimmed">
<Trans>No Activities Found</Trans>
</Title>
</Stack>
)}
{result.totalPages > 1 && (
<Group justify="center" py="md">
<Pagination
total={result.totalPages}
value={page}
onChange={setPage}
size="sm"
/>
</Group>
)}
</>
);
};
const SLOW_THRESHOLD_MS = 1000;
export const ActivitiesTable = () => {
const { t } = useLingui();
const { t, i18n } = useLingui();
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [successFilter, setSuccessFilter] = useState<string | null>(null);
const [playerFilter, setPlayerFilter] = useState<string | null>(null);
const [slowOnly, setSlowOnly] = useState(false);
const [sortBy, setSortBy] = useState("-created");
const [selectedActivity, setSelectedActivity] = useState<Activity | null>(null);
const { isOpen: detailsOpened, open: openDetails, close: closeDetails } = useSheet();
const {
isOpen: detailsOpened,
open: openDetails,
close: closeDetails,
} = useSheet();
const { data: players } = usePlayers();
const searchParams: ActivitySearchParams = useMemo(
() => ({
@@ -270,13 +34,24 @@ export const ActivitiesTable = () => {
perPage: 100,
name: search || undefined,
success: successFilter === "success" ? true : successFilter === "failure" ? false : undefined,
player: playerFilter || undefined,
sortBy,
}),
[page, search, successFilter, sortBy]
[page, search, successFilter, playerFilter, sortBy]
);
const { data: result } = useActivities(searchParams);
const items = useMemo(
() => (slowOnly ? result.items.filter((a) => a.duration > SLOW_THRESHOLD_MS) : result.items),
[result.items, slowOnly]
);
const playerName = (activity: Activity) =>
typeof activity.player === "object" && activity.player
? `${activity.player.first_name} ${activity.player.last_name}`
: t`System`;
const handleSort = (field: string) => {
setSortBy((prev) => {
if (prev === field) return `-${field}`;
@@ -285,12 +60,6 @@ export const ActivitiesTable = () => {
});
};
const getSortIcon = (field: string) => {
if (sortBy === field) return <CaretUpIcon size={14} />;
if (sortBy === `-${field}`) return <CaretDownIcon size={14} />;
return null;
};
const handleActivityClick = (activity: Activity) => {
setSelectedActivity(activity);
openDetails();
@@ -304,90 +73,163 @@ export const ActivitiesTable = () => {
return (
<Container size="100%" px={0}>
<Stack gap="xs">
<Stack gap="xs" px="md">
<TextInput
placeholder={t`serverFn name`}
value={search}
onChange={(e) => {
setSearch(e.currentTarget.value);
setPage(1);
}}
leftSection={<MagnifyingGlassIcon size={16} />}
size="md"
/>
<Group>
<Select
placeholder={t`Status`}
value={successFilter}
onChange={(value) => {
<SearchFilterBar
searchValue={search}
onSearchChange={(value) => {
setSearch(value);
setPage(1);
}}
searchPlaceholder={t`serverFn name`}
filters={[
{
placeholder: t`Status`,
value: successFilter,
onChange: (value) => {
setSuccessFilter(value);
setPage(1);
}}
data={[
},
data: [
{ value: "all", label: t`All` },
{ value: "success", label: t`Success` },
{ value: "failure", label: t`Failure` },
]}
clearable
size="sm"
style={{ flex: 1 }}
/>
</Group>
</Stack>
<Group px="md" justify="space-between" align="center">
<Text size="10px" lh={0} c="dimmed">
],
},
{
placeholder: t`Player`,
value: playerFilter,
onChange: (value) => {
setPlayerFilter(value);
setPage(1);
},
data: players.map((player) => ({
value: player.id,
label: `${player.first_name} ${player.last_name}`,
})),
searchable: true,
},
]}
chips={[
{
label: t`Slow >1s (this page)`,
checked: slowOnly,
onChange: setSlowOnly,
},
]}
sortFields={[
{ field: "created", label: <Trans>Date</Trans> },
{ field: "duration", label: <Trans>Duration</Trans> },
]}
sortBy={sortBy}
onSort={handleSort}
summary={
<Plural value={result.totalItems} one="# total activity" other="# total activities" />
</Text>
<Group gap="xs">
<Text size="xs" c="dimmed">
<Trans>Sort:</Trans>
</Text>
<UnstyledButton
onClick={() => handleSort("created")}
style={{ display: "flex", alignItems: "center", gap: 4 }}
>
<Text
size="xs"
fw={sortBy.includes("created") ? 600 : 400}
c={sortBy.includes("created") ? "var(--mantine-color-text)" : "dimmed"}
>
<Trans>Date</Trans>
</Text>
{getSortIcon("created")}
</UnstyledButton>
<Text size="xs" c="dimmed">
•
</Text>
<UnstyledButton
onClick={() => handleSort("duration")}
style={{ display: "flex", alignItems: "center", gap: 4 }}
>
<Text
size="xs"
fw={sortBy.includes("duration") ? 600 : 400}
c={sortBy.includes("duration") ? "var(--mantine-color-text)" : "dimmed"}
>
<Trans>Duration</Trans>
</Text>
{getSortIcon("duration")}
</UnstyledButton>
</Group>
</Group>
}
/>
<ActivitiesResults
searchParams={searchParams}
<PaginatedList
items={items}
keyOf={(activity) => activity.id}
empty={t`No Activities Found`}
page={page}
setPage={setPage}
onActivityClick={handleActivityClick}
totalPages={result.totalPages}
onPageChange={setPage}
renderItem={(activity) => (
<TelemetryListItem
title={activity.name}
icon={
activity.success ? (
<CheckIcon size={16} color="var(--mantine-color-green-6)" />
) : (
<XIcon size={16} color="var(--mantine-color-red-6)" />
)
}
meta={[
playerName(activity),
<Trans>{activity.duration}ms</Trans>,
new Date(activity.created).toLocaleString(i18n.locale),
]}
error={activity.error}
onClick={() => handleActivityClick(activity)}
/>
)}
/>
</Stack>
<ActivityDetailsSheet
activity={selectedActivity}
isOpen={detailsOpened}
<DetailSheet
title={t`Activity Details`}
opened={detailsOpened}
onClose={handleCloseDetails}
fields={
selectedActivity
? [
{ label: <Trans>Function Name</Trans>, value: selectedActivity.name },
{
label: <Trans>Status</Trans>,
value: (
<Group gap="xs">
{selectedActivity.success ? (
<>
<CheckIcon size={16} color="var(--mantine-color-green-6)" />
<Text size="sm" c="green">
<Trans>Success</Trans>
</Text>
</>
) : (
<>
<XIcon size={16} color="var(--mantine-color-red-6)" />
<Text size="sm" c="red">
<Trans>Failed</Trans>
</Text>
</>
)}
</Group>
),
},
{ label: <Trans>Player</Trans>, value: playerName(selectedActivity) },
{
label: <Trans>Duration</Trans>,
value: (
<Text size="sm">
<Trans>{selectedActivity.duration}ms</Trans>
</Text>
),
},
{
label: <Trans>Created</Trans>,
value: new Date(selectedActivity.created).toLocaleString(i18n.locale),
},
...(selectedActivity.user_agent
? [{ label: <Trans>User Agent</Trans>, value: selectedActivity.user_agent }]
: []),
...(selectedActivity.error
? [
{
label: <Trans>Error Message</Trans>,
value: (
<Alert color="red" variant="light">
<Text size="sm" style={{ wordBreak: "break-word" }}>
{selectedActivity.error}
</Text>
</Alert>
),
},
]
: []),
...(selectedActivity.arguments
? [
{
label: <Trans>Arguments</Trans>,
value: (
<Code block style={{ fontSize: "11px" }}>
{JSON.stringify(selectedActivity.arguments, null, 2)}
</Code>
),
},
]
: []),
]
: []
}
/>
</Container>
);
+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,30 @@
import { Suspense } from "react";
import { SimpleGrid, Skeleton } from "@mantine/core";
import QuietBoundary from "./quiet-boundary";
import SectionBox from "./section-box";
import TournamentSection from "./tournament-section";
import ToolsList from "./tools-list";
const TournamentSkeleton = () => (
<SectionBox>
<Skeleton height={24} width={180} radius="sm" />
<SimpleGrid cols={3} spacing="sm" mt="sm">
<Skeleton height={72} radius="md" />
<Skeleton height={72} radius="md" />
<Skeleton height={72} radius="md" />
</SimpleGrid>
</SectionBox>
);
export const AdminDashboard = () => (
<>
<QuietBoundary>
<Suspense fallback={<TournamentSkeleton />}>
<TournamentSection />
</Suspense>
</QuietBoundary>
<ToolsList />
</>
);
export default AdminDashboard;
@@ -0,0 +1,32 @@
import { Component, ReactNode } from "react";
import { Text } from "@mantine/core";
import { Trans } from "@lingui/react/macro";
interface QuietBoundaryProps {
children: ReactNode;
}
interface QuietBoundaryState {
hasError: boolean;
}
export class QuietBoundary extends Component<QuietBoundaryProps, QuietBoundaryState> {
state: QuietBoundaryState = { hasError: false };
static getDerivedStateFromError(): QuietBoundaryState {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return (
<Text size="sm" c="dimmed">
<Trans>Unavailable right now</Trans>
</Text>
);
}
return this.props.children;
}
}
export default QuietBoundary;
@@ -0,0 +1,14 @@
import { ReactNode } from "react";
import { Box } from "@mantine/core";
export const SectionBox = ({ children }: { children: ReactNode }) => (
<Box
px="md"
py="sm"
style={{ borderBottom: "1px solid var(--mantine-color-default-border)" }}
>
{children}
</Box>
);
export default SectionBox;
@@ -1,20 +1,20 @@
import { List, Text } from "@mantine/core";
import { Plural, Trans, useLingui } from "@lingui/react/macro";
import ListLink from "@/components/list-link";
import {
DatabaseIcon,
TreeStructureIcon,
TrophyIcon,
MedalIcon,
CrownIcon,
ListIcon,
PulseIcon,
} from "@phosphor-icons/react";
import ListLink from "@/components/list-link";
import ListButton from "@/components/list-button";
import { getBadgeMigrationStatus, migrateBadgeProgress } from "@/features/badges/server";
import { useServerQuery } from "@/lib/tanstack-query/hooks";
import toast from "@/lib/sonner";
const AdminPage = () => {
export const ToolsList = () => {
const { t } = useLingui();
const { data: migrationStatus, refetch: refetchMigrationStatus } = useServerQuery({
@@ -51,6 +51,11 @@ const AdminPage = () => {
Icon={TrophyIcon}
to="/admin/tournaments"
/>
<ListLink
label={t`Telemetry`}
Icon={PulseIcon}
to="/admin/telemetry/usage"
/>
<ListLink
label={t`Award Badges`}
Icon={CrownIcon}
@@ -86,11 +91,6 @@ const AdminPage = () => {
<Trans>Badge recalculation failed. Check the server logs.</Trans>
</Text>
)}
<ListLink
label={t`Activities`}
Icon={ListIcon}
to="/admin/activities"
/>
<ListButton
label={t`Open Pocketbase`}
Icon={DatabaseIcon}
@@ -107,4 +107,4 @@ const AdminPage = () => {
);
};
export default AdminPage;
export default ToolsList;
@@ -0,0 +1,48 @@
import { SimpleGrid, Text, Title } from "@mantine/core";
import { useLingui } from "@lingui/react/macro";
import { useNavigate } from "@tanstack/react-router";
import { GearIcon, PlayIcon, UsersThreeIcon } from "@phosphor-icons/react";
import { useCurrentTournament } from "@/features/tournaments/queries";
import { NavTile } from "@/components/nav-tile";
import { Countdown } from "@/components/countdown";
import SectionBox from "./section-box";
export const TournamentSection = () => {
const { t } = useLingui();
const navigate = useNavigate();
const { data: tournament } = useCurrentTournament();
if (!tournament) return null;
const upcoming = !!tournament.start_time && Date.parse(tournament.start_time) > Date.now();
return (
<SectionBox>
<Title order={3}>{tournament.name}</Title>
{upcoming && (
<Text size="sm" c="dimmed" component="div">
<Countdown date={new Date(tournament.start_time)} label={t`Starts in`} />
</Text>
)}
<SimpleGrid cols={3} spacing="sm" mt="sm">
<NavTile
label={t`Run`}
Icon={PlayIcon}
onClick={() => navigate({ to: `/admin/tournaments/run/${tournament.id}` })}
/>
<NavTile
label={t`Enrollment`}
Icon={UsersThreeIcon}
onClick={() => navigate({ to: `/admin/tournaments/${tournament.id}/enrollment` })}
/>
<NavTile
label={t`Manage`}
Icon={GearIcon}
onClick={() => navigate({ to: `/admin/tournaments/${tournament.id}` })}
/>
</SimpleGrid>
</SectionBox>
);
};
export default TournamentSection;
+1 -1
View File
@@ -2,4 +2,4 @@ import { Logger } from "@/lib/logger";
export const logger = new Logger("Admin");
export { default as AdminPage } from "./components/admin-page";
export { default as AdminDashboard } from "./components/dashboard";
@@ -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);
@@ -1,11 +1,46 @@
import { useState } from "react";
import { Box, Title, Stack, Switch, Button, Text, Group } from "@mantine/core";
import { Trans, useLingui } from "@lingui/react/macro";
import { useWebPush } from "@/hooks/use-web-push";
import { useAuth } from "@/contexts/auth-context";
import { useIsAdmin } from "@/features/core/hooks/use-is-admin";
import { updateUserTelemetryAlerts } from "@/features/settings/server";
import toast from "@/lib/sonner";
const TelemetryAlertsSwitch = () => {
const { t } = useLingui();
const { metadata, set } = useAuth();
const [saving, setSaving] = useState(false);
const handleToggle = async (checked: boolean) => {
setSaving(true);
try {
const result = await updateUserTelemetryAlerts({ data: checked });
if (result?.metadata) {
set({ metadata: result.metadata });
}
} catch {
toast.error(t`Couldn't update telemetry alerts`);
} finally {
setSaving(false);
}
};
return (
<Switch
checked={metadata?.telemetryAlerts === true}
onChange={(e) => handleToggle(e.currentTarget.checked)}
disabled={saving}
label={t`Telemetry alerts`}
description={t`Error spikes and failing functions, pushed to this account`}
/>
);
};
export function NotificationsSection() {
const { supported, configured, permission, subscribed, busy, enable, disable, sendTest } =
useWebPush();
const isAdmin = useIsAdmin();
const { t } = useLingui();
if (!supported || !configured) {
@@ -64,6 +99,7 @@ export function NotificationsSection() {
disabled={busy}
label={t`Enable notifications on this device`}
/>
{subscribed && isAdmin && <TelemetryAlertsSwitch />}
{subscribed && (
<Group>
<Button
+32 -7
View File
@@ -1,6 +1,8 @@
import { createServerFn } from "@tanstack/react-start";
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
import { serverFnLoggingMiddleware } from "@/utils/activities";
import {
superTokensAdminFunctionMiddleware,
superTokensFunctionMiddleware,
} from "@/utils/supertokens";
// Whitelists: these values flow into Mantine's primaryColor / the
// data-mantine-color-scheme attribute — an unknown value crash-loops or
@@ -19,7 +21,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 +45,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 +71,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 +98,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 +121,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;
@@ -140,3 +142,26 @@ export const updateUserAccentColor = createServerFn({ method: "POST" })
},
};
});
export const updateUserTelemetryAlerts = createServerFn({ method: "POST" })
.validator((data: boolean) => data === true)
.middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ context, data }) => {
const { userAuthId, metadata } = context;
if (!userAuthId) return;
const { updateUserMetadataFields } = await import(
"@/utils/supertokens-core.server"
);
await updateUserMetadataFields(userAuthId, {
telemetryAlerts: data,
});
return {
metadata: {
...metadata,
telemetryAlerts: data,
},
};
});
@@ -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;
@@ -0,0 +1,87 @@
import { ReactNode } from "react";
import { Box, Group, Stack, Text, UnstyledButton } from "@mantine/core";
export interface BarListItem {
label: ReactNode;
value: number;
onClick?: () => void;
}
interface BarListProps {
items: BarListItem[];
formatValue?: (value: number) => string;
color?: string;
}
const BarRow = ({
item,
max,
formatValue,
color,
}: {
item: BarListItem;
max: number;
formatValue: (value: number) => string;
color: string;
}) => {
const content = (
<Stack gap={4} w="100%">
<Group justify="space-between" wrap="nowrap" gap="md">
<Text size="sm" truncate style={{ minWidth: 0 }}>
{item.label}
</Text>
<Text size="sm" c="dimmed" style={{ fontVariantNumeric: "tabular-nums" }}>
{formatValue(item.value)}
</Text>
</Group>
<Box
h={8}
w={`${max > 0 ? Math.max((item.value / max) * 100, 1) : 0}%`}
bg={color}
style={{ borderRadius: "0 4px 4px 0" }}
/>
</Stack>
);
if (!item.onClick) {
return <Box py={6}>{content}</Box>;
}
return (
<UnstyledButton w="100%" py={6} onClick={item.onClick}>
{content}
</UnstyledButton>
);
};
export const BarList = ({
items,
formatValue = (value) => value.toLocaleString(),
color = "var(--mantine-color-anchor)",
}: BarListProps) => {
if (items.length === 0) {
return (
<Text c="dimmed" size="sm">
&mdash;
</Text>
);
}
const max = Math.max(...items.map((item) => item.value));
return (
<Stack gap={2}>
{items.map((item, index) => (
<BarRow
key={index}
item={item}
max={max}
formatValue={formatValue}
color={color}
/>
))}
</Stack>
);
};
export default BarList;
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { niceExtent, pointsToPath } from "./chart-path";
describe("pointsToPath", () => {
it("returns an empty string for an empty series", () => {
expect(pointsToPath([], 100, 40)).toBe("");
});
it("renders a single point as a horizontal segment without NaN", () => {
const path = pointsToPath([5], 100, 40, 2);
expect(path).not.toContain("NaN");
expect(path).toBe("M2,20 L98,20");
});
it("centers a flat series vertically without NaN", () => {
const path = pointsToPath([3, 3, 3], 100, 40);
expect(path).not.toContain("NaN");
expect(path).toBe("M0,20 L50,20 L100,20");
});
it("maps min to the bottom and max to the top", () => {
const path = pointsToPath([0, 10], 100, 40);
expect(path).toBe("M0,40 L100,0");
});
it("respects padding", () => {
const path = pointsToPath([0, 10], 100, 40, 4);
expect(path).toBe("M4,36 L96,4");
});
it("uses a shared extent when provided", () => {
const path = pointsToPath([0, 5], 100, 40, 0, [0, 10]);
expect(path).toBe("M0,40 L100,20");
});
});
describe("niceExtent", () => {
it("handles an empty series", () => {
expect(niceExtent([])).toEqual([0, 1]);
});
it("handles an all-zero series", () => {
expect(niceExtent([0, 0, 0])).toEqual([0, 1]);
});
it("spreads a flat non-zero series", () => {
const [lo, hi] = niceExtent([10, 10]);
expect(lo).toBeLessThan(10);
expect(hi).toBeGreaterThan(10);
expect(Number.isNaN(lo)).toBe(false);
expect(Number.isNaN(hi)).toBe(false);
});
it("covers the data with rounded bounds", () => {
const [lo, hi] = niceExtent([3, 47, 12]);
expect(lo).toBeLessThanOrEqual(3);
expect(hi).toBeGreaterThanOrEqual(47);
});
it("never dips below zero for non-negative data", () => {
const [lo] = niceExtent([1, 2, 3]);
expect(lo).toBeGreaterThanOrEqual(0);
});
});
@@ -0,0 +1,61 @@
export function pointsToPath(
values: number[],
width: number,
height: number,
pad = 0,
extent?: [number, number]
): string {
if (values.length === 0) return "";
const [lo, hi] = extent ?? [Math.min(...values), Math.max(...values)];
const span = hi - lo;
const innerWidth = Math.max(width - pad * 2, 0);
const innerHeight = Math.max(height - pad * 2, 0);
const yFor = (value: number) =>
span === 0
? pad + innerHeight / 2
: pad + innerHeight - ((value - lo) / span) * innerHeight;
if (values.length === 1) {
const y = yFor(values[0]);
return `M${pad},${y} L${pad + innerWidth},${y}`;
}
const step = innerWidth / (values.length - 1);
return values
.map((value, index) => {
const command = index === 0 ? "M" : "L";
return `${command}${pad + step * index},${yFor(value)}`;
})
.join(" ");
}
export function niceExtent(values: number[]): [number, number] {
if (values.length === 0) return [0, 1];
let min = Math.min(...values);
let max = Math.max(...values);
if (min === max) {
if (min === 0) return [0, 1];
const spread = Math.abs(min) / 2;
min -= spread;
max += spread;
}
const step = niceStep((max - min) / 4);
let lo = Math.floor(min / step) * step;
const hi = Math.ceil(max / step) * step;
if (min >= 0 && lo < 0) lo = 0;
return [lo, hi];
}
function niceStep(rough: number): number {
const power = 10 ** Math.floor(Math.log10(rough));
const fraction = rough / power;
if (fraction <= 1) return power;
if (fraction <= 2) return 2 * power;
if (fraction <= 5) return 5 * power;
return 10 * power;
}
@@ -0,0 +1,5 @@
export { pointsToPath, niceExtent } from "./chart-path";
export { Sparkline } from "./sparkline";
export { BarList } from "./bar-list";
export type { BarListItem } from "./bar-list";
export { StatTile } from "./stat-tile";
@@ -0,0 +1,110 @@
import { Group, Text } from "@mantine/core";
import { niceExtent, pointsToPath } from "./chart-path";
const VIEW_WIDTH = 240;
const PAD = 3;
export interface SparklineScale {
start?: string;
end?: string;
formatValue?: (value: number) => string;
}
interface SparklineProps {
data: number[];
series2?: number[];
height?: number;
showArea?: boolean;
label?: string;
color?: string;
series2Color?: string;
scale?: SparklineScale;
}
export const Sparkline = ({
data,
series2,
height = 48,
showArea = false,
label,
color = "var(--mantine-color-anchor)",
series2Color = "var(--mantine-color-dimmed)",
scale,
}: SparklineProps) => {
if (data.length === 0) {
return (
<Text c="dimmed" size="sm" aria-label={label}>
&mdash;
</Text>
);
}
const extent = niceExtent(series2?.length ? [...data, ...series2] : data);
const path = pointsToPath(data, VIEW_WIDTH, height, PAD, extent);
const secondPath = series2?.length
? pointsToPath(series2, VIEW_WIDTH, height, PAD, extent)
: null;
const areaPath = showArea && !secondPath
? `${path} L${VIEW_WIDTH - PAD},${height - PAD} L${PAD},${height - PAD} Z`
: null;
const allValues = series2?.length ? [...data, ...series2] : data;
const max = Math.max(...allValues);
const formatValue = scale?.formatValue ?? ((value: number) => value.toLocaleString());
const svg = (
<svg
viewBox={`0 0 ${VIEW_WIDTH} ${height}`}
preserveAspectRatio="none"
width="100%"
height={height}
role="img"
aria-label={label}
style={{ display: "block" }}
>
{areaPath && <path d={areaPath} fill={color} fillOpacity={0.1} stroke="none" />}
<path
d={path}
fill="none"
stroke={color}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
/>
{secondPath && (
<path
d={secondPath}
fill="none"
stroke={series2Color}
strokeWidth={2}
strokeDasharray="4 3"
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
/>
)}
</svg>
);
if (!scale) return svg;
return (
<div>
{svg}
<Group justify="space-between" gap="xs" mt={2}>
<Text size="xs" c="dimmed">
{scale.start}
</Text>
<Text size="xs" c="dimmed" style={{ fontVariantNumeric: "tabular-nums" }}>
↑{formatValue(max)}
</Text>
<Text size="xs" c="dimmed">
{scale.end}
</Text>
</Group>
</div>
);
};
export default Sparkline;
@@ -0,0 +1,60 @@
import { ReactNode } from "react";
import { Box, Group, Stack, Text, UnstyledButton } from "@mantine/core";
import Sparkline, { SparklineScale } from "./sparkline";
interface StatTileProps {
label: ReactNode;
value: ReactNode;
valueColor?: string;
delta?: number;
spark?: number[];
sparkScale?: SparklineScale;
caption?: ReactNode;
onClick?: () => void;
}
export const StatTile = ({
label,
value,
valueColor,
delta,
spark,
sparkScale,
caption,
onClick,
}: StatTileProps) => {
const content = (
<Stack gap={2}>
<Group gap="xs" align="baseline" wrap="nowrap">
<Text fz="xl" fw={700} lh={1.2} c={valueColor}>
{value}
</Text>
{delta !== undefined && delta !== 0 && (
<Text size="xs" c={delta > 0 ? "green" : "red"}>
{delta > 0 ? "+" : ""}
{delta.toLocaleString()}
</Text>
)}
</Group>
<Text size="xs" c="dimmed">
{label}
</Text>
{caption && <Box>{caption}</Box>}
{spark && spark.length > 0 && (
<Sparkline data={spark} height={28} scale={sparkScale} />
)}
</Stack>
);
if (onClick) {
return (
<UnstyledButton className="flxn-press" onClick={onClick} w="100%">
{content}
</UnstyledButton>
);
}
return content;
};
export default StatTile;
@@ -0,0 +1,69 @@
import { Suspense, useMemo, useState } from "react";
import { Trans, useLingui } from "@lingui/react/macro";
import { Container, Divider, Group, Stack, Switch, Text } from "@mantine/core";
import { ListSkeleton } from "@/components/skeletons";
import { useClientErrorGroups } from "../../queries";
import { lastNDays } from "../../range";
import { ErrorGroupRow } from "./error-group-row";
const ErrorGroupList = ({
from,
to,
includeResolved,
}: {
from: string;
to: string;
includeResolved: boolean;
}) => {
const { data: groups } = useClientErrorGroups({ from, to, includeResolved });
if (groups.length === 0) {
return (
<Text size="sm" c="dimmed" px="md" py="xl" ta="center">
<Trans>No unresolved errors</Trans>
</Text>
);
}
return (
<Stack gap={0}>
{groups.map((group, index) => (
<div key={group.group_hash}>
<ErrorGroupRow group={group} />
{index < groups.length - 1 && <Divider />}
</div>
))}
</Stack>
);
};
export const ErrorBrowser = () => {
const { t } = useLingui();
const [includeResolved, setIncludeResolved] = useState(false);
const range = useMemo(() => lastNDays(30), []);
return (
<Container size="100%" px={0}>
<Stack gap="xs">
<Group px="md" justify="flex-end">
<Switch
size="sm"
label={t`Include resolved`}
checked={includeResolved}
onChange={(event) => setIncludeResolved(event.currentTarget.checked)}
/>
</Group>
<Suspense fallback={<ListSkeleton rows={6} withSubtitle />}>
<ErrorGroupList
from={range.from}
to={range.to}
includeResolved={includeResolved}
/>
</Suspense>
</Stack>
</Container>
);
};
export default ErrorBrowser;
@@ -0,0 +1,111 @@
import { useState } from "react";
import { Trans, useLingui } from "@lingui/react/macro";
import {
Box,
Button,
Code,
Collapse,
Group,
Stack,
Text,
UnstyledButton,
} from "@mantine/core";
import { useQueryClient } from "@tanstack/react-query";
import { useServerMutation } from "@/lib/tanstack-query/hooks";
import { resolveClientErrorGroup } from "../../server";
import { telemetryKeys } from "../../queries";
import type { ClientErrorGroup } from "../../types";
import { RelativeTime } from "../shared";
export const ErrorGroupRow = ({ group }: { group: ClientErrorGroup }) => {
const { t } = useLingui();
const [expanded, setExpanded] = useState(false);
const queryClient = useQueryClient();
const resolveMutation = useServerMutation({
mutationFn: (variables: { groupHash: string; resolved: boolean }) =>
resolveClientErrorGroup({ data: variables }),
successMessage: group.resolved ? t`Error group reopened` : t`Error group resolved`,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: telemetryKeys.all });
},
});
return (
<Box>
<UnstyledButton
w="100%"
p="md"
onClick={() => setExpanded((prev) => !prev)}
style={{ borderRadius: 0 }}
>
<Stack gap={4}>
<Group gap="xs" wrap="nowrap">
<Text
size="sm"
fw={700}
c={group.resolved ? "dimmed" : "red"}
style={{ flexShrink: 0, fontVariantNumeric: "tabular-nums" }}
>
{group.count.toLocaleString()}×
</Text>
<Text size="sm" fw={600} lineClamp={2} style={{ minWidth: 0 }}>
{group.sample.message}
</Text>
</Group>
<Group gap="md">
{group.sample.source && (
<Text size="xs" c="dimmed">
{group.sample.source}
</Text>
)}
<Text size="xs" c="dimmed">
<RelativeTime date={group.lastSeen} />
</Text>
{group.resolved && (
<Text size="xs" c="dimmed">
<Trans>Resolved</Trans>
</Text>
)}
</Group>
</Stack>
</UnstyledButton>
<Collapse in={expanded} transitionDuration={200}>
<Stack gap="sm" px="md" pb="md">
{group.sample.path && (
<Text size="xs" c="dimmed">
{group.sample.path}
</Text>
)}
{group.sample.stack && (
<Code
block
style={{ fontSize: "11px", maxHeight: 240, overflow: "auto" }}
>
{group.sample.stack}
</Code>
)}
<Group justify="flex-end">
<Button
size="xs"
variant="subtle"
color={group.resolved ? "yellow" : "green"}
loading={resolveMutation.isPending}
onClick={() =>
resolveMutation.mutate({
groupHash: group.group_hash,
resolved: !group.resolved,
})
}
>
{group.resolved ? <Trans>Reopen</Trans> : <Trans>Mark resolved</Trans>}
</Button>
</Group>
</Stack>
</Collapse>
</Box>
);
};
export default ErrorGroupRow;
@@ -0,0 +1,2 @@
export { ErrorBrowser } from "./error-browser";
export { ErrorGroupRow } from "./error-group-row";
@@ -0,0 +1,99 @@
import { Fragment } from "react";
import { Box, Group, Stack, Text } from "@mantine/core";
import { Plural, useLingui } from "@lingui/react/macro";
import { useSessionTimeline } from "../../queries";
import type { TimelineEntry } from "../../types";
const GAP_MARKER_MS = 5 * 60 * 1000;
const parseTs = (ts: string) => Date.parse(ts.replace(" ", "T"));
const dotColor = (entry: TimelineEntry): string => {
if (entry.kind === "error" || entry.success === false) return "var(--mantine-color-red-6)";
if (entry.kind === "server_fn") return "var(--mantine-color-green-6)";
return "var(--mantine-color-dimmed)";
};
const Dot = ({ color }: { color: string }) => (
<Box
w={6}
h={6}
style={{ borderRadius: "50%", backgroundColor: color, flexShrink: 0 }}
/>
);
export const SessionTimelineView = ({ sessionId }: { sessionId: string }) => {
const { i18n } = useLingui();
const { data } = useSessionTimeline(sessionId);
const clock = (ts: string) =>
i18n.date(new Date(parseTs(ts)), { hour: "2-digit", minute: "2-digit", second: "2-digit" });
return (
<Stack gap={0}>
<Group gap="md" pb="sm">
<Text size="xs" c="dimmed">
{i18n.date(new Date(parseTs(data.session.started)), {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</Text>
<Text size="xs" c="dimmed">
<Plural value={data.session.pageViews} one="# page" other="# pages" />
</Text>
{data.session.errors > 0 && (
<Text size="xs" c="red">
<Plural value={data.session.errors} one="# error" other="# errors" />
</Text>
)}
</Group>
{data.entries.map((entry, index) => {
const prev = data.entries[index - 1];
const gapMs = prev ? parseTs(entry.ts) - parseTs(prev.ts) : 0;
return (
<Fragment key={`${entry.ts}-${index}`}>
{gapMs > GAP_MARKER_MS && (
<Text size="xs" c="dimmed" py={4} pl={46}>
+{Math.round(gapMs / 60000)}m
</Text>
)}
<Group gap="xs" py={5} wrap="nowrap" align="center">
<Text
size="xs"
c="dimmed"
w={64}
style={{ flexShrink: 0, fontVariantNumeric: "tabular-nums" }}
>
{clock(entry.ts)}
</Text>
<Dot color={dotColor(entry)} />
<Text
size="sm"
lineClamp={1}
c={entry.kind === "error" ? "red" : undefined}
style={{ minWidth: 0, flex: 1 }}
>
{entry.label}
</Text>
{entry.kind === "server_fn" && entry.success === false && (
<Text size="xs" c="red" style={{ flexShrink: 0 }}>
✗
</Text>
)}
{entry.kind === "server_fn" && typeof entry.duration === "number" && entry.duration > 0 && (
<Text size="xs" c="dimmed" style={{ flexShrink: 0, fontVariantNumeric: "tabular-nums" }}>
{entry.duration}ms
</Text>
)}
</Group>
</Fragment>
);
})}
</Stack>
);
};
export default SessionTimelineView;
@@ -0,0 +1,81 @@
import { Suspense, useState } from "react";
import { Plural, Trans, useLingui } from "@lingui/react/macro";
import { Text } from "@mantine/core";
import Sheet from "@/components/sheet/sheet";
import { ListSkeleton } from "@/components/skeletons";
import { useSessions } from "../../queries";
import type { SessionSummary } from "../../types";
import { PaginatedList, RelativeTime, TelemetryListItem } from "../shared";
import SessionTimelineView from "./session-timeline";
export const defaultSessionParams = { days: 7, page: 1 } as const;
const playerName = (player?: SessionSummary["player"]): string | null => {
if (!player) return null;
return [player.first_name, player.last_name].filter(Boolean).join(" ") || null;
};
const durationLabel = (session: SessionSummary): string => {
const ms =
Date.parse(session.lastSeen.replace(" ", "T")) -
Date.parse(session.started.replace(" ", "T"));
const minutes = Math.round(ms / 60000);
return minutes < 1 ? "<1m" : `${minutes}m`;
};
export const SessionsList = () => {
const { t } = useLingui();
const [page, setPage] = useState(1);
const [selected, setSelected] = useState<SessionSummary | null>(null);
const { data } = useSessions({ days: 7, page });
return (
<>
<PaginatedList
items={data.items}
keyOf={(session) => session.session_id}
empty={t`No sessions yet`}
page={data.page}
totalPages={data.totalPages}
onPageChange={setPage}
renderItem={(session) => (
<TelemetryListItem
title={playerName(session.player) ?? t`Anonymous`}
meta={[
<RelativeTime key="time" date={session.lastSeen} />,
<Plural key="pages" value={session.pageViews} one="# page" other="# pages" />,
durationLabel(session),
]}
error={
session.errors > 0 ? (
<Plural value={session.errors} one="# error" other="# errors" />
) : undefined
}
onClick={() => setSelected(session)}
/>
)}
/>
<Sheet
title={selected ? (playerName(selected.player) ?? t`Anonymous`) : ""}
opened={selected !== null}
onChange={() => setSelected(null)}
>
{selected && (
<Suspense fallback={<ListSkeleton rows={8} />}>
<SessionTimelineView sessionId={selected.session_id} />
</Suspense>
)}
</Sheet>
{data.totalItems > 0 && (
<Text size="xs" c="dimmed" px="md" py="sm">
<Trans>Last 7 days</Trans>
</Text>
)}
</>
);
};
export default SessionsList;
@@ -0,0 +1,38 @@
import { ReactNode, memo } from "react";
import { Stack, Text } from "@mantine/core";
import Sheet from "@/components/sheet/sheet";
export interface DetailField {
label: ReactNode;
value: ReactNode;
}
interface DetailSheetProps {
title: string;
opened: boolean;
onClose: () => void;
fields: DetailField[];
}
export const DetailSheet = memo(({ title, opened, onClose, fields }: DetailSheetProps) => (
<Sheet title={title} opened={opened} onChange={onClose}>
<Stack gap="md" p="md">
{fields.map((field, index) => (
<Stack gap="xs" key={index}>
<Text size="xs" fw={700} c="dimmed">
{field.label}
</Text>
{typeof field.value === "string" || typeof field.value === "number" ? (
<Text size="sm" style={{ wordBreak: "break-word" }}>
{field.value}
</Text>
) : (
field.value
)}
</Stack>
))}
</Stack>
</Sheet>
));
export default DetailSheet;
@@ -0,0 +1,7 @@
export { TelemetryListItem } from "./telemetry-list-item";
export { DetailSheet } from "./detail-sheet";
export type { DetailField } from "./detail-sheet";
export { SearchFilterBar } from "./search-filter-bar";
export type { FilterSelectConfig, FilterChipConfig, SortFieldConfig } from "./search-filter-bar";
export { PaginatedList } from "./paginated-list";
export { RelativeTime } from "./relative-time";
@@ -0,0 +1,47 @@
import { ReactNode } from "react";
import { Box, Divider, Group, Pagination, Stack, Text } from "@mantine/core";
interface PaginatedListProps<T> {
items: T[];
renderItem: (item: T) => ReactNode;
keyOf: (item: T) => string;
empty: string;
page?: number;
totalPages?: number;
onPageChange?: (page: number) => void;
}
export const PaginatedList = <T,>({
items,
renderItem,
keyOf,
empty,
page = 1,
totalPages = 1,
onPageChange,
}: PaginatedListProps<T>) => (
<>
<Stack gap={0}>
{items.map((item, index) => (
<Box key={keyOf(item)}>
{renderItem(item)}
{index < items.length - 1 && <Divider />}
</Box>
))}
</Stack>
{items.length === 0 && (
<Text size="sm" c="dimmed" px="md" py="xl" ta="center">
{empty}
</Text>
)}
{totalPages > 1 && onPageChange && (
<Group justify="center" py="md">
<Pagination total={totalPages} value={page} onChange={onPageChange} size="sm" />
</Group>
)}
</>
);
export default PaginatedList;
@@ -0,0 +1,21 @@
import { useLingui } from "@lingui/react/macro";
export const RelativeTime = ({ date }: { date?: string }) => {
const { t, i18n } = useLingui();
if (!date) return <>{t`Never`}</>;
const then = new Date(date);
const diffMs = Date.now() - then.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMins < 1) return <>{t`Just now`}</>;
if (diffMins < 60) return <>{t`${diffMins}m ago`}</>;
if (diffHours < 24) return <>{t`${diffHours}h ago`}</>;
if (diffDays < 30) return <>{t`${diffDays}d ago`}</>;
return <>{then.toLocaleString(i18n.locale)}</>;
};
export default RelativeTime;
@@ -0,0 +1,134 @@
import { ReactNode } from "react";
import { Chip, Group, Select, Stack, Text, TextInput, UnstyledButton } from "@mantine/core";
import { CaretDownIcon, CaretUpIcon, MagnifyingGlassIcon } from "@phosphor-icons/react";
import { Trans } from "@lingui/react/macro";
export interface FilterSelectConfig {
placeholder: string;
value: string | null;
onChange: (value: string | null) => void;
data: { value: string; label: string }[];
searchable?: boolean;
}
export interface FilterChipConfig {
label: ReactNode;
checked: boolean;
onChange: (checked: boolean) => void;
}
export interface SortFieldConfig {
field: string;
label: ReactNode;
}
interface SearchFilterBarProps {
searchValue: string;
onSearchChange: (value: string) => void;
searchPlaceholder: string;
filters?: FilterSelectConfig[];
chips?: FilterChipConfig[];
sortFields?: SortFieldConfig[];
sortBy?: string;
onSort?: (field: string) => void;
summary?: ReactNode;
}
export const SearchFilterBar = ({
searchValue,
onSearchChange,
searchPlaceholder,
filters,
chips,
sortFields,
sortBy = "",
onSort,
summary,
}: SearchFilterBarProps) => {
const getSortIcon = (field: string) => {
if (sortBy === field) return <CaretUpIcon size={14} />;
if (sortBy === `-${field}`) return <CaretDownIcon size={14} />;
return null;
};
return (
<>
<Stack gap="xs" px="md">
<TextInput
placeholder={searchPlaceholder}
value={searchValue}
onChange={(e) => onSearchChange(e.currentTarget.value)}
leftSection={<MagnifyingGlassIcon size={16} />}
size="md"
/>
{filters && filters.length > 0 && (
<Group>
{filters.map((filter, index) => (
<Select
key={index}
placeholder={filter.placeholder}
value={filter.value}
onChange={filter.onChange}
data={filter.data}
searchable={filter.searchable}
clearable
size="sm"
style={{ flex: 1 }}
/>
))}
</Group>
)}
{chips && chips.length > 0 && (
<Group gap="xs">
{chips.map((chip, index) => (
<Chip key={index} size="xs" checked={chip.checked} onChange={chip.onChange}>
{chip.label}
</Chip>
))}
</Group>
)}
</Stack>
{(summary || (sortFields && sortFields.length > 0)) && (
<Group px="md" justify="space-between" align="center">
<Text size="10px" lh={0} c="dimmed" component="div">
{summary}
</Text>
{sortFields && sortFields.length > 0 && onSort && (
<Group gap="xs">
<Text size="xs" c="dimmed">
<Trans>Sort:</Trans>
</Text>
{sortFields.map((sortField, index) => (
<Group gap="xs" key={sortField.field}>
{index > 0 && (
<Text size="xs" c="dimmed">
•
</Text>
)}
<UnstyledButton
onClick={() => onSort(sortField.field)}
style={{ display: "flex", alignItems: "center", gap: 4 }}
>
<Text
size="xs"
fw={sortBy.includes(sortField.field) ? 600 : 400}
c={sortBy.includes(sortField.field) ? "var(--mantine-color-text)" : "dimmed"}
>
{sortField.label}
</Text>
{getSortIcon(sortField.field)}
</UnstyledButton>
</Group>
))}
</Group>
)}
</Group>
)}
</>
);
};
export default SearchFilterBar;
@@ -0,0 +1,60 @@
import { Fragment, ReactNode, memo } from "react";
import { Group, Stack, Text, UnstyledButton } from "@mantine/core";
interface TelemetryListItemProps {
title: ReactNode;
icon?: ReactNode;
meta?: ReactNode[];
error?: ReactNode;
rightSection?: ReactNode;
onClick?: () => void;
}
export const TelemetryListItem = memo(
({ title, icon, meta, error, rightSection, onClick }: TelemetryListItemProps) => (
<UnstyledButton
w="100%"
p="md"
onClick={onClick}
style={{
borderRadius: 0,
transition: "background-color 0.15s ease",
}}
styles={{
root: {
"&:hover": {
backgroundColor: "var(--mantine-color-gray-0)",
},
},
}}
>
<Group justify="space-between" align="flex-start" w="100%" wrap="nowrap">
<Stack gap={4} flex={1} style={{ minWidth: 0 }}>
<Group gap="xs" wrap="nowrap">
<Text size="sm" fw={600} truncate>
{title}
</Text>
{icon}
</Group>
{meta && meta.length > 0 && (
<Group gap="md">
{meta.map((item, index) => (
<Text key={index} size="xs" c="dimmed" component="span">
{item}
</Text>
))}
</Group>
)}
{error && (
<Text size="xs" c="red" lineClamp={1}>
{error}
</Text>
)}
</Stack>
{rightSection && <Fragment>{rightSection}</Fragment>}
</Group>
</UnstyledButton>
)
);
export default TelemetryListItem;
@@ -0,0 +1,288 @@
import { Suspense, useMemo, useState } from "react";
import { Trans, useLingui } from "@lingui/react/macro";
import {
Divider,
Group,
SegmentedControl,
SimpleGrid,
Stack,
Text,
Title,
} from "@mantine/core";
import { ListSkeleton, StatTileSkeleton } from "@/components/skeletons";
import { useRollupRange } from "../queries";
import { fillDailySeries, lastNDays, latestValue, listDays, sumByDim } from "../range";
import { BarList, Sparkline, StatTile } from "./charts";
import { DetailSheet } from "./shared";
import { useSheet } from "@/hooks/use-sheet";
export const USAGE_METRICS = [
"dau",
"wau",
"mau",
"page_view.count",
"page_view.sessions",
"server_fn.count",
"client_error.count",
"vital.p75",
];
const TOP_LIST_SIZE = 8;
type VitalName = "LCP" | "INP" | "CLS";
const VITAL_THRESHOLDS: Record<VitalName, { good: number; poor: number }> = {
LCP: { good: 2500, poor: 4000 },
INP: { good: 200, poor: 500 },
CLS: { good: 0.1, poor: 0.25 },
};
const vitalRating = (name: VitalName, value: number): "good" | "ok" | "poor" => {
const { good, poor } = VITAL_THRESHOLDS[name];
if (value <= good) return "good";
if (value > poor) return "poor";
return "ok";
};
const vitalColor = (name: VitalName, value: number): string | undefined => {
const rating = vitalRating(name, value);
if (rating === "good") return undefined;
return rating === "ok" ? "yellow.8" : "red";
};
const formatVital = (name: VitalName, value: number) =>
name === "CLS" ? value.toFixed(2) : `${Math.round(value)}ms`;
const dayLabel = (i18n: { date: (d: Date, opts?: Intl.DateTimeFormatOptions) => string }, day: string) =>
i18n.date(new Date(`${day}T12:00:00Z`), { month: "short", day: "numeric" });
const topItems = (totals: Map<string, number>, onClick?: (dim: string) => void) =>
[...totals.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, TOP_LIST_SIZE)
.map(([dim, value]) => ({
label: dim,
value,
onClick: onClick ? () => onClick(dim) : undefined,
}));
const FnDurationDetail = ({ fn, from, to }: { fn: string; from: string; to: string }) => {
const { i18n } = useLingui();
const { data: rows } = useRollupRange({
metrics: ["server_fn.duration.p50", "server_fn.duration.p95"],
from,
to,
dim: fn,
});
const days = listDays({ from, to });
const p50 = fillDailySeries(rows, "server_fn.duration.p50", fn, days);
const p95 = fillDailySeries(rows, "server_fn.duration.p95", fn, days);
const lastNonZero = (series: number[]) =>
[...series].reverse().find((value) => value > 0) ?? 0;
return (
<Stack gap="xs">
<Sparkline
data={p50}
series2={p95}
height={64}
scale={{
start: dayLabel(i18n, from),
end: dayLabel(i18n, to),
formatValue: (value) => `${Math.round(value)}ms`,
}}
/>
<Group gap="md">
<Group gap={6}>
<svg width={18} height={8} aria-hidden>
<line x1={0} y1={4} x2={18} y2={4} stroke="var(--mantine-color-anchor)" strokeWidth={2} />
</svg>
<Text size="xs" c="dimmed">
<Trans>p50</Trans> · {Math.round(lastNonZero(p50))}ms
</Text>
</Group>
<Group gap={6}>
<svg width={18} height={8} aria-hidden>
<line
x1={0}
y1={4}
x2={18}
y2={4}
stroke="var(--mantine-color-dimmed)"
strokeWidth={2}
strokeDasharray="4 3"
/>
</svg>
<Text size="xs" c="dimmed">
<Trans>p95</Trans> · {Math.round(lastNonZero(p95))}ms
</Text>
</Group>
</Group>
</Stack>
);
};
const UsageSections = ({ from, to }: { from: string; to: string }) => {
const { t, i18n } = useLingui();
const [selectedFn, setSelectedFn] = useState<string | null>(null);
const { isOpen: fnDetailOpened, open: openFnDetail, close: closeFnDetail } = useSheet();
const { data: rows } = useRollupRange({ metrics: USAGE_METRICS, from, to });
const days = useMemo(() => listDays({ from, to }), [from, to]);
const dauSeries = fillDailySeries(rows, "dau", "", days);
const errorSeries = fillDailySeries(rows, "client_error.count", "", days);
const sessions = fillDailySeries(rows, "page_view.sessions", "", days).reduce(
(sum, value) => sum + value,
0
);
const pageViewItems = topItems(sumByDim(rows, "page_view.count"));
const serverFnItems = topItems(sumByDim(rows, "server_fn.count"), (dim) => {
setSelectedFn(dim);
openFnDetail();
});
const vitals = (["LCP", "INP", "CLS"] as VitalName[]).map((name) => ({
name,
value: latestValue(rows, "vital.p75", name),
}));
const handleCloseFnDetail = () => {
setSelectedFn(null);
closeFnDetail();
};
return (
<Stack gap="lg">
<Stack gap="sm">
<Title order={4}>
<Trans>Engagement</Trans>
</Title>
<StatTile
label={<Trans>Daily active players</Trans>}
value={(dauSeries[dauSeries.length - 1] ?? 0).toLocaleString()}
spark={dauSeries}
sparkScale={{ start: dayLabel(i18n, from), end: dayLabel(i18n, to) }}
/>
<SimpleGrid cols={3} spacing="sm">
<StatTile
label={<Trans>WAU</Trans>}
value={(latestValue(rows, "wau", "") ?? 0).toLocaleString()}
/>
<StatTile
label={<Trans>MAU</Trans>}
value={(latestValue(rows, "mau", "") ?? 0).toLocaleString()}
/>
<StatTile label={<Trans>Sessions</Trans>} value={sessions.toLocaleString()} />
</SimpleGrid>
<Stack gap="xs">
<Text size="xs" c="dimmed">
<Trans>Page views by route</Trans>
</Text>
<BarList items={pageViewItems} />
</Stack>
</Stack>
<Divider />
<Stack gap="sm">
<Title order={4}>
<Trans>Server functions</Trans>
</Title>
<Stack gap="xs">
<Text size="xs" c="dimmed">
<Trans>Top functions by call count</Trans>
</Text>
<BarList items={serverFnItems} />
</Stack>
</Stack>
<Divider />
<Stack gap="sm">
<Title order={4}>
<Trans>Errors</Trans>
</Title>
<StatTile
label={<Trans>Client errors</Trans>}
value={errorSeries.reduce((sum, value) => sum + value, 0).toLocaleString()}
spark={errorSeries}
sparkScale={{ start: dayLabel(i18n, from), end: dayLabel(i18n, to) }}
/>
</Stack>
<Divider />
<Stack gap="sm">
<Title order={4}>
<Trans>Vitals</Trans>
</Title>
<SimpleGrid cols={3} spacing="sm">
{vitals.map(({ name, value }) => (
<StatTile
key={name}
label={name}
value={value !== undefined ? formatVital(name, value) : "—"}
valueColor={value !== undefined ? vitalColor(name, value) : undefined}
/>
))}
</SimpleGrid>
</Stack>
<DetailSheet
title={selectedFn ?? t`Function`}
opened={fnDetailOpened}
onClose={handleCloseFnDetail}
fields={
selectedFn
? [
{
label: <Trans>Duration p50 / p95</Trans>,
value: <FnDurationDetail fn={selectedFn} from={from} to={to} />,
},
]
: []
}
/>
</Stack>
);
};
export const UsageScreen = () => {
const { t } = useLingui();
const [rangeDays, setRangeDays] = useState<"7" | "30">("7");
const range = useMemo(() => lastNDays(Number(rangeDays)), [rangeDays]);
return (
<Stack gap="md" px="md" pb="md">
<SegmentedControl
value={rangeDays}
onChange={(value) => setRangeDays(value as "7" | "30")}
data={[
{ value: "7", label: t`7d` },
{ value: "30", label: t`30d` },
]}
fullWidth
/>
<Suspense
fallback={
<Stack gap="lg">
<StatTileSkeleton align="row" />
<SimpleGrid cols={3} spacing="sm">
<StatTileSkeleton />
<StatTileSkeleton />
<StatTileSkeleton />
</SimpleGrid>
<ListSkeleton rows={6} withTrailing="text" />
</Stack>
}
>
<UsageSections from={range.from} to={range.to} />
</Suspense>
</Stack>
);
};
export default UsageScreen;
+9
View File
@@ -0,0 +1,9 @@
export * from "./types";
export * from "./queries";
export * from "./range";
export * from "./components/charts";
export * from "./components/shared";
export { SessionsList, defaultSessionParams } from "./components/sessions/sessions-list";
export { SessionTimelineView } from "./components/sessions/session-timeline";
export { ErrorBrowser } from "./components/error-browser";
export { UsageScreen, USAGE_METRICS } from "./components/usage-screen";
+96
View File
@@ -0,0 +1,96 @@
import { useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
import {
getClientErrorGroups,
getRollupRange,
getSessionTimeline,
getTelemetryRuntimeStatus,
listSessions,
listTelemetryAlerts,
searchClientErrors,
searchClientEvents,
} from "./server";
import type { ClientErrorSearchParams, ClientEventSearchParams } from "./types";
interface ErrorGroupParams {
from: string;
to: string;
includeResolved?: boolean;
limit?: number;
}
interface RollupRangeParams {
metrics: string[];
from: string;
to: string;
dim?: string;
}
export const telemetryKeys = {
all: ["telemetry"] as const,
events: (params: ClientEventSearchParams) => ["telemetry", "events", params] as const,
errors: (params: ClientErrorSearchParams) => ["telemetry", "errors", params] as const,
errorGroups: (params: ErrorGroupParams) => ["telemetry", "errorGroups", params] as const,
rollups: (params: RollupRangeParams) => ["telemetry", "rollups", params] as const,
alerts: (page: number) => ["telemetry", "alerts", page] as const,
sessions: (params: { days?: number; page?: number }) =>
["telemetry", "sessions", params] as const,
sessionTimeline: (sessionId: string) =>
["telemetry", "sessionTimeline", sessionId] as const,
runtimeStatus: ["telemetry", "runtimeStatus"] as const,
};
export const telemetryQueries = {
events: (params: ClientEventSearchParams = {}) => ({
queryKey: telemetryKeys.events(params),
queryFn: () => searchClientEvents({ data: params }),
}),
errors: (params: ClientErrorSearchParams = {}) => ({
queryKey: telemetryKeys.errors(params),
queryFn: () => searchClientErrors({ data: params }),
}),
errorGroups: (params: ErrorGroupParams) => ({
queryKey: telemetryKeys.errorGroups(params),
queryFn: () => getClientErrorGroups({ data: params }),
}),
rollups: (params: RollupRangeParams) => ({
queryKey: telemetryKeys.rollups(params),
queryFn: () => getRollupRange({ data: params }),
}),
alerts: (page = 1) => ({
queryKey: telemetryKeys.alerts(page),
queryFn: () => listTelemetryAlerts({ data: { page } }),
}),
sessions: (params: { days?: number; page?: number } = {}) => ({
queryKey: telemetryKeys.sessions(params),
queryFn: () => listSessions({ data: params }),
}),
sessionTimeline: (sessionId: string) => ({
queryKey: telemetryKeys.sessionTimeline(sessionId),
queryFn: () => getSessionTimeline({ data: { sessionId } }),
}),
runtimeStatus: () => ({
queryKey: telemetryKeys.runtimeStatus,
queryFn: () => getTelemetryRuntimeStatus(),
}),
};
export const useClientEvents = (params: ClientEventSearchParams = {}) =>
useServerSuspenseQuery(telemetryQueries.events(params));
export const useClientErrors = (params: ClientErrorSearchParams = {}) =>
useServerSuspenseQuery(telemetryQueries.errors(params));
export const useClientErrorGroups = (params: ErrorGroupParams) =>
useServerSuspenseQuery(telemetryQueries.errorGroups(params));
export const useRollupRange = (params: RollupRangeParams) =>
useServerSuspenseQuery(telemetryQueries.rollups(params));
export const useSessions = (params: { days?: number; page?: number } = {}) =>
useServerSuspenseQuery(telemetryQueries.sessions(params));
export const useSessionTimeline = (sessionId: string) =>
useServerSuspenseQuery(telemetryQueries.sessionTimeline(sessionId));
export const useTelemetryRuntimeStatus = () =>
useServerSuspenseQuery(telemetryQueries.runtimeStatus());
+64
View File
@@ -0,0 +1,64 @@
import type { RollupRow } from "./types";
export const isoDay = (date: Date): string => date.toISOString().slice(0, 10);
export interface DayRange {
from: string;
to: string;
}
export const lastNDays = (days: number): DayRange => {
const now = Date.now();
return {
from: isoDay(new Date(now - (days - 1) * 86400000)),
to: isoDay(new Date(now)),
};
};
export const listDays = ({ from, to }: DayRange): string[] => {
const start = Date.parse(`${from}T00:00:00Z`);
const end = Date.parse(`${to}T00:00:00Z`);
const days: string[] = [];
for (let ts = start; ts <= end; ts += 86400000) {
days.push(isoDay(new Date(ts)));
}
return days;
};
export const fillDailySeries = (
rows: RollupRow[],
metric: string,
dim: string,
days: string[]
): number[] => {
const byDate = new Map<string, number>();
for (const row of rows) {
if (row.metric === metric && row.dim === dim) {
byDate.set(row.date, (byDate.get(row.date) ?? 0) + row.value);
}
}
return days.map((day) => byDate.get(day) ?? 0);
};
export const sumByDim = (rows: RollupRow[], metric: string): Map<string, number> => {
const totals = new Map<string, number>();
for (const row of rows) {
if (row.metric === metric && row.dim !== "") {
totals.set(row.dim, (totals.get(row.dim) ?? 0) + row.value);
}
}
return totals;
};
export const latestValue = (
rows: RollupRow[],
metric: string,
dim: string
): number | undefined => {
let latest: RollupRow | undefined;
for (const row of rows) {
if (row.metric !== metric || row.dim !== dim) continue;
if (!latest || row.date > latest.date) latest = row;
}
return latest?.value;
};
+381
View File
@@ -0,0 +1,381 @@
import { createServerFn } from "@tanstack/react-start";
import { z } from "zod";
import { superTokensAdminFunctionMiddleware } from "@/utils/supertokens";
import { pbAdmin } from "@/lib/pocketbase/client";
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
import {
transformClientError,
transformClientEvent,
} from "@/lib/pocketbase/util/transform-types";
import { ensureTelemetryScheduler, getSchedulerStatus } from "@/lib/telemetry/scheduler.server";
import { getHealthSnapshot } from "@/lib/telemetry/health.server";
import { getActiveConnectionCount } from "@/lib/events/emitter";
import type {
ClientError,
ClientErrorGroup,
ClientEvent,
RollupRow,
SessionSummary,
SessionTimeline,
TelemetryAlert,
TelemetryListResult,
TelemetryRuntimeStatus,
TimelineEntry,
} from "./types";
const clientEventSearchSchema = z.object({
page: z.number().optional(),
perPage: z.number().max(200).optional(),
name: z.string().optional(),
player: z.string().optional(),
sessionId: z.string().optional(),
path: z.string().optional(),
from: z.string().optional(),
to: z.string().optional(),
sortBy: z.string().optional(),
});
export const searchClientEvents = createServerFn()
.validator(clientEventSearchSchema)
.middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data }) =>
toServerResult<TelemetryListResult<ClientEvent>>(async () => {
const result = await pbAdmin.searchClientEvents(data);
return { ...result, items: result.items.map(transformClientEvent) };
})
);
const clientErrorSearchSchema = z.object({
page: z.number().optional(),
perPage: z.number().max(200).optional(),
groupHash: z.string().optional(),
resolved: z.boolean().optional(),
player: z.string().optional(),
from: z.string().optional(),
to: z.string().optional(),
sortBy: z.string().optional(),
});
export const searchClientErrors = createServerFn()
.validator(clientErrorSearchSchema)
.middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data }) =>
toServerResult<TelemetryListResult<ClientError>>(async () => {
const result = await pbAdmin.searchClientErrors(data);
return { ...result, items: result.items.map(transformClientError) };
})
);
const errorGroupsSchema = z.object({
from: z.string(),
to: z.string(),
includeResolved: z.boolean().optional(),
limit: z.number().max(100).optional(),
});
export const getClientErrorGroups = createServerFn()
.validator(errorGroupsSchema)
.middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data }) =>
toServerResult<ClientErrorGroup[]>(async () => {
const { from, to, includeResolved = false, limit = 50 } = data;
const rollups = await pbAdmin.getRollupRange({
metrics: ["client_error.count"],
from,
to,
});
const countByGroup = new Map<string, number>();
for (const rollup of rollups) {
if (!rollup.dim) continue;
countByGroup.set(rollup.dim, (countByGroup.get(rollup.dim) ?? 0) + rollup.value);
}
const topGroups = [...countByGroup.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, limit);
const groups: ClientErrorGroup[] = [];
for (const [groupHash, count] of topGroups) {
const latest = await pbAdmin.searchClientErrors({ groupHash, perPage: 1 });
const sample = latest.items[0];
if (!sample) continue;
const resolved = !!sample.resolved;
if (resolved && !includeResolved) continue;
groups.push({
group_hash: groupHash,
count,
lastSeen: sample.created,
resolved,
sample: {
message: sample.message,
stack: sample.stack,
path: sample.path,
source: sample.source,
},
});
}
return groups;
})
);
export const resolveClientErrorGroup = createServerFn({ method: "POST" })
.validator(z.object({ groupHash: z.string(), resolved: z.boolean().optional() }))
.middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data }) =>
toServerResult<{ updated: number }>(async () => {
const updated = await pbAdmin.resolveErrorsByGroup(data.groupHash, data.resolved ?? true);
return { updated };
})
);
const rollupRangeSchema = z.object({
metrics: z.array(z.string()).min(1).max(8),
from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
dim: z.string().optional(),
});
export const getRollupRange = createServerFn()
.validator(rollupRangeSchema)
.middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data }) =>
toServerResult<RollupRow[]>(async () => pbAdmin.getRollupRange(data))
);
export const listTelemetryAlerts = createServerFn()
.validator(
z.object({ page: z.number().optional(), perPage: z.number().max(200).optional() })
)
.middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data }) =>
toServerResult<TelemetryListResult<TelemetryAlert>>(async () =>
pbAdmin.listTelemetryAlerts(data.page, data.perPage)
)
);
const pbTime = (date: Date): string => date.toISOString().replace("T", " ");
const playerInfoById = async (ids: Set<string>) => {
if (ids.size === 0) return new Map<string, { id: string; first_name?: string; last_name?: string }>();
const players = await pbAdmin.listPlayers();
return new Map(
players
.filter((player) => ids.has(player.id))
.map((player) => [
player.id,
{ id: player.id, first_name: player.first_name, last_name: player.last_name },
])
);
};
export const listSessions = createServerFn()
.validator(
z.object({
days: z.number().min(1).max(90).optional(),
page: z.number().optional(),
perPage: z.number().max(100).optional(),
})
)
.middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data }) =>
toServerResult<TelemetryListResult<SessionSummary>>(async () => {
const { days = 7, page = 1, perPage = 30 } = data;
const from = pbTime(new Date(Date.now() - days * 86400000));
const sessions = new Map<
string,
{ playerId?: string; started: string; lastSeen: string; pageViews: number; events: number; errors: number; user_agent?: string }
>();
await pbAdmin.pageCollection<{
session_id?: string;
player?: string;
name: string;
user_agent?: string;
created: string;
}>(
"client_events",
{ filter: `created >= '${from}'`, fields: "session_id,player,name,user_agent,created" },
(rows) => {
for (const row of rows) {
if (!row.session_id) continue;
const session = sessions.get(row.session_id) ?? {
playerId: row.player,
started: row.created,
lastSeen: row.created,
pageViews: 0,
events: 0,
errors: 0,
user_agent: row.user_agent,
};
session.playerId ||= row.player;
if (row.created < session.started) session.started = row.created;
if (row.created > session.lastSeen) session.lastSeen = row.created;
if (row.name === "page_view") session.pageViews++;
else if (!row.name.startsWith("vital.")) session.events++;
sessions.set(row.session_id, session);
}
}
);
await pbAdmin.pageCollection<{ session_id?: string; created: string }>(
"client_errors",
{ filter: `created >= '${from}'`, fields: "session_id,created" },
(rows) => {
for (const row of rows) {
if (!row.session_id) continue;
const session = sessions.get(row.session_id);
if (session) session.errors++;
}
}
);
const playerIds = new Set(
[...sessions.values()].map((s) => s.playerId).filter((id): id is string => !!id)
);
const players = await playerInfoById(playerIds);
const all: SessionSummary[] = [...sessions.entries()]
.map(([session_id, s]) => ({
session_id,
player: s.playerId ? players.get(s.playerId) : undefined,
started: s.started,
lastSeen: s.lastSeen,
pageViews: s.pageViews,
events: s.events,
errors: s.errors,
user_agent: s.user_agent,
}))
.sort((a, b) => (a.lastSeen < b.lastSeen ? 1 : -1));
const start = (page - 1) * perPage;
return {
items: all.slice(start, start + perPage),
page,
perPage,
totalPages: Math.max(1, Math.ceil(all.length / perPage)),
totalItems: all.length,
};
})
);
const SESSION_WINDOW_PAD_MS = 30_000;
const MAX_TIMELINE_ENTRIES = 500;
export const getSessionTimeline = createServerFn()
.validator(z.object({ sessionId: z.string().min(8).max(64) }))
.middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data }) =>
toServerResult<SessionTimeline>(async () => {
const events = await pbAdmin.searchClientEvents({
sessionId: data.sessionId,
perPage: 200,
sortBy: "created",
});
const errors = await pbAdmin.searchClientErrors({
sessionId: data.sessionId,
perPage: 200,
sortBy: "created",
});
const sessionErrors = errors.items;
if (events.items.length === 0 && sessionErrors.length === 0) {
throw new Error("Session not found");
}
const timestamps = [
...events.items.map((e) => e.created),
...sessionErrors.map((e) => e.created),
].sort();
const started = timestamps[0];
const lastSeen = timestamps[timestamps.length - 1];
const playerId = events.items.find((e) => typeof e.player === "string" && e.player)
?.player as string | undefined;
const entries: TimelineEntry[] = [];
for (const event of events.items) {
if (event.name.startsWith("vital.")) continue;
entries.push({
ts: event.created,
kind: event.name === "page_view" ? "page_view" : "event",
label: event.name === "page_view" ? event.path || event.route_id || "?" : event.name,
detail: event.name === "page_view" ? undefined : event.path,
});
}
for (const error of sessionErrors) {
entries.push({
ts: error.created,
kind: "error",
label: error.message,
detail: error.path,
success: false,
});
}
// Server-fn activities have no session id; join by player inside the
// session's padded time window.
if (playerId) {
const windowFrom = pbTime(new Date(Date.parse(started.replace(" ", "T")) - SESSION_WINDOW_PAD_MS));
const windowTo = pbTime(new Date(Date.parse(lastSeen.replace(" ", "T")) + SESSION_WINDOW_PAD_MS));
const activities = await pbAdmin.searchActivities({
player: playerId,
from: windowFrom,
to: windowTo,
perPage: 200,
sortBy: "created",
});
for (const activity of activities.items) {
entries.push({
ts: activity.created,
kind: "server_fn",
label: activity.name,
detail: activity.error,
success: activity.success,
duration: activity.duration,
});
}
}
entries.sort((a, b) => (a.ts < b.ts ? -1 : 1));
const players = await playerInfoById(new Set(playerId ? [playerId] : []));
return {
session: {
session_id: data.sessionId,
player: playerId ? players.get(playerId) : undefined,
started,
lastSeen,
pageViews: events.items.filter((e) => e.name === "page_view").length,
events: events.items.filter(
(e) => e.name !== "page_view" && !e.name.startsWith("vital.")
).length,
errors: sessionErrors.length,
user_agent: events.items[0]?.user_agent,
},
entries: entries.slice(0, MAX_TIMELINE_ENTRIES),
};
})
);
export const getTelemetryRuntimeStatus = createServerFn()
.middleware([superTokensAdminFunctionMiddleware])
.handler(async () =>
toServerResult<TelemetryRuntimeStatus>(async () => {
ensureTelemetryScheduler();
const health = await getHealthSnapshot();
return {
health: { status: health.status, checks: health.checks },
scheduler: getSchedulerStatus(),
activeSseConnections: getActiveConnectionCount(),
};
})
);
+65
View File
@@ -0,0 +1,65 @@
export type {
ClientEventRecord as ClientEvent,
ClientErrorRecord as ClientError,
ClientErrorSource,
ClientEventSearchParams,
ClientErrorSearchParams,
RollupRecord as RollupRow,
TelemetryAlertRecord as TelemetryAlert,
TelemetryListResult,
} from "@/lib/pocketbase/services/telemetry";
export interface ClientErrorGroup {
group_hash: string;
count: number;
lastSeen: string;
resolved: boolean;
sample: {
message: string;
stack?: string;
path?: string;
source?: string;
};
}
export interface SessionSummary {
session_id: string;
player?: { id: string; first_name?: string; last_name?: string };
started: string;
lastSeen: string;
pageViews: number;
events: number;
errors: number;
user_agent?: string;
}
export type TimelineKind = "page_view" | "event" | "error" | "server_fn";
export interface TimelineEntry {
ts: string;
kind: TimelineKind;
label: string;
detail?: string;
success?: boolean;
duration?: number;
}
export interface SessionTimeline {
session: SessionSummary;
entries: TimelineEntry[];
}
export interface TelemetryRuntimeStatus {
health: {
status: "ok" | "degraded";
checks: { pocketbase: string; supertokens: string };
};
scheduler: {
running: boolean;
lastRollupAt?: string;
lastAlertCheckAt?: string;
lastRollupError?: string;
lastAlertError?: string;
};
activeSseConnections: number;
}
@@ -1,184 +0,0 @@
import {
Stack,
ActionIcon,
Text,
Group,
Loader,
} from "@mantine/core";
import Typeahead, { TypeaheadOption } from "@/components/typeahead";
import { TrashIcon } from "@phosphor-icons/react";
import { useState, useCallback, useMemo, memo } from "react";
import { Trans, Plural, useLingui } from "@lingui/react/macro";
import { useTournament, useUnenrolledTeams } from "../queries";
import useEnrollTeam from "../hooks/use-enroll-team";
import useUnenrollTeam from "../hooks/use-unenroll-team";
import TeamAvatar from "@/components/team-avatar";
import { Team, TeamInfo } from "@/features/teams/types";
interface EditEnrolledTeamsProps {
tournamentId: string;
}
interface TeamItemProps {
team: TeamInfo;
onUnenroll: (teamId: string) => void;
disabled: boolean;
isRegional?: boolean;
}
const TeamItem = memo(({ team, onUnenroll, disabled, isRegional }: TeamItemProps) => {
const playerNames = useMemo(
() =>
team.players?.map((p) => `${p.first_name} ${p.last_name}`).join(", ") ||
"",
[team.players]
);
return (
<Group py="xs" px="sm" w="100%" gap="sm" align="center">
<TeamAvatar
team={team}
size={32}
radius="sm"
isRegional={isRegional}
/>
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} truncate>
{team.name}
</Text>
{playerNames && (
<Text size="xs" c="dimmed" truncate>
{playerNames}
</Text>
)}
</Stack>
<ActionIcon
variant="subtle"
color="red"
onClick={() => onUnenroll(team.id)}
disabled={disabled}
size="sm"
>
<TrashIcon size={14} />
</ActionIcon>
</Group>
);
});
const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
const { t } = useLingui();
const { data: tournament, isLoading: tournamentLoading } =
useTournament(tournamentId);
const { data: unenrolledTeams = [], isLoading: unenrolledLoading } =
useUnenrolledTeams(tournamentId);
const isRegional = tournament?.regional;
const { mutate: enrollTeam, isPending: isEnrolling } = useEnrollTeam();
const { mutate: unenrollTeam, isPending: isUnenrolling } = useUnenrollTeam();
const searchTeams = async (query: string): Promise<TypeaheadOption<Team>[]> => {
if (!query.trim()) return [];
const filtered = unenrolledTeams.filter((team: Team) =>
team.name.toLowerCase().includes(query.toLowerCase())
);
return filtered.map((team: Team) => ({
id: team.id,
data: team
}));
};
const handleEnrollTeam = useCallback(
(option: TypeaheadOption<Team>) => {
enrollTeam({ tournamentId, teamId: option.data.id });
},
[enrollTeam, tournamentId]
);
const handleUnenrollTeam = useCallback(
(teamId: string) => {
unenrollTeam({ tournamentId, teamId });
},
[unenrollTeam, tournamentId]
);
const renderTeamOption = (option: TypeaheadOption<Team>) => {
const team = option.data;
return (
<Group py="xs" px="sm" gap="sm" align="center">
<TeamAvatar
team={team as any}
size={32}
radius="sm"
isRegional={isRegional}
/>
<Text fw={500} truncate>
{team.name}
</Text>
</Group>
);
};
const formatTeam = (option: TypeaheadOption<Team>) => {
return option.data.name;
};
const isLoading = tournamentLoading || unenrolledLoading;
const enrolledTeams = tournament?.teams || [];
const hasEnrolledTeams = enrolledTeams.length > 0;
return (
<Stack gap="lg" w="100%">
<Stack gap="xs">
<Text fw={600} size="sm">
<Trans>Add Team</Trans>
</Text>
<Typeahead
placeholder={t`Search for teams to enroll...`}
onSelect={handleEnrollTeam}
searchFn={searchTeams}
renderOption={renderTeamOption}
format={formatTeam}
disabled={isEnrolling || unenrolledLoading}
/>
</Stack>
<Stack gap="xs">
<Group justify="space-between">
<Text fw={600} size="sm">
<Trans>Enrolled Teams</Trans>
</Text>
<Text size="xs" c="dimmed">
<Plural value={enrolledTeams.length} one="# team" other="# teams" />
</Text>
</Group>
{isLoading ? (
<Group justify="center" py="md">
<Loader size="sm" />
</Group>
) : !hasEnrolledTeams ? (
<Text size="sm" c="dimmed" ta="center" py="lg">
<Trans>No teams enrolled yet</Trans>
</Text>
) : (
<Stack gap="xs" w="100%">
{enrolledTeams.map((team: TeamInfo) => (
<TeamItem
key={team.id}
team={team}
onUnenroll={handleUnenrollTeam}
disabled={isUnenrolling}
isRegional={isRegional}
/>
))}
</Stack>
)}
</Stack>
</Stack>
);
};
export default EditEnrolledTeams;
@@ -0,0 +1,48 @@
import { Alert, Group, Stack, Text } from "@mantine/core";
import { WarningCircleIcon } from "@phosphor-icons/react";
import { Trans, Plural } from "@lingui/react/macro";
interface EnrollmentStatsProps {
count: number;
regional?: boolean;
}
const EnrollmentStats = (props: EnrollmentStatsProps) => {
const hasOddPlayers = props.count % 2 !== 0;
const hasEnoughPlayers = props.count >= 2;
return (
<Stack gap="xs">
<Group gap="xs" align="baseline">
<Text size="xl" fw={700}>
{props.count}
</Text>
<Text size="sm" c="dimmed">
{props.regional ? (
<Plural
value={props.count}
one="player enrolled"
other="players enrolled"
/>
) : (
<Trans>Enrolled Teams</Trans>
)}
</Text>
</Group>
{props.regional && !hasEnoughPlayers && (
<Alert color="yellow" icon={<WarningCircleIcon size={16} />}>
<Trans>Need at least 2 players to create teams</Trans>
</Alert>
)}
{props.regional && hasOddPlayers && (
<Alert color="red" icon={<WarningCircleIcon size={16} />}>
<Trans>Cannot create teams with an odd number of players. Please have one player unenroll.</Trans>
</Alert>
)}
</Stack>
);
};
export default EnrollmentStats;
@@ -4,19 +4,23 @@ import {
Text,
Group,
Loader,
Box,
} from "@mantine/core";
import { TrashIcon } from "@phosphor-icons/react";
import { TrashIcon, ShuffleIcon } from "@phosphor-icons/react";
import { useCallback, memo } from "react";
import { Trans, Plural, useLingui } from "@lingui/react/macro";
import { useFreeAgents } from "../queries";
import { useNavigate } from "@tanstack/react-router";
import Button from "@/components/button";
import { useFreeAgents } from "../../queries";
import PlayerAvatar from "@/components/player-avatar";
import { PlayerInfo, Player } from "@/features/players/types";
import Typeahead, { TypeaheadOption } from "@/components/typeahead";
import { usePlayers } from "@/features/players/queries";
import useAdminEnrollPlayer from "../hooks/use-admin-enroll-player";
import useAdminUnenrollPlayer from "../hooks/use-admin-unenroll-player";
import useAdminEnrollPlayer from "../../hooks/use-admin-enroll-player";
import useAdminUnenrollPlayer from "../../hooks/use-admin-unenroll-player";
import EnrollmentStats from "./enrollment-stats";
interface EditEnrolledPlayersProps {
interface FreeAgentSectionProps {
tournamentId: string;
}
@@ -51,8 +55,9 @@ const PlayerItem = memo(({ player, onRemove, disabled }: PlayerItemProps) => {
);
});
const EditEnrolledPlayers = ({ tournamentId }: EditEnrolledPlayersProps) => {
const FreeAgentSection = ({ tournamentId }: FreeAgentSectionProps) => {
const { t } = useLingui();
const navigate = useNavigate();
const { data: freeAgents = [], isLoading } = useFreeAgents(tournamentId);
const { data: allPlayers = [] } = usePlayers();
@@ -112,19 +117,37 @@ const EditEnrolledPlayers = ({ tournamentId }: EditEnrolledPlayersProps) => {
return (
<Stack gap="lg" w="100%">
<Stack gap="xs">
<Text fw={600} size="sm">
<Trans>Add Player</Trans>
</Text>
<Typeahead
placeholder={t`Search for players to enroll...`}
onSelect={handleEnrollPlayer}
searchFn={searchPlayers}
renderOption={renderPlayerOption}
format={formatPlayer}
disabled={isEnrolling}
/>
</Stack>
<EnrollmentStats count={enrolledPlayers.length} regional />
<Box
pos="sticky"
top={0}
py="xs"
style={{ zIndex: 5, backgroundColor: "var(--mantine-color-body)" }}
>
<Stack gap="xs">
<Text fw={600} size="sm">
<Trans>Add Player</Trans>
</Text>
<Typeahead
placeholder={t`Search for players to enroll...`}
onSelect={handleEnrollPlayer}
searchFn={searchPlayers}
renderOption={renderPlayerOption}
format={formatPlayer}
disabled={isEnrolling}
/>
</Stack>
</Box>
<Button
leftSection={<ShuffleIcon size={18} />}
onClick={() =>
navigate({ to: `/admin/tournaments/${tournamentId}/assign-partners` })
}
>
<Trans>Assign Partners</Trans>
</Button>
<Stack gap="xs">
<Group justify="space-between">
@@ -161,4 +184,4 @@ const EditEnrolledPlayers = ({ tournamentId }: EditEnrolledPlayersProps) => {
);
};
export default EditEnrolledPlayers;
export default FreeAgentSection;
@@ -0,0 +1,17 @@
import { Tournament } from "../../types";
import FreeAgentSection from "./free-agent-section";
import TeamSection from "./team-section";
interface EnrollmentProps {
tournament: Tournament;
}
const Enrollment = ({ tournament }: EnrollmentProps) => {
return tournament.regional === true ? (
<FreeAgentSection tournamentId={tournament.id} />
) : (
<TeamSection tournamentId={tournament.id} />
);
};
export default Enrollment;
@@ -0,0 +1,283 @@
import {
Stack,
ActionIcon,
Text,
Group,
Loader,
Box,
Button as MantineButton,
} from "@mantine/core";
import Typeahead, { TypeaheadOption } from "@/components/typeahead";
import { TrashIcon, PencilIcon, MusicNotesIcon } from "@phosphor-icons/react";
import { useState, useCallback, useMemo, memo } from "react";
import { Trans, Plural, useLingui } from "@lingui/react/macro";
import { useNavigate } from "@tanstack/react-router";
import { useTournament, useUnenrolledTeams } from "../../queries";
import useEnrollTeam from "../../hooks/use-enroll-team";
import useUnenrollTeam from "../../hooks/use-unenroll-team";
import TeamAvatar from "@/components/team-avatar";
import { Team, TeamInfo } from "@/features/teams/types";
import Sheet from "@/components/sheet/sheet";
import TeamForm from "@/features/teams/components/team-form";
import { useTeam } from "@/features/teams/queries";
import { useSheet } from "@/hooks/use-sheet";
import EnrollmentStats from "./enrollment-stats";
interface TeamSectionProps {
tournamentId: string;
}
interface TeamItemProps {
team: TeamInfo;
onUnenroll: (teamId: string) => void;
onEdit: (teamId: string) => void;
disabled: boolean;
isRegional?: boolean;
}
const TeamItem = memo(({ team, onUnenroll, onEdit, disabled, isRegional }: TeamItemProps) => {
const playerNames = useMemo(
() =>
team.players?.map((p) => `${p.first_name} ${p.last_name}`).join(", ") ||
"",
[team.players]
);
return (
<Group py="xs" px="sm" w="100%" gap="sm" align="center">
<TeamAvatar
team={team}
size={32}
radius="sm"
isRegional={isRegional}
/>
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} truncate>
{team.name}
</Text>
{playerNames && (
<Text size="xs" c="dimmed" truncate>
{playerNames}
</Text>
)}
</Stack>
<ActionIcon
variant="subtle"
onClick={() => onEdit(team.id)}
size="sm"
>
<PencilIcon size={14} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
onClick={() => onUnenroll(team.id)}
disabled={disabled}
size="sm"
>
<TrashIcon size={14} />
</ActionIcon>
</Group>
);
});
interface TeamEditSheetProps {
teamId: string;
isOpen: boolean;
onClose: () => void;
}
const TeamEditSheet = ({ teamId, isOpen, onClose }: TeamEditSheetProps) => {
const { data: team } = useTeam(teamId);
const { t } = useLingui();
return (
<Sheet
title={team ? t`Edit ${team.name}` : t`Edit Team`}
opened={isOpen}
onChange={onClose}
>
{team && (
<TeamForm
teamId={team.id}
initialValues={{
...team,
players: team.players ? team.players.map((p) => p.id) : [],
logo: typeof team.logo === "string" ? undefined : team.logo,
}}
close={onClose}
/>
)}
</Sheet>
);
};
const TeamSection = ({ tournamentId }: TeamSectionProps) => {
const { t } = useLingui();
const navigate = useNavigate();
const { data: tournament, isLoading: tournamentLoading } =
useTournament(tournamentId);
const { data: unenrolledTeams = [], isLoading: unenrolledLoading } =
useUnenrolledTeams(tournamentId);
const isRegional = tournament?.regional;
const { mutate: enrollTeam, isPending: isEnrolling } = useEnrollTeam();
const { mutate: unenrollTeam, isPending: isUnenrolling } = useUnenrollTeam();
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
const {
isOpen: editTeamOpened,
open: openEditTeam,
close: closeEditTeam,
} = useSheet();
const handleEditTeam = useCallback(
(teamId: string) => {
setSelectedTeamId(teamId);
openEditTeam();
},
[openEditTeam]
);
const handleCloseEditTeam = useCallback(() => {
setSelectedTeamId(null);
closeEditTeam();
}, [closeEditTeam]);
const searchTeams = async (query: string): Promise<TypeaheadOption<Team>[]> => {
if (!query.trim()) return [];
const filtered = unenrolledTeams.filter((team: Team) =>
team.name.toLowerCase().includes(query.toLowerCase())
);
return filtered.map((team: Team) => ({
id: team.id,
data: team
}));
};
const handleEnrollTeam = useCallback(
(option: TypeaheadOption<Team>) => {
enrollTeam({ tournamentId, teamId: option.data.id });
},
[enrollTeam, tournamentId]
);
const handleUnenrollTeam = useCallback(
(teamId: string) => {
unenrollTeam({ tournamentId, teamId });
},
[unenrollTeam, tournamentId]
);
const renderTeamOption = (option: TypeaheadOption<Team>) => {
const team = option.data;
return (
<Group py="xs" px="sm" gap="sm" align="center">
<TeamAvatar
team={team as any}
size={32}
radius="sm"
isRegional={isRegional}
/>
<Text fw={500} truncate>
{team.name}
</Text>
</Group>
);
};
const formatTeam = (option: TypeaheadOption<Team>) => {
return option.data.name;
};
const isLoading = tournamentLoading || unenrolledLoading;
const enrolledTeams = tournament?.teams || [];
const hasEnrolledTeams = enrolledTeams.length > 0;
return (
<>
<Stack gap="lg" w="100%">
<EnrollmentStats count={enrolledTeams.length} />
<Box
pos="sticky"
top={0}
py="xs"
style={{ zIndex: 5, backgroundColor: "var(--mantine-color-body)" }}
>
<Stack gap="xs">
<Text fw={600} size="sm">
<Trans>Add Team</Trans>
</Text>
<Typeahead
placeholder={t`Search for teams to enroll...`}
onSelect={handleEnrollTeam}
searchFn={searchTeams}
renderOption={renderTeamOption}
format={formatTeam}
disabled={isEnrolling || unenrolledLoading}
/>
</Stack>
</Box>
<MantineButton
fullWidth
variant="light"
leftSection={<MusicNotesIcon size={18} />}
onClick={() =>
navigate({ to: `/admin/tournaments/${tournamentId}/teams` })
}
>
<Trans>Manage Team Songs/Logos</Trans>
</MantineButton>
<Stack gap="xs">
<Group justify="space-between">
<Text fw={600} size="sm">
<Trans>Enrolled Teams</Trans>
</Text>
<Text size="xs" c="dimmed">
<Plural value={enrolledTeams.length} one="# team" other="# teams" />
</Text>
</Group>
{isLoading ? (
<Group justify="center" py="md">
<Loader size="sm" />
</Group>
) : !hasEnrolledTeams ? (
<Text size="sm" c="dimmed" ta="center" py="lg">
<Trans>No teams enrolled yet</Trans>
</Text>
) : (
<Stack gap="xs" w="100%">
{enrolledTeams.map((team: TeamInfo) => (
<TeamItem
key={team.id}
team={team}
onUnenroll={handleUnenrollTeam}
onEdit={handleEditTeam}
disabled={isUnenrolling}
isRegional={isRegional}
/>
))}
</Stack>
)}
</Stack>
</Stack>
{selectedTeamId && (
<TeamEditSheet
teamId={selectedTeamId}
isOpen={editTeamOpened}
onClose={handleCloseEditTeam}
/>
)}
</>
);
};
export default TeamSection;
@@ -8,14 +8,9 @@ import {
PencilLineIcon,
TreeStructureIcon,
UsersThreeIcon,
UsersIcon,
ShuffleIcon,
} from "@phosphor-icons/react";
import { useSheet } from "@/hooks/use-sheet";
import EditEnrolledTeams from "./edit-enrolled-teams";
import EditEnrolledPlayers from "./edit-enrolled-players";
import ListLink from "@/components/list-link";
import React from "react";
import EditRules from "./edit-rules";
import { useLingui } from "@lingui/react/macro";
@@ -38,17 +33,20 @@ const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
open: openEditRules,
close: closeEditRules,
} = useSheet();
const {
isOpen: editTeamsOpened,
open: openEditTeams,
close: closeEditTeams,
} = useSheet();
const [v, setV] = React.useState("");
return (
<>
<List p="0">
<ListLink
label={t`Run Tournament`}
Icon={TreeStructureIcon}
to={`/admin/tournaments/run/${tournamentId}`}
/>
<ListLink
label={t`Enrollment & Teams`}
Icon={UsersThreeIcon}
to={`/admin/tournaments/${tournamentId}/enrollment`}
/>
<ListButton
label={t`Edit Tournament`}
Icon={HardDrivesIcon}
@@ -59,30 +57,6 @@ const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
Icon={PencilLineIcon}
onClick={openEditRules}
/>
<ListButton
label={t`Edit Enrollments`}
Icon={UsersThreeIcon}
onClick={openEditTeams}
/>
{tournament.regional && (
<ListLink
label={t`Assign Partners`}
Icon={ShuffleIcon}
to={`/admin/tournaments/${tournamentId}/assign-partners`}
/>
)}
{!tournament.regional && (
<ListLink
label={t`Manage Team Songs/Logos`}
Icon={UsersIcon}
to={`/admin/tournaments/${tournamentId}/teams`}
/>
)}
<ListLink
label={t`Run Tournament`}
Icon={TreeStructureIcon}
to={`/admin/tournaments/run/${tournamentId}`}
/>
</List>
<Sheet
@@ -112,18 +86,6 @@ const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
>
<EditRules tournamentId={tournamentId} onClose={closeEditRules} />
</Sheet>
<Sheet
title={tournament.regional === true ? t`Manage Enrollments` : t`Edit Enrolled Teams`}
opened={editTeamsOpened}
onChange={closeEditTeams}
>
{tournament.regional === true ? (
<EditEnrolledPlayers tournamentId={tournamentId} />
) : (
<EditEnrolledTeams tournamentId={tournamentId} />
)}
</Sheet>
</>
);
};
@@ -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]);
@@ -30,28 +30,9 @@ const RulesContent = lazy(
() => import("../../upcoming-tournament/rules-content")
);
interface NavTileProps {
label: string;
Icon: Icon;
onClick?: () => void;
disabled?: boolean;
}
import { NavTile } from "@/components/nav-tile";
/** A single square-ish nav tile with icon-over-label and press feedback. */
export const NavTile = ({ label, Icon, onClick, disabled }: NavTileProps) => (
<UnstyledButton
className={`${classes.tile} flxn-tile`}
onClick={onClick}
disabled={disabled}
>
<span className={`${classes.icon} flxn-tile-icon`}>
<Icon size={22} weight="bold" />
</span>
<Text size="xs" fw={600} lineClamp={2} lh={1.2}>
{label}
</Text>
</UnstyledButton>
);
export { NavTile };
interface NavGridProps {
tournament: Tournament;
@@ -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', {