feat(team): add team analytics dashboard

Add a team document-usage dashboard at /t/:teamUrl/analytics for team admins and managers,
behind the NEXT_PUBLIC_FEATURE_TEAM_ANALYTICS_ENABLED rollout flag (enabled by default, set to
"false" to gate it off).

Backend:
- getTeamAnalytics Kysely query over team-produced documents across all folders, with exact
  COUNT(*) (no STATS_COUNT_CAP). Each metric uses its own date axis: Sent/Draft/Pending by
  createdAt, Completed by Envelope.completedAt, Declined by the DOCUMENT_RECIPIENT_REJECTED
  audit-log timestamp.
- resolveAnalyticsPeriod turns calendar presets into half-open [start, end) ranges in the
  viewer's timezone, falling back to UTC.
- team.getAnalytics tRPC route gated to ADMIN/MANAGER.

Frontend:
- Standalone /t/:teamUrl/analytics route whose loader gates the flag and role, silently
  redirecting members to documents.
- Headline metrics and compact stat tiles, a member multiselect filter, a calendar-preset
  period selector, and an empty state.
- Role- and flag-gated nav entries in the desktop and mobile navigation.

Tests:
- Unit tests for the period resolver (timezone and preset boundaries).
- Integration/E2E tests for the query semantics (date axes, audit-log decline, all-folders
  aggregation, sender attribution), access control, filters and the empty state.
