Compare commits

...

2 commits

Author SHA1 Message Date
Mike Cao
5f27ba149b New overview layout.
Some checks failed
Node.js CI / build (postgresql, 18.18) (push) Has been cancelled
2025-08-29 00:17:59 -07:00
Mike Cao
bab4f8ebcc Cohort selection. 2025-08-28 23:29:42 -07:00
36 changed files with 886 additions and 697 deletions

View file

@ -74,15 +74,15 @@
"@date-fns/utc": "^1.2.0", "@date-fns/utc": "^1.2.0",
"@dicebear/collection": "^9.2.3", "@dicebear/collection": "^9.2.3",
"@dicebear/core": "^9.2.3", "@dicebear/core": "^9.2.3",
"@fontsource/inter": "^4.5.15", "@fontsource/inter": "^5.2.6",
"@hello-pangea/dnd": "^17.0.0", "@hello-pangea/dnd": "^17.0.0",
"@prisma/adapter-pg": "^6.15.0", "@prisma/adapter-pg": "^6.15.0",
"@prisma/client": "^6.15.0", "@prisma/client": "^6.15.0",
"@prisma/extension-read-replicas": "^0.4.1", "@prisma/extension-read-replicas": "^0.4.1",
"@react-spring/web": "^9.7.3", "@react-spring/web": "^10.0.1",
"@svgr/cli": "^8.1.0", "@svgr/cli": "^8.1.0",
"@tanstack/react-query": "^5.85.5", "@tanstack/react-query": "^5.85.5",
"@umami/react-zen": "^0.169.0", "@umami/react-zen": "^0.171.0",
"@umami/redis-client": "^0.27.0", "@umami/redis-client": "^0.27.0",
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"chalk": "^5.6.0", "chalk": "^5.6.0",
@ -97,10 +97,10 @@
"debug": "^4.3.4", "debug": "^4.3.4",
"del": "^6.0.0", "del": "^6.0.0",
"detect-browser": "^5.2.0", "detect-browser": "^5.2.0",
"dotenv": "^10.0.0", "dotenv": "^17.2.1",
"eslint-plugin-promise": "^6.1.1", "eslint-plugin-promise": "^6.1.1",
"fs-extra": "^10.0.1", "fs-extra": "^11.3.1",
"immer": "^9.0.12", "immer": "^10.1.1",
"ipaddr.js": "^2.0.1", "ipaddr.js": "^2.0.1",
"is-ci": "^3.0.1", "is-ci": "^3.0.1",
"is-docker": "^3.0.0", "is-docker": "^3.0.0",
@ -109,8 +109,8 @@
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"kafkajs": "^2.1.0", "kafkajs": "^2.1.0",
"lucide-react": "^0.540.0", "lucide-react": "^0.542.0",
"maxmind": "^4.3.28", "maxmind": "^5.0.0",
"md5": "^2.3.0", "md5": "^2.3.0",
"next": "15.5.2", "next": "15.5.2",
"node-fetch": "^3.2.8", "node-fetch": "^3.2.8",
@ -131,7 +131,7 @@
"serialize-error": "^12.0.0", "serialize-error": "^12.0.0",
"thenby": "^1.3.4", "thenby": "^1.3.4",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"zod": "^3.25.76", "zod": "^4.1.5",
"zustand": "^5.0.8" "zustand": "^5.0.8"
}, },
"devDependencies": { "devDependencies": {
@ -143,15 +143,15 @@
"@rollup/plugin-node-resolve": "^15.2.0", "@rollup/plugin-node-resolve": "^15.2.0",
"@rollup/plugin-replace": "^5.0.2", "@rollup/plugin-replace": "^5.0.2",
"@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-terser": "^0.4.4",
"@types/jest": "^29.5.14", "@types/jest": "^30.0.0",
"@types/node": "^22.18.0", "@types/node": "^24.3.0",
"@types/react": "^19.1.12", "@types/react": "^19.1.12",
"@types/react-dom": "^19.1.8", "@types/react-dom": "^19.1.8",
"@types/react-window": "^1.8.8", "@types/react-window": "^1.8.8",
"@typescript-eslint/eslint-plugin": "^8.41.0", "@typescript-eslint/eslint-plugin": "^8.41.0",
"@typescript-eslint/parser": "^8.41.0", "@typescript-eslint/parser": "^8.41.0",
"babel-plugin-react-compiler": "19.1.0-rc.2", "babel-plugin-react-compiler": "19.1.0-rc.2",
"cross-env": "^7.0.3", "cross-env": "^10.0.0",
"cypress": "^13.6.6", "cypress": "^13.6.6",
"esbuild": "^0.25.8", "esbuild": "^0.25.8",
"eslint": "^8.33.0", "eslint": "^8.33.0",
@ -164,9 +164,9 @@
"eslint-plugin-jest": "^27.9.0", "eslint-plugin-jest": "^27.9.0",
"eslint-plugin-prettier": "^5.5.3", "eslint-plugin-prettier": "^5.5.3",
"extract-react-intl-messages": "^4.1.1", "extract-react-intl-messages": "^4.1.1",
"husky": "^8.0.3", "husky": "^9.1.7",
"jest": "^29.7.0", "jest": "^29.7.0",
"lint-staged": "^14.0.1", "lint-staged": "^16.1.5",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"postcss-flexbugs-fixes": "^5.0.2", "postcss-flexbugs-fixes": "^5.0.2",
"postcss-import": "^15.1.0", "postcss-import": "^15.1.0",
@ -187,6 +187,6 @@
"tar": "^6.1.2", "tar": "^6.1.2",
"ts-jest": "^29.4.0", "ts-jest": "^29.4.0",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"typescript": "^5.5.3" "typescript": "^5.9.2"
} }
} }

