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

View file

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

View file

@ -1,6 +1,3 @@
-- DropIndex
DROP INDEX "board_board_id_key";
-- DropIndex
DROP INDEX "link_link_id_key";
@ -19,9 +16,6 @@ DROP INDEX "segment_segment_id_key";
-- DropIndex
DROP INDEX "session_session_id_key";
-- DropIndex
DROP INDEX "share_share_id_key";
-- DropIndex
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 { Panel } from '@/components/common/Panel';
import { useMessages } from '@/components/hooks';
import { TeamsAddButton } from '../../teams/TeamsAddButton';
import { AdminTeamsDataTable } from './AdminTeamsDataTable';
export function AdminTeamsPage() {
const { formatMessage, labels } = useMessages();
const handleSave = () => {};
return (
<Column gap="6" margin="2">
<PageHeader title={formatMessage(labels.teams)} />
<PageHeader title={formatMessage(labels.teams)}>
<TeamsAddButton onSave={handleSave} isAdmin={true} />
</PageHeader>
<Panel>
<AdminTeamsDataTable />
</Panel>

View file

@ -7,8 +7,17 @@ import {
TextField,
} from '@umami/react-zen';
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 { mutateAsync, error, isPending } = useUpdateQuery('/teams');
@ -26,6 +35,11 @@ export function TeamAddForm({ onSave, onClose }: { onSave: () => void; onClose:
<FormField name="name" label={formatMessage(labels.name)}>
<TextField autoComplete="off" />
</FormField>
{isAdmin && (
<FormField name="ownerId" label={formatMessage(labels.teamOwner)}>
<UserSelect buttonProps={{ style: { outline: 'none' } }} />
</FormField>
)}
<FormButtons>
<Button isDisabled={isPending} onPress={onClose}>
{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 { TeamAddForm } from './TeamAddForm';
export function TeamsAddButton({ onSave }: { onSave?: () => void }) {
export function TeamsAddButton({
onSave,
isAdmin = false,
}: {
onSave?: () => void;
isAdmin?: boolean;
}) {
const { formatMessage, labels } = useMessages();
const { toast } = useToast();
const { touch } = useModified();
@ -25,7 +31,7 @@ export function TeamsAddButton({ onSave }: { onSave?: () => void }) {
</Button>
<Modal>
<Dialog title={formatMessage(labels.createTeam)} style={{ width: 400 }}>
{({ close }) => <TeamAddForm onSave={handleSave} onClose={close} />}
{({ close }) => <TeamAddForm onSave={handleSave} onClose={close} isAdmin={isAdmin} />}
</Dialog>
</Modal>
</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 { PageHeader } from '@/components/common/PageHeader';
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 { labels } from '@/components/messages';
import { ROLES } from '@/lib/constants';
import { TeamsMemberAddButton } from '../TeamsMemberAddButton';
import { TeamEditForm } from './TeamEditForm';
import { TeamManage } from './TeamManage';
import { TeamMembersDataTable } from './TeamMembersDataTable';
@ -13,6 +15,7 @@ export function TeamSettings({ teamId }: { teamId: string }) {
const team: any = useTeam();
const { user } = useLoginQuery();
const { pathname } = useNavigation();
const { formatMessage } = useMessages();
const isAdmin = pathname.includes('/admin');
@ -37,6 +40,10 @@ export function TeamSettings({ teamId }: { teamId: string }) {
<TeamEditForm teamId={teamId} allowEdit={canEdit} showAccessCode={canEdit} />
</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} />
</Panel>
{isTeamOwner && (

View file

@ -21,9 +21,15 @@ export interface JourneyProps {
steps: number;
startStep?: 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 [activeNode, setActiveNode] = useState(null);
const { formatMessage, labels } = useMessages();
@ -32,6 +38,8 @@ export function Journey({ websiteId, steps, startStep, endStep }: JourneyProps)
steps,
startStep,
endStep,
view,
eventType: EVENT_TYPES[view],
});
useEscapeKey(() => setSelectedNode(null));

View file

@ -1,5 +1,6 @@
'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 { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
import { Panel } from '@/components/common/Panel';
@ -14,10 +15,26 @@ export function JourneysPage({ websiteId }: { websiteId: string }) {
const {
dateRange: { startDate, endDate },
} = useDateRange();
const [view, setView] = useState('all');
const [steps, setSteps] = useState(DEFAULT_STEP);
const [startStep, setStartStep] = 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 (
<Column gap>
<WebsiteControls websiteId={websiteId} />
@ -52,6 +69,9 @@ export function JourneysPage({ websiteId }: { websiteId: string }) {
/>
</Column>
</Grid>
<Row justifyContent="flex-end">
<FilterButtons items={buttons} value={view} onChange={setView} />
</Row>
<Panel height="900px" allowFullscreen>
<Journey
websiteId={websiteId}
@ -60,6 +80,7 @@ export function JourneysPage({ websiteId }: { websiteId: string }) {
steps={steps}
startStep={startStep}
endStep={endStep}
view={view}
/>
</Panel>
</Column>

View file

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

View file

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

View file

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

View file

@ -7,14 +7,18 @@ import { formatLongNumber, formatShortTime } from '@/lib/format';
export function WebsiteMetricsBar({
websiteId,
compareMode,
}: {
websiteId: string;
showChange?: boolean;
compareMode?: boolean;
}) {
const { isAllTime } = useDateRange();
const { isAllTime, dateCompare } = useDateRange();
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 || {};

View file

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

View file

@ -1,7 +1,8 @@
'use client';
import { Column } from '@umami/react-zen';
import { Column, Row } from '@umami/react-zen';
import { ExpandedViewModal } from '@/app/(main)/websites/[websiteId]/ExpandedViewModal';
import { Panel } from '@/components/common/Panel';
import { UnitFilter } from '@/components/input/UnitFilter';
import { WebsiteChart } from './WebsiteChart';
import { WebsiteControls } from './WebsiteControls';
import { WebsiteMetricsBar } from './WebsiteMetricsBar';
@ -13,6 +14,9 @@ export function WebsitePage({ websiteId }: { websiteId: string }) {
<WebsiteControls websiteId={websiteId} />
<WebsiteMetricsBar websiteId={websiteId} showChange={true} />
<Panel minHeight="520px">
<Row justifyContent="end">
<UnitFilter />
</Row>
<WebsiteChart websiteId={websiteId} />
</Panel>
<WebsitePanels websiteId={websiteId} />

View file

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

View file

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

View file

@ -25,6 +25,19 @@ export function EventsTable(props: DataTableProps) {
const { updateParams } = useNavigation();
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 (
<DataTable {...props}>
<DataColumn id="event" label={formatMessage(labels.event)} width="2fr">
@ -43,7 +56,7 @@ export function EventsTable(props: DataTableProps) {
title={row.eventName || row.urlPath}
truncate
>
{row.eventName || row.urlPath}
{row.eventName || renderLink(row.urlPath, row.hostname)}
</Text>
{row.hasData > 0 && <PropertiesButton websiteId={row.websiteId} eventId={row.id} />}
</Row>

View file

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

View file

@ -39,10 +39,23 @@ export function SessionActivity({
const { isMobile } = useMobile();
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 (
<LoadingPanel data={data} isLoading={isLoading} error={error}>
<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));
lastDay = createdAt;
@ -61,7 +74,7 @@ export function SessionActivity({
: formatMessage(labels.viewedPage)}
</Text>
<Text weight="bold" style={{ maxWidth: isMobile ? '400px' : null }} truncate>
{eventName || urlPath}
{eventName || renderLink(urlPath, hostname)}
</Text>
{hasData > 0 && <PropertiesButton websiteId={websiteId} eventId={eventId} />}
</Row>

View file

@ -12,11 +12,16 @@ export async function POST(request: Request) {
}
const { websiteId, parameters, filters } = body;
const { eventType } = parameters;
if (!(await canViewWebsite(auth, websiteId))) {
return unauthorized();
}
if (eventType) {
filters.eventType = eventType;
}
const queryFilters = await getQueryFilters(filters, websiteId);
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) {
const schema = z.object({
name: z.string().max(50),
ownerId: z.uuid().optional(),
});
const { auth, body, error } = await parseRequest(request, schema);
@ -40,7 +41,7 @@ export async function POST(request: Request) {
return unauthorized();
}
const { name } = body;
const { name, ownerId } = body;
const team = await createTeam(
{
@ -48,7 +49,7 @@ export async function POST(request: Request) {
name,
accessCode: `team_${getRandomChars(16)}`,
},
auth.user.id,
ownerId ?? auth.user.id,
);
return json(team);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -15,6 +15,7 @@ export function useFields() {
{ name: 'region', type: 'string', label: formatMessage(labels.region) },
{ name: 'city', type: 'string', label: formatMessage(labels.city) },
{ 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: 'event', type: 'string', label: formatMessage(labels.event) },
];

View file

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

View file

@ -61,7 +61,9 @@ export function FilterEditForm({ websiteId, onChange, onClose }: FilterEditFormP
websiteId={websiteId}
value={currentFilters}
onChange={setCurrentFilters}
exclude={excludeFilters ? ['path', 'title', 'hostname', 'tag', 'event'] : []}
exclude={
excludeFilters ? ['path', 'title', 'hostname', 'distinctId', 'tag', 'event'] : []
}
/>
</TabPanel>
<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 {
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' },
segment: { id: 'label.segment', defaultMessage: 'Segment' },
cohort: { id: 'label.cohort', defaultMessage: 'Cohort' },
minute: { id: 'label.minute', defaultMessage: 'Minute' },
hour: { id: 'label.hour', defaultMessage: 'Hour' },
day: { id: 'label.day', defaultMessage: 'Day' },
month: { id: 'label.month', defaultMessage: 'Month' },
date: { id: 'label.date', defaultMessage: 'Date' },
pageOf: { id: 'label.page-of', defaultMessage: 'Page {current} of {total}' },
create: { id: 'label.create', defaultMessage: 'Create' },

View file

@ -25,7 +25,7 @@ export const MetricCard = ({
showChange = false,
}: MetricCardProps) => {
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 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/TeamLeaveButton';
export * from '@/app/(main)/teams/TeamLeaveForm';
export * from '@/app/(main)/teams/TeamMemberAddForm';
export * from '@/app/(main)/teams/TeamProvider';
export * from '@/app/(main)/teams/TeamsAddButton';
export * from '@/app/(main)/teams/TeamsDataTable';
export * from '@/app/(main)/teams/TeamsHeader';
export * from '@/app/(main)/teams/TeamsJoinButton';
export * from '@/app/(main)/teams/TeamsMemberAddButton';
export * from '@/app/(main)/teams/TeamsTable';
export * from '@/app/(main)/websites/[websiteId]/settings/WebsiteData';
export * from '@/app/(main)/websites/[websiteId]/settings/WebsiteDeleteForm';

View file

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

View file

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

View file

@ -20,14 +20,6 @@ const PRISMA_LOG_OPTIONS = {
};
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"',
hour: '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}), '${DATE_FORMATS_UTC[unit]}')`;
return `to_char(date_trunc('${unit}', ${field}), '${DATE_FORMATS[unit]}')`;
}
function getDateWeeklySQL(field: string, timezone?: string) {

View file

@ -36,6 +36,7 @@ export const filterParams = {
city: z.string().optional(),
tag: z.string().optional(),
hostname: z.string().optional(),
distinctId: z.string().optional(),
language: z.string().optional(),
event: z.string().optional(),
segment: z.uuid().optional(),
@ -89,6 +90,7 @@ export const fieldsParam = z.enum([
'city',
'tag',
'hostname',
'distinctId',
'language',
'event',
]);
@ -166,6 +168,7 @@ export const journeyReportSchema = z.object({
steps: z.coerce.number().min(2).max(7),
startStep: 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"
from (
select
${column} name,
${column} as "name",
website_event.session_id,
website_event.visit_id,
count(*) as "c",
@ -72,6 +72,7 @@ async function relationalQuery(
${filterQuery}
group by name, website_event.session_id, website_event.visit_id
) as t
where name != ''
group by name
order by visitors desc, visits desc
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', 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')
else '' end AS name,
else '' end as "name",
session_id,
visit_id,
c,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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