refactor(ui): use feature analytics and saved definition routes

This commit is contained in:
Mike Cao committed 2026-09-09 18:59:08 -07:00
1 parent e202b740a1
commit 007919b173
41 files changed
+645 -367

No files matched your search

@@ -1,3 +1,4 @@
import { useModified } from '@/components/hooks/useModified';
import { Button, Column, ListItem, Row, Select, Text, TextField } from '@umami/react-zen';
import { useEffect, useMemo, useState } from 'react';
import { useApi, useMessages } from '@/components/hooks';
@@ -157,9 +158,13 @@ export function BoardComponentSelect({
const reportFields = selectedDef?.configFields?.filter(
field => field.type === 'report' && field.reportType,
);
const { modified: funnelsModified } = useModified('websites:funnels');
const { modified: goalsModified } = useModified('websites:goals');
const { data: reportOptionsData, isLoading: isLoadingReportOptions } = useQuery({
queryKey: [
'board-component-report-options',
funnelsModified,
goalsModified,
{
websiteId: resolvedEntityId,
reportTypes: reportFields?.map(field => field.reportType).join(','),
@@ -170,9 +175,7 @@ export function BoardComponentSelect({
const entries = await Promise.all(
types.map(async type => {
const response = await get('/reports', {
websiteId: resolvedEntityId,
type,
const response = await get(`/websites/${resolvedEntityId}/${type}s`, {
pageSize: 1000,
});
@@ -2,7 +2,7 @@ import { Column, Grid } from '@umami/react-zen';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { Panel } from '@/components/common/Panel';
import { SectionHeader } from '@/components/common/SectionHeader';
import { useMessages, useResultQuery } from '@/components/hooks';
import { useAttributionQuery, useMessages } from '@/components/hooks';
import { ListTable } from '@/components/metrics/ListTable';
import { MetricCard } from '@/components/metrics/MetricCard';
import { MetricsBar } from '@/components/metrics/MetricsBar';
@@ -26,8 +26,7 @@ export function Attribution({
type,
step,
}: AttributionProps) {
const { data, error, isLoading } = useResultQuery<any>(
'attribution',
const { data, error, isLoading } = useAttributionQuery(
{
websiteId,
startDate,
@@ -113,7 +112,7 @@ export function Attribution({
<AttributionTable data={data?.utm_medium} title={t(labels.medium)} />
</Panel>
<Panel>
<AttributionTable data={data?.utm_cmapaign} title={t(labels.campaigns)} />
<AttributionTable data={data?.utm_campaign} title={t(labels.campaigns)} />
</Panel>
<Panel>
<AttributionTable data={data?.utm_content} title={t(labels.content)} />
@@ -1,6 +1,6 @@
import { Column, DataColumn, DataTable, Text } from '@umami/react-zen';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { useFields, useFormat, useMessages, useResultQuery } from '@/components/hooks';
import { useBreakdownQuery, useFields, useFormat, useMessages } from '@/components/hooks';
import { formatShortTime } from '@/lib/format';
export interface BreakdownProps {
@@ -14,8 +14,7 @@ export function Breakdown({ websiteId, selectedFields = [], startDate, endDate }
const { t, labels } = useMessages();
const { formatValue } = useFormat();
const { fields } = useFields();
const { data, error, isLoading } = useResultQuery<any>(
'breakdown',
const { data, error, isLoading } = useBreakdownQuery(
{
websiteId,
startDate,
@@ -4,7 +4,7 @@ import { useState } from 'react';
import { FieldSelectForm } from '@/app/(main)/websites/[websiteId]/(reports)/breakdown/FieldSelectForm';
import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls';
import { Panel } from '@/components/common/Panel';
import { useDateRange, useMessages, useResultQuery } from '@/components/hooks';
import { useBreakdownQuery, useDateRange, useMessages } from '@/components/hooks';
import { ListCheck } from '@/components/icons';
import { DialogButton } from '@/components/input/DialogButton';
import { DownloadButton } from '@/components/input/DownloadButton';
@@ -15,8 +15,7 @@ export function BreakdownPage({ websiteId }: { websiteId: string }) {
dateRange: { startDate, endDate },
} = useDateRange();
const [fields, setFields] = useState(['path']);
const { data } = useResultQuery<any>(
'breakdown',
const { data } = useBreakdownQuery(
{ websiteId, startDate, endDate, fields },
{ enabled: !!fields.length },
);
@@ -1,8 +1,8 @@
import { Text } from '@umami/react-zen';
import { EmptyPlaceholder } from '@/components/common/EmptyPlaceholder';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { useFunnelDefinitionQuery } from '@/components/hooks/queries/useSavedDefinitionsQuery';
import { Link2Off } from '@/components/icons';
import { useReportQuery } from '@/components/hooks/queries/useReportQuery';
import { Funnel } from './Funnel';
export function BoardFunnel({
@@ -14,7 +14,10 @@ export function BoardFunnel({
reportId?: string;
isPreview?: boolean;
}) {
const { data, isLoading, error, isFetching } = useReportQuery(reportId || '');
const { data, isLoading, error, isFetching } = useFunnelDefinitionQuery(
websiteId,
reportId || '',
);
if (!reportId) {
return (
@@ -1,8 +1,8 @@
import { Box, Column, Grid, Icon, ProgressBar, Row, Text } from '@umami/react-zen';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { useMessages, useNavigation, useOperatorLabels, useResultQuery } from '@/components/hooks';
import { useFunnelQuery, useMessages, useNavigation, useOperatorLabels } from '@/components/hooks';
import { File, User } from '@/components/icons';
import { ReportEditButton } from '@/components/input/ReportEditButton';
import { SavedDefinitionEditButton } from '@/components/input/SavedDefinitionEditButton';
import { ChangeLabel } from '@/components/metrics/ChangeLabel';
import { Lightning } from '@/components/svg';
import { formatLongNumber } from '@/lib/format';
@@ -22,8 +22,9 @@ export function Funnel({ id, name, type, parameters, websiteId, allowEdit = true
const { t, labels } = useMessages();
const { pathname } = useNavigation();
const isSharePage = pathname.includes('/share/');
const { data, error, isLoading } = useResultQuery<Array<FunnelResult>>(type, {
const { data, error, isLoading } = useFunnelQuery({
websiteId,
id,
...parameters,
});
@@ -41,7 +42,8 @@ export function Funnel({ id, name, type, parameters, websiteId, allowEdit = true
</Column>
{allowEdit && !isSharePage && (
<Column>
<ReportEditButton
<SavedDefinitionEditButton
websiteId={websiteId}
id={id}
name={name}
type={type}
@@ -50,7 +52,7 @@ export function Funnel({ id, name, type, parameters, websiteId, allowEdit = true
height="600px"
>
{({ close }) => <FunnelEditForm id={id} websiteId={websiteId} onClose={close} />}
</ReportEditButton>
</SavedDefinitionEditButton>
</Column>
)}
</Grid>
@@ -15,7 +15,13 @@ import {
TextField,
} from '@umami/react-zen';
import { Fragment, useState } from 'react';
import { useApi, useMessages, useMobile, useReportQuery, useUpdateQuery } from '@/components/hooks';
import {
useApi,
useFunnelDefinitionQuery,
useMessages,
useMobile,
useUpdateQuery,
} from '@/components/hooks';
import { Plus, X } from '@/components/icons';
import { ActionSelect } from '@/components/input/ActionSelect';
import { LookupField } from '@/components/input/LookupField';
@@ -160,8 +166,10 @@ export function FunnelEditForm({
onClose?: () => void;
}) {
const { t, labels } = useMessages();
const { data, isLoading } = useReportQuery(id);
const { mutateAsync, error, isPending, touch } = useUpdateQuery(`/reports${id ? `/${id}` : ''}`);
const { data, isLoading } = useFunnelDefinitionQuery(websiteId, id);
const { mutateAsync, error, isPending, touch } = useUpdateQuery(
`/websites/${websiteId}/funnels${id ? `/${id}` : ''}`,
);
const handleSubmit = async ({
name,
@@ -171,11 +179,10 @@ export function FunnelEditForm({
[key: string]: unknown;
}) => {
await mutateAsync(
{ ...data, id, name, type: 'funnel', websiteId, parameters },
{ name, description: data?.description, parameters },
{
onSuccess: async () => {
touch('reports:funnel');
touch(`report:${id}`);
touch('websites:funnels');
onSave?.();
onClose?.();
},
@@ -206,7 +213,10 @@ export function FunnelEditForm({
label={t(labels.window)}
rules={{
required: t(labels.required),
pattern: { value: /^[1-9][0-9]*$/, message: t(labels.invalidValue) || 'Must be greater than 0' },
pattern: {
value: /^[1-9][0-9]*$/,
message: t(labels.invalidValue) || 'Must be greater than 0',
},
}}
>
<TextField />
@@ -4,12 +4,12 @@ import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteContro
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { Panel } from '@/components/common/Panel';
import { SectionHeader } from '@/components/common/SectionHeader';
import { useDateRange, useNavigation, useReportsQuery } from '@/components/hooks';
import { useDateRange, useFunnelsQuery, useNavigation } from '@/components/hooks';
import { Funnel } from './Funnel';
import { FunnelAddButton } from './FunnelAddButton';
export function FunnelsPage({ websiteId }: { websiteId: string }) {
const { data, isLoading, error } = useReportsQuery({ websiteId, type: 'funnel' });
const { data, isLoading, error } = useFunnelsQuery({ websiteId });
const {
dateRange: { startDate, endDate },
} = useDateRange();
@@ -2,8 +2,8 @@ import { Text } from '@umami/react-zen';
import { EmptyPlaceholder } from '@/components/common/EmptyPlaceholder';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { useDateRange } from '@/components/hooks';
import { useGoalDefinitionQuery } from '@/components/hooks/queries/useSavedDefinitionsQuery';
import { Link2Off } from '@/components/icons';
import { useReportQuery } from '@/components/hooks/queries/useReportQuery';
import { Goal } from './Goal';
export function BoardGoal({
@@ -18,7 +18,7 @@ export function BoardGoal({
const {
dateRange: { startDate, endDate },
} = useDateRange();
const { data, isLoading, error, isFetching } = useReportQuery(reportId || '');
const { data, isLoading, error, isFetching } = useGoalDefinitionQuery(websiteId, reportId || '');
if (!reportId) {
return (
@@ -1,8 +1,8 @@
import { Column, Grid, Icon, ProgressBar, Row, Text } from '@umami/react-zen';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { useMessages, useNavigation, useResultQuery } from '@/components/hooks';
import { useGoalQuery, useMessages, useNavigation } from '@/components/hooks';
import { File, User } from '@/components/icons';
import { ReportEditButton } from '@/components/input/ReportEditButton';
import { SavedDefinitionEditButton } from '@/components/input/SavedDefinitionEditButton';
import { Lightning } from '@/components/svg';
import { formatLongNumber } from '@/lib/format';
import { GoalEditForm } from './GoalEditForm';
@@ -37,8 +37,9 @@ export function Goal({
const { t, labels } = useMessages();
const { pathname } = useNavigation();
const isSharePage = pathname.includes('/share/');
const { data, error, isLoading, isFetching } = useResultQuery<GoalData>(type, {
const { data, error, isLoading, isFetching } = useGoalQuery({
websiteId,
id,
startDate,
endDate,
...parameters,
@@ -57,7 +58,8 @@ export function Goal({
</Column>
{allowEdit && !isSharePage && (
<Column>
<ReportEditButton
<SavedDefinitionEditButton
websiteId={websiteId}
id={id}
name={name}
type={type}
@@ -66,7 +68,7 @@ export function Goal({
minHeight="300px"
>
{({ close }) => <GoalEditForm id={id} websiteId={websiteId} onClose={close} />}
</ReportEditButton>
</SavedDefinitionEditButton>
</Column>
)}
</Grid>
@@ -10,7 +10,7 @@ import {
Loading,
TextField,
} from '@umami/react-zen';
import { useMessages, useMobile, useReportQuery, useUpdateQuery } from '@/components/hooks';
import { useGoalDefinitionQuery, useMessages, useMobile, useUpdateQuery } from '@/components/hooks';
import { ActionSelect } from '@/components/input/ActionSelect';
import { LookupField } from '@/components/input/LookupField';
@@ -27,16 +27,17 @@ export function GoalEditForm({
}) {
const { t, labels } = useMessages();
const { isMobile } = useMobile();
const { data } = useReportQuery(id);
const { mutateAsync, error, isPending, touch } = useUpdateQuery(`/reports${id ? `/${id}` : ''}`);
const { data } = useGoalDefinitionQuery(websiteId, id);
const { mutateAsync, error, isPending, touch } = useUpdateQuery(
`/websites/${websiteId}/goals${id ? `/${id}` : ''}`,
);
const handleSubmit = async (formData: Record<string, any>) => {
await mutateAsync(
{ ...formData, type: 'goal', websiteId },
{ name: formData.name, description: formData.description, parameters: formData.parameters },
{
onSuccess: async () => {
if (id) touch(`report:${id}`);
touch('reports:goal');
touch('websites:goals');
onSave?.();
onClose?.();
},
@@ -72,7 +73,14 @@ export function GoalEditForm({
</FormField>
<FormField name="parameters.value" rules={{ required: t(labels.required) }}>
{({ field }) => {
return <LookupField websiteId={websiteId} type={type} allowCustomValue {...field} />;
return (
<LookupField
websiteId={websiteId}
type={type}
allowCustomValue
{...field}
/>
);
}}
</FormField>
</Column>
@@ -86,7 +94,14 @@ export function GoalEditForm({
<Column style={{ minWidth: 0 }}>
<FormField name="parameters.value" rules={{ required: t(labels.required) }}>
{({ field }) => {
return <LookupField websiteId={websiteId} type={type} allowCustomValue {...field} />;
return (
<LookupField
websiteId={websiteId}
type={type}
allowCustomValue
{...field}
/>
);
}}
</FormField>
</Column>
@@ -4,12 +4,12 @@ import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteContro
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { Panel } from '@/components/common/Panel';
import { SectionHeader } from '@/components/common/SectionHeader';
import { useDateRange, useNavigation, useReportsQuery } from '@/components/hooks';
import { useDateRange, useGoalsQuery, useNavigation } from '@/components/hooks';
import { Goal } from './Goal';
import { GoalAddButton } from './GoalAddButton';
export function GoalsPage({ websiteId }: { websiteId: string }) {
const { data, isLoading, error } = useReportsQuery({ websiteId, type: 'goal' });
const { data, isLoading, error } = useGoalsQuery({ websiteId });
const {
dateRange: { startDate, endDate },
} = useDateRange();
@@ -18,7 +18,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ControlledDialog } from '@/components/common/ControlledDialog';
import { IconLabel } from '@/components/common/IconLabel';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { useMobile, useResultQuery } from '@/components/hooks';
import { useHeatmapQuery, useMobile } from '@/components/hooks';
import { ListCheck } from '@/components/icons';
import { formatLongNumber } from '@/lib/format';
import type { HeatmapMode, HeatmapPoint, HeatmapResult, HeatmapSnapshot } from '@/queries/sql';
@@ -65,7 +65,7 @@ export function Heatmap({ websiteId, urlPath, onUrlPathChange, mode, search }: H
data: pagesData,
error,
isLoading,
} = useResultQuery<HeatmapResult>('heatmap', {
} = useHeatmapQuery({
websiteId,
mode,
});
@@ -74,8 +74,7 @@ export function Heatmap({ websiteId, urlPath, onUrlPathChange, mode, search }: H
data: detailData,
isLoading: isDetailLoading,
isFetching: isDetailFetching,
} = useResultQuery<HeatmapResult>(
'heatmap',
} = useHeatmapQuery(
{
websiteId,
urlPath: urlPath || undefined,
@@ -134,7 +133,12 @@ export function Heatmap({ websiteId, urlPath, onUrlPathChange, mode, search }: H
{isPhone ? (
<Column gap="4" minHeight="900px">
<Column gap="2" className={styles.mobilePageSection}>
<Row alignItems="center" justifyContent="space-between" gap className={styles.mobilePageHeader}>
<Row
alignItems="center"
justifyContent="space-between"
gap
className={styles.mobilePageHeader}
>
<Text color="muted" className={styles.mobileSectionLabel}>
Selected page
</Text>
@@ -1005,13 +1009,7 @@ function SnapshotPreview({
return <IframeSnapshot snapshot={snapshot} onReady={onReady} />;
}
function IframeSnapshot({
snapshot,
onReady,
}: {
snapshot: HeatmapSnapshot;
onReady: () => void;
}) {
function IframeSnapshot({ snapshot, onReady }: { snapshot: HeatmapSnapshot; onReady: () => void }) {
const [available, setAvailable] = useState(true);
const iframeUrl = snapshot.url;
const frameHeight = getSnapshotFrameHeight(snapshot);
@@ -2,7 +2,7 @@ import { Column, cn, Icon, Row, Text, Tooltip, TooltipTrigger } from '@umami/rea
import { useMemo, useState } from 'react';
import { firstBy } from 'thenby';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { useEscapeKey, useMessages, useResultQuery } from '@/components/hooks';
import { useEscapeKey, useJourneyQuery, useMessages } from '@/components/hooks';
import { File } from '@/components/icons';
import { Lightning } from '@/components/svg';
import { objectToArray } from '@/lib/data';
@@ -32,12 +32,11 @@ export function Journey({ websiteId, steps, startStep, endStep, view }: JourneyP
const [selectedNode, setSelectedNode] = useState(null);
const [activeNode, setActiveNode] = useState(null);
const { t, labels } = useMessages();
const { data, error, isLoading } = useResultQuery<any>('journey', {
const { data, error, isLoading } = useJourneyQuery({
websiteId,
steps,
startStep,
endStep,
view,
eventType: EVENT_TYPES[view],
});
@@ -18,7 +18,13 @@ import { BarChart } from '@/components/charts/BarChart';
import { GridRow } from '@/components/common/GridRow';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { Panel } from '@/components/common/Panel';
import { useLocale, useMessages, useResultQuery } from '@/components/hooks';
import {
useLocale,
useMessages,
usePerformanceChartQuery,
usePerformanceMetricsQuery,
usePerformanceStatsQuery,
} from '@/components/hooks';
import { ListTable } from '@/components/metrics/ListTable';
import { MetricLabel } from '@/components/metrics/MetricLabel';
import { PerformanceCard } from '@/components/metrics/PerformanceCard';
@@ -63,12 +69,10 @@ export function Performance({ websiteId, startDate, endDate, unit }: Performance
const { t, labels } = useMessages();
const { locale, dateLocale } = useLocale();
const { data, error, isLoading } = useResultQuery<any>('performance', {
websiteId,
startDate,
endDate,
metric: selectedMetric,
});
const queryParams = { websiteId, startDate, endDate, unit };
const stats = usePerformanceStatsQuery(queryParams);
const chart = usePerformanceChartQuery({ ...queryParams, metric: selectedMetric });
const data = chart.data;
const chartData: any = useMemo(() => {
if (!data?.chart) return { datasets: [] };
@@ -158,147 +162,137 @@ export function Performance({ websiteId, startDate, endDate, unit }: Performance
))}
</Select>
</Grid>
<LoadingPanel data={data} isLoading={isLoading} error={error}>
{data && (
<Column gap>
<Grid columns={{ base: '1fr 1fr', lg: 'repeat(5, 1fr)' }} gap>
{METRICS.map(metric => (
<PerformanceCard
key={metric}
metric={metric}
value={Number(data.summary?.[metric]?.[selectedPercentile] || 0)}
label={t(labels[metric]) || metric.toUpperCase()}
formatValue={(n: number) => formatMetricValue(metric, n)}
onClick={() => setSelectedMetric(metric)}
selected={selectedMetric === metric}
/>
))}
</Grid>
<Panel>
<Column gap="4" padding="4">
<Row justifyContent="space-between" alignItems="center">
<Text weight="bold">{METRIC_LABELS[selectedMetric]}</Text>
<Row gap="4">
<Text size="sm" className={styles.sampleCount}>
{t(labels.sampleSize)}: {formatLongNumber(data.summary?.count || 0)}
</Text>
</Row>
<Column gap>
<LoadingPanel data={stats.data} isLoading={stats.isLoading} error={stats.error}>
<Grid columns={{ base: '1fr 1fr', lg: 'repeat(5, 1fr)' }} gap>
{METRICS.map(metric => (
<PerformanceCard
key={metric}
metric={metric}
value={Number(stats.data?.[metric]?.[selectedPercentile] || 0)}
label={t(labels[metric]) || metric.toUpperCase()}
formatValue={(n: number) => formatMetricValue(metric, n)}
onClick={() => setSelectedMetric(metric)}
selected={selectedMetric === metric}
/>
))}
</Grid>
</LoadingPanel>
<LoadingPanel data={chart.data} isLoading={chart.isLoading} error={chart.error}>
<Panel>
<Column gap="4" padding="4">
<Row justifyContent="space-between" alignItems="center">
<Text weight="bold">{METRIC_LABELS[selectedMetric]}</Text>
<Row gap="4">
<Text size="sm" className={styles.sampleCount}>
{t(labels.sampleSize)}: {formatLongNumber(stats.data?.count || 0)}
</Text>
</Row>
<BarChart
chartData={chartData}
minDate={startDate}
maxDate={endDate}
unit={unit}
renderXLabel={renderXLabel}
renderYLabel={(label: string) => {
const val = Number(label);
if (selectedMetric === 'cls') return val.toFixed(2);
if (val >= 1000) return `${(val / 1000).toFixed(2)} s`;
return `${Math.round(val)} ms`;
}}
height="400px"
</Row>
<BarChart
chartData={chartData}
minDate={startDate}
maxDate={endDate}
unit={unit}
renderXLabel={renderXLabel}
renderYLabel={(label: string) => {
const val = Number(label);
if (selectedMetric === 'cls') return val.toFixed(2);
if (val >= 1000) return `${(val / 1000).toFixed(2)} s`;
return `${Math.round(val)} ms`;
}}
height="400px"
/>
</Column>
</Panel>
</LoadingPanel>
<GridRow layout="two">
<Panel>
<Tabs>
<Heading size="2xl">{t(labels.pages)}</Heading>
<TabList>
<Tab id="path">{t(labels.path)}</Tab>
<Tab id="title">{t(labels.pageTitle)}</Tab>
</TabList>
<TabPanel id="path">
<PerformanceMetricsTable
params={{ ...queryParams, metric: selectedMetric, type: 'path' }}
percentile={selectedPercentile}
metricLabel={metricLabel}
formatCount={formatListCount}
renderLabel={({ label }: { label: string }) => <Text>{label}</Text>}
/>
</Column>
</Panel>
<GridRow layout="two">
<Panel>
<Tabs>
<Heading size="2xl">{t(labels.pages)}</Heading>
<TabList>
<Tab id="path">{t(labels.path)}</Tab>
<Tab id="title">{t(labels.pageTitle)}</Tab>
</TabList>
<TabPanel id="path">
<ListTable
metric={metricLabel}
showPercentage={false}
formatCount={formatListCount}
data={data.pages
?.filter(
({ p50, p75, p95 }: any) =>
Number({ p50, p75, p95 }[selectedPercentile]) > 0,
)
.slice(0, 20)
.map(({ name, p50, p75, p95 }: any) => ({
label: name,
count: Number({ p50, p75, p95 }[selectedPercentile]),
percent: 0,
}))}
renderLabel={({ label }: { label: string }) => <Text>{label}</Text>}
/>
</TabPanel>
<TabPanel id="title">
<ListTable
metric={metricLabel}
showPercentage={false}
formatCount={formatListCount}
data={data.pageTitles
?.filter(
({ p50, p75, p95 }: any) =>
Number({ p50, p75, p95 }[selectedPercentile]) > 0,
)
.slice(0, 20)
.map(({ name, p50, p75, p95 }: any) => ({
label: name,
count: Number({ p50, p75, p95 }[selectedPercentile]),
percent: 0,
}))}
renderLabel={(row: any) => <MetricLabel type="title" data={row} />}
/>
</TabPanel>
</Tabs>
</Panel>
<Panel>
<Tabs>
<Heading size="2xl">{t(labels.environment)}</Heading>
<TabList>
<Tab id="device">{t(labels.device)}</Tab>
<Tab id="browser">{t(labels.browser)}</Tab>
</TabList>
<TabPanel id="device">
<ListTable
metric={metricLabel}
showPercentage={false}
formatCount={formatListCount}
data={data.devices
?.filter(
({ p50, p75, p95 }: any) =>
Number({ p50, p75, p95 }[selectedPercentile]) > 0,
)
.slice(0, 20)
.map(({ name, p50, p75, p95 }: any) => ({
label: name,
count: Number({ p50, p75, p95 }[selectedPercentile]),
percent: 0,
}))}
renderLabel={(row: any) => <MetricLabel type="device" data={row} />}
/>
</TabPanel>
<TabPanel id="browser">
<ListTable
metric={metricLabel}
showPercentage={false}
formatCount={formatListCount}
data={data.browsers
?.filter(
({ p50, p75, p95 }: any) =>
Number({ p50, p75, p95 }[selectedPercentile]) > 0,
)
.slice(0, 20)
.map(({ name, p50, p75, p95 }: any) => ({
label: name,
count: Number({ p50, p75, p95 }[selectedPercentile]),
percent: 0,
}))}
renderLabel={(row: any) => <MetricLabel type="browser" data={row} />}
/>
</TabPanel>
</Tabs>
</Panel>
</GridRow>
</Column>
)}
</LoadingPanel>
</TabPanel>
<TabPanel id="title">
<PerformanceMetricsTable
params={{ ...queryParams, metric: selectedMetric, type: 'title' }}
percentile={selectedPercentile}
metricLabel={metricLabel}
formatCount={formatListCount}
renderLabel={(row: any) => <MetricLabel type="title" data={row} />}
/>
</TabPanel>
</Tabs>
</Panel>
<Panel>
<Tabs>
<Heading size="2xl">{t(labels.environment)}</Heading>
<TabList>
<Tab id="device">{t(labels.device)}</Tab>
<Tab id="browser">{t(labels.browser)}</Tab>
</TabList>
<TabPanel id="device">
<PerformanceMetricsTable
params={{ ...queryParams, metric: selectedMetric, type: 'device' }}
percentile={selectedPercentile}
metricLabel={metricLabel}
formatCount={formatListCount}
renderLabel={(row: any) => <MetricLabel type="device" data={row} />}
/>
</TabPanel>
<TabPanel id="browser">
<PerformanceMetricsTable
params={{ ...queryParams, metric: selectedMetric, type: 'browser' }}
percentile={selectedPercentile}
metricLabel={metricLabel}
formatCount={formatListCount}
renderLabel={(row: any) => <MetricLabel type="browser" data={row} />}
/>
</TabPanel>
</Tabs>
</Panel>
</GridRow>
</Column>
</Column>
);
}
function PerformanceMetricsTable({
params,
percentile,
metricLabel,
formatCount,
renderLabel,
}: {
params: Parameters<typeof usePerformanceMetricsQuery>[0];
percentile: 'p50' | 'p75' | 'p95';
metricLabel: string;
formatCount: (value: number) => string;
renderLabel: (row: any) => React.ReactNode;
}) {
const { data, isLoading, error } = usePerformanceMetricsQuery(params);
return (
<LoadingPanel data={data} isLoading={isLoading} error={error}>
<ListTable
metric={metricLabel}
showPercentage={false}
formatCount={formatCount}
data={(data ?? [])
.filter(row => Number(row[percentile]) > 0)
.slice(0, 20)
.map(row => ({ label: row.name, count: Number(row[percentile]), percent: 0 }))}
renderLabel={renderLabel}
/>
</LoadingPanel>
);
}
@@ -3,7 +3,7 @@ import { differenceInCalendarDays } from 'date-fns';
import type { ReactNode } from 'react';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { Panel } from '@/components/common/Panel';
import { useLocale, useMessages, useResultQuery } from '@/components/hooks';
import { useLocale, useMessages, useRetentionQuery } from '@/components/hooks';
import { Users } from '@/components/icons';
import { formatDate } from '@/lib/date';
import { formatLongNumber } from '@/lib/format';
@@ -20,7 +20,7 @@ export interface RetentionProps {
export function Retention({ websiteId, days = DAYS, startDate, endDate }: RetentionProps) {
const { t, labels } = useMessages();
const { locale } = useLocale();
const { data, error, isLoading } = useResultQuery('retention', {
const { data, error, isLoading } = useRetentionQuery({
websiteId,
startDate,
endDate,
@@ -2,7 +2,7 @@ import { Column, Heading, Text } from '@umami/react-zen';
import { useEffect, useRef, useState } from 'react';
import { PieChart } from '@/components/charts/PieChart';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { useDateRange, useMessages, useResultQuery } from '@/components/hooks';
import { useDateRange, useMessages, useUTMMetricsQuery } from '@/components/hooks';
import { ListTable } from '@/components/metrics/ListTable';
import { CHART_COLORS } from '@/lib/constants';
@@ -23,14 +23,15 @@ export function BoardUTM({
dateRange: { startDate, endDate },
} = useDateRange();
const { t, labels } = useMessages();
const { data, error, isLoading } = useResultQuery<any>('utm', {
const { data, error, isLoading } = useUTMMetricsQuery({
type: param,
websiteId,
startDate,
endDate,
});
const itemLimit = Number(limit) || 10;
const items = (data?.[param] ?? []).slice(0, itemLimit);
const items = (data ?? []).slice(0, itemLimit);
const total = items.reduce((sum, { views }) => {
return +sum + +views;
}, 0);
@@ -78,9 +79,7 @@ export function BoardUTM({
<div
style={{
display: 'grid',
gridTemplateColumns: isStacked
? 'minmax(0, 1fr)'
: 'minmax(0, 1fr) minmax(0, 1fr)',
gridTemplateColumns: isStacked ? 'minmax(0, 1fr)' : 'minmax(0, 1fr) minmax(0, 1fr)',
gap: 24,
alignItems: 'start',
}}
@@ -2,7 +2,7 @@ import { Column, Grid, Heading, Text } from '@umami/react-zen';
import { PieChart } from '@/components/charts/PieChart';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import { Panel } from '@/components/common/Panel';
import { useMessages, useResultQuery } from '@/components/hooks';
import { useMessages, useUTMMetricsQuery } from '@/components/hooks';
import { ListTable } from '@/components/metrics/ListTable';
import { CHART_COLORS, UTM_PARAMS } from '@/lib/constants';
@@ -12,60 +12,59 @@ export interface UTMProps {
endDate: Date;
}
export function UTM({ websiteId, startDate, endDate }: UTMProps) {
export function UTM(props: UTMProps) {
return (
<Column gap>
{UTM_PARAMS.map(param => (
<UTMDimension key={param} {...props} param={param} />
))}
</Column>
);
}
function UTMDimension({ websiteId, startDate, endDate, param }: UTMProps & { param: string }) {
const { t, labels } = useMessages();
const { data, error, isLoading } = useResultQuery<any>('utm', {
websiteId,
startDate,
endDate,
});
const {
data: items = [],
error,
isLoading,
} = useUTMMetricsQuery({ websiteId, startDate, endDate, type: param });
const chartData = {
labels: items.map(({ utm }) => utm),
datasets: [
{
data: items.map(({ views }) => views),
backgroundColor: CHART_COLORS,
borderWidth: 0,
},
],
};
const total = items.reduce((sum, { views }) => {
return +sum + +views;
}, 0);
return (
<LoadingPanel data={data} isLoading={isLoading} error={error} minHeight="300px">
{data && (
<Column gap>
{UTM_PARAMS.map(param => {
const items = data?.[param];
const chartData = {
labels: items.map(({ utm }) => utm),
datasets: [
{
data: items.map(({ views }) => views),
backgroundColor: CHART_COLORS,
borderWidth: 0,
},
],
};
const total = items.reduce((sum, { views }) => {
return +sum + +views;
}, 0);
return (
<Panel key={param}>
<Grid columns={{ base: '1fr', md: '1fr 1fr' }} gap="6">
<Column>
<Heading>
<Text transform="capitalize">{param.replace(/^utm_/, '')}</Text>
</Heading>
<ListTable
metric={t(labels.views)}
data={items.map(({ utm, views }) => ({
label: utm,
count: views,
percent: (views / total) * 100,
}))}
/>
</Column>
<Column>
<PieChart type="doughnut" chartData={chartData} />
</Column>
</Grid>
</Panel>
);
})}
</Column>
)}
<LoadingPanel data={items} isLoading={isLoading} error={error} minHeight="300px">
<Panel>
<Grid columns={{ base: '1fr', md: '1fr 1fr' }} gap="6">
<Column>
<Heading>
<Text transform="capitalize">{param.replace(/^utm_/, '')}</Text>
</Heading>
<ListTable
metric={t(labels.views)}
data={items.map(({ utm, views }) => ({
label: utm,
count: views,
percent: (views / total) * 100,
}))}
/>
</Column>
<Column>
<PieChart type="doughnut" chartData={chartData} />
</Column>
</Grid>
</Panel>
</LoadingPanel>
);
}
@@ -13,5 +13,5 @@ export async function GET(
const { websiteId } = await params;
if (!(await canViewWebsiteSection(auth, websiteId, 'utm'))) return unauthorized();
const filters = await getQueryFilters(query, websiteId);
return json(await getUTM(websiteId, { ...filters, column: query.type }, filters));
return json(await getUTM(websiteId, { startDate: filters.startDate, endDate: filters.endDate, column: query.type }, filters));
}
+17 -9
View File
@@ -12,22 +12,28 @@ export * from './context/useWebsite';
// Query hooks
export * from './queries/useActiveUsersQuery';
export * from './queries/useApiKeysQuery';
export * from './queries/useAttributionQuery';
export * from './queries/useBoardQuery';
export * from './queries/useBoardSharesQuery';
export * from './queries/useBoardsQuery';
export * from './queries/useBreakdownQuery';
export * from './queries/useDashboardQuery';
export * from './queries/useDateRangeQuery';
export * from './queries/useDeleteQuery';
export * from './queries/useEventDataPivotQuery';
export * from './queries/useEventDataPropertiesQuery';
export * from './queries/useEventDataQuery';
export * from './queries/useFunnelQuery';
export * from './queries/useGoalQuery';
export * from './queries/useHeatmapQuery';
export * from './queries/useJourneyQuery';
export * from './queries/useLinkQuery';
export * from './queries/useLinkSharesQuery';
export * from './queries/useLinksQuery';
export * from './queries/useLoginQuery';
export * from './queries/useTwoFactorStatusQuery';
export * from './queries/useTwoFactorUserStatus';
export * from './queries/useTwoFactorVerifyMutation';
export * from './queries/usePerformanceChartQuery';
export * from './queries/usePerformanceMetricsQuery';
export * from './queries/usePerformanceStatsQuery';
export * from './queries/usePixelQuery';
export * from './queries/usePixelSharesQuery';
export * from './queries/usePixelsQuery';
@@ -42,13 +48,12 @@ export * from './queries/useRealtimeQuery';
export * from './queries/useReplayQuery';
export * from './queries/useReplaySavedQuery';
export * from './queries/useReplaysQuery';
export * from './queries/useReportQuery';
export * from './queries/useReportsQuery';
export * from './queries/useResultQuery';
export * from './queries/useRetentionQuery';
export * from './queries/useRevenueChartQuery';
export * from './queries/useRevenueMetricsQuery';
export * from './queries/useRevenueSessionsQuery';
export * from './queries/useRevenueStatsQuery';
export * from './queries/useSavedDefinitionsQuery';
export * from './queries/useSavedReplaysQuery';
export * from './queries/useSessionActivityQuery';
export * from './queries/useSessionDataActivityStatsQuery';
@@ -62,21 +67,25 @@ export * from './queries/useTeamMembersQuery';
export * from './queries/useTeamQuery';
export * from './queries/useTeamsQuery';
export * from './queries/useTeamWebsitesQuery';
export * from './queries/useTwoFactorStatusQuery';
export * from './queries/useTwoFactorUserStatus';
export * from './queries/useTwoFactorVerifyMutation';
export * from './queries/useUpdateQuery';
export * from './queries/useUserQuery';
export * from './queries/useUsersQuery';
export * from './queries/useUserTeamsQuery';
export * from './queries/useUserWebsitesQuery';
export * from './queries/useUTMMetricsQuery';
export * from './queries/useWebsiteAnnotationsQuery';
export * from './queries/useWebsiteCohortQuery';
export * from './queries/useWebsiteCohortsQuery';
export * from './queries/useWebsiteEventsQuery';
export * from './queries/useWebsiteEventsSeriesQuery';
export * from './queries/useWebsiteExpandedMetricsQuery';
export * from './queries/useWebsiteListChartsQuery';
export * from './queries/useWebsiteMetricsQuery';
export * from './queries/useWebsitePageviewsQuery';
export * from './queries/useWebsiteListChartsQuery';
export * from './queries/useWebsiteQuery';
export * from './queries/useWebsiteAnnotationsQuery';
export * from './queries/useWebsiteSegmentQuery';
export * from './queries/useWebsiteSegmentsQuery';
export * from './queries/useWebsiteSessionQuery';
@@ -87,7 +96,6 @@ export * from './queries/useWebsiteStatsQuery';
export * from './queries/useWebsitesQuery';
export * from './queries/useWebsiteValuesQuery';
export * from './queries/useWeeklyTrafficQuery';
// Regular hooks
export * from './useApi';
export * from './useConfig';
@@ -0,0 +1,47 @@
import { renderHook } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import { useFunnelQuery } from './useFunnelQuery';
import { usePerformanceStatsQuery } from './usePerformanceStatsQuery';
import { useUTMMetricsQuery } from './useUTMMetricsQuery';
const mocks = vi.hoisted(() => ({ get: vi.fn(), query: vi.fn((options: any) => options) }));
vi.mock('../useApi', () => ({ useApi: () => ({ get: mocks.get, useQuery: mocks.query }) }));
vi.mock('../useDateParameters', () => ({
useDateParameters: () => ({ startAt: 1000, endAt: 2000, timezone: 'UTC', unit: 'day' }),
}));
vi.mock('../useFilterParameters', () => ({
useFilterParameters: () => ({ browser1: 'eq.Chrome' }),
}));
vi.mock('../useModified', () => ({ useModified: () => ({ modified: 1 }) }));
test('saved funnels use ID stats with global dates, timezone, and filters', async () => {
renderHook(() => useFunnelQuery({ websiteId: 'w', id: 'saved', steps: [] }));
const options = mocks.query.mock.lastCall[0];
await options.queryFn();
expect(mocks.get.mock.lastCall).toEqual([
'/websites/w/funnels/saved/stats',
{ startAt: 1000, endAt: 2000, timezone: 'UTC', unit: 'day', browser1: 'eq.Chrome' },
]);
});
test('UTM widgets fetch only their selected dimension', async () => {
renderHook(() => useUTMMetricsQuery({ websiteId: 'w', type: 'utm_source' }));
await mocks.query.mock.lastCall[0].queryFn();
expect(mocks.get.mock.lastCall[0]).toBe('/websites/w/utm/metrics');
expect(mocks.get.mock.lastCall[1].type).toBe('utm_source');
});
test('performance stats cache does not depend on a selected chart metric', () => {
renderHook(() => usePerformanceStatsQuery({ websiteId: 'w' }));
expect(mocks.query.mock.lastCall[0].queryKey).toEqual([
'websites:performance/stats',
{
websiteId: 'w',
startAt: 1000,
endAt: 2000,
timezone: 'UTC',
unit: 'day',
browser1: 'eq.Chrome',
modified: 1,
},
]);
});
@@ -0,0 +1,31 @@
import { type AnalyticsParameters, serializeAnalyticsQuery } from '@/lib/analytics-query';
import type { ReactQueryOptions } from '@/lib/types';
import { useApi } from '../useApi';
import { useDateParameters } from '../useDateParameters';
import { useFilterParameters } from '../useFilterParameters';
import { useModified } from '../useModified';
export function useAnalyticsQuery<T>(
path: string,
params: AnalyticsParameters & Record<string, any>,
options?: ReactQueryOptions<T>,
) {
const { get, useQuery } = useApi();
const { startAt, endAt, timezone, unit } = useDateParameters();
const filters = useFilterParameters({ includePagination: false });
const { modified } = useModified(`websites:${path.split('/')[0]}`);
const query = serializeAnalyticsQuery({
startAt,
endAt,
timezone,
unit,
...filters,
...Object.fromEntries(Object.entries(params).filter(([, value]) => value !== undefined)),
});
return useQuery<T>({
queryKey: [`websites:${path}`, { websiteId: params.websiteId, ...query, modified }],
queryFn: () => get(`/websites/${params.websiteId}/${path}`, query),
enabled: !!params.websiteId,
...options,
});
}
@@ -0,0 +1,14 @@
import type { AnalyticsParameters } from '@/lib/analytics-query';
import type { ReactQueryOptions } from '@/lib/types';
import type {
AttributionParameters,
AttributionResult,
} from '@/queries/sql/attribution/getAttribution';
import { useAnalyticsQuery } from './useAnalyticsQuery';
export function useAttributionQuery(
params: AnalyticsParameters & Partial<Omit<AttributionParameters, 'startDate' | 'endDate'>>,
options?: ReactQueryOptions<AttributionResult>,
) {
return useAnalyticsQuery<AttributionResult>('attribution', params, options);
}
@@ -0,0 +1,11 @@
import type { AnalyticsParameters } from '@/lib/analytics-query';
import type { ReactQueryOptions } from '@/lib/types';
import type { BreakdownData, BreakdownParameters } from '@/queries/sql/breakdown/getBreakdown';
import { useAnalyticsQuery } from './useAnalyticsQuery';
export function useBreakdownQuery(
params: AnalyticsParameters & Partial<Omit<BreakdownParameters, 'startDate' | 'endDate'>>,
options?: ReactQueryOptions<BreakdownData[]>,
) {
return useAnalyticsQuery<BreakdownData[]>('breakdown', params, options);
}
@@ -0,0 +1,24 @@
import type { AnalyticsParameters } from '@/lib/analytics-query';
import type { ReactQueryOptions } from '@/lib/types';
import type { FunnelParameters, FunnelResult } from '@/queries/sql/funnels/getFunnel';
import { useAnalyticsQuery } from './useAnalyticsQuery';
export function useFunnelQuery(
params: AnalyticsParameters &
Partial<Omit<FunnelParameters, 'startDate' | 'endDate'>> & { id?: string },
options?: ReactQueryOptions<FunnelResult[]>,
) {
return useAnalyticsQuery<FunnelResult[]>(
params.id ? `funnels/${params.id}/stats` : 'funnels/stats',
params.id
? {
websiteId: params.websiteId,
startDate: params.startDate,
endDate: params.endDate,
unit: params.unit,
timezone: params.timezone,
}
: params,
options,
);
}
@@ -0,0 +1,24 @@
import type { AnalyticsParameters } from '@/lib/analytics-query';
import type { ReactQueryOptions } from '@/lib/types';
import type { GoalParameters, GoalResult } from '@/queries/sql/goals/getGoal';
import { useAnalyticsQuery } from './useAnalyticsQuery';
export function useGoalQuery(
params: AnalyticsParameters &
Partial<Omit<GoalParameters, 'startDate' | 'endDate'>> & { id?: string },
options?: ReactQueryOptions<GoalResult>,
) {
return useAnalyticsQuery<GoalResult>(
params.id ? `goals/${params.id}/stats` : 'goals/stats',
params.id
? {
websiteId: params.websiteId,
startDate: params.startDate,
endDate: params.endDate,
unit: params.unit,
timezone: params.timezone,
}
: params,
options,
);
}
@@ -0,0 +1,11 @@
import type { AnalyticsParameters } from '@/lib/analytics-query';
import type { ReactQueryOptions } from '@/lib/types';
import type { HeatmapParameters, HeatmapResult } from '@/queries/sql/heatmap/getHeatmap';
import { useAnalyticsQuery } from './useAnalyticsQuery';
export function useHeatmapQuery(
params: AnalyticsParameters & Partial<Omit<HeatmapParameters, 'startDate' | 'endDate'>>,
options?: ReactQueryOptions<HeatmapResult>,
) {
return useAnalyticsQuery<HeatmapResult>('heatmaps', params, options);
}
@@ -0,0 +1,11 @@
import type { AnalyticsParameters } from '@/lib/analytics-query';
import type { ReactQueryOptions } from '@/lib/types';
import type { JourneyParameters, JourneyResult } from '@/queries/sql/journeys/getJourney';
import { useAnalyticsQuery } from './useAnalyticsQuery';
export function useJourneyQuery(
params: AnalyticsParameters & Partial<Omit<JourneyParameters, 'startDate' | 'endDate'>> & { eventType?: number },
options?: ReactQueryOptions<JourneyResult[]>,
) {
return useAnalyticsQuery<JourneyResult[]>('journeys', params, options);
}
@@ -0,0 +1,11 @@
import type { AnalyticsParameters } from '@/lib/analytics-query';
import type { ReactQueryOptions } from '@/lib/types';
import type { PerformanceResult } from '@/queries/sql/performance/getPerformance';
import { useAnalyticsQuery } from './useAnalyticsQuery';
export function usePerformanceChartQuery(
params: AnalyticsParameters & { metric: string },
options?: ReactQueryOptions<Pick<PerformanceResult, 'chart'>>,
) {
return useAnalyticsQuery<Pick<PerformanceResult, 'chart'>>('performance/chart', params, options);
}
@@ -0,0 +1,13 @@
import type { AnalyticsParameters } from '@/lib/analytics-query';
import type { ReactQueryOptions } from '@/lib/types';
import type { PerformanceMetricsData } from '@/queries/sql/performance/getPerformanceMetrics';
import { useAnalyticsQuery } from './useAnalyticsQuery';
export function usePerformanceMetricsQuery(
params: AnalyticsParameters & { metric: string } & {
type: 'path' | 'title' | 'device' | 'browser';
},
options?: ReactQueryOptions<PerformanceMetricsData[]>,
) {
return useAnalyticsQuery<PerformanceMetricsData[]>('performance/metrics', params, options);
}
@@ -0,0 +1,11 @@
import type { AnalyticsParameters } from '@/lib/analytics-query';
import type { ReactQueryOptions } from '@/lib/types';
import type { PerformanceResult } from '@/queries/sql/performance/getPerformance';
import { useAnalyticsQuery } from './useAnalyticsQuery';
export function usePerformanceStatsQuery(
params: AnalyticsParameters,
options?: ReactQueryOptions<PerformanceResult['summary']>,
) {
return useAnalyticsQuery<PerformanceResult['summary']>('performance/stats', params, options);
}
@@ -1,15 +0,0 @@
import { useApi } from '../useApi';
import { useModified } from '../useModified';
export function useReportQuery(reportId: string) {
const { get, useQuery } = useApi();
const { modified } = useModified(`report:${reportId}`);
return useQuery({
queryKey: ['report', { reportId, modified }],
queryFn: () => {
return get(`/reports/${reportId}`);
},
enabled: !!reportId,
});
}
@@ -1,19 +0,0 @@
import type { ReactQueryOptions } from '@/lib/types';
import { useApi } from '../useApi';
import { useModified } from '../useModified';
import { usePagedQuery } from '../usePagedQuery';
export function useReportsQuery(
{ websiteId, type }: { websiteId: string; type?: string },
options?: ReactQueryOptions,
) {
const { modified } = useModified(`reports:${type}`);
const { get } = useApi();
return usePagedQuery({
queryKey: ['reports', { websiteId, type, modified }],
queryFn: async () => get('/reports', { websiteId, type }),
enabled: !!websiteId && !!type,
...options,
});
}
@@ -1,46 +0,0 @@
import { useDateParameters } from '@/components/hooks/useDateParameters';
import type { ReactQueryOptions } from '@/lib/types';
import { useApi } from '../useApi';
import { useFilterParameters } from '../useFilterParameters';
export function useResultQuery<T = any>(
type: string,
params?: Record<string, any>,
options?: ReactQueryOptions<T>,
) {
const { websiteId, ...parameters } = params;
const { post, useQuery } = useApi();
const { startDate, endDate, timezone, unit } = useDateParameters();
const filters = useFilterParameters({ includePagination: false });
return useQuery<T>({
queryKey: [
'reports',
{
type,
websiteId,
startDate,
endDate,
timezone,
unit,
...params,
...filters,
},
],
queryFn: () =>
post(`/reports/${type}`, {
websiteId,
type,
filters,
parameters: {
startDate,
endDate,
timezone,
unit,
...parameters,
},
}),
enabled: !!type,
...options,
});
}
@@ -0,0 +1,11 @@
import type { AnalyticsParameters } from '@/lib/analytics-query';
import type { ReactQueryOptions } from '@/lib/types';
import type { RetentionParameters, RetentionResult } from '@/queries/sql/retention/getRetention';
import { useAnalyticsQuery } from './useAnalyticsQuery';
export function useRetentionQuery(
params: AnalyticsParameters & Partial<Omit<RetentionParameters, 'startDate' | 'endDate'>>,
options?: ReactQueryOptions<RetentionResult[]>,
) {
return useAnalyticsQuery<RetentionResult[]>('retention', params, options);
}
@@ -0,0 +1,41 @@
import type { Report } from '@/generated/prisma/client';
import type { ReactQueryOptions } from '@/lib/types';
import { useApi } from '../useApi';
import { useModified } from '../useModified';
import { usePagedQuery } from '../usePagedQuery';
function useDefinitionsQuery(
feature: 'funnels' | 'goals',
websiteId: string,
options?: ReactQueryOptions,
) {
const { modified } = useModified(`websites:${feature}`);
const { get } = useApi();
return usePagedQuery({
queryKey: [`websites:${feature}`, { websiteId, modified }],
queryFn: params => get(`/websites/${websiteId}/${feature}`, params),
enabled: !!websiteId,
...options,
});
}
function useDefinitionQuery(feature: 'funnels' | 'goals', websiteId: string, id: string) {
const { modified } = useModified(`websites:${feature}`);
const { get, useQuery } = useApi();
return useQuery<Omit<Report, 'parameters'> & { parameters: any }>({
queryKey: [`websites:${feature}:definition`, { websiteId, id, modified }],
queryFn: () => get(`/websites/${websiteId}/${feature}/${id}`),
enabled: !!websiteId && !!id,
});
}
export function useFunnelsQuery({ websiteId }: { websiteId: string }, options?: ReactQueryOptions) {
return useDefinitionsQuery('funnels', websiteId, options);
}
export function useGoalsQuery({ websiteId }: { websiteId: string }, options?: ReactQueryOptions) {
return useDefinitionsQuery('goals', websiteId, options);
}
export function useFunnelDefinitionQuery(websiteId: string, id: string) {
return useDefinitionQuery('funnels', websiteId, id);
}
export function useGoalDefinitionQuery(websiteId: string, id: string) {
return useDefinitionQuery('goals', websiteId, id);
}
@@ -0,0 +1,10 @@
import type { AnalyticsParameters } from '@/lib/analytics-query';
import type { ReactQueryOptions } from '@/lib/types';
import { useAnalyticsQuery } from './useAnalyticsQuery';
export type UTMMetricsData = { utm: string; views: number }[];
export function useUTMMetricsQuery(
params: AnalyticsParameters & { type: string },
options?: ReactQueryOptions<UTMMetricsData>,
) {
return useAnalyticsQuery<UTMMetricsData>('utm/metrics', params, options);
}
@@ -17,8 +17,9 @@ import { useDeleteQuery } from '@/components/hooks/queries/useDeleteQuery';
import { Edit, MoreHorizontal, Trash } from '@/components/icons';
import { DialogButton } from './DialogButton';
export function ReportEditButton({
export function SavedDefinitionEditButton({
id,
websiteId,
name,
type,
title,
@@ -30,6 +31,7 @@ export function ReportEditButton({
onDelete,
}: {
id: string;
websiteId: string;
name: string;
type: string;
title?: ReactNode;
@@ -43,7 +45,7 @@ export function ReportEditButton({
const { t, labels, messages } = useMessages();
const [showEdit, setShowEdit] = useState(false);
const [showDelete, setShowDelete] = useState(false);
const { mutateAsync, touch } = useDeleteQuery(`/reports/${id}`);
const { mutateAsync, touch } = useDeleteQuery(`/websites/${websiteId}/${type}s/${id}`);
const handleAction = (id: any) => {
if (id === 'edit') {
@@ -61,7 +63,7 @@ export function ReportEditButton({
const handleDelete = async () => {
await mutateAsync(null, {
onSuccess: async () => {
touch(`reports:${type}`);
touch(`websites:${type}s`);
setShowDelete(false);
onDelete?.();
},
+39
View File
@@ -0,0 +1,39 @@
import { expect, test } from 'vitest';
import { serializeAnalyticsQuery } from './analytics-query';
import { funnelQuerySchema } from './analytics-schema';
test('structured funnel criteria survive GET URL encoding and validation', () => {
const steps = [
{
type: 'path',
value: '/pricing?plan=a&b="c"',
filters: [{ property: 'a.b', operator: 'eq', value: '日本語 + / ? #' }],
},
{ type: 'event', value: 'signup' },
];
const query = serializeAnalyticsQuery({
websiteId: 'website',
startDate: new Date(1000),
endDate: new Date(2000),
steps,
window: 60,
browser1: 'eq.Chrome',
unused: undefined,
});
const url = new URL(`https://example.org?${new URLSearchParams(query)}`);
expect(funnelQuerySchema.parse(Object.fromEntries(url.searchParams)).steps).toEqual(steps);
expect(url.searchParams.get('browser1')).toBe('eq.Chrome');
expect(url.searchParams.has('websiteId')).toBe(false);
expect(url.searchParams.has('unused')).toBe(false);
});
test('explicit dates win over default timestamps', () => {
expect(
serializeAnalyticsQuery({
startAt: 0,
endAt: 100,
startDate: new Date(1000),
endDate: new Date(2000),
}),
).toEqual({ startAt: 1000, endAt: 2000 });
});
+24
View File
@@ -0,0 +1,24 @@
export interface AnalyticsParameters {
websiteId: string;
startDate?: Date | string;
endDate?: Date | string;
startAt?: number;
endAt?: number;
unit?: string;
timezone?: string;
}
/** Encode structured GET criteria once, before URLSearchParams escapes them. */
export function serializeAnalyticsQuery(params: Record<string, any>) {
const { websiteId: _websiteId, id: _id, startDate, endDate, ...query } = params;
if (startDate != null) query.startAt = +new Date(startDate);
if (endDate != null) query.endAt = +new Date(endDate);
return Object.fromEntries(
Object.entries(query)
.filter(([, value]) => value !== undefined)
.map(([key, value]) => [
key,
typeof value === 'object' && value !== null ? JSON.stringify(value) : value,
]),
);
}
+1 -7
View File
@@ -12,13 +12,7 @@ export interface JourneyParameters {
}
export interface JourneyResult {
e1: string;
e2: string;
e3: string;
e4: string;
e5: string;
e6: string;
e7: string;
items: string[];
count: number;
}