Converted UTM report to a view.

This commit is contained in:
Mike Cao 2025-05-20 21:25:06 -07:00
parent 06f76dda13
commit d0d11225f4
24 changed files with 1815 additions and 1568 deletions

View file

@ -235,3 +235,42 @@ model Report {
@@index([name])
@@map("report")
}
model Board {
id String @id() @unique() @map("board_id") @db.Uuid
userId String @map("user_id") @db.Uuid
teamId String? @map("team_id") @db.Uuid
name String @map("name") @db.VarChar(200)
description String @map("description") @db.VarChar(500)
parameters Json @map("parameters") @db.JsonB
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
user User @relation(fields: [userId], references: [id])
team Team? @relation(fields: [teamId], references: [id])
@@index([userId])
@@index([teamId])
@@index([type])
@@index([name])
@@map("board")
}
model Block {
id String @id() @unique() @map("block_id") @db.Uuid
userId String @map("user_id") @db.Uuid
name String @map("name") @db.VarChar(200)
type String @map("type") @db.VarChar(20)
parameters Json @map("parameters") @db.JsonB
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6)
user User @relation(fields: [userId], references: [id])
website Website @relation(fields: [websiteId], references: [id])
@@index([userId])
@@index([websiteId])
@@index([type])
@@index([name])
@@map("block")
}

View file

