Update Retention report.

This commit is contained in:
Mike Cao 2025-06-28 21:16:50 -07:00
parent 184a387ecd
commit ee8750d9df
22 changed files with 214 additions and 280 deletions

View file

@ -1,17 +0,0 @@
.container {
position: relative;
display: flex;
flex-direction: column;
align-self: stretch;
}
.chart {
position: relative;
overflow: hidden;
}
.title {
font-size: var(--font-size-lg);
line-height: 60px;
font-weight: 600;
}

View file

@ -1,14 +0,0 @@
.container {
margin-bottom: 60px;
}
.nav {
width: 200px;
margin-top: 40px;
}
.title {
color: var(--base800);
text-align: center;
font-weight: 700;
}

View file

@ -2,23 +2,27 @@ import { Column, Row } from '@umami/react-zen';
import { WebsiteFilterButton } from '@/app/(main)/websites/[websiteId]/WebsiteFilterButton';
import { WebsiteDateFilter } from '@/components/input/WebsiteDateFilter';
import { FilterBar } from '@/components/input/FilterBar';
import { WebsiteMonthSelect } from '@/components/input/WebsiteMonthSelect';
export function WebsiteControls({
websiteId,
allowFilter = true,
allowDateFilter = true,
allowMonthFilter,
allowCompare,
}: {
websiteId: string;
allowFilter?: boolean;
allowCompare?: boolean;
allowDateFilter?: boolean;
allowMonthFilter?: boolean;
}) {
return (
<Column gap>
<Row alignItems="center" justifyContent="space-between" gap="3">
{allowFilter && <WebsiteFilterButton websiteId={websiteId} />}
<Row alignItems="center" gap="3">
<WebsiteDateFilter websiteId={websiteId} allowCompare={allowCompare} />
</Row>
{allowDateFilter && <WebsiteDateFilter websiteId={websiteId} allowCompare={allowCompare} />}
{allowMonthFilter && <WebsiteMonthSelect websiteId={websiteId} />}
</Row>
<FilterBar />
</Column>

View file

@ -14,15 +14,17 @@ export function WebsiteHeader() {
return (
<PageHeader title={website.name} icon={<Favicon domain={website.domain} />} showBorder={false}>
<Row alignItems="center" gap>
<Row alignItems="center" gap="6">
<ActiveUsers websiteId={website.id} />
<ShareButton websiteId={website.id} shareId={website.shareId} />
<LinkButton href={renderUrl(`/settings/websites/${website.id}`)}>
<Icon>
<Edit />
</Icon>
<Text>Edit</Text>
</LinkButton>
<Row alignItems="center" gap>
<ShareButton websiteId={website.id} shareId={website.shareId} />
<LinkButton href={renderUrl(`/settings/websites/${website.id}`)}>
<Icon>
<Edit />
</Icon>
<Text>Edit</Text>
</LinkButton>
</Row>
</Row>
</PageHeader>
);

View file

@ -1,52 +0,0 @@
.container {
display: grid;
grid-template-columns: 2fr 1fr;
justify-content: space-between;
align-items: center;
background: var(--base50);
z-index: var(--z-index-above);
min-height: 120px;
padding-bottom: 20px;
}
.actions {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 10px;
flex-wrap: wrap;
}
.vs {
display: flex;
align-items: center;
justify-content: flex-end;
flex-basis: 100%;
gap: 10px;
}
.dropdown {
min-width: 200px;
}
@media screen and (max-width: 1200px) {
.container {
grid-template-columns: 1fr;
}
.actions {
margin: 20px 0;
}
}
@media screen and (min-width: 992px) {
.sticky {
position: sticky;
top: -1px;
}
.isSticky {
padding: 10px 0;
border-bottom: 1px solid var(--base300);
}
}

View file

@ -11,28 +11,30 @@ export function EventsMetricsBar({ websiteId }: { websiteId: string }) {
return (
<LoadingPanel data={data} isLoading={isLoading} isFetching={isFetching} error={error}>
<MetricsBar>
<MetricCard
value={data?.visitors?.value}
label={formatMessage(labels.visitors)}
formatValue={formatLongNumber}
/>
<MetricCard
value={data?.visits?.value}
label={formatMessage(labels.visits)}
formatValue={formatLongNumber}
/>
<MetricCard
value={data?.pageviews?.value}
label={formatMessage(labels.views)}
formatValue={formatLongNumber}
/>
<MetricCard
value={data?.events?.value}
label={formatMessage(labels.events)}
formatValue={formatLongNumber}
/>
</MetricsBar>
{data && (
<MetricsBar>
<MetricCard
value={data?.visitors?.value}
label={formatMessage(labels.visitors)}
formatValue={formatLongNumber}
/>
<MetricCard
value={data?.visits?.value}
label={formatMessage(labels.visits)}
formatValue={formatLongNumber}
/>
<MetricCard
value={data?.pageviews?.value}
label={formatMessage(labels.views)}
formatValue={formatLongNumber}
/>
<MetricCard
value={data?.events?.value}
label={formatMessage(labels.events)}
formatValue={formatLongNumber}
/>
</MetricsBar>
)}
</LoadingPanel>
);
}

View file

@ -67,7 +67,7 @@ export function RealtimeLog({ data }: { data: RealtimeData }) {
country: string;
device: string;
}) => {
const { __type, eventName, urlPath: url, browser, os, country, device } = log;
const { __type, eventName, urlPath, browser, os, country, device } = log;
if (__type === TYPE_EVENT) {
return formatMessage(messages.eventLog, {
@ -75,12 +75,12 @@ export function RealtimeLog({ data }: { data: RealtimeData }) {
url: (
<a
key="a"
href={`//${website?.domain}${url}`}
href={`//${website?.domain}${urlPath}`}
className={styles.link}
target="_blank"
rel="noreferrer noopener"
>
{url}
{urlPath}
</a>
),
});
@ -89,12 +89,12 @@ export function RealtimeLog({ data }: { data: RealtimeData }) {
if (__type === TYPE_PAGEVIEW) {
return (
<a
href={`//${website?.domain}${url}`}
href={`//${website?.domain}${urlPath}`}
className={styles.link}
target="_blank"
rel="noreferrer noopener"
>
{url}
{urlPath}
</a>
);
}

View file

@ -66,7 +66,13 @@ export function ReportsNav({ websiteId }: { websiteId: string }) {
const isSelected = selected === id;
return (
<Link key={id} href={renderUrl(`/websites/${websiteId}/reports${path}`)}>
<Link
key={id}
href={renderUrl(
`/websites/${websiteId}/reports${path}`,
path === '/retention' ? false : null,
)}
>
<NavMenuItem isSelected={isSelected}>
<Row alignItems="center" gap>
<Icon>{icon}</Icon>

View file

@ -1,7 +1,7 @@
import { ReactNode } from 'react';
import { Grid, Row, Column, Text, Icon } from '@umami/react-zen';
import { Users } from '@/components/icons';
import { useMessages, useLocale, useResultQuery } from '@/components/hooks';
import { useMessages, useLocale, useResultQuery, useTimezone } from '@/components/hooks';
import { formatDate } from '@/lib/date';
import { formatLongNumber } from '@/lib/format';
import { Panel } from '@/components/common/Panel';
@ -19,8 +19,10 @@ export interface RetentionProps {
export function Retention({ websiteId, days = DAYS, startDate, endDate }: RetentionProps) {
const { formatMessage, labels } = useMessages();
const { locale } = useLocale();
const { timezone } = useTimezone();
const { data, error, isLoading } = useResultQuery<any>('retention', {
websiteId,
timezone,
dateRange: {
startDate,
endDate,
@ -51,54 +53,56 @@ export function Retention({ websiteId, days = DAYS, startDate, endDate }: Retent
return (
<LoadingPanel data={data} isLoading={isLoading} error={error}>
<Panel allowFullscreen height="900px">
<Column gap="1" width="100%" overflow="auto">
<Grid
columns="120px repeat(10, 100px)"
alignItems="center"
gap="1"
height="50px"
autoFlow="column"
>
<Column>
<Text weight="bold" align="center">
{formatMessage(labels.cohort)}
</Text>
</Column>
{days.map(n => (
<Column key={n}>
<Text weight="bold" align="center" wrap="nowrap">
{formatMessage(labels.day)} {n}
{data && (
<Panel allowFullscreen height="900px">
<Column gap="1" width="100%" overflow="auto">
<Grid
columns="120px repeat(10, 100px)"
alignItems="center"
gap="1"
height="50px"
autoFlow="column"
>
<Column>
<Text weight="bold" align="center">
{formatMessage(labels.cohort)}
</Text>
</Column>
))}
</Grid>
{rows.map(({ date, visitors, records }: any, rowIndex: number) => {
return (
<Grid key={rowIndex} columns="120px repeat(10, 100px)" gap="1" autoFlow="column">
<Column justifyContent="center" gap="1">
<Text weight="bold">{formatDate(date, 'PP', locale)}</Text>
<Row alignItems="center" gap>
<Icon>
<Users />
</Icon>
<Text>{formatLongNumber(visitors)}</Text>
</Row>
{days.map(n => (
<Column key={n}>
<Text weight="bold" align="center" wrap="nowrap">
{formatMessage(labels.day)} {n}
</Text>
</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>
</Panel>
))}
</Grid>
{rows.map(({ date, visitors, records }: any, rowIndex: number) => {
return (
<Grid key={rowIndex} columns="120px repeat(10, 100px)" gap="1" autoFlow="column">
<Column justifyContent="center" gap="1">
<Text weight="bold">{formatDate(date, 'PP', locale)}</Text>
<Row alignItems="center" gap>
<Icon>
<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>
</Panel>
)}
</LoadingPanel>
);
}

View file

@ -3,16 +3,20 @@ import { Column } from '@umami/react-zen';
import { Retention } from './Retention';
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
import { useDateRange } from '@/components/hooks';
import { endOfMonth, startOfMonth } from 'date-fns';
export function RetentionPage({ websiteId }: { websiteId: string }) {
const {
dateRange: { startDate, endDate },
dateRange: { startDate },
} = useDateRange(websiteId);
const monthStartDate = startOfMonth(startDate);
const monthEndDate = endOfMonth(startDate);
return (
<Column gap>
<WebsiteControls websiteId={websiteId} />
<Retention websiteId={websiteId} startDate={startDate} endDate={endDate} />
<WebsiteControls websiteId={websiteId} allowDateFilter={false} allowMonthFilter />
<Retention websiteId={websiteId} startDate={monthStartDate} endDate={monthEndDate} />
</Column>
);
}

View file

@ -11,28 +11,30 @@ export function SessionsMetricsBar({ websiteId }: { websiteId: string }) {
return (
<LoadingPanel data={data} isLoading={isLoading} isFetching={isFetching} error={error}>
<MetricsBar>
<MetricCard
value={data?.visitors?.value}
label={formatMessage(labels.visitors)}
formatValue={formatLongNumber}
/>
<MetricCard
value={data?.visits?.value}
label={formatMessage(labels.visits)}
formatValue={formatLongNumber}
/>
<MetricCard
value={data?.pageviews?.value}
label={formatMessage(labels.views)}
formatValue={formatLongNumber}
/>
<MetricCard
value={data?.countries?.value}
label={formatMessage(labels.countries)}
formatValue={formatLongNumber}
/>
</MetricsBar>
{data && (
<MetricsBar>
<MetricCard
value={data?.visitors?.value}
label={formatMessage(labels.visitors)}
formatValue={formatLongNumber}
/>
<MetricCard
value={data?.visits?.value}
label={formatMessage(labels.visits)}
formatValue={formatLongNumber}
/>
<MetricCard
value={data?.pageviews?.value}
label={formatMessage(labels.views)}
formatValue={formatLongNumber}
/>
<MetricCard
value={data?.countries?.value}
label={formatMessage(labels.countries)}
formatValue={formatLongNumber}
/>
</MetricsBar>
)}
</LoadingPanel>
);
}

View file

@ -14,6 +14,7 @@ export async function POST(request: Request) {
const {
websiteId,
dateRange: { startDate, endDate },
timezone,
} = body;
if (!(await canViewWebsite(auth, websiteId))) {
@ -23,6 +24,7 @@ export async function POST(request: Request) {
const data = await getRetention(websiteId, {
startDate: new Date(startDate),
endDate: new Date(endDate),
timezone,
});
return json(data);