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:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user