Fixed retention report showing wrong dates. Changed Breakdown field select to modal.

This commit is contained in:
Mike Cao 2025-06-29 15:36:43 -07:00
parent ee8750d9df
commit ea83afbc13
20 changed files with 108 additions and 277 deletions

View file

@ -24,7 +24,7 @@ import { Panel } from '@/components/common/Panel';
import { DateDisplay } from '@/components/common/DateDisplay';
const views = {
url: PagesTable,
path: PagesTable,
title: PagesTable,
referrer: ReferrersTable,
browser: BrowsersTable,

View file

@ -20,7 +20,7 @@ export function WebsiteControls({
return (
<Column gap>
<Row alignItems="center" justifyContent="space-between" gap="3">
{allowFilter && <WebsiteFilterButton websiteId={websiteId} />}
{allowFilter ? <WebsiteFilterButton websiteId={websiteId} /> : <div />}
{allowDateFilter && <WebsiteDateFilter websiteId={websiteId} allowCompare={allowCompare} />}
{allowMonthFilter && <WebsiteMonthSelect websiteId={websiteId} />}
</Row>

View file

@ -30,9 +30,9 @@ export function WebsiteMetricsBar({
const metrics = data
? [
{
value: pageviews,
label: formatMessage(labels.views),
change: pageviews - previous.pageviews,
value: visitors,
label: formatMessage(labels.visitors),
change: visitors - previous.visitors,
formatValue: formatLongNumber,
},
{
@ -42,9 +42,9 @@ export function WebsiteMetricsBar({
formatValue: formatLongNumber,
},
{
value: visitors,
label: formatMessage(labels.visitors),
change: visitors - previous.visitors,
value: pageviews,
label: formatMessage(labels.views),
change: pageviews - previous.pageviews,
formatValue: formatLongNumber,
},
{

View file

@ -46,14 +46,14 @@ export function Breakdown({ websiteId, parameters, startDate, endDate }: Breakdo
</DataColumn>
);
})}
<DataColumn id="views" label={formatMessage(labels.views)} align="end">
{row => row?.['views']?.toLocaleString()}
<DataColumn id="visitors" label={formatMessage(labels.visitors)} align="end">
{row => row?.['visitors']?.toLocaleString()}
</DataColumn>
<DataColumn id="visits" label={formatMessage(labels.visits)} align="end">
{row => row?.['visits']?.toLocaleString()}
</DataColumn>
<DataColumn id="visitors" label={formatMessage(labels.visitors)} align="end">
{row => row?.['visitors']?.toLocaleString()}
<DataColumn id="views" label={formatMessage(labels.views)} align="end">
{row => row?.['views']?.toLocaleString()}
</DataColumn>
<DataColumn id="bounceRate" label={formatMessage(labels.bounceRate)} align="end">
{row => {

View file

@ -1,22 +1,12 @@
'use client';
import { useState } from 'react';
import {
List,
ListItem,
Button,
Column,
Box,
Grid,
Text,
Icon,
Popover,
DialogTrigger,
} from '@umami/react-zen';
import { useDateRange, useMessages, useFields } from '@/components/hooks';
import { SquarePlus, Chevron } from '@/components/icons';
import { Button, Column, Box, Text, Icon, DialogTrigger, Modal, Dialog } from '@umami/react-zen';
import { useDateRange, useMessages } from '@/components/hooks';
import { ListCheck } from '@/components/icons';
import { Panel } from '@/components/common/Panel';
import { Breakdown } from './Breakdown';
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
import { FieldSelectForm } from '@/app/(main)/websites/[websiteId]/reports/breakdown/FieldSelectForm';
export function BreakdownPage({ websiteId }: { websiteId: string }) {
const {
@ -27,9 +17,7 @@ export function BreakdownPage({ websiteId }: { websiteId: string }) {
return (
<Column gap>
<WebsiteControls websiteId={websiteId} />
<Box>
<FieldsButton value={fields} onChange={setFields} />
</Box>
<FieldsButton value={fields} onChange={setFields} />
<Panel height="900px" overflow="auto" allowFullscreen>
<Breakdown
websiteId={websiteId}
@ -43,55 +31,25 @@ export function BreakdownPage({ websiteId }: { websiteId: string }) {
}
const FieldsButton = ({ value, onChange }) => {
const [selected, setSelected] = useState(value);
const [isOpen, setIsOpen] = useState(false);
const { formatMessage, labels } = useMessages();
const { fields } = useFields();
const handleChange = value => {
setSelected(value);
};
const handleApply = () => {
setIsOpen(false);
onChange?.(selected);
};
const handleClose = () => {
setIsOpen(false);
setSelected(value);
};
return (
<DialogTrigger>
<Button variant="quiet" onPress={() => setIsOpen(!isOpen)}>
<Icon>
<SquarePlus />
</Icon>
<Text>Fields</Text>
<Icon rotate={90}>
<Chevron />
</Icon>
</Button>
<Popover placement="bottom start" isOpen={isOpen}>
<Column width="300px" padding="2" border borderRadius shadow="3" backgroundColor gap>
<List value={selected} onChange={handleChange} selectionMode="multiple">
{fields.map(({ name, label }) => {
return (
<ListItem key={name} id={name}>
{label}
</ListItem>
);
})}
</List>
<Grid columns="1fr 1fr" gap>
<Button onPress={handleClose}>{formatMessage(labels.cancel)}</Button>
<Button onPress={handleApply} variant="primary">
{formatMessage(labels.apply)}
</Button>
</Grid>
</Column>
</Popover>
</DialogTrigger>
<Box>
<DialogTrigger>
<Button>
<Icon>
<ListCheck />
</Icon>
<Text>Fields</Text>
</Button>
<Modal>
<Dialog title={formatMessage(labels.fields)}>
{({ close }) => (
<FieldSelectForm selectedFields={value} onChange={onChange} onClose={close} />
)}
</Dialog>
</Modal>
</DialogTrigger>
</Box>
);
};

View file

@ -0,0 +1,46 @@
import { Column, List, ListItem, Grid, Button } from '@umami/react-zen';
import { useFields, useMessages } from '@/components/hooks';
import { useState } from 'react';
export function FieldSelectForm({
selectedFields = [],
onChange,
onClose,
}: {
selectedFields?: string[];
onChange: (values: string[]) => void;
onClose?: () => void;
}) {
const [selected, setSelected] = useState(selectedFields);
const { formatMessage, labels } = useMessages();
const { fields } = useFields();
const handleChange = (value: string[]) => {
setSelected(value);
};
const handleApply = () => {
onChange?.(selected);
onClose();
};
return (
<Column width="300px" gap="6">
<List value={selected} onChange={handleChange} selectionMode="multiple">
{fields.map(({ name, label }) => {
return (
<ListItem key={name} id={name}>
{label}
</ListItem>
);
})}
</List>
<Grid columns="1fr 1fr" gap>
<Button onPress={onClose}>{formatMessage(labels.cancel)}</Button>
<Button onPress={handleApply} variant="primary">
{formatMessage(labels.apply)}
</Button>
</Grid>
</Column>
);
}

View file

@ -69,7 +69,7 @@ export function GoalEditForm({
label={formatMessage(labels.name)}
rules={{ required: formatMessage(labels.required) }}
>
<TextField />
<TextField autoFocus />
</FormField>
<FormField
name="type"

View file

@ -22,10 +22,10 @@ export function Retention({ websiteId, days = DAYS, startDate, endDate }: Retent
const { timezone } = useTimezone();
const { data, error, isLoading } = useResultQuery<any>('retention', {
websiteId,
timezone,
dateRange: {
startDate,
endDate,
timezone,
},
});

View file

@ -15,6 +15,7 @@ export async function POST(request: Request) {
websiteId,
dateRange: { startDate, endDate },
parameters: { fields },
filters,
} = body;
if (!(await canViewWebsite(auth, websiteId))) {
@ -24,6 +25,7 @@ export async function POST(request: Request) {
const data = await getBreakdown(websiteId, fields, {
startDate: new Date(startDate),
endDate: new Date(endDate),
...filters,
});
return json(data);

View file

@ -13,8 +13,7 @@ export async function POST(request: Request) {
const {
websiteId,
dateRange: { startDate, endDate },
timezone,
dateRange: { startDate, endDate, timezone },
} = body;
if (!(await canViewWebsite(auth, websiteId))) {

View file

@ -1,10 +0,0 @@
.bar {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
color: var(--base600);
}
.link span {
color: var(--base700) !important;
}

View file

@ -1,37 +0,0 @@
import { Fragment } from 'react';
import Link from 'next/link';
import { Row, Icon, Text } from '@umami/react-zen';
import { Chevron } from '@/components/icons';
import styles from './Breadcrumb.module.css';
export interface BreadcrumbProps {
data: {
url?: string;
label: string;
}[];
}
export function Breadcrumb({ data }: BreadcrumbProps) {
return (
<Row alignItems="center" gap className={styles.bar}>
{data.map((a, i) => {
return (
<Fragment key={i}>
{a.url ? (
<Link href={a.url} className={styles.link}>
<Text>{a.label}</Text>
</Link>
) : (
<Text>{a.label}</Text>
)}
{i !== data.length - 1 ? (
<Icon rotate={270}>
<Chevron />
</Icon>
) : null}
</Fragment>
);
})}
</Row>
);
}

View file

@ -1,34 +0,0 @@
.search {
max-width: 300px;
margin: 20px 0;
}
.body {
display: flex;
flex-direction: column;
position: relative;
overflow-x: auto;
}
.body td {
display: flex;
gap: 10px;
min-height: 70px;
align-items: center;
}
.body > div > div > div {
display: flex;
gap: 10px;
}
.pager {
margin: 20px 0;
}
.status {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
}

View file

@ -1,107 +0,0 @@
.button {
display: flex;
align-items: center;
align-self: flex-start;
white-space: nowrap;
gap: var(--size200);
font-family: inherit;
color: var(--base900);
background: var(--base100);
border: 1px solid transparent;
border-radius: var(--border-radius);
min-height: var(--base-height);
padding: 0 var(--size600);
position: relative;
cursor: pointer;
}
.button:hover {
background: var(--base200);
}
.button:active {
background: var(--base300);
}
.button:visited {
color: var(--base900);
}
.button.disabled {
color: var(--disabled-color) !important;
background-color: var(--disabled-background) !important;
border-color: transparent !important;
pointer-events: none;
}
.button.primary {
color: var(--light50);
background: var(--primary-color);
}
.button.primary:hover {
color: var(--light50);
background: var(--primary500);
}
.button.primary:active {
color: var(--light50);
background: var(--primary600);
}
.button.secondary {
border: 1px solid var(--border-color);
background: var(--base50);
}
.button.secondary:hover {
background: var(--base75);
}
.button.secondary:active {
background: var(--base100);
}
.button.quiet {
color: var(--base900);
background: transparent;
}
.button.quiet:hover {
background: var(--base100);
}
.button.quiet:active {
background: var(--base200);
}
.button.danger {
color: var(--light50);
background: var(--red800);
}
.button.danger:hover {
color: var(--light50);
background: var(--red900);
}
.button.danger:active {
color: var(--light50);
background: var(--red1000);
}
.button.size-sm {
font-size: var(--font-size-sm);
height: calc(var(--base-height) * 0.75);
padding: 0 calc(var(--size600) * 0.75);
}
.button.size-md {
font-size: var(--font-size-md);
}
.button.size-lg {
font-size: var(--font-size-lg);
height: calc(var(--base-height) * 1.25);
padding: 0 calc(var(--size600) * 1.25);
}

View file

@ -1,4 +1,5 @@
import { useApi } from '@/components/hooks';
import { useApi } from '../useApi';
import { useFilterParams } from '../useFilterParams';
import { ReactQueryOptions } from '@/lib/types';
export function useResultQuery<T>(
@ -6,11 +7,21 @@ export function useResultQuery<T>(
params?: { [key: string]: any },
options?: ReactQueryOptions<T>,
) {
const { websiteId } = params;
const { post, useQuery } = useApi();
const filterParams = useFilterParams(websiteId);
return useQuery<T>({
queryKey: ['reports', type, params],
queryFn: () => post(`/reports/${type}`, { type, ...params }),
queryKey: [
'reports',
{
type,
websiteId,
...filterParams,
...params,
},
],
queryFn: () => post(`/reports/${type}`, { type, ...filterParams, ...params }),
enabled: !!type,
...options,
});

View file

@ -11,7 +11,7 @@ export type WebsiteMetricsData = {
export function useWebsiteMetricsQuery(
websiteId: string,
queryParams: { type: string; limit?: number; search?: string; startAt?: number; endAt?: number },
params: { type: string; limit?: number; search?: string; startAt?: number; endAt?: number },
options?: ReactQueryOptions<WebsiteMetricsData>,
) {
const { get, useQuery } = useApi();
@ -24,14 +24,14 @@ export function useWebsiteMetricsQuery(
{
websiteId,
...filterParams,
...queryParams,
...params,
},
],
queryFn: async () =>
get(`/websites/${websiteId}/metrics`, {
...filterParams,
[searchParams.get('view')]: undefined,
...queryParams,
...params,
}),
enabled: !!websiteId,
placeholderData: keepPreviousData,

View file

@ -10,6 +10,7 @@ export {
Download,
Edit,
Ellipsis,
Equal,
Eye,
ExternalLink,
File,
@ -20,6 +21,7 @@ export {
KeyRound,
LayoutDashboard,
Link,
ListCheck,
ListFilter,
LockKeyhole,
LogOut,

View file

@ -88,7 +88,8 @@ export const reportParms = {
endDate: z.coerce.date(),
num: z.coerce.number().optional(),
offset: z.coerce.number().optional(),
unit: z.string().optional(),
timezone: timezoneParam.optional(),
unit: unitParam.optional(),
value: z.string().optional(),
}),
};

View file

@ -68,7 +68,7 @@ export interface QueryFilters {
timezone?: string;
unit?: string;
eventType?: number;
url?: string;
path?: string;
referrer?: string;
title?: string;
query?: string;

View file

@ -108,7 +108,7 @@ async function clickhouseQuery(
user_activities AS (
select distinct
w.session_id,
(${getDateSQL('created_at', unit)} - c.cohort_date) / 86400 as day_number
(${getDateSQL('created_at', unit, timezone)} - c.cohort_date) / 86400 as day_number
from website_event w
join cohort_items c
on w.session_id = c.session_id