Added retention screen.

This commit is contained in:
Mike Cao 2025-05-21 19:19:43 -07:00
parent d0d11225f4
commit bce6737f29
12 changed files with 164 additions and 45 deletions

View file

@ -78,7 +78,7 @@
"@react-spring/web": "^9.7.3",
"@svgr/cli": "^8.1.0",
"@tanstack/react-query": "^5.28.6",
"@umami/react-zen": "^0.114.0",
"@umami/react-zen": "^0.116.0",
"@umami/redis-client": "^0.27.0",
"bcryptjs": "^2.4.3",
"chalk": "^4.1.1",

10
pnpm-lock.yaml generated
View file

@ -42,8 +42,8 @@ importers:
specifier: ^5.28.6
version: 5.76.1(react@19.1.0)
'@umami/react-zen':
specifier: ^0.114.0
version: 0.114.0(@babel/core@7.27.1)(@types/react@19.1.4)(immer@9.0.21)(use-sync-external-store@1.5.0(react@19.1.0))
specifier: ^0.116.0
version: 0.116.0(@babel/core@7.27.1)(@types/react@19.1.4)(immer@9.0.21)(use-sync-external-store@1.5.0(react@19.1.0))
'@umami/redis-client':
specifier: ^0.27.0
version: 0.27.0
@ -3035,8 +3035,8 @@ packages:
resolution: {integrity: sha512-ar0tjQfObzhSaW3C3QNmTc5ofj0hDoNQ5XWrCy6zDyabdr0TWhCkClp+rywGNj/odAFBVzzJrK4tEq5M4Hmu4w==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@umami/react-zen@0.114.0':
resolution: {integrity: sha512-CV/bD5/llE/AuAKDtoPJ10b8mJda/a4Ew/H+2yFbBiS2LTLfNpyEnXyuxeh7FZHRD0o6xRoteQlIFHdHRU7Fzw==}
'@umami/react-zen@0.116.0':
resolution: {integrity: sha512-/OjfYgwA9/4JpfKjf/b3HinVoeEoyOfLHJk8Uv0opBx+Jy2I6WPM4ZwvaRVxIbNwzpw/JZekC46TJs6bQNzbGg==}
'@umami/redis-client@0.27.0':
resolution: {integrity: sha512-SbHTpxhgeZyTBUSp2zdZM+XUtpsaSL4Tad8QXIEhEtjWhvvfoornyT5kLuyYCVtzSAT4daALeGmOO1z6EE1KcA==}
@ -10860,7 +10860,7 @@ snapshots:
'@typescript-eslint/types': 8.32.1
eslint-visitor-keys: 4.2.0
'@umami/react-zen@0.114.0(@babel/core@7.27.1)(@types/react@19.1.4)(immer@9.0.21)(use-sync-external-store@1.5.0(react@19.1.0))':
'@umami/react-zen@0.116.0(@babel/core@7.27.1)(@types/react@19.1.4)(immer@9.0.21)(use-sync-external-store@1.5.0(react@19.1.0))':
dependencies:
'@fontsource/jetbrains-mono': 5.2.5
'@internationalized/date': 3.8.1

View file

@ -6,16 +6,18 @@ import { FilterBar } from '@/components/input/FilterBar';
export function WebsiteControls({
websiteId,
showFilter = true,
showCompare,
}: {
websiteId: string;
showFilter?: boolean;
showCompare?: boolean;
}) {
return (
<Column gap>
<Row alignItems="center" justifyContent="space-between" gap="3">
{showFilter && <WebsiteFilterButton websiteId={websiteId} />}
<Row alignItems="center" gap="3">
<WebsiteDateFilter websiteId={websiteId} />
<WebsiteDateFilter websiteId={websiteId} showCompare={showCompare} />
</Row>
</Row>
<FilterBar />

View file

@ -1,6 +1,16 @@
'use client';
import { Column } from '@umami/react-zen';
import { RetentionTable } from './RetentionTable';
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
import { Panel } from '@/components/common/Panel';
export function RetentionPage({ websiteId }: { websiteId: string }) {
return <RetentionTable websiteId={websiteId} />;
return (
<Column gap>
<WebsiteControls websiteId={websiteId} />
<Panel>
<RetentionTable websiteId={websiteId} />
</Panel>
</Column>
);
}

View file

@ -1,14 +1,22 @@
import { ReactNode } from 'react';
import { Grid, Row, Column, Text, Loading, Icon } from '@umami/react-zen';
import { EmptyPlaceholder } from '@/components/common/EmptyPlaceholder';
import { useMessages, useLocale, useReport } from '@/components/hooks';
import { Lucide } from '@/components/icons';
import { useMessages, useLocale, useRetentionQuery } from '@/components/hooks';
import { formatDate } from '@/lib/date';
import { formatLongNumber } from '@/lib/format';
const DAYS = [1, 2, 3, 4, 5, 6, 7, 14, 21, 28];
export function RetentionTable({ days = DAYS }) {
export function RetentionTable({ websiteId, days = DAYS }: { websiteId: string; days?: number[] }) {
const { formatMessage, labels } = useMessages();
const { locale } = useLocale();
const { report } = useReport();
const { data } = report || {};
const { data: x, isLoading } = useRetentionQuery(websiteId);
const data = x as any;
if (isLoading) {
return <Loading position="page" />;
}
if (!data) {
return <EmptyPlaceholder />;
@ -34,33 +42,62 @@ export function RetentionTable({ days = DAYS }) {
const totalDays = rows.length;
return (
<>
<div>
<div>
<div>{formatMessage(labels.date)}</div>
<div>{formatMessage(labels.visitors)}</div>
{days.map(n => (
<div key={n}>
<Column gap="1">
<Grid
columns="120px repeat(auto-fit, 100px)"
alignItems="center"
gap="1"
height="50px"
autoFlow="column"
>
<Column>
<Text weight="bold">{formatMessage(labels.cohort)}</Text>
</Column>
{days.map(n => (
<Column key={n}>
<Text weight="bold" align="center">
{formatMessage(labels.day)} {n}
</div>
))}
</div>
{rows.map(({ date, visitors, records }, rowIndex) => {
return (
<div key={rowIndex}>
<div>{formatDate(date, 'PP', locale)}</div>
<div>{visitors}</div>
{days.map(day => {
if (totalDays - rowIndex < day) {
return null;
}
const percentage = records.filter(a => a.day === day)[0]?.percentage;
return <div key={day}>{percentage ? `${Number(percentage).toFixed(2)}%` : ''}</div>;
})}
</div>
);
})}
</div>
</>
</Text>
</Column>
))}
</Grid>
{rows.map(({ date, visitors, records }: any, rowIndex: number) => {
return (
<Grid key={rowIndex} columns="120px repeat(auto-fit, 100px)" gap="1" autoFlow="column">
<Column justifyContent="center" gap="1">
<Text weight="bold">{formatDate(date, 'PP', locale)}</Text>
<Row alignItems="center" gap>
<Icon>
<Lucide.Users />
</Icon>
<Text>{formatLongNumber(visitors)}</Text>
</Row>
</Column>
{days.map(day => {
if (totalDays - rowIndex < day) {
return null;
}
const percentage = records.filter(a => a.day === day)[0]?.percentage;
return <Cell key={day}>{percentage ? `${Number(percentage).toFixed(2)}%` : ''}</Cell>;
})}
</Grid>
);
})}
</Column>
);
}
const Cell = ({ children }: { children: ReactNode }) => {
return (
<Column
justifyContent="center"
alignItems="center"
width="100px"
height="100px"
backgroundColor="2"
borderRadius
>
{children}
</Column>
);
};

View file

@ -1,4 +1,4 @@
import { Column, Heading } from '@umami/react-zen';
import { Column, Heading, Text, Loading } from '@umami/react-zen';
import { firstBy } from 'thenby';
import { CHART_COLORS, UTM_PARAMS } from '@/lib/constants';
import { useUTMQuery } from '@/components/hooks';
@ -18,7 +18,11 @@ function toArray(data: { [key: string]: number } = {}) {
export function UTMView({ websiteId }: { websiteId: string }) {
const { formatMessage, labels } = useMessages();
const { data } = useUTMQuery(websiteId);
const { data, isLoading } = useUTMQuery(websiteId);
if (isLoading) {
return <Loading position="page" />;
}
if (!data) {
return null;
@ -46,7 +50,9 @@ export function UTMView({ websiteId }: { websiteId: string }) {
<Panel key={param}>
<GridRow layout="two">
<Column>
<Heading>{param.replace(/^utm_/, '')}</Heading>
<Heading>
<Text transform="capitalize">{param.replace(/^utm_/, '')}</Text>
</Heading>
<ListTable
metric={formatMessage(labels.views)}
data={items.map(({ name, value }) => ({

View file

@ -0,0 +1,42 @@
import { z } from 'zod';
import { canViewWebsite } from '@/lib/auth';
import { unauthorized, json } from '@/lib/response';
import { getRequestDateRange, parseRequest } from '@/lib/request';
import { getRetention } from '@/queries';
import { filterParams, timezoneParam, unitParam } from '@/lib/schema';
export async function GET(
request: Request,
{ params }: { params: Promise<{ websiteId: string }> },
) {
const schema = z.object({
startAt: z.coerce.number().int(),
endAt: z.coerce.number().int(),
unit: unitParam,
timezone: timezoneParam,
...filterParams,
});
const { auth, query, error } = await parseRequest(request, schema);
if (error) {
return error();
}
const { websiteId } = await params;
const { timezone } = query;
if (!(await canViewWebsite(auth, websiteId))) {
return unauthorized();
}
const { startDate, endDate } = await getRequestDateRange(query);
const data = await getRetention(websiteId, {
startDate,
endDate,
timezone,
});
return json(data);
}

View file

@ -33,8 +33,8 @@ export async function GET(
const { startDate, endDate } = await getRequestDateRange(query);
const data = await getUTM(websiteId, {
startDate: new Date(startDate),
endDate: new Date(endDate),
startDate,
endDate,
timezone,
});

View file

@ -7,6 +7,7 @@ export * from './queries/useLoginQuery';
export * from './queries/useRealtimeQuery';
export * from './queries/useReportQuery';
export * from './queries/useReportsQuery';
export * from './queries/useRetentionQuery';
export * from './queries/useSessionActivityQuery';
export * from './queries/useSessionDataQuery';
export * from './queries/useSessionDataPropertiesQuery';

View file

@ -0,0 +1,20 @@
import { useApi } from '../useApi';
import { useFilterParams } from '../useFilterParams';
import { UseQueryOptions } from '@tanstack/react-query';
export function useRetentionQuery(
websiteId: string,
queryParams?: { type: string; limit?: number; search?: string; startAt?: number; endAt?: number },
options?: Omit<UseQueryOptions & { onDataLoad?: (data: any) => void }, 'queryKey' | 'queryFn'>,
) {
const { get, useQuery } = useApi();
const filterParams = useFilterParams(websiteId);
return useQuery({
queryKey: ['retention', websiteId, { ...filterParams, ...queryParams }],
queryFn: () =>
get(`/websites/${websiteId}/retention`, { websiteId, ...filterParams, ...queryParams }),
enabled: !!websiteId,
...options,
});
}

View file

@ -319,6 +319,7 @@ export const labels = defineMessages({
links: { id: 'label.links', defaultMessage: 'Links' },
pixels: { id: 'label.pixels', defaultMessage: 'Pixels' },
addBoard: { id: 'label.add-board', defaultMessage: 'Add board' },
cohort: { id: 'label.cohort', defaultMessage: 'Cohort' },
});
export const messages = defineMessages({

View file

@ -29,7 +29,7 @@ export function ActiveUsers({
return (
<StatusLight variant="success">
<Text size="2" weight="bold">
{formatMessage(messages.activeUsers, { x: count })}
{formatMessage(messages.numberOfUsers, { x: count })}
</Text>
</StatusLight>
);