This commit is contained in:
ephraimduncan
2026-05-29 13:52:29 +00:00
parent 22ceff43e3
commit ae07df6061
14 changed files with 1002 additions and 3 deletions
@@ -0,0 +1,65 @@
import { ZAnalyticsPeriodSchema } from '@documenso/trpc/server/team-router/get-team-analytics.types';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { useMemo } from 'react';
import { useLocation, useNavigate, useSearchParams } from 'react-router';
const DEFAULT_PERIOD = 'month';
const PERIOD_OPTIONS = [
{ value: 'week', label: msg`This week` },
{ value: 'month', label: msg`This month` },
{ value: 'quarter', label: msg`This quarter` },
{ value: 'year', label: msg`This year` },
{ value: 'lastMonth', label: msg`Last month` },
{ value: 'last7Days', label: msg`Last 7 days` },
{ value: 'last30Days', label: msg`Last 30 days` },
] as const;
export const AnalyticsPeriodSelector = () => {
const { _ } = useLingui();
const { pathname } = useLocation();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const period = useMemo(() => {
const parsed = ZAnalyticsPeriodSchema.safeParse(searchParams?.get('period') ?? DEFAULT_PERIOD);
return parsed.success ? parsed.data : DEFAULT_PERIOD;
}, [searchParams]);
const onPeriodChange = (newPeriod: string) => {
if (!pathname) {
return;
}
const params = new URLSearchParams(searchParams?.toString());
params.set('period', newPeriod);
if (newPeriod === DEFAULT_PERIOD) {
params.delete('period');
}
void navigate(`${pathname}?${params.toString()}`, { preventScrollReset: true });
};
return (
<Select value={period} onValueChange={onPeriodChange}>
<SelectTrigger className="max-w-[200px] text-muted-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{PERIOD_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{_(option.label)}
</SelectItem>
))}
</SelectContent>
</Select>
);
};
@@ -1,5 +1,7 @@
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_TEAM_ANALYTICS_ENABLED } from '@documenso/lib/constants/app';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { msg } from '@lingui/core/macro';
@@ -45,7 +47,7 @@ export const AppNavDesktop = ({ className, setIsCommandMenuOpen, ...props }: App
return [];
}
return [
const links = [
{
href: `/t/${teamUrl}/documents`,
label: msg`Documents`,
@@ -55,6 +57,19 @@ export const AppNavDesktop = ({ className, setIsCommandMenuOpen, ...props }: App
label: msg`Templates`,
},
];
if (
currentTeam &&
IS_TEAM_ANALYTICS_ENABLED() &&
canExecuteTeamAction('MANAGE_TEAM', currentTeam.currentTeamRole)
) {
links.push({
href: `/t/${currentTeam.url}/analytics`,
label: msg`Analytics`,
});
}
return links;
}, [currentTeam, organisations]);
return (
@@ -1,7 +1,9 @@
import LogoImage from '@documenso/assets/logo.png';
import { authClient } from '@documenso/auth/client';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_TEAM_ANALYTICS_ENABLED } from '@documenso/lib/constants/app';
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Sheet, SheetContent } from '@documenso/ui/primitives/sheet';
import { ThemeSwitcher } from '@documenso/ui/primitives/theme-switcher';
@@ -57,7 +59,7 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
];
}
return [
const links = [
{
href: `/t/${teamUrl}/documents`,
text: t`Documents`,
@@ -66,6 +68,20 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
href: `/t/${teamUrl}/templates`,
text: t`Templates`,
},
];
if (
currentTeam &&
IS_TEAM_ANALYTICS_ENABLED() &&
canExecuteTeamAction('MANAGE_TEAM', currentTeam.currentTeamRole)
) {
links.push({
href: `/t/${currentTeam.url}/analytics`,
text: t`Analytics`,
});
}
links.push(
{
href: '/inbox',
text: t`Inbox`,
@@ -74,7 +90,9 @@ export const AppNavMobile = ({ isMenuOpen, onMenuOpenChange }: AppNavMobileProps
href: '/settings/profile',
text: t`Settings`,
},
];
);
return links;
}, [currentTeam, organisations]);
return (
@@ -0,0 +1,174 @@
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { IS_TEAM_ANALYTICS_ENABLED } from '@documenso/lib/constants/app';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { parseToIntegerArray } from '@documenso/lib/utils/params';
import { canExecuteTeamAction, formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { ZAnalyticsPeriodSchema } from '@documenso/trpc/server/team-router/get-team-analytics.types';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useMemo } from 'react';
import { Link, redirect, useSearchParams } from 'react-router';
import { z } from 'zod';
import { AnalyticsPeriodSelector } from '~/components/general/analytics-period-selector';
import { CardMetric } from '~/components/general/metric-card';
import { DocumentsTableSenderFilter } from '~/components/tables/documents-table-sender-filter';
import { useCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta';
import type { Route } from './+types/analytics._index';
export function meta() {
return appMetaTags(msg`Analytics`);
}
export async function loader({ request, params }: Route.LoaderArgs) {
// Behind a rollout flag: silently send everyone back to documents when off.
if (!IS_TEAM_ANALYTICS_ENABLED()) {
throw redirect(formatDocumentsPath(params.teamUrl));
}
const session = await getSession(request);
const team = await getTeamByUrl({
userId: session.user.id,
teamUrl: params.teamUrl,
});
// Admins and managers only. Members are silently redirected (no existence leak).
if (!team || !canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole)) {
throw redirect(formatDocumentsPath(params.teamUrl));
}
}
const ZSearchParamsSchema = z.object({
period: ZAnalyticsPeriodSchema.optional().catch(undefined),
senderIds: z.string().transform(parseToIntegerArray).optional().catch([]),
});
export default function TeamAnalyticsPage() {
const { _ } = useLingui();
const team = useCurrentTeam();
const [searchParams] = useSearchParams();
const { period, senderIds } = useMemo(
() => ZSearchParamsSchema.parse(Object.fromEntries(searchParams.entries())),
[searchParams],
);
const timezone = useMemo(() => {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
} catch {
return undefined;
}
}, []);
const { data, isLoading } = trpc.team.getAnalytics.useQuery({
teamId: team.id,
period,
timezone,
senderIds,
});
const analytics = data ?? {
sent: 0,
draft: 0,
pending: 0,
completed: 0,
declined: 0,
};
const hasActivity =
analytics.sent > 0 ||
analytics.draft > 0 ||
analytics.pending > 0 ||
analytics.completed > 0 ||
analytics.declined > 0;
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<div className="mt-8 flex flex-wrap items-center justify-between gap-x-4 gap-y-8">
<div className="flex flex-row items-center">
<Avatar className="mr-3 h-12 w-12 border-2 border-white border-solid dark:border-border">
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
<AvatarFallback className="text-muted-foreground text-xs">{team.name.slice(0, 1)}</AvatarFallback>
</Avatar>
<h2 className="font-semibold text-4xl">
<Trans>Analytics</Trans>
</h2>
</div>
<div className="-m-1 flex flex-wrap items-center gap-x-4 gap-y-6 overflow-hidden p-1">
<DocumentsTableSenderFilter teamId={team.id} />
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
<AnalyticsPeriodSelector />
</div>
</div>
</div>
<div className="mt-8">
{isLoading ? (
<SpinnerBox className="py-32" />
) : hasActivity ? (
<div data-testid="team-analytics-content">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div data-testid="metric-sent" className="contents">
<CardMetric title={_(msg`Documents Sent`)} value={analytics.sent} />
</div>
<div data-testid="metric-completed-headline" className="contents">
<CardMetric title={_(msg`Completed`)} value={analytics.completed} />
</div>
</div>
<div className="mt-4 grid grid-cols-2 gap-4 lg:grid-cols-4">
<CardMetric title={_(msg`Draft`)} value={analytics.draft} />
<CardMetric title={_(msg`Pending`)} value={analytics.pending} />
<CardMetric title={_(msg`Completed`)} value={analytics.completed} />
<CardMetric title={_(msg`Declined`)} value={analytics.declined} />
</div>
<p className="mt-3 max-w-3xl text-muted-foreground text-xs">
<Trans>
Each tile counts documents that entered that state during the selected period, on its own date. They are
independent activity counts and do not add up to Documents Sent.
</Trans>
</p>
</div>
) : (
<div
data-testid="team-analytics-empty"
className="flex flex-col items-center justify-center rounded-lg border border-border border-dashed py-20 text-center"
>
<h3 className="font-semibold text-foreground text-lg">
<Trans>No analytics to show yet</Trans>
</h3>
<p className="mt-2 max-w-md text-muted-foreground text-sm">
<Trans>
There's no document activity for the selected period. Send your first document to start tracking your
team's usage here.
</Trans>
</p>
<Button asChild className="mt-6">
<Link to={formatDocumentsPath(team.url)}>
<Trans>Send a document</Trans>
</Link>
</Button>
</div>
)}
</div>
</div>
);
}