@ -78,7 +78,7 @@
"@react-spring/web": "^9.7.3",
"@svgr/cli": "^8.1.0",
"@tanstack/react-query": "^5.28.6",
"@umami/react-zen": "^0.111.0",
"@umami/react-zen": "^0.114.0",
"@umami/redis-client": "^0.27.0",
"bcryptjs": "^2.4.3",
"chalk": "^4.1.1",
@ -125,7 +125,7 @@
"serialize-error": "^12.0.0",
"thenby": "^1.3.4",
"uuid": "^9.0.0",
"zod": "^3.24.3",
"zod": "^3.25.7",
"zustand": "^4.5.5"
},
"devDependencies": {
@ -144,8 +144,8 @@
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.2",
"@types/react-window": "^1.8.8",
"@typescript-eslint/eslint-plugin": "^6.7.3",
"@typescript-eslint/parser": "^6.7.3",
"@typescript-eslint/eslint-plugin": "^8.32.1",
"@typescript-eslint/parser": "^8.32.1",
"cross-env": "^7.0.3",
"cypress": "^13.6.6",
"esbuild": "^0.25.0",

2980
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,40 @@
import { useMessages, useModified, useNavigation } from '@/components/hooks';
import {
Button,
Icon,
Icons,
Modal,
Dialog,
DialogTrigger,
Text,
useToast,
} from '@umami/react-zen';
import { BoardAddForm } from './BoardAddForm';
export function BoardAddButton() {
const { formatMessage, labels, messages } = useMessages();
const { toast } = useToast();
const { touch } = useModified();
const { teamId } = useNavigation();
const handleSave = async () => {
toast(formatMessage(messages.saved));
touch('boards');
};
return (
<DialogTrigger>
<Button data-test="button-website-add" variant="primary">
<Icon>
<Icons.Plus />
</Icon>
<Text>{formatMessage(labels.addBoard)}</Text>
</Button>
<Modal>
<Dialog title={formatMessage(labels.addBoard)}>
{({ close }) => <BoardAddForm teamId={teamId} onSave={handleSave} onClose={close} />}
</Dialog>
</Modal>
</DialogTrigger>
);
}

View file

@ -0,0 +1,64 @@
import { Form, FormField, FormSubmitButton, Row, TextField, Button } from '@umami/react-zen';
import { useApi } from '@/components/hooks';
import { DOMAIN_REGEX } from '@/lib/constants';
import { useMessages } from '@/components/hooks';
export function BoardAddForm({
teamId,
onSave,
onClose,
}: {
teamId?: string;
onSave?: () => void;
onClose?: () => void;
}) {
const { formatMessage, labels, messages } = useMessages();
const { post, useMutation } = useApi();
const { mutate, error, isPending } = useMutation({
mutationFn: (data: any) => post('/websites', { ...data, teamId }),
});
const handleSubmit = async (data: any) => {
mutate(data, {
onSuccess: async () => {
onSave?.();
onClose?.();
},
});
};
return (
<Form onSubmit={handleSubmit} error={error?.message}>
<FormField
label={formatMessage(labels.name)}
data-test="input-name"
name="name"
rules={{ required: formatMessage(labels.required) }}
>
<TextField autoComplete="off" />
</FormField>
<FormField
label={formatMessage(labels.domain)}
data-test="input-domain"
name="domain"
rules={{
required: formatMessage(labels.required),
pattern: { value: DOMAIN_REGEX, message: formatMessage(messages.invalidDomain) },
}}
>
<TextField autoComplete="off" />
</FormField>
<Row justifyContent="flex-end" paddingTop="3" gap="3">
{onClose && (
<Button isDisabled={isPending} onPress={onClose}>
{formatMessage(labels.cancel)}
</Button>
)}
<FormSubmitButton data-test="button-submit" isDisabled={false}>
{formatMessage(labels.save)}
</FormSubmitButton>
</Row>
</Form>
);
}

View file

@ -1,14 +1,21 @@
'use client';
import { Column } from '@umami/react-zen';
import Link from 'next/link';
import { PageHeader } from '@/components/common/PageHeader';
import { PageBody } from '@/components/common/PageBody';
import { BoardAddButton } from './BoardAddButton';
export function BoardsPage() {
return (
<Column>
<PageHeader title="My Boards" />
<Link href="/teams/3a97e34a-7f9d-4de2-8754-ed81714b528d/boards/86d4095c-a2a8-4fc8-9521-103e858e2b41">
Board 1
</Link>
</Column>
<PageBody>
<Column>
<PageHeader title="My Boards">
<BoardAddButton />
</PageHeader>
<Link href="/teams/3a97e34a-7f9d-4de2-8754-ed81714b528d/boards/86d4095c-a2a8-4fc8-9521-103e858e2b41">
Board 1
</Link>
</Column>
</PageBody>
);
}

View file

@ -11,8 +11,8 @@ export function WebsiteControls({
showFilter?: boolean;
}) {
return (
<Column marginBottom="6" gap="3">
<Row alignItems="center" justifyContent="space-between" gap="3" paddingY="3">
<Column gap>
<Row alignItems="center" justifyContent="space-between" gap="3">
{showFilter && <WebsiteFilterButton websiteId={websiteId} />}
<Row alignItems="center" gap="3">
<WebsiteDateFilter websiteId={websiteId} />

View file

@ -7,6 +7,7 @@ import { WebsiteExpandedView } from './WebsiteExpandedView';
import { WebsiteMetricsBar } from './WebsiteMetricsBar';
import { WebsiteTableView } from './WebsiteTableView';
import { WebsiteCompareTables } from './WebsiteCompareTables';
import { WebsiteControls } from './WebsiteControls';
export function WebsiteDetailsPage({ websiteId }: { websiteId: string }) {
const {
@ -15,6 +16,7 @@ export function WebsiteDetailsPage({ websiteId }: { websiteId: string }) {
return (
<Column gap>
<WebsiteControls websiteId={websiteId} />
<Panel>
<WebsiteMetricsBar websiteId={websiteId} showFilter={true} showChange={true} />
</Panel>

View file

@ -3,6 +3,7 @@ import { PageHeader } from '@/components/common/PageHeader';
import { useWebsite } from '@/components/hooks/useWebsite';
import { Lucide } from '@/components/icons';
import { Favicon } from '@/components/common/Favicon';
import { ActiveUsers } from '@/components/metrics/ActiveUsers';
export function WebsiteHeader() {
const website = useWebsite();
@ -10,6 +11,7 @@ export function WebsiteHeader() {
return (
<PageHeader title={website.name} icon={<Favicon domain={website.domain} />}>
<Row alignItems="center" gap>
<ActiveUsers websiteId={website.id} />
<Button>
<Icon>
<Lucide.Share />

View file

@ -2,7 +2,6 @@
import { ReactNode } from 'react';
import { Grid, Column } from '@umami/react-zen';
import { WebsiteProvider } from './WebsiteProvider';
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
import { WebsiteNav } from '@/app/(main)/websites/[websiteId]/WebsiteNav';
import { PageBody } from '@/components/common/PageBody';
import { WebsiteHeader } from '@/app/(main)/websites/[websiteId]/WebsiteHeader';
@ -11,16 +10,15 @@ export function WebsiteLayout({ websiteId, children }: { websiteId: string; chil
return (
<WebsiteProvider websiteId={websiteId}>
<PageBody>
<WebsiteHeader />
<Grid columns="auto 1fr" justifyContent="center" gap width="100%">
<Column position="sticky" top="0px" alignSelf="flex-start" width="200px" paddingTop="3">
<WebsiteNav websiteId={websiteId} />
</Column>
<Column>
<WebsiteControls websiteId={websiteId} />
{children}
</Column>
</Grid>
<Column gap="6">
<WebsiteHeader />
<Grid columns="auto 1fr" justifyContent="center" gap="6" width="100%">
<Column position="sticky" top="20px" alignSelf="flex-start" width="200px">
<WebsiteNav websiteId={websiteId} />
</Column>
<Column>{children}</Column>
</Grid>
</Column>
</PageBody>
</WebsiteProvider>
);

View file

@ -9,6 +9,7 @@ import { EventsChart } from '@/components/metrics/EventsChart';
import { GridRow } from '@/components/common/GridRow';
import { useMessages } from '@/components/hooks';
import { EventProperties } from './EventProperties';
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
export function EventsPage({ websiteId }) {
const [label, setLabel] = useState(null);
@ -21,6 +22,7 @@ export function EventsPage({ websiteId }) {
return (
<Column gap="3">
<WebsiteControls websiteId={websiteId} />
<Panel>
<EventsMetricsBar websiteId={websiteId} />
</Panel>

View file

@ -1,6 +1,14 @@
'use client';
import { Column } from '@umami/react-zen';
import { Column, Button, Heading } from '@umami/react-zen';
export function GoalsPage({ websiteId }: { websiteId: string }) {
return <Column>Goals {websiteId}</Column>;
return (
<Column>
{websiteId}
<Button>Add goal</Button>
<Heading>Goal 1</Heading>
<Heading>Goal 2</Heading>
<Heading>Goal 3</Heading>
</Column>
);
}

View file

@ -1,6 +1,13 @@
'use client';
import { Column } from '@umami/react-zen';
import { UTMView } from './UTMView';
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
export function UTMPage({ websiteId }: { websiteId: string }) {
return <Column>Goals {websiteId}</Column>;
return (
<Column gap>
<WebsiteControls websiteId={websiteId} />
<UTMView websiteId={websiteId} />
</Column>
);
}

View file

@ -0,0 +1,68 @@
import { Column, Heading } from '@umami/react-zen';
import { firstBy } from 'thenby';
import { CHART_COLORS, UTM_PARAMS } from '@/lib/constants';
import { useUTMQuery } from '@/components/hooks';
import { PieChart } from '@/components/charts/PieChart';
import { ListTable } from '@/components/metrics/ListTable';
import { useMessages } from '@/components/hooks';
import { Panel } from '@/components/common/Panel';
import { GridRow } from '@/components/common/GridRow';
function toArray(data: { [key: string]: number } = {}) {
return Object.keys(data)
.map(key => {
return { name: key, value: data[key] };
})
.sort(firstBy('value', -1));
}
export function UTMView({ websiteId }: { websiteId: string }) {
const { formatMessage, labels } = useMessages();
const { data } = useUTMQuery(websiteId);
if (!data) {
return null;
}
return (
<Column gap>
{UTM_PARAMS.map(param => {
const items = toArray(data[param]);
const chartData = {
labels: items.map(({ name }) => name),
datasets: [
{
data: items.map(({ value }) => value),
backgroundColor: CHART_COLORS,
borderWidth: 0,
},
],
};
const total = items.reduce((sum, { value }) => {
return +sum + +value;
}, 0);
return (
<Panel key={param}>
<GridRow layout="two">
<Column>
<Heading>{param.replace(/^utm_/, '')}</Heading>
<ListTable
metric={formatMessage(labels.views)}
data={items.map(({ name, value }) => ({
x: name,
y: value,
z: (value / total) * 100,
}))}
/>
</Column>
<Column>
<PieChart type="doughnut" data={chartData} />
</Column>
</GridRow>
</Panel>
);
})}
</Column>
);
}

View 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 { getUTM } 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 getUTM(websiteId, {
startDate: new Date(startDate),
endDate: new Date(endDate),
timezone,
});
return json(data);
}

View file

@ -38,7 +38,7 @@ export function FilterRecord({
<Row gap alignItems="center">
<Select
items={operators.filter(({ type }) => type === 'string')}
selectedKey={operator}
value={operator}
onSelectionChange={value => onSelect?.(name, value)}
>
{({ name, label }: any) => {

View file

@ -21,6 +21,7 @@ export * from './queries/useTeamWebsitesQuery';
export * from './queries/useTeamMembersQuery';
export * from './queries/useUserQuery';
export * from './queries/useUsersQuery';
export * from './queries/useUTMQuery';
export * from './queries/useWebsiteQuery';
export * from './queries/useWebsites';
export * from './queries/useWebsiteEventsQuery';

View file

@ -0,0 +1,20 @@
import { useApi } from '../useApi';
import { useFilterParams } from '../useFilterParams';
import { UseQueryOptions } from '@tanstack/react-query';
export function useUTMQuery(
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: ['utm', websiteId, { ...filterParams, ...queryParams }],
queryFn: () =>
get(`/websites/${websiteId}/utm`, { websiteId, ...filterParams, ...queryParams }),
enabled: !!websiteId,
...options,
});
}

View file

@ -23,22 +23,8 @@ export function FilterBar() {
}
return (
<Row
theme="dark"
backgroundColor="1"
gap
alignItems="center"
justifyContent="space-between"
paddingY="2"
paddingLeft="3"
paddingRight="2"
border
borderRadius="2"
>
<Row alignItems="center" gap="3" wrap="wrap" paddingX="2">
<Text color="11" weight="bold">
{formatMessage(labels.filters)}
</Text>
<Row gap alignItems="center" justifyContent="space-between" paddingY="3">
<Row alignItems="center" gap="3" wrap="wrap">
{Object.keys(filters).map(key => {
const filter = filters[key];
const { name, label, operator, value } = filter;
@ -51,9 +37,9 @@ export function FilterBar() {
padding
backgroundColor
borderRadius
shadow="1"
alignItems="center"
justifyContent="space-between"
theme="dark"
>
<Row alignItems="center" gap="4">
<Row alignItems="center" gap="2">

View file

@ -95,7 +95,7 @@ export function WebsiteDateFilter({
{!isAllTime && showCompare && (
<TooltipTrigger delay={0}>
<Button variant="quiet" onPress={handleCompare}>
<Icon fillColor="currentColor">{compare ? <Icons.Close /> : <Icons.Compare />}</Icon>
<Icon fillColor>{compare ? <Icons.Close /> : <Icons.Compare />}</Icon>
</Button>
<Tooltip>{formatMessage(compare ? labels.cancel : labels.compareDates)}</Tooltip>
</TooltipTrigger>

View file

@ -318,6 +318,7 @@ export const labels = defineMessages({
apply: { id: 'label.apply', defaultMessage: 'Apply' },
links: { id: 'label.links', defaultMessage: 'Links' },
pixels: { id: 'label.pixels', defaultMessage: 'Pixels' },
addBoard: { id: 'label.add-board', defaultMessage: 'Add board' },
});
export const messages = defineMessages({

View file

@ -28,7 +28,9 @@ export function ActiveUsers({
return (
<StatusLight variant="success">
<Text size="2">{formatMessage(messages.activeUsers, { x: count })}</Text>
<Text size="2" weight="bold">
{formatMessage(messages.activeUsers, { x: count })}
</Text>
</StatusLight>
);
}

View file

@ -1,4 +1,4 @@
import { z, ZodSchema } from 'zod';
import { z } from 'zod/v4';
import { FILTER_COLUMNS } from '@/lib/constants';
import { badRequest, unauthorized } from '@/lib/response';
import { getAllowedUnits, getMinimumUnit } from '@/lib/date';
@ -7,7 +7,7 @@ import { getWebsiteDateRange } from '@/queries';
export async function parseRequest(
request: Request,
schema?: ZodSchema,
schema?: any,
options?: { skipAuth: boolean },
): Promise<any> {
const url = new URL(request.url);
@ -21,7 +21,7 @@ export async function parseRequest(
const result = schema.safeParse(isGet ? query : body);
if (!result.success) {
error = () => badRequest(getErrorMessages(result.error));
error = () => badRequest(z.treeifyError(result.error));
} else if (isGet) {
query = result.data;
} else {
@ -87,13 +87,3 @@ export function getRequestFilters(query: Record<string, any>) {
return obj;
}, {});
}
export function getErrorMessages(error: z.ZodError) {
return Object.entries(error.format())
.flatMap(([key, value]) => {
if (key === '_errors') {
return value;
}
})
.filter(Boolean);
}

View file

@ -1,9 +1,8 @@
{
"compilerOptions": {
"target": "es2022",
"outDir": "./build",
"module": "esnext",
"moduleResolution": "node",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"lib": ["dom", "dom.iterable", "esnext"],
@ -21,9 +20,8 @@
"noEmit": true,
"jsx": "preserve",
"incremental": false,
"types": ["node", "jest"],
"typeRoots": ["node_modules/@types"],
"baseUrl": ".",
"outDir": "./build",
"paths": {
"@/*": ["./src/*"]
},