From bce6737f29df054d3ac022854818f1877af19d9a Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Wed, 21 May 2025 19:19:43 -0700 Subject: [PATCH] Added retention screen. --- package.json | 2 +- pnpm-lock.yaml | 10 +- .../websites/[websiteId]/WebsiteControls.tsx | 4 +- .../[websiteId]/retention/RetentionPage.tsx | 12 ++- .../[websiteId]/retention/RetentionTable.tsx | 99 +++++++++++++------ .../websites/[websiteId]/utm/UTMView.tsx | 12 ++- .../websites/[websiteId]/retention/route.ts | 42 ++++++++ src/app/api/websites/[websiteId]/utm/route.ts | 4 +- src/components/hooks/index.ts | 1 + .../hooks/queries/useRetentionQuery.ts | 20 ++++ src/components/messages.ts | 1 + src/components/metrics/ActiveUsers.tsx | 2 +- 12 files changed, 164 insertions(+), 45 deletions(-) create mode 100644 src/app/api/websites/[websiteId]/retention/route.ts create mode 100644 src/components/hooks/queries/useRetentionQuery.ts diff --git a/package.json b/package.json index 70df2de1..ad067168 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b40fb17..e8dfce21 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/app/(main)/websites/[websiteId]/WebsiteControls.tsx b/src/app/(main)/websites/[websiteId]/WebsiteControls.tsx index 7d38d93a..fef80c8b 100644 --- a/src/app/(main)/websites/[websiteId]/WebsiteControls.tsx +++ b/src/app/(main)/websites/[websiteId]/WebsiteControls.tsx @@ -6,16 +6,18 @@ import { FilterBar } from '@/components/input/FilterBar'; export function WebsiteControls({ websiteId, showFilter = true, + showCompare, }: { websiteId: string; showFilter?: boolean; + showCompare?: boolean; }) { return ( {showFilter && } - + diff --git a/src/app/(main)/websites/[websiteId]/retention/RetentionPage.tsx b/src/app/(main)/websites/[websiteId]/retention/RetentionPage.tsx index 0ca95b09..346bba5c 100644 --- a/src/app/(main)/websites/[websiteId]/retention/RetentionPage.tsx +++ b/src/app/(main)/websites/[websiteId]/retention/RetentionPage.tsx @@ -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 ; + return ( + + + + + + + ); } diff --git a/src/app/(main)/websites/[websiteId]/retention/RetentionTable.tsx b/src/app/(main)/websites/[websiteId]/retention/RetentionTable.tsx index 028fb58a..a8e35662 100644 --- a/src/app/(main)/websites/[websiteId]/retention/RetentionTable.tsx +++ b/src/app/(main)/websites/[websiteId]/retention/RetentionTable.tsx @@ -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 ; + } if (!data) { return ; @@ -34,33 +42,62 @@ export function RetentionTable({ days = DAYS }) { const totalDays = rows.length; return ( - <> -
-
-
{formatMessage(labels.date)}
-
{formatMessage(labels.visitors)}
- {days.map(n => ( -
+ + + + {formatMessage(labels.cohort)} + + {days.map(n => ( + + {formatMessage(labels.day)} {n} -
- ))} -
- {rows.map(({ date, visitors, records }, rowIndex) => { - return ( -
-
{formatDate(date, 'PP', locale)}
-
{visitors}
- {days.map(day => { - if (totalDays - rowIndex < day) { - return null; - } - const percentage = records.filter(a => a.day === day)[0]?.percentage; - return
{percentage ? `${Number(percentage).toFixed(2)}%` : ''}
; - })} -
- ); - })} -
- + +
+ ))} + + {rows.map(({ date, visitors, records }: any, rowIndex: number) => { + return ( + + + {formatDate(date, 'PP', locale)} + + + + + {formatLongNumber(visitors)} + + + {days.map(day => { + if (totalDays - rowIndex < day) { + return null; + } + const percentage = records.filter(a => a.day === day)[0]?.percentage; + return {percentage ? `${Number(percentage).toFixed(2)}%` : ''}; + })} + + ); + })} + ); } + +const Cell = ({ children }: { children: ReactNode }) => { + return ( + + {children} + + ); +}; diff --git a/src/app/(main)/websites/[websiteId]/utm/UTMView.tsx b/src/app/(main)/websites/[websiteId]/utm/UTMView.tsx index 67c0014e..8548695e 100644 --- a/src/app/(main)/websites/[websiteId]/utm/UTMView.tsx +++ b/src/app/(main)/websites/[websiteId]/utm/UTMView.tsx @@ -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 ; + } if (!data) { return null; @@ -46,7 +50,9 @@ export function UTMView({ websiteId }: { websiteId: string }) { - {param.replace(/^utm_/, '')} + + {param.replace(/^utm_/, '')} + ({ diff --git a/src/app/api/websites/[websiteId]/retention/route.ts b/src/app/api/websites/[websiteId]/retention/route.ts new file mode 100644 index 00000000..2f8bde18 --- /dev/null +++ b/src/app/api/websites/[websiteId]/retention/route.ts @@ -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); +} diff --git a/src/app/api/websites/[websiteId]/utm/route.ts b/src/app/api/websites/[websiteId]/utm/route.ts index f6f3173c..9cebb144 100644 --- a/src/app/api/websites/[websiteId]/utm/route.ts +++ b/src/app/api/websites/[websiteId]/utm/route.ts @@ -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, }); diff --git a/src/components/hooks/index.ts b/src/components/hooks/index.ts index 133a37f4..5d2ebf26 100644 --- a/src/components/hooks/index.ts +++ b/src/components/hooks/index.ts @@ -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'; diff --git a/src/components/hooks/queries/useRetentionQuery.ts b/src/components/hooks/queries/useRetentionQuery.ts new file mode 100644 index 00000000..eadd8020 --- /dev/null +++ b/src/components/hooks/queries/useRetentionQuery.ts @@ -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 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, + }); +} diff --git a/src/components/messages.ts b/src/components/messages.ts index cac49540..3729116e 100644 --- a/src/components/messages.ts +++ b/src/components/messages.ts @@ -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({ diff --git a/src/components/metrics/ActiveUsers.tsx b/src/components/metrics/ActiveUsers.tsx index b4d5ee5d..024f6856 100644 --- a/src/components/metrics/ActiveUsers.tsx +++ b/src/components/metrics/ActiveUsers.tsx @@ -29,7 +29,7 @@ export function ActiveUsers({ return ( - {formatMessage(messages.activeUsers, { x: count })} + {formatMessage(messages.numberOfUsers, { x: count })} );