869
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -33,7 +33,7 @@ export function LanguageSetting() {
allowSearch allowSearch
onSearch={setSearch} onSearch={setSearch}
onOpenChange={handleOpen} onOpenChange={handleOpen}
listProps={{ style: { maxHeight: '300px' } }} listProps={{ style: { maxHeight: 300 } }}
> >
{items.map(item => ( {items.map(item => (
<ListItem key={item} id={item}> <ListItem key={item} id={item}>

View file

@ -29,7 +29,7 @@ export function TimezoneSetting() {
allowSearch={true} allowSearch={true}
onSearch={setSearch} onSearch={setSearch}
onOpenChange={handleOpen} onOpenChange={handleOpen}
listProps={{ style: { maxHeight: '300px' } }} listProps={{ style: { maxHeight: 300 } }}
> >
{items.map((item: any) => ( {items.map((item: any) => (
<ListItem key={item} id={item}> <ListItem key={item} id={item}>

View file

@ -7,7 +7,7 @@ import { useDateRange, useMessages } from '@/components/hooks';
export function AttributionPage({ websiteId }: { websiteId: string }) { export function AttributionPage({ websiteId }: { websiteId: string }) {
const [model, setModel] = useState('first-click'); const [model, setModel] = useState('first-click');
const [type, setType] = useState('page'); const [type, setType] = useState('path');
const [step, setStep] = useState('/'); const [step, setStep] = useState('/');
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { const {
@ -36,7 +36,7 @@ export function AttributionPage({ websiteId }: { websiteId: string }) {
defaultValue={type} defaultValue={type}
onChange={setType} onChange={setType}
> >
<ListItem id="page">{formatMessage(labels.page)}</ListItem> <ListItem id="path">{formatMessage(labels.page)}</ListItem>
<ListItem id="event">{formatMessage(labels.event)}</ListItem> <ListItem id="event">{formatMessage(labels.event)}</ListItem>
</Select> </Select>
</Column> </Column>

View file

@ -56,7 +56,7 @@ export function Funnel({ id, name, type, parameters, websiteId }) {
{ type, value, visitors, previous, dropped, dropoff, remaining }: FunnelResult, { type, value, visitors, previous, dropped, dropoff, remaining }: FunnelResult,
index: number, index: number,
) => { ) => {
const isPage = type === 'page'; const isPage = type === 'path';
return ( return (
<Grid key={index} columns="auto 1fr" gap="6"> <Grid key={index} columns="auto 1fr" gap="6">
<Column alignItems="center" position="relative"> <Column alignItems="center" position="relative">
@ -92,7 +92,7 @@ export function Funnel({ id, name, type, parameters, websiteId }) {
</Row> </Row>
<Row alignItems="center" justifyContent="space-between" gap> <Row alignItems="center" justifyContent="space-between" gap>
<Row alignItems="center" gap> <Row alignItems="center" gap>
<Icon>{type === 'page' ? <File /> : <Lightning />}</Icon> <Icon>{type === 'path' ? <File /> : <Lightning />}</Icon>
<Text>{value}</Text> <Text>{value}</Text>
</Row> </Row>
<Row alignItems="center" gap> <Row alignItems="center" gap>

View file

@ -55,7 +55,7 @@ export function FunnelEditForm({
const defaultValues = { const defaultValues = {
name: data?.name || '', name: data?.name || '',
window: data?.parameters?.window || 60, window: data?.parameters?.window || 60,
steps: data?.parameters?.steps || [{ type: 'page', value: '/' }], steps: data?.parameters?.steps || [{ type: 'path', value: '/' }],
}; };
return ( return (
@ -91,7 +91,7 @@ export function FunnelEditForm({
onChange={field.onChange} onChange={field.onChange}
> >
<Grid columns="1fr 1fr" flexGrow={1} gap> <Grid columns="1fr 1fr" flexGrow={1} gap>
<Radio id="page" value="page"> <Radio id="path" value="path">
<Icon> <Icon>
<File /> <File />
</Icon> </Icon>
@ -130,7 +130,7 @@ export function FunnelEditForm({
})} })}
<Row> <Row>
<Button <Button
onPress={() => append({ type: 'page', value: '/' })} onPress={() => append({ type: 'path', value: '/' })}
isDisabled={fields.length >= FUNNEL_STEPS_MAX} isDisabled={fields.length >= FUNNEL_STEPS_MAX}
> >
<Icon> <Icon>

View file

@ -30,7 +30,7 @@ export function Goal({ id, name, type, parameters, websiteId, startDate, endDate
endDate, endDate,
...parameters, ...parameters,
}); });
const isPage = parameters?.type === 'page'; const isPage = parameters?.type === 'path';
return ( return (
<LoadingPanel data={data} isLoading={isLoading} isFetching={isFetching} error={error}> <LoadingPanel data={data} isLoading={isLoading} isFetching={isFetching} error={error}>
@ -68,7 +68,7 @@ export function Goal({ id, name, type, parameters, websiteId, startDate, endDate
</Row> </Row>
<Row alignItems="center" justifyContent="space-between" gap> <Row alignItems="center" justifyContent="space-between" gap>
<Row alignItems="center" gap> <Row alignItems="center" gap>
<Icon>{parameters.type === 'page' ? <File /> : <Lightning />}</Icon> <Icon>{parameters.type === 'path' ? <File /> : <Lightning />}</Icon>
<Text>{parameters.value}</Text> <Text>{parameters.value}</Text>
</Row> </Row>
<Row alignItems="center" gap> <Row alignItems="center" gap>

View file

@ -16,9 +16,10 @@ export function GoalAddButton({ websiteId }: { websiteId: string }) {
</Button> </Button>
<Modal> <Modal>
<Dialog <Dialog
aria-label="add goal"
variant="modal" variant="modal"
title={formatMessage(labels.goal)} title={formatMessage(labels.goal)}
style={{ minHeight: 375, minWidth: 400 }} style={{ minWidth: 800, minHeight: 300 }}
> >
{({ close }) => <GoalEditForm websiteId={websiteId} onClose={close} />} {({ close }) => <GoalEditForm websiteId={websiteId} onClose={close} />}
</Dialog> </Dialog>

View file

@ -6,14 +6,13 @@ import {
FormButtons, FormButtons,
FormSubmitButton, FormSubmitButton,
Button, Button,
RadioGroup,
Radio,
Text,
Icon,
Loading, Loading,
Column,
Label,
} from '@umami/react-zen'; } from '@umami/react-zen';
import { useMessages, useReportQuery, useUpdateQuery } from '@/components/hooks'; import { useMessages, useReportQuery, useUpdateQuery } from '@/components/hooks';
import { File, Lightning } from '@/components/icons'; import { LookupField } from '@/components/input/LookupField';
import { ActionSelect } from '@/components/input/ActionSelect';
export function GoalEditForm({ export function GoalEditForm({
id, id,
@ -27,13 +26,12 @@ export function GoalEditForm({
onClose?: () => void; onClose?: () => void;
}) { }) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { data } = useReportQuery(id); const { data } = useReportQuery(id);
const { mutate, error, isPending, touch } = useUpdateQuery(`/reports${id ? `/${id}` : ''}`); const { mutate, error, isPending, touch } = useUpdateQuery(`/reports${id ? `/${id}` : ''}`);
const handleSubmit = async ({ name, ...parameters }) => { const handleSubmit = async (formData: Record<string, any>) => {
mutate( mutate(
{ ...data, id, name, type: 'goal', websiteId, parameters }, { ...formData, type: 'goal', websiteId },
{ {
onSuccess: async () => { onSuccess: async () => {
if (id) touch(`report:${id}`); if (id) touch(`report:${id}`);
@ -50,15 +48,15 @@ export function GoalEditForm({
} }
const defaultValues = { const defaultValues = {
name: data?.name || '', name: '',
type: data?.parameters?.type || 'page', parameters: { type: 'path', value: '' },
value: data?.parameters?.value || '',
}; };
return ( return (
<Form onSubmit={handleSubmit} error={error?.message} defaultValues={defaultValues}> <Form onSubmit={handleSubmit} error={error?.message} defaultValues={data || defaultValues}>
{({ watch }) => { {({ watch }) => {
const watchType = watch('type'); const type = watch('parameters.type');
return ( return (
<> <>
<FormField <FormField
@ -68,35 +66,30 @@ export function GoalEditForm({
> >
<TextField autoFocus /> <TextField autoFocus />
</FormField> </FormField>
<FormField <Column>
name="type" <Label>{formatMessage(labels.action)}</Label>
label={formatMessage(labels.type)} <Grid columns="260px 1fr" gap>
rules={{ required: formatMessage(labels.required) }} <Column>
> <FormField
<RadioGroup orientation="horizontal" variant="box"> name="parameters.type"
<Grid columns="1fr 1fr" flexGrow={1} gap> rules={{ required: formatMessage(labels.required) }}
<Radio value="page"> >
<Icon> <ActionSelect />
<File /> </FormField>
</Icon> </Column>
<Text>{formatMessage(labels.page)}</Text> <Column>
</Radio> <FormField
<Radio value="event"> name="parameters.value"
<Icon> rules={{ required: formatMessage(labels.required) }}
<Lightning /> >
</Icon> {({ field }) => {
<Text>{formatMessage(labels.event)}</Text> return <LookupField websiteId={websiteId} type={type} {...field} />;
</Radio> }}
</Grid> </FormField>
</RadioGroup> </Column>
</FormField> </Grid>
<FormField </Column>
name="value"
label={formatMessage(watchType === 'event' ? labels.eventName : labels.path)}
rules={{ required: formatMessage(labels.required) }}
>
<TextField />
</FormField>
<FormButtons> <FormButtons>
<Button onPress={onClose} isDisabled={isPending}> <Button onPress={onClose} isDisabled={isPending}>
{formatMessage(labels.cancel)} {formatMessage(labels.cancel)}

View file

@ -52,12 +52,12 @@ export function WebsiteExpandedView({
items: [ items: [
{ {
id: 'referrer', id: 'referrer',
label: formatMessage(labels.referrers), label: formatMessage(labels.referrer),
path: updateParams({ view: 'referrer' }), path: updateParams({ view: 'referrer' }),
}, },
{ {
id: 'channel', id: 'channel',
label: formatMessage(labels.channels), label: formatMessage(labels.channel),
path: updateParams({ view: 'channel' }), path: updateParams({ view: 'channel' }),
}, },
{ {
@ -72,17 +72,17 @@ export function WebsiteExpandedView({
items: [ items: [
{ {
id: 'country', id: 'country',
label: formatMessage(labels.countries), label: formatMessage(labels.country),
path: updateParams({ view: 'country' }), path: updateParams({ view: 'country' }),
}, },
{ {
id: 'region', id: 'region',
label: formatMessage(labels.regions), label: formatMessage(labels.region),
path: updateParams({ view: 'region' }), path: updateParams({ view: 'region' }),
}, },
{ {
id: 'city', id: 'city',
label: formatMessage(labels.cities), label: formatMessage(labels.city),
path: updateParams({ view: 'city' }), path: updateParams({ view: 'city' }),
}, },
], ],
@ -92,7 +92,7 @@ export function WebsiteExpandedView({
items: [ items: [
{ {
id: 'browser', id: 'browser',
label: formatMessage(labels.browsers), label: formatMessage(labels.browser),
path: updateParams({ view: 'browser' }), path: updateParams({ view: 'browser' }),
}, },
{ {
@ -102,17 +102,17 @@ export function WebsiteExpandedView({
}, },
{ {
id: 'device', id: 'device',
label: formatMessage(labels.devices), label: formatMessage(labels.device),
path: updateParams({ view: 'device' }), path: updateParams({ view: 'device' }),
}, },
{ {
id: 'language', id: 'language',
label: formatMessage(labels.languages), label: formatMessage(labels.language),
path: updateParams({ view: 'language' }), path: updateParams({ view: 'language' }),
}, },
{ {
id: 'screen', id: 'screen',
label: formatMessage(labels.screens), label: formatMessage(labels.screen),
path: updateParams({ view: 'screen' }), path: updateParams({ view: 'screen' }),
}, },
], ],
@ -122,7 +122,7 @@ export function WebsiteExpandedView({
items: [ items: [
{ {
id: 'event', id: 'event',
label: formatMessage(labels.events), label: formatMessage(labels.event),
path: updateParams({ view: 'event' }), path: updateParams({ view: 'event' }),
}, },
{ {
@ -132,7 +132,7 @@ export function WebsiteExpandedView({
}, },
{ {
id: 'tag', id: 'tag',
label: formatMessage(labels.tags), label: formatMessage(labels.tag),
path: updateParams({ view: 'tag' }), path: updateParams({ view: 'tag' }),
}, },
], ],

View file

@ -1,7 +1,7 @@
import { Button, Icon, DialogTrigger, Dialog, Modal, Text } from '@umami/react-zen'; import { Button, Icon, DialogTrigger, Dialog, Modal, Text } from '@umami/react-zen';
import { ListFilter } from '@/components/icons'; import { ListFilter } from '@/components/icons';
import { FilterEditForm } from '@/components/input/FilterEditForm'; import { FilterEditForm } from '@/components/input/FilterEditForm';
import { useMessages, useNavigation, useFilters } from '@/components/hooks'; import { useMessages, useNavigation } from '@/components/hooks';
import { filtersArrayToObject } from '@/lib/params'; import { filtersArrayToObject } from '@/lib/params';
export function WebsiteFilterButton({ export function WebsiteFilterButton({
@ -14,17 +14,12 @@ export function WebsiteFilterButton({
showText?: boolean; showText?: boolean;
}) { }) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { const { replaceParams, router } = useNavigation();
replaceParams,
router,
query: { segment },
} = useNavigation();
const { filters } = useFilters();
const handleChange = ({ filters, segment }) => { const handleChange = ({ filters, segment, cohort }: any) => {
const params = filtersArrayToObject(filters); const params = filtersArrayToObject(filters);
const url = replaceParams({ ...params, segment }); const url = replaceParams({ ...params, segment, cohort });
router.push(url); router.push(url);
}; };
@ -40,15 +35,7 @@ export function WebsiteFilterButton({
<Modal> <Modal>
<Dialog title={formatMessage(labels.filters)} style={{ width: 800, minHeight: 600 }}> <Dialog title={formatMessage(labels.filters)} style={{ width: 800, minHeight: 600 }}>
{({ close }) => { {({ close }) => {
return ( return <FilterEditForm websiteId={websiteId} onChange={handleChange} onClose={close} />;
<FilterEditForm
websiteId={websiteId}
filters={filters}
segmentId={segment}
onChange={handleChange}
onClose={close}
/>
);
}} }}
</Dialog> </Dialog>
</Modal> </Modal>

View file

@ -11,7 +11,7 @@ import { WebsiteControls } from './WebsiteControls';
export function WebsitePage({ websiteId }: { websiteId: string }) { export function WebsitePage({ websiteId }: { websiteId: string }) {
const { const {
router, router,
query: { view, compare }, query: { view },
updateParams, updateParams,
} = useNavigation(); } = useNavigation();
const handleClose = (close: () => void) => { const handleClose = (close: () => void) => {
@ -30,7 +30,7 @@ export function WebsitePage({ websiteId }: { websiteId: string }) {
<WebsiteControls websiteId={websiteId} /> <WebsiteControls websiteId={websiteId} />
<WebsiteMetricsBar websiteId={websiteId} showChange={true} /> <WebsiteMetricsBar websiteId={websiteId} showChange={true} />
<Panel minHeight="520px"> <Panel minHeight="520px">
<WebsiteChart websiteId={websiteId} compareMode={compare} /> <WebsiteChart websiteId={websiteId} />
</Panel> </Panel>
<WebsitePanels websiteId={websiteId} /> <WebsitePanels websiteId={websiteId} />
<Modal isOpen={!!view} onOpenChange={handleOpenChange} isDismissable> <Modal isOpen={!!view} onOpenChange={handleOpenChange} isDismissable>

View file

@ -15,7 +15,7 @@ export function WebsitePanels({ websiteId }: { websiteId: string }) {
showMore: true, showMore: true,
metric: formatMessage(labels.visitors), metric: formatMessage(labels.visitors),
}; };
const rowProps = { minHeight: 570 }; const rowProps = { minHeight: '570px' };
return ( return (
<Grid gap="3"> <Grid gap="3">
@ -59,30 +59,7 @@ export function WebsitePanels({ websiteId }: { websiteId: string }) {
</Tabs> </Tabs>
</Panel> </Panel>
</GridRow> </GridRow>
<GridRow layout="two-one" {...rowProps}>
<Panel gridColumn="span 2" noPadding>
<WorldMap websiteId={websiteId} />
</Panel>
<Panel>
<Heading size="2">{formatMessage(labels.location)}</Heading>
<Tabs>
<TabList>
<Tab id="country">{formatMessage(labels.countries)}</Tab>
<Tab id="region">{formatMessage(labels.regions)}</Tab>
<Tab id="city">{formatMessage(labels.cities)}</Tab>
</TabList>
<TabPanel id="country">
<MetricsTable type="country" title={formatMessage(labels.country)} {...tableProps} />
</TabPanel>
<TabPanel id="region">
<MetricsTable type="region" title={formatMessage(labels.region)} {...tableProps} />
</TabPanel>
<TabPanel id="city">
<MetricsTable type="city" title={formatMessage(labels.city)} {...tableProps} />
</TabPanel>
</Tabs>
</Panel>
</GridRow>
<GridRow layout="two" {...rowProps}> <GridRow layout="two" {...rowProps}>
<Panel> <Panel>
<Heading size="2">{formatMessage(labels.environment)}</Heading> <Heading size="2">{formatMessage(labels.environment)}</Heading>
@ -103,6 +80,33 @@ export function WebsitePanels({ websiteId }: { websiteId: string }) {
</TabPanel> </TabPanel>
</Tabs> </Tabs>
</Panel> </Panel>
<Panel>
<Heading size="2">{formatMessage(labels.location)}</Heading>
<Tabs>
<TabList>
<Tab id="country">{formatMessage(labels.countries)}</Tab>
<Tab id="region">{formatMessage(labels.regions)}</Tab>
<Tab id="city">{formatMessage(labels.cities)}</Tab>
</TabList>
<TabPanel id="country">
<MetricsTable type="country" title={formatMessage(labels.country)} {...tableProps} />
</TabPanel>
<TabPanel id="region">
<MetricsTable type="region" title={formatMessage(labels.region)} {...tableProps} />
</TabPanel>
<TabPanel id="city">
<MetricsTable type="city" title={formatMessage(labels.city)} {...tableProps} />
</TabPanel>
</Tabs>
</Panel>
</GridRow>
<GridRow layout="two-one" {...rowProps}>
<Panel gridColumn="span 2" noPadding>
<WorldMap websiteId={websiteId} />
</Panel>
<Panel> <Panel>
<Heading size="2">{formatMessage(labels.traffic)}</Heading> <Heading size="2">{formatMessage(labels.traffic)}</Heading>
<Row border="bottom" marginBottom="4" /> <Row border="bottom" marginBottom="4" />

View file

@ -15,10 +15,7 @@ export function CohortAddButton({ websiteId }: { websiteId: string }) {
<Text>{formatMessage(labels.cohort)}</Text> <Text>{formatMessage(labels.cohort)}</Text>
</Button> </Button>
<Modal> <Modal>
<Dialog <Dialog title={formatMessage(labels.cohort)} style={{ width: 800, minHeight: 300 }}>
title={formatMessage(labels.cohort)}
style={{ width: 800, minHeight: 300, maxHeight: '90vh' }}
>
{({ close }) => { {({ close }) => {
return <CohortEditForm websiteId={websiteId} onClose={close} />; return <CohortEditForm websiteId={websiteId} onClose={close} />;
}} }}

View file

@ -18,10 +18,7 @@ export function CohortEditButton({
return ( return (
<ActionButton title={formatMessage(labels.edit)} icon={<Edit />}> <ActionButton title={formatMessage(labels.edit)} icon={<Edit />}>
<Dialog <Dialog title={formatMessage(labels.cohort)} style={{ width: 800, minHeight: 300 }}>
title={formatMessage(labels.cohort)}
style={{ width: 800, minHeight: 300, maxHeight: '90vh' }}
>
{({ close }) => { {({ close }) => {
return ( return (
<CohortEditForm <CohortEditForm

View file

@ -8,23 +8,13 @@ import {
Label, Label,
Loading, Loading,
Column, Column,
ComboBox,
Select,
ListItem,
Grid, Grid,
useDebounce,
} from '@umami/react-zen'; } from '@umami/react-zen';
import { import { useMessages, useUpdateQuery, useWebsiteCohortQuery } from '@/components/hooks';
useMessages,
useUpdateQuery,
useWebsiteCohortQuery,
useWebsiteValuesQuery,
} from '@/components/hooks';
import { DateFilter } from '@/components/input/DateFilter'; import { DateFilter } from '@/components/input/DateFilter';
import { FieldFilters } from '@/components/input/FieldFilters'; import { FieldFilters } from '@/components/input/FieldFilters';
import { SetStateAction, useMemo, useState } from 'react'; import { LookupField } from '@/components/input/LookupField';
import { endOfDay, subMonths } from 'date-fns'; import { ActionSelect } from '@/components/input/ActionSelect';
import { Empty } from '@/components/common/Empty';
export function CohortEditForm({ export function CohortEditForm({
cohortId, cohortId,
@ -40,21 +30,8 @@ export function CohortEditForm({
onSave?: () => void; onSave?: () => void;
onClose?: () => void; onClose?: () => void;
}) { }) {
const [action, setAction] = useState('path');
const [search, setSearch] = useState('');
const searchValue = useDebounce(search, 300);
const { data } = useWebsiteCohortQuery(websiteId, cohortId); const { data } = useWebsiteCohortQuery(websiteId, cohortId);
const { formatMessage, labels, messages } = useMessages(); const { formatMessage, labels, messages } = useMessages();
const startDate = subMonths(endOfDay(new Date()), 6);
const endDate = endOfDay(new Date());
const { data: searchResults, isLoading } = useWebsiteValuesQuery({
websiteId,
type: action,
search: searchValue,
startDate,
endDate,
});
const { mutate, error, isPending, touch, toast } = useUpdateQuery( const { mutate, error, isPending, touch, toast } = useUpdateQuery(
`/websites/${websiteId}/segments${cohortId ? `/${cohortId}` : ''}`, `/websites/${websiteId}/segments${cohortId ? `/${cohortId}` : ''}`,
@ -63,10 +40,6 @@ export function CohortEditForm({
}, },
); );
const items: string[] = useMemo(() => {
return searchResults?.map(({ value }) => value) || [];
}, [searchResults]);
const handleSubmit = async (formData: any) => { const handleSubmit = async (formData: any) => {
mutate(formData, { mutate(formData, {
onSuccess: async () => { onSuccess: async () => {
@ -78,105 +51,84 @@ export function CohortEditForm({
}); });
}; };
const handleSearch = (value: SetStateAction<string>) => {
setSearch(value);
};
if (cohortId && !data) { if (cohortId && !data) {
return <Loading position="page" />; return <Loading position="page" />;
} }
const defaultValues = {
parameters: { filters, dateRange: '30day', action: { type: 'path', value: '' } },
};
return ( return (
<Form <Form error={error} onSubmit={handleSubmit} defaultValues={data || defaultValues}>
error={error} {({ watch }) => {
onSubmit={handleSubmit} const type = watch('parameters.action.type');
defaultValues={
data || { parameters: { filters, dateRange: '30day', action: { type: 'path' } } }
}
>
<FormField
name="name"
label={formatMessage(labels.name)}
rules={{ required: formatMessage(labels.required) }}
>
<TextField autoFocus />
</FormField>
<Column> return (
<Label>{formatMessage(labels.action)}</Label> <>
<Grid columns="260px 1fr" gap>
<Column>
<FormField <FormField
name="parameters.action.type" name="name"
label={formatMessage(labels.name)}
rules={{ required: formatMessage(labels.required) }} rules={{ required: formatMessage(labels.required) }}
> >
<Select onSelectionChange={(value: any) => setAction(value)}> <TextField autoFocus />
<ListItem id="path">{formatMessage(labels.viewedPage)}</ListItem>
<ListItem id="event">{formatMessage(labels.triggeredEvent)}</ListItem>
</Select>
</FormField> </FormField>
</Column>
<Column> <Column>
<FormField <Label>{formatMessage(labels.action)}</Label>
name="parameters.action.value" <Grid columns="260px 1fr" gap>
rules={{ required: formatMessage(labels.required) }} <Column>
> <FormField
{({ field }) => { name="parameters.action.type"
return ( rules={{ required: formatMessage(labels.required) }}
<ComboBox
aria-label="action"
items={items}
inputValue={field?.value}
onInputChange={value => {
handleSearch(value);
field?.onChange?.(value);
}}
formValue="text"
allowsEmptyCollection
allowsCustomValue
renderEmptyState={() =>
isLoading ? (
<Loading position="center" icon="dots" />
) : (
<Empty message={formatMessage(messages.noResultsFound)} />
)
}
> >
{items.map(item => ( <ActionSelect />
<ListItem key={item} id={item}> </FormField>
{item} </Column>
</ListItem> <Column>
))} <FormField
</ComboBox> name="parameters.action.value"
); rules={{ required: formatMessage(labels.required) }}
}} >
</FormField> {({ field }) => {
</Column> return <LookupField websiteId={websiteId} type={type} {...field} />;
</Grid> }}
</Column> </FormField>
</Column>
</Grid>
</Column>
<Column width="260px"> <Column width="260px">
<Label>{formatMessage(labels.dateRange)}</Label> <Label>{formatMessage(labels.dateRange)}</Label>
<FormField name="parameters.dateRange" rules={{ required: formatMessage(labels.required) }}> <FormField
<DateFilter placement="bottom start" /> name="parameters.dateRange"
</FormField> rules={{ required: formatMessage(labels.required) }}
</Column> >
<DateFilter placement="bottom start" />
</FormField>
</Column>
<Column> <Column>
<Label>{formatMessage(labels.filters)}</Label> <Label>{formatMessage(labels.filters)}</Label>
<FormField name="parameters.filters" rules={{ required: formatMessage(labels.required) }}> <FormField
<FieldFilters websiteId={websiteId} exclude={['path', 'event']} /> name="parameters.filters"
</FormField> rules={{ required: formatMessage(labels.required) }}
</Column> >
<FieldFilters websiteId={websiteId} exclude={['path', 'event']} />
</FormField>
</Column>
<FormButtons> <FormButtons>
<Button isDisabled={isPending} onPress={onClose}> <Button isDisabled={isPending} onPress={onClose}>
{formatMessage(labels.cancel)} {formatMessage(labels.cancel)}
</Button> </Button>
<FormSubmitButton variant="primary" data-test="button-submit" isDisabled={isPending}> <FormSubmitButton variant="primary" data-test="button-submit" isDisabled={isPending}>
{formatMessage(labels.save)} {formatMessage(labels.save)}
</FormSubmitButton> </FormSubmitButton>
</FormButtons> </FormButtons>
</>
);
}}
</Form> </Form>
); );
} }

View file

@ -15,10 +15,7 @@ export function SegmentAddButton({ websiteId }: { websiteId: string }) {
<Text>{formatMessage(labels.segment)}</Text> <Text>{formatMessage(labels.segment)}</Text>
</Button> </Button>
<Modal> <Modal>
<Dialog <Dialog title={formatMessage(labels.segment)} style={{ width: 800, minHeight: 300 }}>
title={formatMessage(labels.segment)}
style={{ width: 800, minHeight: 300, maxHeight: '90vh' }}
>
{({ close }) => { {({ close }) => {
return <SegmentEditForm websiteId={websiteId} onClose={close} />; return <SegmentEditForm websiteId={websiteId} onClose={close} />;
}} }}

View file

@ -1,8 +1,8 @@
import { Dialog } from '@umami/react-zen';
import { ActionButton } from '@/components/input/ActionButton'; import { ActionButton } from '@/components/input/ActionButton';
import { Edit } from '@/components/icons'; import { Edit } from '@/components/icons';
import { Dialog } from '@umami/react-zen';
import { SegmentEditForm } from '@/app/(main)/websites/[websiteId]/segments/SegmentEditForm';
import { useMessages } from '@/components/hooks'; import { useMessages } from '@/components/hooks';
import { SegmentEditForm } from './SegmentEditForm';
import { Filter } from '@/lib/types'; import { Filter } from '@/lib/types';
export function SegmentEditButton({ export function SegmentEditButton({
@ -18,10 +18,7 @@ export function SegmentEditButton({
return ( return (
<ActionButton title={formatMessage(labels.edit)} icon={<Edit />}> <ActionButton title={formatMessage(labels.edit)} icon={<Edit />}>
<Dialog <Dialog title={formatMessage(labels.segment)} style={{ width: 800, minHeight: 300 }}>
title={formatMessage(labels.segment)}
style={{ width: 800, minHeight: 300, maxHeight: '90vh' }}
>
{({ close }) => { {({ close }) => {
return ( return (
<SegmentEditForm <SegmentEditForm

View file

@ -51,7 +51,7 @@ export async function POST(
type, type,
name, name,
description, description,
parameters: parameters, parameters,
} as any); } as any);
return json(result); return json(result);

View file

@ -10,6 +10,7 @@ export interface LoadingPanelProps extends ColumnProps {
isLoading?: boolean; isLoading?: boolean;
isFetching?: boolean; isFetching?: boolean;
loadingIcon?: 'dots' | 'spinner'; loadingIcon?: 'dots' | 'spinner';
loadingPosition?: 'center' | 'page' | 'inline';
renderEmpty?: () => ReactNode; renderEmpty?: () => ReactNode;
children: ReactNode; children: ReactNode;
} }
@ -21,6 +22,7 @@ export function LoadingPanel({
isLoading, isLoading,
isFetching, isFetching,
loadingIcon = 'dots', loadingIcon = 'dots',
loadingPosition = 'page',
renderEmpty = () => <Empty />, renderEmpty = () => <Empty />,
children, children,
...props ...props
@ -32,7 +34,7 @@ export function LoadingPanel({
{/* Show loading spinner only if no data exists */} {/* Show loading spinner only if no data exists */}
{(isLoading || isFetching) && ( {(isLoading || isFetching) && (
<Column position="relative" height="100%" {...props}> <Column position="relative" height="100%" {...props}>
<Loading icon={loadingIcon} position="page" /> <Loading icon={loadingIcon} position={loadingPosition} />
</Column> </Column>
)} )}

View file

@ -7,7 +7,7 @@ export function ActionButton({
title, title,
children, children,
}: { }: {
onSave?: () => void; onClick?: () => void;
icon?: ReactNode; icon?: ReactNode;
title?: string; title?: string;
children?: ReactNode; children?: ReactNode;

View file

@ -0,0 +1,18 @@
import { Select, ListItem } from '@umami/react-zen';
import { useMessages } from '@/components/hooks';
export interface ActionSelectProps {
value?: string;
onChange?: (value: string) => void;
}
export function ActionSelect({ value = 'path', onChange }: ActionSelectProps) {
const { formatMessage, labels } = useMessages();
return (
<Select value={value} onChange={onChange}>
<ListItem id="path">{formatMessage(labels.viewedPage)}</ListItem>
<ListItem id="event">{formatMessage(labels.triggeredEvent)}</ListItem>
</Select>
);
}

View file

@ -14,7 +14,7 @@ export function CurrencySelect({ value, onChange }) {
value={value} value={value}
defaultValue={value} defaultValue={value}
onChange={onChange} onChange={onChange}
listProps={{ style: { maxHeight: '300px' } }} listProps={{ style: { maxHeight: 300 } }}
onSearch={setSearch} onSearch={setSearch}
allowSearch allowSearch
> >

View file

@ -30,8 +30,8 @@ export function FilterBar({ websiteId }: { websiteId: string }) {
query: { segment, cohort }, query: { segment, cohort },
} = useNavigation(); } = useNavigation();
const { filters, operatorLabels } = useFilters(); const { filters, operatorLabels } = useFilters();
const { data, isLoading } = useWebsiteSegmentQuery(websiteId, segment); const { data, isLoading } = useWebsiteSegmentQuery(websiteId, segment || cohort);
const canSave = filters.length > 0 && !segment && !cohort; const canSaveSegment = filters.length > 0 && !segment && !cohort;
const handleCloseFilter = (param: string) => { const handleCloseFilter = (param: string) => {
router.push(updateParams({ [param]: undefined })); router.push(updateParams({ [param]: undefined }));
@ -41,11 +41,11 @@ export function FilterBar({ websiteId }: { websiteId: string }) {
router.push(replaceParams()); router.push(replaceParams());
}; };
const handleSegmentRemove = () => { const handleSegmentRemove = (type: string) => {
router.push(updateParams({ segment: undefined })); router.push(updateParams({ [type]: undefined }));
}; };
if (!filters.length && !segment) { if (!filters.length && !segment && !cohort) {
return null; return null;
} }
@ -58,7 +58,16 @@ export function FilterBar({ websiteId }: { websiteId: string }) {
label={formatMessage(labels.segment)} label={formatMessage(labels.segment)}
value={data?.name || segment} value={data?.name || segment}
operator={operatorLabels.eq} operator={operatorLabels.eq}
onRemove={handleSegmentRemove} onRemove={() => handleSegmentRemove('segment')}
/>
)}
{cohort && !isLoading && (
<FilterItem
name="cohort"
label={formatMessage(labels.cohort)}
value={data?.name || cohort}
operator={operatorLabels.eq}
onRemove={() => handleSegmentRemove('cohort')}
/> />
)} )}
{filters.map(filter => { {filters.map(filter => {
@ -79,7 +88,7 @@ export function FilterBar({ websiteId }: { websiteId: string }) {
</Row> </Row>
<Row alignItems="center"> <Row alignItems="center">
<DialogTrigger> <DialogTrigger>
{canSave && ( {canSaveSegment && (
<TooltipTrigger delay={0}> <TooltipTrigger delay={0}>
<Button variant="zero"> <Button variant="zero">
<Icon> <Icon>

View file

@ -1,40 +1,43 @@
import { useState } from 'react'; import { useState } from 'react';
import { Column, Tabs, TabList, Tab, TabPanel, Row, Button } from '@umami/react-zen'; import { Column, Tabs, TabList, Tab, TabPanel, Row, Button } from '@umami/react-zen';
import { useMessages } from '@/components/hooks'; import { useFilters, useMessages, useNavigation } from '@/components/hooks';
import { FieldFilters } from '@/components/input/FieldFilters'; import { FieldFilters } from '@/components/input/FieldFilters';
import { SegmentFilters } from '@/components/input/SegmentFilters'; import { SegmentFilters } from '@/components/input/SegmentFilters';
export interface FilterEditFormProps { export interface FilterEditFormProps {
websiteId?: string; websiteId?: string;
filters: any[]; onChange?: (params: { filters: any[]; segment?: string; cohort?: string }) => void;
segmentId?: string;
onChange?: (params: { filters: any[]; segment: any }) => void;
onClose?: () => void; onClose?: () => void;
} }
export function FilterEditForm({ export function FilterEditForm({ websiteId, onChange, onClose }: FilterEditFormProps) {
websiteId, const {
filters = [], query: { segment, cohort },
segmentId, } = useNavigation();
onChange, const { filters } = useFilters();
onClose,
}: FilterEditFormProps) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const [currentFilters, setCurrentFilters] = useState(filters); const [currentFilters, setCurrentFilters] = useState(filters);
const [currentSegment, setCurrentSegment] = useState(segmentId); const [currentSegment, setCurrentSegment] = useState(segment);
const [currentCohort, setCurrentCohort] = useState(cohort);
const handleReset = () => { const handleReset = () => {
setCurrentFilters([]); setCurrentFilters([]);
setCurrentSegment(null); setCurrentSegment(undefined);
setCurrentCohort(undefined);
}; };
const handleSave = () => { const handleSave = () => {
onChange?.({ filters: currentFilters.filter(f => f.value), segment: currentSegment }); onChange?.({
filters: currentFilters.filter(f => f.value),
segment: currentSegment,
cohort: currentCohort,
});
onClose?.(); onClose?.();
}; };
const handleSegmentChange = (id: string) => { const handleSegmentChange = (id: string, type: string) => {
setCurrentSegment(id); setCurrentSegment(type === 'segment' ? id : undefined);
setCurrentCohort(type === 'cohort' ? id : undefined);
}; };
return ( return (
@ -43,6 +46,7 @@ export function FilterEditForm({
<TabList> <TabList>
<Tab id="fields">{formatMessage(labels.fields)}</Tab> <Tab id="fields">{formatMessage(labels.fields)}</Tab>
<Tab id="segments">{formatMessage(labels.segments)}</Tab> <Tab id="segments">{formatMessage(labels.segments)}</Tab>
<Tab id="cohorts">{formatMessage(labels.cohorts)}</Tab>
</TabList> </TabList>
<TabPanel id="fields"> <TabPanel id="fields">
<FieldFilters websiteId={websiteId} value={currentFilters} onChange={setCurrentFilters} /> <FieldFilters websiteId={websiteId} value={currentFilters} onChange={setCurrentFilters} />
@ -51,7 +55,15 @@ export function FilterEditForm({
<SegmentFilters <SegmentFilters
websiteId={websiteId} websiteId={websiteId}
segmentId={currentSegment} segmentId={currentSegment}
onSave={handleSegmentChange} onChange={handleSegmentChange}
/>
</TabPanel>
<TabPanel id="cohorts" style={{ height: 400 }}>
<SegmentFilters
type="cohort"
websiteId={websiteId}
segmentId={currentCohort}
onChange={handleSegmentChange}
/> />
</TabPanel> </TabPanel>
</Tabs> </Tabs>

View file

@ -0,0 +1,65 @@
import { SetStateAction, useMemo, useState } from 'react';
import { endOfDay, subMonths } from 'date-fns';
import { ComboBox, ListItem, Loading, useDebounce, ComboBoxProps } from '@umami/react-zen';
import { Empty } from '@/components/common/Empty';
import { useMessages, useWebsiteValuesQuery } from '@/components/hooks';
export interface LookupFieldProps extends ComboBoxProps {
websiteId: string;
type: string;
value: string;
onChange: (value: string) => void;
}
export function LookupField({ websiteId, type, value, onChange, ...props }: LookupFieldProps) {
const { formatMessage, messages } = useMessages();
const [search, setSearch] = useState(value);
const searchValue = useDebounce(search, 300);
const startDate = subMonths(endOfDay(new Date()), 6);
const endDate = endOfDay(new Date());
const { data, isLoading } = useWebsiteValuesQuery({
websiteId,
type,
search: searchValue,
startDate,
endDate,
});
const items: string[] = useMemo(() => {
return data?.map(({ value }) => value) || [];
}, [data]);
const handleSearch = (value: SetStateAction<string>) => {
setSearch(value);
};
return (
<ComboBox
aria-label="LookupField"
{...props}
items={items}
inputValue={value}
onInputChange={value => {
handleSearch(value);
onChange?.(value);
}}
formValue="text"
allowsEmptyCollection
allowsCustomValue
renderEmptyState={() =>
isLoading ? (
<Loading position="center" icon="dots" />
) : (
<Empty message={formatMessage(messages.noResultsFound)} />
)
}
>
{items.map(item => (
<ListItem key={item} id={item}>
{item}
</ListItem>
))}
</ComboBox>
);
}

View file

@ -5,14 +5,20 @@ import { LoadingPanel } from '@/components/common/LoadingPanel';
export interface SegmentFiltersProps { export interface SegmentFiltersProps {
websiteId: string; websiteId: string;
segmentId: string; segmentId: string;
onSave?: (data: any) => void; type?: string;
onChange?: (id: string, type: string) => void;
} }
export function SegmentFilters({ websiteId, segmentId, onSave }: SegmentFiltersProps) { export function SegmentFilters({
const { data, isLoading, isFetching } = useWebsiteSegmentsQuery(websiteId, { type: 'segment' }); websiteId,
segmentId,
type = 'segment',
onChange,
}: SegmentFiltersProps) {
const { data, isLoading, isFetching } = useWebsiteSegmentsQuery(websiteId, { type });
const handleChange = (id: string) => { const handleChange = (id: string) => {
onSave?.(id); onChange?.(id, type);
}; };
return ( return (

View file

@ -67,6 +67,7 @@ export function MetricsExpandedTable({
isLoading={isLoading} isLoading={isLoading}
error={error} error={error}
height="100%" height="100%"
loadingIcon="spinner"
> >
<Column overflowY="auto" minHeight="0" height="100%" paddingRight="3" overflow="hidden"> <Column overflowY="auto" minHeight="0" height="100%" paddingRight="3" overflow="hidden">
{items && ( {items && (

View file

@ -68,7 +68,13 @@ export function MetricsTable({
}; };
return ( return (
<LoadingPanel data={data} isFetching={isFetching} isLoading={isLoading} error={error}> <LoadingPanel
data={data}
isFetching={isFetching}
isLoading={isLoading}
error={error}
minHeight="380px"
>
{data && <ListTable {...props} data={filteredData} renderLabel={renderLabel} />} {data && <ListTable {...props} data={filteredData} renderLabel={renderLabel} />}
{showMore && limit && ( {showMore && limit && (
<Row justifyContent="center"> <Row justifyContent="center">

View file

@ -5,7 +5,6 @@ import classNames from 'classnames';
import { colord } from 'colord'; import { colord } from 'colord';
import { ISO_COUNTRIES, MAP_FILE } from '@/lib/constants'; import { ISO_COUNTRIES, MAP_FILE } from '@/lib/constants';
import { import {
useDateRange,
useWebsiteMetricsQuery, useWebsiteMetricsQuery,
useCountryNames, useCountryNames,
useLocale, useLocale,
@ -30,14 +29,11 @@ export function WorldMap({ websiteId, data, className, ...props }: WorldMapProps
const { countryNames } = useCountryNames(locale); const { countryNames } = useCountryNames(locale);
const visitorsLabel = formatMessage(labels.visitors).toLocaleLowerCase(locale); const visitorsLabel = formatMessage(labels.visitors).toLocaleLowerCase(locale);
const unknownLabel = formatMessage(labels.unknown); const unknownLabel = formatMessage(labels.unknown);
const {
dateRange: { startDate, endDate },
} = useDateRange(websiteId);
const { data: mapData } = useWebsiteMetricsQuery(websiteId, { const { data: mapData } = useWebsiteMetricsQuery(websiteId, {
type: 'country', type: 'country',
startAt: +startDate,
endAt: +endDate,
}); });
const metrics = useMemo( const metrics = useMemo(
() => (data || mapData ? percentFilter((data || mapData) as any[]) : []), () => (data || mapData ? percentFilter((data || mapData) as any[]) : []),
[data, mapData], [data, mapData],

View file

@ -101,57 +101,6 @@ export const reportTypeParam = z.enum([
'utm', 'utm',
]); ]);
export const dateRangeSchema = z.object({ ...dateRangeParams }).superRefine((data, ctx) => {
const hasTimestamps = data.startAt !== undefined && data.endAt !== undefined;
const hasDates = data.startDate !== undefined && data.endDate !== undefined;
if (!hasTimestamps && !hasDates) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'You must provide either startAt & endAt or startDate & endDate.',
});
}
if (hasTimestamps && hasDates) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Provide either startAt & endAt or startDate & endDate, not both.',
});
}
if (data.startAt !== undefined && data.endAt === undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'If you provide startAt, you must also provide endAt.',
path: ['endAt'],
});
}
if (data.endAt !== undefined && data.startAt === undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'If you provide endAt, you must also provide startAt.',
path: ['startAt'],
});
}
if (data.startDate !== undefined && data.endDate === undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'If you provide startDate, you must also provide endDate.',
path: ['endDate'],
});
}
if (data.endDate !== undefined && data.startDate === undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'If you provide endDate, you must also provide startDate.',
path: ['startDate'],
});
}
});
export const goalReportSchema = z.object({ export const goalReportSchema = z.object({
type: z.literal('goal'), type: z.literal('goal'),
parameters: z parameters: z
@ -180,7 +129,7 @@ export const funnelReportSchema = z.object({
steps: z steps: z
.array( .array(
z.object({ z.object({
type: z.enum(['page', 'event']), type: z.enum(['path', 'event']),
value: z.string(), value: z.string(),
}), }),
) )
@ -233,7 +182,7 @@ export const attributionReportSchema = z.object({
startDate: z.coerce.date(), startDate: z.coerce.date(),
endDate: z.coerce.date(), endDate: z.coerce.date(),
model: z.enum(['first-click', 'last-click']), model: z.enum(['first-click', 'last-click']),
type: z.enum(['page', 'event']), type: z.enum(['path', 'event']),
step: z.string(), step: z.string(),
currency: z.string().optional(), currency: z.string().optional(),
}), }),
@ -253,6 +202,7 @@ export const reportBaseSchema = z.object({
type: reportTypeParam, type: reportTypeParam,
name: z.string().max(200), name: z.string().max(200),
description: z.string().max(500).optional(), description: z.string().max(500).optional(),
parameters: z.object({}).passthrough(),
}); });
export const reportTypeSchema = z.discriminatedUnion('type', [ export const reportTypeSchema = z.discriminatedUnion('type', [
@ -266,7 +216,7 @@ export const reportTypeSchema = z.discriminatedUnion('type', [
breakdownReportSchema, breakdownReportSchema,
]); ]);
export const reportSchema = z.intersection(reportBaseSchema, reportTypeSchema); export const reportSchema = reportBaseSchema;
export const reportResultSchema = z.intersection( export const reportResultSchema = z.intersection(
z.object({ z.object({

View file

@ -40,8 +40,8 @@ async function relationalQuery(
): Promise<AttributionResult> { ): Promise<AttributionResult> {
const { model, type, currency } = parameters; const { model, type, currency } = parameters;
const { rawQuery, parseFilters } = prisma; const { rawQuery, parseFilters } = prisma;
const eventType = type === 'page' ? EVENT_TYPE.pageView : EVENT_TYPE.customEvent; const eventType = type === 'path' ? EVENT_TYPE.pageView : EVENT_TYPE.customEvent;
const column = type === 'page' ? 'url_path' : 'event_name'; const column = type === 'path' ? 'url_path' : 'event_name';
const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({ const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({
...filters, ...filters,
...parameters, ...parameters,
@ -266,8 +266,8 @@ async function clickhouseQuery(
): Promise<AttributionResult> { ): Promise<AttributionResult> {
const { model, type, currency } = parameters; const { model, type, currency } = parameters;
const { rawQuery, parseFilters } = clickhouse; const { rawQuery, parseFilters } = clickhouse;
const eventType = type === 'page' ? EVENT_TYPE.pageView : EVENT_TYPE.customEvent; const eventType = type === 'path' ? EVENT_TYPE.pageView : EVENT_TYPE.customEvent;
const column = type === 'page' ? 'url_path' : 'event_name'; const column = type === 'path' ? 'url_path' : 'event_name';
const { filterQuery, cohortQuery, queryParams } = parseFilters({ const { filterQuery, cohortQuery, queryParams } = parseFilters({
...filters, ...filters,
...parameters, ...parameters,

View file

@ -54,7 +54,7 @@ async function relationalQuery(
(pv, cv, i) => { (pv, cv, i) => {
const levelNumber = i + 1; const levelNumber = i + 1;
const startSum = i > 0 ? 'union ' : ''; const startSum = i > 0 ? 'union ' : '';
const isURL = cv.type === 'page'; const isURL = cv.type === 'path';
const column = isURL ? 'url_path' : 'event_name'; const column = isURL ? 'url_path' : 'event_name';
let operator = '='; let operator = '=';
@ -161,7 +161,7 @@ async function clickhouseQuery(
const levelNumber = i + 1; const levelNumber = i + 1;
const startSum = i > 0 ? 'union all ' : ''; const startSum = i > 0 ? 'union all ' : '';
const startFilter = i > 0 ? 'or' : ''; const startFilter = i > 0 ? 'or' : '';
const isURL = cv.type === 'page'; const isURL = cv.type === 'path';
const column = isURL ? 'url_path' : 'event_name'; const column = isURL ? 'url_path' : 'event_name';
let operator = '='; let operator = '=';

View file

@ -29,8 +29,8 @@ async function relationalQuery(
) { ) {
const { startDate, endDate, type, value } = parameters; const { startDate, endDate, type, value } = parameters;
const { rawQuery, parseFilters } = prisma; const { rawQuery, parseFilters } = prisma;
const eventType = type === 'page' ? EVENT_TYPE.pageView : EVENT_TYPE.customEvent; const eventType = type === 'path' ? EVENT_TYPE.pageView : EVENT_TYPE.customEvent;
const column = type === 'page' ? 'url_path' : 'event_name'; const column = type === 'path' ? 'url_path' : 'event_name';
const { filterQuery, dateQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({ const { filterQuery, dateQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({
...filters, ...filters,
websiteId, websiteId,
@ -71,8 +71,8 @@ async function clickhouseQuery(
) { ) {
const { startDate, endDate, type, value } = parameters; const { startDate, endDate, type, value } = parameters;
const { rawQuery, parseFilters } = clickhouse; const { rawQuery, parseFilters } = clickhouse;
const eventType = type === 'page' ? EVENT_TYPE.pageView : EVENT_TYPE.customEvent; const eventType = type === 'path' ? EVENT_TYPE.pageView : EVENT_TYPE.customEvent;
const column = type === 'page' ? 'url_path' : 'event_name'; const column = type === 'path' ? 'url_path' : 'event_name';
const { filterQuery, dateQuery, cohortQuery, queryParams } = parseFilters({ const { filterQuery, dateQuery, cohortQuery, queryParams } = parseFilters({
...filters, ...filters,
websiteId, websiteId,

View file

@ -106,7 +106,7 @@ async function clickhouseQuery(websiteId: string, filters: QueryFilters) {
${getDateStringSQL('min(created_at)')} as firstAt, ${getDateStringSQL('min(created_at)')} as firstAt,
${getDateStringSQL('max(created_at)')} as lastAt, ${getDateStringSQL('max(created_at)')} as lastAt,
uniq(visit_id) as visits, uniq(visit_id) as visits,
sumIf(views, event_type = 1) as views, sumIf(1, event_type = 1) as views,
lastAt as createdAt lastAt as createdAt
from website_event from website_event
${cohortQuery} ${cohortQuery}