mirror of
https://github.com/umami-software/umami.git
synced 2025-12-08 05:12:36 +01:00
Added retention screen.
This commit is contained in:
parent
d0d11225f4
commit
bce6737f29
12 changed files with 164 additions and 45 deletions
|
|
@ -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
10
pnpm-lock.yaml
generated
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 }) => ({
|
||||||
|
|
|
||||||
42
src/app/api/websites/[websiteId]/retention/route.ts
Normal file
42
src/app/api/websites/[websiteId]/retention/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
20
src/components/hooks/queries/useRetentionQuery.ts
Normal file
20
src/components/hooks/queries/useRetentionQuery.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue