mirror of
https://github.com/umami-software/umami.git
synced 2026-02-04 04:37:11 +01:00
Merge branch 'dev' of https://github.com/umami-software/umami into dev
This commit is contained in:
commit
4680c89e28
52 changed files with 498 additions and 118 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
76
src/app/(main)/teams/TeamMemberAddForm.tsx
Normal file
76
src/app/(main)/teams/TeamMemberAddForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
40
src/app/(main)/teams/TeamsMemberAddButton.tsx
Normal file
40
src/app/(main)/teams/TeamsMemberAddButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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 || {};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 = [
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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`),
|
||||||
|
|
|
||||||
|
|
@ -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) },
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
71
src/components/input/UnitFilter.tsx
Normal file
71
src/components/input/UnitFilter.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
src/components/input/UserSelect.tsx
Normal file
71
src/components/input/UserSelect.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -41,7 +41,7 @@ export function WebsiteDateFilter({
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
router.push(updateParams({ date, offset: undefined }));
|
router.push(updateParams({ date, offset: undefined, unit: undefined }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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' },
|
||||||
|
|
|
||||||
|
|
@ -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 } });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 = '';
|
||||||
|
|
|
||||||
|
|
@ -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 *
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue