diff --git a/src/app/(main)/websites/[websiteId]/event-data/EventDataDateChart.tsx b/src/app/(main)/websites/[websiteId]/event-data/EventDataDateChart.tsx
new file mode 100644
index 000000000..136d73aa0
--- /dev/null
+++ b/src/app/(main)/websites/[websiteId]/event-data/EventDataDateChart.tsx
@@ -0,0 +1,373 @@
+'use client';
+import { Column, Grid, Text, useTheme } from '@umami/react-zen';
+import { colord } from 'colord';
+import {
+ differenceInCalendarDays,
+ format,
+ formatDistance,
+ isValid,
+ startOfDay,
+ startOfMonth,
+ startOfWeek,
+ startOfYear,
+ type Locale,
+} from 'date-fns';
+import { toZonedTime } from 'date-fns-tz';
+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 { 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';
+
+type DateChartUnit = 'day' | 'week' | 'month' | 'year';
+type ParsedDateRow = EventDataDateSeriesPoint & { date: Date };
+
+function parseDateValue(value: string) {
+ const normalized = value?.trim().replace(' ', 'T').replace(/Z$/, '').replace(/\.\d+$/, '');
+ const [datePart = '', timePart = '00:00:00'] = normalized.split('T');
+ const [year = 0, month = 1, day = 1] = datePart.split('-').map(Number);
+ const [hour = 0, minute = 0, second = 0] = timePart.split(':').map(Number);
+ const date = new Date(year, month - 1, day, hour, minute, second);
+
+ return isValid(date) ? date : null;
+}
+
+function InsightCard({
+ label,
+ value,
+ hint,
+}: {
+ label: string;
+ value: string;
+ hint?: ReactNode;
+}) {
+ return (
+
+
+ {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';
+}
+
+function getBucketStart(date: Date, unit: DateChartUnit, dateLocale: Locale) {
+ switch (unit) {
+ case 'week':
+ return startOfWeek(date, { locale: dateLocale });
+ case 'month':
+ return startOfMonth(date);
+ case 'year':
+ return startOfYear(date);
+ default:
+ return startOfDay(date);
+ }
+}
+
+export function EventDataDateChart({
+ websiteId,
+ eventName,
+ propertyName,
+ eventFilters = [],
+}: {
+ websiteId: string;
+ eventName: string;
+ propertyName: string;
+ eventFilters?: EventPropertyFilter[];
+}) {
+ 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 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 minPropertyDate = exactDateRows[0]?.date ?? null;
+ const lastExactDateRow = exactDateRows[exactDateRows.length - 1];
+ const maxPropertyDate = lastExactDateRow?.date ?? null;
+
+ const chartUnit = useMemo(
+ () =>
+ minPropertyDate && maxPropertyDate
+ ? getChartUnit(minPropertyDate, maxPropertyDate)
+ : 'day',
+ [minPropertyDate, maxPropertyDate],
+ );
+
+ 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,
+ };
+
+ return obj;
+ },
+ {} as Record,
+ );
+
+ return Object.values(buckets).sort(
+ (a, b) => (parseDateValue(a.t)?.getTime() ?? 0) - (parseDateValue(b.t)?.getTime() ?? 0),
+ );
+ }, [exactDateRows, chartUnit, dateLocale]);
+
+ const chartData: any = useMemo(() => {
+ if (!rows.length || !minPropertyDate || !maxPropertyDate) return null;
+
+ return {
+ datasets: [
+ {
+ label: t(labels.count),
+ data: generateTimeSeries(
+ chartRows.map(({ t, y }) => ({ x: t, y })),
+ minPropertyDate,
+ maxPropertyDate,
+ chartUnit,
+ dateLocale,
+ ),
+ barPercentage: 0.9,
+ categoryPercentage: 0.9,
+ ...colors.chart.visitors,
+ borderWidth: 1,
+ },
+ ],
+ };
+ }, [
+ rows,
+ minPropertyDate,
+ maxPropertyDate,
+ chartRows,
+ chartUnit,
+ dateLocale,
+ colors,
+ t,
+ labels.count,
+ ]);
+
+ 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 weekdayTotals = exactDateRows.reduce(
+ (obj, { date, y }) => {
+ const day = date.getDay();
+ obj[day] = (obj[day] ?? 0) + y;
+ return obj;
+ },
+ {} as Record,
+ );
+
+ return weekdayOrder.map(day => {
+ const referenceDate = new Date(2026, 0, 4 + day);
+ const count = weekdayTotals[day] ?? 0;
+
+ return {
+ label: format(referenceDate, 'EEEE', { locale: dateLocale }),
+ count,
+ percent: total ? 100 * (count / total) : 0,
+ };
+ });
+ }, [exactDateRows, total, dateLocale]);
+
+ 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();
+ return obj;
+ },
+ {} as Record,
+ );
+ }, [weekdayLabels]);
+ const weekdayPieChartData = useMemo(() => {
+ if (!weekdayTableData.length) return null;
+
+ return {
+ labels: weekdayLabels,
+ datasets: [
+ {
+ data: weekdayTableData.map(({ count }) => count),
+ backgroundColor: weekdayLabels.map(label => weekdayColorMap[label]),
+ borderWidth: 0,
+ },
+ ],
+ };
+ }, [weekdayTableData, weekdayLabels, weekdayColorMap]);
+ const spanDays = useMemo(
+ () =>
+ minPropertyDate && maxPropertyDate
+ ? differenceInCalendarDays(maxPropertyDate, minPropertyDate) + 1
+ : 0,
+ [minPropertyDate, maxPropertyDate],
+ );
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ {chartData && minPropertyDate && maxPropertyDate && (
+ formatLongNumber(Number(value))}
+ height="400px"
+ />
+ )}
+
+
+
+
+
+
+
+ {weekdayPieChartData && (
+
+ )}
+
+
+
+
+ );
+}
diff --git a/src/app/(main)/websites/[websiteId]/event-data/EventDataFilterBar.tsx b/src/app/(main)/websites/[websiteId]/event-data/EventDataFilterBar.tsx
index cec4ed408..41568558b 100644
--- a/src/app/(main)/websites/[websiteId]/event-data/EventDataFilterBar.tsx
+++ b/src/app/(main)/websites/[websiteId]/event-data/EventDataFilterBar.tsx
@@ -1,9 +1,9 @@
'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';
export function EventDataFilterBar({
filters,
@@ -18,17 +18,28 @@ export function EventDataFilterBar({
const operatorLabel = (op: string) => {
switch (op) {
- case OPERATORS.equals: return t(labels.is);
- case OPERATORS.notEquals: return t(labels.isNot);
- case OPERATORS.contains: return t(labels.contains);
- case OPERATORS.doesNotContain: return t(labels.doesNotContain);
- case OPERATORS.regex: return t(labels.regexMatch);
- case OPERATORS.notRegex: return t(labels.regexNotMatch);
- case OPERATORS.greaterThan: return t(labels.greaterThan);
- case OPERATORS.lessThan: return t(labels.lessThan);
- case OPERATORS.greaterThanEquals: return t(labels.greaterThanEquals);
- case OPERATORS.lessThanEquals: return t(labels.lessThanEquals);
- default: return op;
+ case OPERATORS.equals:
+ return t(labels.is);
+ case OPERATORS.notEquals:
+ return t(labels.isNot);
+ case OPERATORS.contains:
+ return t(labels.contains);
+ case OPERATORS.doesNotContain:
+ return t(labels.doesNotContain);
+ case OPERATORS.regex:
+ return t(labels.regexMatch);
+ case OPERATORS.notRegex:
+ return t(labels.regexNotMatch);
+ case OPERATORS.greaterThan:
+ return t(labels.greaterThan);
+ case OPERATORS.lessThan:
+ return t(labels.lessThan);
+ case OPERATORS.greaterThanEquals:
+ return t(labels.greaterThanEquals);
+ case OPERATORS.lessThanEquals:
+ return t(labels.lessThanEquals);
+ default:
+ return op;
}
};
@@ -37,7 +48,15 @@ export function EventDataFilterBar({
};
return (
-
+
{filters.map((filter, index) => (
void;
}) {
return (
-
-
+
+
{label}
diff --git a/src/app/(main)/websites/[websiteId]/event-data/EventDataNumericChart.tsx b/src/app/(main)/websites/[websiteId]/event-data/EventDataNumericChart.tsx
index 17ef799da..94da88f82 100644
--- a/src/app/(main)/websites/[websiteId]/event-data/EventDataNumericChart.tsx
+++ b/src/app/(main)/websites/[websiteId]/event-data/EventDataNumericChart.tsx
@@ -1,19 +1,22 @@
'use client';
-import { Column } from '@umami/react-zen';
-import { useTheme } from '@umami/react-zen';
+import { Column, useTheme } from '@umami/react-zen';
import { useCallback, useMemo } from 'react';
import { BarChart } from '@/components/charts/BarChart';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import {
useDateRange,
useEventDataNumericSeriesQuery,
+ useEventDataNumericStatsQuery,
useLocale,
useMessages,
useTimezone,
} from '@/components/hooks';
+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';
export function EventDataNumericChart({
@@ -48,13 +51,35 @@ export function EventDataNumericChart({
'avg',
eventFilters,
);
+ const statsQuery = useEventDataNumericStatsQuery(
+ websiteId,
+ eventName,
+ propertyName,
+ eventFilters,
+ );
+
+ 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(
+ (n: number) =>
+ Number(n).toLocaleString(locale, {
+ maximumFractionDigits: 2,
+ minimumFractionDigits: Number.isInteger(Number(n)) ? 0 : 2,
+ }),
+ [locale],
+ );
const chartData: any = useMemo(() => {
if (!sumQuery.data && !avgQuery.data) return;
- const sumRows = (sumQuery.data as { t: string; y: number }[] | undefined) ?? [];
- const avgRows = (avgQuery.data as { t: string; y: number }[] | undefined) ?? [];
-
return {
datasets: [
{
@@ -89,15 +114,50 @@ export function EventDataNumericChart({
},
],
};
- }, [sumQuery.data, avgQuery.data, t, labels, startDate, endDate, unit, dateLocale, colors]);
+ }, [sumQuery.data, avgQuery.data, t, labels, startDate, endDate, unit, dateLocale, colors, sumRows, avgRows]);
const renderXLabel = useCallback(renderDateLabels(unit, locale), [unit, locale]);
return (
+
+
+
+
+
+
+
+
+
{chartData && (
diff --git a/src/app/(main)/websites/[websiteId]/event-data/EventDataPropertyChart.tsx b/src/app/(main)/websites/[websiteId]/event-data/EventDataPropertyChart.tsx
index 431b87a6f..0fccff7c0 100644
--- a/src/app/(main)/websites/[websiteId]/event-data/EventDataPropertyChart.tsx
+++ b/src/app/(main)/websites/[websiteId]/event-data/EventDataPropertyChart.tsx
@@ -6,6 +6,7 @@ import { BarChart } from '@/components/charts/BarChart';
import { PieChart } from '@/components/charts/PieChart';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import {
+ useEventDataArraySeriesQuery,
useDateRange,
useEventDataPropertySeriesQuery,
useLocale,
@@ -16,34 +17,46 @@ import { ListTable } from '@/components/metrics/ListTable';
import { renderDateLabels } from '@/lib/charts';
import { CHART_COLORS } from '@/lib/constants';
import { generateTimeSeries } from '@/lib/date';
-import type { EventPropertyFilter } from '@/lib/types';
+import type { EventDataSeriesPoint, EventPropertyFilter } from '@/lib/types';
export function EventDataPropertyChart({
websiteId,
eventName,
propertyName,
eventFilters = [],
+ seriesType = 'property',
}: {
websiteId: string;
eventName: string;
propertyName: string;
eventFilters?: EventPropertyFilter[];
+ seriesType?: 'property' | 'array';
}) {
const { t, labels } = useMessages();
const { timezone } = useTimezone();
const { dateRange: { startDate, endDate, unit } } = useDateRange({ timezone });
const { locale, dateLocale } = useLocale();
- const { data, isLoading, isFetching, error } = useEventDataPropertySeriesQuery(
+ const propertySeriesQuery = useEventDataPropertySeriesQuery(
websiteId,
eventName,
propertyName,
eventFilters,
+ { enabled: seriesType === 'property' },
);
+ const arraySeriesQuery = useEventDataArraySeriesQuery(
+ websiteId,
+ eventName,
+ propertyName,
+ eventFilters,
+ { 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 as any[]).reduce((obj: Record, { x, y }) => {
+ const totals = data.reduce((obj: Record, { x, y }) => {
obj[x] = (obj[x] ?? 0) + y;
return obj;
}, {});
@@ -64,12 +77,12 @@ export function EventDataPropertyChart({
);
}, [valueLabels]);
- const chartData: any = useMemo(() => {
+ const chartData = useMemo(() => {
if (!data) return;
- const map = (data as any[]).reduce((obj: Record, { x, t, y }) => {
+ const map = data.reduce((obj: Record, { x, t, y }) => {
if (!obj[x]) obj[x] = [];
- obj[x].push({ x: t, y });
+ obj[x].push({ x, t, y });
return obj;
}, {});
@@ -95,7 +108,13 @@ export function EventDataPropertyChart({
const color = colord(colorMap[key] || CHART_COLORS[index % CHART_COLORS.length]);
return {
label: key,
- data: generateTimeSeries(map[key], startDate, endDate, unit, dateLocale),
+ data: generateTimeSeries(
+ map[key].map(({ t, y }) => ({ x: t, y })),
+ startDate,
+ endDate,
+ unit,
+ dateLocale,
+ ),
lineTension: 0,
backgroundColor: color.alpha(0.6).toRgbString(),
borderColor: color.alpha(0.7).toRgbString(),
@@ -121,7 +140,7 @@ export function EventDataPropertyChart({
}));
}, [aggregated, propertySum]);
- const pieChartData: any = useMemo(() => {
+ const pieChartData = useMemo(() => {
if (!aggregated.length) return null;
return {
labels: valueLabels,
diff --git a/src/app/(main)/websites/[websiteId]/events/EventProperties.tsx b/src/app/(main)/websites/[websiteId]/events/EventProperties.tsx
index 5f3b68e5d..4fe4297a3 100644
--- a/src/app/(main)/websites/[websiteId]/events/EventProperties.tsx
+++ b/src/app/(main)/websites/[websiteId]/events/EventProperties.tsx
@@ -3,7 +3,9 @@ import { Column, ComboBox, Grid, Label, ListItem, Row, Select } from '@umami/rea
import { useMemo, useState } from 'react';
import { LoadingPanel } from '@/components/common/LoadingPanel';
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';
@@ -62,8 +64,20 @@ export function EventProperties({ websiteId }: { websiteId: string }) {
>
{data && (
-
-
+
+
{eventName && (
-
+
+
+
)}
-
+
)}
{eventName && (
)}
{eventName && propertyName && (
- {selectedProperty?.dataType === 1 && (
+ {(selectedProperty?.dataType === DATA_TYPE.string ||
+ selectedProperty?.dataType === DATA_TYPE.boolean) && (
)}
- {selectedProperty?.dataType === 2 && (
+ {selectedProperty?.dataType === DATA_TYPE.number && (
)}
+ {selectedProperty?.dataType === DATA_TYPE.date && (
+
+ )}
+ {selectedProperty?.dataType === DATA_TYPE.array && (
+
+ )}
)}
{eventName && (
diff --git a/src/app/api/websites/[websiteId]/event-data-pivot/array-series/route.ts b/src/app/api/websites/[websiteId]/event-data-pivot/array-series/route.ts
new file mode 100644
index 000000000..59afc1e72
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/event-data-pivot/array-series/route.ts
@@ -0,0 +1,41 @@
+import { z } from 'zod';
+import { parseEventPropertyFilters } 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 { getEventDataArraySeries } from '@/queries/sql/events/getEventDataArraySeries';
+
+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(),
+ eventName: z.string(),
+ 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 { eventName, propertyName, ...rest } = query;
+ const filters = await getQueryFilters(rest, websiteId);
+ const eventFilters = parseEventPropertyFilters(query);
+ const data = await getEventDataArraySeries(websiteId, eventName, propertyName, filters, eventFilters);
+
+ return json(data);
+}
diff --git a/src/app/api/websites/[websiteId]/event-data-pivot/date-series/route.ts b/src/app/api/websites/[websiteId]/event-data-pivot/date-series/route.ts
new file mode 100644
index 000000000..af071dcd9
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/event-data-pivot/date-series/route.ts
@@ -0,0 +1,40 @@
+import { z } from 'zod';
+import { parseEventPropertyFilters } 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 { getEventDataDateSeries } from '@/queries/sql/events/getEventDataDateSeries';
+
+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(),
+ eventName: z.string(),
+ 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 { eventName, propertyName, ...rest } = query;
+ const filters = await getQueryFilters(rest, websiteId);
+ const eventFilters = parseEventPropertyFilters(query);
+ const data = await getEventDataDateSeries(websiteId, eventName, propertyName, filters, eventFilters);
+
+ return json(data);
+}
diff --git a/src/app/api/websites/[websiteId]/event-data-pivot/numeric-series/route.ts b/src/app/api/websites/[websiteId]/event-data-pivot/numeric-series/route.ts
index 048fe7c5c..ca359251b 100644
--- a/src/app/api/websites/[websiteId]/event-data-pivot/numeric-series/route.ts
+++ b/src/app/api/websites/[websiteId]/event-data-pivot/numeric-series/route.ts
@@ -2,7 +2,7 @@ import { z } from 'zod';
import { parseEventPropertyFilters } from '@/lib/params';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
-import { filterParams } from '@/lib/schema';
+import { filterParams, timezoneParam, unitParam } from '@/lib/schema';
import { canViewWebsite } from '@/permissions';
import { getEventDataNumericSeries } from '@/queries/sql/events/getEventDataNumericSeries';
@@ -15,7 +15,9 @@ export async function GET(
endAt: z.coerce.number().int(),
eventName: z.string(),
propertyName: z.string(),
- metric: z.enum(['sum', 'avg']).default('sum'),
+ metric: z.enum(['sum', 'avg', 'count']).default('sum'),
+ timezone: timezoneParam.optional(),
+ unit: unitParam.optional(),
...filterParams,
});
diff --git a/src/app/api/websites/[websiteId]/event-data-pivot/numeric-stats/route.ts b/src/app/api/websites/[websiteId]/event-data-pivot/numeric-stats/route.ts
new file mode 100644
index 000000000..7ff49e3f5
--- /dev/null
+++ b/src/app/api/websites/[websiteId]/event-data-pivot/numeric-stats/route.ts
@@ -0,0 +1,39 @@
+import { z } from 'zod';
+import { parseEventPropertyFilters } from '@/lib/params';
+import { getQueryFilters, parseRequest } from '@/lib/request';
+import { json, unauthorized } from '@/lib/response';
+import { filterParams } from '@/lib/schema';
+import { canViewWebsite } from '@/permissions';
+import { getEventDataNumericStats } from '@/queries/sql/events/getEventDataNumericStats';
+
+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(),
+ eventName: z.string(),
+ propertyName: z.string(),
+ ...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 { eventName, propertyName, ...rest } = query;
+ const filters = await getQueryFilters(rest, websiteId);
+ const eventFilters = parseEventPropertyFilters(query);
+ const data = await getEventDataNumericStats(websiteId, eventName, propertyName, filters, eventFilters);
+
+ return json(data);
+}
diff --git a/src/app/api/websites/[websiteId]/event-data-pivot/property-series/route.ts b/src/app/api/websites/[websiteId]/event-data-pivot/property-series/route.ts
index 4a60edbef..37c31ad34 100644
--- a/src/app/api/websites/[websiteId]/event-data-pivot/property-series/route.ts
+++ b/src/app/api/websites/[websiteId]/event-data-pivot/property-series/route.ts
@@ -2,7 +2,7 @@ import { z } from 'zod';
import { parseEventPropertyFilters } from '@/lib/params';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
-import { filterParams } from '@/lib/schema';
+import { filterParams, timezoneParam, unitParam } from '@/lib/schema';
import { canViewWebsite } from '@/permissions';
import { getEventDataPropertySeries } from '@/queries/sql/events/getEventDataPropertySeries';
@@ -15,6 +15,8 @@ export async function GET(
endAt: z.coerce.number().int(),
eventName: z.string(),
propertyName: z.string(),
+ timezone: timezoneParam.optional(),
+ unit: unitParam.optional(),
...filterParams,
});
diff --git a/src/components/hooks/index.ts b/src/components/hooks/index.ts
index cfc71afb8..d5719d404 100644
--- a/src/components/hooks/index.ts
+++ b/src/components/hooks/index.ts
@@ -17,9 +17,12 @@ 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';
diff --git a/src/components/hooks/queries/useEventDataArraySeriesQuery.ts b/src/components/hooks/queries/useEventDataArraySeriesQuery.ts
new file mode 100644
index 000000000..dba01c8f1
--- /dev/null
+++ b/src/components/hooks/queries/useEventDataArraySeriesQuery.ts
@@ -0,0 +1,47 @@
+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
new file mode 100644
index 000000000..606395b54
--- /dev/null
+++ b/src/components/hooks/queries/useEventDataDateSeriesQuery.ts
@@ -0,0 +1,45 @@
+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/useEventDataNumericSeriesQuery.ts b/src/components/hooks/queries/useEventDataNumericSeriesQuery.ts
index d02674e89..26804ed3b 100644
--- a/src/components/hooks/queries/useEventDataNumericSeriesQuery.ts
+++ b/src/components/hooks/queries/useEventDataNumericSeriesQuery.ts
@@ -8,7 +8,7 @@ export function useEventDataNumericSeriesQuery(
websiteId: string,
eventName: string,
propertyName: string,
- metric: 'sum' | 'avg',
+ metric: 'sum' | 'avg' | 'count',
eventFilters: EventPropertyFilter[] = [],
options?: ReactQueryOptions,
) {
diff --git a/src/components/hooks/queries/useEventDataNumericStatsQuery.ts b/src/components/hooks/queries/useEventDataNumericStatsQuery.ts
new file mode 100644
index 000000000..f52e44ca5
--- /dev/null
+++ b/src/components/hooks/queries/useEventDataNumericStatsQuery.ts
@@ -0,0 +1,36 @@
+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/useEventDataPropertySeriesQuery.ts b/src/components/hooks/queries/useEventDataPropertySeriesQuery.ts
index 68d963ba8..2d94d8cf4 100644
--- a/src/components/hooks/queries/useEventDataPropertySeriesQuery.ts
+++ b/src/components/hooks/queries/useEventDataPropertySeriesQuery.ts
@@ -1,5 +1,5 @@
import { serializeEventPropertyFilters } from '@/lib/params';
-import type { EventPropertyFilter, ReactQueryOptions } from '@/lib/types';
+import type { EventDataSeriesPoint, EventPropertyFilter, ReactQueryOptions } from '@/lib/types';
import { useApi } from '../useApi';
import { useDateParameters } from '../useDateParameters';
import { useFilterParameters } from '../useFilterParameters';
@@ -15,7 +15,7 @@ export function useEventDataPropertySeriesQuery(
const { startAt, endAt, unit, timezone } = useDateParameters();
const params = useFilterParameters();
- return useQuery({
+ return useQuery({
queryKey: [
'websites:event-data-pivot:property-series',
{ websiteId, eventName, propertyName, eventFilters, startAt, endAt, unit, timezone, ...params },
diff --git a/src/lib/clickhouse.ts b/src/lib/clickhouse.ts
index efeaa3b84..3329a2b75 100644
--- a/src/lib/clickhouse.ts
+++ b/src/lib/clickhouse.ts
@@ -8,7 +8,7 @@ import type { EventPropertyFilter, Operator, QueryFilters, QueryOptions } from '
export const CLICKHOUSE_DATE_FORMATS = {
utc: '%Y-%m-%dT%H:%i:%SZ',
- second: '%Y-%m-%d %H:%i:%S',
+ second: '%Y-%m-%dT%H:%i:%S',
minute: '%Y-%m-%d %H:%i:00',
hour: '%Y-%m-%d %H:00:00',
day: '%Y-%m-%d',
diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts
index 422069159..8b9f143fc 100644
--- a/src/lib/prisma.ts
+++ b/src/lib/prisma.ts
@@ -39,6 +39,11 @@ const DATE_FORMATS_UTC = {
year: 'YYYY-01-01"T"HH24:00:00"Z"',
};
+const DATE_STRING_FORMATS = {
+ utc: 'YYYY-MM-DD"T"HH24:MI:SS"Z"',
+ second: 'YYYY-MM-DD"T"HH24:MI:SS',
+};
+
function getAddIntervalQuery(field: string, interval: string): string {
return `${field} + interval '${interval}'`;
}
@@ -59,6 +64,14 @@ function getDateSQL(field: string, unit: string, timezone?: string): string {
return `to_char(date_trunc('${unit}', ${field}), '${DATE_FORMATS_UTC[unit]}')`;
}
+function getDateStringSQL(field: string, unit: keyof typeof DATE_STRING_FORMATS = 'utc', timezone?: string): string {
+ if (timezone && timezone !== 'utc') {
+ return `to_char(${field} at time zone '${timezone}', '${DATE_STRING_FORMATS[unit]}')`;
+ }
+
+ return `to_char(${field}, '${DATE_STRING_FORMATS.utc}')`;
+}
+
function getDateWeeklySQL(field: string, timezone?: string) {
return `concat(extract(dow from (${field} at time zone '${timezone}')), ':', to_char((${field} at time zone '${timezone}'), 'HH24'))`;
}
@@ -504,6 +517,7 @@ export default {
getCastColumnQuery,
getDayDiffQuery,
getDateSQL,
+ getDateStringSQL,
getDateWeeklySQL,
getFilterQuery,
getEventPropertyFilterQuery,
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 2f74a4fd2..c93854910 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -60,6 +60,25 @@ export interface DynamicData {
[key: string]: number | string | number[] | string[];
}
+export interface EventDataSeriesPoint {
+ x: string;
+ t: string;
+ y: number;
+}
+
+export interface EventDataDateSeriesPoint {
+ t: string;
+ y: number;
+}
+
+export interface EventDataNumericStats {
+ total: number;
+ average: number;
+ median: number;
+ max: number;
+ min: number;
+}
+
export interface QueryOptions {
joinSession?: boolean;
columns?: Record;
diff --git a/src/queries/sql/events/getEventDataArraySeries.ts b/src/queries/sql/events/getEventDataArraySeries.ts
new file mode 100644
index 000000000..b9aa1e476
--- /dev/null
+++ b/src/queries/sql/events/getEventDataArraySeries.ts
@@ -0,0 +1,110 @@
+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, EventPropertyFilter, QueryFilters } from '@/lib/types';
+
+const FUNCTION_NAME = 'getEventDataArraySeries';
+
+export async function getEventDataArraySeries(
+ ...args: [
+ websiteId: string,
+ eventName: string,
+ propertyName: string,
+ filters: QueryFilters,
+ eventFilters?: EventPropertyFilter[],
+ ]
+): Promise {
+ return runQuery({
+ [PRISMA]: () => relationalQuery(...args),
+ [CLICKHOUSE]: () => clickhouseQuery(...args),
+ });
+}
+
+async function relationalQuery(
+ websiteId: string,
+ eventName: string,
+ propertyName: string,
+ filters: QueryFilters,
+ eventFilters: EventPropertyFilter[] = [],
+): Promise {
+ const { timezone = 'utc', unit = 'day' } = filters;
+ const { rawQuery, getDateSQL, parseFilters, getEventPropertyFilterQuery } = prisma;
+ const { filterQuery, cohortQuery, joinSessionQuery, queryParams } = parseFilters({
+ ...filters,
+ websiteId,
+ });
+ const { sql: epfSQL, params: epfParams } = getEventPropertyFilterQuery(eventFilters);
+
+ return rawQuery(
+ `
+ select
+ array_item.value as x,
+ ${getDateSQL('event_data.created_at', unit, timezone)} as t,
+ count(*) as y
+ from event_data
+ join website_event on website_event.event_id = event_data.website_event_id
+ and website_event.website_id = {{websiteId::uuid}}
+ and website_event.created_at between {{startDate}} and {{endDate}}
+ and website_event.event_type = 2
+ and website_event.event_name = {{eventName}}
+ cross join lateral jsonb_array_elements_text(coalesce(event_data.string_value, '[]')::jsonb) as array_item(value)
+ ${cohortQuery}
+ ${joinSessionQuery}
+ where event_data.website_id = {{websiteId::uuid}}
+ and event_data.created_at between {{startDate}} and {{endDate}}
+ and event_data.data_key = {{propertyName}}
+ and event_data.data_type = ${DATA_TYPE.array}
+ ${filterQuery}
+ ${epfSQL}
+ group by 1, 2
+ order by 2
+ `,
+ { ...queryParams, eventName, propertyName, ...epfParams },
+ FUNCTION_NAME,
+ );
+}
+
+async function clickhouseQuery(
+ websiteId: string,
+ eventName: string,
+ propertyName: string,
+ filters: QueryFilters,
+ eventFilters: EventPropertyFilter[] = [],
+): Promise {
+ const { timezone = 'UTC', unit = 'day' } = filters;
+ const { rawQuery, getDateSQL, parseFilters, getEventPropertyFilterQuery } = clickhouse;
+ const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId });
+ const { sql: epfSQL, params: epfParams } = getEventPropertyFilterQuery(eventFilters);
+
+ return rawQuery(
+ `
+ select
+ arrayJoin(JSONExtract(ifNull(event_data.string_value, '[]'), 'Array(String)')) as x,
+ ${getDateSQL('event_data.created_at', unit, timezone)} as t,
+ count() as y
+ from event_data
+ any left join (
+ select *
+ from website_event
+ where website_id = {websiteId:UUID}
+ and created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ and event_type = 2
+ and event_name = {eventName:String}) website_event
+ on website_event.event_id = event_data.event_id
+ and website_event.session_id = event_data.session_id
+ and website_event.website_id = event_data.website_id
+ ${cohortQuery}
+ 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}
+ and event_data.data_type = ${DATA_TYPE.array}
+ ${filterQuery}
+ ${epfSQL}
+ group by x, t
+ order by t
+ `,
+ { ...queryParams, eventName, propertyName, ...epfParams },
+ FUNCTION_NAME,
+ );
+}
diff --git a/src/queries/sql/events/getEventDataDateSeries.ts b/src/queries/sql/events/getEventDataDateSeries.ts
new file mode 100644
index 000000000..eda146515
--- /dev/null
+++ b/src/queries/sql/events/getEventDataDateSeries.ts
@@ -0,0 +1,107 @@
+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, EventPropertyFilter, QueryFilters } from '@/lib/types';
+
+const FUNCTION_NAME = 'getEventDataDateSeries';
+
+export async function getEventDataDateSeries(
+ ...args: [
+ websiteId: string,
+ eventName: string,
+ propertyName: string,
+ filters: QueryFilters,
+ eventFilters?: EventPropertyFilter[],
+ ]
+): Promise {
+ return runQuery({
+ [PRISMA]: () => relationalQuery(...args),
+ [CLICKHOUSE]: () => clickhouseQuery(...args),
+ });
+}
+
+async function relationalQuery(
+ websiteId: string,
+ eventName: string,
+ propertyName: string,
+ filters: QueryFilters,
+ eventFilters: EventPropertyFilter[] = [],
+) : Promise {
+ const { timezone = 'UTC' } = filters;
+ const { rawQuery, parseFilters, getDateStringSQL, getEventPropertyFilterQuery } = prisma;
+ const { filterQuery, cohortQuery, joinSessionQuery, queryParams } = parseFilters({
+ ...filters,
+ websiteId,
+ });
+ const { sql: epfSQL, params: epfParams } = getEventPropertyFilterQuery(eventFilters);
+
+ return rawQuery(
+ `
+ select
+ ${getDateStringSQL('event_data.date_value', 'second', timezone)} as t,
+ count(*) as y
+ from event_data
+ join website_event on website_event.event_id = event_data.website_event_id
+ and website_event.website_id = {{websiteId::uuid}}
+ and website_event.created_at between {{startDate}} and {{endDate}}
+ and website_event.event_type = 2
+ and website_event.event_name = {{eventName}}
+ ${cohortQuery}
+ ${joinSessionQuery}
+ where event_data.website_id = {{websiteId::uuid}}
+ and event_data.created_at between {{startDate}} and {{endDate}}
+ and event_data.data_key = {{propertyName}}
+ and event_data.data_type = ${DATA_TYPE.date}
+ ${filterQuery}
+ ${epfSQL}
+ group by 1
+ order by 1
+ `,
+ { ...queryParams, eventName, propertyName, ...epfParams },
+ FUNCTION_NAME,
+ );
+}
+
+async function clickhouseQuery(
+ websiteId: string,
+ eventName: string,
+ propertyName: string,
+ filters: QueryFilters,
+ eventFilters: EventPropertyFilter[] = [],
+): Promise {
+ const { timezone = 'UTC' } = filters;
+ const { rawQuery, parseFilters, getDateStringSQL, getEventPropertyFilterQuery } = clickhouse;
+ const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId });
+ const { sql: epfSQL, params: epfParams } = getEventPropertyFilterQuery(eventFilters);
+
+ return rawQuery(
+ `
+ select
+ ${getDateStringSQL('event_data.date_value', 'second', timezone)} as t,
+ count() as y
+ from event_data
+ any left join (
+ select *
+ from website_event
+ where website_id = {websiteId:UUID}
+ and created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ and event_type = 2
+ and event_name = {eventName:String}) website_event
+ on website_event.event_id = event_data.event_id
+ and website_event.session_id = event_data.session_id
+ and website_event.website_id = event_data.website_id
+ ${cohortQuery}
+ 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}
+ and event_data.data_type = ${DATA_TYPE.date}
+ ${filterQuery}
+ ${epfSQL}
+ group by t
+ order by t
+ `,
+ { ...queryParams, eventName, propertyName, ...epfParams },
+ FUNCTION_NAME,
+ );
+}
diff --git a/src/queries/sql/events/getEventDataNumericSeries.ts b/src/queries/sql/events/getEventDataNumericSeries.ts
index 64bd9b66e..aa039cdad 100644
--- a/src/queries/sql/events/getEventDataNumericSeries.ts
+++ b/src/queries/sql/events/getEventDataNumericSeries.ts
@@ -10,7 +10,7 @@ export async function getEventDataNumericSeries(
websiteId: string,
eventName: string,
propertyName: string,
- metric: 'sum' | 'avg',
+ metric: 'sum' | 'avg' | 'count',
filters: QueryFilters,
eventFilters?: EventPropertyFilter[],
]
@@ -25,7 +25,7 @@ async function relationalQuery(
websiteId: string,
eventName: string,
propertyName: string,
- metric: 'sum' | 'avg',
+ metric: 'sum' | 'avg' | 'count',
filters: QueryFilters,
eventFilters: EventPropertyFilter[] = [],
) {
@@ -36,13 +36,16 @@ async function relationalQuery(
websiteId,
});
const { sql: epfSQL, params: epfParams } = getEventPropertyFilterQuery(eventFilters);
- const aggFn = metric === 'avg' ? 'avg' : 'sum';
+ const aggSql =
+ metric === 'avg' ? 'avg(cast(event_data.number_value as decimal))' :
+ metric === 'count' ? 'count(*)' :
+ 'sum(cast(event_data.number_value as decimal))';
return rawQuery(
`
select
${getDateSQL('event_data.created_at', unit, timezone)} t,
- ${aggFn}(cast(event_data.number_value as decimal)) y
+ ${aggSql} y
from event_data
join website_event on website_event.event_id = event_data.website_event_id
and website_event.website_id = {{websiteId::uuid}}
@@ -69,7 +72,7 @@ async function clickhouseQuery(
websiteId: string,
eventName: string,
propertyName: string,
- metric: 'sum' | 'avg',
+ metric: 'sum' | 'avg' | 'count',
filters: QueryFilters,
eventFilters: EventPropertyFilter[] = [],
): Promise<{ t: string; y: number }[]> {
@@ -77,13 +80,16 @@ async function clickhouseQuery(
const { rawQuery, getDateSQL, parseFilters, getEventPropertyFilterQuery } = clickhouse;
const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId });
const { sql: epfSQL, params: epfParams } = getEventPropertyFilterQuery(eventFilters);
- const aggFn = metric === 'avg' ? 'avg' : 'sum';
+ const aggSql =
+ metric === 'avg' ? 'avg(event_data.number_value)' :
+ metric === 'count' ? 'count()' :
+ 'sum(event_data.number_value)';
return rawQuery(
`
select
${getDateSQL('event_data.created_at', unit, timezone)} as t,
- ${aggFn}(event_data.number_value) as y
+ ${aggSql} as y
from event_data
any left join (
select *
diff --git a/src/queries/sql/events/getEventDataNumericStats.ts b/src/queries/sql/events/getEventDataNumericStats.ts
new file mode 100644
index 000000000..8b649b792
--- /dev/null
+++ b/src/queries/sql/events/getEventDataNumericStats.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, EventPropertyFilter, QueryFilters } from '@/lib/types';
+
+const FUNCTION_NAME = 'getEventDataNumericStats';
+
+export async function getEventDataNumericStats(
+ ...args: [
+ websiteId: string,
+ eventName: string,
+ propertyName: string,
+ filters: QueryFilters,
+ eventFilters?: EventPropertyFilter[],
+ ]
+): Promise {
+ return runQuery({
+ [PRISMA]: () => relationalQuery(...args),
+ [CLICKHOUSE]: () => clickhouseQuery(...args),
+ }).then(results => results?.[0]);
+}
+
+async function relationalQuery(
+ websiteId: string,
+ eventName: string,
+ propertyName: string,
+ filters: QueryFilters,
+ eventFilters: EventPropertyFilter[] = [],
+) {
+ const { rawQuery, parseFilters, getEventPropertyFilterQuery } = prisma;
+ const { filterQuery, cohortQuery, joinSessionQuery, queryParams } = parseFilters({
+ ...filters,
+ websiteId,
+ });
+ const { sql: epfSQL, params: epfParams } = getEventPropertyFilterQuery(eventFilters);
+
+ return rawQuery(
+ `
+ select
+ coalesce(sum(cast(event_data.number_value as decimal)), 0) as "total",
+ coalesce(avg(cast(event_data.number_value as decimal)), 0) as "average",
+ coalesce(percentile_cont(0.5) within group (order by event_data.number_value), 0) as "median",
+ coalesce(max(event_data.number_value), 0) as "max",
+ coalesce(min(event_data.number_value), 0) as "min"
+ from event_data
+ join website_event on website_event.event_id = event_data.website_event_id
+ and website_event.website_id = {{websiteId::uuid}}
+ and website_event.created_at between {{startDate}} and {{endDate}}
+ and website_event.event_type = 2
+ and website_event.event_name = {{eventName}}
+ ${cohortQuery}
+ ${joinSessionQuery}
+ where event_data.website_id = {{websiteId::uuid}}
+ and event_data.created_at between {{startDate}} and {{endDate}}
+ and event_data.data_key = {{propertyName}}
+ and event_data.data_type = 2
+ ${filterQuery}
+ ${epfSQL}
+ `,
+ { ...queryParams, eventName, propertyName, ...epfParams },
+ FUNCTION_NAME,
+ );
+}
+
+async function clickhouseQuery(
+ websiteId: string,
+ eventName: string,
+ propertyName: string,
+ filters: QueryFilters,
+ eventFilters: EventPropertyFilter[] = [],
+): Promise {
+ const { rawQuery, parseFilters, getEventPropertyFilterQuery } = clickhouse;
+ const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId });
+ const { sql: epfSQL, params: epfParams } = getEventPropertyFilterQuery(eventFilters);
+
+ return rawQuery(
+ `
+ select
+ if(count() = 0, 0, sum(event_data.number_value)) as total,
+ if(count() = 0, 0, avg(event_data.number_value)) as average,
+ if(count() = 0, 0, median(event_data.number_value)) as median,
+ if(count() = 0, 0, max(event_data.number_value)) as max,
+ if(count() = 0, 0, min(event_data.number_value)) as min
+ from event_data
+ any left join (
+ select *
+ from website_event
+ where website_id = {websiteId:UUID}
+ and created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ and event_type = 2
+ and event_name = {eventName:String}) website_event
+ on website_event.event_id = event_data.event_id
+ and website_event.session_id = event_data.session_id
+ and website_event.website_id = event_data.website_id
+ ${cohortQuery}
+ 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}
+ and event_data.data_type = 2
+ ${filterQuery}
+ ${epfSQL}
+ `,
+ { ...queryParams, eventName, propertyName, ...epfParams },
+ FUNCTION_NAME,
+ );
+}
diff --git a/src/queries/sql/events/getEventDataPropertySeries.ts b/src/queries/sql/events/getEventDataPropertySeries.ts
index c8ef193dc..e75004fa0 100644
--- a/src/queries/sql/events/getEventDataPropertySeries.ts
+++ b/src/queries/sql/events/getEventDataPropertySeries.ts
@@ -1,7 +1,8 @@
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 { EventPropertyFilter, QueryFilters } from '@/lib/types';
+import type { EventDataSeriesPoint, EventPropertyFilter, QueryFilters } from '@/lib/types';
const FUNCTION_NAME = 'getEventDataPropertySeries';
@@ -13,7 +14,7 @@ export async function getEventDataPropertySeries(
filters: QueryFilters,
eventFilters?: EventPropertyFilter[],
]
-) {
+): Promise {
return runQuery({
[PRISMA]: () => relationalQuery(...args),
[CLICKHOUSE]: () => clickhouseQuery(...args),
@@ -26,7 +27,7 @@ async function relationalQuery(
propertyName: string,
filters: QueryFilters,
eventFilters: EventPropertyFilter[] = [],
-) {
+): Promise {
const { timezone = 'utc', unit = 'day' } = filters;
const { rawQuery, getDateSQL, parseFilters, getEventPropertyFilterQuery } = prisma;
const { filterQuery, cohortQuery, joinSessionQuery, queryParams } = parseFilters({
@@ -52,7 +53,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}}
- and event_data.data_type = 1
+ and event_data.data_type in (${DATA_TYPE.string}, ${DATA_TYPE.boolean})
${filterQuery}
${epfSQL}
group by 1, 2
@@ -69,7 +70,7 @@ async function clickhouseQuery(
propertyName: string,
filters: QueryFilters,
eventFilters: EventPropertyFilter[] = [],
-): Promise<{ x: string; t: string; y: number }[]> {
+): Promise {
const { timezone = 'UTC', unit = 'day' } = filters;
const { rawQuery, getDateSQL, parseFilters, getEventPropertyFilterQuery } = clickhouse;
const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId });
@@ -96,7 +97,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}
- and event_data.data_type = 1
+ and event_data.data_type in (${DATA_TYPE.string}, ${DATA_TYPE.boolean})
${filterQuery}
${epfSQL}
group by x, t
diff --git a/src/queries/sql/index.ts b/src/queries/sql/index.ts
index 94e283df3..f5a05d758 100644
--- a/src/queries/sql/index.ts
+++ b/src/queries/sql/index.ts
@@ -1,7 +1,10 @@
+export * from './events/getEventDataArraySeries';
export * from './events/getEventDataById';
+export * from './events/getEventDataDateSeries';
export * from './events/getEventDataEvents';
export * from './events/getEventDataFields';
export * from './events/getEventDataNumericSeries';
+export * from './events/getEventDataNumericStats';
export * from './events/getEventDataPivot';
export * from './events/getEventDataProperties';
export * from './events/getEventDataPropertySeries';