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", "@react-spring/web": "^9.7.3",
"@svgr/cli": "^8.1.0", "@svgr/cli": "^8.1.0",
"@tanstack/react-query": "^5.28.6", "@tanstack/react-query": "^5.28.6",
"@umami/react-zen": "^0.114.0", "@umami/react-zen": "^0.116.0",
"@umami/redis-client": "^0.27.0", "@umami/redis-client": "^0.27.0",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"chalk": "^4.1.1", "chalk": "^4.1.1",

10
pnpm-lock.yaml generated
View file

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

View file

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

View file

@ -1,6 +1,16 @@
'use client'; 'use client';
import { Column } from '@umami/react-zen';
import { RetentionTable } from './RetentionTable'; import { RetentionTable } from './RetentionTable';
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
import { Panel } from '@/components/common/Panel';
export function RetentionPage({ websiteId }: { websiteId: string }) { 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 { 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 { formatDate } from '@/lib/date';
import { formatLongNumber } from '@/lib/format';
const DAYS = [1, 2, 3, 4, 5, 6, 7, 14, 21, 28]; 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 { formatMessage, labels } = useMessages();
const { locale } = useLocale(); const { locale } = useLocale();
const { report } = useReport(); const { data: x, isLoading } = useRetentionQuery(websiteId);
const { data } = report || {}; const data = x as any;
if (isLoading) {
return <Loading position="page" />;
}
if (!data) { if (!data) {
return <EmptyPlaceholder />; return <EmptyPlaceholder />;
@ -34,33 +42,62 @@ export function RetentionTable({ days = DAYS }) {
const totalDays = rows.length; const totalDays = rows.length;
return ( return (
<> <Column gap="1">
<div> <Grid
<div> columns="120px repeat(auto-fit, 100px)"
<div>{formatMessage(labels.date)}</div> alignItems="center"
<div>{formatMessage(labels.visitors)}</div> gap="1"
{days.map(n => ( height="50px"
<div key={n}> 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} {formatMessage(labels.day)} {n}
</div> </Text>
))} </Column>
</div> ))}
{rows.map(({ date, visitors, records }, rowIndex) => { </Grid>
return ( {rows.map(({ date, visitors, records }: any, rowIndex: number) => {
<div key={rowIndex}> return (
<div>{formatDate(date, 'PP', locale)}</div> <Grid key={rowIndex} columns="120px repeat(auto-fit, 100px)" gap="1" autoFlow="column">
<div>{visitors}</div> <Column justifyContent="center" gap="1">
{days.map(day => { <Text weight="bold">{formatDate(date, 'PP', locale)}</Text>
if (totalDays - rowIndex < day) { <Row alignItems="center" gap>
return null; <Icon>
} <Lucide.Users />
const percentage = records.filter(a => a.day === day)[0]?.percentage; </Icon>
return <div key={day}>{percentage ? `${Number(percentage).toFixed(2)}%` : ''}</div>; <Text>{formatLongNumber(visitors)}</Text>
})} </Row>
</div> </Column>
); {days.map(day => {
})} if (totalDays - rowIndex < day) {
</div> 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 { firstBy } from 'thenby';
import { CHART_COLORS, UTM_PARAMS } from '@/lib/constants'; import { CHART_COLORS, UTM_PARAMS } from '@/lib/constants';
import { useUTMQuery } from '@/components/hooks'; import { useUTMQuery } from '@/components/hooks';
@ -18,7 +18,11 @@ function toArray(data: { [key: string]: number } = {}) {
export function UTMView({ websiteId }: { websiteId: string }) { export function UTMView({ websiteId }: { websiteId: string }) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { data } = useUTMQuery(websiteId); const { data, isLoading } = useUTMQuery(websiteId);
if (isLoading) {
return <Loading position="page" />;
}
if (!data) { if (!data) {
return null; return null;
@ -46,7 +50,9 @@ export function UTMView({ websiteId }: { websiteId: string }) {
<Panel key={param}> <Panel key={param}>
<GridRow layout="two"> <GridRow layout="two">
<Column> <Column>
<Heading>{param.replace(/^utm_/, '')}</Heading> <Heading>
<Text transform="capitalize">{param.replace(/^utm_/, '')}</Text>
</Heading>
<ListTable <ListTable
metric={formatMessage(labels.views)} metric={formatMessage(labels.views)}
data={items.map(({ name, value }) => ({ 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 { startDate, endDate } = await getRequestDateRange(query);
const data = await getUTM(websiteId, { const data = await getUTM(websiteId, {
startDate: new Date(startDate), startDate,
endDate: new Date(endDate), endDate,
timezone, timezone,
}); });

View file

@ -7,6 +7,7 @@ export * from './queries/useLoginQuery';
export * from './queries/useRealtimeQuery'; export * from './queries/useRealtimeQuery';
export * from './queries/useReportQuery'; export * from './queries/useReportQuery';
export * from './queries/useReportsQuery'; export * from './queries/useReportsQuery';
export * from './queries/useRetentionQuery';
export * from './queries/useSessionActivityQuery'; export * from './queries/useSessionActivityQuery';
export * from './queries/useSessionDataQuery'; export * from './queries/useSessionDataQuery';
export * from './queries/useSessionDataPropertiesQuery'; 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' }, links: { id: 'label.links', defaultMessage: 'Links' },
pixels: { id: 'label.pixels', defaultMessage: 'Pixels' }, pixels: { id: 'label.pixels', defaultMessage: 'Pixels' },
addBoard: { id: 'label.add-board', defaultMessage: 'Add board' }, addBoard: { id: 'label.add-board', defaultMessage: 'Add board' },
cohort: { id: 'label.cohort', defaultMessage: 'Cohort' },
}); });
export const messages = defineMessages({ export const messages = defineMessages({

View file

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