diff --git a/apps/remix/app/components/general/admin-monthly-active-user-charts.tsx b/apps/remix/app/components/general/admin-monthly-active-user-charts.tsx new file mode 100644 index 000000000..048d9599c --- /dev/null +++ b/apps/remix/app/components/general/admin-monthly-active-user-charts.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { DateTime } from 'luxon'; +import type { TooltipProps } from 'recharts'; +import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; +import type { NameType, ValueType } from 'recharts/types/component/DefaultTooltipContent'; + +import type { GetMonthlyActiveUsersResult } from '@documenso/lib/server-only/admin/get-users-stats'; + +export type MonthlyActiveUsersChartProps = { + className?: string; + title: string; + cummulative?: boolean; + data: GetMonthlyActiveUsersResult; +}; + +const CustomTooltip = ({ active, payload, label }: TooltipProps) => { + if (active && payload && payload.length) { + return ( +
+

{label}

+

+ {payload[0].name === 'cume_count' ? 'Cumulative MAU' : 'Monthly Active Users'}:{' '} + {Number(payload[0].value).toLocaleString('en-US')} +

+
+ ); + } + + return null; +}; + +export const MonthlyActiveUsersChart = ({ + className, + data, + title, + cummulative = false, +}: MonthlyActiveUsersChartProps) => { + const formattedData = [...data].reverse().map(({ month, count, cume_count }) => { + return { + month: DateTime.fromFormat(month, 'yyyy-MM').toFormat('MMM yyyy'), + count: Number(count), + cume_count: Number(cume_count), + }; + }); + + return ( +
+
+
+

{title}

+
+ + + + + + + } cursor={{ fill: 'hsl(var(--primary) / 10%)' }} /> + + + + +
+
+ ); +}; diff --git a/apps/remix/app/routes/_authenticated+/admin+/stats.tsx b/apps/remix/app/routes/_authenticated+/admin+/stats.tsx index 2e53c3782..83963962f 100644 --- a/apps/remix/app/routes/_authenticated+/admin+/stats.tsx +++ b/apps/remix/app/routes/_authenticated+/admin+/stats.tsx @@ -18,12 +18,14 @@ import { import { getDocumentStats } from '@documenso/lib/server-only/admin/get-documents-stats'; import { getRecipientsStats } from '@documenso/lib/server-only/admin/get-recipients-stats'; import { + getMonthlyActiveUsers, getOrganisationsWithSubscriptionsCount, getUserWithSignedDocumentMonthlyGrowth, getUsersCount, } from '@documenso/lib/server-only/admin/get-users-stats'; import { getSignerConversionMonthly } from '@documenso/lib/server-only/user/get-signer-conversion'; +import { MonthlyActiveUsersChart } from '~/components/general/admin-monthly-active-user-charts'; import { AdminStatsSignerConversionChart } from '~/components/general/admin-stats-signer-conversion-chart'; import { AdminStatsUsersWithDocumentsChart } from '~/components/general/admin-stats-users-with-documents'; import { CardMetric } from '~/components/general/metric-card'; @@ -39,6 +41,7 @@ export async function loader() { recipientStats, signerConversionMonthly, monthlyUsersWithDocuments, + monthlyActiveUsers, ] = await Promise.all([ getUsersCount(), getOrganisationsWithSubscriptionsCount(), @@ -46,6 +49,7 @@ export async function loader() { getRecipientsStats(), getSignerConversionMonthly(), getUserWithSignedDocumentMonthlyGrowth(), + getMonthlyActiveUsers(), ]); return { @@ -55,6 +59,7 @@ export async function loader() { recipientStats, signerConversionMonthly, monthlyUsersWithDocuments, + monthlyActiveUsers, }; } @@ -68,6 +73,7 @@ export default function AdminStatsPage({ loaderData }: Route.ComponentProps) { recipientStats, signerConversionMonthly, monthlyUsersWithDocuments, + monthlyActiveUsers, } = loaderData; return ( @@ -144,6 +150,14 @@ export default function AdminStatsPage({ loaderData }: Route.ComponentProps) { Charts
+ + + + { return await prisma.user.count(); @@ -49,3 +49,37 @@ export const getUserWithSignedDocumentMonthlyGrowth = async () => { signed_count: Number(row.signed_count), })); }; + +export type GetMonthlyActiveUsersResult = Array<{ + month: string; + count: number; + cume_count: number; +}>; + +export const getMonthlyActiveUsers = async () => { + const qb = kyselyPrisma.$kysely + .selectFrom('UserSecurityAuditLog') + .select(({ fn }) => [ + fn('DATE_TRUNC', [sql.lit('MONTH'), 'UserSecurityAuditLog.createdAt']).as('month'), + fn.count('userId').distinct().as('count'), + fn + .sum(fn.count('userId').distinct()) + .over((ob) => + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/consistent-type-assertions + ob.orderBy(fn('DATE_TRUNC', [sql.lit('MONTH'), 'UserSecurityAuditLog.createdAt']) as any), + ) + .as('cume_count'), + ]) + .where(sql`type = ${UserSecurityAuditLogType.SIGN_IN}::"UserSecurityAuditLogType"`) + .groupBy(({ fn }) => fn('DATE_TRUNC', [sql.lit('MONTH'), 'UserSecurityAuditLog.createdAt'])) + .orderBy('month', 'desc') + .limit(12); + + const result = await qb.execute(); + + return result.map((row) => ({ + month: DateTime.fromJSDate(row.month).toFormat('yyyy-MM'), + count: Number(row.count), + cume_count: Number(row.cume_count), + })); +}; diff --git a/packages/ui/primitives/document-flow/add-settings.tsx b/packages/ui/primitives/document-flow/add-settings.tsx index 3ad28d17f..3c06f9d1c 100644 --- a/packages/ui/primitives/document-flow/add-settings.tsx +++ b/packages/ui/primitives/document-flow/add-settings.tsx @@ -1,10 +1,15 @@ import { useEffect } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; -import { Trans } from '@lingui/react/macro'; -import { useLingui } from '@lingui/react/macro'; -import { DocumentVisibility, TeamMemberRole } from '@prisma/client'; -import { DocumentStatus, type Field, type Recipient, SendStatus } from '@prisma/client'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { + DocumentStatus, + DocumentVisibility, + type Field, + type Recipient, + SendStatus, + TeamMemberRole, +} from '@prisma/client'; import { InfoIcon } from 'lucide-react'; import { useForm } from 'react-hook-form'; import { match } from 'ts-pattern';