Breakdown report.

This commit is contained in:
Mike Cao 2025-06-10 20:59:27 -07:00
parent 79ea9974b7
commit e3cc19638c
21 changed files with 495 additions and 456 deletions

View file

@ -1,6 +1,6 @@
generator client {
provider = "prisma-client"
previewFeatures = ["queryCompiler", "driverAdapters"]
previewFeatures = ["driverAdapters"]
output = "../src/generated/prisma"
moduleFormat = "esm"
}

View file

@ -81,7 +81,7 @@
"@react-spring/web": "^9.7.3",
"@svgr/cli": "^8.1.0",
"@tanstack/react-query": "^5.28.6",
"@umami/react-zen": "^0.136.0",
"@umami/react-zen": "^0.137.0",
"@umami/redis-client": "^0.27.0",
"bcryptjs": "^2.4.3",
"chalk": "^4.1.1",

490
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -7,6 +7,7 @@ import {
Logo,
Grid2X2,
Settings,
LockKeyhole,
} from '@/components/icons';
import { useMessages, useNavigation, useGlobalState } from '@/components/hooks';
@ -41,6 +42,11 @@ export function SideNav(props: any) {
href: renderTeamUrl('/settings'),
icon: <Settings />,
},
{
label: formatMessage(labels.admin),
href: renderTeamUrl('/admin'),
icon: <LockKeyhole />,
},
].filter(n => n);
return (

View file

@ -5,7 +5,7 @@ import { ReportsNav } from './ReportsNav';
export function ReportsLayout({ websiteId, children }: { websiteId: string; children: ReactNode }) {
return (
<Grid columns="200px 1fr" gap="6">
<Grid columns="180px 1fr" gap="6">
<Column>
<ReportsNav websiteId={websiteId} />
</Column>

View file

@ -1,6 +1,6 @@
import { Row, NavMenu, NavMenuItem, Icon, Text } from '@umami/react-zen';
import { useMessages, useNavigation } from '@/components/hooks';
import { Funnel, Lightbulb, Magnet, Money, Network, Path, Tag, Target } from '@/components/icons';
import { Funnel, Sheet, Magnet, Money, Network, Path, Tag, Target } from '@/components/icons';
import Link from 'next/link';
export function ReportsNav({ websiteId }: { websiteId: string }) {
@ -32,6 +32,12 @@ export function ReportsNav({ websiteId }: { websiteId: string }) {
icon: <Magnet />,
path: '/retention',
},
{
id: 'breakdown',
label: formatMessage(labels.breakdown),
icon: <Sheet />,
path: '/breakdown',
},
{
id: 'utm',
label: formatMessage(labels.utm),
@ -50,12 +56,6 @@ export function ReportsNav({ websiteId }: { websiteId: string }) {
icon: <Network />,
path: '/attribution',
},
{
id: 'insights',
label: formatMessage(labels.insights),
icon: <Lightbulb />,
path: '/insights',
},
];
const selected = links.find(({ path }) => path && pathname.endsWith(path))?.id || 'goals';

View file

@ -1,4 +1,4 @@
import { Grid, Column, Heading } from '@umami/react-zen';
import { Grid, Column } from '@umami/react-zen';
import { useMessages, useResultQuery } from '@/components/hooks';
import { Panel } from '@/components/common/Panel';
import { LoadingPanel } from '@/components/common/LoadingPanel';
@ -125,8 +125,7 @@ export function Attribution({
};
return (
<Panel key={value}>
<Heading>{label}</Heading>
<Panel key={value} title={label}>
<Grid columns="1fr 1fr" gap>
<ListTable
metric={formatMessage(currency ? labels.revenue : labels.visitors)}
@ -142,25 +141,15 @@ export function Attribution({
</Panel>
);
})}
<Grid gap>
<Panel title="UTM">
<Grid columns="1fr 1fr" gap>
<Panel>
<UTMTable data={data} title={formatMessage(labels.sources)} utm={'utm_source'} />
</Panel>
<Panel>
<UTMTable data={data} title={formatMessage(labels.medium)} utm={'utm_medium'} />
</Panel>
<Panel>
<UTMTable data={data} title={formatMessage(labels.campaigns)} utm={'utm_campaign'} />
</Panel>
<Panel>
<UTMTable data={data} title={formatMessage(labels.content)} utm={'utm_content'} />
</Panel>
<Panel>
<UTMTable data={data} title={formatMessage(labels.terms)} utm={'utm_term'} />
</Panel>
<UTMTable data={data} title={formatMessage(labels.sources)} utm={'utm_source'} />
<UTMTable data={data} title={formatMessage(labels.medium)} utm={'utm_medium'} />
<UTMTable data={data} title={formatMessage(labels.campaigns)} utm={'utm_campaign'} />
<UTMTable data={data} title={formatMessage(labels.content)} utm={'utm_content'} />
<UTMTable data={data} title={formatMessage(labels.terms)} utm={'utm_term'} />
</Grid>
</Grid>
</Panel>
</Column>
</LoadingPanel>
);

View file

@ -8,7 +8,7 @@ import { useDateRange, useMessages } from '@/components/hooks';
export function AttributionPage({ websiteId }: { websiteId: string }) {
const [model, setModel] = useState('first-click');
const [type, setType] = useState('page');
const [step, setStep] = useState('');
const [step, setStep] = useState('/');
const { formatMessage, labels } = useMessages();
const {
dateRange: { startDate, endDate },
@ -44,6 +44,7 @@ export function AttributionPage({ websiteId }: { websiteId: string }) {
<SearchField
label={formatMessage(labels.conversionStep)}
value={step}
defaultValue={step}
onSearch={setStep}
/>
</Column>

View file

@ -0,0 +1,73 @@
import { Text, DataTable, DataColumn } from '@umami/react-zen';
import { useMessages, useResultQuery, useFormat, useFields } from '@/components/hooks';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { formatShortTime } from '@/lib/format';
export interface BreakdownProps {
websiteId: string;
startDate: Date;
endDate: Date;
parameters: {
fields: string[];
};
}
export function Breakdown({ websiteId, parameters, startDate, endDate }: BreakdownProps) {
const { formatMessage, labels } = useMessages();
const { formatValue } = useFormat();
const { fields } = useFields();
const { data, error, isLoading } = useResultQuery<any>(
'breakdown',
{
websiteId,
dateRange: {
startDate,
endDate,
},
parameters,
},
{ enabled: !!parameters.fields.length },
);
return (
<LoadingPanel isEmpty={!data?.length} isLoading={isLoading} error={error}>
<DataTable data={data}>
{parameters?.fields.map(field => {
return (
<DataColumn key={field} id={field} label={fields.find(f => f.name === field)?.label}>
{row => {
const value = formatValue(row[field], field);
return (
<Text truncate title={value}>
{value}
</Text>
);
}}
</DataColumn>
);
})}
<DataColumn id="views" label={formatMessage(labels.views)} align="end">
{row => row?.['views']?.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>
<DataColumn id="bounceRate" label={formatMessage(labels.bounceRate)} align="end">
{row => {
const n = (Math.min(row?.['visits'], row?.['bounces']) / row?.['visits']) * 100;
return Math.round(+n) + '%';
}}
</DataColumn>
<DataColumn id="visitDuration" label={formatMessage(labels.visitDuration)} align="end">
{row => {
const n = (row?.['totaltime'] / row?.['visits']) * 100;
return `${+n < 0 ? '-' : ''}${formatShortTime(Math.abs(~~n), ['m', 's'], ' ')}`;
}}
</DataColumn>
</DataTable>
</LoadingPanel>
);
}

View file

@ -0,0 +1,97 @@
'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 { Panel } from '@/components/common/Panel';
import { Breakdown } from './Breakdown';
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
export function BreakdownPage({ websiteId }: { websiteId: string }) {
const {
dateRange: { startDate, endDate },
} = useDateRange(websiteId);
const [fields, setFields] = useState([]);
return (
<Column gap>
<WebsiteControls websiteId={websiteId} />
<Box>
<FieldsButton value={fields} onChange={setFields} />
</Box>
<Panel height="900px" overflow="auto" allowFullscreen>
<Breakdown
websiteId={websiteId}
startDate={startDate}
endDate={endDate}
parameters={{ fields }}
/>
</Panel>
</Column>
);
}
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>
);
};

View file

@ -1,10 +1,10 @@
import { Metadata } from 'next';
import { InsightsPage } from './InsightsPage';
import { BreakdownPage } from './BreakdownPage';
export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
const { websiteId } = await params;
return <InsightsPage websiteId={websiteId} />;
return <BreakdownPage websiteId={websiteId} />;
}
export const metadata: Metadata = {

View file

@ -24,7 +24,7 @@ export function GoalsPage({ websiteId }: { websiteId: string }) {
<Grid columns="1fr 1fr" gap>
{result?.data?.map((report: any) => (
<Panel key={report.id}>
<Goal {...report} reportId={report.id} startDate={startDate} endDate={endDate} />
<Goal {...report} startDate={startDate} endDate={endDate} />
</Panel>
))}
</Grid>

View file

@ -1,126 +0,0 @@
import { ReactNode } from 'react';
import { Grid, Row, Column, Text, Loading, Icon } from '@umami/react-zen';
import { Empty } from '@/components/common/Empty';
import { Users } from '@/components/icons';
import { useMessages, useLocale, useResultQuery } from '@/components/hooks';
import { formatDate } from '@/lib/date';
import { formatLongNumber } from '@/lib/format';
import { Panel } from '@/components/common/Panel';
import { LoadingPanel } from '@/components/common/LoadingPanel';
const DAYS = [1, 2, 3, 4, 5, 6, 7, 14, 21, 28];
export interface AttributionProps {
websiteId: string;
startDate: Date;
endDate: Date;
days?: number[];
}
export function Insights({ websiteId, days = DAYS, startDate, endDate }: AttributionProps) {
const { formatMessage, labels } = useMessages();
const { locale } = useLocale();
const { data, error, isLoading } = useResultQuery<any>('insights', {
websiteId,
dateRange: {
startDate,
endDate,
},
parameters: {
days,
},
});
if (isLoading) {
return <Loading position="page" />;
}
if (!data) {
return <Empty />;
}
const rows = data.reduce((arr: any[], row: { date: any; visitors: any; day: any }) => {
const { date, visitors, day } = row;
if (day === 0) {
return arr.concat({
date,
visitors,
records: days
.reduce((arr, day) => {
arr[day] = data.find(x => x.date === date && x.day === day);
return arr;
}, [])
.filter(n => n),
});
}
return arr;
}, []);
const totalDays = rows.length;
return (
<LoadingPanel isEmpty={!data?.length} 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">{formatMessage(labels.cohort)}</Text>
</Column>
{days.map(n => (
<Column key={n}>
<Text weight="bold" align="center" wrap="nowrap">
{formatMessage(labels.day)} {n}
</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>
</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>
);
}
const Cell = ({ children }: { children: ReactNode }) => {
return (
<Column
justifyContent="center"
alignItems="center"
width="100px"
height="100px"
backgroundColor="2"
borderRadius
>
{children}
</Column>
);
};

View file

@ -1,18 +0,0 @@
'use client';
import { Column } from '@umami/react-zen';
import { Insights } from './Insights';
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
import { useDateRange } from '@/components/hooks';
export function InsightsPage({ websiteId }: { websiteId: string }) {
const {
dateRange: { startDate, endDate },
} = useDateRange(websiteId);
return (
<Column gap>
<WebsiteControls websiteId={websiteId} />
<Insights websiteId={websiteId} startDate={startDate} endDate={endDate} />
</Column>
);
}

View file

@ -1,7 +1,7 @@
import { canViewWebsite } from '@/lib/auth';
import { unauthorized, json } from '@/lib/response';
import { parseRequest } from '@/lib/request';
import { getInsights } from '@/queries';
import { getBreakdown } from '@/queries';
import { reportResultSchema } from '@/lib/schema';
export async function POST(request: Request) {
@ -14,27 +14,17 @@ export async function POST(request: Request) {
const {
websiteId,
dateRange: { startDate, endDate },
fields,
filters,
parameters: { fields },
} = body;
if (!(await canViewWebsite(auth, websiteId))) {
return unauthorized();
}
const data = await getInsights(websiteId, fields, {
...convertFilters(filters),
const data = await getBreakdown(websiteId, fields, {
startDate: new Date(startDate),
endDate: new Date(endDate),
});
return json(data);
}
function convertFilters(filters: any[]) {
return filters.reduce((obj, filter) => {
obj[filter.name] = filter;
return obj;
}, {});
}

View file

@ -7,11 +7,13 @@ import {
Button,
TooltipTrigger,
Tooltip,
Heading,
} from '@umami/react-zen';
import { Maximize, Close } from '@/components/icons';
import { useMessages } from '@/components/hooks';
export interface PanelProps extends ColumnProps {
title?: string;
allowFullscreen?: boolean;
}
@ -25,7 +27,7 @@ const fullscreenStyles = {
zIndex: 9999,
} as any;
export function Panel({ allowFullscreen, style, children, ...props }: PanelProps) {
export function Panel({ title, allowFullscreen, style, children, ...props }: PanelProps) {
const { formatMessage, labels } = useMessages();
const [isFullscreen, setIsFullscreen] = useState(false);
@ -44,6 +46,7 @@ export function Panel({ allowFullscreen, style, children, ...props }: PanelProps
{...props}
style={{ ...style, ...(isFullscreen ? fullscreenStyles : {}) }}
>
{title && <Heading>{title}</Heading>}
{allowFullscreen && (
<Row justifyContent="flex-end" alignItems="center">
<TooltipTrigger delay={0} isDisabled={isFullscreen}>

View file

@ -5,8 +5,8 @@ export {
ChartPie,
ChevronRight as Chevron,
Clock,
X as Close,
Copy,
Download,
Edit,
Ellipsis,
Eye,
@ -29,11 +29,15 @@ export {
RefreshCw as Refresh,
Settings,
Share,
Sheet,
Slash,
SquarePen,
SquarePlus,
Sun,
Trash,
Upload,
User,
Users,
X as Close,
} from 'lucide-react';
export * from '@/components/svg';

View file

@ -18,7 +18,7 @@ export const labels = defineMessages({
user: { id: 'label.user', defaultMessage: 'User' },
viewOnly: { id: 'label.view-only', defaultMessage: 'View only' },
manage: { id: 'label.manage', defaultMessage: 'Manage' },
admin: { id: 'label.admin', defaultMessage: 'Administrator' },
admin: { id: 'label.admin', defaultMessage: 'Admin' },
confirm: { id: 'label.confirm', defaultMessage: 'Confirm' },
details: { id: 'label.details', defaultMessage: 'Details' },
website: { id: 'label.website', defaultMessage: 'Website' },
@ -215,6 +215,7 @@ export const labels = defineMessages({
value: { id: 'label.value', defaultMessage: 'Value' },
overview: { id: 'label.overview', defaultMessage: 'Overview' },
totalRecords: { id: 'label.total-records', defaultMessage: 'Total records' },
insight: { id: 'label.insight', defaultMessage: 'Insight' },
insights: { id: 'label.insights', defaultMessage: 'Insights' },
insightsDescription: {
id: 'label.insights-description',

View file

@ -54,15 +54,31 @@ export const urlOrPathParam = z.string().refine(
},
);
export const fieldsParam = z.enum([
'url',
'referrer',
'title',
'query',
'os',
'browser',
'device',
'country',
'region',
'city',
'tag',
'host',
'language',
]);
export const reportTypeParam = z.enum([
'attribution',
'breakdown',
'funnel',
'insight',
'retention',
'utm',
'goal',
'journey',
'retention',
'revenue',
'attribution',
'utm',
]);
export const reportParms = {
@ -141,8 +157,11 @@ export const attributionReportSchema = z.object({
}),
});
export const insightsReportSchema = z.object({
type: z.literal('insights'),
export const breakdownReportSchema = z.object({
type: z.literal('breakdown'),
parameters: z.object({
fields: z.array(fieldsParam),
}),
});
export const reportBaseSchema = z.object({
@ -160,7 +179,7 @@ export const reportTypeSchema = z.discriminatedUnion('type', [
utmReportSchema,
revenueReportSchema,
attributionReportSchema,
insightsReportSchema,
breakdownReportSchema,
]);
export const reportSchema = z.intersection(reportBaseSchema, reportTypeSchema);

View file

@ -16,7 +16,7 @@ export * from '@/queries/sql/events/saveEvent';
export * from '@/queries/sql/reports/getFunnel';
export * from '@/queries/sql/reports/getJourney';
export * from '@/queries/sql/reports/getRetention';
export * from '@/queries/sql/reports/getInsights';
export * from '@/queries/sql/reports/getBreakdown';
export * from '@/queries/sql/reports/getUTM';
export * from '@/queries/sql/pageviews/getPageviewMetrics';
export * from '@/queries/sql/pageviews/getPageviewStats';

View file

@ -4,8 +4,8 @@ import clickhouse from '@/lib/clickhouse';
import { EVENT_TYPE, FILTER_COLUMNS, SESSION_COLUMNS } from '@/lib/constants';
import { QueryFilters } from '@/lib/types';
export async function getInsights(
...args: [websiteId: string, fields: { name: string; type?: string }[], filters: QueryFilters]
export async function getBreakdown(
...args: [websiteId: string, fields: string[], filters: QueryFilters]
) {
return runQuery({
[PRISMA]: () => relationalQuery(...args),
@ -15,7 +15,7 @@ export async function getInsights(
async function relationalQuery(
websiteId: string,
fields: { name: string; type?: string }[],
fields: string[],
filters: QueryFilters,
): Promise<
{
@ -31,7 +31,7 @@ async function relationalQuery(
eventType: EVENT_TYPE.pageView,
},
{
joinSession: !!fields.find(({ name }) => SESSION_COLUMNS.includes(name)),
joinSession: !!fields.find(name => SESSION_COLUMNS.includes(name)),
},
);
@ -71,7 +71,7 @@ async function relationalQuery(
async function clickhouseQuery(
websiteId: string,
fields: { name: string; type?: string }[],
fields: string[],
filters: QueryFilters,
): Promise<
{
@ -118,10 +118,10 @@ async function clickhouseQuery(
);
}
function parseFields(fields: { name: any }[]) {
return fields.map(({ name }) => `${FILTER_COLUMNS[name]} as "${name}"`).join(',');
function parseFields(fields: string[]) {
return fields.map(name => `${FILTER_COLUMNS[name]} as "${name}"`).join(',');
}
function parseFieldsByName(fields: { name: any }[]) {
return `${fields.map(({ name }) => name).join(',')}`;
function parseFieldsByName(fields: string[]) {
return `${fields.map(name => name).join(',')}`;
}