diff --git a/src/app/(main)/boards/[boardId]/BoardComponentSelect.tsx b/src/app/(main)/boards/[boardId]/BoardComponentSelect.tsx index c1fa5fde4..1f889337a 100644 --- a/src/app/(main)/boards/[boardId]/BoardComponentSelect.tsx +++ b/src/app/(main)/boards/[boardId]/BoardComponentSelect.tsx @@ -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, }); diff --git a/src/app/(main)/websites/[websiteId]/(reports)/attribution/Attribution.tsx b/src/app/(main)/websites/[websiteId]/(reports)/attribution/Attribution.tsx index f66d261a3..8434fd327 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/attribution/Attribution.tsx +++ b/src/app/(main)/websites/[websiteId]/(reports)/attribution/Attribution.tsx @@ -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( - 'attribution', + const { data, error, isLoading } = useAttributionQuery( { websiteId, startDate, @@ -113,7 +112,7 @@ export function Attribution({ - + diff --git a/src/app/(main)/websites/[websiteId]/(reports)/breakdown/Breakdown.tsx b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/Breakdown.tsx index 0092a506e..a752281d1 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/breakdown/Breakdown.tsx +++ b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/Breakdown.tsx @@ -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( - 'breakdown', + const { data, error, isLoading } = useBreakdownQuery( { websiteId, startDate, diff --git a/src/app/(main)/websites/[websiteId]/(reports)/breakdown/BreakdownPage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/BreakdownPage.tsx index 05b914cea..323dc3372 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/breakdown/BreakdownPage.tsx +++ b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/BreakdownPage.tsx @@ -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( - 'breakdown', + const { data } = useBreakdownQuery( { websiteId, startDate, endDate, fields }, { enabled: !!fields.length }, ); diff --git a/src/app/(main)/websites/[websiteId]/(reports)/funnels/BoardFunnel.tsx b/src/app/(main)/websites/[websiteId]/(reports)/funnels/BoardFunnel.tsx index 4905170df..5e9e62d5d 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/funnels/BoardFunnel.tsx +++ b/src/app/(main)/websites/[websiteId]/(reports)/funnels/BoardFunnel.tsx @@ -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 ( diff --git a/src/app/(main)/websites/[websiteId]/(reports)/funnels/Funnel.tsx b/src/app/(main)/websites/[websiteId]/(reports)/funnels/Funnel.tsx index fe6fc0132..19f44b70c 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/funnels/Funnel.tsx +++ b/src/app/(main)/websites/[websiteId]/(reports)/funnels/Funnel.tsx @@ -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>(type, { + const { data, error, isLoading } = useFunnelQuery({ websiteId, + id, ...parameters, }); @@ -41,7 +42,8 @@ export function Funnel({ id, name, type, parameters, websiteId, allowEdit = true {allowEdit && !isSharePage && ( - {({ close }) => } - + )} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelEditForm.tsx b/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelEditForm.tsx index 3ab6dac7d..c7ec43ade 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelEditForm.tsx +++ b/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelEditForm.tsx @@ -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', + }, }} > diff --git a/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelsPage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelsPage.tsx index a56917b76..7d5f2b43b 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelsPage.tsx +++ b/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelsPage.tsx @@ -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(); diff --git a/src/app/(main)/websites/[websiteId]/(reports)/goals/BoardGoal.tsx b/src/app/(main)/websites/[websiteId]/(reports)/goals/BoardGoal.tsx index c5861213c..519848c2a 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/goals/BoardGoal.tsx +++ b/src/app/(main)/websites/[websiteId]/(reports)/goals/BoardGoal.tsx @@ -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 ( diff --git a/src/app/(main)/websites/[websiteId]/(reports)/goals/Goal.tsx b/src/app/(main)/websites/[websiteId]/(reports)/goals/Goal.tsx index 5d0293d9d..6fe42dbe7 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/goals/Goal.tsx +++ b/src/app/(main)/websites/[websiteId]/(reports)/goals/Goal.tsx @@ -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(type, { + const { data, error, isLoading, isFetching } = useGoalQuery({ websiteId, + id, startDate, endDate, ...parameters, @@ -57,7 +58,8 @@ export function Goal({ {allowEdit && !isSharePage && ( - {({ close }) => } - + )} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalEditForm.tsx b/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalEditForm.tsx index 78877f59e..808057411 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalEditForm.tsx +++ b/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalEditForm.tsx @@ -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) => { 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({ {({ field }) => { - return ; + return ( + + ); }} @@ -86,7 +94,14 @@ export function GoalEditForm({ {({ field }) => { - return ; + return ( + + ); }} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalsPage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalsPage.tsx index aa399b92b..51c54a2c2 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalsPage.tsx +++ b/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalsPage.tsx @@ -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(); diff --git a/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.tsx b/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.tsx index 15375066c..d579dbd3c 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.tsx +++ b/src/app/(main)/websites/[websiteId]/(reports)/heatmaps/Heatmap.tsx @@ -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('heatmap', { + } = useHeatmapQuery({ websiteId, mode, }); @@ -74,8 +74,7 @@ export function Heatmap({ websiteId, urlPath, onUrlPathChange, mode, search }: H data: detailData, isLoading: isDetailLoading, isFetching: isDetailFetching, - } = useResultQuery( - 'heatmap', + } = useHeatmapQuery( { websiteId, urlPath: urlPath || undefined, @@ -134,7 +133,12 @@ export function Heatmap({ websiteId, urlPath, onUrlPathChange, mode, search }: H {isPhone ? ( - + Selected page @@ -1005,13 +1009,7 @@ function SnapshotPreview({ return ; } -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); diff --git a/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.tsx b/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.tsx index 700f51ac2..fb9a3cdf1 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.tsx +++ b/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.tsx @@ -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('journey', { + const { data, error, isLoading } = useJourneyQuery({ websiteId, steps, startStep, endStep, - view, eventType: EVENT_TYPES[view], }); diff --git a/src/app/(main)/websites/[websiteId]/(reports)/performance/Performance.tsx b/src/app/(main)/websites/[websiteId]/(reports)/performance/Performance.tsx index db8d64beb..2c9a7c0a6 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/performance/Performance.tsx +++ b/src/app/(main)/websites/[websiteId]/(reports)/performance/Performance.tsx @@ -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('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 ))} - - {data && ( - - - {METRICS.map(metric => ( - formatMetricValue(metric, n)} - onClick={() => setSelectedMetric(metric)} - selected={selectedMetric === metric} - /> - ))} - - - - - {METRIC_LABELS[selectedMetric]} - - - {t(labels.sampleSize)}: {formatLongNumber(data.summary?.count || 0)} - - + + + + {METRICS.map(metric => ( + formatMetricValue(metric, n)} + onClick={() => setSelectedMetric(metric)} + selected={selectedMetric === metric} + /> + ))} + + + + + + + {METRIC_LABELS[selectedMetric]} + + + {t(labels.sampleSize)}: {formatLongNumber(stats.data?.count || 0)} + - { - 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" + + { + 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" + /> + + + + + + + {t(labels.pages)} + + {t(labels.path)} + {t(labels.pageTitle)} + + + {label}} /> - - - - - - {t(labels.pages)} - - {t(labels.path)} - {t(labels.pageTitle)} - - - - 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 }) => {label}} - /> - - - - 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) => } - /> - - - - - - {t(labels.environment)} - - {t(labels.device)} - {t(labels.browser)} - - - - 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) => } - /> - - - - 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) => } - /> - - - - - - )} - + + + } + /> + + + + + + {t(labels.environment)} + + {t(labels.device)} + {t(labels.browser)} + + + } + /> + + + } + /> + + + + + ); } + +function PerformanceMetricsTable({ + params, + percentile, + metricLabel, + formatCount, + renderLabel, +}: { + params: Parameters[0]; + percentile: 'p50' | 'p75' | 'p95'; + metricLabel: string; + formatCount: (value: number) => string; + renderLabel: (row: any) => React.ReactNode; +}) { + const { data, isLoading, error } = usePerformanceMetricsQuery(params); + return ( + + Number(row[percentile]) > 0) + .slice(0, 20) + .map(row => ({ label: row.name, count: Number(row[percentile]), percent: 0 }))} + renderLabel={renderLabel} + /> + + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/retention/Retention.tsx b/src/app/(main)/websites/[websiteId]/(reports)/retention/Retention.tsx index 127618ad9..583b951a3 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/retention/Retention.tsx +++ b/src/app/(main)/websites/[websiteId]/(reports)/retention/Retention.tsx @@ -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, diff --git a/src/app/(main)/websites/[websiteId]/(reports)/utm/BoardUTM.tsx b/src/app/(main)/websites/[websiteId]/(reports)/utm/BoardUTM.tsx index 5444e5e71..c9cdebe8d 100644 --- a/src/app/(main)/websites/[websiteId]/(reports)/utm/BoardUTM.tsx +++ b/src/app/(main)/websites/[websiteId]/(reports)/utm/BoardUTM.tsx @@ -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('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({
+ {UTM_PARAMS.map(param => ( + + ))} + + ); +} + +function UTMDimension({ websiteId, startDate, endDate, param }: UTMProps & { param: string }) { const { t, labels } = useMessages(); - const { data, error, isLoading } = useResultQuery('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 ( - - {data && ( - - {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 ( - - - - - {param.replace(/^utm_/, '')} - - ({ - label: utm, - count: views, - percent: (views / total) * 100, - }))} - /> - - - - - - - ); - })} - - )} + + + + + + {param.replace(/^utm_/, '')} + + ({ + label: utm, + count: views, + percent: (views / total) * 100, + }))} + /> + + + + + + ); } diff --git a/src/app/api/websites/[websiteId]/utm/metrics/route.ts b/src/app/api/websites/[websiteId]/utm/metrics/route.ts index 774590ac4..42571789c 100644 --- a/src/app/api/websites/[websiteId]/utm/metrics/route.ts +++ b/src/app/api/websites/[websiteId]/utm/metrics/route.ts @@ -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)); } diff --git a/src/components/hooks/index.ts b/src/components/hooks/index.ts index cc6e70571..2dd752ec0 100644 --- a/src/components/hooks/index.ts +++ b/src/components/hooks/index.ts @@ -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'; diff --git a/src/components/hooks/queries/useAnalyticsQuery.test.ts b/src/components/hooks/queries/useAnalyticsQuery.test.ts new file mode 100644 index 000000000..b0e592636 --- /dev/null +++ b/src/components/hooks/queries/useAnalyticsQuery.test.ts @@ -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, + }, + ]); +}); diff --git a/src/components/hooks/queries/useAnalyticsQuery.ts b/src/components/hooks/queries/useAnalyticsQuery.ts new file mode 100644 index 000000000..ac803b59c --- /dev/null +++ b/src/components/hooks/queries/useAnalyticsQuery.ts @@ -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( + path: string, + params: AnalyticsParameters & Record, + options?: ReactQueryOptions, +) { + 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({ + queryKey: [`websites:${path}`, { websiteId: params.websiteId, ...query, modified }], + queryFn: () => get(`/websites/${params.websiteId}/${path}`, query), + enabled: !!params.websiteId, + ...options, + }); +} diff --git a/src/components/hooks/queries/useAttributionQuery.ts b/src/components/hooks/queries/useAttributionQuery.ts new file mode 100644 index 000000000..60dfb2f4b --- /dev/null +++ b/src/components/hooks/queries/useAttributionQuery.ts @@ -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>, + options?: ReactQueryOptions, +) { + return useAnalyticsQuery('attribution', params, options); +} diff --git a/src/components/hooks/queries/useBreakdownQuery.ts b/src/components/hooks/queries/useBreakdownQuery.ts new file mode 100644 index 000000000..33cfd178c --- /dev/null +++ b/src/components/hooks/queries/useBreakdownQuery.ts @@ -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>, + options?: ReactQueryOptions, +) { + return useAnalyticsQuery('breakdown', params, options); +} diff --git a/src/components/hooks/queries/useFunnelQuery.ts b/src/components/hooks/queries/useFunnelQuery.ts new file mode 100644 index 000000000..8d363b9d9 --- /dev/null +++ b/src/components/hooks/queries/useFunnelQuery.ts @@ -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> & { id?: string }, + options?: ReactQueryOptions, +) { + return useAnalyticsQuery( + 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, + ); +} diff --git a/src/components/hooks/queries/useGoalQuery.ts b/src/components/hooks/queries/useGoalQuery.ts new file mode 100644 index 000000000..dc55defb9 --- /dev/null +++ b/src/components/hooks/queries/useGoalQuery.ts @@ -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> & { id?: string }, + options?: ReactQueryOptions, +) { + return useAnalyticsQuery( + 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, + ); +} diff --git a/src/components/hooks/queries/useHeatmapQuery.ts b/src/components/hooks/queries/useHeatmapQuery.ts new file mode 100644 index 000000000..cb6c19eb9 --- /dev/null +++ b/src/components/hooks/queries/useHeatmapQuery.ts @@ -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>, + options?: ReactQueryOptions, +) { + return useAnalyticsQuery('heatmaps', params, options); +} diff --git a/src/components/hooks/queries/useJourneyQuery.ts b/src/components/hooks/queries/useJourneyQuery.ts new file mode 100644 index 000000000..f17aea78a --- /dev/null +++ b/src/components/hooks/queries/useJourneyQuery.ts @@ -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> & { eventType?: number }, + options?: ReactQueryOptions, +) { + return useAnalyticsQuery('journeys', params, options); +} diff --git a/src/components/hooks/queries/usePerformanceChartQuery.ts b/src/components/hooks/queries/usePerformanceChartQuery.ts new file mode 100644 index 000000000..072dcf26d --- /dev/null +++ b/src/components/hooks/queries/usePerformanceChartQuery.ts @@ -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>, +) { + return useAnalyticsQuery>('performance/chart', params, options); +} diff --git a/src/components/hooks/queries/usePerformanceMetricsQuery.ts b/src/components/hooks/queries/usePerformanceMetricsQuery.ts new file mode 100644 index 000000000..9d8ad0a12 --- /dev/null +++ b/src/components/hooks/queries/usePerformanceMetricsQuery.ts @@ -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, +) { + return useAnalyticsQuery('performance/metrics', params, options); +} diff --git a/src/components/hooks/queries/usePerformanceStatsQuery.ts b/src/components/hooks/queries/usePerformanceStatsQuery.ts new file mode 100644 index 000000000..17922e807 --- /dev/null +++ b/src/components/hooks/queries/usePerformanceStatsQuery.ts @@ -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, +) { + return useAnalyticsQuery('performance/stats', params, options); +} diff --git a/src/components/hooks/queries/useReportQuery.ts b/src/components/hooks/queries/useReportQuery.ts deleted file mode 100644 index 6973e2d34..000000000 --- a/src/components/hooks/queries/useReportQuery.ts +++ /dev/null @@ -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, - }); -} diff --git a/src/components/hooks/queries/useReportsQuery.ts b/src/components/hooks/queries/useReportsQuery.ts deleted file mode 100644 index ba1bdd4d5..000000000 --- a/src/components/hooks/queries/useReportsQuery.ts +++ /dev/null @@ -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, - }); -} diff --git a/src/components/hooks/queries/useResultQuery.ts b/src/components/hooks/queries/useResultQuery.ts deleted file mode 100644 index 785b05694..000000000 --- a/src/components/hooks/queries/useResultQuery.ts +++ /dev/null @@ -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( - type: string, - params?: Record, - options?: ReactQueryOptions, -) { - const { websiteId, ...parameters } = params; - const { post, useQuery } = useApi(); - const { startDate, endDate, timezone, unit } = useDateParameters(); - const filters = useFilterParameters({ includePagination: false }); - - return useQuery({ - 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, - }); -} diff --git a/src/components/hooks/queries/useRetentionQuery.ts b/src/components/hooks/queries/useRetentionQuery.ts new file mode 100644 index 000000000..f7d74fdc8 --- /dev/null +++ b/src/components/hooks/queries/useRetentionQuery.ts @@ -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>, + options?: ReactQueryOptions, +) { + return useAnalyticsQuery('retention', params, options); +} diff --git a/src/components/hooks/queries/useSavedDefinitionsQuery.ts b/src/components/hooks/queries/useSavedDefinitionsQuery.ts new file mode 100644 index 000000000..55e8462df --- /dev/null +++ b/src/components/hooks/queries/useSavedDefinitionsQuery.ts @@ -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 & { 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); +} diff --git a/src/components/hooks/queries/useUTMMetricsQuery.ts b/src/components/hooks/queries/useUTMMetricsQuery.ts new file mode 100644 index 000000000..445467fc3 --- /dev/null +++ b/src/components/hooks/queries/useUTMMetricsQuery.ts @@ -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, +) { + return useAnalyticsQuery('utm/metrics', params, options); +} diff --git a/src/components/input/ReportEditButton.tsx b/src/components/input/SavedDefinitionEditButton.tsx similarity index 93% rename from src/components/input/ReportEditButton.tsx rename to src/components/input/SavedDefinitionEditButton.tsx index 3d245b151..eb75616da 100644 --- a/src/components/input/ReportEditButton.tsx +++ b/src/components/input/SavedDefinitionEditButton.tsx @@ -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?.(); }, diff --git a/src/lib/analytics-query.test.ts b/src/lib/analytics-query.test.ts new file mode 100644 index 000000000..e3f9cd0fe --- /dev/null +++ b/src/lib/analytics-query.test.ts @@ -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 }); +}); diff --git a/src/lib/analytics-query.ts b/src/lib/analytics-query.ts new file mode 100644 index 000000000..c091404a0 --- /dev/null +++ b/src/lib/analytics-query.ts @@ -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) { + 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, + ]), + ); +} diff --git a/src/queries/sql/journeys/getJourney.ts b/src/queries/sql/journeys/getJourney.ts index 66e8c2714..992874a59 100644 --- a/src/queries/sql/journeys/getJourney.ts +++ b/src/queries/sql/journeys/getJourney.ts @@ -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; }