refactor Property components to be resused for both session/event data. Implement session-data screens + filtering. Remove all event-data specific compoenents/queries

This commit is contained in:
Francis Cao committed 2026-04-29 13:11:03 -07:00
1 parent 9da332c489
commit 680a1ca1a2
71 files changed
+2814 -958

No files matched your search

@@ -0,0 +1,188 @@
'use client';
import { Column, Grid } from '@umami/react-zen';
import { colord } from 'colord';
import { useCallback, useMemo } from 'react';
import { BarChart } from '@/components/charts/BarChart';
import { PieChart } from '@/components/charts/PieChart';
import { LoadingPanel } from '@/components/common/LoadingPanel';
import {
useDateRange,
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, PropertyFilter } from '@/lib/types';
export function PropertyChart({
source,
websiteId,
propertyName,
propertyFilters = [],
eventName,
seriesType = 'property',
}: {
source: PropertyDataSource;
websiteId: string;
propertyName: string;
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 = usePropertySeriesQuery(
source,
websiteId,
propertyName,
propertyFilters,
eventName,
{ enabled: seriesType === 'property' },
);
const arraySeriesQuery = usePropertyArraySeriesQuery(
source,
websiteId,
propertyName,
propertyFilters,
eventName,
{ enabled: seriesType === 'array' },
);
const { data, isLoading, isFetching, error } =
seriesType === 'array' ? arraySeriesQuery : propertySeriesQuery;
const aggregated = useMemo(() => {
if (!data) return [];
const totals = data.reduce((obj: Record<string, number>, { x, y }) => {
obj[x] = (obj[x] ?? 0) + y;
return obj;
}, {});
return Object.entries(totals)
.map(([value, total]) => ({ value, total: total as number }))
.sort((a, b) => b.total - a.total);
}, [data]);
const valueLabels = useMemo(() => aggregated.map(({ value }) => value), [aggregated]);
const colorMap = useMemo(
() =>
valueLabels.reduce(
(obj, label, index) => {
obj[label] = CHART_COLORS[index % CHART_COLORS.length];
return obj;
},
{} as Record<string, string>,
),
[valueLabels],
);
const chartData = useMemo(() => {
if (!data) return;
const map = data.reduce((obj: Record<string, EventDataSeriesPoint[]>, { x, t, y }) => {
if (!obj[x]) obj[x] = [];
obj[x].push({ x, t, y });
return obj;
}, {});
if (!Object.keys(map).length) {
return {
datasets: [
{
data: generateTimeSeries([], startDate, endDate, unit, dateLocale),
lineTension: 0,
borderWidth: 1,
},
],
};
}
const keys = [
...valueLabels.filter(label => map[label]),
...Object.keys(map).filter(key => !valueLabels.includes(key)),
];
return {
datasets: keys.map((key, index) => {
const color = colord(colorMap[key] || CHART_COLORS[index % CHART_COLORS.length]);
return {
label: key,
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(),
borderWidth: 1,
};
}),
};
}, [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 }) => ({
label: value,
count: total,
percent: 100 * (total / propertySum),
}));
}, [aggregated, propertySum]);
const pieChartData = useMemo(() => {
if (!aggregated.length) return null;
return {
labels: valueLabels,
datasets: [
{
data: aggregated.map(({ total }) => total),
backgroundColor: valueLabels.map(label => colorMap[label]),
borderWidth: 0,
},
],
};
}, [aggregated, valueLabels, colorMap]);
return (
<Column gap="6">
<LoadingPanel isLoading={isLoading} error={error} minHeight="400px">
{chartData && (
<BarChart
chartData={chartData}
minDate={startDate}
maxDate={endDate}
unit={unit}
stacked={true}
renderXLabel={renderXLabel}
height="400px"
/>
)}
</LoadingPanel>
<LoadingPanel
data={tableData}
isLoading={isLoading}
isFetching={isFetching}
error={error}
minHeight="300px"
>
<Grid columns={{ base: '1fr', md: '1fr 1fr' }} gap padding="2" alignItems="start">
<ListTable title={propertyName} metric={t(labels.count)} data={tableData} />
{pieChartData && <PieChart type="doughnut" chartData={pieChartData} />}
</Grid>
</LoadingPanel>
</Column>
);
}
@@ -0,0 +1,300 @@
'use client';
import { Column, Grid, Text, useTheme } from '@umami/react-zen';
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 { 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 type { EventDataDateSeriesPoint, PropertyFilter } 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 (
<Column
justifyContent="center"
paddingX="6"
paddingY="4"
borderRadius
backgroundColor="surface-base"
border
gap="2"
height="100%"
style={{ minWidth: 0 }}
>
<Text weight="bold" style={{ overflowWrap: 'anywhere' }}>{label}</Text>
<Text weight="bold" style={{ overflowWrap: 'anywhere' }}>{value}</Text>
{hint && <Text color="muted" style={{ overflowWrap: 'anywhere' }}>{hint}</Text>}
</Column>
);
}
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 PropertyDateChart({
source,
websiteId,
propertyName,
propertyFilters = [],
eventName,
}: {
source: PropertyDataSource;
websiteId: string;
propertyName: string;
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 = 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(
() =>
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 maxPropertyDate = exactDateRows[exactDateRows.length - 1]?.date ?? null;
const chartUnit = useMemo(
() => (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");
obj[bucketKey] = {
t: bucketKey,
y: (obj[bucketKey]?.y ?? 0) + row.y,
};
return obj;
},
{} as Record<string, EventDataDateSeriesPoint>,
);
return Object.values(buckets).sort(
(a, b) => (parseDateValue(a.t)?.getTime() ?? 0) - (parseDateValue(b.t)?.getTime() ?? 0),
);
}, [chartUnit, dateLocale, exactDateRows]);
const chartData: any = useMemo(() => {
if (!minPropertyDate || !maxPropertyDate) {
return {
datasets: [
{
label: t(labels.count),
data: [],
barPercentage: 0.9,
categoryPercentage: 0.9,
...colors.chart.visitors,
borderWidth: 1,
},
],
};
}
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,
},
],
};
}, [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 weekdayTotals = exactDateRows.reduce(
(obj, { date, y }) => {
const day = date.getDay();
obj[day] = (obj[day] ?? 0) + y;
return obj;
},
{} as Record<number, number>,
);
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,
};
});
}, [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');
obj[key] = { date: dayDate, count: (obj[key]?.count ?? 0) + y };
return obj;
},
{} as Record<string, { date: Date; count: number }>,
);
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(
() =>
weekdayLabels.reduce((obj, label, index) => {
obj[label] = CHART_COLORS[index % CHART_COLORS.length];
return obj;
}, {} as Record<string, string>),
[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,
},
],
};
}, [weekdayColorMap, weekdayLabels, weekdayTableData]);
return (
<Column gap="4">
<LoadingPanel isLoading={query.isLoading} isFetching={query.isFetching} error={query.error} minHeight="100px">
<MetricsBar padding="2">
<InsightCard
label="Top weekday"
value={topWeekday?.label ?? 'None'}
hint={topWeekday ? `${topWeekday.count.toLocaleString(locale)} count - ${Math.round(topWeekday.percent)}%` : undefined}
/>
<InsightCard
label="Top date"
value={topDateByDay ? formatDate(topDateByDay.date, 'PP', locale) : 'None'}
hint={topDateByDay ? `${topDateByDay.count.toLocaleString(locale)} count - ${Math.round((topDateByDay.count / total) * 100)}%` : undefined}
/>
<InsightCard
label="Earliest date"
value={minPropertyDate ? formatDate(minPropertyDate, 'PP', locale) : 'None'}
hint={minPropertyDate ? formatDistance(minPropertyDate, zonedNow, { addSuffix: true }) : undefined}
/>
<InsightCard
label="Latest date"
value={maxPropertyDate ? formatDate(maxPropertyDate, 'PP', locale) : 'None'}
hint={maxPropertyDate ? formatDistance(maxPropertyDate, zonedNow, { addSuffix: true }) : undefined}
/>
</MetricsBar>
</LoadingPanel>
<LoadingPanel isLoading={query.isLoading} error={query.error} minHeight="400px">
{chartData && (
<BarChart
chartData={chartData}
minDate={minPropertyDate ?? new Date()}
maxDate={maxPropertyDate ?? new Date()}
unit={chartUnit}
stacked={false}
renderXLabel={renderXLabel}
height="400px"
/>
)}
</LoadingPanel>
<LoadingPanel
data={weekdayTableData}
isLoading={query.isLoading}
isFetching={query.isFetching}
error={query.error}
minHeight="300px"
>
<Grid columns={{ base: '1fr', md: '1fr 1fr' }} gap padding="2" alignItems="start">
<ListTable title={propertyName} metric={t(labels.count)} data={weekdayTableData} />
{weekdayPieChartData && <PieChart type="doughnut" chartData={weekdayPieChartData} />}
</Grid>
</LoadingPanel>
</Column>
);
}
@@ -0,0 +1,129 @@
'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 { PropertyFilter } from '@/lib/types';
export function PropertyFilterBar({
filters,
onChange,
}: {
filters: PropertyFilter[];
onChange: (filters: PropertyFilter[]) => void;
}) {
const { t, labels } = useMessages();
if (!filters.length) return null;
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);
case OPERATORS.before:
return t(labels.before);
case OPERATORS.after:
return t(labels.after);
default:
return op;
}
};
return (
<Row
gap
alignItems="center"
justifyContent="space-between"
padding="2"
border
borderRadius
wrap="wrap"
>
<Row alignItems="center" gap="2" wrap="wrap" width={{ base: '100%', md: 'auto' }}>
{filters.map((filter, index) => (
<FilterPill
key={`${filter.propertyName}-${index}`}
label={filter.propertyName}
operator={operatorLabel(filter.operator)}
value={filter.value}
onRemove={() => onChange(filters.filter((_, i) => i !== index))}
/>
))}
</Row>
<TooltipTrigger delay={0}>
<Button variant="zero" onPress={() => onChange([])}>
<Icon>
<X />
</Icon>
</Button>
<Tooltip>
<Text>{t(labels.clearAll)}</Text>
</Tooltip>
</TooltipTrigger>
</Row>
);
}
function FilterPill({
label,
operator,
value,
onRemove,
}: {
label: string;
operator: string;
value: string;
onRemove: () => void;
}) {
return (
<Row
border
padding="2"
color
backgroundColor
borderRadius
alignItems="center"
gap="4"
theme="dark"
>
<Row
alignItems="center"
gap="2"
style={{ maxWidth: 'min(500px, calc(100vw - 10rem))', minWidth: 0, overflow: 'hidden' }}
>
<Text color="primary" weight="bold">
{label}
</Text>
<Text color="muted">{operator}</Text>
<Text
color="primary"
weight="bold"
style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
>
{value}
</Text>
</Row>
<Icon onClick={onRemove} size="xs" style={{ cursor: 'pointer' }}>
<X />
</Icon>
</Row>
);
}
@@ -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 (
<DialogButton icon={<ListFilter />} label={t(labels.propertyFilter)} variant="outline">
{({ close }) => (
<PropertyFilterEditForm
source={source}
websiteId={websiteId}
eventName={eventName}
fields={fields}
value={filters}
onApply={onApply}
onClose={close}
/>
)}
</DialogButton>
);
}
@@ -0,0 +1,135 @@
'use client';
import { Button, Column, Grid, Icon, List, ListItem, Menu, MenuItem, MenuTrigger, Popover, Row } from '@umami/react-zen';
import { format } from 'date-fns';
import { useState } from 'react';
import { Empty } from '@/components/common/Empty';
import { useMessages, useMobile, usePropertyFieldsQuery } from '@/components/hooks';
import { Plus } from '@/components/icons';
import { DATA_TYPE, OPERATORS } from '@/lib/constants';
import type { PropertyFilter } from '@/lib/types';
import type { PropertyDataSource } from '@/components/hooks/queries/usePropertyFieldsQuery';
import { PropertyFilterRecord } from './PropertyFilterRecord';
export function PropertyFilterEditForm({
source,
websiteId,
eventName,
fields: providedFields,
value,
onApply,
onClose,
}: {
source: PropertyDataSource;
websiteId: string;
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<PropertyFilter[]>(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 ?? DATA_TYPE.string;
setFilters(prev => [
...prev,
{
propertyName,
dataType,
operator:
dataType === DATA_TYPE.date
? OPERATORS.before
: dataType === DATA_TYPE.array
? OPERATORS.contains
: OPERATORS.equals,
value:
dataType === DATA_TYPE.boolean
? 'true'
: dataType === DATA_TYPE.date
? format(new Date(), 'yyyy-MM-dd')
: '',
},
]);
};
return (
<Column width={isMobile ? 'auto' : '700px'} gap="6">
<Column minHeight="400px">
<Grid columns={{ base: '1fr', md: '180px 1fr' }} overflow="hidden" gapY="6">
<Row display={{ base: 'flex', md: 'none' }}>
<MenuTrigger>
<Button>
<Icon>
<Plus />
</Icon>
</Button>
<Popover placement="bottom start" shouldFlip>
<Menu
onAction={key => handleAdd(key.toString())}
style={{ maxHeight: 'calc(100vh - 2rem)', overflowY: 'auto' }}
>
{(fields as any[]).map(field => (
<MenuItem key={`${field.propertyName}:${field.dataType}`} id={field.propertyName}>
{field.propertyName}
</MenuItem>
))}
</Menu>
</Popover>
</MenuTrigger>
</Row>
<Column
display={{ base: 'none', md: 'flex' }}
border="right"
paddingRight="3"
marginRight="6"
>
<List onAction={key => handleAdd(key.toString())}>
{(fields as any[]).map(field => (
<ListItem key={`${field.propertyName}:${field.dataType}`} id={field.propertyName}>
{field.propertyName}
</ListItem>
))}
</List>
</Column>
<Column overflow="auto" gapY="4" style={{ contain: 'layout' }}>
{filters.map((filter, index) => (
<PropertyFilterRecord
key={`${filter.propertyName}-${index}`}
source={source}
websiteId={websiteId}
eventName={eventName}
filter={filter}
filters={filters}
onChange={f => setFilters(prev => prev.map((item, i) => (i === index ? f : item)))}
onRemove={() => setFilters(prev => prev.filter((_, i) => i !== index))}
/>
))}
{!filters.length && <Empty message={t(messages.nothingSelected)} />}
</Column>
</Grid>
</Column>
<Row alignItems="center" justifyContent="space-between" gap>
<Button onPress={() => setFilters([])}>{t(labels.reset)}</Button>
<Row gap>
<Button onPress={onClose}>{t(labels.cancel)}</Button>
<Button
variant="primary"
onPress={() => {
onApply(filters);
onClose();
}}
>
{t(labels.apply)}
</Button>
</Row>
</Row>
</Column>
);
}
@@ -0,0 +1,274 @@
'use client';
import { Button, Calendar, Column, ComboBox, Dialog, DialogTrigger, Grid, Icon, Label, ListItem, Loading, Popover, Row, Select, TextField } from '@umami/react-zen';
import { format, parseISO } from 'date-fns';
import { useState } from 'react';
import { DateDisplay } from '@/components/common/DateDisplay';
import { Empty } from '@/components/common/Empty';
import { MultiSelect } from '@/components/common/MultiSelect';
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 { Operator, PropertyFilter } from '@/lib/types';
import type { PropertyDataSource } from '@/components/hooks/queries/usePropertyFieldsQuery';
const STRING_OPERATORS: Operator[] = [
OPERATORS.equals,
OPERATORS.notEquals,
OPERATORS.contains,
OPERATORS.doesNotContain,
OPERATORS.regex,
OPERATORS.notRegex,
];
const NUMERIC_OPERATORS: Operator[] = [
OPERATORS.equals,
OPERATORS.notEquals,
OPERATORS.greaterThan,
OPERATORS.lessThan,
OPERATORS.greaterThanEquals,
OPERATORS.lessThanEquals,
];
const BOOLEAN_OPERATORS: Operator[] = [OPERATORS.equals, OPERATORS.notEquals];
const DATE_OPERATORS: Operator[] = [OPERATORS.before, OPERATORS.after];
const ARRAY_OPERATORS: Operator[] = [OPERATORS.contains, OPERATORS.doesNotContain];
const MULTI_OPERATORS: Operator[] = [OPERATORS.equals, OPERATORS.notEquals];
const FREE_TEXT_OPERATORS: Operator[] = [
OPERATORS.contains,
OPERATORS.doesNotContain,
OPERATORS.regex,
OPERATORS.notRegex,
];
export function PropertyFilterRecord({
source,
websiteId,
eventName,
filter,
filters,
onChange,
onRemove,
}: {
source: PropertyDataSource;
websiteId: string;
eventName?: string;
filter: PropertyFilter;
filters: PropertyFilter[];
onChange: (filter: PropertyFilter) => void;
onRemove: () => void;
}) {
const { t, labels, messages } = useMessages();
const [search, setSearch] = useState('');
const isNumeric = filter.dataType === DATA_TYPE.number;
const isBoolean = filter.dataType === DATA_TYPE.boolean;
const isDate = filter.dataType === DATA_TYPE.date;
const isArray = filter.dataType === DATA_TYPE.array;
const operators = isNumeric
? NUMERIC_OPERATORS
: isBoolean
? BOOLEAN_OPERATORS
: isDate
? DATE_OPERATORS
: isArray
? ARRAY_OPERATORS
: STRING_OPERATORS;
const isFreeText = !isArray && FREE_TEXT_OPERATORS.includes(filter.operator);
const { data, isLoading } = usePropertyValuesQuery(
source,
websiteId,
filter.propertyName,
filter.dataType,
filters.filter(f => f !== filter),
eventName,
{ enabled: !isNumeric && !isBoolean && !isDate && !isFreeText },
);
const values = (data as Array<{ value: string }> | undefined)?.map(d => d.value) ?? [];
const filteredValues = search
? values.filter(v => v.toLowerCase().includes(search.toLowerCase()))
: values;
const selected = filter.value ? filter.value.split(',').filter(Boolean) : [];
const operatorLabel = (op: Operator) => {
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);
case OPERATORS.before: return t(labels.before);
case OPERATORS.after: return t(labels.after);
default: return op;
}
};
const handleOperatorChange = (op: Operator) => {
const wasMulti = MULTI_OPERATORS.includes(filter.operator);
const isMulti = MULTI_OPERATORS.includes(op);
onChange({
...filter,
operator: op,
value: wasMulti === isMulti ? filter.value : '',
});
};
return (
<Column>
<Label>{filter.propertyName}</Label>
<Grid columns="1fr auto" gap>
<Grid columns={{ base: '1fr', md: '200px 1fr' }} gap>
<Select value={filter.operator} onChange={value => handleOperatorChange(value as Operator)}>
{operators.map(op => (
<ListItem key={op} id={op}>
{operatorLabel(op)}
</ListItem>
))}
</Select>
{isBoolean ? (
<Select
value={filter.value || 'true'}
onChange={value => onChange({ ...filter, value: value as string })}
>
<ListItem id="true">{t(labels.true)}</ListItem>
<ListItem id="false">{t(labels.false)}</ListItem>
</Select>
) : isDate ? (
<DateValuePicker
value={filter.value}
onChange={value => onChange({ ...filter, value })}
/>
) : isArray ? (
<ComboBox
aria-label={filter.propertyName}
items={filteredValues}
inputValue={filter.value}
style={{ width: '100%' }}
onInputChange={v => {
setSearch(v);
onChange({ ...filter, value: v });
}}
formValue="text"
allowsEmptyCollection
allowsCustomValue
renderEmptyState={() =>
isLoading ? (
<Loading placement="center" icon="dots" />
) : (
<Empty message={t(messages.noResultsFound)} />
)
}
>
{filteredValues.map(v => (
<ListItem key={v} id={v}>
{v}
</ListItem>
))}
</ComboBox>
) : isNumeric || isFreeText ? (
<TextField
value={filter.value}
onChange={v => onChange({ ...filter, value: v })}
inputMode={isNumeric ? 'numeric' : 'text'}
/>
) : (
<MultiSelect
value={selected}
onChange={vals => onChange({ ...filter, value: vals.join(',') })}
searchValue={search}
onSearch={setSearch}
allowSearch
renderValue={vals => (vals.length > 0 ? vals.join(', ') : undefined)}
renderEmptyState={() =>
isLoading ? (
<Loading placement="center" icon="dots" />
) : (
<Empty message={t(messages.noResultsFound)} />
)
}
>
{filteredValues.map(v => (
<ListItem key={v} id={v}>
{v}
</ListItem>
))}
</MultiSelect>
)}
</Grid>
<Column justifyContent="flex-start">
<Button onPress={onRemove}>
<Icon>
<X />
</Icon>
</Button>
</Column>
</Grid>
</Column>
);
}
function DateValuePicker({
value,
onChange,
}: {
value: string;
onChange: (value: string) => void;
}) {
const { t, labels } = useMessages();
const [showPicker, setShowPicker] = useState(false);
const selectedDate = getSelectedDate(value);
const [draftDate, setDraftDate] = useState<Date>(selectedDate);
return (
<DialogTrigger isOpen={showPicker} onOpenChange={setShowPicker}>
<Button
onPress={() => {
setDraftDate(getSelectedDate(value));
setShowPicker(true);
}}
style={{ width: '100%', justifyContent: 'flex-start' }}
>
<DateDisplay startDate={selectedDate} endDate={selectedDate} />
</Button>
<Popover placement="bottom start" shouldFlip isNonModal>
<Dialog>
<Column gap>
<Calendar
value={draftDate}
minValue={new Date(2000, 0, 1)}
maxValue={getMaxSelectableDate()}
onChange={setDraftDate}
/>
<Row justifyContent="end" gap>
<Button onPress={() => setShowPicker(false)}>{t(labels.cancel)}</Button>
<Button
variant="primary"
onPress={() => {
onChange(format(draftDate, 'yyyy-MM-dd'));
setShowPicker(false);
}}
>
{t(labels.apply)}
</Button>
</Row>
</Column>
</Dialog>
</Popover>
</DialogTrigger>
);
}
function getSelectedDate(value?: string) {
if (!value) {
return new Date();
}
const date = parseISO(value);
return Number.isNaN(date.getTime()) ? new Date() : date;
}
@@ -0,0 +1,137 @@
'use client';
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,
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 { PropertyFilter } from '@/lib/types';
export function PropertyNumericChart({
source,
websiteId,
propertyName,
propertyFilters = [],
eventName,
}: {
source: PropertyDataSource;
websiteId: string;
propertyName: string;
propertyFilters?: PropertyFilter[];
eventName?: string;
}) {
const { t, labels } = useMessages();
const { theme } = useTheme();
const { timezone } = useTimezone();
const { dateRange: { startDate, endDate, unit } } = useDateRange({ timezone });
const { locale, dateLocale } = useLocale();
const { colors } = useMemo(() => getThemeColors(theme), [theme]);
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 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;
return {
datasets: [
{
type: 'bar',
label: t(labels.sum),
data: generateTimeSeries(
sumRows.map(({ t, y }) => ({ x: t, y })),
startDate,
endDate,
unit,
dateLocale,
),
barPercentage: 0.9,
categoryPercentage: 0.9,
...colors.chart.visitors,
borderWidth: 1,
},
{
type: 'bar',
label: t(labels.average),
data: generateTimeSeries(
avgRows.map(({ t, y }) => ({ x: t, y })),
startDate,
endDate,
unit,
dateLocale,
),
barPercentage: 0.9,
categoryPercentage: 0.9,
...colors.chart.views,
borderWidth: 1,
},
],
};
}, [avgQuery.data, avgRows, colors, dateLocale, endDate, labels.average, labels.sum, startDate, sumQuery.data, sumRows, t, unit]);
const renderXLabel = useCallback(renderDateLabels(unit, locale), [unit, locale]);
return (
<Column gap="4">
<LoadingPanel
data={stats}
isLoading={sumQuery.isLoading || avgQuery.isLoading || statsQuery.isLoading}
isFetching={sumQuery.isFetching || avgQuery.isFetching || statsQuery.isFetching}
error={sumQuery.error || avgQuery.error || statsQuery.error}
minHeight="100px"
>
<MetricsBar padding="2">
<MetricCard label={t(labels.total)} value={stats?.total ?? 0} formatValue={formatLongNumber} />
<MetricCard label={t(labels.average)} value={stats?.average ?? 0} formatValue={formatMetricValue} />
<MetricCard label="Median" value={stats?.median ?? 0} formatValue={formatMetricValue} />
<MetricCard label={t(labels.max)} value={stats?.max ?? 0} formatValue={formatMetricValue} />
<MetricCard label={t(labels.min)} value={stats?.min ?? 0} formatValue={formatMetricValue} />
</MetricsBar>
</LoadingPanel>
<LoadingPanel
isLoading={sumQuery.isLoading || avgQuery.isLoading || statsQuery.isLoading}
error={sumQuery.error || avgQuery.error || statsQuery.error}
minHeight="400px"
>
{chartData && (
<BarChart
chartData={chartData}
minDate={startDate}
maxDate={endDate}
unit={unit}
stacked={false}
renderXLabel={renderXLabel}
height="400px"
/>
)}
</LoadingPanel>
</Column>
);
}