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);

View file

@ -62,7 +62,7 @@ export function Panel({
<Button variant="quiet" onPress={handleFullscreen}>
<Icon>{isFullscreen ? <Close /> : <Maximize />}</Icon>
</Button>
<Tooltip>{formatMessage(labels.expand)}</Tooltip>
<Tooltip>{formatMessage(labels.maximize)}</Tooltip>
</TooltipTrigger>
</Row>
)}

View file

@ -22,7 +22,7 @@ export function useDateRange(websiteId?: string) {
);
const dateRange = useMemo(
() => (offset ? getOffsetDateRange(dateRangeObject, +offset) : dateRangeObject),
[date, offset],
[date, offset, websiteConfig],
);
const dateCompare = useWebsites(state => state[websiteId]?.dateCompare || DEFAULT_DATE_COMPARE);

View file

@ -8,7 +8,7 @@ export function useFilterParams(websiteId: string) {
const { timezone, toUtc } = useTimezone();
const {
query: {
url,
path,
referrer,
title,
query,
@ -29,7 +29,7 @@ export function useFilterParams(websiteId: string) {
endAt: +toUtc(endDate),
unit,
timezone,
url,
path,
referrer,
title,
query,

View file

@ -99,7 +99,7 @@ export function DateFilter({
};
return (
<Row width="280px">
<Row minWidth="200px">
<Select
value={value}
placeholder={formatMessage(labels.selectDate)}

View file

@ -1,70 +1,47 @@
import { Row, Text, Icon, Button, MenuTrigger, Popover, Menu, MenuItem } from '@umami/react-zen';
import { startOfMonth, endOfMonth, startOfYear, addMonths, subYears } from 'date-fns';
import { Chevron } from '@/components/icons';
import { Row, Select, ListItem } from '@umami/react-zen';
import { useLocale } from '@/components/hooks';
import { formatDate } from '@/lib/date';
export function MonthSelect({ date = new Date(), onChange }) {
const { locale } = useLocale();
const month = formatDate(date, 'MMMM', locale);
const month = date.getMonth();
const year = date.getFullYear();
const currentYear = new Date().getFullYear();
// eslint-disable-next-line
const handleChange = (close: () => void, date: Date) => {
onChange(`range:${startOfMonth(date).getTime()}:${endOfMonth(date).getTime()}`);
close();
const months = [...Array(12)].map((_, i) => i);
const years = [...Array(10)].map((_, i) => currentYear - i);
const handleMonthChange = (month: number) => {
const d = new Date(date);
d.setMonth(month);
onChange?.(d);
};
const handleYearChange = (year: number) => {
const d = new Date(date);
d.setFullYear(year);
onChange?.(d);
};
const start = startOfYear(date);
const months: Date[] = [];
for (let i = 0; i < 12; i++) {
months.push(addMonths(start, i));
}
const years: number[] = [];
for (let i = 0; i < 10; i++) {
years.push(subYears(start, 10 - i).getFullYear());
}
return (
<Row>
<MenuTrigger>
<Button variant="quiet">
<Text>{month}</Text>
<Icon size="sm">
<Chevron />
</Icon>
</Button>
<Popover>
<Menu>
{months.map(month => {
return (
<MenuItem key={month.toString()} id={month.toString()}>
{month.getDay()}
</MenuItem>
);
})}
</Menu>
</Popover>
</MenuTrigger>
<MenuTrigger>
<Button variant="quiet">
<Text>{year}</Text>
<Icon size="sm">
<Chevron />
</Icon>
</Button>
<Popover>
<Menu>
{years.map(year => {
return (
<MenuItem key={year} id={year}>
{year}
</MenuItem>
);
})}
</Menu>
</Popover>
</MenuTrigger>
<Row gap>
<Select value={month} onChange={handleMonthChange}>
{months.map(m => {
return (
<ListItem id={m} key={m}>
{formatDate(new Date(year, m, 1), 'MMMM', locale)}
</ListItem>
);
})}
</Select>
<Select value={year} onChange={handleYearChange}>
{years.map(y => {
return (
<ListItem id={y} key={y}>
{y}
</ListItem>
);
})}
</Select>
</Row>
);
}

View file

@ -55,7 +55,7 @@ export function WebsiteDateFilter({
};
return (
<Row gap="3">
<Row gap>
{showButtons && !isAllTime && !isCustomRange && (
<Row gap="1">
<Button onPress={() => handleIncrement(-1)} variant="outline">

View file

@ -0,0 +1,17 @@
import { useDateRange } from '@/components/hooks';
import { dateToRangeValue } from '@/lib/date';
import { MonthSelect } from './MonthSelect';
export function WebsiteMonthSelect({ websiteId }: { websiteId: string }) {
const {
dateRange: { startDate },
saveDateRange,
} = useDateRange(websiteId);
const handleMonthSelect = (date: Date) => {
const range = dateToRangeValue(date);
saveDateRange(range);
};
return <MonthSelect date={startDate} onChange={handleMonthSelect} />;
}

View file

@ -271,7 +271,7 @@ export const labels = defineMessages({
defaultMessage: 'Track your campaigns through UTM parameters.',
},
conversionStep: { id: 'label.conversion-step', defaultMessage: 'Conversion step' },
conversionRate: { id: 'label.conversion-ratep', defaultMessage: 'Conversion rate' },
conversionRate: { id: 'label.conversion-rate', defaultMessage: 'Conversion rate' },
steps: { id: 'label.steps', defaultMessage: 'Steps' },
startStep: { id: 'label.start-step', defaultMessage: 'Start Step' },
endStep: { id: 'label.end-step', defaultMessage: 'End Step' },
@ -325,7 +325,7 @@ export const labels = defineMessages({
pixels: { id: 'label.pixels', defaultMessage: 'Pixels' },
addBoard: { id: 'label.add-board', defaultMessage: 'Add board' },
cohort: { id: 'label.cohort', defaultMessage: 'Cohort' },
expand: { id: 'label.expand', defaultMessage: 'Expand' },
maximize: { id: 'label.maximize', defaultMessage: 'Maximize' },
remaining: { id: 'label.remaining', defaultMessage: 'Remaining' },
conversion: { id: 'label.conversion', defaultMessage: 'Conversion' },
firstClick: { id: 'label.first-click', defaultMessage: 'First click' },

View file

@ -349,3 +349,7 @@ export function generateTimeSeries(
return { x: t, d: x, y: y ?? null };
});
}
export function dateToRangeValue(date: Date) {
return `range:${startOfMonth(date).getTime()}:${endOfMonth(date).getTime()}`;
}

View file

@ -5,6 +5,15 @@ import prisma from '@/lib/prisma';
export interface RetentionCriteria {
startDate: Date;
endDate: Date;
timezone?: string;
}
export interface RetentionResult {
date: string;
day: number;
visitors: number;
returnVisitors: number;
percentage: number;
}
export async function getRetention(...args: [websiteId: string, criteria: RetentionCriteria]) {
@ -17,16 +26,8 @@ export async function getRetention(...args: [websiteId: string, criteria: Retent
async function relationalQuery(
websiteId: string,
criteria: RetentionCriteria,
): Promise<
{
date: string;
day: number;
visitors: number;
returnVisitors: number;
percentage: number;
}[]
> {
const { startDate, endDate } = criteria;
): Promise<RetentionResult[]> {
const { startDate, endDate, timezone } = criteria;
const { getDateSQL, getDayDiffQuery, getCastColumnQuery, rawQuery } = prisma;
const unit = 'day';
@ -42,7 +43,7 @@ async function relationalQuery(
user_activities AS (
select distinct
w.session_id,
${getDayDiffQuery(getDateSQL('created_at', unit), 'c.cohort_date')} as day_number
${getDayDiffQuery(getDateSQL('created_at', unit, timezone), 'c.cohort_date')} as day_number
from website_event w
join cohort_items c
on w.session_id = c.session_id
@ -88,16 +89,8 @@ async function relationalQuery(
async function clickhouseQuery(
websiteId: string,
criteria: RetentionCriteria,
): Promise<
{
date: string;
day: number;
visitors: number;
returnVisitors: number;
percentage: number;
}[]
> {
const { startDate, endDate } = criteria;
): Promise<RetentionResult[]> {
const { startDate, endDate, timezone } = criteria;
const { getDateSQL, rawQuery } = clickhouse;
const unit = 'day';
@ -105,7 +98,7 @@ async function clickhouseQuery(
`
WITH cohort_items AS (
select
min(${getDateSQL('created_at', unit)}) as cohort_date,
min(${getDateSQL('created_at', unit, timezone)}) as cohort_date,
session_id
from website_event
where website_id = {websiteId:UUID}