This commit is contained in:
Mike Cao 2026-01-26 11:18:34 -08:00
commit 4680c89e28
52 changed files with 498 additions and 118 deletions

View file

@ -2,8 +2,8 @@
CREATE TABLE "share" ( CREATE TABLE "share" (
"share_id" UUID NOT NULL, "share_id" UUID NOT NULL,
"entity_id" UUID NOT NULL, "entity_id" UUID NOT NULL,
"share_type" INTEGER NOT NULL,
"name" VARCHAR(200) NOT NULL, "name" VARCHAR(200) NOT NULL,
"share_type" INTEGER NOT NULL,
"slug" VARCHAR(100) NOT NULL, "slug" VARCHAR(100) NOT NULL,
"parameters" JSONB NOT NULL, "parameters" JSONB NOT NULL,
"created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP, "created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
@ -12,9 +12,6 @@ CREATE TABLE "share" (
CONSTRAINT "share_pkey" PRIMARY KEY ("share_id") CONSTRAINT "share_pkey" PRIMARY KEY ("share_id")
); );
-- CreateIndex
CREATE UNIQUE INDEX "share_share_id_key" ON "share"("share_id");
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "share_slug_key" ON "share"("slug"); CREATE UNIQUE INDEX "share_slug_key" ON "share"("slug");
@ -28,7 +25,7 @@ SELECT gen_random_uuid(),
name, name,
1, 1,
share_id, share_id,
'{}'::jsonb, '{"overview":true}'::jsonb,
now() now()
FROM "website" FROM "website"
WHERE share_id IS NOT NULL; WHERE share_id IS NOT NULL;

View file

@ -14,9 +14,6 @@ CREATE TABLE "board" (
CONSTRAINT "board_pkey" PRIMARY KEY ("board_id") CONSTRAINT "board_pkey" PRIMARY KEY ("board_id")
); );
-- CreateIndex
CREATE UNIQUE INDEX "board_board_id_key" ON "board"("board_id");
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "board_slug_key" ON "board"("slug"); CREATE UNIQUE INDEX "board_slug_key" ON "board"("slug");

View file

@ -1,6 +1,3 @@
-- DropIndex
DROP INDEX "board_board_id_key";
-- DropIndex -- DropIndex
DROP INDEX "link_link_id_key"; DROP INDEX "link_link_id_key";
@ -19,9 +16,6 @@ DROP INDEX "segment_segment_id_key";
-- DropIndex -- DropIndex
DROP INDEX "session_session_id_key"; DROP INDEX "session_session_id_key";
-- DropIndex
DROP INDEX "share_share_id_key";
-- DropIndex -- DropIndex
DROP INDEX "team_team_id_key"; DROP INDEX "team_team_id_key";

View file

