diff --git a/db/clickhouse/migrations/11_add_event_data_pivot.sql b/db/clickhouse/migrations/11_add_event_data_pivot.sql deleted file mode 100644 index 15ba1a7e8..000000000 --- a/db/clickhouse/migrations/11_add_event_data_pivot.sql +++ /dev/null @@ -1,56 +0,0 @@ -CREATE TABLE IF NOT EXISTS umami.event_data_pivot -( - website_id UUID, - session_id UUID, - event_id UUID, - event_name LowCardinality(String), - url_path String, - created_at DateTime('UTC'), - property_keys AggregateFunction(groupArray, String), - property_values AggregateFunction(groupArray, String), - property_types AggregateFunction(groupArray, UInt32) -) -ENGINE = AggregatingMergeTree() -PARTITION BY toYYYYMM(created_at) -ORDER BY (website_id, event_name, created_at, event_id) -SETTINGS index_granularity = 8192; - -CREATE MATERIALIZED VIEW IF NOT EXISTS umami.event_data_pivot_mv -TO umami.event_data_pivot -AS SELECT - website_id, - session_id, - event_id, - event_name, - url_path, - created_at, - groupArrayState(data_key) AS property_keys, - groupArrayState(multiIf( - data_type IN (1, 3, 5), ifNull(string_value, ''), - data_type = 2, toString(ifNull(number_value, 0)), - data_type = 4, toString(ifNull(date_value, toDateTime(0))), - '' - )) AS property_values, - groupArrayState(data_type) AS property_types -FROM umami.event_data -GROUP BY website_id, session_id, event_id, event_name, url_path, created_at; - --- Backfill existing data -INSERT INTO umami.event_data_pivot -SELECT - website_id, - session_id, - event_id, - event_name, - url_path, - created_at, - groupArrayState(data_key), - groupArrayState(multiIf( - data_type IN (1, 3, 5), ifNull(string_value, ''), - data_type = 2, toString(ifNull(number_value, 0)), - data_type = 4, toString(ifNull(date_value, toDateTime(0))), - '' - )), - groupArrayState(data_type) -FROM umami.event_data -GROUP BY website_id, session_id, event_id, event_name, url_path, created_at; diff --git a/db/clickhouse/migrations/11_add_event_session_data_pivot.sql b/db/clickhouse/migrations/11_add_event_session_data_pivot.sql new file mode 100644 index 000000000..2d44cf0bf --- /dev/null +++ b/db/clickhouse/migrations/11_add_event_session_data_pivot.sql @@ -0,0 +1,110 @@ +CREATE TABLE IF NOT EXISTS umami.event_data_pivot +( + website_id UUID, + session_id UUID, + event_id UUID, + event_name LowCardinality(String), + url_path String, + created_at DateTime('UTC'), + property_keys AggregateFunction(groupArray, String), + property_values AggregateFunction(groupArray, String), + property_types AggregateFunction(groupArray, UInt32) +) +ENGINE = AggregatingMergeTree() +PARTITION BY toYYYYMM(created_at) +ORDER BY (website_id, event_name, created_at, event_id) +SETTINGS index_granularity = 8192; + +CREATE MATERIALIZED VIEW IF NOT EXISTS umami.event_data_pivot_mv +TO umami.event_data_pivot +AS SELECT + website_id, + session_id, + event_id, + event_name, + url_path, + created_at, + groupArrayState(data_key) AS property_keys, + groupArrayState(multiIf( + data_type IN (1, 3, 5), ifNull(string_value, ''), + data_type = 2, toString(ifNull(number_value, 0)), + data_type = 4, toString(ifNull(date_value, toDateTime(0))), + '' + )) AS property_values, + groupArrayState(data_type) AS property_types +FROM umami.event_data +GROUP BY website_id, session_id, event_id, event_name, url_path, created_at; + +-- Backfill existing event data +INSERT INTO umami.event_data_pivot +SELECT + website_id, + session_id, + event_id, + event_name, + url_path, + created_at, + groupArrayState(data_key), + groupArrayState(multiIf( + data_type IN (1, 3, 5), ifNull(string_value, ''), + data_type = 2, toString(ifNull(number_value, 0)), + data_type = 4, toString(ifNull(date_value, toDateTime(0))), + '' + )), + groupArrayState(data_type) +FROM umami.event_data +GROUP BY website_id, session_id, event_id, event_name, url_path, created_at; + +CREATE TABLE IF NOT EXISTS umami.session_data_pivot +( + website_id UUID, + session_id UUID, + distinct_id String, + created_year_month UInt32, + created_at AggregateFunction(max, DateTime('UTC')), + property_keys AggregateFunction(groupArray, String), + property_values AggregateFunction(groupArray, String), + property_types AggregateFunction(groupArray, UInt32) +) +ENGINE = AggregatingMergeTree() +PARTITION BY created_year_month +ORDER BY (website_id, session_id, distinct_id) +SETTINGS index_granularity = 8192; + +CREATE MATERIALIZED VIEW IF NOT EXISTS umami.session_data_pivot_mv +TO umami.session_data_pivot +AS SELECT + website_id, + session_id, + ifNull(distinct_id, '') AS distinct_id, + toYYYYMM(max(session_data.created_at)) AS created_year_month, + maxState(session_data.created_at) AS created_at, + groupArrayState(data_key) AS property_keys, + groupArrayState(multiIf( + data_type IN (1, 3, 5), ifNull(string_value, ''), + data_type = 2, toString(ifNull(number_value, 0)), + data_type = 4, toString(ifNull(date_value, toDateTime(0))), + '' + )) AS property_values, + groupArrayState(data_type) AS property_types +FROM umami.session_data +GROUP BY website_id, session_id, distinct_id; + +-- Backfill existing session data +INSERT INTO umami.session_data_pivot +SELECT + website_id, + session_id, + ifNull(distinct_id, '') AS distinct_id, + toYYYYMM(max(session_data.created_at)) AS created_year_month, + maxState(session_data.created_at) AS created_at, + groupArrayState(data_key), + groupArrayState(multiIf( + data_type IN (1, 3, 5), ifNull(string_value, ''), + data_type = 2, toString(ifNull(number_value, 0)), + data_type = 4, toString(ifNull(date_value, toDateTime(0))), + '' + )), + groupArrayState(data_type) +FROM umami.session_data +GROUP BY website_id, session_id, distinct_id; diff --git a/db/clickhouse/schema.sql b/db/clickhouse/schema.sql index 0540c97b2..c5c8dce67 100644 --- a/db/clickhouse/schema.sql +++ b/db/clickhouse/schema.sql @@ -344,3 +344,39 @@ AS SELECT groupArrayState(data_type) AS property_types FROM umami.event_data GROUP BY website_id, session_id, event_id, event_name, url_path, created_at; + +-- Create session_data_pivot +CREATE TABLE IF NOT EXISTS umami.session_data_pivot +( + website_id UUID, + session_id UUID, + distinct_id String, + created_year_month UInt32, + created_at AggregateFunction(max, DateTime('UTC')), + property_keys AggregateFunction(groupArray, String), + property_values AggregateFunction(groupArray, String), + property_types AggregateFunction(groupArray, UInt32) +) +ENGINE = AggregatingMergeTree() +PARTITION BY created_year_month +ORDER BY (website_id, session_id, distinct_id) +SETTINGS index_granularity = 8192; + +CREATE MATERIALIZED VIEW IF NOT EXISTS umami.session_data_pivot_mv +TO umami.session_data_pivot +AS SELECT + website_id, + session_id, + ifNull(distinct_id, '') AS distinct_id, + toYYYYMM(max(session_data.created_at)) AS created_year_month, + maxState(session_data.created_at) AS created_at, + groupArrayState(data_key) AS property_keys, + groupArrayState(multiIf( + data_type IN (1, 3, 5), ifNull(string_value, ''), + data_type = 2, toString(ifNull(number_value, 0)), + data_type = 4, toString(ifNull(date_value, toDateTime(0))), + '' + )) AS property_values, + groupArrayState(data_type) AS property_types +FROM umami.session_data +GROUP BY website_id, session_id, distinct_id; diff --git a/src/app/(main)/websites/[websiteId]/event-data/EventDataFilterButton.tsx b/src/app/(main)/websites/[websiteId]/event-data/EventDataFilterButton.tsx deleted file mode 100644 index 4f39b41e2..000000000 --- a/src/app/(main)/websites/[websiteId]/event-data/EventDataFilterButton.tsx +++ /dev/null @@ -1,34 +0,0 @@ -'use client'; -import { useMessages } from '@/components/hooks'; -import { ListFilter } from '@/components/icons'; -import { DialogButton } from '@/components/input/DialogButton'; -import type { EventPropertyFilter } from '@/lib/types'; -import { EventDataFilterEditForm } from './EventDataFilterEditForm'; - -export function EventDataFilterButton({ - websiteId, - eventName, - eventFilters, - onApply, -}: { - websiteId: string; - eventName: string; - eventFilters: EventPropertyFilter[]; - onApply: (filters: EventPropertyFilter[]) => void; -}) { - const { t, labels } = useMessages(); - - return ( - } label={t(labels.propertyFilter)} variant="outline"> - {({ close }) => ( - - )} - - ); -} diff --git a/src/app/(main)/websites/[websiteId]/event-data/EventDataPivotTable.tsx b/src/app/(main)/websites/[websiteId]/event-data/EventDataPivotTable.tsx index fa91e56bd..900d5b20e 100644 --- a/src/app/(main)/websites/[websiteId]/event-data/EventDataPivotTable.tsx +++ b/src/app/(main)/websites/[websiteId]/event-data/EventDataPivotTable.tsx @@ -70,6 +70,14 @@ export function EventDataPivotTable({ router.push(updateParams({ page })); }; + const renderTruncatedText = (value: string) => ( + + + {value} + + + ); + return ( - {(row: any) => ( - - {row.urlPath} - - )} + {(row: any) => renderTruncatedText(row.urlPath ?? '')} {propertyKeys.map(key => ( - {(row: any) => ( - - {row[key] ?? ''} - - )} + {(row: any) => renderTruncatedText(row[key] ?? '')} ))} @@ -125,19 +125,11 @@ export function EventDataPivotTable({ )} - {(row: any) => ( - - {row.urlPath} - - )} + {(row: any) => renderTruncatedText(row.urlPath ?? '')} {propertyKeys.map(key => ( - {(row: any) => ( - - {row[key] ?? ''} - - )} + {(row: any) => renderTruncatedText(row[key] ?? '')} ))} diff --git a/src/app/(main)/websites/[websiteId]/events/EventProperties.tsx b/src/app/(main)/websites/[websiteId]/events/EventProperties.tsx index 6764f4ebf..b7b31b9c7 100644 --- a/src/app/(main)/websites/[websiteId]/events/EventProperties.tsx +++ b/src/app/(main)/websites/[websiteId]/events/EventProperties.tsx @@ -5,18 +5,18 @@ import { LoadingPanel } from '@/components/common/LoadingPanel'; import { Panel } from '@/components/common/Panel'; import { useEventDataPropertiesQuery, useMessages } from '@/components/hooks'; import { DATA_TYPE } from '@/lib/constants'; -import type { EventPropertyFilter } from '@/lib/types'; -import { EventDataDateChart } from '../event-data/EventDataDateChart'; -import { EventDataFilterBar } from '../event-data/EventDataFilterBar'; -import { EventDataFilterButton } from '../event-data/EventDataFilterButton'; -import { EventDataNumericChart } from '../event-data/EventDataNumericChart'; +import type { PropertyFilter } from '@/lib/types'; +import { PropertyChart } from '@/components/property-data/PropertyChart'; +import { PropertyDateChart } from '@/components/property-data/PropertyDateChart'; +import { PropertyFilterBar } from '@/components/property-data/PropertyFilterBar'; +import { PropertyFilterButton } from '@/components/property-data/PropertyFilterButton'; +import { PropertyNumericChart } from '@/components/property-data/PropertyNumericChart'; import { EventDataPivotTable } from '../event-data/EventDataPivotTable'; -import { EventDataPropertyChart } from '../event-data/EventDataPropertyChart'; export function EventProperties({ websiteId }: { websiteId: string }) { const [eventName, setEventName] = useState(''); const [propertyName, setPropertyName] = useState(''); - const [eventFilters, setEventFilters] = useState([]); + const [propertyFilters, setPropertyFilters] = useState([]); const { t, labels } = useMessages(); const { data, isLoading, isFetching, error } = useEventDataPropertiesQuery(websiteId); @@ -34,10 +34,8 @@ export function EventProperties({ websiteId }: { websiteId: string }) { return data .filter((field: { eventName: string }) => field.eventName === eventName) .filter((field: { propertyName: string; dataType: number }) => { - const key = `${field.propertyName}:${field.dataType}`; - - if (seen.has(key)) return false; - seen.add(key); + if (seen.has(field.propertyName)) return false; + seen.add(field.propertyName); return true; }); @@ -52,7 +50,7 @@ export function EventProperties({ websiteId }: { websiteId: string }) { const handleEventChange = (value: string) => { setEventName(value); setPropertyName(''); - setEventFilters([]); + setPropertyFilters([]); }; return ( @@ -117,52 +115,57 @@ export function EventProperties({ websiteId }: { websiteId: string }) { marginTop={{ base: '2', md: '0' }} style={{ minWidth: 0 }} > - )} )} {eventName && ( - + )} {eventName && propertyName && ( {(selectedProperty?.dataType === DATA_TYPE.string || selectedProperty?.dataType === DATA_TYPE.boolean) && ( - )} {selectedProperty?.dataType === DATA_TYPE.number && ( - )} {selectedProperty?.dataType === DATA_TYPE.date && ( - )} {selectedProperty?.dataType === DATA_TYPE.array && ( - )} @@ -173,7 +176,7 @@ export function EventProperties({ websiteId }: { websiteId: string }) { )} diff --git a/src/app/(main)/websites/[websiteId]/session-data/HighCardinalityPropertyChart.tsx b/src/app/(main)/websites/[websiteId]/session-data/HighCardinalityPropertyChart.tsx new file mode 100644 index 000000000..1dd39d757 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/session-data/HighCardinalityPropertyChart.tsx @@ -0,0 +1,133 @@ +'use client'; +import { PieChart } from '@/components/charts/PieChart'; +import { Empty } from '@/components/common/Empty'; +import { useMessages, useMobile } from '@/components/hooks'; +import { ListTable } from '@/components/metrics/ListTable'; +import { CHART_COLORS } from '@/lib/constants'; +import { formatLongNumber, formatShortTime } from '@/lib/format'; +import type { PropertyLeaderboardRow } from '@/lib/types'; +import { Column, DataColumn, DataTable, Grid, Row, Text } from '@umami/react-zen'; +import { useMemo } from 'react'; + +export function HighCardinalityPropertyChart({ rows }: { rows: PropertyLeaderboardRow[] }) { + const { t, labels } = useMessages(); + const { isPhone } = useMobile(); + const activeTable = useMemo(() => rows.filter(row => row.activity > 0).slice(0, 10), [rows]); + const userMix = useMemo(() => { + const totalNew = rows.reduce((sum, row) => sum + row.newSessions, 0); + const totalReturning = rows.reduce((sum, row) => sum + row.returningSessions, 0); + const total = totalNew + totalReturning; + + return { + newCount: totalNew, + returningCount: totalReturning, + newRate: total ? (totalNew / total) * 100 : 0, + returningRate: total ? (totalReturning / total) * 100 : 0, + }; + }, [rows]); + const sessionTypeTable = useMemo(() => { + return [ + { + label: 'New', + count: userMix.newCount, + percent: userMix.newRate, + }, + { + label: 'Returning', + count: userMix.returningCount, + percent: userMix.returningRate, + }, + ].filter(row => row.count > 0); + }, [userMix]); + const sessionTypeChartData = useMemo(() => { + if (!sessionTypeTable.length) return null; + + return { + labels: sessionTypeTable.map(row => row.label), + datasets: [ + { + data: sessionTypeTable.map(row => row.count), + backgroundColor: [CHART_COLORS[0], CHART_COLORS[1]], + borderWidth: 0, + }, + ], + }; + }, [sessionTypeTable]); + + return ( + + + + + {activeTable.length === 0 ? ( + + ) : isPhone ? ( + + + + {row => ( + + + {row.label} + + + )} + + + + ) : ( + + + + + {row => ( + + + {row.label} + + + )} + + + {row => formatLongNumber(row.sessions)} + + + {row => formatLongNumber(row.visits)} + + + {row => formatLongNumber(row.views)} + + + {row => formatLongNumber(row.events)} + + + {row => + formatShortTime(Math.abs(~~Number(row.totaltime)), ['h', 'm', 's'], ' ') + } + + + + + )} + + + + + + + {sessionTypeChartData && } + + + + ); +} diff --git a/src/app/(main)/websites/[websiteId]/session-data/SessionDataPivotTable.tsx b/src/app/(main)/websites/[websiteId]/session-data/SessionDataPivotTable.tsx new file mode 100644 index 000000000..c7c3716bc --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/session-data/SessionDataPivotTable.tsx @@ -0,0 +1,143 @@ +'use client'; +import { Column, DataColumn, DataTable, Row, Text } from '@umami/react-zen'; +import { useMemo } from 'react'; +import { Avatar } from '@/components/common/Avatar'; +import { DateDistance } from '@/components/common/DateDistance'; +import { Empty } from '@/components/common/Empty'; +import Link from '@/components/common/Link'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { Pager } from '@/components/common/Pager'; +import { + useMessages, + useMobile, + useNavigation, + useSessionDataPivotQuery, +} from '@/components/hooks'; +import type { PropertyFilter } from '@/lib/types'; + +export function SessionDataPivotTable({ + websiteId, + propertyName, + propertyKeys: providedPropertyKeys, + propertyFilters = [], +}: { + websiteId: string; + propertyName: string; + propertyKeys?: string[]; + propertyFilters?: PropertyFilter[]; +}) { + const { t, labels } = useMessages(); + const { router, updateParams } = useNavigation(); + const { isMobile } = useMobile(); + + const pivotQuery = useSessionDataPivotQuery(websiteId, propertyName, propertyFilters); + + const propertyKeys = useMemo(() => { + if (providedPropertyKeys) return providedPropertyKeys; + return []; + }, [providedPropertyKeys]); + + const tableMinWidth = useMemo(() => `${72 + 220 + 180 + propertyKeys.length * 160}px`, [propertyKeys.length]); + + const tableData = useMemo(() => { + if (!pivotQuery.data?.data) return []; + + return pivotQuery.data.data.map( + (row: { + sessionId: string; + distinctId: string; + createdAt: string; + propertyKeys: string[]; + propertyValues: string[]; + }) => { + const flat: Record = { + sessionId: row.sessionId, + distinctId: row.distinctId, + createdAt: row.createdAt, + }; + + row.propertyKeys.forEach((key, i) => { + flat[key] = row.propertyValues[i] ?? ''; + }); + + return flat; + }, + ); + }, [pivotQuery.data]); + + const tableQuery = useMemo( + () => (pivotQuery.data ? { ...pivotQuery.data, data: tableData } : undefined), + [pivotQuery.data, tableData], + ); + + const showPager = tableQuery && tableQuery.count > tableQuery.pageSize; + + const handlePageChange = (page: number) => { + router.push(updateParams({ page })); + }; + + const renderTruncatedText = (value: string) => ( + + + {value} + + + ); + + const renderTable = (displayMode: 'cards' | 'table') => ( + + + {(row: any) => ( + + + + )} + + {propertyKeys.map(key => ( + + {(row: any) => renderTruncatedText(row[key] ?? '')} + + ))} + + {(row: any) => renderTruncatedText(row.distinctId || '')} + + + {(row: any) => } + + + ); + + return ( + + } + > + + {isMobile ? ( + renderTable('cards') + ) : ( +
+
+ {renderTable('table')} +
+
+ )} + {showPager && tableQuery && ( + + + + )} +
+
+
+ ); +} diff --git a/src/app/(main)/websites/[websiteId]/session-data/SessionStringPropertyView.tsx b/src/app/(main)/websites/[websiteId]/session-data/SessionStringPropertyView.tsx new file mode 100644 index 000000000..e8a623220 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/session-data/SessionStringPropertyView.tsx @@ -0,0 +1,53 @@ +'use client'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { useSessionDataStatsQuery } from '@/components/hooks'; +import type { PropertyFilter } from '@/lib/types'; +import { PropertyChart } from '@/components/property-data/PropertyChart'; +import { HighCardinalityPropertyChart } from './HighCardinalityPropertyChart'; + +const HIGH_CARDINALITY_NAME_PATTERN = + /(email|e-mail|name|(^|_)(id|uuid|token)($|_)|distinct_id|user_id|customer_id|client_id|member_id)/i; + +function isHighCardinalityProperty(propertyName: string, totalValues: number, uniqueValues: number) { + if (!totalValues) return false; + + const uniquenessRate = uniqueValues / totalValues; + const hasHighCardinalityName = HIGH_CARDINALITY_NAME_PATTERN.test(propertyName); + + return totalValues >= 20 && (hasHighCardinalityName || uniqueValues >= 20 || uniquenessRate >= 0.6); +} + +export function SessionStringPropertyView({ + websiteId, + propertyName, + propertyFilters = [], +}: { + websiteId: string; + propertyName: string; + propertyFilters?: PropertyFilter[]; +}) { + const statsQuery = useSessionDataStatsQuery(websiteId, propertyName, propertyFilters); + const rows = statsQuery.data ?? []; + const totalValues = rows.reduce((sum, row) => sum + row.sessions, 0); + const uniqueValues = rows.length; + const isHighCardinality = isHighCardinalityProperty( + propertyName, + totalValues, + uniqueValues, + ); + + return ( + + {isHighCardinality ? ( + + ) : ( + + )} + + ); +} diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionProperties.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionProperties.tsx index f6377edb3..13f7b1c60 100644 --- a/src/app/(main)/websites/[websiteId]/sessions/SessionProperties.tsx +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionProperties.tsx @@ -1,21 +1,58 @@ -import { Column, Grid, ListItem, Select } from '@umami/react-zen'; +import { Column, ComboBox, Grid, Label, ListItem, Row } from '@umami/react-zen'; import { useMemo, useState } from 'react'; -import { PieChart } from '@/components/charts/PieChart'; import { LoadingPanel } from '@/components/common/LoadingPanel'; -import { - useMessages, - useSessionDataPropertiesQuery, - useSessionDataValuesQuery, -} from '@/components/hooks'; -import { ListTable } from '@/components/metrics/ListTable'; -import { CHART_COLORS } from '@/lib/constants'; +import { useMessages, usePropertyFieldsQuery, useSessionDataPropertiesQuery } from '@/components/hooks'; +import { DATA_TYPE } from '@/lib/constants'; +import type { PropertyFilter } from '@/lib/types'; +import { PropertyChart } from '@/components/property-data/PropertyChart'; +import { PropertyDateChart } from '@/components/property-data/PropertyDateChart'; +import { PropertyFilterBar } from '@/components/property-data/PropertyFilterBar'; +import { PropertyFilterButton } from '@/components/property-data/PropertyFilterButton'; +import { PropertyNumericChart } from '@/components/property-data/PropertyNumericChart'; +import { Panel } from '@/components/common/Panel'; +import { SessionDataPivotTable } from '../session-data/SessionDataPivotTable'; +import { SessionStringPropertyView } from '../session-data/SessionStringPropertyView'; export function SessionProperties({ websiteId }: { websiteId: string }) { const [propertyName, setPropertyName] = useState(''); + const [propertyFilters, setPropertyFilters] = useState([]); const { t, labels } = useMessages(); - const { data, isLoading, isFetching, error } = useSessionDataPropertiesQuery(websiteId); + const { data, isLoading, isFetching, error } = usePropertyFieldsQuery('session', websiteId); + const { data: scopedData } = useSessionDataPropertiesQuery( + websiteId, + propertyName ? { selectedPropertyName: propertyName, propertyFilters } : undefined, + { enabled: !!propertyName }, + ); - const properties: string[] = data?.map(e => e.propertyName); + const properties = useMemo(() => { + if (!data) return []; + const seen = new Set(); + + return data.filter((field: { propertyName: string; dataType: number }) => { + if (seen.has(field.propertyName)) return false; + seen.add(field.propertyName); + return true; + }); + }, [data]); + + const selectedProperty = useMemo( + () => properties.find((field: { propertyName: string }) => field.propertyName === propertyName), + [properties, propertyName], + ); + const scopedProperties = useMemo(() => { + if (!scopedData) return []; + const seen = new Set(); + + return scopedData.filter((field: { propertyName: string }) => { + if (seen.has(field.propertyName)) return false; + seen.add(field.propertyName); + return true; + }); + }, [scopedData]); + const scopedPropertyKeys = useMemo( + () => scopedProperties.map((field: { propertyName: string }) => field.propertyName), + [scopedProperties], + ); return ( - + {data && ( - - + + + + {properties.map((field: { propertyName: string }) => ( + + {field.propertyName} + + ))} + + + + {propertyName && ( + + + + )} )} - {propertyName && } + {propertyName && } + {propertyName && selectedProperty?.dataType === DATA_TYPE.number && ( + + )} + {propertyName && selectedProperty?.dataType === DATA_TYPE.date && ( + + )} + {propertyName && selectedProperty?.dataType === DATA_TYPE.string && ( + + )} + {propertyName && selectedProperty?.dataType === DATA_TYPE.boolean && ( + + )} + {propertyName && selectedProperty?.dataType === DATA_TYPE.array && ( + + )} + {propertyName && ( + + + + )} ); } - -const SessionValues = ({ websiteId, propertyName }) => { - const { data, isLoading, isFetching, error } = useSessionDataValuesQuery(websiteId, propertyName); - - const propertySum = useMemo(() => { - return data?.reduce((sum, { total }) => sum + total, 0) ?? 0; - }, [data]); - - const chartData = useMemo(() => { - if (!propertyName || !data) return null; - return { - labels: data.map(({ value }) => value), - datasets: [ - { - data: data.map(({ total }) => total), - backgroundColor: CHART_COLORS, - borderWidth: 0, - }, - ], - }; - }, [propertyName, data]); - - const tableData = useMemo(() => { - if (!propertyName || !data || propertySum === 0) return []; - return data.map(({ value, total }) => ({ - label: value, - count: total, - percent: 100 * (total / propertySum), - })); - }, [propertyName, data, propertySum]); - - return ( - - {data && ( - - - - - )} - - ); -}; diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionsPage.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionsPage.tsx index f891f2ad7..f7eb9a868 100644 --- a/src/app/(main)/websites/[websiteId]/sessions/SessionsPage.tsx +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionsPage.tsx @@ -24,16 +24,20 @@ export function SessionsPage({ websiteId }) { - - + + {t(labels.activity)} {t(labels.properties)} - + - + diff --git a/src/app/api/websites/[websiteId]/session-data-pivot/route.ts b/src/app/api/websites/[websiteId]/session-data-pivot/route.ts new file mode 100644 index 000000000..f27b17781 --- /dev/null +++ b/src/app/api/websites/[websiteId]/session-data-pivot/route.ts @@ -0,0 +1,39 @@ +import { z } from 'zod'; +import { parsePropertyFilters } from '@/lib/params'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { filterParams, pagingParams } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getSessionDataPivot } from '@/queries/sql/sessions/getSessionDataPivot'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + startAt: z.coerce.number().int(), + endAt: z.coerce.number().int(), + propertyName: z.string(), + ...filterParams, + ...pagingParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const { propertyName, ...rest } = query; + const filters = await getQueryFilters(rest, websiteId); + const propertyFilters = parsePropertyFilters(query); + const result = await getSessionDataPivot(websiteId, propertyName, filters, propertyFilters); + + return json(result); +} diff --git a/src/app/api/websites/[websiteId]/session-data/array-series/route.ts b/src/app/api/websites/[websiteId]/session-data/array-series/route.ts new file mode 100644 index 000000000..5dd5b4b4a --- /dev/null +++ b/src/app/api/websites/[websiteId]/session-data/array-series/route.ts @@ -0,0 +1,40 @@ +import { z } from 'zod'; +import { parsePropertyFilters } from '@/lib/params'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { filterParams, timezoneParam, unitParam } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getSessionDataArraySeries } from '@/queries/sql'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + startAt: z.coerce.number().int(), + endAt: z.coerce.number().int(), + propertyName: z.string(), + timezone: timezoneParam.optional(), + unit: unitParam.optional(), + ...filterParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const { propertyName, ...rest } = query; + const filters = await getQueryFilters(rest, websiteId); + const propertyFilters = parsePropertyFilters(query); + const data = await getSessionDataArraySeries(websiteId, propertyName, filters, propertyFilters); + + return json(data); +} diff --git a/src/app/api/websites/[websiteId]/session-data/date-series/route.ts b/src/app/api/websites/[websiteId]/session-data/date-series/route.ts new file mode 100644 index 000000000..a607ff9ac --- /dev/null +++ b/src/app/api/websites/[websiteId]/session-data/date-series/route.ts @@ -0,0 +1,39 @@ +import { z } from 'zod'; +import { parsePropertyFilters } from '@/lib/params'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { filterParams, timezoneParam } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getSessionDataDateSeries } from '@/queries/sql'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + startAt: z.coerce.number().int(), + endAt: z.coerce.number().int(), + propertyName: z.string(), + timezone: timezoneParam.optional(), + ...filterParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const { propertyName, ...rest } = query; + const filters = await getQueryFilters(rest, websiteId); + const propertyFilters = parsePropertyFilters(query); + const data = await getSessionDataDateSeries(websiteId, propertyName, filters, propertyFilters); + + return json(data); +} diff --git a/src/app/api/websites/[websiteId]/session-data/numeric-series/route.ts b/src/app/api/websites/[websiteId]/session-data/numeric-series/route.ts new file mode 100644 index 000000000..09cf623b6 --- /dev/null +++ b/src/app/api/websites/[websiteId]/session-data/numeric-series/route.ts @@ -0,0 +1,41 @@ +import { z } from 'zod'; +import { parsePropertyFilters } from '@/lib/params'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { filterParams, timezoneParam, unitParam } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getSessionDataNumericSeries } from '@/queries/sql'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + startAt: z.coerce.number().int(), + endAt: z.coerce.number().int(), + propertyName: z.string(), + metric: z.enum(['sum', 'avg', 'count']).default('sum'), + timezone: timezoneParam.optional(), + unit: unitParam.optional(), + ...filterParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const { propertyName, metric, ...rest } = query; + const filters = await getQueryFilters(rest, websiteId); + const propertyFilters = parsePropertyFilters(query); + const data = await getSessionDataNumericSeries(websiteId, propertyName, metric, filters, propertyFilters); + + return json(data); +} diff --git a/src/app/api/websites/[websiteId]/session-data/numeric-stats/route.ts b/src/app/api/websites/[websiteId]/session-data/numeric-stats/route.ts new file mode 100644 index 000000000..a27f1bc73 --- /dev/null +++ b/src/app/api/websites/[websiteId]/session-data/numeric-stats/route.ts @@ -0,0 +1,39 @@ +import { z } from 'zod'; +import { parsePropertyFilters } from '@/lib/params'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { filterParams, timezoneParam } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getSessionDataNumericStats } from '@/queries/sql'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + startAt: z.coerce.number().int(), + endAt: z.coerce.number().int(), + propertyName: z.string(), + timezone: timezoneParam.optional(), + ...filterParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const { propertyName, ...rest } = query; + const filters = await getQueryFilters(rest, websiteId); + const propertyFilters = parsePropertyFilters(query); + const data = await getSessionDataNumericStats(websiteId, propertyName, filters, propertyFilters); + + return json(data); +} diff --git a/src/app/api/websites/[websiteId]/session-data/properties/route.ts b/src/app/api/websites/[websiteId]/session-data/properties/route.ts index 2d8db1535..61a5c13d9 100644 --- a/src/app/api/websites/[websiteId]/session-data/properties/route.ts +++ b/src/app/api/websites/[websiteId]/session-data/properties/route.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { parsePropertyFilters } from '@/lib/params'; import { getQueryFilters, parseRequest } from '@/lib/request'; import { json, unauthorized } from '@/lib/response'; import { filterParams } from '@/lib/schema'; @@ -12,6 +13,7 @@ export async function GET( const schema = z.object({ startAt: z.coerce.number().int(), endAt: z.coerce.number().int(), + selectedPropertyName: z.string().optional(), ...filterParams, }); @@ -27,9 +29,16 @@ export async function GET( return unauthorized(); } - const filters = await getQueryFilters(query, websiteId); + const { selectedPropertyName, ...rest } = query; + const filters = await getQueryFilters(rest, websiteId); + const propertyFilters = parsePropertyFilters(query); - const data = await getSessionDataProperties(websiteId, filters); + const data = await getSessionDataProperties( + websiteId, + filters, + propertyFilters, + selectedPropertyName, + ); return json(data); } diff --git a/src/app/api/websites/[websiteId]/session-data/property-series/route.ts b/src/app/api/websites/[websiteId]/session-data/property-series/route.ts new file mode 100644 index 000000000..fc054e6e2 --- /dev/null +++ b/src/app/api/websites/[websiteId]/session-data/property-series/route.ts @@ -0,0 +1,40 @@ +import { z } from 'zod'; +import { parsePropertyFilters } from '@/lib/params'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { filterParams, timezoneParam, unitParam } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getSessionDataPropertySeries } from '@/queries/sql'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + startAt: z.coerce.number().int(), + endAt: z.coerce.number().int(), + propertyName: z.string(), + timezone: timezoneParam.optional(), + unit: unitParam.optional(), + ...filterParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const { propertyName, ...rest } = query; + const filters = await getQueryFilters(rest, websiteId); + const propertyFilters = parsePropertyFilters(query); + const data = await getSessionDataPropertySeries(websiteId, propertyName, filters, propertyFilters); + + return json(data); +} diff --git a/src/app/api/websites/[websiteId]/session-data/stats/route.ts b/src/app/api/websites/[websiteId]/session-data/stats/route.ts new file mode 100644 index 000000000..c3a97fed6 --- /dev/null +++ b/src/app/api/websites/[websiteId]/session-data/stats/route.ts @@ -0,0 +1,40 @@ +import { z } from 'zod'; +import { parsePropertyFilters } from '@/lib/params'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { filterParams, timezoneParam, unitParam } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getSessionDataStats } from '@/queries/sql/sessions/getSessionDataStats'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + startAt: z.coerce.number().int(), + endAt: z.coerce.number().int(), + propertyName: z.string(), + timezone: timezoneParam.optional(), + unit: unitParam.optional(), + ...filterParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const { propertyName, ...rest } = query; + const filters = await getQueryFilters(rest, websiteId); + const propertyFilters = parsePropertyFilters(query); + const result = await getSessionDataStats(websiteId, propertyName, filters, propertyFilters); + + return json(result); +} diff --git a/src/app/api/websites/[websiteId]/session-data/values/route.ts b/src/app/api/websites/[websiteId]/session-data/values/route.ts index 7d06870ac..57b27e300 100644 --- a/src/app/api/websites/[websiteId]/session-data/values/route.ts +++ b/src/app/api/websites/[websiteId]/session-data/values/route.ts @@ -13,6 +13,7 @@ export async function GET( startAt: z.coerce.number().int(), endAt: z.coerce.number().int(), propertyName: z.string().optional(), + dataType: z.coerce.number().int().optional(), ...filterParams, }); @@ -28,12 +29,13 @@ export async function GET( return unauthorized(); } - const { propertyName } = query; + const { propertyName, dataType } = query; const filters = await getQueryFilters(query, websiteId); const data = await getSessionDataValues(websiteId, { ...filters, propertyName, + dataType, }); return json(data); diff --git a/src/components/hooks/index.ts b/src/components/hooks/index.ts index d5719d404..bd508317d 100644 --- a/src/components/hooks/index.ts +++ b/src/components/hooks/index.ts @@ -17,17 +17,9 @@ export * from './queries/useBoardsQuery'; export * from './queries/useDashboardQuery'; export * from './queries/useDateRangeQuery'; export * from './queries/useDeleteQuery'; -export * from './queries/useEventDataArraySeriesQuery'; -export * from './queries/useEventDataDateSeriesQuery'; -export * from './queries/useEventDataEventsQuery'; -export * from './queries/useEventDataFieldsQuery'; -export * from './queries/useEventDataNumericSeriesQuery'; -export * from './queries/useEventDataNumericStatsQuery'; export * from './queries/useEventDataPivotQuery'; export * from './queries/useEventDataPropertiesQuery'; -export * from './queries/useEventDataPropertySeriesQuery'; export * from './queries/useEventDataQuery'; -export * from './queries/useEventDataValuesQuery'; export * from './queries/useLinkQuery'; export * from './queries/useLinkSharesQuery'; export * from './queries/useLinksQuery'; @@ -35,6 +27,13 @@ export * from './queries/useLoginQuery'; export * from './queries/usePixelQuery'; export * from './queries/usePixelSharesQuery'; export * from './queries/usePixelsQuery'; +export * from './queries/usePropertyArraySeriesQuery'; +export * from './queries/usePropertyDateSeriesQuery'; +export * from './queries/usePropertyFieldsQuery'; +export * from './queries/usePropertyNumericSeriesQuery'; +export * from './queries/usePropertyNumericStatsQuery'; +export * from './queries/usePropertySeriesQuery'; +export * from './queries/usePropertyValuesQuery'; export * from './queries/useRealtimeQuery'; export * from './queries/useReplayQuery'; export * from './queries/useReplaySavedQuery'; @@ -46,7 +45,9 @@ export * from './queries/useRevenueSessionsQuery'; export * from './queries/useSavedReplaysQuery'; export * from './queries/useSessionActivityQuery'; export * from './queries/useSessionDataPropertiesQuery'; +export * from './queries/useSessionDataPivotQuery'; export * from './queries/useSessionDataQuery'; +export * from './queries/useSessionDataStatsQuery'; export * from './queries/useSessionDataValuesQuery'; export * from './queries/useSessionReplaysQuery'; export * from './queries/useShareTokenQuery'; diff --git a/src/components/hooks/queries/useEventDataArraySeriesQuery.ts b/src/components/hooks/queries/useEventDataArraySeriesQuery.ts deleted file mode 100644 index dba01c8f1..000000000 --- a/src/components/hooks/queries/useEventDataArraySeriesQuery.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { serializeEventPropertyFilters } from '@/lib/params'; -import type { EventDataSeriesPoint, EventPropertyFilter, ReactQueryOptions } from '@/lib/types'; -import { useApi } from '../useApi'; -import { useDateParameters } from '../useDateParameters'; -import { useFilterParameters } from '../useFilterParameters'; - -export function useEventDataArraySeriesQuery( - websiteId: string, - eventName: string, - propertyName: string, - eventFilters: EventPropertyFilter[] = [], - options?: ReactQueryOptions, -) { - const { get, useQuery } = useApi(); - const { startAt, endAt, unit, timezone } = useDateParameters(); - const params = useFilterParameters(); - - return useQuery({ - queryKey: [ - 'websites:event-data-pivot:array-series', - { - websiteId, - eventName, - propertyName, - eventFilters, - startAt, - endAt, - unit, - timezone, - ...params, - }, - ], - queryFn: () => - get(`/websites/${websiteId}/event-data-pivot/array-series`, { - eventName, - propertyName, - startAt, - endAt, - unit, - timezone, - ...serializeEventPropertyFilters(eventFilters), - ...params, - }), - enabled: !!(websiteId && eventName && propertyName), - ...options, - }); -} diff --git a/src/components/hooks/queries/useEventDataDateSeriesQuery.ts b/src/components/hooks/queries/useEventDataDateSeriesQuery.ts deleted file mode 100644 index 606395b54..000000000 --- a/src/components/hooks/queries/useEventDataDateSeriesQuery.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { serializeEventPropertyFilters } from '@/lib/params'; -import type { EventDataDateSeriesPoint, EventPropertyFilter, ReactQueryOptions } from '@/lib/types'; -import { useApi } from '../useApi'; -import { useDateParameters } from '../useDateParameters'; -import { useFilterParameters } from '../useFilterParameters'; - -export function useEventDataDateSeriesQuery( - websiteId: string, - eventName: string, - propertyName: string, - eventFilters: EventPropertyFilter[] = [], - options?: ReactQueryOptions, -) { - const { get, useQuery } = useApi(); - const { startAt, endAt, timezone } = useDateParameters(); - const params = useFilterParameters(); - - return useQuery({ - queryKey: [ - 'websites:event-data-pivot:date-series', - { - websiteId, - eventName, - propertyName, - eventFilters, - startAt, - endAt, - timezone, - ...params, - }, - ], - queryFn: () => - get(`/websites/${websiteId}/event-data-pivot/date-series`, { - eventName, - propertyName, - startAt, - endAt, - timezone, - ...serializeEventPropertyFilters(eventFilters), - ...params, - }), - enabled: !!(websiteId && eventName && propertyName), - ...options, - }); -} diff --git a/src/components/hooks/queries/useEventDataEventsQuery.ts b/src/components/hooks/queries/useEventDataEventsQuery.ts deleted file mode 100644 index 1401989f3..000000000 --- a/src/components/hooks/queries/useEventDataEventsQuery.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { ReactQueryOptions } from '@/lib/types'; -import { useApi } from '../useApi'; -import { useDateParameters } from '../useDateParameters'; -import { useFilterParameters } from '../useFilterParameters'; - -export function useEventDataEventsQuery(websiteId: string, options?: ReactQueryOptions) { - const { get, useQuery } = useApi(); - const { startAt, endAt, unit, timezone } = useDateParameters(); - const filters = useFilterParameters(); - - return useQuery({ - queryKey: [ - 'websites:event-data:events', - { websiteId, startAt, endAt, unit, timezone, ...filters }, - ], - queryFn: () => - get(`/websites/${websiteId}/event-data/events`, { - startAt, - endAt, - unit, - timezone, - ...filters, - }), - enabled: !!websiteId, - ...options, - }); -} diff --git a/src/components/hooks/queries/useEventDataFieldsQuery.ts b/src/components/hooks/queries/useEventDataFieldsQuery.ts deleted file mode 100644 index bd9e0f5ce..000000000 --- a/src/components/hooks/queries/useEventDataFieldsQuery.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { ReactQueryOptions } from '@/lib/types'; -import { useApi } from '../useApi'; -import { useDateParameters } from '../useDateParameters'; -import { useFilterParameters } from '../useFilterParameters'; - -export function useEventDataFieldsQuery( - websiteId: string, - eventName?: string, - options?: ReactQueryOptions, -) { - const { get, useQuery } = useApi(); - const { startAt, endAt, unit, timezone } = useDateParameters(); - const params = useFilterParameters(); - - return useQuery({ - queryKey: [ - 'websites:event-data:fields', - { websiteId, eventName, startAt, endAt, unit, timezone, ...params }, - ], - queryFn: () => - get(`/websites/${websiteId}/event-data/fields`, { - event: eventName, - startAt, - endAt, - unit, - timezone, - ...params, - }), - enabled: !!websiteId, - ...options, - }); -} diff --git a/src/components/hooks/queries/useEventDataNumericSeriesQuery.ts b/src/components/hooks/queries/useEventDataNumericSeriesQuery.ts deleted file mode 100644 index 26804ed3b..000000000 --- a/src/components/hooks/queries/useEventDataNumericSeriesQuery.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { serializeEventPropertyFilters } from '@/lib/params'; -import type { EventPropertyFilter, ReactQueryOptions } from '@/lib/types'; -import { useApi } from '../useApi'; -import { useDateParameters } from '../useDateParameters'; -import { useFilterParameters } from '../useFilterParameters'; - -export function useEventDataNumericSeriesQuery( - websiteId: string, - eventName: string, - propertyName: string, - metric: 'sum' | 'avg' | 'count', - eventFilters: EventPropertyFilter[] = [], - options?: ReactQueryOptions, -) { - const { get, useQuery } = useApi(); - const { startAt, endAt, unit, timezone } = useDateParameters(); - const params = useFilterParameters(); - - return useQuery({ - queryKey: [ - 'websites:event-data-pivot:numeric-series', - { websiteId, eventName, propertyName, metric, eventFilters, startAt, endAt, unit, timezone, ...params }, - ], - queryFn: () => - get(`/websites/${websiteId}/event-data-pivot/numeric-series`, { - eventName, - propertyName, - metric, - startAt, - endAt, - unit, - timezone, - ...serializeEventPropertyFilters(eventFilters), - ...params, - }), - enabled: !!(websiteId && eventName && propertyName), - ...options, - }); -} diff --git a/src/components/hooks/queries/useEventDataNumericStatsQuery.ts b/src/components/hooks/queries/useEventDataNumericStatsQuery.ts deleted file mode 100644 index f52e44ca5..000000000 --- a/src/components/hooks/queries/useEventDataNumericStatsQuery.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { serializeEventPropertyFilters } from '@/lib/params'; -import type { EventDataNumericStats, EventPropertyFilter, ReactQueryOptions } from '@/lib/types'; -import { useApi } from '../useApi'; -import { useDateParameters } from '../useDateParameters'; -import { useFilterParameters } from '../useFilterParameters'; - -export function useEventDataNumericStatsQuery( - websiteId: string, - eventName: string, - propertyName: string, - eventFilters: EventPropertyFilter[] = [], - options?: ReactQueryOptions, -) { - const { get, useQuery } = useApi(); - const { startAt, endAt, timezone } = useDateParameters(); - const params = useFilterParameters(); - - return useQuery({ - queryKey: [ - 'websites:event-data-pivot:numeric-stats', - { websiteId, eventName, propertyName, eventFilters, startAt, endAt, timezone, ...params }, - ], - queryFn: () => - get(`/websites/${websiteId}/event-data-pivot/numeric-stats`, { - eventName, - propertyName, - startAt, - endAt, - timezone, - ...serializeEventPropertyFilters(eventFilters), - ...params, - }), - enabled: !!(websiteId && eventName && propertyName), - ...options, - }); -} diff --git a/src/components/hooks/queries/useEventDataPropertiesQuery.ts b/src/components/hooks/queries/useEventDataPropertiesQuery.ts index dfa6e9295..ef921824e 100644 --- a/src/components/hooks/queries/useEventDataPropertiesQuery.ts +++ b/src/components/hooks/queries/useEventDataPropertiesQuery.ts @@ -6,7 +6,7 @@ import { useFilterParameters } from '../useFilterParameters'; export function useEventDataPropertiesQuery(websiteId: string, options?: ReactQueryOptions) { const { get, useQuery } = useApi(); const { startAt, endAt, unit, timezone } = useDateParameters(); - const filters = useFilterParameters(); + const filters = useFilterParameters({ includePagination: false }); return useQuery({ queryKey: [ diff --git a/src/components/hooks/queries/useEventDataPropertySeriesQuery.ts b/src/components/hooks/queries/useEventDataPropertySeriesQuery.ts deleted file mode 100644 index 2d94d8cf4..000000000 --- a/src/components/hooks/queries/useEventDataPropertySeriesQuery.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { serializeEventPropertyFilters } from '@/lib/params'; -import type { EventDataSeriesPoint, EventPropertyFilter, ReactQueryOptions } from '@/lib/types'; -import { useApi } from '../useApi'; -import { useDateParameters } from '../useDateParameters'; -import { useFilterParameters } from '../useFilterParameters'; - -export function useEventDataPropertySeriesQuery( - websiteId: string, - eventName: string, - propertyName: string, - eventFilters: EventPropertyFilter[] = [], - options?: ReactQueryOptions, -) { - const { get, useQuery } = useApi(); - const { startAt, endAt, unit, timezone } = useDateParameters(); - const params = useFilterParameters(); - - return useQuery({ - queryKey: [ - 'websites:event-data-pivot:property-series', - { websiteId, eventName, propertyName, eventFilters, startAt, endAt, unit, timezone, ...params }, - ], - queryFn: () => - get(`/websites/${websiteId}/event-data-pivot/property-series`, { - eventName, - propertyName, - startAt, - endAt, - unit, - timezone, - ...serializeEventPropertyFilters(eventFilters), - ...params, - }), - enabled: !!(websiteId && eventName && propertyName), - ...options, - }); -} diff --git a/src/components/hooks/queries/useEventStatsQuery.ts b/src/components/hooks/queries/useEventStatsQuery.ts index b3ca60eaa..6c61e75f5 100644 --- a/src/components/hooks/queries/useEventStatsQuery.ts +++ b/src/components/hooks/queries/useEventStatsQuery.ts @@ -26,7 +26,7 @@ export function useEventStatsQuery( ) { const { get, useQuery } = useApi(); const { startAt, endAt } = useDateParameters(); - const filters = useFilterParameters(); + const filters = useFilterParameters({ includePagination: false }); return useQuery({ queryKey: ['websites:events:stats', { websiteId, startAt, endAt, ...filters }], diff --git a/src/components/hooks/queries/usePropertyArraySeriesQuery.ts b/src/components/hooks/queries/usePropertyArraySeriesQuery.ts new file mode 100644 index 000000000..fd4b8a42c --- /dev/null +++ b/src/components/hooks/queries/usePropertyArraySeriesQuery.ts @@ -0,0 +1,44 @@ +import { serializePropertyFilters } from '@/lib/params'; +import type { EventDataSeriesPoint, PropertyFilter, ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useDateParameters } from '../useDateParameters'; +import { useFilterParameters } from '../useFilterParameters'; +import type { PropertyDataSource } from './usePropertyFieldsQuery'; + +export function usePropertyArraySeriesQuery( + source: PropertyDataSource, + websiteId: string, + propertyName: string, + propertyFilters: PropertyFilter[] = [], + eventName?: string, + options?: ReactQueryOptions, +) { + const { get, useQuery } = useApi(); + const { startAt, endAt, unit, timezone } = useDateParameters(); + const params = useFilterParameters({ includePagination: false }); + + return useQuery({ + queryKey: [ + `websites:${source}-data:array-series`, + { websiteId, propertyName, eventName, propertyFilters, startAt, endAt, unit, timezone, ...params }, + ], + queryFn: () => + get( + source === 'event' + ? `/websites/${websiteId}/event-data-pivot/array-series` + : `/websites/${websiteId}/session-data/array-series`, + { + ...(source === 'event' ? { eventName } : {}), + propertyName, + startAt, + endAt, + unit, + timezone, + ...serializePropertyFilters(propertyFilters), + ...params, + }, + ), + enabled: !!(websiteId && propertyName && (source === 'session' || eventName)), + ...options, + }); +} diff --git a/src/components/hooks/queries/usePropertyDateSeriesQuery.ts b/src/components/hooks/queries/usePropertyDateSeriesQuery.ts new file mode 100644 index 000000000..4c34c0400 --- /dev/null +++ b/src/components/hooks/queries/usePropertyDateSeriesQuery.ts @@ -0,0 +1,43 @@ +import { serializePropertyFilters } from '@/lib/params'; +import type { EventDataDateSeriesPoint, PropertyFilter, ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useDateParameters } from '../useDateParameters'; +import { useFilterParameters } from '../useFilterParameters'; +import type { PropertyDataSource } from './usePropertyFieldsQuery'; + +export function usePropertyDateSeriesQuery( + source: PropertyDataSource, + websiteId: string, + propertyName: string, + propertyFilters: PropertyFilter[] = [], + eventName?: string, + options?: ReactQueryOptions, +) { + const { get, useQuery } = useApi(); + const { startAt, endAt, timezone } = useDateParameters(); + const params = useFilterParameters({ includePagination: false }); + + return useQuery({ + queryKey: [ + `websites:${source}-data:date-series`, + { websiteId, propertyName, eventName, propertyFilters, startAt, endAt, timezone, ...params }, + ], + queryFn: () => + get( + source === 'event' + ? `/websites/${websiteId}/event-data-pivot/date-series` + : `/websites/${websiteId}/session-data/date-series`, + { + ...(source === 'event' ? { eventName } : {}), + propertyName, + startAt, + endAt, + timezone, + ...serializePropertyFilters(propertyFilters), + ...params, + }, + ), + enabled: !!(websiteId && propertyName && (source === 'session' || eventName)), + ...options, + }); +} diff --git a/src/components/hooks/queries/usePropertyFieldsQuery.ts b/src/components/hooks/queries/usePropertyFieldsQuery.ts new file mode 100644 index 000000000..48cfea9e7 --- /dev/null +++ b/src/components/hooks/queries/usePropertyFieldsQuery.ts @@ -0,0 +1,40 @@ +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useDateParameters } from '../useDateParameters'; +import { useFilterParameters } from '../useFilterParameters'; + +export type PropertyDataSource = 'event' | 'session'; + +export function usePropertyFieldsQuery( + source: PropertyDataSource, + websiteId: string, + eventName?: string, + options?: ReactQueryOptions, +) { + const { get, useQuery } = useApi(); + const { startAt, endAt, unit, timezone } = useDateParameters(); + const params = useFilterParameters({ includePagination: false }); + + return useQuery({ + queryKey: [ + `websites:${source}-data:fields`, + { websiteId, eventName, startAt, endAt, unit, timezone, ...params }, + ], + queryFn: () => + get( + source === 'event' + ? `/websites/${websiteId}/event-data/fields` + : `/websites/${websiteId}/session-data/properties`, + { + ...(source === 'event' ? { event: eventName } : {}), + startAt, + endAt, + unit, + timezone, + ...params, + }, + ), + enabled: !!(websiteId && (source === 'session' || eventName)), + ...options, + }); +} diff --git a/src/components/hooks/queries/usePropertyNumericSeriesQuery.ts b/src/components/hooks/queries/usePropertyNumericSeriesQuery.ts new file mode 100644 index 000000000..ffe5b18b7 --- /dev/null +++ b/src/components/hooks/queries/usePropertyNumericSeriesQuery.ts @@ -0,0 +1,46 @@ +import { serializePropertyFilters } from '@/lib/params'; +import type { PropertyFilter, ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useDateParameters } from '../useDateParameters'; +import { useFilterParameters } from '../useFilterParameters'; +import type { PropertyDataSource } from './usePropertyFieldsQuery'; + +export function usePropertyNumericSeriesQuery( + source: PropertyDataSource, + websiteId: string, + propertyName: string, + metric: 'sum' | 'avg' | 'count', + propertyFilters: PropertyFilter[] = [], + eventName?: string, + options?: ReactQueryOptions, +) { + const { get, useQuery } = useApi(); + const { startAt, endAt, unit, timezone } = useDateParameters(); + const params = useFilterParameters({ includePagination: false }); + + return useQuery({ + queryKey: [ + `websites:${source}-data:numeric-series`, + { websiteId, propertyName, metric, eventName, propertyFilters, startAt, endAt, unit, timezone, ...params }, + ], + queryFn: () => + get( + source === 'event' + ? `/websites/${websiteId}/event-data-pivot/numeric-series` + : `/websites/${websiteId}/session-data/numeric-series`, + { + ...(source === 'event' ? { eventName } : {}), + propertyName, + metric, + startAt, + endAt, + unit, + timezone, + ...serializePropertyFilters(propertyFilters), + ...params, + }, + ), + enabled: !!(websiteId && propertyName && (source === 'session' || eventName)), + ...options, + }); +} diff --git a/src/components/hooks/queries/usePropertyNumericStatsQuery.ts b/src/components/hooks/queries/usePropertyNumericStatsQuery.ts new file mode 100644 index 000000000..01cc48568 --- /dev/null +++ b/src/components/hooks/queries/usePropertyNumericStatsQuery.ts @@ -0,0 +1,43 @@ +import { serializePropertyFilters } from '@/lib/params'; +import type { EventDataNumericStats, PropertyFilter, ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useDateParameters } from '../useDateParameters'; +import { useFilterParameters } from '../useFilterParameters'; +import type { PropertyDataSource } from './usePropertyFieldsQuery'; + +export function usePropertyNumericStatsQuery( + source: PropertyDataSource, + websiteId: string, + propertyName: string, + propertyFilters: PropertyFilter[] = [], + eventName?: string, + options?: ReactQueryOptions, +) { + const { get, useQuery } = useApi(); + const { startAt, endAt, timezone } = useDateParameters(); + const params = useFilterParameters({ includePagination: false }); + + return useQuery({ + queryKey: [ + `websites:${source}-data:numeric-stats`, + { websiteId, propertyName, eventName, propertyFilters, startAt, endAt, timezone, ...params }, + ], + queryFn: () => + get( + source === 'event' + ? `/websites/${websiteId}/event-data-pivot/numeric-stats` + : `/websites/${websiteId}/session-data/numeric-stats`, + { + ...(source === 'event' ? { eventName } : {}), + propertyName, + startAt, + endAt, + timezone, + ...serializePropertyFilters(propertyFilters), + ...params, + }, + ), + enabled: !!(websiteId && propertyName && (source === 'session' || eventName)), + ...options, + }); +} diff --git a/src/components/hooks/queries/usePropertySeriesQuery.ts b/src/components/hooks/queries/usePropertySeriesQuery.ts new file mode 100644 index 000000000..3df8af796 --- /dev/null +++ b/src/components/hooks/queries/usePropertySeriesQuery.ts @@ -0,0 +1,44 @@ +import { serializePropertyFilters } from '@/lib/params'; +import type { EventDataSeriesPoint, PropertyFilter, ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useDateParameters } from '../useDateParameters'; +import { useFilterParameters } from '../useFilterParameters'; +import type { PropertyDataSource } from './usePropertyFieldsQuery'; + +export function usePropertySeriesQuery( + source: PropertyDataSource, + websiteId: string, + propertyName: string, + propertyFilters: PropertyFilter[] = [], + eventName?: string, + options?: ReactQueryOptions, +) { + const { get, useQuery } = useApi(); + const { startAt, endAt, unit, timezone } = useDateParameters(); + const params = useFilterParameters({ includePagination: false }); + + return useQuery({ + queryKey: [ + `websites:${source}-data:property-series`, + { websiteId, propertyName, eventName, propertyFilters, startAt, endAt, unit, timezone, ...params }, + ], + queryFn: () => + get( + source === 'event' + ? `/websites/${websiteId}/event-data-pivot/property-series` + : `/websites/${websiteId}/session-data/property-series`, + { + ...(source === 'event' ? { eventName } : {}), + propertyName, + startAt, + endAt, + unit, + timezone, + ...serializePropertyFilters(propertyFilters), + ...params, + }, + ), + enabled: !!(websiteId && propertyName && (source === 'session' || eventName)), + ...options, + }); +} diff --git a/src/components/hooks/queries/usePropertyValuesQuery.ts b/src/components/hooks/queries/usePropertyValuesQuery.ts new file mode 100644 index 000000000..131620fae --- /dev/null +++ b/src/components/hooks/queries/usePropertyValuesQuery.ts @@ -0,0 +1,46 @@ +import { serializePropertyFilters } from '@/lib/params'; +import type { PropertyFilter, ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useDateParameters } from '../useDateParameters'; +import { useFilterParameters } from '../useFilterParameters'; +import type { PropertyDataSource } from './usePropertyFieldsQuery'; + +export function usePropertyValuesQuery( + source: PropertyDataSource, + websiteId: string, + propertyName: string, + dataType?: number, + propertyFilters: PropertyFilter[] = [], + eventName?: string, + options?: ReactQueryOptions, +) { + const { get, useQuery } = useApi(); + const { startAt, endAt, unit, timezone } = useDateParameters(); + const params = useFilterParameters({ includePagination: false }); + + return useQuery({ + queryKey: [ + `websites:${source}-data:values`, + { websiteId, propertyName, dataType, eventName, propertyFilters, startAt, endAt, unit, timezone, ...params }, + ], + queryFn: () => + get( + source === 'event' + ? `/websites/${websiteId}/event-data/values` + : `/websites/${websiteId}/session-data/values`, + { + ...(source === 'event' ? { event: eventName } : {}), + startAt, + endAt, + unit, + timezone, + propertyName, + dataType, + ...serializePropertyFilters(propertyFilters), + ...params, + }, + ), + enabled: !!(websiteId && propertyName && (source === 'session' || eventName)), + ...options, + }); +} diff --git a/src/components/hooks/queries/useResultQuery.ts b/src/components/hooks/queries/useResultQuery.ts index 1828e594a..785b05694 100644 --- a/src/components/hooks/queries/useResultQuery.ts +++ b/src/components/hooks/queries/useResultQuery.ts @@ -11,7 +11,7 @@ export function useResultQuery( const { websiteId, ...parameters } = params; const { post, useQuery } = useApi(); const { startDate, endDate, timezone, unit } = useDateParameters(); - const filters = useFilterParameters(); + const filters = useFilterParameters({ includePagination: false }); return useQuery({ queryKey: [ diff --git a/src/components/hooks/queries/useEventDataValuesQuery.ts b/src/components/hooks/queries/useSessionDataPivotQuery.ts similarity index 50% rename from src/components/hooks/queries/useEventDataValuesQuery.ts rename to src/components/hooks/queries/useSessionDataPivotQuery.ts index 3141e41d1..db5fecccf 100644 --- a/src/components/hooks/queries/useEventDataValuesQuery.ts +++ b/src/components/hooks/queries/useSessionDataPivotQuery.ts @@ -1,34 +1,33 @@ -import type { ReactQueryOptions } from '@/lib/types'; +import { serializePropertyFilters } from '@/lib/params'; +import type { PropertyFilter, ReactQueryOptions } from '@/lib/types'; import { useApi } from '../useApi'; import { useDateParameters } from '../useDateParameters'; import { useFilterParameters } from '../useFilterParameters'; -export function useEventDataValuesQuery( +export function useSessionDataPivotQuery( websiteId: string, - event: string, propertyName: string, - dataType?: number, + propertyFilters: PropertyFilter[] = [], options?: ReactQueryOptions, ) { const { get, useQuery } = useApi(); const { startAt, endAt, unit, timezone } = useDateParameters(); - const filters = useFilterParameters(); + const params = useFilterParameters(); - return useQuery({ + return useQuery({ queryKey: [ - 'websites:event-data:values', - { websiteId, startAt, endAt, unit, timezone, ...filters, event, propertyName, dataType }, + 'websites:session-data-pivot', + { websiteId, propertyName, propertyFilters, startAt, endAt, unit, timezone, ...params }, ], queryFn: () => - get(`/websites/${websiteId}/event-data/values`, { + get(`/websites/${websiteId}/session-data-pivot`, { startAt, endAt, unit, timezone, - ...filters, - event, propertyName, - dataType, + ...serializePropertyFilters(propertyFilters), + ...params, }), enabled: !!(websiteId && propertyName), ...options, diff --git a/src/components/hooks/queries/useSessionDataPropertiesQuery.ts b/src/components/hooks/queries/useSessionDataPropertiesQuery.ts index ac651bb96..fa52ae302 100644 --- a/src/components/hooks/queries/useSessionDataPropertiesQuery.ts +++ b/src/components/hooks/queries/useSessionDataPropertiesQuery.ts @@ -1,17 +1,36 @@ import type { ReactQueryOptions } from '@/lib/types'; +import { serializePropertyFilters } from '@/lib/params'; +import type { PropertyFilter } from '@/lib/types'; import { useApi } from '../useApi'; import { useDateParameters } from '../useDateParameters'; import { useFilterParameters } from '../useFilterParameters'; -export function useSessionDataPropertiesQuery(websiteId: string, options?: ReactQueryOptions) { +export function useSessionDataPropertiesQuery( + websiteId: string, + params?: { + selectedPropertyName?: string; + propertyFilters?: PropertyFilter[]; + }, + options?: ReactQueryOptions, +) { const { get, useQuery } = useApi(); const { startAt, endAt, unit, timezone } = useDateParameters(); - const filters = useFilterParameters(); + const filters = useFilterParameters({ includePagination: false }); + const { selectedPropertyName, propertyFilters = [] } = params || {}; return useQuery({ queryKey: [ 'websites:session-data:properties', - { websiteId, startAt, endAt, unit, timezone, ...filters }, + { + websiteId, + selectedPropertyName, + propertyFilters, + startAt, + endAt, + unit, + timezone, + ...filters, + }, ], queryFn: () => get(`/websites/${websiteId}/session-data/properties`, { @@ -19,6 +38,8 @@ export function useSessionDataPropertiesQuery(websiteId: string, options?: React endAt, unit, timezone, + selectedPropertyName, + ...serializePropertyFilters(propertyFilters), ...filters, }), enabled: !!websiteId, diff --git a/src/components/hooks/queries/useSessionDataStatsQuery.ts b/src/components/hooks/queries/useSessionDataStatsQuery.ts new file mode 100644 index 000000000..8380fedf5 --- /dev/null +++ b/src/components/hooks/queries/useSessionDataStatsQuery.ts @@ -0,0 +1,35 @@ +import { serializePropertyFilters } from '@/lib/params'; +import type { PropertyFilter, ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useDateParameters } from '../useDateParameters'; +import { useFilterParameters } from '../useFilterParameters'; + +export function useSessionDataStatsQuery( + websiteId: string, + propertyName: string, + propertyFilters: PropertyFilter[] = [], + options?: ReactQueryOptions, +) { + const { get, useQuery } = useApi(); + const { startAt, endAt, unit, timezone } = useDateParameters(); + const params = useFilterParameters({ includePagination: false }); + + return useQuery({ + queryKey: [ + 'websites:session-data:stats', + { websiteId, propertyName, propertyFilters, startAt, endAt, unit, timezone, ...params }, + ], + queryFn: () => + get(`/websites/${websiteId}/session-data/stats`, { + startAt, + endAt, + unit, + timezone, + propertyName, + ...serializePropertyFilters(propertyFilters), + ...params, + }), + enabled: !!(websiteId && propertyName), + ...options, + }); +} diff --git a/src/components/hooks/useFilterParameters.ts b/src/components/hooks/useFilterParameters.ts index 3579bef95..24211c386 100644 --- a/src/components/hooks/useFilterParameters.ts +++ b/src/components/hooks/useFilterParameters.ts @@ -2,7 +2,7 @@ import { useMemo } from 'react'; import { FILTER_COLUMNS } from '@/lib/constants'; import { useNavigation } from './useNavigation'; -export function useFilterParameters() { +export function useFilterParameters({ includePagination = true }: { includePagination?: boolean } = {}) { const { query } = useNavigation(); return useMemo(() => { @@ -15,15 +15,20 @@ export function useFilterParameters() { } } - return { + const params = { ...filterParams, search: query.search, segment: query.segment, cohort: query.cohort, excludeBounce: query.excludeBounce, match: query.match, - page: query.page, - pageSize: query.pageSize, - }; - }, [query]); + } as Record; + + if (includePagination) { + params.page = query.page; + params.pageSize = query.pageSize; + } + + return params; + }, [query, includePagination]); } diff --git a/src/app/(main)/websites/[websiteId]/event-data/EventDataPropertyChart.tsx b/src/components/property-data/PropertyChart.tsx similarity index 84% rename from src/app/(main)/websites/[websiteId]/event-data/EventDataPropertyChart.tsx rename to src/components/property-data/PropertyChart.tsx index 0fccff7c0..0700ff1a7 100644 --- a/src/app/(main)/websites/[websiteId]/event-data/EventDataPropertyChart.tsx +++ b/src/components/property-data/PropertyChart.tsx @@ -6,54 +6,58 @@ import { BarChart } from '@/components/charts/BarChart'; import { PieChart } from '@/components/charts/PieChart'; import { LoadingPanel } from '@/components/common/LoadingPanel'; import { - useEventDataArraySeriesQuery, useDateRange, - useEventDataPropertySeriesQuery, useLocale, useMessages, + usePropertyArraySeriesQuery, + usePropertySeriesQuery, useTimezone, } from '@/components/hooks'; +import type { PropertyDataSource } from '@/components/hooks/queries/usePropertyFieldsQuery'; import { ListTable } from '@/components/metrics/ListTable'; import { renderDateLabels } from '@/lib/charts'; import { CHART_COLORS } from '@/lib/constants'; import { generateTimeSeries } from '@/lib/date'; -import type { EventDataSeriesPoint, EventPropertyFilter } from '@/lib/types'; +import type { EventDataSeriesPoint, PropertyFilter } from '@/lib/types'; -export function EventDataPropertyChart({ +export function PropertyChart({ + source, websiteId, - eventName, propertyName, - eventFilters = [], + propertyFilters = [], + eventName, seriesType = 'property', }: { + source: PropertyDataSource; websiteId: string; - eventName: string; propertyName: string; - eventFilters?: EventPropertyFilter[]; + propertyFilters?: PropertyFilter[]; + eventName?: string; seriesType?: 'property' | 'array'; }) { const { t, labels } = useMessages(); const { timezone } = useTimezone(); const { dateRange: { startDate, endDate, unit } } = useDateRange({ timezone }); const { locale, dateLocale } = useLocale(); - const propertySeriesQuery = useEventDataPropertySeriesQuery( + const propertySeriesQuery = usePropertySeriesQuery( + source, websiteId, - eventName, propertyName, - eventFilters, + propertyFilters, + eventName, { enabled: seriesType === 'property' }, ); - const arraySeriesQuery = useEventDataArraySeriesQuery( + const arraySeriesQuery = usePropertyArraySeriesQuery( + source, websiteId, - eventName, propertyName, - eventFilters, + propertyFilters, + eventName, { enabled: seriesType === 'array' }, ); const { data, isLoading, isFetching, error } = seriesType === 'array' ? arraySeriesQuery : propertySeriesQuery; - // Aggregate totals per value from the already-filtered time series const aggregated = useMemo(() => { if (!data) return []; const totals = data.reduce((obj: Record, { x, y }) => { @@ -66,16 +70,17 @@ export function EventDataPropertyChart({ }, [data]); const valueLabels = useMemo(() => aggregated.map(({ value }) => value), [aggregated]); - - const colorMap = useMemo(() => { - return valueLabels.reduce( - (obj, label, index) => { - obj[label] = CHART_COLORS[index % CHART_COLORS.length]; - return obj; - }, - {} as Record, - ); - }, [valueLabels]); + const colorMap = useMemo( + () => + valueLabels.reduce( + (obj, label, index) => { + obj[label] = CHART_COLORS[index % CHART_COLORS.length]; + return obj; + }, + {} as Record, + ), + [valueLabels], + ); const chartData = useMemo(() => { if (!data) return; @@ -86,7 +91,7 @@ export function EventDataPropertyChart({ return obj; }, {}); - if (!map || Object.keys(map).length === 0) { + if (!Object.keys(map).length) { return { datasets: [ { @@ -125,12 +130,10 @@ export function EventDataPropertyChart({ }, [data, startDate, endDate, unit, dateLocale, valueLabels, colorMap]); const renderXLabel = useCallback(renderDateLabels(unit, locale), [unit, locale]); - const propertySum = useMemo( () => aggregated.reduce((sum, { total }) => sum + total, 0), [aggregated], ); - const tableData = useMemo(() => { if (!aggregated.length || propertySum === 0) return []; return aggregated.map(({ value, total }) => ({ @@ -139,7 +142,6 @@ export function EventDataPropertyChart({ percent: 100 * (total / propertySum), })); }, [aggregated, propertySum]); - const pieChartData = useMemo(() => { if (!aggregated.length) return null; return { diff --git a/src/app/(main)/websites/[websiteId]/event-data/EventDataDateChart.tsx b/src/components/property-data/PropertyDateChart.tsx similarity index 51% rename from src/app/(main)/websites/[websiteId]/event-data/EventDataDateChart.tsx rename to src/components/property-data/PropertyDateChart.tsx index c075a46d5..4eb83d6ea 100644 --- a/src/app/(main)/websites/[websiteId]/event-data/EventDataDateChart.tsx +++ b/src/components/property-data/PropertyDateChart.tsx @@ -1,6 +1,5 @@ 'use client'; import { Column, Grid, Text, useTheme } from '@umami/react-zen'; -import { colord } from 'colord'; import { differenceInCalendarDays, format, @@ -17,15 +16,15 @@ import { type ReactNode, useCallback, useMemo } from 'react'; import { BarChart } from '@/components/charts/BarChart'; import { PieChart } from '@/components/charts/PieChart'; import { LoadingPanel } from '@/components/common/LoadingPanel'; -import { useEventDataDateSeriesQuery, useLocale, useMessages, useTimezone } from '@/components/hooks'; +import { useLocale, useMessages, usePropertyDateSeriesQuery, useTimezone } from '@/components/hooks'; +import type { PropertyDataSource } from '@/components/hooks/queries/usePropertyFieldsQuery'; import { ListTable } from '@/components/metrics/ListTable'; import { MetricsBar } from '@/components/metrics/MetricsBar'; import { renderDateLabels } from '@/lib/charts'; import { getThemeColors } from '@/lib/colors'; import { CHART_COLORS } from '@/lib/constants'; import { formatDate, generateTimeSeries } from '@/lib/date'; -import { formatLongNumber } from '@/lib/format'; -import type { EventDataDateSeriesPoint, EventPropertyFilter } from '@/lib/types'; +import type { EventDataDateSeriesPoint, PropertyFilter } from '@/lib/types'; type DateChartUnit = 'day' | 'week' | 'month' | 'year'; type ParsedDateRow = EventDataDateSeriesPoint & { date: Date }; @@ -40,15 +39,7 @@ function parseDateValue(value: string) { return isValid(date) ? date : null; } -function InsightCard({ - label, - value, - hint, -}: { - label: string; - value: string; - hint?: ReactNode; -}) { +function InsightCard({ label, value, hint }: { label: string; value: string; hint?: ReactNode }) { return ( - - {label} - - - {value} - - {hint && ( - - {hint} - - )} + {label} + {value} + {hint && {hint}} ); } function getChartUnit(minDate: Date, maxDate: Date): DateChartUnit { const spanDays = differenceInCalendarDays(maxDate, minDate); - if (spanDays <= 45) return 'day'; if (spanDays <= 180) return 'week'; if (spanDays <= 730) return 'month'; - return 'year'; } @@ -99,74 +80,67 @@ function getBucketStart(date: Date, unit: DateChartUnit, dateLocale: Locale) { } } -export function EventDataDateChart({ +export function PropertyDateChart({ + source, websiteId, - eventName, propertyName, - eventFilters = [], + propertyFilters = [], + eventName, }: { + source: PropertyDataSource; websiteId: string; - eventName: string; propertyName: string; - eventFilters?: EventPropertyFilter[]; + propertyFilters?: PropertyFilter[]; + eventName?: string; }) { const { theme } = useTheme(); const { t, labels } = useMessages(); const { locale, dateLocale } = useLocale(); const { timezone } = useTimezone(); const { colors } = useMemo(() => getThemeColors(theme), [theme]); - const query = useEventDataDateSeriesQuery(websiteId, eventName, propertyName, eventFilters); + const query = usePropertyDateSeriesQuery(source, websiteId, propertyName, propertyFilters, eventName); const rows = useMemo(() => query.data ?? [], [query.data]); const total = useMemo(() => rows.reduce((sum, { y }) => sum + y, 0), [rows]); const zonedNow = useMemo(() => toZonedTime(new Date(), timezone), [timezone]); - const exactDateRows = useMemo(() => { - return rows - .map(row => { - const date = parseDateValue(row.t); - - return date ? { ...row, date } : null; - }) - .filter((row): row is ParsedDateRow => row !== null) - .sort((a, b) => a.date.getTime() - b.date.getTime()); - }, [rows]); + const exactDateRows = useMemo( + () => + rows + .map(row => { + const date = parseDateValue(row.t); + return date ? { ...row, date } : null; + }) + .filter((row): row is ParsedDateRow => row !== null) + .sort((a, b) => a.date.getTime() - b.date.getTime()), + [rows], + ); const minPropertyDate = exactDateRows[0]?.date ?? null; - const lastExactDateRow = exactDateRows[exactDateRows.length - 1]; - const maxPropertyDate = lastExactDateRow?.date ?? null; - + const maxPropertyDate = exactDateRows[exactDateRows.length - 1]?.date ?? null; const chartUnit = useMemo( - () => - minPropertyDate && maxPropertyDate - ? getChartUnit(minPropertyDate, maxPropertyDate) - : 'day', - [minPropertyDate, maxPropertyDate], + () => (minPropertyDate && maxPropertyDate ? getChartUnit(minPropertyDate, maxPropertyDate) : 'day'), + [maxPropertyDate, minPropertyDate], ); const chartRows = useMemo(() => { if (!exactDateRows.length) return []; - const buckets = exactDateRows.reduce( (obj, row) => { const bucketStart = getBucketStart(row.date, chartUnit, dateLocale); const bucketKey = format(bucketStart, "yyyy-MM-dd'T'HH:mm:ss"); - const existing = obj[bucketKey]; - obj[bucketKey] = { t: bucketKey, - y: (existing?.y ?? 0) + row.y, + y: (obj[bucketKey]?.y ?? 0) + row.y, }; - return obj; }, {} as Record, ); - return Object.values(buckets).sort( (a, b) => (parseDateValue(a.t)?.getTime() ?? 0) - (parseDateValue(b.t)?.getTime() ?? 0), ); - }, [exactDateRows, chartUnit, dateLocale]); + }, [chartUnit, dateLocale, exactDateRows]); const chartData: any = useMemo(() => { if (!minPropertyDate || !maxPropertyDate) { @@ -202,28 +176,14 @@ export function EventDataDateChart({ }, ], }; - }, [ - rows, - minPropertyDate, - maxPropertyDate, - chartRows, - chartUnit, - dateLocale, - colors, - t, - labels.count, - ]); + }, [chartRows, chartUnit, colors, dateLocale, labels.count, maxPropertyDate, minPropertyDate, t]); const renderXLabel = useCallback(renderDateLabels(chartUnit, locale), [chartUnit, locale]); const weekdayTableData = useMemo(() => { if (!exactDateRows.length || total === 0) return []; - const weekStartsOn = dateLocale.options?.weekStartsOn ?? 0; - const weekdayOrder = Array.from( - { length: 7 }, - (_, index) => (weekStartsOn + index) % 7, - ); + const weekdayOrder = Array.from({ length: 7 }, (_, index) => (weekStartsOn + index) % 7); const weekdayTotals = exactDateRows.reduce( (obj, { date, y }) => { const day = date.getDay(); @@ -243,51 +203,35 @@ export function EventDataDateChart({ percent: total ? 100 * (count / total) : 0, }; }); - }, [exactDateRows, total, dateLocale]); + }, [dateLocale, exactDateRows, total]); + const topDateByDay = useMemo(() => { if (!exactDateRows.length || total === 0) return null; - const dayTotals = exactDateRows.reduce( (obj, { date, y }) => { const dayDate = startOfDay(date); const key = format(dayDate, 'yyyy-MM-dd'); - const existing = obj[key]; - - obj[key] = { - date: dayDate, - count: (existing?.count ?? 0) + y, - }; - + obj[key] = { date: dayDate, count: (obj[key]?.count ?? 0) + y }; return obj; }, {} as Record, ); - - return Object.values(dayTotals) - .sort((a, b) => b.count - a.count || b.date.getTime() - a.date.getTime())[0]; + return Object.values(dayTotals).sort((a, b) => b.count - a.count || b.date.getTime() - a.date.getTime())[0]; }, [exactDateRows, total]); - const topWeekday = useMemo( - () => [...weekdayTableData].sort((a, b) => b.count - a.count)[0], - [weekdayTableData], - ); - const weekdayLabels = useMemo( - () => weekdayTableData.map(({ label }) => label), - [weekdayTableData], - ); - const weekdayColorMap = useMemo(() => { - return weekdayLabels.reduce( - (obj, label, index) => { - const color = colord(CHART_COLORS[index % CHART_COLORS.length]); - obj[label] = color.alpha(0.7).toRgbString(); + const topWeekday = useMemo(() => [...weekdayTableData].sort((a, b) => b.count - a.count)[0], [weekdayTableData]); + const weekdayLabels = useMemo(() => weekdayTableData.map(({ label }) => label), [weekdayTableData]); + const weekdayColorMap = useMemo( + () => + weekdayLabels.reduce((obj, label, index) => { + obj[label] = CHART_COLORS[index % CHART_COLORS.length]; return obj; - }, - {} as Record, - ); - }, [weekdayLabels]); + }, {} as Record), + [weekdayLabels], + ); + const weekdayPieChartData = useMemo(() => { if (!weekdayTableData.length) return null; - return { labels: weekdayLabels, datasets: [ @@ -298,86 +242,46 @@ export function EventDataDateChart({ }, ], }; - }, [weekdayTableData, weekdayLabels, weekdayColorMap]); + }, [weekdayColorMap, weekdayLabels, weekdayTableData]); + return ( - - - - - - - - - - + + + + + + + + - - - {chartData && ( - formatLongNumber(Number(value))} - height="400px" - /> - )} - + + {chartData && ( + + )} - - - - - {weekdayPieChartData && ( - - )} - + + {weekdayPieChartData && } diff --git a/src/app/(main)/websites/[websiteId]/event-data/EventDataFilterBar.tsx b/src/components/property-data/PropertyFilterBar.tsx similarity index 90% rename from src/app/(main)/websites/[websiteId]/event-data/EventDataFilterBar.tsx rename to src/components/property-data/PropertyFilterBar.tsx index 62fe1d39a..4fa27eac3 100644 --- a/src/app/(main)/websites/[websiteId]/event-data/EventDataFilterBar.tsx +++ b/src/components/property-data/PropertyFilterBar.tsx @@ -1,16 +1,16 @@ 'use client'; +import { Button, Icon, Row, Text, Tooltip, TooltipTrigger } from '@umami/react-zen'; import { useMessages } from '@/components/hooks'; import { X } from '@/components/icons'; import { OPERATORS } from '@/lib/constants'; -import type { EventPropertyFilter } from '@/lib/types'; -import { Button, Icon, Row, Text, Tooltip, TooltipTrigger } from '@umami/react-zen'; +import type { PropertyFilter } from '@/lib/types'; -export function EventDataFilterBar({ +export function PropertyFilterBar({ filters, onChange, }: { - filters: EventPropertyFilter[]; - onChange: (filters: EventPropertyFilter[]) => void; + filters: PropertyFilter[]; + onChange: (filters: PropertyFilter[]) => void; }) { const { t, labels } = useMessages(); @@ -47,10 +47,6 @@ export function EventDataFilterBar({ } }; - const handleRemove = (index: number) => { - onChange(filters.filter((_, i) => i !== index)); - }; - return ( handleRemove(index)} + onRemove={() => onChange(filters.filter((_, i) => i !== index))} /> ))} diff --git a/src/components/property-data/PropertyFilterButton.tsx b/src/components/property-data/PropertyFilterButton.tsx new file mode 100644 index 000000000..b5beb3de6 --- /dev/null +++ b/src/components/property-data/PropertyFilterButton.tsx @@ -0,0 +1,41 @@ +'use client'; +import { useMessages } from '@/components/hooks'; +import { ListFilter } from '@/components/icons'; +import { DialogButton } from '@/components/input/DialogButton'; +import type { PropertyFilter } from '@/lib/types'; +import type { PropertyDataSource } from '@/components/hooks/queries/usePropertyFieldsQuery'; +import { PropertyFilterEditForm } from './PropertyFilterEditForm'; + +export function PropertyFilterButton({ + source, + websiteId, + eventName, + fields, + filters, + onApply, +}: { + source: PropertyDataSource; + websiteId: string; + eventName?: string; + fields?: Array<{ propertyName: string; dataType: number }>; + filters: PropertyFilter[]; + onApply: (filters: PropertyFilter[]) => void; +}) { + const { t, labels } = useMessages(); + + return ( + } label={t(labels.propertyFilter)} variant="outline"> + {({ close }) => ( + + )} + + ); +} diff --git a/src/app/(main)/websites/[websiteId]/event-data/EventDataFilterEditForm.tsx b/src/components/property-data/PropertyFilterEditForm.tsx similarity index 67% rename from src/app/(main)/websites/[websiteId]/event-data/EventDataFilterEditForm.tsx rename to src/components/property-data/PropertyFilterEditForm.tsx index ab394b0d0..6d2db0b5f 100644 --- a/src/app/(main)/websites/[websiteId]/event-data/EventDataFilterEditForm.tsx +++ b/src/components/property-data/PropertyFilterEditForm.tsx @@ -3,34 +3,41 @@ import { Button, Column, Grid, Icon, List, ListItem, Menu, MenuItem, MenuTrigger import { format } from 'date-fns'; import { useState } from 'react'; import { Empty } from '@/components/common/Empty'; -import { useEventDataFieldsQuery, useMessages, useMobile } from '@/components/hooks'; +import { useMessages, useMobile, usePropertyFieldsQuery } from '@/components/hooks'; import { Plus } from '@/components/icons'; import { DATA_TYPE, OPERATORS } from '@/lib/constants'; -import type { EventPropertyFilter } from '@/lib/types'; -import { EventDataFilterRecord } from './EventDataFilterRecord'; +import type { PropertyFilter } from '@/lib/types'; +import type { PropertyDataSource } from '@/components/hooks/queries/usePropertyFieldsQuery'; +import { PropertyFilterRecord } from './PropertyFilterRecord'; -export function EventDataFilterEditForm({ +export function PropertyFilterEditForm({ + source, websiteId, eventName, + fields: providedFields, value, onApply, onClose, }: { + source: PropertyDataSource; websiteId: string; - eventName: string; - value: EventPropertyFilter[]; - onApply: (filters: EventPropertyFilter[]) => void; + eventName?: string; + fields?: Array<{ propertyName: string; dataType: number }>; + value: PropertyFilter[]; + onApply: (filters: PropertyFilter[]) => void; onClose: () => void; }) { const { t, labels, messages } = useMessages(); const { isMobile } = useMobile(); - const [filters, setFilters] = useState(value); - - const { data: fields = [] } = useEventDataFieldsQuery(websiteId, eventName); + const [filters, setFilters] = useState(value); + const { data: queriedFields = [] } = usePropertyFieldsQuery(source, websiteId, eventName, { + enabled: !providedFields, + }); + const fields = providedFields ?? queriedFields; const handleAdd = (propertyName: string) => { const field = (fields as any[]).find(f => f.propertyName === propertyName); - const dataType: number = field?.dataType ?? 1; + const dataType: number = field?.dataType ?? DATA_TYPE.string; setFilters(prev => [ ...prev, { @@ -52,14 +59,6 @@ export function EventDataFilterEditForm({ ]); }; - const handleChange = (index: number, filter: EventPropertyFilter) => { - setFilters(prev => prev.map((f, i) => (i === index ? filter : f))); - }; - - const handleRemove = (index: number) => { - setFilters(prev => prev.filter((_, i) => i !== index)); - }; - return ( @@ -77,7 +76,7 @@ export function EventDataFilterEditForm({ style={{ maxHeight: 'calc(100vh - 2rem)', overflowY: 'auto' }} > {(fields as any[]).map(field => ( - + {field.propertyName} ))} @@ -93,7 +92,7 @@ export function EventDataFilterEditForm({ > handleAdd(key.toString())}> {(fields as any[]).map(field => ( - + {field.propertyName} ))} @@ -101,13 +100,15 @@ export function EventDataFilterEditForm({ {filters.map((filter, index) => ( - handleChange(index, f)} - onRemove={() => handleRemove(index)} + filters={filters} + onChange={f => setFilters(prev => prev.map((item, i) => (i === index ? f : item)))} + onRemove={() => setFilters(prev => prev.filter((_, i) => i !== index))} /> ))} {!filters.length && } @@ -118,7 +119,13 @@ export function EventDataFilterEditForm({ - diff --git a/src/app/(main)/websites/[websiteId]/event-data/EventDataFilterRecord.tsx b/src/components/property-data/PropertyFilterRecord.tsx similarity index 89% rename from src/app/(main)/websites/[websiteId]/event-data/EventDataFilterRecord.tsx rename to src/components/property-data/PropertyFilterRecord.tsx index e6d58027a..9a36df9f4 100644 --- a/src/app/(main)/websites/[websiteId]/event-data/EventDataFilterRecord.tsx +++ b/src/components/property-data/PropertyFilterRecord.tsx @@ -5,11 +5,12 @@ import { useState } from 'react'; import { DateDisplay } from '@/components/common/DateDisplay'; import { Empty } from '@/components/common/Empty'; import { MultiSelect } from '@/components/common/MultiSelect'; -import { useEventDataValuesQuery, useMessages } from '@/components/hooks'; +import { useMessages, usePropertyValuesQuery } from '@/components/hooks'; import { X } from '@/components/icons'; import { DATA_TYPE, OPERATORS } from '@/lib/constants'; import { getMaxSelectableDate } from '@/lib/date'; -import type { EventPropertyFilter, Operator } from '@/lib/types'; +import type { Operator, PropertyFilter } from '@/lib/types'; +import type { PropertyDataSource } from '@/components/hooks/queries/usePropertyFieldsQuery'; const STRING_OPERATORS: Operator[] = [ OPERATORS.equals, @@ -38,17 +39,21 @@ const FREE_TEXT_OPERATORS: Operator[] = [ OPERATORS.notRegex, ]; -export function EventDataFilterRecord({ +export function PropertyFilterRecord({ + source, websiteId, eventName, filter, + filters, onChange, onRemove, }: { + source: PropertyDataSource; websiteId: string; - eventName: string; - filter: EventPropertyFilter; - onChange: (filter: EventPropertyFilter) => void; + eventName?: string; + filter: PropertyFilter; + filters: PropertyFilter[]; + onChange: (filter: PropertyFilter) => void; onRemove: () => void; }) { const { t, labels, messages } = useMessages(); @@ -69,11 +74,13 @@ export function EventDataFilterRecord({ : STRING_OPERATORS; const isFreeText = !isArray && FREE_TEXT_OPERATORS.includes(filter.operator); - const { data, isLoading } = useEventDataValuesQuery( + const { data, isLoading } = usePropertyValuesQuery( + source, websiteId, - eventName, filter.propertyName, filter.dataType, + filters.filter(f => f !== filter), + eventName, { enabled: !isNumeric && !isBoolean && !isDate && !isFreeText }, ); @@ -102,7 +109,6 @@ export function EventDataFilterRecord({ }; const handleOperatorChange = (op: Operator) => { - // clear value when switching between multi-select and free-text modes const wasMulti = MULTI_OPERATORS.includes(filter.operator); const isMulti = MULTI_OPERATORS.includes(op); onChange({ @@ -218,19 +224,15 @@ function DateValuePicker({ const selectedDate = getSelectedDate(value); const [draftDate, setDraftDate] = useState(selectedDate); - const handleOpen = () => { - setDraftDate(getSelectedDate(value)); - setShowPicker(true); - }; - - const handleApply = () => { - onChange(format(draftDate, 'yyyy-MM-dd')); - setShowPicker(false); - }; - return ( - @@ -244,7 +246,13 @@ function DateValuePicker({ /> - diff --git a/src/app/(main)/websites/[websiteId]/event-data/EventDataNumericChart.tsx b/src/components/property-data/PropertyNumericChart.tsx similarity index 65% rename from src/app/(main)/websites/[websiteId]/event-data/EventDataNumericChart.tsx rename to src/components/property-data/PropertyNumericChart.tsx index 94da88f82..ed98e23df 100644 --- a/src/app/(main)/websites/[websiteId]/event-data/EventDataNumericChart.tsx +++ b/src/components/property-data/PropertyNumericChart.tsx @@ -5,30 +5,33 @@ import { BarChart } from '@/components/charts/BarChart'; import { LoadingPanel } from '@/components/common/LoadingPanel'; import { useDateRange, - useEventDataNumericSeriesQuery, - useEventDataNumericStatsQuery, useLocale, useMessages, + usePropertyNumericSeriesQuery, + usePropertyNumericStatsQuery, useTimezone, } from '@/components/hooks'; +import type { PropertyDataSource } from '@/components/hooks/queries/usePropertyFieldsQuery'; import { MetricCard } from '@/components/metrics/MetricCard'; import { MetricsBar } from '@/components/metrics/MetricsBar'; import { renderDateLabels } from '@/lib/charts'; import { getThemeColors } from '@/lib/colors'; import { generateTimeSeries } from '@/lib/date'; import { formatLongNumber } from '@/lib/format'; -import type { EventPropertyFilter } from '@/lib/types'; +import type { PropertyFilter } from '@/lib/types'; -export function EventDataNumericChart({ +export function PropertyNumericChart({ + source, websiteId, - eventName, propertyName, - eventFilters = [], + propertyFilters = [], + eventName, }: { + source: PropertyDataSource; websiteId: string; - eventName: string; propertyName: string; - eventFilters?: EventPropertyFilter[]; + propertyFilters?: PropertyFilter[]; + eventName?: string; }) { const { t, labels } = useMessages(); const { theme } = useTheme(); @@ -37,35 +40,12 @@ export function EventDataNumericChart({ const { locale, dateLocale } = useLocale(); const { colors } = useMemo(() => getThemeColors(theme), [theme]); - const sumQuery = useEventDataNumericSeriesQuery( - websiteId, - eventName, - propertyName, - 'sum', - eventFilters, - ); - const avgQuery = useEventDataNumericSeriesQuery( - websiteId, - eventName, - propertyName, - 'avg', - eventFilters, - ); - const statsQuery = useEventDataNumericStatsQuery( - websiteId, - eventName, - propertyName, - eventFilters, - ); + const sumQuery = usePropertyNumericSeriesQuery(source, websiteId, propertyName, 'sum', propertyFilters, eventName); + const avgQuery = usePropertyNumericSeriesQuery(source, websiteId, propertyName, 'avg', propertyFilters, eventName); + const statsQuery = usePropertyNumericStatsQuery(source, websiteId, propertyName, propertyFilters, eventName); - const sumRows = useMemo( - () => (sumQuery.data as { t: string; y: number }[] | undefined) ?? [], - [sumQuery.data], - ); - const avgRows = useMemo( - () => (avgQuery.data as { t: string; y: number }[] | undefined) ?? [], - [avgQuery.data], - ); + const sumRows = useMemo(() => (sumQuery.data as { t: string; y: number }[] | undefined) ?? [], [sumQuery.data]); + const avgRows = useMemo(() => (avgQuery.data as { t: string; y: number }[] | undefined) ?? [], [avgQuery.data]); const stats = statsQuery.data; const formatMetricValue = useCallback( @@ -114,7 +94,7 @@ export function EventDataNumericChart({ }, ], }; - }, [sumQuery.data, avgQuery.data, t, labels, startDate, endDate, unit, dateLocale, colors, sumRows, avgRows]); + }, [avgQuery.data, avgRows, colors, dateLocale, endDate, labels.average, labels.sum, startDate, sumQuery.data, sumRows, t, unit]); const renderXLabel = useCallback(renderDateLabels(unit, locale), [unit, locale]); @@ -128,31 +108,11 @@ export function EventDataNumericChart({ minHeight="100px" > - - - - - + + + + + , options?: QueryOptions) { }; } -function getEventPropertyFilterQuery( - filters: EventPropertyFilter[] = [], +function getPropertyFilterQuery( + filters: PropertyFilter[] = [], + propertyType: 'event' | 'session' = 'event', timezone?: string, ): { sql: string; @@ -249,10 +250,13 @@ function getEventPropertyFilterQuery( const parts: string[] = []; const params: Record = {}; + const table = propertyType === 'event' ? 'event_data' : 'session_data final'; + const column = propertyType === 'event' ? 'event_id' : 'session_id'; + const outerColumn = propertyType === 'event' ? 'event_id' : 'website_event.session_id'; filters.forEach(({ propertyName, dataType, operator, value }, i) => { - const keyParam = `epf_key_${i}`; - const valParam = `epf_val_${i}`; + const keyParam = `pf_key_${i}`; + const valParam = `pf_val_${i}`; params[keyParam] = propertyName; let condition: string; @@ -329,9 +333,9 @@ function getEventPropertyFilterQuery( } } - parts.push(`and event_id in ( - select event_id - from event_data + parts.push(`and ${outerColumn} in ( + select ${column} + from ${table} where website_id = {websiteId:UUID} and created_at between {startDate:DateTime64} and {endDate:DateTime64} and data_key = {${keyParam}:String} @@ -429,7 +433,7 @@ export default { getDateSQL, getSearchSQL, getFilterQuery, - getEventPropertyFilterQuery, + getPropertyFilterQuery, getUTCString, parseFilters, pagedRawQuery, diff --git a/src/lib/params.ts b/src/lib/params.ts index 6f430ddbe..50cb6db2d 100644 --- a/src/lib/params.ts +++ b/src/lib/params.ts @@ -1,5 +1,5 @@ import { DATA_TYPE, FILTER_COLUMNS, OPERATORS } from '@/lib/constants'; -import type { EventPropertyFilter, Filter, Operator, QueryFilters, QueryOptions } from '@/lib/types'; +import type { Filter, Operator, PropertyFilter, QueryFilters, QueryOptions } from '@/lib/types'; const VALID_OPERATORS: Operator[] = Object.values(OPERATORS); const VALID_EVENT_DATA_TYPES = Object.values(DATA_TYPE); @@ -100,12 +100,24 @@ export function filtersArrayToObject(filters: Filter[]) { }, {}); } -export function parseEventPropertyFilters(query: Record): EventPropertyFilter[] { +function getPropertyFilterPrefix(key: string, prefixes: string[]) { + return prefixes.find(prefix => key.startsWith(`${prefix}_`)); +} + +export function parsePropertyFilters( + query: Record, + prefixes: string[] = ['pf'], +): PropertyFilter[] { return Object.entries(query) - .filter(([key]) => /^epf_/.test(key)) .flatMap(([key, val]) => { + const prefix = getPropertyFilterPrefix(key, prefixes); + + if (!prefix) { + return []; + } + const stringValue = String(val); - const withoutPrefix = key.slice(4); // strip "epf_" + const withoutPrefix = key.slice(prefix.length + 1); const propertyName = withoutPrefix.replace(/\d+$/, ''); // strip trailing index digits const prefixedDotMatch = stringValue.match(/^(\d+)\.([^.]+)\.(.*)$/); const untypedDotMatch = stringValue.match(/^([^.]+)\.(.*)$/); @@ -134,13 +146,24 @@ export function parseEventPropertyFilters(query: Record): EventProp }); } -export function serializeEventPropertyFilters(filters: EventPropertyFilter[]): Record { +export function parseEventPropertyFilters(query: Record) { + return parsePropertyFilters(query, ['pf']); +} + +export function serializePropertyFilters( + filters: PropertyFilter[], + prefix = 'pf', +): Record { const counts: Record = {}; return Object.fromEntries( filters.map(f => { const n = counts[f.propertyName] ?? 0; counts[f.propertyName] = n + 1; - return [`epf_${f.propertyName}${n > 0 ? n : ''}`, `${f.dataType}.${f.operator}.${f.value}`]; + return [`${prefix}_${f.propertyName}${n > 0 ? n : ''}`, `${f.dataType}.${f.operator}.${f.value}`]; }), ); } + +export function serializeEventPropertyFilters(filters: PropertyFilter[]) { + return serializePropertyFilters(filters, 'pf'); +} diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts index baab7c2a4..1da0f81b7 100644 --- a/src/lib/prisma.ts +++ b/src/lib/prisma.ts @@ -4,7 +4,7 @@ import { readReplicas } from '@prisma/extension-read-replicas'; import debug from 'debug'; import { DATA_TYPE, DEFAULT_PAGE_SIZE, FILTER_COLUMNS, OPERATORS, SESSION_COLUMNS } from './constants'; import { filtersObjectToArray } from './params'; -import type { EventPropertyFilter, Operator, QueryFilters, QueryOptions } from './types'; +import type { Operator, PropertyFilter, QueryFilters, QueryOptions } from './types'; const log = debug('umami:prisma'); @@ -273,8 +273,9 @@ function parseFilters(filters: Record, options?: QueryOptions) { }; } -function getEventPropertyFilterQuery( - filters: EventPropertyFilter[] = [], +function getPropertyFilterQuery( + filters: PropertyFilter[] = [], + propertyType: 'event' | 'session' = 'event', timezone?: string, ): { sql: string; @@ -284,10 +285,14 @@ function getEventPropertyFilterQuery( const parts: string[] = []; const params: Record = {}; + const table = propertyType === 'event' ? 'event_data' : 'session_data'; + const column = propertyType === 'event' ? 'website_event_id' : 'session_id'; + const outerColumn = + propertyType === 'event' ? 'website_event.event_id' : 'website_event.session_id'; filters.forEach(({ propertyName, dataType, operator, value }, i) => { - const keyParam = `epf_key_${i}`; - const valParam = `epf_val_${i}`; + const keyParam = `pf_key_${i}`; + const valParam = `pf_val_${i}`; params[keyParam] = propertyName; let condition: string; @@ -346,8 +351,8 @@ function getEventPropertyFilterQuery( params[valParam] = vals; condition = operator === OPERATORS.equals - ? `${col} = ANY({{${valParam}::text[]}})` - : `${col} != ALL({{${valParam}::text[]}})`; + ? `${col} = ANY({{${valParam}}})` + : `${col} != ALL({{${valParam}}})`; } else if (REGEX_OPERATORS.includes(operator)) { if (!value) return; params[valParam] = value; @@ -367,9 +372,9 @@ function getEventPropertyFilterQuery( } } - parts.push(`and website_event.event_id in ( - select website_event_id - from event_data + parts.push(`and ${outerColumn} in ( + select ${column} + from ${table} where website_id = {{websiteId::uuid}} and created_at between {{startDate}} and {{endDate}} and data_key = {{${keyParam}}} @@ -567,7 +572,7 @@ export default { getDateStringSQL, getDateWeeklySQL, getFilterQuery, - getEventPropertyFilterQuery, + getPropertyFilterQuery, getSearchParameters, getTimestampDiffSQL, getSearchSQL, diff --git a/src/lib/request.ts b/src/lib/request.ts index 40ffabe06..9b32b6736 100644 --- a/src/lib/request.ts +++ b/src/lib/request.ts @@ -32,7 +32,7 @@ export async function parseRequest( // Re-add dynamic params stripped by Zod schema: suffixed filter params (browser1, os2) for (const key of Object.keys(rawQuery)) { - if ((/\d+$/.test(key) || /^epf_/.test(key)) && !(key in query)) { + if ((/\d+$/.test(key) || /^pf_/.test(key)) && !(key in query)) { query[key] = rawQuery[key]; } } diff --git a/src/lib/types.ts b/src/lib/types.ts index c93854910..587f77b96 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -30,13 +30,15 @@ export interface Auth { }; } -export interface EventPropertyFilter { +export interface PropertyFilter { propertyName: string; dataType: number; operator: Operator; value: string; } +export type EventPropertyFilter = PropertyFilter; + export interface Filter { name: string; operator: Operator; @@ -79,6 +81,31 @@ export interface EventDataNumericStats { min: number; } +export interface SessionDataPivotRow { + sessionId: string; + distinctId: string; + createdAt: string | Date; + propertyKeys: string[]; + propertyValues: string[]; +} + +export interface PropertyCardinalityBucket { + label: string; + count: number; +} + +export interface PropertyLeaderboardRow { + label: string; + activity: number; + sessions: number; + visits: number; + views: number; + events: number; + totaltime: number; + newSessions: number; + returningSessions: number; +} + export interface QueryOptions { joinSession?: boolean; columns?: Record; diff --git a/src/queries/sql/events/getEventDataArraySeries.ts b/src/queries/sql/events/getEventDataArraySeries.ts index acf1e2034..6d3e3989c 100644 --- a/src/queries/sql/events/getEventDataArraySeries.ts +++ b/src/queries/sql/events/getEventDataArraySeries.ts @@ -29,13 +29,13 @@ async function relationalQuery( eventFilters: EventPropertyFilter[] = [], ): Promise { const { timezone = 'utc', unit = 'day' } = filters; - const { rawQuery, getDateSQL, parseFilters, getEventPropertyFilterQuery } = prisma; + const { rawQuery, getDateSQL, parseFilters, getPropertyFilterQuery } = prisma; const { filterQuery, cohortQuery, joinSessionQuery, queryParams } = parseFilters({ ...filters, websiteId, timezone, }); - const { sql: epfSQL, params: epfParams } = getEventPropertyFilterQuery(eventFilters, timezone); + const { sql: pfSQL, params: pfParams } = getPropertyFilterQuery(eventFilters, 'event', timezone); return rawQuery( ` @@ -57,11 +57,11 @@ async function relationalQuery( and event_data.data_key = {{propertyName}} and event_data.data_type = ${DATA_TYPE.array} ${filterQuery} - ${epfSQL} + ${pfSQL} group by 1, 2 order by 2 `, - { ...queryParams, eventName, propertyName, ...epfParams }, + { ...queryParams, eventName, propertyName, ...pfParams }, FUNCTION_NAME, ); } @@ -74,9 +74,9 @@ async function clickhouseQuery( eventFilters: EventPropertyFilter[] = [], ): Promise { const { timezone = 'UTC', unit = 'day' } = filters; - const { rawQuery, getDateSQL, parseFilters, getEventPropertyFilterQuery } = clickhouse; + const { rawQuery, getDateSQL, parseFilters, getPropertyFilterQuery } = clickhouse; const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId, timezone }); - const { sql: epfSQL, params: epfParams } = getEventPropertyFilterQuery(eventFilters, timezone); + const { sql: pfSQL, params: pfParams } = getPropertyFilterQuery(eventFilters, 'event', timezone); return rawQuery( ` @@ -101,11 +101,11 @@ async function clickhouseQuery( and event_data.data_key = {propertyName:String} and event_data.data_type = ${DATA_TYPE.array} ${filterQuery} - ${epfSQL} + ${pfSQL} group by x, t order by t `, - { ...queryParams, eventName, propertyName, ...epfParams }, + { ...queryParams, eventName, propertyName, ...pfParams }, FUNCTION_NAME, ); } diff --git a/src/queries/sql/events/getEventDataDateSeries.ts b/src/queries/sql/events/getEventDataDateSeries.ts index 711968926..26719dbc3 100644 --- a/src/queries/sql/events/getEventDataDateSeries.ts +++ b/src/queries/sql/events/getEventDataDateSeries.ts @@ -29,13 +29,13 @@ async function relationalQuery( eventFilters: EventPropertyFilter[] = [], ) : Promise { const { timezone = 'utc' } = filters; - const { rawQuery, parseFilters, getDateStringSQL, getEventPropertyFilterQuery } = prisma; + const { rawQuery, parseFilters, getDateStringSQL, getPropertyFilterQuery } = prisma; const { filterQuery, cohortQuery, joinSessionQuery, queryParams } = parseFilters({ ...filters, websiteId, timezone, }); - const { sql: epfSQL, params: epfParams } = getEventPropertyFilterQuery(eventFilters, timezone); + const { sql: pfSQL, params: pfParams } = getPropertyFilterQuery(eventFilters, 'event', timezone); return rawQuery( ` @@ -55,11 +55,11 @@ async function relationalQuery( and event_data.data_key = {{propertyName}} and event_data.data_type = ${DATA_TYPE.date} ${filterQuery} - ${epfSQL} + ${pfSQL} group by 1 order by 1 `, - { ...queryParams, eventName, propertyName, ...epfParams }, + { ...queryParams, eventName, propertyName, ...pfParams }, FUNCTION_NAME, ); } @@ -72,9 +72,9 @@ async function clickhouseQuery( eventFilters: EventPropertyFilter[] = [], ): Promise { const { timezone = 'UTC' } = filters; - const { rawQuery, parseFilters, getDateStringSQL, getEventPropertyFilterQuery } = clickhouse; + const { rawQuery, parseFilters, getDateStringSQL, getPropertyFilterQuery } = clickhouse; const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId, timezone }); - const { sql: epfSQL, params: epfParams } = getEventPropertyFilterQuery(eventFilters, timezone); + const { sql: pfSQL, params: pfParams } = getPropertyFilterQuery(eventFilters, 'event', timezone); return rawQuery( ` @@ -98,11 +98,11 @@ async function clickhouseQuery( and event_data.data_key = {propertyName:String} and event_data.data_type = ${DATA_TYPE.date} ${filterQuery} - ${epfSQL} + ${pfSQL} group by t order by t `, - { ...queryParams, eventName, propertyName, ...epfParams }, + { ...queryParams, eventName, propertyName, ...pfParams }, FUNCTION_NAME, ); } diff --git a/src/queries/sql/events/getEventDataNumericSeries.ts b/src/queries/sql/events/getEventDataNumericSeries.ts index 2624380ba..1fca6c0f6 100644 --- a/src/queries/sql/events/getEventDataNumericSeries.ts +++ b/src/queries/sql/events/getEventDataNumericSeries.ts @@ -30,13 +30,13 @@ async function relationalQuery( eventFilters: EventPropertyFilter[] = [], ) { const { timezone = 'utc', unit = 'day' } = filters; - const { rawQuery, getDateSQL, parseFilters, getEventPropertyFilterQuery } = prisma; + const { rawQuery, getDateSQL, parseFilters, getPropertyFilterQuery } = prisma; const { filterQuery, cohortQuery, joinSessionQuery, queryParams } = parseFilters({ ...filters, websiteId, timezone, }); - const { sql: epfSQL, params: epfParams } = getEventPropertyFilterQuery(eventFilters, timezone); + const { sql: pfSQL, params: pfParams } = getPropertyFilterQuery(eventFilters, 'event', timezone); const aggSql = metric === 'avg' ? 'avg(cast(event_data.number_value as decimal))' : metric === 'count' ? 'count(*)' : @@ -60,11 +60,11 @@ async function relationalQuery( and event_data.data_key = {{propertyName}} and event_data.data_type = 2 ${filterQuery} - ${epfSQL} + ${pfSQL} group by 1 order by 1 `, - { ...queryParams, eventName, propertyName, ...epfParams }, + { ...queryParams, eventName, propertyName, ...pfParams }, FUNCTION_NAME, ); } @@ -78,9 +78,9 @@ async function clickhouseQuery( eventFilters: EventPropertyFilter[] = [], ): Promise<{ t: string; y: number }[]> { const { timezone = 'UTC', unit = 'day' } = filters; - const { rawQuery, getDateSQL, parseFilters, getEventPropertyFilterQuery } = clickhouse; + const { rawQuery, getDateSQL, parseFilters, getPropertyFilterQuery } = clickhouse; const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId, timezone }); - const { sql: epfSQL, params: epfParams } = getEventPropertyFilterQuery(eventFilters, timezone); + const { sql: pfSQL, params: pfParams } = getPropertyFilterQuery(eventFilters, 'event', timezone); const aggSql = metric === 'avg' ? 'avg(event_data.number_value)' : metric === 'count' ? 'count()' : @@ -108,11 +108,11 @@ async function clickhouseQuery( and event_data.data_key = {propertyName:String} and event_data.data_type = 2 ${filterQuery} - ${epfSQL} + ${pfSQL} group by t order by t `, - { ...queryParams, eventName, propertyName, ...epfParams }, + { ...queryParams, eventName, propertyName, ...pfParams }, FUNCTION_NAME, ); } diff --git a/src/queries/sql/events/getEventDataNumericStats.ts b/src/queries/sql/events/getEventDataNumericStats.ts index 64a3f884b..90896d989 100644 --- a/src/queries/sql/events/getEventDataNumericStats.ts +++ b/src/queries/sql/events/getEventDataNumericStats.ts @@ -28,13 +28,13 @@ async function relationalQuery( eventFilters: EventPropertyFilter[] = [], ) { const { timezone = 'utc' } = filters; - const { rawQuery, parseFilters, getEventPropertyFilterQuery } = prisma; + const { rawQuery, parseFilters, getPropertyFilterQuery } = prisma; const { filterQuery, cohortQuery, joinSessionQuery, queryParams } = parseFilters({ ...filters, websiteId, timezone, }); - const { sql: epfSQL, params: epfParams } = getEventPropertyFilterQuery(eventFilters, timezone); + const { sql: pfSQL, params: pfParams } = getPropertyFilterQuery(eventFilters, 'event', timezone); return rawQuery( ` @@ -57,9 +57,9 @@ async function relationalQuery( and event_data.data_key = {{propertyName}} and event_data.data_type = 2 ${filterQuery} - ${epfSQL} + ${pfSQL} `, - { ...queryParams, eventName, propertyName, ...epfParams }, + { ...queryParams, eventName, propertyName, ...pfParams }, FUNCTION_NAME, ); } @@ -72,9 +72,9 @@ async function clickhouseQuery( eventFilters: EventPropertyFilter[] = [], ): Promise { const { timezone = 'UTC' } = filters; - const { rawQuery, parseFilters, getEventPropertyFilterQuery } = clickhouse; + const { rawQuery, parseFilters, getPropertyFilterQuery } = clickhouse; const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId, timezone }); - const { sql: epfSQL, params: epfParams } = getEventPropertyFilterQuery(eventFilters, timezone); + const { sql: pfSQL, params: pfParams } = getPropertyFilterQuery(eventFilters, 'event', timezone); return rawQuery( ` @@ -101,9 +101,9 @@ async function clickhouseQuery( and event_data.data_key = {propertyName:String} and event_data.data_type = 2 ${filterQuery} - ${epfSQL} + ${pfSQL} `, - { ...queryParams, eventName, propertyName, ...epfParams }, + { ...queryParams, eventName, propertyName, ...pfParams }, FUNCTION_NAME, ); } diff --git a/src/queries/sql/events/getEventDataPivot.ts b/src/queries/sql/events/getEventDataPivot.ts index 18f8aabda..7a494a1da 100644 --- a/src/queries/sql/events/getEventDataPivot.ts +++ b/src/queries/sql/events/getEventDataPivot.ts @@ -22,7 +22,7 @@ export async function getEventDataPivot( async function relationalQuery(websiteId: string, eventName: string, filters: QueryFilters, eventFilters: EventPropertyFilter[] = []) { const { timezone = 'utc' } = filters; - const { rawQuery, parseFilters, getEventPropertyFilterQuery } = prisma; + const { rawQuery, parseFilters, getPropertyFilterQuery } = prisma; const { page = 1, pageSize } = filters; const size = +pageSize || DEFAULT_PAGE_SIZE; const offset = +size * (+page - 1); @@ -32,7 +32,7 @@ async function relationalQuery(websiteId: string, eventName: string, filters: Qu websiteId, timezone, }); - const { sql: epfSQL, params: epfParams } = getEventPropertyFilterQuery(eventFilters, timezone); + const { sql: pfSQL, params: pfParams } = getPropertyFilterQuery(eventFilters, 'event', timezone); const countResult = await rawQuery( ` @@ -47,9 +47,9 @@ async function relationalQuery(websiteId: string, eventName: string, filters: Qu and website_event.created_at between {{startDate}} and {{endDate}} and website_event.event_name = {{eventName}} ${filterQuery} - ${epfSQL} + ${pfSQL} `, - { ...queryParams, eventName, ...epfParams }, + { ...queryParams, eventName, ...pfParams }, ); const count = countResult[0].num; @@ -68,7 +68,7 @@ async function relationalQuery(websiteId: string, eventName: string, filters: Qu and website_event.created_at between {{startDate}} and {{endDate}} and website_event.event_name = {{eventName}} ${filterQuery} - ${epfSQL} + ${pfSQL} group by website_event.event_id order by max(website_event.created_at) desc limit ${size} offset ${offset} @@ -97,7 +97,7 @@ async function relationalQuery(websiteId: string, eventName: string, filters: Qu and event_data.created_at between {{startDate}} and {{endDate}} order by website_event.created_at desc `, - { ...queryParams, eventName, ...epfParams }, + { ...queryParams, eventName, ...pfParams }, FUNCTION_NAME, ); @@ -120,13 +120,13 @@ async function relationalQuery(websiteId: string, eventName: string, filters: Qu async function clickhouseQuery(websiteId: string, eventName: string, filters: QueryFilters, eventFilters: EventPropertyFilter[] = []) { const { timezone = 'UTC' } = filters; - const { rawQuery, parseFilters, getEventPropertyFilterQuery } = clickhouse; + const { rawQuery, parseFilters, getPropertyFilterQuery } = clickhouse; const { page = 1, pageSize } = filters; const size = +pageSize || DEFAULT_PAGE_SIZE; const offset = +size * (+page - 1); const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId, timezone }); - const { sql: epfSQL, params: epfParams } = getEventPropertyFilterQuery(eventFilters, timezone); + const { sql: pfSQL, params: pfParams } = getPropertyFilterQuery(eventFilters, 'event', timezone); const count = await rawQuery( ` @@ -146,9 +146,9 @@ async function clickhouseQuery(websiteId: string, eventName: string, filters: Qu where event_data_pivot.website_id = {websiteId:UUID} and event_data_pivot.created_at between {startDate:DateTime64} and {endDate:DateTime64} ${filterQuery} - ${epfSQL} + ${pfSQL} `, - { ...queryParams, eventName, ...epfParams }, + { ...queryParams, eventName, ...pfParams }, ).then((res: any) => res[0].num); const data = await rawQuery( @@ -176,7 +176,7 @@ async function clickhouseQuery(websiteId: string, eventName: string, filters: Qu where event_data_pivot.website_id = {websiteId:UUID} and event_data_pivot.created_at between {startDate:DateTime64} and {endDate:DateTime64} ${filterQuery} - ${epfSQL} + ${pfSQL} group by event_data_pivot.event_id, event_data_pivot.session_id, @@ -186,7 +186,7 @@ async function clickhouseQuery(websiteId: string, eventName: string, filters: Qu order by event_data_pivot.created_at desc limit ${size} offset ${offset} `, - { ...queryParams, eventName, ...epfParams }, + { ...queryParams, eventName, ...pfParams }, FUNCTION_NAME, ); diff --git a/src/queries/sql/events/getEventDataPropertySeries.ts b/src/queries/sql/events/getEventDataPropertySeries.ts index e48045236..b2de7d379 100644 --- a/src/queries/sql/events/getEventDataPropertySeries.ts +++ b/src/queries/sql/events/getEventDataPropertySeries.ts @@ -29,13 +29,13 @@ async function relationalQuery( eventFilters: EventPropertyFilter[] = [], ): Promise { const { timezone = 'utc', unit = 'day' } = filters; - const { rawQuery, getDateSQL, parseFilters, getEventPropertyFilterQuery } = prisma; + const { rawQuery, getDateSQL, parseFilters, getPropertyFilterQuery } = prisma; const { filterQuery, cohortQuery, joinSessionQuery, queryParams } = parseFilters({ ...filters, websiteId, timezone, }); - const { sql: epfSQL, params: epfParams } = getEventPropertyFilterQuery(eventFilters, timezone); + const { sql: pfSQL, params: pfParams } = getPropertyFilterQuery(eventFilters, 'event', timezone); return rawQuery( ` @@ -56,11 +56,11 @@ async function relationalQuery( and event_data.data_key = {{propertyName}} and event_data.data_type in (${DATA_TYPE.string}, ${DATA_TYPE.boolean}) ${filterQuery} - ${epfSQL} + ${pfSQL} group by 1, 2 order by 2 `, - { ...queryParams, eventName, propertyName, ...epfParams }, + { ...queryParams, eventName, propertyName, ...pfParams }, FUNCTION_NAME, ); } @@ -73,9 +73,9 @@ async function clickhouseQuery( eventFilters: EventPropertyFilter[] = [], ): Promise { const { timezone = 'UTC', unit = 'day' } = filters; - const { rawQuery, getDateSQL, parseFilters, getEventPropertyFilterQuery } = clickhouse; + const { rawQuery, getDateSQL, parseFilters, getPropertyFilterQuery } = clickhouse; const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId, timezone }); - const { sql: epfSQL, params: epfParams } = getEventPropertyFilterQuery(eventFilters, timezone); + const { sql: pfSQL, params: pfParams } = getPropertyFilterQuery(eventFilters, 'event', timezone); return rawQuery( ` @@ -100,11 +100,11 @@ async function clickhouseQuery( and event_data.data_key = {propertyName:String} and event_data.data_type in (${DATA_TYPE.string}, ${DATA_TYPE.boolean}) ${filterQuery} - ${epfSQL} + ${pfSQL} group by x, t order by t `, - { ...queryParams, eventName, propertyName, ...epfParams }, + { ...queryParams, eventName, propertyName, ...pfParams }, FUNCTION_NAME, ); } diff --git a/src/queries/sql/events/getEventDataValues.ts b/src/queries/sql/events/getEventDataValues.ts index 5063646b7..8196ff5ce 100644 --- a/src/queries/sql/events/getEventDataValues.ts +++ b/src/queries/sql/events/getEventDataValues.ts @@ -76,6 +76,7 @@ async function relationalQuery( where event_data.website_id = {{websiteId::uuid}} and event_data.created_at between {{startDate}} and {{endDate}} and event_data.data_key = {{propertyName}} + ${dataType ? `and event_data.data_type = ${dataType}` : ''} ${filterQuery} group by value order by 2 desc @@ -146,6 +147,7 @@ async function clickhouseQuery( where event_data.website_id = {websiteId:UUID} and event_data.created_at between {startDate:DateTime64} and {endDate:DateTime64} and event_data.data_key = {propertyName:String} + ${dataType ? `and event_data.data_type = ${dataType}` : ''} ${filterQuery} group by value order by 2 desc diff --git a/src/queries/sql/index.ts b/src/queries/sql/index.ts index f5a05d758..13800ef3e 100644 --- a/src/queries/sql/index.ts +++ b/src/queries/sql/index.ts @@ -41,7 +41,14 @@ export * from './reports/getUTM'; export * from './sessions/createSession'; export * from './sessions/getSessionActivity'; export * from './sessions/getSessionData'; +export * from './sessions/getSessionDataArraySeries'; +export * from './sessions/getSessionDataDateSeries'; export * from './sessions/getSessionDataProperties'; +export * from './sessions/getSessionDataPropertySeries'; +export * from './sessions/getSessionDataNumericSeries'; +export * from './sessions/getSessionDataNumericStats'; +export * from './sessions/getSessionDataPivot'; +export * from './sessions/getSessionDataStats'; export * from './sessions/getSessionDataValues'; export * from './sessions/getSessionExpandedMetrics'; export * from './sessions/getSessionMetrics'; diff --git a/src/queries/sql/sessions/getSessionDataArraySeries.ts b/src/queries/sql/sessions/getSessionDataArraySeries.ts new file mode 100644 index 000000000..a4b81ecb1 --- /dev/null +++ b/src/queries/sql/sessions/getSessionDataArraySeries.ts @@ -0,0 +1,99 @@ +import clickhouse from '@/lib/clickhouse'; +import { DATA_TYPE } from '@/lib/constants'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; +import type { EventDataSeriesPoint, PropertyFilter, QueryFilters } from '@/lib/types'; + +const FUNCTION_NAME = 'getSessionDataArraySeries'; + +export async function getSessionDataArraySeries( + ...args: [ + websiteId: string, + propertyName: string, + filters: QueryFilters, + propertyFilters?: PropertyFilter[], + ] +): Promise { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery( + websiteId: string, + propertyName: string, + filters: QueryFilters, + propertyFilters: PropertyFilter[] = [], +): Promise { + const { timezone = 'utc', unit = 'day' } = filters; + const { rawQuery, getDateSQL, parseFilters, getPropertyFilterQuery } = prisma; + const { filterQuery, cohortQuery, joinSessionQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + timezone, + }); + const { sql: pfSQL, params: pfParams } = getPropertyFilterQuery(propertyFilters, 'session', timezone); + + return rawQuery( + ` + select + array_item.value as x, + ${getDateSQL('session_data.created_at', unit, timezone)} as t, + count(distinct session_data.session_id) as y + from website_event + ${cohortQuery} + ${joinSessionQuery} + join session_data + on session_data.session_id = website_event.session_id + and session_data.website_id = website_event.website_id + cross join lateral jsonb_array_elements_text(coalesce(session_data.string_value, '[]')::jsonb) as array_item(value) + where website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + and session_data.data_key = {{propertyName}} + and session_data.data_type = ${DATA_TYPE.array} + ${filterQuery} + ${pfSQL} + group by 1, 2 + order by 2 + `, + { ...queryParams, propertyName, ...pfParams }, + FUNCTION_NAME, + ); +} + +async function clickhouseQuery( + websiteId: string, + propertyName: string, + filters: QueryFilters, + propertyFilters: PropertyFilter[] = [], +): Promise { + const { timezone = 'UTC', unit = 'day' } = filters; + const { rawQuery, getDateSQL, parseFilters, getPropertyFilterQuery } = clickhouse; + const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId, timezone }); + const { sql: pfSQL, params: pfParams } = getPropertyFilterQuery(propertyFilters, 'session', timezone); + + return rawQuery( + ` + select + arrayJoin(JSONExtract(ifNull(session_data.string_value, '[]'), 'Array(String)')) as x, + ${getDateSQL('session_data.created_at', unit, timezone)} as t, + uniq(session_data.session_id) as y + from website_event + ${cohortQuery} + join session_data final + on session_data.session_id = website_event.session_id + and session_data.website_id = {websiteId:UUID} + where website_event.website_id = {websiteId:UUID} + and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64} + and session_data.data_key = {propertyName:String} + and session_data.data_type = ${DATA_TYPE.array} + ${filterQuery} + ${pfSQL} + group by x, t + order by t + `, + { ...queryParams, propertyName, ...pfParams }, + FUNCTION_NAME, + ); +} diff --git a/src/queries/sql/sessions/getSessionDataDateSeries.ts b/src/queries/sql/sessions/getSessionDataDateSeries.ts new file mode 100644 index 000000000..d74b5e8a8 --- /dev/null +++ b/src/queries/sql/sessions/getSessionDataDateSeries.ts @@ -0,0 +1,96 @@ +import clickhouse from '@/lib/clickhouse'; +import { DATA_TYPE } from '@/lib/constants'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; +import type { EventDataDateSeriesPoint, PropertyFilter, QueryFilters } from '@/lib/types'; + +const FUNCTION_NAME = 'getSessionDataDateSeries'; + +export async function getSessionDataDateSeries( + ...args: [ + websiteId: string, + propertyName: string, + filters: QueryFilters, + propertyFilters?: PropertyFilter[], + ] +): Promise { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery( + websiteId: string, + propertyName: string, + filters: QueryFilters, + propertyFilters: PropertyFilter[] = [], +): Promise { + const { timezone = 'utc' } = filters; + const { rawQuery, parseFilters, getDateStringSQL, getPropertyFilterQuery } = prisma; + const { filterQuery, cohortQuery, joinSessionQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + timezone, + }); + const { sql: pfSQL, params: pfParams } = getPropertyFilterQuery(propertyFilters, 'session', timezone); + + return rawQuery( + ` + select + ${getDateStringSQL('session_data.date_value', 'second', timezone)} as t, + count(distinct session_data.session_id) as y + from website_event + ${cohortQuery} + ${joinSessionQuery} + join session_data + on session_data.session_id = website_event.session_id + and session_data.website_id = website_event.website_id + where website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + and session_data.data_key = {{propertyName}} + and session_data.data_type = ${DATA_TYPE.date} + ${filterQuery} + ${pfSQL} + group by 1 + order by 1 + `, + { ...queryParams, propertyName, ...pfParams }, + FUNCTION_NAME, + ); +} + +async function clickhouseQuery( + websiteId: string, + propertyName: string, + filters: QueryFilters, + propertyFilters: PropertyFilter[] = [], +): Promise { + const { timezone = 'UTC' } = filters; + const { rawQuery, parseFilters, getDateStringSQL, getPropertyFilterQuery } = clickhouse; + const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId, timezone }); + const { sql: pfSQL, params: pfParams } = getPropertyFilterQuery(propertyFilters, 'session', timezone); + + return rawQuery( + ` + select + ${getDateStringSQL('session_data.date_value', 'second', timezone)} as t, + uniq(session_data.session_id) as y + from website_event + ${cohortQuery} + join session_data final + on session_data.session_id = website_event.session_id + and session_data.website_id = {websiteId:UUID} + where website_event.website_id = {websiteId:UUID} + and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64} + and session_data.data_key = {propertyName:String} + and session_data.data_type = ${DATA_TYPE.date} + ${filterQuery} + ${pfSQL} + group by t + order by t + `, + { ...queryParams, propertyName, ...pfParams }, + FUNCTION_NAME, + ); +} diff --git a/src/queries/sql/sessions/getSessionDataNumericSeries.ts b/src/queries/sql/sessions/getSessionDataNumericSeries.ts new file mode 100644 index 000000000..03d660f02 --- /dev/null +++ b/src/queries/sql/sessions/getSessionDataNumericSeries.ts @@ -0,0 +1,119 @@ +import clickhouse from '@/lib/clickhouse'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; +import type { PropertyFilter, QueryFilters } from '@/lib/types'; + +const FUNCTION_NAME = 'getSessionDataNumericSeries'; + +export async function getSessionDataNumericSeries( + ...args: [ + websiteId: string, + propertyName: string, + metric: 'sum' | 'avg' | 'count', + filters: QueryFilters, + propertyFilters?: PropertyFilter[], + ] +) { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery( + websiteId: string, + propertyName: string, + metric: 'sum' | 'avg' | 'count', + filters: QueryFilters, + propertyFilters: PropertyFilter[] = [], +) { + const { timezone = 'utc', unit = 'day' } = filters; + const { rawQuery, getDateSQL, parseFilters, getPropertyFilterQuery } = prisma; + const { filterQuery, cohortQuery, joinSessionQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + timezone, + }); + const { sql: pfSQL, params: pfParams } = getPropertyFilterQuery(propertyFilters, 'session', timezone); + const aggSql = + metric === 'avg' + ? 'avg(cast(session_data.number_value as decimal))' + : metric === 'count' + ? 'count(distinct session_data.session_id)' + : 'sum(cast(session_data.number_value as decimal))'; + + return rawQuery( + ` + with filtered_sessions as ( + select distinct website_event.session_id, website_event.website_id + from website_event + ${cohortQuery} + ${joinSessionQuery} + where website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + ${filterQuery} + ${pfSQL} + ) + select + ${getDateSQL('session_data.created_at', unit, timezone)} t, + ${aggSql} y + from session_data + join filtered_sessions + on filtered_sessions.session_id = session_data.session_id + and filtered_sessions.website_id = session_data.website_id + where session_data.website_id = {{websiteId::uuid}} + and session_data.data_key = {{propertyName}} + and session_data.data_type = 2 + group by 1 + order by 1 + `, + { ...queryParams, propertyName, ...pfParams }, + FUNCTION_NAME, + ); +} + +async function clickhouseQuery( + websiteId: string, + propertyName: string, + metric: 'sum' | 'avg' | 'count', + filters: QueryFilters, + propertyFilters: PropertyFilter[] = [], +): Promise<{ t: string; y: number }[]> { + const { timezone = 'UTC', unit = 'day' } = filters; + const { rawQuery, getDateSQL, parseFilters, getPropertyFilterQuery } = clickhouse; + const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId, timezone }); + const { sql: pfSQL, params: pfParams } = getPropertyFilterQuery(propertyFilters, 'session', timezone); + const aggSql = + metric === 'avg' + ? 'avg(session_data.number_value)' + : metric === 'count' + ? 'uniq(session_data.session_id)' + : 'sum(session_data.number_value)'; + + return rawQuery( + ` + with filtered_sessions as ( + select distinct website_event.session_id + from website_event + ${cohortQuery} + where website_event.website_id = {websiteId:UUID} + and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64} + ${filterQuery} + ${pfSQL} + ) + select + ${getDateSQL('session_data.created_at', unit, timezone)} as t, + ${aggSql} as y + from session_data final + join filtered_sessions + on filtered_sessions.session_id = session_data.session_id + where session_data.website_id = {websiteId:UUID} + and session_data.data_key = {propertyName:String} + and session_data.data_type = 2 + group by t + order by t + `, + { ...queryParams, propertyName, ...pfParams }, + FUNCTION_NAME, + ); +} diff --git a/src/queries/sql/sessions/getSessionDataNumericStats.ts b/src/queries/sql/sessions/getSessionDataNumericStats.ts new file mode 100644 index 000000000..a7dcd7df0 --- /dev/null +++ b/src/queries/sql/sessions/getSessionDataNumericStats.ts @@ -0,0 +1,106 @@ +import clickhouse from '@/lib/clickhouse'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; +import type { EventDataNumericStats, PropertyFilter, QueryFilters } from '@/lib/types'; + +const FUNCTION_NAME = 'getSessionDataNumericStats'; + +export async function getSessionDataNumericStats( + ...args: [ + websiteId: string, + propertyName: string, + filters: QueryFilters, + propertyFilters?: PropertyFilter[], + ] +): Promise { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }).then(results => results?.[0]); +} + +async function relationalQuery( + websiteId: string, + propertyName: string, + filters: QueryFilters, + propertyFilters: PropertyFilter[] = [], +) { + const { timezone = 'utc' } = filters; + const { rawQuery, parseFilters, getPropertyFilterQuery } = prisma; + const { filterQuery, cohortQuery, joinSessionQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + timezone, + }); + const { sql: pfSQL, params: pfParams } = getPropertyFilterQuery(propertyFilters, 'session', timezone); + + return rawQuery( + ` + with filtered_sessions as ( + select distinct website_event.session_id, website_event.website_id + from website_event + ${cohortQuery} + ${joinSessionQuery} + where website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + ${filterQuery} + ${pfSQL} + ) + select + coalesce(sum(cast(session_data.number_value as decimal)), 0) as "total", + coalesce(avg(cast(session_data.number_value as decimal)), 0) as "average", + coalesce(percentile_cont(0.5) within group (order by session_data.number_value), 0) as "median", + coalesce(max(session_data.number_value), 0) as "max", + coalesce(min(session_data.number_value), 0) as "min" + from session_data + join filtered_sessions + on filtered_sessions.session_id = session_data.session_id + and filtered_sessions.website_id = session_data.website_id + where session_data.website_id = {{websiteId::uuid}} + and session_data.data_key = {{propertyName}} + and session_data.data_type = 2 + `, + { ...queryParams, propertyName, ...pfParams }, + FUNCTION_NAME, + ); +} + +async function clickhouseQuery( + websiteId: string, + propertyName: string, + filters: QueryFilters, + propertyFilters: PropertyFilter[] = [], +): Promise { + const { timezone = 'UTC' } = filters; + const { rawQuery, parseFilters, getPropertyFilterQuery } = clickhouse; + const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId, timezone }); + const { sql: pfSQL, params: pfParams } = getPropertyFilterQuery(propertyFilters, 'session', timezone); + + return rawQuery( + ` + with filtered_sessions as ( + select distinct website_event.session_id + from website_event + ${cohortQuery} + where website_event.website_id = {websiteId:UUID} + and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64} + ${filterQuery} + ${pfSQL} + ) + select + if(count() = 0, 0, sum(session_data.number_value)) as total, + if(count() = 0, 0, avg(session_data.number_value)) as average, + if(count() = 0, 0, median(session_data.number_value)) as median, + if(count() = 0, 0, max(session_data.number_value)) as max, + if(count() = 0, 0, min(session_data.number_value)) as min + from session_data final + join filtered_sessions + on filtered_sessions.session_id = session_data.session_id + where session_data.website_id = {websiteId:UUID} + and session_data.data_key = {propertyName:String} + and session_data.data_type = 2 + `, + { ...queryParams, propertyName, ...pfParams }, + FUNCTION_NAME, + ); +} diff --git a/src/queries/sql/sessions/getSessionDataPivot.ts b/src/queries/sql/sessions/getSessionDataPivot.ts new file mode 100644 index 000000000..6ea7b6b5f --- /dev/null +++ b/src/queries/sql/sessions/getSessionDataPivot.ts @@ -0,0 +1,215 @@ +import clickhouse from '@/lib/clickhouse'; +import { DEFAULT_PAGE_SIZE } from '@/lib/constants'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; +import type { PageResult, PropertyFilter, QueryFilters, SessionDataPivotRow } from '@/lib/types'; + +const FUNCTION_NAME = 'getSessionDataPivot'; + +export async function getSessionDataPivot( + ...args: [ + websiteId: string, + propertyName: string, + filters: QueryFilters, + propertyFilters?: PropertyFilter[], + ] +): Promise> { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery( + websiteId: string, + propertyName: string, + filters: QueryFilters, + propertyFilters: PropertyFilter[] = [], +): Promise> { + const { timezone = 'utc' } = filters; + const { rawQuery, parseFilters, getPropertyFilterQuery } = prisma; + const { page = 1, pageSize } = filters; + const size = +pageSize || DEFAULT_PAGE_SIZE; + const offset = +size * (+page - 1); + + const { filterQuery, cohortQuery, joinSessionQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + timezone, + }); + const { sql: pfSQL, params: pfParams } = getPropertyFilterQuery( + propertyFilters, + 'session', + timezone, + ); + + const countResult = await rawQuery( + ` + with filtered_sessions as ( + select distinct website_event.session_id + from website_event + ${cohortQuery} + ${joinSessionQuery} + where website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + ${filterQuery} + ${pfSQL} + ), + selected_property_sessions as ( + select distinct session_data.session_id + from session_data + join filtered_sessions + on filtered_sessions.session_id = session_data.session_id + where session_data.website_id = {{websiteId::uuid}} + and session_data.data_key = {{propertyName}} + ) + select count(*) as num + from selected_property_sessions + `, + { ...queryParams, websiteId, propertyName, ...pfParams }, + ); + + const count = countResult[0].num; + + const rows = await rawQuery( + ` + with filtered_sessions as ( + select distinct website_event.session_id + from website_event + ${cohortQuery} + ${joinSessionQuery} + where website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + ${filterQuery} + ${pfSQL} + ), + selected_property_sessions as ( + select distinct session_data.session_id + from session_data + join filtered_sessions + on filtered_sessions.session_id = session_data.session_id + where session_data.website_id = {{websiteId::uuid}} + and session_data.data_key = {{propertyName}} + ), + paged_sessions as ( + select session_data.session_id + from session_data + join selected_property_sessions + on selected_property_sessions.session_id = session_data.session_id + where session_data.website_id = {{websiteId::uuid}} + group by session_data.session_id + order by max(session_data.created_at) desc + limit ${size} offset ${offset} + ) + select + session_data.session_id as "sessionId", + coalesce(max(session_data.distinct_id), '') as "distinctId", + max(session_data.created_at) as "createdAt", + array_agg(session_data.data_key order by session_data.data_key asc) as "propertyKeys", + array_agg( + coalesce( + case when session_data.data_type = 1 then session_data.string_value end, + case when session_data.data_type = 2 then cast(session_data.number_value as varchar) end, + case when session_data.data_type = 3 then session_data.string_value end, + case when session_data.data_type = 4 then cast(session_data.date_value as varchar) end, + case when session_data.data_type = 5 then session_data.string_value end, + '' + ) + order by session_data.data_key asc + ) as "propertyValues" + from session_data + join paged_sessions on paged_sessions.session_id = session_data.session_id + where session_data.website_id = {{websiteId::uuid}} + group by session_data.session_id + order by max(session_data.created_at) desc + `, + { ...queryParams, websiteId, propertyName, ...pfParams }, + FUNCTION_NAME, + ); + + return { data: rows, count, page: +page, pageSize: size }; +} + +async function clickhouseQuery( + websiteId: string, + propertyName: string, + filters: QueryFilters, + propertyFilters: PropertyFilter[] = [], +): Promise> { + const { timezone = 'UTC' } = filters; + const { rawQuery, parseFilters, getPropertyFilterQuery } = clickhouse; + const { page = 1, pageSize } = filters; + const size = +pageSize || DEFAULT_PAGE_SIZE; + const offset = +size * (+page - 1); + + const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId, timezone }); + const { sql: pfSQL, params: pfParams } = getPropertyFilterQuery( + propertyFilters, + 'session', + timezone, + ); + + const count = await rawQuery( + ` + with filtered_sessions as ( + select distinct website_event.session_id + from website_event + ${cohortQuery} + where website_event.website_id = {websiteId:UUID} + and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64} + ${filterQuery} + ${pfSQL} + ), + selected_property_sessions as ( + select distinct session_data.session_id + from session_data final + join filtered_sessions on filtered_sessions.session_id = session_data.session_id + where session_data.website_id = {websiteId:UUID} + and session_data.data_key = {propertyName:String} + ) + select count() as num + from selected_property_sessions + `, + { ...queryParams, websiteId, propertyName, ...pfParams }, + ).then((res: any) => res[0].num); + + const data = await rawQuery( + ` + with filtered_sessions as ( + select distinct website_event.session_id + from website_event + ${cohortQuery} + where website_event.website_id = {websiteId:UUID} + and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64} + ${filterQuery} + ${pfSQL} + ), + selected_property_sessions as ( + select distinct session_data.session_id + from session_data final + join filtered_sessions on filtered_sessions.session_id = session_data.session_id + where session_data.website_id = {websiteId:UUID} + and session_data.data_key = {propertyName:String} + ) + select + session_data_pivot.session_id as sessionId, + session_data_pivot.distinct_id as distinctId, + maxMerge(session_data_pivot.created_at) as createdAt, + groupArrayMerge(session_data_pivot.property_keys) as propertyKeys, + groupArrayMerge(session_data_pivot.property_values) as propertyValues + from umami.session_data_pivot session_data_pivot + join selected_property_sessions + on selected_property_sessions.session_id = session_data_pivot.session_id + where session_data_pivot.website_id = {websiteId:UUID} + group by + session_data_pivot.session_id, + session_data_pivot.distinct_id + order by createdAt desc + limit ${size} offset ${offset} + `, + { ...queryParams, websiteId, propertyName, ...pfParams }, + FUNCTION_NAME, + ); + + return { data, count, page: +page, pageSize: size }; +} diff --git a/src/queries/sql/sessions/getSessionDataProperties.ts b/src/queries/sql/sessions/getSessionDataProperties.ts index 9b429f9b9..446d8e465 100644 --- a/src/queries/sql/sessions/getSessionDataProperties.ts +++ b/src/queries/sql/sessions/getSessionDataProperties.ts @@ -1,12 +1,17 @@ import clickhouse from '@/lib/clickhouse'; import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; import prisma from '@/lib/prisma'; -import type { QueryFilters } from '@/lib/types'; +import type { PropertyFilter, QueryFilters } from '@/lib/types'; const FUNCTION_NAME = 'getSessionDataProperties'; export async function getSessionDataProperties( - ...args: [websiteId: string, filters: QueryFilters] + ...args: [ + websiteId: string, + filters: QueryFilters, + propertyFilters?: PropertyFilter[], + selectedPropertyName?: string, + ] ) { return runQuery({ [PRISMA]: () => relationalQuery(...args), @@ -14,32 +19,58 @@ export async function getSessionDataProperties( }); } -async function relationalQuery(websiteId: string, filters: QueryFilters) { - const { rawQuery, parseFilters } = prisma; +async function relationalQuery( + websiteId: string, + filters: QueryFilters, + propertyFilters: PropertyFilter[] = [], + selectedPropertyName?: string, +) { + const { timezone = 'utc' } = filters; + const { rawQuery, parseFilters, getPropertyFilterQuery } = prisma; const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId, + timezone, }); + const { sql: pfSQL, params: pfParams } = getPropertyFilterQuery( + propertyFilters, + 'session', + timezone, + ); return rawQuery( ` + with filtered_sessions as ( + select distinct website_event.session_id, website_event.website_id + from website_event + ${cohortQuery} + ${joinSessionQuery} + where website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + ${filterQuery} + ${pfSQL} + ), + selected_property_sessions as ( + select distinct session_data.session_id, session_data.website_id + from session_data + join filtered_sessions + on filtered_sessions.session_id = session_data.session_id + and filtered_sessions.website_id = session_data.website_id + ${selectedPropertyName ? 'where session_data.data_key = {{selectedPropertyName}}' : ''} + ) select data_key as "propertyName", + data_type as "dataType", count(distinct session_data.session_id) as "total" - from website_event - ${cohortQuery} - ${joinSessionQuery} + from selected_property_sessions join session_data - on session_data.session_id = website_event.session_id - and session_data.website_id = website_event.website_id - where website_event.website_id = {{websiteId::uuid}} - and website_event.created_at between {{startDate}} and {{endDate}} - ${filterQuery} - group by 1 - order by 2 desc + on session_data.session_id = selected_property_sessions.session_id + and session_data.website_id = selected_property_sessions.website_id + group by 1, 2 + order by 3 desc, 1 asc limit 500 `, - queryParams, + { ...queryParams, websiteId, selectedPropertyName, ...pfParams }, FUNCTION_NAME, ); } @@ -47,29 +78,52 @@ async function relationalQuery(websiteId: string, filters: QueryFilters) { async function clickhouseQuery( websiteId: string, filters: QueryFilters, -): Promise<{ propertyName: string; total: number }[]> { - const { rawQuery, parseFilters } = clickhouse; - const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId }); + propertyFilters: PropertyFilter[] = [], + selectedPropertyName?: string, +): Promise<{ propertyName: string; dataType: number; total: number }[]> { + const { timezone = 'UTC' } = filters; + const { rawQuery, parseFilters, getPropertyFilterQuery } = clickhouse; + const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId, timezone }); + const { sql: pfSQL, params: pfParams } = getPropertyFilterQuery( + propertyFilters, + 'session', + timezone, + ); return rawQuery( ` + with filtered_sessions as ( + select distinct website_event.session_id, website_event.website_id + from website_event + ${cohortQuery} + where website_event.website_id = {websiteId:UUID} + and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64} + ${filterQuery} + ${pfSQL} + ), + selected_property_sessions as ( + select distinct session_data.session_id, session_data.website_id + from session_data final + join filtered_sessions + on filtered_sessions.session_id = session_data.session_id + and filtered_sessions.website_id = session_data.website_id + ${selectedPropertyName ? 'where session_data.data_key = {selectedPropertyName:String}' : ''} + ) select data_key as propertyName, + data_type as dataType, count(distinct session_data.session_id) as total - from website_event - ${cohortQuery} + from selected_property_sessions join session_data final - on session_data.session_id = website_event.session_id - and session_data.website_id = {websiteId:UUID} - where website_event.website_id = {websiteId:UUID} - and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64} + on session_data.session_id = selected_property_sessions.session_id + and session_data.website_id = selected_property_sessions.website_id + where session_data.website_id = {websiteId:UUID} and session_data.data_key != '' - ${filterQuery} - group by 1 - order by 2 desc + group by 1, 2 + order by 3 desc, 1 asc limit 500 `, - queryParams, + { ...queryParams, websiteId, selectedPropertyName, ...pfParams }, FUNCTION_NAME, ); } diff --git a/src/queries/sql/sessions/getSessionDataPropertySeries.ts b/src/queries/sql/sessions/getSessionDataPropertySeries.ts new file mode 100644 index 000000000..fdffab423 --- /dev/null +++ b/src/queries/sql/sessions/getSessionDataPropertySeries.ts @@ -0,0 +1,98 @@ +import clickhouse from '@/lib/clickhouse'; +import { DATA_TYPE } from '@/lib/constants'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; +import type { EventDataSeriesPoint, PropertyFilter, QueryFilters } from '@/lib/types'; + +const FUNCTION_NAME = 'getSessionDataPropertySeries'; + +export async function getSessionDataPropertySeries( + ...args: [ + websiteId: string, + propertyName: string, + filters: QueryFilters, + propertyFilters?: PropertyFilter[], + ] +): Promise { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery( + websiteId: string, + propertyName: string, + filters: QueryFilters, + propertyFilters: PropertyFilter[] = [], +): Promise { + const { timezone = 'utc', unit = 'day' } = filters; + const { rawQuery, getDateSQL, parseFilters, getPropertyFilterQuery } = prisma; + const { filterQuery, cohortQuery, joinSessionQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + timezone, + }); + const { sql: pfSQL, params: pfParams } = getPropertyFilterQuery(propertyFilters, 'session', timezone); + + return rawQuery( + ` + select + session_data.string_value as x, + ${getDateSQL('session_data.created_at', unit, timezone)} t, + count(distinct session_data.session_id) y + from website_event + ${cohortQuery} + ${joinSessionQuery} + join session_data + on session_data.session_id = website_event.session_id + and session_data.website_id = website_event.website_id + where website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + and session_data.data_key = {{propertyName}} + and session_data.data_type in (${DATA_TYPE.string}, ${DATA_TYPE.boolean}) + ${filterQuery} + ${pfSQL} + group by 1, 2 + order by 2 + `, + { ...queryParams, propertyName, ...pfParams }, + FUNCTION_NAME, + ); +} + +async function clickhouseQuery( + websiteId: string, + propertyName: string, + filters: QueryFilters, + propertyFilters: PropertyFilter[] = [], +): Promise { + const { timezone = 'UTC', unit = 'day' } = filters; + const { rawQuery, getDateSQL, parseFilters, getPropertyFilterQuery } = clickhouse; + const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId, timezone }); + const { sql: pfSQL, params: pfParams } = getPropertyFilterQuery(propertyFilters, 'session', timezone); + + return rawQuery( + ` + select + session_data.string_value as x, + ${getDateSQL('session_data.created_at', unit, timezone)} as t, + uniq(session_data.session_id) as y + from website_event + ${cohortQuery} + join session_data final + on session_data.session_id = website_event.session_id + and session_data.website_id = {websiteId:UUID} + where website_event.website_id = {websiteId:UUID} + and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64} + and session_data.data_key = {propertyName:String} + and session_data.data_type in (${DATA_TYPE.string}, ${DATA_TYPE.boolean}) + ${filterQuery} + ${pfSQL} + group by x, t + order by t + `, + { ...queryParams, propertyName, ...pfParams }, + FUNCTION_NAME, + ); +} diff --git a/src/queries/sql/sessions/getSessionDataStats.ts b/src/queries/sql/sessions/getSessionDataStats.ts new file mode 100644 index 000000000..d0ea361cd --- /dev/null +++ b/src/queries/sql/sessions/getSessionDataStats.ts @@ -0,0 +1,185 @@ +import clickhouse from '@/lib/clickhouse'; +import { DATA_TYPE } from '@/lib/constants'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; +import type { PropertyFilter, PropertyLeaderboardRow, QueryFilters } from '@/lib/types'; + +const FUNCTION_NAME = 'getSessionDataStats'; + +export async function getSessionDataStats( + ...args: [websiteId: string, propertyName: string, filters: QueryFilters, propertyFilters?: PropertyFilter[]] +): Promise { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery( + websiteId: string, + propertyName: string, + filters: QueryFilters, + propertyFilters: PropertyFilter[] = [], +) { + const { timezone = 'utc' } = filters; + const { rawQuery, parseFilters, getPropertyFilterQuery, getTimestampDiffSQL } = prisma; + const { filterQuery, cohortQuery, joinSessionQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + timezone, + }); + const { sql: pfSQL, params: pfParams } = getPropertyFilterQuery(propertyFilters, 'session', timezone); + + return rawQuery( + ` + with filtered_sessions as ( + select distinct website_event.session_id, website_event.website_id + from website_event + ${cohortQuery} + ${joinSessionQuery} + where website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + ${filterQuery} + ${pfSQL} + ), + session_rollup as ( + select + website_event.session_id, + website_event.visit_id, + count(*) as activity, + sum(case when website_event.event_type = 1 then 1 else 0 end) as views, + sum(case when website_event.event_type = 2 then 1 else 0 end) as events, + min(website_event.created_at) as min_time, + max(website_event.created_at) as max_time + from website_event + join filtered_sessions + on filtered_sessions.session_id = website_event.session_id + and filtered_sessions.website_id = website_event.website_id + where website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + group by website_event.session_id, website_event.visit_id + ), + session_stats as ( + select + session_id, + count(*) as visits, + sum(activity) as activity, + sum(views) as views, + sum(events) as events, + sum(${getTimestampDiffSQL('min_time', 'max_time')}) as totaltime + from session_rollup + group by session_id + ), + property_values as ( + select + session_data.session_id, + session_data.string_value as value + from session_data + join filtered_sessions + on filtered_sessions.session_id = session_data.session_id + and filtered_sessions.website_id = session_data.website_id + where session_data.website_id = {{websiteId::uuid}} + and session_data.data_key = {{propertyName}} + and session_data.data_type = ${DATA_TYPE.string} + and coalesce(session_data.string_value, '') != '' + ) + select + property_values.value as label, + coalesce(sum(session_stats.activity), 0) as activity, + count(distinct property_values.session_id) as sessions, + coalesce(sum(session_stats.visits), 0) as visits, + coalesce(sum(session_stats.views), 0) as views, + coalesce(sum(session_stats.events), 0) as events, + coalesce(sum(session_stats.totaltime), 0) as totaltime, + coalesce(sum(case when session_stats.visits = 1 then 1 else 0 end), 0) as "newSessions", + coalesce(sum(case when session_stats.visits > 1 then 1 else 0 end), 0) as "returningSessions" + from property_values + join session_stats on session_stats.session_id = property_values.session_id + group by property_values.value + order by activity desc, property_values.value asc + limit 100 + `, + { ...queryParams, websiteId, propertyName, ...pfParams }, + FUNCTION_NAME, + ); +} + +async function clickhouseQuery( + websiteId: string, + propertyName: string, + filters: QueryFilters, + propertyFilters: PropertyFilter[] = [], +) { + const { timezone = 'UTC' } = filters; + const { rawQuery, parseFilters, getPropertyFilterQuery } = clickhouse; + const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId, timezone }); + const { sql: pfSQL, params: pfParams } = getPropertyFilterQuery(propertyFilters, 'session', timezone); + + return rawQuery( + ` + with filtered_sessions as ( + select distinct website_event.session_id + from website_event + ${cohortQuery} + where website_event.website_id = {websiteId:UUID} + and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64} + ${filterQuery} + ${pfSQL} + ), + session_rollup as ( + select + website_event.session_id, + website_event.visit_id, + count() as activity, + sumIf(1, website_event.event_type = 1) as views, + sumIf(1, website_event.event_type = 2) as events, + min(website_event.created_at) as min_time, + max(website_event.created_at) as max_time + from website_event + join filtered_sessions on filtered_sessions.session_id = website_event.session_id + where website_event.website_id = {websiteId:UUID} + and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64} + group by website_event.session_id, website_event.visit_id + ), + session_stats as ( + select + session_id, + count() as visits, + sum(activity) as activity, + sum(views) as views, + sum(events) as events, + sum(max_time - min_time) as totaltime + from session_rollup + group by session_id + ), + property_values as ( + select + session_data.session_id, + session_data.string_value as value + from session_data final + join filtered_sessions on filtered_sessions.session_id = session_data.session_id + where session_data.website_id = {websiteId:UUID} + and session_data.data_key = {propertyName:String} + and session_data.data_type = ${DATA_TYPE.string} + and ifNull(session_data.string_value, '') != '' + ) + select + property_values.value as label, + ifNull(sum(session_stats.activity), 0) as activity, + uniq(property_values.session_id) as sessions, + ifNull(sum(session_stats.visits), 0) as visits, + ifNull(sum(session_stats.views), 0) as views, + ifNull(sum(session_stats.events), 0) as events, + ifNull(sum(session_stats.totaltime), 0) as totaltime, + ifNull(sum(if(session_stats.visits = 1, 1, 0)), 0) as newSessions, + ifNull(sum(if(session_stats.visits > 1, 1, 0)), 0) as returningSessions + from property_values + join session_stats on session_stats.session_id = property_values.session_id + group by property_values.value + order by activity desc, property_values.value asc + limit 100 + `, + { ...queryParams, websiteId, propertyName, ...pfParams }, + FUNCTION_NAME, + ); +} diff --git a/src/queries/sql/sessions/getSessionDataValues.ts b/src/queries/sql/sessions/getSessionDataValues.ts index 5790141c5..e015e30ef 100644 --- a/src/queries/sql/sessions/getSessionDataValues.ts +++ b/src/queries/sql/sessions/getSessionDataValues.ts @@ -1,4 +1,5 @@ import clickhouse from '@/lib/clickhouse'; +import { DATA_TYPE } from '@/lib/constants'; import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; import prisma from '@/lib/prisma'; import type { QueryFilters } from '@/lib/types'; @@ -6,7 +7,7 @@ import type { QueryFilters } from '@/lib/types'; const FUNCTION_NAME = 'getSessionDataValues'; export async function getSessionDataValues( - ...args: [websiteId: string, filters: QueryFilters & { propertyName?: string }] + ...args: [websiteId: string, filters: QueryFilters & { propertyName?: string; dataType?: number }] ) { return runQuery({ [PRISMA]: () => relationalQuery(...args), @@ -16,14 +17,42 @@ export async function getSessionDataValues( async function relationalQuery( websiteId: string, - filters: QueryFilters & { propertyName?: string }, + filters: QueryFilters & { propertyName?: string; dataType?: number }, ) { const { rawQuery, parseFilters, getDateSQL } = prisma; + const { dataType } = filters; const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId, }); + if (dataType === DATA_TYPE.array) { + return rawQuery( + ` + select + array_item.value as "value", + count(distinct session_data.session_id) as "total" + from website_event + ${cohortQuery} + ${joinSessionQuery} + join session_data + on session_data.session_id = website_event.session_id + and session_data.website_id = website_event.website_id + cross join lateral jsonb_array_elements_text(coalesce(session_data.string_value, '[]')::jsonb) as array_item(value) + where website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + and session_data.data_key = {{propertyName}} + and session_data.data_type = ${DATA_TYPE.array} + ${filterQuery} + group by array_item.value + order by 2 desc + limit 100 + `, + queryParams, + FUNCTION_NAME, + ); + } + return rawQuery( ` select @@ -42,6 +71,7 @@ async function relationalQuery( where website_event.website_id = {{websiteId::uuid}} and website_event.created_at between {{startDate}} and {{endDate}} and session_data.data_key = {{propertyName}} + ${dataType ? `and session_data.data_type = ${dataType}` : ''} ${filterQuery} group by value order by 2 desc @@ -54,11 +84,37 @@ async function relationalQuery( async function clickhouseQuery( websiteId: string, - filters: QueryFilters & { propertyName?: string }, + filters: QueryFilters & { propertyName?: string; dataType?: number }, ): Promise<{ propertyName: string; dataType: number; propertyValue: string; total: number }[]> { const { rawQuery, parseFilters } = clickhouse; + const { dataType } = filters; const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId }); + if (dataType === DATA_TYPE.array) { + return rawQuery( + ` + select + arrayJoin(JSONExtract(ifNull(session_data.string_value, '[]'), 'Array(String)')) as "value", + uniq(session_data.session_id) as "total" + from website_event + ${cohortQuery} + join session_data final + on session_data.session_id = website_event.session_id + and session_data.website_id = {websiteId:UUID} + where website_event.website_id = {websiteId:UUID} + and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64} + and session_data.data_key = {propertyName:String} + and session_data.data_type = ${DATA_TYPE.array} + ${filterQuery} + group by value + order by 2 desc + limit 100 + `, + queryParams, + FUNCTION_NAME, + ); + } + return rawQuery( ` select @@ -74,6 +130,7 @@ async function clickhouseQuery( where website_event.website_id = {websiteId:UUID} and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64} and session_data.data_key = {propertyName:String} + ${dataType ? `and session_data.data_type = ${dataType}` : ''} ${filterQuery} group by value order by 2 desc