aboutsummaryrefslogtreecommitdiff
path: root/src/components/metrics
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-01-24 13:09:50 +0000
committerFuwn <[email protected]>2026-01-24 13:09:50 +0000
commit396acf3bbbe00a192cb0ea0a9ccf91b1d8d2850b (patch)
treeb9df4ca6a70db45cfffbae6fdd7252e20fb8e93c /src/components/metrics
downloadumami-main.tar.xz
umami-main.zip
Initial commitHEADmain
Created from https://vercel.com/new
Diffstat (limited to 'src/components/metrics')
-rw-r--r--src/components/metrics/ActiveUsers.tsx39
-rw-r--r--src/components/metrics/ChangeLabel.tsx60
-rw-r--r--src/components/metrics/DatePickerForm.tsx74
-rw-r--r--src/components/metrics/EventData.tsx22
-rw-r--r--src/components/metrics/EventsChart.tsx93
-rw-r--r--src/components/metrics/Legend.tsx39
-rw-r--r--src/components/metrics/ListTable.tsx152
-rw-r--r--src/components/metrics/MetricCard.tsx56
-rw-r--r--src/components/metrics/MetricLabel.tsx142
-rw-r--r--src/components/metrics/MetricsBar.tsx14
-rw-r--r--src/components/metrics/MetricsExpandedTable.tsx139
-rw-r--r--src/components/metrics/MetricsTable.tsx95
-rw-r--r--src/components/metrics/PageviewsChart.tsx98
-rw-r--r--src/components/metrics/RealtimeChart.tsx59
-rw-r--r--src/components/metrics/WeeklyTraffic.tsx112
-rw-r--r--src/components/metrics/WorldMap.tsx105
16 files changed, 1299 insertions, 0 deletions
diff --git a/src/components/metrics/ActiveUsers.tsx b/src/components/metrics/ActiveUsers.tsx
new file mode 100644
index 0000000..a4bc7da
--- /dev/null
+++ b/src/components/metrics/ActiveUsers.tsx
@@ -0,0 +1,39 @@
+import { StatusLight, Text } from '@umami/react-zen';
+import { useMemo } from 'react';
+import { LinkButton } from '@/components/common/LinkButton';
+import { useActyiveUsersQuery, useMessages } from '@/components/hooks';
+
+export function ActiveUsers({
+ websiteId,
+ value,
+ refetchInterval = 60000,
+}: {
+ websiteId: string;
+ value?: number;
+ refetchInterval?: number;
+}) {
+ const { formatMessage, labels } = useMessages();
+ const { data } = useActyiveUsersQuery(websiteId, { refetchInterval });
+
+ const count = useMemo(() => {
+ if (websiteId) {
+ return data?.visitors || 0;
+ }
+
+ return value !== undefined ? value : 0;
+ }, [data, value, websiteId]);
+
+ if (count === 0) {
+ return null;
+ }
+
+ return (
+ <LinkButton href={`/websites/${websiteId}/realtime`} variant="quiet">
+ <StatusLight variant="success">
+ <Text size="2" weight="medium">
+ {count} {formatMessage(labels.online)}
+ </Text>
+ </StatusLight>
+ </LinkButton>
+ );
+}
diff --git a/src/components/metrics/ChangeLabel.tsx b/src/components/metrics/ChangeLabel.tsx
new file mode 100644
index 0000000..192f0ff
--- /dev/null
+++ b/src/components/metrics/ChangeLabel.tsx
@@ -0,0 +1,60 @@
+import { Icon, Row, type RowProps, Text } from '@umami/react-zen';
+import type { ReactNode } from 'react';
+import { ArrowRight } from '@/components/icons';
+
+const STYLES = {
+ positive: {
+ color: `var(--success-color)`,
+ background: `color-mix(in srgb, var(--success-color), var(--background-color) 95%)`,
+ },
+ negative: {
+ color: `var(--danger-color)`,
+ background: `color-mix(in srgb, var(--danger-color), var(--background-color) 95%)`,
+ },
+ neutral: {
+ color: `var(--font-color-muted)`,
+ background: `var(--base-color-2)`,
+ },
+};
+
+export function ChangeLabel({
+ value,
+ size,
+ reverseColors,
+ children,
+ ...props
+}: {
+ value: number;
+ size?: 'xs' | 'sm' | 'md' | 'lg';
+ title?: string;
+ reverseColors?: boolean;
+ showPercentage?: boolean;
+ children?: ReactNode;
+} & RowProps) {
+ const positive = value >= 0;
+ const negative = value < 0;
+ const neutral = value === 0 || Number.isNaN(value);
+ const good = reverseColors ? negative : positive;
+
+ const style =
+ STYLES[good && 'positive'] || STYLES[!good && 'negative'] || STYLES[neutral && 'neutral'];
+
+ return (
+ <Row
+ {...props}
+ style={style}
+ alignItems="center"
+ alignSelf="flex-start"
+ paddingX="2"
+ paddingY="1"
+ gap="2"
+ >
+ {!neutral && (
+ <Icon rotate={positive ? -90 : 90} size={size}>
+ <ArrowRight />
+ </Icon>
+ )}
+ <Text>{children || value}</Text>
+ </Row>
+ );
+}
diff --git a/src/components/metrics/DatePickerForm.tsx b/src/components/metrics/DatePickerForm.tsx
new file mode 100644
index 0000000..59d1709
--- /dev/null
+++ b/src/components/metrics/DatePickerForm.tsx
@@ -0,0 +1,74 @@
+import { Button, Calendar, Column, Row, ToggleGroup, ToggleGroupItem } from '@umami/react-zen';
+import { endOfDay, isAfter, isBefore, isSameDay, startOfDay } from 'date-fns';
+import { useState } from 'react';
+import { useMessages } from '@/components/hooks';
+
+const FILTER_DAY = 'filter-day';
+const FILTER_RANGE = 'filter-range';
+
+export function DatePickerForm({
+ startDate: defaultStartDate,
+ endDate: defaultEndDate,
+ minDate,
+ maxDate,
+ onChange,
+ onClose,
+}) {
+ const [selected, setSelected] = useState<any>([
+ isSameDay(defaultStartDate, defaultEndDate) ? FILTER_DAY : FILTER_RANGE,
+ ]);
+ const [date, setDate] = useState(defaultStartDate || new Date());
+ const [startDate, setStartDate] = useState(defaultStartDate || new Date());
+ const [endDate, setEndDate] = useState(defaultEndDate || new Date());
+ const { formatMessage, labels } = useMessages();
+
+ const disabled = selected.includes(FILTER_DAY)
+ ? isAfter(minDate, date) && isBefore(maxDate, date)
+ : isAfter(startDate, endDate);
+
+ const handleSave = () => {
+ if (selected.includes(FILTER_DAY)) {
+ onChange(`range:${startOfDay(date).getTime()}:${endOfDay(date).getTime()}`);
+ } else {
+ onChange(`range:${startOfDay(startDate).getTime()}:${endOfDay(endDate).getTime()}`);
+ }
+ };
+
+ return (
+ <Column gap>
+ <Row justifyContent="center">
+ <ToggleGroup disallowEmptySelection value={selected} onChange={setSelected}>
+ <ToggleGroupItem id={FILTER_DAY}>{formatMessage(labels.singleDay)}</ToggleGroupItem>
+ <ToggleGroupItem id={FILTER_RANGE}>{formatMessage(labels.dateRange)}</ToggleGroupItem>
+ </ToggleGroup>
+ </Row>
+ <Column>
+ {selected.includes(FILTER_DAY) && (
+ <Calendar value={date} minValue={minDate} maxValue={maxDate} onChange={setDate} />
+ )}
+ {selected.includes(FILTER_RANGE) && (
+ <Row gap wrap="wrap" style={{ margin: '0 auto' }}>
+ <Calendar
+ value={startDate}
+ minValue={minDate}
+ maxValue={endDate}
+ onChange={setStartDate}
+ />
+ <Calendar
+ value={endDate}
+ minValue={startDate}
+ maxValue={maxDate}
+ onChange={setEndDate}
+ />
+ </Row>
+ )}
+ </Column>
+ <Row justifyContent="end" gap>
+ <Button onPress={onClose}>{formatMessage(labels.cancel)}</Button>
+ <Button variant="primary" onPress={handleSave} isDisabled={disabled}>
+ {formatMessage(labels.apply)}
+ </Button>
+ </Row>
+ </Column>
+ );
+}
diff --git a/src/components/metrics/EventData.tsx b/src/components/metrics/EventData.tsx
new file mode 100644
index 0000000..48d21c5
--- /dev/null
+++ b/src/components/metrics/EventData.tsx
@@ -0,0 +1,22 @@
+import { Column, Grid, Label, Text } from '@umami/react-zen';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useEventDataQuery } from '@/components/hooks';
+
+export function EventData({ websiteId, eventId }: { websiteId: string; eventId: string }) {
+ const { data, isLoading, error } = useEventDataQuery(websiteId, eventId);
+
+ return (
+ <LoadingPanel isLoading={isLoading} error={error}>
+ <Grid columns="1fr 1fr" gap="5">
+ {data?.map(({ dataKey, stringValue }) => {
+ return (
+ <Column key={dataKey}>
+ <Label>{dataKey}</Label>
+ <Text>{stringValue}</Text>
+ </Column>
+ );
+ })}
+ </Grid>
+ </LoadingPanel>
+ );
+}
diff --git a/src/components/metrics/EventsChart.tsx b/src/components/metrics/EventsChart.tsx
new file mode 100644
index 0000000..3a53ba9
--- /dev/null
+++ b/src/components/metrics/EventsChart.tsx
@@ -0,0 +1,93 @@
+import { colord } from 'colord';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { BarChart, type BarChartProps } from '@/components/charts/BarChart';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import {
+ useDateRange,
+ useLocale,
+ useTimezone,
+ useWebsiteEventsSeriesQuery,
+} from '@/components/hooks';
+import { renderDateLabels } from '@/lib/charts';
+import { CHART_COLORS } from '@/lib/constants';
+import { generateTimeSeries } from '@/lib/date';
+
+export interface EventsChartProps extends BarChartProps {
+ websiteId: string;
+ focusLabel?: string;
+}
+
+export function EventsChart({ websiteId, focusLabel }: EventsChartProps) {
+ const { timezone } = useTimezone();
+ const {
+ dateRange: { startDate, endDate, unit },
+ } = useDateRange({ timezone: timezone });
+ const { locale, dateLocale } = useLocale();
+ const { data, isLoading, error } = useWebsiteEventsSeriesQuery(websiteId);
+ const [label, setLabel] = useState<string>(focusLabel);
+
+ const chartData: any = useMemo(() => {
+ if (!data) return;
+
+ const map = (data as any[]).reduce((obj, { x, t, y }) => {
+ if (!obj[x]) {
+ obj[x] = [];
+ }
+
+ obj[x].push({ x: t, y });
+
+ return obj;
+ }, {});
+
+ if (!map || Object.keys(map).length === 0) {
+ return {
+ datasets: [
+ {
+ data: generateTimeSeries([], startDate, endDate, unit, dateLocale),
+ lineTension: 0,
+ borderWidth: 1,
+ },
+ ],
+ };
+ } else {
+ return {
+ datasets: Object.keys(map).map((key, index) => {
+ const color = colord(CHART_COLORS[index % CHART_COLORS.length]);
+ return {
+ label: key,
+ data: generateTimeSeries(map[key], startDate, endDate, unit, dateLocale),
+ lineTension: 0,
+ backgroundColor: color.alpha(0.6).toRgbString(),
+ borderColor: color.alpha(0.7).toRgbString(),
+ borderWidth: 1,
+ };
+ }),
+ focusLabel,
+ };
+ }
+ }, [data, startDate, endDate, unit, focusLabel]);
+
+ useEffect(() => {
+ if (label !== focusLabel) {
+ setLabel(focusLabel);
+ }
+ }, [focusLabel]);
+
+ const renderXLabel = useCallback(renderDateLabels(unit, locale), [unit, locale]);
+
+ return (
+ <LoadingPanel isLoading={isLoading} error={error} minHeight="400px">
+ {chartData && (
+ <BarChart
+ chartData={chartData}
+ minDate={startDate}
+ maxDate={endDate}
+ unit={unit}
+ stacked={true}
+ renderXLabel={renderXLabel}
+ height="400px"
+ />
+ )}
+ </LoadingPanel>
+ );
+}
diff --git a/src/components/metrics/Legend.tsx b/src/components/metrics/Legend.tsx
new file mode 100644
index 0000000..34ddb5a
--- /dev/null
+++ b/src/components/metrics/Legend.tsx
@@ -0,0 +1,39 @@
+import { Row, StatusLight, Text } from '@umami/react-zen';
+import type { LegendItem } from 'chart.js/auto';
+import { colord } from 'colord';
+
+export function Legend({
+ items = [],
+ onClick,
+}: {
+ items: any[];
+ onClick: (index: LegendItem) => void;
+}) {
+ if (!items.find(({ text }) => text)) {
+ return null;
+ }
+
+ return (
+ <Row gap wrap="wrap" justifyContent="center">
+ {items.map(item => {
+ const { text, fillStyle, hidden } = item;
+ const color = colord(fillStyle);
+
+ return (
+ <Row key={text} onClick={() => onClick(item)}>
+ <StatusLight color={color.alpha(color.alpha() + 0.2).toHex()}>
+ <Text
+ size="2"
+ color={hidden ? 'disabled' : undefined}
+ truncate={true}
+ style={{ maxWidth: '300px' }}
+ >
+ {text}
+ </Text>
+ </StatusLight>
+ </Row>
+ );
+ })}
+ </Row>
+ );
+}
diff --git a/src/components/metrics/ListTable.tsx b/src/components/metrics/ListTable.tsx
new file mode 100644
index 0000000..f233bfe
--- /dev/null
+++ b/src/components/metrics/ListTable.tsx
@@ -0,0 +1,152 @@
+import { config, useSpring } from '@react-spring/web';
+import { Column, Grid, Row, Text } from '@umami/react-zen';
+import type { ReactNode } from 'react';
+import { FixedSizeList } from 'react-window';
+import { AnimatedDiv } from '@/components/common/AnimatedDiv';
+import { Empty } from '@/components/common/Empty';
+import { useMessages, useMobile } from '@/components/hooks';
+import { formatLongCurrency, formatLongNumber } from '@/lib/format';
+
+const ITEM_SIZE = 30;
+
+interface ListData {
+ label: string;
+ count: number;
+ percent: number;
+}
+
+export interface ListTableProps {
+ data?: ListData[];
+ title?: string;
+ metric?: string;
+ className?: string;
+ renderLabel?: (data: ListData, index: number) => ReactNode;
+ renderChange?: (data: ListData, index: number) => ReactNode;
+ animate?: boolean;
+ virtualize?: boolean;
+ showPercentage?: boolean;
+ itemCount?: number;
+ currency?: string;
+}
+
+export function ListTable({
+ data = [],
+ title,
+ metric,
+ renderLabel,
+ renderChange,
+ animate = true,
+ virtualize = false,
+ showPercentage = true,
+ itemCount = 10,
+ currency,
+}: ListTableProps) {
+ const { formatMessage, labels } = useMessages();
+ const { isPhone } = useMobile();
+
+ const getRow = (row: ListData, index: number) => {
+ const { label, count, percent } = row;
+
+ return (
+ <AnimatedRow
+ key={`${label}${index}`}
+ label={renderLabel ? renderLabel(row, index) : (label ?? formatMessage(labels.unknown))}
+ value={count}
+ percent={percent}
+ animate={animate && !virtualize}
+ showPercentage={showPercentage}
+ change={renderChange ? renderChange(row, index) : null}
+ currency={currency}
+ isPhone={isPhone}
+ />
+ );
+ };
+
+ const ListTableRow = ({ index, style }) => {
+ return <div style={style}>{getRow(data[index], index)}</div>;
+ };
+
+ return (
+ <Column gap>
+ <Grid alignItems="center" justifyContent="space-between" paddingLeft="2" columns="1fr 100px">
+ <Text weight="bold">{title}</Text>
+ <Text weight="bold" align="center">
+ {metric}
+ </Text>
+ </Grid>
+ <Column gap="1">
+ {data?.length === 0 && <Empty />}
+ {virtualize && data.length > 0 ? (
+ <FixedSizeList
+ width="100%"
+ height={itemCount * ITEM_SIZE}
+ itemCount={data.length}
+ itemSize={ITEM_SIZE}
+ >
+ {ListTableRow}
+ </FixedSizeList>
+ ) : (
+ data.map(getRow)
+ )}
+ </Column>
+ </Column>
+ );
+}
+
+const AnimatedRow = ({
+ label,
+ value = 0,
+ percent,
+ change,
+ animate,
+ showPercentage = true,
+ currency,
+ isPhone,
+}) => {
+ const props = useSpring({
+ width: percent,
+ y: !Number.isNaN(value) ? value : 0,
+ from: { width: 0, y: 0 },
+ config: animate ? config.default : { duration: 0 },
+ });
+
+ return (
+ <Grid
+ columns="1fr 50px 50px"
+ paddingLeft="2"
+ alignItems="center"
+ hoverBackgroundColor="2"
+ borderRadius
+ gap
+ >
+ <Row alignItems="center">
+ <Text truncate={true} style={{ maxWidth: isPhone ? '200px' : '400px' }}>
+ {label}
+ </Text>
+ </Row>
+ <Row alignItems="center" height="30px" justifyContent="flex-end">
+ {change}
+ <Text weight="bold">
+ <AnimatedDiv title={props?.y as any}>
+ {currency
+ ? props.y?.to(n => formatLongCurrency(n, currency))
+ : props.y?.to(formatLongNumber)}
+ </AnimatedDiv>
+ </Text>
+ </Row>
+ {showPercentage && (
+ <Row
+ alignItems="center"
+ justifyContent="flex-start"
+ position="relative"
+ border="left"
+ borderColor="8"
+ color="muted"
+ paddingLeft="3"
+ >
+ <AnimatedDiv>{props.width.to(n => `${n?.toFixed?.(0)}%`)}</AnimatedDiv>
+ </Row>
+ )}
+ </Grid>
+ );
+};
diff --git a/src/components/metrics/MetricCard.tsx b/src/components/metrics/MetricCard.tsx
new file mode 100644
index 0000000..d15bcf1
--- /dev/null
+++ b/src/components/metrics/MetricCard.tsx
@@ -0,0 +1,56 @@
+import { useSpring } from '@react-spring/web';
+import { Column, Text } from '@umami/react-zen';
+import { AnimatedDiv } from '@/components/common/AnimatedDiv';
+import { ChangeLabel } from '@/components/metrics/ChangeLabel';
+import { formatNumber } from '@/lib/format';
+
+export interface MetricCardProps {
+ value: number;
+ previousValue?: number;
+ change?: number;
+ label?: string;
+ reverseColors?: boolean;
+ formatValue?: (n: any) => string;
+ showLabel?: boolean;
+ showChange?: boolean;
+}
+
+export const MetricCard = ({
+ value = 0,
+ change = 0,
+ label,
+ reverseColors = false,
+ formatValue = formatNumber,
+ showLabel = true,
+ showChange = false,
+}: MetricCardProps) => {
+ const diff = value - change;
+ const pct = ((value - diff) / diff) * 100;
+ const props = useSpring({ x: Number(value) || 0, from: { x: 0 } });
+ const changeProps = useSpring({ x: Number(pct) || 0, from: { x: 0 } });
+
+ return (
+ <Column
+ justifyContent="center"
+ paddingX="6"
+ paddingY="4"
+ borderRadius="3"
+ backgroundColor
+ border
+ >
+ {showLabel && (
+ <Text weight="bold" wrap="nowrap">
+ {label}
+ </Text>
+ )}
+ <Text size="8" weight="bold" wrap="nowrap">
+ <AnimatedDiv title={value?.toString()}>{props?.x?.to(x => formatValue(x))}</AnimatedDiv>
+ </Text>
+ {showChange && (
+ <ChangeLabel value={change} title={formatValue(change)} reverseColors={reverseColors}>
+ <AnimatedDiv>{changeProps?.x?.to(x => `${Math.abs(~~x)}%`)}</AnimatedDiv>
+ </ChangeLabel>
+ )}
+ </Column>
+ );
+};
diff --git a/src/components/metrics/MetricLabel.tsx b/src/components/metrics/MetricLabel.tsx
new file mode 100644
index 0000000..31c331f
--- /dev/null
+++ b/src/components/metrics/MetricLabel.tsx
@@ -0,0 +1,142 @@
+import { Row } from '@umami/react-zen';
+import { Favicon } from '@/components/common/Favicon';
+import { FilterLink } from '@/components/common/FilterLink';
+import { TypeIcon } from '@/components/common/TypeIcon';
+import {
+ useCountryNames,
+ useFormat,
+ useLocale,
+ useMessages,
+ useRegionNames,
+} from '@/components/hooks';
+import { GROUPED_DOMAINS } from '@/lib/constants';
+
+export interface MetricLabelProps {
+ type: string;
+ data: any;
+ onClick?: () => void;
+}
+
+export function MetricLabel({ type, data }: MetricLabelProps) {
+ const { formatMessage, labels } = useMessages();
+ const { formatValue, formatCity } = useFormat();
+ const { locale } = useLocale();
+ const { countryNames } = useCountryNames(locale);
+ const { getRegionName } = useRegionNames(locale);
+
+ const { label, country, domain } = data;
+
+ switch (type) {
+ case 'browser':
+ case 'os':
+ return (
+ <FilterLink
+ type={type}
+ value={label}
+ label={formatValue(label, type)}
+ icon={<TypeIcon type={type} value={label} />}
+ />
+ );
+
+ case 'channel':
+ return formatMessage(labels[label]);
+
+ case 'city':
+ return (
+ <FilterLink
+ type="city"
+ value={label}
+ label={formatCity(label, country)}
+ icon={
+ country && (
+ <img
+ src={`${process.env.basePath || ''}/images/country/${
+ country?.toLowerCase() || 'xx'
+ }.png`}
+ alt={country}
+ />
+ )
+ }
+ />
+ );
+
+ case 'region':
+ return (
+ <FilterLink
+ type="region"
+ value={label}
+ label={getRegionName(label, country)}
+ icon={<TypeIcon type="country" value={country} />}
+ />
+ );
+
+ case 'country':
+ return (
+ <FilterLink
+ type="country"
+ value={(countryNames[label] && label) || label}
+ label={formatValue(label, 'country')}
+ icon={<TypeIcon type="country" value={label} />}
+ />
+ );
+
+ case 'path':
+ case 'entry':
+ case 'exit':
+ return (
+ <FilterLink
+ type={type === 'entry' || type === 'exit' ? 'path' : type}
+ value={label}
+ label={!label && formatMessage(labels.none)}
+ externalUrl={
+ domain ? `${domain?.startsWith('http') ? domain : `https://${domain}`}${label}` : null
+ }
+ />
+ );
+
+ case 'device':
+ return (
+ <FilterLink
+ type="device"
+ value={labels[label] && label}
+ label={formatValue(label, 'device')}
+ icon={<TypeIcon type="device" value={label} />}
+ />
+ );
+
+ case 'referrer':
+ return (
+ <FilterLink
+ type="referrer"
+ value={label}
+ externalUrl={`https://${label}`}
+ label={!label && formatMessage(labels.none)}
+ icon={<Favicon domain={label} />}
+ />
+ );
+
+ case 'domain':
+ if (label === 'Other') {
+ return `(${formatMessage(labels.other)})`;
+ } else {
+ const name = GROUPED_DOMAINS.find(({ domain }) => domain === label)?.name;
+
+ if (!name) {
+ return null;
+ }
+
+ return (
+ <Row alignItems="center" gap="3">
+ <Favicon domain={label} />
+ {name}
+ </Row>
+ );
+ }
+
+ case 'language':
+ return formatValue(label, 'language');
+
+ default:
+ return <FilterLink type={type} value={label} />;
+ }
+}
diff --git a/src/components/metrics/MetricsBar.tsx b/src/components/metrics/MetricsBar.tsx
new file mode 100644
index 0000000..850c6bc
--- /dev/null
+++ b/src/components/metrics/MetricsBar.tsx
@@ -0,0 +1,14 @@
+import { Grid, type GridProps } from '@umami/react-zen';
+import type { ReactNode } from 'react';
+
+export interface MetricsBarProps extends GridProps {
+ children?: ReactNode;
+}
+
+export function MetricsBar({ children, ...props }: MetricsBarProps) {
+ return (
+ <Grid columns="repeat(auto-fit, minmax(160px, 1fr))" gap {...props}>
+ {children}
+ </Grid>
+ );
+}
diff --git a/src/components/metrics/MetricsExpandedTable.tsx b/src/components/metrics/MetricsExpandedTable.tsx
new file mode 100644
index 0000000..f24c952
--- /dev/null
+++ b/src/components/metrics/MetricsExpandedTable.tsx
@@ -0,0 +1,139 @@
+import { Button, Column, DataColumn, DataTable, Icon, Row, SearchField } from '@umami/react-zen';
+import { type ReactNode, useState } from 'react';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useMessages, useWebsiteExpandedMetricsQuery } from '@/components/hooks';
+import { X } from '@/components/icons';
+import { DownloadButton } from '@/components/input/DownloadButton';
+import { MetricLabel } from '@/components/metrics/MetricLabel';
+import { SESSION_COLUMNS } from '@/lib/constants';
+import { formatShortTime } from '@/lib/format';
+
+export interface MetricsExpandedTableProps {
+ websiteId: string;
+ type?: string;
+ title?: string;
+ dataFilter?: (data: any) => any;
+ onSearch?: (search: string) => void;
+ params?: { [key: string]: any };
+ allowSearch?: boolean;
+ allowDownload?: boolean;
+ renderLabel?: (row: any, index: number) => ReactNode;
+ onClose?: () => void;
+ children?: ReactNode;
+}
+
+export function MetricsExpandedTable({
+ websiteId,
+ type,
+ title,
+ params,
+ allowSearch = true,
+ allowDownload = true,
+ onClose,
+ children,
+}: MetricsExpandedTableProps) {
+ const [search, setSearch] = useState('');
+ const { formatMessage, labels } = useMessages();
+ const isType = ['browser', 'country', 'device', 'os'].includes(type);
+ const showBounceDuration = SESSION_COLUMNS.includes(type);
+
+ const { data, isLoading, isFetching, error } = useWebsiteExpandedMetricsQuery(websiteId, {
+ type,
+ search: isType ? undefined : search,
+ ...params,
+ });
+
+ const items = data?.map(({ name, ...props }) => ({ label: name, ...props }));
+
+ return (
+ <>
+ <Row alignItems="center" paddingBottom="3">
+ {allowSearch && <SearchField value={search} onSearch={setSearch} delay={300} />}
+ <Row justifyContent="flex-end" flexGrow={1} gap>
+ {children}
+ {allowDownload && <DownloadButton filename={type} data={data} />}
+ {onClose && (
+ <Button onPress={onClose} variant="quiet">
+ <Icon>
+ <X />
+ </Icon>
+ </Button>
+ )}
+ </Row>
+ </Row>
+ <LoadingPanel
+ data={data}
+ isFetching={isFetching}
+ isLoading={isLoading}
+ error={error}
+ height="100%"
+ loadingIcon="spinner"
+ >
+ <Column overflow="auto" minHeight="0" height="100%" paddingRight="3">
+ {items && (
+ <DataTable data={items}>
+ <DataColumn id="label" label={title} width="minmax(200px, 2fr)" align="start">
+ {row => (
+ <Row overflow="hidden">
+ <MetricLabel type={type} data={row} />
+ </Row>
+ )}
+ </DataColumn>
+ <DataColumn
+ id="visitors"
+ label={formatMessage(labels.visitors)}
+ align="end"
+ width="120px"
+ >
+ {row => row?.visitors?.toLocaleString()}
+ </DataColumn>
+ <DataColumn
+ id="visits"
+ label={formatMessage(labels.visits)}
+ align="end"
+ width="120px"
+ >
+ {row => row?.visits?.toLocaleString()}
+ </DataColumn>
+ <DataColumn
+ id="pageviews"
+ label={formatMessage(labels.views)}
+ align="end"
+ width="120px"
+ >
+ {row => row?.pageviews?.toLocaleString()}
+ </DataColumn>
+ {showBounceDuration && [
+ <DataColumn
+ key="bounceRate"
+ id="bounceRate"
+ label={formatMessage(labels.bounceRate)}
+ align="end"
+ width="120px"
+ >
+ {row => {
+ const n = (Math.min(row?.visits, row?.bounces) / row?.visits) * 100;
+ return `${Math.round(+n)}%`;
+ }}
+ </DataColumn>,
+
+ <DataColumn
+ key="visitDuration"
+ id="visitDuration"
+ label={formatMessage(labels.visitDuration)}
+ align="end"
+ width="120px"
+ >
+ {row => {
+ const n = row?.totaltime / row?.visits;
+ return `${+n < 0 ? '-' : ''}${formatShortTime(Math.abs(~~n), ['m', 's'], ' ')}`;
+ }}
+ </DataColumn>,
+ ]}
+ </DataTable>
+ )}
+ </Column>
+ </LoadingPanel>
+ </>
+ );
+}
diff --git a/src/components/metrics/MetricsTable.tsx b/src/components/metrics/MetricsTable.tsx
new file mode 100644
index 0000000..e99bd21
--- /dev/null
+++ b/src/components/metrics/MetricsTable.tsx
@@ -0,0 +1,95 @@
+import { Grid, Icon, Row, Text } from '@umami/react-zen';
+import { useEffect, useMemo } from 'react';
+import { LinkButton } from '@/components/common/LinkButton';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useMessages, useNavigation, useWebsiteMetricsQuery } from '@/components/hooks';
+import { Maximize } from '@/components/icons';
+import { MetricLabel } from '@/components/metrics/MetricLabel';
+import { percentFilter } from '@/lib/filters';
+import { ListTable, type ListTableProps } from './ListTable';
+
+export interface MetricsTableProps extends ListTableProps {
+ websiteId: string;
+ type: string;
+ dataFilter?: (data: any) => any;
+ limit?: number;
+ showMore?: boolean;
+ filterLink?: boolean;
+ params?: Record<string, any>;
+ onDataLoad?: (data: any) => void;
+}
+
+export function MetricsTable({
+ websiteId,
+ type,
+ dataFilter,
+ limit,
+ showMore = false,
+ filterLink = true,
+ params,
+ onDataLoad,
+ ...props
+}: MetricsTableProps) {
+ const { updateParams } = useNavigation();
+ const { formatMessage, labels } = useMessages();
+ const { data, isLoading, isFetching, error } = useWebsiteMetricsQuery(websiteId, {
+ type,
+ limit,
+ ...params,
+ });
+
+ const filteredData = useMemo(() => {
+ if (data) {
+ let items = data as any[];
+
+ if (dataFilter) {
+ if (Array.isArray(dataFilter)) {
+ items = dataFilter.reduce((arr, filter) => {
+ return filter(arr);
+ }, items);
+ } else {
+ items = dataFilter(items);
+ }
+ }
+
+ items = percentFilter(items);
+
+ return items.map(({ x, y, z, ...props }) => ({ label: x, count: y, percent: z, ...props }));
+ }
+ return [];
+ }, [data, dataFilter, limit, type]);
+
+ useEffect(() => {
+ if (data) {
+ onDataLoad?.(data);
+ }
+ }, [data]);
+
+ const renderLabel = (row: any) => {
+ return filterLink ? <MetricLabel type={type} data={row} /> : row.label;
+ };
+
+ return (
+ <LoadingPanel
+ data={data}
+ isFetching={isFetching}
+ isLoading={isLoading}
+ error={error}
+ minHeight="400px"
+ >
+ <Grid>
+ {data && <ListTable {...props} data={filteredData} renderLabel={renderLabel} />}
+ {showMore && limit && (
+ <Row justifyContent="center" alignItems="flex-end">
+ <LinkButton href={updateParams({ view: type })} variant="quiet">
+ <Icon size="sm">
+ <Maximize />
+ </Icon>
+ <Text>{formatMessage(labels.more)}</Text>
+ </LinkButton>
+ </Row>
+ )}
+ </Grid>
+ </LoadingPanel>
+ );
+}
diff --git a/src/components/metrics/PageviewsChart.tsx b/src/components/metrics/PageviewsChart.tsx
new file mode 100644
index 0000000..b83f8dc
--- /dev/null
+++ b/src/components/metrics/PageviewsChart.tsx
@@ -0,0 +1,98 @@
+import { useTheme } from '@umami/react-zen';
+import { useCallback, useMemo } from 'react';
+import { BarChart, type BarChartProps } from '@/components/charts/BarChart';
+import { useLocale, useMessages } from '@/components/hooks';
+import { renderDateLabels } from '@/lib/charts';
+import { getThemeColors } from '@/lib/colors';
+import { generateTimeSeries } from '@/lib/date';
+
+export interface PageviewsChartProps extends BarChartProps {
+ data: {
+ pageviews: any[];
+ sessions: any[];
+ compare?: {
+ pageviews: any[];
+ sessions: any[];
+ };
+ };
+ unit: string;
+}
+
+export function PageviewsChart({ data, unit, minDate, maxDate, ...props }: PageviewsChartProps) {
+ const { formatMessage, labels } = useMessages();
+ const { theme } = useTheme();
+ const { locale, dateLocale } = useLocale();
+ const { colors } = useMemo(() => getThemeColors(theme), [theme]);
+
+ const chartData: any = useMemo(() => {
+ if (!data) return;
+
+ return {
+ __id: Date.now(),
+ datasets: [
+ {
+ type: 'bar',
+ label: formatMessage(labels.visitors),
+ data: generateTimeSeries(data.sessions, minDate, maxDate, unit, dateLocale),
+ borderWidth: 1,
+ barPercentage: 0.9,
+ categoryPercentage: 0.9,
+ ...colors.chart.visitors,
+ order: 3,
+ },
+ {
+ type: 'bar',
+ label: formatMessage(labels.views),
+ data: generateTimeSeries(data.pageviews, minDate, maxDate, unit, dateLocale),
+ barPercentage: 0.9,
+ categoryPercentage: 0.9,
+ borderWidth: 1,
+ ...colors.chart.views,
+ order: 4,
+ },
+ ...(data.compare
+ ? [
+ {
+ type: 'line',
+ label: `${formatMessage(labels.views)} (${formatMessage(labels.previous)})`,
+ data: generateTimeSeries(
+ data.compare.pageviews,
+ minDate,
+ maxDate,
+ unit,
+ dateLocale,
+ ),
+ borderWidth: 2,
+ backgroundColor: '#8601B0',
+ borderColor: '#8601B0',
+ order: 1,
+ },
+ {
+ type: 'line',
+ label: `${formatMessage(labels.visitors)} (${formatMessage(labels.previous)})`,
+ data: generateTimeSeries(data.compare.sessions, minDate, maxDate, unit, dateLocale),
+ borderWidth: 2,
+ backgroundColor: '#f15bb5',
+ borderColor: '#f15bb5',
+ order: 2,
+ },
+ ]
+ : []),
+ ],
+ };
+ }, [data, locale]);
+
+ const renderXLabel = useCallback(renderDateLabels(unit, locale), [unit, locale]);
+
+ return (
+ <BarChart
+ {...props}
+ chartData={chartData}
+ unit={unit}
+ minDate={minDate}
+ maxDate={maxDate}
+ renderXLabel={renderXLabel}
+ height="400px"
+ />
+ );
+}
diff --git a/src/components/metrics/RealtimeChart.tsx b/src/components/metrics/RealtimeChart.tsx
new file mode 100644
index 0000000..f42b96d
--- /dev/null
+++ b/src/components/metrics/RealtimeChart.tsx
@@ -0,0 +1,59 @@
+import { isBefore, startOfMinute, subMinutes } from 'date-fns';
+import { useMemo, useRef } from 'react';
+import { useTimezone } from '@/components/hooks';
+import { DEFAULT_ANIMATION_DURATION, REALTIME_RANGE } from '@/lib/constants';
+import type { RealtimeData } from '@/lib/types';
+import { PageviewsChart } from './PageviewsChart';
+
+export interface RealtimeChartProps {
+ data: RealtimeData;
+ unit: string;
+ className?: string;
+}
+
+export function RealtimeChart({ data, unit, ...props }: RealtimeChartProps) {
+ const { formatSeriesTimezone, fromUtc, timezone } = useTimezone();
+ const endDate = startOfMinute(new Date());
+ const startDate = subMinutes(endDate, REALTIME_RANGE);
+ const prevEndDate = useRef(endDate);
+ const prevData = useRef<string | null>(null);
+
+ const chartData = useMemo(() => {
+ if (!data) {
+ return { pageviews: [], sessions: [] };
+ }
+
+ return {
+ pageviews: formatSeriesTimezone(data.series.views, 'x', timezone),
+ sessions: formatSeriesTimezone(data.series.visitors, 'x', timezone),
+ };
+ }, [data, startDate, endDate, unit]);
+
+ const animationDuration = useMemo(() => {
+ // Don't animate the bars shifting over because it looks weird
+ if (isBefore(prevEndDate.current, endDate)) {
+ prevEndDate.current = endDate;
+ return 0;
+ }
+
+ // Don't animate when data hasn't changed
+ const serialized = JSON.stringify(chartData);
+ if (prevData.current === serialized) {
+ return 0;
+ }
+ prevData.current = serialized;
+
+ return DEFAULT_ANIMATION_DURATION;
+ }, [endDate, chartData]);
+
+ return (
+ <PageviewsChart
+ {...props}
+ minDate={fromUtc(startDate)}
+ maxDate={fromUtc(endDate)}
+ unit={unit}
+ data={chartData}
+ animationDuration={animationDuration}
+ />
+ );
+}
diff --git a/src/components/metrics/WeeklyTraffic.tsx b/src/components/metrics/WeeklyTraffic.tsx
new file mode 100644
index 0000000..90e47c6
--- /dev/null
+++ b/src/components/metrics/WeeklyTraffic.tsx
@@ -0,0 +1,112 @@
+import { Focusable, Grid, Row, Text, Tooltip, TooltipTrigger } from '@umami/react-zen';
+import { addHours, format, startOfDay } from 'date-fns';
+import { LoadingPanel } from '@/components/common/LoadingPanel';
+import { useLocale, useMessages, useWeeklyTrafficQuery } from '@/components/hooks';
+import { getDayOfWeekAsDate } from '@/lib/date';
+
+export function WeeklyTraffic({ websiteId }: { websiteId: string }) {
+ const { data, isLoading, error } = useWeeklyTrafficQuery(websiteId);
+ const { dateLocale } = useLocale();
+ const { labels, formatMessage } = useMessages();
+ const { weekStartsOn } = dateLocale.options;
+ const daysOfWeek = Array(7)
+ .fill(weekStartsOn)
+ .map((d, i) => (d + i) % 7);
+
+ const [, max = 1] = data
+ ? data.reduce((arr: number[], hours: number[], index: number) => {
+ const min = Math.min(...hours);
+ const max = Math.max(...hours);
+
+ if (index === 0) {
+ return [min, max];
+ }
+
+ if (min < arr[0]) {
+ arr[0] = min;
+ }
+
+ if (max > arr[1]) {
+ arr[1] = max;
+ }
+
+ return arr;
+ }, [])
+ : [];
+
+ return (
+ <LoadingPanel data={data} isLoading={isLoading} error={error}>
+ <Grid columns="repeat(8, 1fr)" gap>
+ {data && (
+ <>
+ <Grid rows="repeat(25, 16px)" gap="1">
+ <Row>&nbsp;</Row>
+ {Array(24)
+ .fill(null)
+ .map((_, i) => {
+ const label = format(addHours(startOfDay(new Date()), i), 'haaa', {
+ locale: dateLocale,
+ });
+ return (
+ <Row key={i} justifyContent="flex-end">
+ <Text color="muted" size="2">
+ {label}
+ </Text>
+ </Row>
+ );
+ })}
+ </Grid>
+ {daysOfWeek.map((index: number) => {
+ const day = data[index];
+ return (
+ <Grid
+ rows="repeat(24, 16px)"
+ justifyContent="center"
+ alignItems="center"
+ key={index}
+ gap="1"
+ >
+ <Row alignItems="center" justifyContent="center" marginBottom="3">
+ <Text weight="bold" align="center">
+ {format(getDayOfWeekAsDate(index), 'EEE', { locale: dateLocale })}
+ </Text>
+ </Row>
+ {day?.map((count: number, j) => {
+ const pct = max ? count / max : 0;
+ return (
+ <TooltipTrigger key={j} delay={0} isDisabled={count <= 0}>
+ <Focusable>
+ <Row
+ alignItems="center"
+ justifyContent="center"
+ backgroundColor="2"
+ width="16px"
+ height="16px"
+ borderRadius="full"
+ style={{ margin: '0 auto' }}
+ role="button"
+ >
+ <Row
+ backgroundColor="primary"
+ width="16px"
+ height="16px"
+ borderRadius="full"
+ style={{ opacity: pct, transform: `scale(${pct})` }}
+ />
+ </Row>
+ </Focusable>
+ <Tooltip placement="right">{`${formatMessage(
+ labels.visitors,
+ )}: ${count}`}</Tooltip>
+ </TooltipTrigger>
+ );
+ })}
+ </Grid>
+ );
+ })}
+ </>
+ )}
+ </Grid>
+ </LoadingPanel>
+ );
+}
diff --git a/src/components/metrics/WorldMap.tsx b/src/components/metrics/WorldMap.tsx
new file mode 100644
index 0000000..3c8fadb
--- /dev/null
+++ b/src/components/metrics/WorldMap.tsx
@@ -0,0 +1,105 @@
+import { Column, type ColumnProps, FloatingTooltip, useTheme } from '@umami/react-zen';
+import { colord } from 'colord';
+import { useMemo, useState } from 'react';
+import { ComposableMap, Geographies, Geography, ZoomableGroup } from 'react-simple-maps';
+import {
+ useCountryNames,
+ useLocale,
+ useMessages,
+ useWebsiteMetricsQuery,
+} from '@/components/hooks';
+import { getThemeColors } from '@/lib/colors';
+import { ISO_COUNTRIES, MAP_FILE } from '@/lib/constants';
+import { percentFilter } from '@/lib/filters';
+import { formatLongNumber } from '@/lib/format';
+
+export interface WorldMapProps extends ColumnProps {
+ websiteId?: string;
+ data?: any[];
+}
+
+export function WorldMap({ websiteId, data, ...props }: WorldMapProps) {
+ const [tooltip, setTooltipPopup] = useState();
+ const { theme } = useTheme();
+ const { colors } = getThemeColors(theme);
+ const { locale } = useLocale();
+ const { formatMessage, labels } = useMessages();
+ const { countryNames } = useCountryNames(locale);
+ const visitorsLabel = formatMessage(labels.visitors).toLocaleLowerCase(locale);
+ const unknownLabel = formatMessage(labels.unknown);
+
+ const { data: mapData } = useWebsiteMetricsQuery(websiteId, {
+ type: 'country',
+ });
+
+ const metrics = useMemo(
+ () => (data || mapData ? percentFilter((data || mapData) as any[]) : []),
+ [data, mapData],
+ );
+
+ const getFillColor = (code: string) => {
+ if (code === 'AQ') return;
+ const country = metrics?.find(({ x }) => x === code);
+
+ if (!country) {
+ return colors.map.fillColor;
+ }
+
+ return colord(colors.map.baseColor)
+ [theme === 'light' ? 'lighten' : 'darken'](0.4 * (1.0 - country.z / 100))
+ .toHex();
+ };
+
+ const getOpacity = (code: string) => {
+ return code === 'AQ' ? 0 : 1;
+ };
+
+ const handleHover = (code: string) => {
+ if (code === 'AQ') return;
+ const country = metrics?.find(({ x }) => x === code);
+ setTooltipPopup(
+ `${countryNames[code] || unknownLabel}: ${formatLongNumber(
+ country?.y || 0,
+ )} ${visitorsLabel}` as any,
+ );
+ };
+
+ return (
+ <Column
+ {...props}
+ data-tip=""
+ data-for="world-map-tooltip"
+ style={{ margin: 'auto 0', overflow: 'hidden' }}
+ >
+ <ComposableMap projection="geoMercator">
+ <ZoomableGroup zoom={0.8} minZoom={0.7} center={[0, 40]}>
+ <Geographies geography={`${process.env.basePath || ''}${MAP_FILE}`}>
+ {({ geographies }) => {
+ return geographies.map(geo => {
+ const code = ISO_COUNTRIES[geo.id];
+
+ return (
+ <Geography
+ key={geo.rsmKey}
+ geography={geo}
+ fill={getFillColor(code)}
+ stroke={colors.map.strokeColor}
+ opacity={getOpacity(code)}
+ style={{
+ default: { outline: 'none' },
+ hover: { outline: 'none', fill: colors.map.hoverColor },
+ pressed: { outline: 'none' },
+ }}
+ onMouseOver={() => handleHover(code)}
+ onMouseOut={() => setTooltipPopup(null)}
+ />
+ );
+ });
+ }}
+ </Geographies>
+ </ZoomableGroup>
+ </ComposableMap>
+ {tooltip && <FloatingTooltip>{tooltip}</FloatingTooltip>}
+ </Column>
+ );
+}