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 && ( - - + +