@ -3,14 +3,19 @@ import { Column } from '@umami/react-zen';
import { PageHeader } from '@/components/common/PageHeader'; import { PageHeader } from '@/components/common/PageHeader';
import { Panel } from '@/components/common/Panel'; import { Panel } from '@/components/common/Panel';
import { useMessages } from '@/components/hooks'; import { useMessages } from '@/components/hooks';
import { TeamsAddButton } from '../../teams/TeamsAddButton';
import { AdminTeamsDataTable } from './AdminTeamsDataTable'; import { AdminTeamsDataTable } from './AdminTeamsDataTable';
export function AdminTeamsPage() { export function AdminTeamsPage() {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const handleSave = () => {};
return ( return (
<Column gap="6" margin="2"> <Column gap="6" margin="2">
<PageHeader title={formatMessage(labels.teams)} /> <PageHeader title={formatMessage(labels.teams)}>
<TeamsAddButton onSave={handleSave} isAdmin={true} />
</PageHeader>
<Panel> <Panel>
<AdminTeamsDataTable /> <AdminTeamsDataTable />
</Panel> </Panel>

View file

@ -7,8 +7,17 @@ import {
TextField, TextField,
} from '@umami/react-zen'; } from '@umami/react-zen';
import { useMessages, useUpdateQuery } from '@/components/hooks'; import { useMessages, useUpdateQuery } from '@/components/hooks';
import { UserSelect } from '@/components/input/UserSelect';
export function TeamAddForm({ onSave, onClose }: { onSave: () => void; onClose: () => void }) { export function TeamAddForm({
onSave,
onClose,
isAdmin,
}: {
onSave: () => void;
onClose: () => void;
isAdmin: boolean;
}) {
const { formatMessage, labels, getErrorMessage } = useMessages(); const { formatMessage, labels, getErrorMessage } = useMessages();
const { mutateAsync, error, isPending } = useUpdateQuery('/teams'); const { mutateAsync, error, isPending } = useUpdateQuery('/teams');
@ -26,6 +35,11 @@ export function TeamAddForm({ onSave, onClose }: { onSave: () => void; onClose:
<FormField name="name" label={formatMessage(labels.name)}> <FormField name="name" label={formatMessage(labels.name)}>
<TextField autoComplete="off" /> <TextField autoComplete="off" />
</FormField> </FormField>
{isAdmin && (
<FormField name="ownerId" label={formatMessage(labels.teamOwner)}>
<UserSelect buttonProps={{ style: { outline: 'none' } }} />
</FormField>
)}
<FormButtons> <FormButtons>
<Button isDisabled={isPending} onPress={onClose}> <Button isDisabled={isPending} onPress={onClose}>
{formatMessage(labels.cancel)} {formatMessage(labels.cancel)}

View file

@ -0,0 +1,76 @@
import {
Button,
Form,
FormButtons,
FormField,
FormSubmitButton,
ListItem,
Select,
} from '@umami/react-zen';
import { useMessages, useUpdateQuery } from '@/components/hooks';
import { UserSelect } from '@/components/input/UserSelect';
import { ROLES } from '@/lib/constants';
const roles = [ROLES.teamManager, ROLES.teamMember, ROLES.teamViewOnly];
export function TeamMemberAddForm({
teamId,
onSave,
onClose,
}: {
teamId: string;
onSave?: () => void;
onClose?: () => void;
}) {
const { formatMessage, labels, getErrorMessage } = useMessages();
const { mutateAsync, error, isPending } = useUpdateQuery(`/teams/${teamId}/users`);
const handleSubmit = async (data: any) => {
await mutateAsync(data, {
onSuccess: async () => {
onSave?.();
onClose?.();
},
});
};
const renderRole = role => {
switch (role) {
case ROLES.teamManager:
return formatMessage(labels.manager);
case ROLES.teamMember:
return formatMessage(labels.member);
case ROLES.teamViewOnly:
return formatMessage(labels.viewOnly);
}
};
return (
<Form onSubmit={handleSubmit} error={getErrorMessage(error)}>
<FormField
name="userId"
label={formatMessage(labels.username)}
rules={{ required: 'Required' }}
>
<UserSelect teamId={teamId} />
</FormField>
<FormField name="role" label={formatMessage(labels.role)} rules={{ required: 'Required' }}>
<Select items={roles} renderValue={value => renderRole(value as any)}>
{roles.map(value => (
<ListItem key={value} id={value}>
{renderRole(value)}
</ListItem>
))}
</Select>
</FormField>
<FormButtons>
<Button isDisabled={isPending} onPress={onClose}>
{formatMessage(labels.cancel)}
</Button>
<FormSubmitButton variant="primary" isDisabled={isPending}>
{formatMessage(labels.save)}
</FormSubmitButton>
</FormButtons>
</Form>
);
}

View file

@ -4,7 +4,13 @@ import { Plus } from '@/components/icons';
import { messages } from '@/components/messages'; import { messages } from '@/components/messages';
import { TeamAddForm } from './TeamAddForm'; import { TeamAddForm } from './TeamAddForm';
export function TeamsAddButton({ onSave }: { onSave?: () => void }) { export function TeamsAddButton({
onSave,
isAdmin = false,
}: {
onSave?: () => void;
isAdmin?: boolean;
}) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
const { toast } = useToast(); const { toast } = useToast();
const { touch } = useModified(); const { touch } = useModified();
@ -25,7 +31,7 @@ export function TeamsAddButton({ onSave }: { onSave?: () => void }) {
</Button> </Button>
<Modal> <Modal>
<Dialog title={formatMessage(labels.createTeam)} style={{ width: 400 }}> <Dialog title={formatMessage(labels.createTeam)} style={{ width: 400 }}>
{({ close }) => <TeamAddForm onSave={handleSave} onClose={close} />} {({ close }) => <TeamAddForm onSave={handleSave} onClose={close} isAdmin={isAdmin} />}
</Dialog> </Dialog>
</Modal> </Modal>
</DialogTrigger> </DialogTrigger>

View file

@ -0,0 +1,40 @@
import { Button, Dialog, DialogTrigger, Icon, Modal, Text, useToast } from '@umami/react-zen';
import { useMessages, useModified } from '@/components/hooks';
import { Plus } from '@/components/icons';
import { messages } from '@/components/messages';
import { TeamMemberAddForm } from './TeamMemberAddForm';
export function TeamsMemberAddButton({
teamId,
onSave,
}: {
teamId: string;
onSave?: () => void;
isAdmin?: boolean;
}) {
const { formatMessage, labels } = useMessages();
const { toast } = useToast();
const { touch } = useModified();
const handleSave = async () => {
toast(formatMessage(messages.saved));
touch('teams:members');
onSave?.();
};
return (
<DialogTrigger>
<Button>
<Icon>
<Plus />
</Icon>
<Text>{formatMessage(labels.addMember)}</Text>
</Button>
<Modal>
<Dialog title={formatMessage(labels.addMember)} style={{ width: 400 }}>
{({ close }) => <TeamMemberAddForm teamId={teamId} onSave={handleSave} onClose={close} />}
</Dialog>
</Modal>
</DialogTrigger>
);
}

View file

@ -1,10 +1,12 @@
import { Column } from '@umami/react-zen'; import { Column, Heading, Row } from '@umami/react-zen';
import { TeamLeaveButton } from '@/app/(main)/teams/TeamLeaveButton'; import { TeamLeaveButton } from '@/app/(main)/teams/TeamLeaveButton';
import { PageHeader } from '@/components/common/PageHeader'; import { PageHeader } from '@/components/common/PageHeader';
import { Panel } from '@/components/common/Panel'; import { Panel } from '@/components/common/Panel';
import { useLoginQuery, useNavigation, useTeam } from '@/components/hooks'; import { useLoginQuery, useMessages, useNavigation, useTeam } from '@/components/hooks';
import { Users } from '@/components/icons'; import { Users } from '@/components/icons';
import { labels } from '@/components/messages';
import { ROLES } from '@/lib/constants'; import { ROLES } from '@/lib/constants';
import { TeamsMemberAddButton } from '../TeamsMemberAddButton';
import { TeamEditForm } from './TeamEditForm'; import { TeamEditForm } from './TeamEditForm';
import { TeamManage } from './TeamManage'; import { TeamManage } from './TeamManage';
import { TeamMembersDataTable } from './TeamMembersDataTable'; import { TeamMembersDataTable } from './TeamMembersDataTable';
@ -13,6 +15,7 @@ export function TeamSettings({ teamId }: { teamId: string }) {
const team: any = useTeam(); const team: any = useTeam();
const { user } = useLoginQuery(); const { user } = useLoginQuery();
const { pathname } = useNavigation(); const { pathname } = useNavigation();
const { formatMessage } = useMessages();
const isAdmin = pathname.includes('/admin'); const isAdmin = pathname.includes('/admin');
@ -37,6 +40,10 @@ export function TeamSettings({ teamId }: { teamId: string }) {
<TeamEditForm teamId={teamId} allowEdit={canEdit} showAccessCode={canEdit} /> <TeamEditForm teamId={teamId} allowEdit={canEdit} showAccessCode={canEdit} />
</Panel> </Panel>
<Panel> <Panel>
<Row alignItems="center" justifyContent="space-between">
<Heading size="2">{formatMessage(labels.members)}</Heading>
{isAdmin && <TeamsMemberAddButton teamId={teamId} />}
</Row>
<TeamMembersDataTable teamId={teamId} allowEdit={canEdit} /> <TeamMembersDataTable teamId={teamId} allowEdit={canEdit} />
</Panel> </Panel>
{isTeamOwner && ( {isTeamOwner && (

View file

@ -21,9 +21,15 @@ export interface JourneyProps {
steps: number; steps: number;
startStep?: string; startStep?: string;
endStep?: string; endStep?: string;
view: string;
} }
export function Journey({ websiteId, steps, startStep, endStep }: JourneyProps) { const EVENT_TYPES = {
views: 1,
events: 2,
};
export function Journey({ websiteId, steps, startStep, endStep, view }: JourneyProps) {
const [selectedNode, setSelectedNode] = useState(null); const [selectedNode, setSelectedNode] = useState(null);
const [activeNode, setActiveNode] = useState(null); const [activeNode, setActiveNode] = useState(null);
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
@ -32,6 +38,8 @@ export function Journey({ websiteId, steps, startStep, endStep }: JourneyProps)
steps, steps,
startStep, startStep,
endStep, endStep,
view,
eventType: EVENT_TYPES[view],
}); });
useEscapeKey(() => setSelectedNode(null)); useEscapeKey(() => setSelectedNode(null));

View file

@ -1,5 +1,6 @@
'use client'; 'use client';
import { Column, Grid, ListItem, SearchField, Select } from '@umami/react-zen'; import { Column, Grid, ListItem, Row, SearchField, Select } from '@umami/react-zen';
import { FilterButtons } from 'dist';
import { useState } from 'react'; import { useState } from 'react';
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls'; import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
import { Panel } from '@/components/common/Panel'; import { Panel } from '@/components/common/Panel';
@ -14,10 +15,26 @@ export function JourneysPage({ websiteId }: { websiteId: string }) {
const { const {
dateRange: { startDate, endDate }, dateRange: { startDate, endDate },
} = useDateRange(); } = useDateRange();
const [view, setView] = useState('all');
const [steps, setSteps] = useState(DEFAULT_STEP); const [steps, setSteps] = useState(DEFAULT_STEP);
const [startStep, setStartStep] = useState(''); const [startStep, setStartStep] = useState('');
const [endStep, setEndStep] = useState(''); const [endStep, setEndStep] = useState('');
const buttons = [
{
id: 'all',
label: formatMessage(labels.all),
},
{
id: 'views',
label: formatMessage(labels.views),
},
{
id: 'events',
label: formatMessage(labels.events),
},
];
return ( return (
<Column gap> <Column gap>
<WebsiteControls websiteId={websiteId} /> <WebsiteControls websiteId={websiteId} />
@ -52,6 +69,9 @@ export function JourneysPage({ websiteId }: { websiteId: string }) {
/> />
</Column> </Column>
</Grid> </Grid>
<Row justifyContent="flex-end">
<FilterButtons items={buttons} value={view} onChange={setView} />
</Row>
<Panel height="900px" allowFullscreen> <Panel height="900px" allowFullscreen>
<Journey <Journey
websiteId={websiteId} websiteId={websiteId}
@ -60,6 +80,7 @@ export function JourneysPage({ websiteId }: { websiteId: string }) {
steps={steps} steps={steps}
startStep={startStep} startStep={startStep}
endStep={endStep} endStep={endStep}
view={view}
/> />
</Panel> </Panel>
</Column> </Column>

View file

@ -21,30 +21,28 @@ export function WebsiteChart({
const { pageviews, sessions, compare } = (data || {}) as any; const { pageviews, sessions, compare } = (data || {}) as any;
const chartData = useMemo(() => { const chartData = useMemo(() => {
if (data) { if (!data) {
const result = { return { pageviews: [], sessions: [] };
pageviews, }
sessions,
};
if (compare) { return {
result.compare = { pageviews,
pageviews: result.pageviews.map(({ x }, i) => ({ sessions,
...(compare && {
compare: {
pageviews: pageviews.map(({ x }, i) => ({
x, x,
y: compare.pageviews[i]?.y, y: compare.pageviews[i]?.y,
d: compare.pageviews[i]?.x, d: compare.pageviews[i]?.x,
})), })),
sessions: result.sessions.map(({ x }, i) => ({ sessions: sessions.map(({ x }, i) => ({
x, x,
y: compare.sessions[i]?.y, y: compare.sessions[i]?.y,
d: compare.sessions[i]?.x, d: compare.sessions[i]?.x,
})), })),
}; },
} }),
};
return result;
}
return { pageviews: [], sessions: [] };
}, [data, startDate, endDate, unit]); }, [data, startDate, endDate, unit]);
return ( return (

View file

@ -169,6 +169,12 @@ export function WebsiteExpandedMenu({
path: updateParams({ view: 'hostname' }), path: updateParams({ view: 'hostname' }),
icon: <Network />, icon: <Network />,
}, },
{
id: 'distinctId',
label: formatMessage(labels.distinctId),
path: updateParams({ view: 'distinctId' }),
icon: <Tag />,
},
{ {
id: 'tag', id: 'tag',
label: formatMessage(labels.tag), label: formatMessage(labels.tag),

View file

@ -10,7 +10,7 @@ import {
} from '@umami/react-zen'; } from '@umami/react-zen';
import { Fragment } from 'react'; import { Fragment } from 'react';
import { useMessages, useNavigation } from '@/components/hooks'; import { useMessages, useNavigation } from '@/components/hooks';
import { Edit, More, Share } from '@/components/icons'; import { Edit, MoreHorizontal, Share } from '@/components/icons';
export function WebsiteMenu({ websiteId }: { websiteId: string }) { export function WebsiteMenu({ websiteId }: { websiteId: string }) {
const { formatMessage, labels } = useMessages(); const { formatMessage, labels } = useMessages();
@ -33,7 +33,7 @@ export function WebsiteMenu({ websiteId }: { websiteId: string }) {
<MenuTrigger> <MenuTrigger>
<Button variant="quiet"> <Button variant="quiet">
<Icon> <Icon>
<More /> <MoreHorizontal />
</Icon> </Icon>
</Button> </Button>
<Popover placement="bottom"> <Popover placement="bottom">

View file

@ -7,14 +7,18 @@ import { formatLongNumber, formatShortTime } from '@/lib/format';
export function WebsiteMetricsBar({ export function WebsiteMetricsBar({
websiteId, websiteId,
compareMode,
}: { }: {
websiteId: string; websiteId: string;
showChange?: boolean; showChange?: boolean;
compareMode?: boolean; compareMode?: boolean;
}) { }) {
const { isAllTime } = useDateRange(); const { isAllTime, dateCompare } = useDateRange();
const { formatMessage, labels, getErrorMessage } = useMessages(); const { formatMessage, labels, getErrorMessage } = useMessages();
const { data, isLoading, isFetching, error } = useWebsiteStatsQuery(websiteId); const { data, isLoading, isFetching, error } = useWebsiteStatsQuery({
websiteId,
compare: compareMode ? dateCompare?.compare : undefined,
});
const { pageviews, visitors, visits, bounces, totaltime, comparison } = data || {}; const { pageviews, visitors, visits, bounces, totaltime, comparison } = data || {};

View file

@ -29,6 +29,7 @@ export function WebsiteNav({
event: undefined, event: undefined,
compare: undefined, compare: undefined,
view: undefined, view: undefined,
unit: undefined,
}); });
const items = [ const items = [

View file

@ -1,7 +1,8 @@
'use client'; 'use client';
import { Column } from '@umami/react-zen'; import { Column, Row } from '@umami/react-zen';
import { ExpandedViewModal } from '@/app/(main)/websites/[websiteId]/ExpandedViewModal'; import { ExpandedViewModal } from '@/app/(main)/websites/[websiteId]/ExpandedViewModal';
import { Panel } from '@/components/common/Panel'; import { Panel } from '@/components/common/Panel';
import { UnitFilter } from '@/components/input/UnitFilter';
import { WebsiteChart } from './WebsiteChart'; import { WebsiteChart } from './WebsiteChart';
import { WebsiteControls } from './WebsiteControls'; import { WebsiteControls } from './WebsiteControls';
import { WebsiteMetricsBar } from './WebsiteMetricsBar'; import { WebsiteMetricsBar } from './WebsiteMetricsBar';
@ -13,6 +14,9 @@ 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">
<Row justifyContent="end">
<UnitFilter />
</Row>
<WebsiteChart websiteId={websiteId} /> <WebsiteChart websiteId={websiteId} />
</Panel> </Panel>
<WebsitePanels websiteId={websiteId} /> <WebsitePanels websiteId={websiteId} />

View file

@ -10,7 +10,7 @@ export function ComparePage({ websiteId }: { websiteId: string }) {
return ( return (
<Column gap> <Column gap>
<WebsiteControls websiteId={websiteId} allowCompare={true} /> <WebsiteControls websiteId={websiteId} allowCompare={true} />
<WebsiteMetricsBar websiteId={websiteId} showChange={true} /> <WebsiteMetricsBar websiteId={websiteId} compareMode={true} showChange={true} />
<Panel minHeight="520px"> <Panel minHeight="520px">
<WebsiteChart websiteId={websiteId} compareMode={true} /> <WebsiteChart websiteId={websiteId} compareMode={true} />
</Panel> </Panel>

View file

@ -93,6 +93,11 @@ export function CompareTables({ websiteId }: { websiteId: string }) {
label: formatMessage(labels.hostname), label: formatMessage(labels.hostname),
path: renderPath('hostname'), path: renderPath('hostname'),
}, },
{
id: 'distinctId',
label: formatMessage(labels.distinctId),
path: renderPath('distinctId'),
},
{ {
id: 'tag', id: 'tag',
label: formatMessage(labels.tags), label: formatMessage(labels.tags),

View file

@ -25,6 +25,19 @@ export function EventsTable(props: DataTableProps) {
const { updateParams } = useNavigation(); const { updateParams } = useNavigation();
const { formatValue } = useFormat(); const { formatValue } = useFormat();
const renderLink = (label: string, hostname: string) => {
return (
<a
href={`//${hostname}${label}`}
style={{ fontWeight: 'bold' }}
target="_blank"
rel="noreferrer noopener"
>
{label}
</a>
);
};
return ( return (
<DataTable {...props}> <DataTable {...props}>
<DataColumn id="event" label={formatMessage(labels.event)} width="2fr"> <DataColumn id="event" label={formatMessage(labels.event)} width="2fr">
@ -43,7 +56,7 @@ export function EventsTable(props: DataTableProps) {
title={row.eventName || row.urlPath} title={row.eventName || row.urlPath}
truncate truncate
> >
{row.eventName || row.urlPath} {row.eventName || renderLink(row.urlPath, row.hostname)}
</Text> </Text>
{row.hasData > 0 && <PropertiesButton websiteId={row.websiteId} eventId={row.id} />} {row.hasData > 0 && <PropertiesButton websiteId={row.websiteId} eventId={row.id} />}
</Row> </Row>

View file

@ -74,8 +74,9 @@ export function RealtimeLog({ data }: { data: any }) {
os: string; os: string;
country: string; country: string;
device: string; device: string;
hostname: string;
}) => { }) => {
const { __type, eventName, urlPath, browser, os, country, device } = log; const { __type, eventName, urlPath, browser, os, country, device, hostname } = log;
if (__type === TYPE_EVENT) { if (__type === TYPE_EVENT) {
return ( return (
@ -86,7 +87,8 @@ export function RealtimeLog({ data }: { data: any }) {
url: ( url: (
<a <a
key="a" key="a"
href={`//${website?.domain}${urlPath}`} href={`//${hostname}${urlPath}`}
style={{ fontWeight: 'bold' }}
target="_blank" target="_blank"
rel="noreferrer noopener" rel="noreferrer noopener"
> >
@ -100,7 +102,12 @@ export function RealtimeLog({ data }: { data: any }) {
if (__type === TYPE_PAGEVIEW) { if (__type === TYPE_PAGEVIEW) {
return ( return (
<a href={`//${website?.domain}${urlPath}`} target="_blank" rel="noreferrer noopener"> <a
href={`//${hostname}${urlPath}`}
style={{ fontWeight: 'bold' }}
target="_blank"
rel="noreferrer noopener"
>
{urlPath} {urlPath}
</a> </a>
); );

View file

@ -39,10 +39,23 @@ export function SessionActivity({
const { isMobile } = useMobile(); const { isMobile } = useMobile();
let lastDay = null; let lastDay = null;
const renderLink = (label: string, hostname: string) => {
return (
<a
href={`//${hostname}${label}`}
style={{ fontWeight: 'bold' }}
target="_blank"
rel="noreferrer noopener"
>
{label}
</a>
);
};
return ( return (
<LoadingPanel data={data} isLoading={isLoading} error={error}> <LoadingPanel data={data} isLoading={isLoading} error={error}>
<Column gap> <Column gap>
{data?.map(({ eventId, createdAt, urlPath, eventName, visitId, hasData }) => { {data?.map(({ eventId, createdAt, urlPath, eventName, visitId, hostname, hasData }) => {
const showHeader = !lastDay || !isSameDay(new Date(lastDay), new Date(createdAt)); const showHeader = !lastDay || !isSameDay(new Date(lastDay), new Date(createdAt));
lastDay = createdAt; lastDay = createdAt;
@ -61,7 +74,7 @@ export function SessionActivity({
: formatMessage(labels.viewedPage)} : formatMessage(labels.viewedPage)}
</Text> </Text>
<Text weight="bold" style={{ maxWidth: isMobile ? '400px' : null }} truncate> <Text weight="bold" style={{ maxWidth: isMobile ? '400px' : null }} truncate>
{eventName || urlPath} {eventName || renderLink(urlPath, hostname)}
</Text> </Text>
{hasData > 0 && <PropertiesButton websiteId={websiteId} eventId={eventId} />} {hasData > 0 && <PropertiesButton websiteId={websiteId} eventId={eventId} />}
</Row> </Row>

View file

@ -12,11 +12,16 @@ export async function POST(request: Request) {
} }
const { websiteId, parameters, filters } = body; const { websiteId, parameters, filters } = body;
const { eventType } = parameters;
if (!(await canViewWebsite(auth, websiteId))) { if (!(await canViewWebsite(auth, websiteId))) {
return unauthorized(); return unauthorized();
} }
if (eventType) {
filters.eventType = eventType;
}
const queryFilters = await getQueryFilters(filters, websiteId); const queryFilters = await getQueryFilters(filters, websiteId);
const data = await getJourney(websiteId, parameters, queryFilters); const data = await getJourney(websiteId, parameters, queryFilters);

View file

@ -28,6 +28,7 @@ export async function GET(request: Request) {
export async function POST(request: Request) { export async function POST(request: Request) {
const schema = z.object({ const schema = z.object({
name: z.string().max(50), name: z.string().max(50),
ownerId: z.uuid().optional(),
}); });
const { auth, body, error } = await parseRequest(request, schema); const { auth, body, error } = await parseRequest(request, schema);
@ -40,7 +41,7 @@ export async function POST(request: Request) {
return unauthorized(); return unauthorized();
} }
const { name } = body; const { name, ownerId } = body;
const team = await createTeam( const team = await createTeam(
{ {
@ -48,7 +49,7 @@ export async function POST(request: Request) {
name, name,
accessCode: `team_${getRandomChars(16)}`, accessCode: `team_${getRandomChars(16)}`,
}, },
auth.user.id, ownerId ?? auth.user.id,
); );
return json(team); return json(team);

View file

@ -31,9 +31,11 @@ export async function GET(
const data = await getWebsiteStats(websiteId, filters); const data = await getWebsiteStats(websiteId, filters);
const compare = filters.compare ?? 'prev'; const { startDate, endDate } = getCompareDate(
filters.compare ?? 'prev',
const { startDate, endDate } = getCompareDate(compare, filters.startDate, filters.endDate); filters.startDate,
filters.endDate,
);
const comparison = await getWebsiteStats(websiteId, { const comparison = await getWebsiteStats(websiteId, {
...filters, ...filters,

View file

@ -19,7 +19,7 @@ export function useWebsiteExpandedMetricsQuery(
options?: ReactQueryOptions<WebsiteExpandedMetricsData>, options?: ReactQueryOptions<WebsiteExpandedMetricsData>,
) { ) {
const { get, useQuery } = useApi(); const { get, useQuery } = useApi();
const { startAt, endAt, unit, timezone } = useDateParameters(); const { startAt, endAt } = useDateParameters();
const filters = useFilterParameters(); const filters = useFilterParameters();
return useQuery<WebsiteExpandedMetricsData>({ return useQuery<WebsiteExpandedMetricsData>({
@ -29,8 +29,6 @@ export function useWebsiteExpandedMetricsQuery(
websiteId, websiteId,
startAt, startAt,
endAt, endAt,
unit,
timezone,
...filters, ...filters,
...params, ...params,
}, },
@ -39,8 +37,6 @@ export function useWebsiteExpandedMetricsQuery(
get(`/websites/${websiteId}/metrics/expanded`, { get(`/websites/${websiteId}/metrics/expanded`, {
startAt, startAt,
endAt, endAt,
unit,
timezone,
...filters, ...filters,
...params, ...params,
}), }),

View file

@ -15,7 +15,7 @@ export function useWebsiteMetricsQuery(
options?: ReactQueryOptions<WebsiteMetricsData>, options?: ReactQueryOptions<WebsiteMetricsData>,
) { ) {
const { get, useQuery } = useApi(); const { get, useQuery } = useApi();
const { startAt, endAt, unit, timezone } = useDateParameters(); const { startAt, endAt } = useDateParameters();
const filters = useFilterParameters(); const filters = useFilterParameters();
return useQuery<WebsiteMetricsData>({ return useQuery<WebsiteMetricsData>({
@ -25,8 +25,6 @@ export function useWebsiteMetricsQuery(
websiteId, websiteId,
startAt, startAt,
endAt, endAt,
unit,
timezone,
...filters, ...filters,
...params, ...params,
}, },
@ -35,8 +33,6 @@ export function useWebsiteMetricsQuery(
get(`/websites/${websiteId}/metrics`, { get(`/websites/${websiteId}/metrics`, {
startAt, startAt,
endAt, endAt,
unit,
timezone,
...filters, ...filters,
...params, ...params,
}), }),

View file

@ -1,6 +1,5 @@
import type { UseQueryOptions } from '@tanstack/react-query'; import type { UseQueryOptions } from '@tanstack/react-query';
import { useDateParameters } from '@/components/hooks/useDateParameters'; import { useDateParameters } from '@/components/hooks/useDateParameters';
import { useDateRange } from '@/components/hooks/useDateRange';
import { useApi } from '../useApi'; import { useApi } from '../useApi';
import { useFilterParameters } from '../useFilterParameters'; import { useFilterParameters } from '../useFilterParameters';
@ -20,21 +19,16 @@ export interface WebsiteStatsData {
} }
export function useWebsiteStatsQuery( export function useWebsiteStatsQuery(
websiteId: string, { websiteId, compare }: { websiteId: string; compare?: string },
options?: UseQueryOptions<WebsiteStatsData, Error, WebsiteStatsData>, options?: UseQueryOptions<WebsiteStatsData, Error, WebsiteStatsData>,
) { ) {
const { get, useQuery } = useApi(); const { get, useQuery } = useApi();
const { startAt, endAt, unit, timezone } = useDateParameters(); const { startAt, endAt } = useDateParameters();
const { compare } = useDateRange();
const filters = useFilterParameters(); const filters = useFilterParameters();
return useQuery<WebsiteStatsData>({ return useQuery<WebsiteStatsData>({
queryKey: [ queryKey: ['websites:stats', { websiteId, compare, startAt, endAt, ...filters }],
'websites:stats', queryFn: () => get(`/websites/${websiteId}/stats`, { compare, startAt, endAt, ...filters }),
{ websiteId, startAt, endAt, unit, timezone, compare, ...filters },
],
queryFn: () =>
get(`/websites/${websiteId}/stats`, { startAt, endAt, unit, timezone, compare, ...filters }),
enabled: !!websiteId, enabled: !!websiteId,
...options, ...options,
}); });

View file

@ -12,13 +12,12 @@ export function useWeeklyTrafficQuery(websiteId: string, params?: Record<string,
return useQuery({ return useQuery({
queryKey: [ queryKey: [
'sessions', 'sessions',
{ websiteId, modified, startAt, endAt, unit, timezone, ...params, ...filters }, { websiteId, modified, startAt, endAt, timezone, ...params, ...filters },
], ],
queryFn: () => { queryFn: () => {
return get(`/websites/${websiteId}/sessions/weekly`, { return get(`/websites/${websiteId}/sessions/weekly`, {
startAt, startAt,
endAt, endAt,
unit,
timezone, timezone,
...params, ...params,
...filters, ...filters,

View file

@ -7,13 +7,13 @@ import { getItem } from '@/lib/storage';
export function useDateRange(options: { ignoreOffset?: boolean; timezone?: string } = {}) { export function useDateRange(options: { ignoreOffset?: boolean; timezone?: string } = {}) {
const { const {
query: { date = '', offset = 0, compare = 'prev' }, query: { date = '', unit = '', offset = 0, compare = 'prev' },
} = useNavigation(); } = useNavigation();
const { locale } = useLocale(); const { locale } = useLocale();
const dateRange = useMemo(() => { const dateRange = useMemo(() => {
const dateRangeObject = parseDateRange( const dateRangeObject = parseDateRange(
date || getItem(DATE_RANGE_CONFIG) || DEFAULT_DATE_RANGE_VALUE, date || getItem(DATE_RANGE_CONFIG) || DEFAULT_DATE_RANGE_VALUE,
unit,
locale, locale,
options.timezone, options.timezone,
); );
@ -21,12 +21,13 @@ export function useDateRange(options: { ignoreOffset?: boolean; timezone?: strin
return !options.ignoreOffset && offset return !options.ignoreOffset && offset
? getOffsetDateRange(dateRangeObject, +offset) ? getOffsetDateRange(dateRangeObject, +offset)
: dateRangeObject; : dateRangeObject;
}, [date, offset, options]); }, [date, unit, offset, options]);
const dateCompare = getCompareDate(compare, dateRange.startDate, dateRange.endDate); const dateCompare = getCompareDate(compare, dateRange.startDate, dateRange.endDate);
return { return {
date, date,
unit,
offset, offset,
compare, compare,
isAllTime: date.endsWith(`:all`), isAllTime: date.endsWith(`:all`),

View file

@ -15,6 +15,7 @@ export function useFields() {
{ name: 'region', type: 'string', label: formatMessage(labels.region) }, { name: 'region', type: 'string', label: formatMessage(labels.region) },
{ name: 'city', type: 'string', label: formatMessage(labels.city) }, { name: 'city', type: 'string', label: formatMessage(labels.city) },
{ name: 'hostname', type: 'string', label: formatMessage(labels.hostname) }, { name: 'hostname', type: 'string', label: formatMessage(labels.hostname) },
{ name: 'distinctId', type: 'string', label: formatMessage(labels.distinctId) },
{ name: 'tag', type: 'string', label: formatMessage(labels.tag) }, { name: 'tag', type: 'string', label: formatMessage(labels.tag) },
{ name: 'event', type: 'string', label: formatMessage(labels.event) }, { name: 'event', type: 'string', label: formatMessage(labels.event) },
]; ];

View file

@ -18,6 +18,7 @@ export function useFilterParameters() {
event, event,
tag, tag,
hostname, hostname,
distinctId,
page, page,
pageSize, pageSize,
search, search,
@ -42,6 +43,7 @@ export function useFilterParameters() {
event, event,
tag, tag,
hostname, hostname,
distinctId,
search, search,
segment, segment,
cohort, cohort,
@ -61,6 +63,7 @@ export function useFilterParameters() {
event, event,
tag, tag,
hostname, hostname,
distinctId,
page, page,
pageSize, pageSize,
search, search,

View file

@ -61,7 +61,9 @@ export function FilterEditForm({ websiteId, onChange, onClose }: FilterEditFormP
websiteId={websiteId} websiteId={websiteId}
value={currentFilters} value={currentFilters}
onChange={setCurrentFilters} onChange={setCurrentFilters}
exclude={excludeFilters ? ['path', 'title', 'hostname', 'tag', 'event'] : []} exclude={
excludeFilters ? ['path', 'title', 'hostname', 'distinctId', 'tag', 'event'] : []
}
/> />
</TabPanel> </TabPanel>
<TabPanel id="segments"> <TabPanel id="segments">

View file

@ -0,0 +1,71 @@
import { ListItem, Row, Select } from '@umami/react-zen';
import { useMessages, useNavigation } from '@/components/hooks';
import { DATE_RANGE_CONFIG, DEFAULT_DATE_RANGE_VALUE } from '@/lib/constants';
import { getItem } from '@/lib/storage';
export function UnitFilter() {
const { formatMessage, labels } = useMessages();
const { router, query, updateParams } = useNavigation();
const DATE_RANGE_UNIT_CONFIG = {
'0week': {
defaultUnit: 'day',
availableUnits: ['day', 'hour'],
},
'7day': {
defaultUnit: 'day',
availableUnits: ['day', 'hour'],
},
'0month': {
defaultUnit: 'day',
availableUnits: ['day', 'hour'],
},
'30day': {
defaultUnit: 'day',
availableUnits: ['day', 'hour'],
},
'90day': {
defaultUnit: 'day',
availableUnits: ['day', 'month'],
},
'6month': {
defaultUnit: 'month',
availableUnits: ['month', 'day'],
},
};
const unitConfig =
DATE_RANGE_UNIT_CONFIG[query.date || getItem(DATE_RANGE_CONFIG) || DEFAULT_DATE_RANGE_VALUE];
if (!unitConfig) {
return null;
}
const handleChange = (value: string) => {
router.push(updateParams({ unit: value }));
};
const options = unitConfig.availableUnits.map(unit => ({
id: unit,
label: formatMessage(labels[unit]),
}));
const selectedUnit = query.unit ?? unitConfig.defaultUnit;
return (
<Row>
<Select
value={selectedUnit}
onChange={handleChange}
popoverProps={{ placement: 'bottom right' }}
style={{ width: 100 }}
>
{options.map(({ id, label }) => (
<ListItem key={id} id={id}>
{label}
</ListItem>
))}
</Select>
</Row>
);
}

View file

@ -0,0 +1,71 @@
import { ListItem, Row, Select, type SelectProps, Text } from '@umami/react-zen';
import { useMemo, useState } from 'react';
import { Empty } from '@/components/common/Empty';
import { useMessages, useTeamMembersQuery, useUsersQuery } from '@/components/hooks';
export function UserSelect({
teamId,
onChange,
...props
}: {
teamId?: string;
} & SelectProps) {
const { formatMessage, messages } = useMessages();
const { data: users, isLoading: usersLoading } = useUsersQuery();
const { data: teamMembers, isLoading: teamMembersLoading } = useTeamMembersQuery(teamId);
const [username, setUsername] = useState<string>();
const [search, setSearch] = useState('');
const listItems = useMemo(() => {
if (!users) {
return [];
}
if (!teamId || !teamMembers) {
return users.data;
}
const teamMemberIds = teamMembers.data.map(({ userId }) => userId);
return users.data.filter(({ id }) => !teamMemberIds.includes(id));
}, [users, teamMembers, teamId]);
const handleSearch = (value: string) => {
setSearch(value);
};
const handleOpenChange = () => {
setSearch('');
};
const handleChange = (id: string) => {
setUsername(listItems.find(item => item.id === id)?.username);
onChange(id);
};
const renderValue = () => {
return (
<Row maxWidth="160px">
<Text truncate>{username}</Text>
</Row>
);
};
return (
<Select
{...props}
items={listItems}
value={username}
isLoading={usersLoading || (teamId && teamMembersLoading)}
allowSearch={true}
searchValue={search}
onSearch={handleSearch}
onChange={handleChange}
onOpenChange={handleOpenChange}
renderValue={renderValue}
listProps={{
renderEmptyState: () => <Empty message={formatMessage(messages.noResultsFound)} />,
style: { maxHeight: 'calc(42vh - 65px)' },
}}
>
{({ id, username }: any) => <ListItem key={id}>{username}</ListItem>}
</Select>
);
}

View file

@ -41,7 +41,7 @@ export function WebsiteDateFilter({
}), }),
); );
} else { } else {
router.push(updateParams({ date, offset: undefined })); router.push(updateParams({ date, offset: undefined, unit: undefined }));
} }
}; };

View file

@ -245,7 +245,10 @@ export const labels = defineMessages({
tag: { id: 'label.tag', defaultMessage: 'Tag' }, tag: { id: 'label.tag', defaultMessage: 'Tag' },
segment: { id: 'label.segment', defaultMessage: 'Segment' }, segment: { id: 'label.segment', defaultMessage: 'Segment' },
cohort: { id: 'label.cohort', defaultMessage: 'Cohort' }, cohort: { id: 'label.cohort', defaultMessage: 'Cohort' },
minute: { id: 'label.minute', defaultMessage: 'Minute' },
hour: { id: 'label.hour', defaultMessage: 'Hour' },
day: { id: 'label.day', defaultMessage: 'Day' }, day: { id: 'label.day', defaultMessage: 'Day' },
month: { id: 'label.month', defaultMessage: 'Month' },
date: { id: 'label.date', defaultMessage: 'Date' }, date: { id: 'label.date', defaultMessage: 'Date' },
pageOf: { id: 'label.page-of', defaultMessage: 'Page {current} of {total}' }, pageOf: { id: 'label.page-of', defaultMessage: 'Page {current} of {total}' },
create: { id: 'label.create', defaultMessage: 'Create' }, create: { id: 'label.create', defaultMessage: 'Create' },

View file

@ -25,7 +25,7 @@ export const MetricCard = ({
showChange = false, showChange = false,
}: MetricCardProps) => { }: MetricCardProps) => {
const diff = value - change; const diff = value - change;
const pct = ((value - diff) / diff) * 100; const pct = diff !== 0 ? ((value - diff) / diff) * 100 : value !== 0 ? 100 : 0;
const props = useSpring({ x: Number(value) || 0, from: { x: 0 } }); const props = useSpring({ x: Number(value) || 0, from: { x: 0 } });
const changeProps = useSpring({ x: Number(pct) || 0, from: { x: 0 } }); const changeProps = useSpring({ x: Number(pct) || 0, from: { x: 0 } });

View file

@ -19,11 +19,13 @@ export * from '@/app/(main)/teams/TeamAddForm';
export * from '@/app/(main)/teams/TeamJoinForm'; export * from '@/app/(main)/teams/TeamJoinForm';
export * from '@/app/(main)/teams/TeamLeaveButton'; export * from '@/app/(main)/teams/TeamLeaveButton';
export * from '@/app/(main)/teams/TeamLeaveForm'; export * from '@/app/(main)/teams/TeamLeaveForm';
export * from '@/app/(main)/teams/TeamMemberAddForm';
export * from '@/app/(main)/teams/TeamProvider'; export * from '@/app/(main)/teams/TeamProvider';
export * from '@/app/(main)/teams/TeamsAddButton'; export * from '@/app/(main)/teams/TeamsAddButton';
export * from '@/app/(main)/teams/TeamsDataTable'; export * from '@/app/(main)/teams/TeamsDataTable';
export * from '@/app/(main)/teams/TeamsHeader'; export * from '@/app/(main)/teams/TeamsHeader';
export * from '@/app/(main)/teams/TeamsJoinButton'; export * from '@/app/(main)/teams/TeamsJoinButton';
export * from '@/app/(main)/teams/TeamsMemberAddButton';
export * from '@/app/(main)/teams/TeamsTable'; export * from '@/app/(main)/teams/TeamsTable';
export * from '@/app/(main)/websites/[websiteId]/settings/WebsiteData'; export * from '@/app/(main)/websites/[websiteId]/settings/WebsiteData';
export * from '@/app/(main)/websites/[websiteId]/settings/WebsiteDeleteForm'; export * from '@/app/(main)/websites/[websiteId]/settings/WebsiteDeleteForm';

View file

@ -55,6 +55,7 @@ export const SESSION_COLUMNS = [
'country', 'country',
'city', 'city',
'region', 'region',
'distinctId',
]; ];
export const SEGMENT_TYPES = { export const SEGMENT_TYPES = {
@ -69,6 +70,7 @@ export const FILTER_COLUMNS = {
referrer: 'referrer_domain', referrer: 'referrer_domain',
domain: 'referrer_domain', domain: 'referrer_domain',
hostname: 'hostname', hostname: 'hostname',
distinctId: 'distinct_id',
title: 'page_title', title: 'page_title',
query: 'url_query', query: 'url_query',
os: 'os', os: 'os',

View file

@ -9,6 +9,7 @@ import {
differenceInCalendarMonths, differenceInCalendarMonths,
differenceInCalendarWeeks, differenceInCalendarWeeks,
differenceInCalendarYears, differenceInCalendarYears,
differenceInDays,
differenceInHours, differenceInHours,
differenceInMinutes, differenceInMinutes,
endOfDay, endOfDay,
@ -136,7 +137,12 @@ export function parseDateValue(value: string) {
return { num: +num, unit }; return { num: +num, unit };
} }
export function parseDateRange(value: string, locale = 'en-US', timezone?: string): DateRange { export function parseDateRange(
value: string,
unitValue?: string,
locale = 'en-US',
timezone?: string,
): DateRange {
if (typeof value !== 'string') { if (typeof value !== 'string') {
return null; return null;
} }
@ -146,7 +152,7 @@ export function parseDateRange(value: string, locale = 'en-US', timezone?: strin
const startDate = new Date(+startTime); const startDate = new Date(+startTime);
const endDate = new Date(+endTime); const endDate = new Date(+endTime);
const unit = getMinimumUnit(startDate, endDate); const unit = getMinimumUnit(startDate, endDate, true);
return { return {
startDate, startDate,
@ -169,14 +175,14 @@ export function parseDateRange(value: string, locale = 'en-US', timezone?: strin
endDate: endOfHour(now), endDate: endOfHour(now),
offset: 0, offset: 0,
num: num || 1, num: num || 1,
unit, unit: unitValue || unit,
value, value,
}; };
case 'day': case 'day':
return { return {
startDate: num ? subDays(startOfDay(now), num) : startOfDay(now), startDate: num ? subDays(startOfDay(now), num) : startOfDay(now),
endDate: endOfDay(now), endDate: endOfDay(now),
unit: num ? 'day' : 'hour', unit: unitValue ? unitValue : num ? 'day' : 'hour',
offset: 0, offset: 0,
num: num || 1, num: num || 1,
value, value,
@ -187,7 +193,7 @@ export function parseDateRange(value: string, locale = 'en-US', timezone?: strin
? subWeeks(startOfWeek(now, { locale: dateLocale }), num) ? subWeeks(startOfWeek(now, { locale: dateLocale }), num)
: startOfWeek(now, { locale: dateLocale }), : startOfWeek(now, { locale: dateLocale }),
endDate: endOfWeek(now, { locale: dateLocale }), endDate: endOfWeek(now, { locale: dateLocale }),
unit: 'day', unit: unitValue || 'day',
offset: 0, offset: 0,
num: num || 1, num: num || 1,
value, value,
@ -196,7 +202,7 @@ export function parseDateRange(value: string, locale = 'en-US', timezone?: strin
return { return {
startDate: num ? subMonths(startOfMonth(now), num) : startOfMonth(now), startDate: num ? subMonths(startOfMonth(now), num) : startOfMonth(now),
endDate: endOfMonth(now), endDate: endOfMonth(now),
unit: num ? 'month' : 'day', unit: unitValue ? unitValue : num ? 'month' : 'day',
offset: 0, offset: 0,
num: num || 1, num: num || 1,
value, value,
@ -205,7 +211,7 @@ export function parseDateRange(value: string, locale = 'en-US', timezone?: strin
return { return {
startDate: num ? subYears(startOfYear(now), num) : startOfYear(now), startDate: num ? subYears(startOfYear(now), num) : startOfYear(now),
endDate: endOfYear(now), endDate: endOfYear(now),
unit: 'month', unit: unitValue || 'month',
offset: 0, offset: 0,
num: num || 1, num: num || 1,
value, value,
@ -273,12 +279,20 @@ export function getAllowedUnits(startDate: Date, endDate: Date) {
return index >= 0 ? units.splice(index) : []; return index >= 0 ? units.splice(index) : [];
} }
export function getMinimumUnit(startDate: number | Date, endDate: number | Date) { export function getMinimumUnit(
startDate: number | Date,
endDate: number | Date,
isDateRange: boolean = false,
) {
if (differenceInMinutes(endDate, startDate) <= 60) { if (differenceInMinutes(endDate, startDate) <= 60) {
return 'minute'; return 'minute';
} else if (differenceInHours(endDate, startDate) <= 48) { } else if (
isDateRange
? differenceInHours(endDate, startDate) <= 48
: differenceInDays(endDate, startDate) <= 30
) {
return 'hour'; return 'hour';
} else if (differenceInCalendarMonths(endDate, startDate) <= 6) { } else if (differenceInCalendarMonths(endDate, startDate) <= 7) {
return 'day'; return 'day';
} else if (differenceInCalendarMonths(endDate, startDate) <= 24) { } else if (differenceInCalendarMonths(endDate, startDate) <= 24) {
return 'month'; return 'month';

View file

@ -20,14 +20,6 @@ const PRISMA_LOG_OPTIONS = {
}; };
const DATE_FORMATS = { const DATE_FORMATS = {
minute: 'YYYY-MM-DD HH24:MI:00',
hour: 'YYYY-MM-DD HH24:00:00',
day: 'YYYY-MM-DD HH24:00:00',
month: 'YYYY-MM-01 HH24:00:00',
year: 'YYYY-01-01 HH24:00:00',
};
const DATE_FORMATS_UTC = {
minute: 'YYYY-MM-DD"T"HH24:MI:00"Z"', minute: 'YYYY-MM-DD"T"HH24:MI:00"Z"',
hour: 'YYYY-MM-DD"T"HH24:00:00"Z"', hour: 'YYYY-MM-DD"T"HH24:00:00"Z"',
day: 'YYYY-MM-DD"T"HH24:00:00"Z"', day: 'YYYY-MM-DD"T"HH24:00:00"Z"',
@ -52,7 +44,7 @@ function getDateSQL(field: string, unit: string, timezone?: string): string {
return `to_char(date_trunc('${unit}', ${field} at time zone '${timezone}'), '${DATE_FORMATS[unit]}')`; return `to_char(date_trunc('${unit}', ${field} at time zone '${timezone}'), '${DATE_FORMATS[unit]}')`;
} }
return `to_char(date_trunc('${unit}', ${field}), '${DATE_FORMATS_UTC[unit]}')`; return `to_char(date_trunc('${unit}', ${field}), '${DATE_FORMATS[unit]}')`;
} }
function getDateWeeklySQL(field: string, timezone?: string) { function getDateWeeklySQL(field: string, timezone?: string) {

View file

@ -36,6 +36,7 @@ export const filterParams = {
city: z.string().optional(), city: z.string().optional(),
tag: z.string().optional(), tag: z.string().optional(),
hostname: z.string().optional(), hostname: z.string().optional(),
distinctId: z.string().optional(),
language: z.string().optional(), language: z.string().optional(),
event: z.string().optional(), event: z.string().optional(),
segment: z.uuid().optional(), segment: z.uuid().optional(),
@ -89,6 +90,7 @@ export const fieldsParam = z.enum([
'city', 'city',
'tag', 'tag',
'hostname', 'hostname',
'distinctId',
'language', 'language',
'event', 'event',
]); ]);
@ -166,6 +168,7 @@ export const journeyReportSchema = z.object({
steps: z.coerce.number().min(2).max(7), steps: z.coerce.number().min(2).max(7),
startStep: z.string().optional(), startStep: z.string().optional(),
endStep: z.string().optional(), endStep: z.string().optional(),
eventType: z.coerce.number().int().positive().optional(),
}), }),
}); });

View file

@ -58,7 +58,7 @@ async function relationalQuery(
sum(${getTimestampDiffSQL('t.min_time', 't.max_time')}) as "totaltime" sum(${getTimestampDiffSQL('t.min_time', 't.max_time')}) as "totaltime"
from ( from (
select select
${column} name, ${column} as "name",
website_event.session_id, website_event.session_id,
website_event.visit_id, website_event.visit_id,
count(*) as "c", count(*) as "c",
@ -72,6 +72,7 @@ async function relationalQuery(
${filterQuery} ${filterQuery}
group by name, website_event.session_id, website_event.visit_id group by name, website_event.session_id, website_event.visit_id
) as t ) as t
where name != ''
group by name group by name
order by visitors desc, visits desc order by visitors desc, visits desc
limit ${limit} limit ${limit}

View file

@ -89,7 +89,7 @@ async function relationalQuery(
when ${toPostgresPositionClause('referrer_domain', EMAIL_DOMAINS)} or utm_medium ilike '%mail%' then 'email' when ${toPostgresPositionClause('referrer_domain', EMAIL_DOMAINS)} or utm_medium ilike '%mail%' then 'email'
when ${toPostgresPositionClause('referrer_domain', SHOPPING_DOMAINS)} or utm_medium ilike '%shop%' then concat(prefix, 'Shopping') when ${toPostgresPositionClause('referrer_domain', SHOPPING_DOMAINS)} or utm_medium ilike '%shop%' then concat(prefix, 'Shopping')
when ${toPostgresPositionClause('referrer_domain', VIDEO_DOMAINS)} or utm_medium ilike '%video%' then concat(prefix, 'Video') when ${toPostgresPositionClause('referrer_domain', VIDEO_DOMAINS)} or utm_medium ilike '%video%' then concat(prefix, 'Video')
else '' end AS name, else '' end as "name",
session_id, session_id,
visit_id, visit_id,
c, c,

View file

@ -30,7 +30,8 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) {
session.device, session.device,
session.country, session.country,
website_event.url_path as "urlPath", website_event.url_path as "urlPath",
website_event.referrer_domain as "referrerDomain" website_event.referrer_domain as "referrerDomain",
website_event.hostname
from website_event from website_event
${cohortQuery} ${cohortQuery}
inner join session inner join session
@ -65,7 +66,8 @@ async function clickhouseQuery(websiteId: string, filters: QueryFilters): Promis
device, device,
country, country,
url_path as urlPath, url_path as urlPath,
referrer_domain as referrerDomain referrer_domain as referrerDomain,
hostname
from website_event from website_event
${cohortQuery} ${cohortQuery}
where website_id = {websiteId:UUID} where website_id = {websiteId:UUID}

View file

@ -86,7 +86,7 @@ async function relationalQuery(
sum(${getTimestampDiffSQL('t.min_time', 't.max_time')}) as "totaltime" sum(${getTimestampDiffSQL('t.min_time', 't.max_time')}) as "totaltime"
from ( from (
select select
${column} as name, ${column} as "name",
website_event.session_id, website_event.session_id,
website_event.visit_id, website_event.visit_id,
count(*) as "c", count(*) as "c",

View file

@ -52,8 +52,8 @@ async function relationalQuery(
function getUTMQuery(utmColumn: string) { function getUTMQuery(utmColumn: string) {
return ` return `
select select
coalesce(we.${utmColumn}, '') name, coalesce(we.${utmColumn}, '') as "name",
${currency ? 'sum(e.value)' : 'count(distinct we.session_id)'} value ${currency ? 'sum(e.value)' : 'count(distinct we.session_id)'} as "value"
from model m from model m
join website_event we join website_event we
on we.created_at = m.created_at on we.created_at = m.created_at
@ -128,7 +128,7 @@ async function relationalQuery(
` `
${currency ? revenueEventQuery : eventQuery} ${currency ? revenueEventQuery : eventQuery}
${getModelQuery(model)} ${getModelQuery(model)}
select coalesce(we.referrer_domain, '') name, select coalesce(we.referrer_domain, '') as "name",
${currency ? 'sum(e.value)' : 'count(distinct we.session_id)'} value ${currency ? 'sum(e.value)' : 'count(distinct we.session_id)'} value
from model m from model m
join website_event we join website_event we
@ -166,8 +166,8 @@ async function relationalQuery(
when coalesce(li_fat_id, '') != '' then 'LinkedIn Ads' when coalesce(li_fat_id, '') != '' then 'LinkedIn Ads'
when coalesce(twclid, '') != '' then 'Twitter Ads (X)' when coalesce(twclid, '') != '' then 'Twitter Ads (X)'
else '' else ''
end name, end as "name",
${currency ? 'sum(e.value)' : 'count(distinct we.session_id)'} value ${currency ? 'sum(e.value)' : 'count(distinct we.session_id)'} as "value"
from model m from model m
join website_event we join website_event we
on we.created_at = m.created_at on we.created_at = m.created_at

View file

@ -60,7 +60,7 @@ async function relationalQuery(
endStepQuery: string; endStepQuery: string;
params: Record<string, string>; params: Record<string, string>;
} { } {
const params = {}; const params: { startStep?: string; endStep?: string } = {};
let sequenceQuery = ''; let sequenceQuery = '';
let startStepQuery = ''; let startStepQuery = '';
let endStepQuery = ''; let endStepQuery = '';
@ -172,7 +172,7 @@ async function clickhouseQuery(
endStepQuery: string; endStepQuery: string;
params: Record<string, string>; params: Record<string, string>;
} { } {
const params = {}; const params: { startStep?: string; endStep?: string } = {};
let sequenceQuery = ''; let sequenceQuery = '';
let startStepQuery = ''; let startStepQuery = '';
let endStepQuery = ''; let endStepQuery = '';

View file

@ -76,8 +76,8 @@ async function relationalQuery(
const country = await rawQuery( const country = await rawQuery(
` `
select select
session.country as name, session.country as "name",
sum(revenue) value sum(revenue) as "value"
from revenue from revenue
${joinQuery} ${joinQuery}
join session join session
@ -176,8 +176,8 @@ async function clickhouseQuery(
>( >(
` `
select select
website_event.country as name, website_event.country as "name",
sum(website_revenue.revenue) as value sum(website_revenue.revenue) as "value"
from website_revenue from website_revenue
any left join ( any left join (
select * select *

View file

@ -29,6 +29,7 @@ async function relationalQuery(websiteId: string, sessionId: string, filters: Qu
event_type as "eventType", event_type as "eventType",
event_name as "eventName", event_name as "eventName",
visit_id as "visitId", visit_id as "visitId",
hostname,
event_id IN (select website_event_id event_id IN (select website_event_id
from event_data from event_data
where website_id = {{websiteId::uuid}} where website_id = {{websiteId::uuid}}
@ -60,6 +61,7 @@ async function clickhouseQuery(websiteId: string, sessionId: string, filters: Qu
event_type as eventType, event_type as eventType,
event_name as eventName, event_name as eventName,
visit_id as visitId, visit_id as visitId,
hostname,
event_id IN (select event_id event_id IN (select event_id
from event_data from event_data
where website_id = {websiteId:UUID} where website_id = {websiteId:UUID}

View file

@ -65,7 +65,7 @@ async function relationalQuery(
sum(${getTimestampDiffSQL('t.min_time', 't.max_time')}) as "totaltime" sum(${getTimestampDiffSQL('t.min_time', 't.max_time')}) as "totaltime"
from ( from (
select select
${column} name, ${column} as "name",
${includeCountry ? 'country,' : ''} ${includeCountry ? 'country,' : ''}
website_event.session_id, website_event.session_id,
website_event.visit_id, website_event.visit_id,
@ -82,6 +82,7 @@ async function relationalQuery(
group by name, website_event.session_id, website_event.visit_id group by name, website_event.session_id, website_event.visit_id
${includeCountry ? ', country' : ''} ${includeCountry ? ', country' : ''}
) as t ) as t
where name != ''
group by name group by name
${includeCountry ? ', country' : ''} ${includeCountry ? ', country' : ''}
order by visitors desc, visits desc order by visitors desc, visits desc