diff options
| author | Fuwn <[email protected]> | 2026-01-24 13:09:50 +0000 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-01-24 13:09:50 +0000 |
| commit | 396acf3bbbe00a192cb0ea0a9ccf91b1d8d2850b (patch) | |
| tree | b9df4ca6a70db45cfffbae6fdd7252e20fb8e93c /src/components/metrics | |
| download | umami-main.tar.xz umami-main.zip | |
Created from https://vercel.com/new
Diffstat (limited to 'src/components/metrics')
| -rw-r--r-- | src/components/metrics/ActiveUsers.tsx | 39 | ||||
| -rw-r--r-- | src/components/metrics/ChangeLabel.tsx | 60 | ||||
| -rw-r--r-- | src/components/metrics/DatePickerForm.tsx | 74 | ||||
| -rw-r--r-- | src/components/metrics/EventData.tsx | 22 | ||||
| -rw-r--r-- | src/components/metrics/EventsChart.tsx | 93 | ||||
| -rw-r--r-- | src/components/metrics/Legend.tsx | 39 | ||||
| -rw-r--r-- | src/components/metrics/ListTable.tsx | 152 | ||||
| -rw-r--r-- | src/components/metrics/MetricCard.tsx | 56 | ||||
| -rw-r--r-- | src/components/metrics/MetricLabel.tsx | 142 | ||||
| -rw-r--r-- | src/components/metrics/MetricsBar.tsx | 14 | ||||
| -rw-r--r-- | src/components/metrics/MetricsExpandedTable.tsx | 139 | ||||
| -rw-r--r-- | src/components/metrics/MetricsTable.tsx | 95 | ||||
| -rw-r--r-- | src/components/metrics/PageviewsChart.tsx | 98 | ||||
| -rw-r--r-- | src/components/metrics/RealtimeChart.tsx | 59 | ||||
| -rw-r--r-- | src/components/metrics/WeeklyTraffic.tsx | 112 | ||||
| -rw-r--r-- | src/components/metrics/WorldMap.tsx | 105 |
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> </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> + ); +} |