From 396acf3bbbe00a192cb0ea0a9ccf91b1d8d2850b Mon Sep 17 00:00:00 2001 From: Fuwn <50817549+Fuwn@users.noreply.github.com> Date: Sat, 24 Jan 2026 13:09:50 +0000 Subject: Initial commit Created from https://vercel.com/new --- src/components/metrics/ActiveUsers.tsx | 39 ++++++ src/components/metrics/ChangeLabel.tsx | 60 ++++++++++ src/components/metrics/DatePickerForm.tsx | 74 ++++++++++++ src/components/metrics/EventData.tsx | 22 ++++ src/components/metrics/EventsChart.tsx | 93 +++++++++++++++ src/components/metrics/Legend.tsx | 39 ++++++ src/components/metrics/ListTable.tsx | 152 ++++++++++++++++++++++++ src/components/metrics/MetricCard.tsx | 56 +++++++++ src/components/metrics/MetricLabel.tsx | 142 ++++++++++++++++++++++ src/components/metrics/MetricsBar.tsx | 14 +++ src/components/metrics/MetricsExpandedTable.tsx | 139 ++++++++++++++++++++++ src/components/metrics/MetricsTable.tsx | 95 +++++++++++++++ src/components/metrics/PageviewsChart.tsx | 98 +++++++++++++++ src/components/metrics/RealtimeChart.tsx | 59 +++++++++ src/components/metrics/WeeklyTraffic.tsx | 112 +++++++++++++++++ src/components/metrics/WorldMap.tsx | 105 ++++++++++++++++ 16 files changed, 1299 insertions(+) create mode 100644 src/components/metrics/ActiveUsers.tsx create mode 100644 src/components/metrics/ChangeLabel.tsx create mode 100644 src/components/metrics/DatePickerForm.tsx create mode 100644 src/components/metrics/EventData.tsx create mode 100644 src/components/metrics/EventsChart.tsx create mode 100644 src/components/metrics/Legend.tsx create mode 100644 src/components/metrics/ListTable.tsx create mode 100644 src/components/metrics/MetricCard.tsx create mode 100644 src/components/metrics/MetricLabel.tsx create mode 100644 src/components/metrics/MetricsBar.tsx create mode 100644 src/components/metrics/MetricsExpandedTable.tsx create mode 100644 src/components/metrics/MetricsTable.tsx create mode 100644 src/components/metrics/PageviewsChart.tsx create mode 100644 src/components/metrics/RealtimeChart.tsx create mode 100644 src/components/metrics/WeeklyTraffic.tsx create mode 100644 src/components/metrics/WorldMap.tsx (limited to 'src/components/metrics') 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 ( + + + + {count} {formatMessage(labels.online)} + + + + ); +} 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 ( + + {!neutral && ( + + + + )} + {children || value} + + ); +} 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([ + 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 ( + + + + {formatMessage(labels.singleDay)} + {formatMessage(labels.dateRange)} + + + + {selected.includes(FILTER_DAY) && ( + + )} + {selected.includes(FILTER_RANGE) && ( + + + + + )} + + + + + + + ); +} 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 ( + + + {data?.map(({ dataKey, stringValue }) => { + return ( + + + {stringValue} + + ); + })} + + + ); +} 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(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 ( + + {chartData && ( + + )} + + ); +} 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 ( + + {items.map(item => { + const { text, fillStyle, hidden } = item; + const color = colord(fillStyle); + + return ( + onClick(item)}> + + + {text} + + + + ); + })} + + ); +} 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 ( + + ); + }; + + const ListTableRow = ({ index, style }) => { + return
{getRow(data[index], index)}
; + }; + + return ( + + + {title} + + {metric} + + + + {data?.length === 0 && } + {virtualize && data.length > 0 ? ( + + {ListTableRow} + + ) : ( + data.map(getRow) + )} + + + ); +} + +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 ( + + + + {label} + + + + {change} + + + {currency + ? props.y?.to(n => formatLongCurrency(n, currency)) + : props.y?.to(formatLongNumber)} + + + + {showPercentage && ( + + {props.width.to(n => `${n?.toFixed?.(0)}%`)} + + )} + + ); +}; 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 ( + + {showLabel && ( + + {label} + + )} + + {props?.x?.to(x => formatValue(x))} + + {showChange && ( + + {changeProps?.x?.to(x => `${Math.abs(~~x)}%`)} + + )} + + ); +}; 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 ( + } + /> + ); + + case 'channel': + return formatMessage(labels[label]); + + case 'city': + return ( + + ) + } + /> + ); + + case 'region': + return ( + } + /> + ); + + case 'country': + return ( + } + /> + ); + + case 'path': + case 'entry': + case 'exit': + return ( + + ); + + case 'device': + return ( + } + /> + ); + + case 'referrer': + return ( + } + /> + ); + + case 'domain': + if (label === 'Other') { + return `(${formatMessage(labels.other)})`; + } else { + const name = GROUPED_DOMAINS.find(({ domain }) => domain === label)?.name; + + if (!name) { + return null; + } + + return ( + + + {name} + + ); + } + + case 'language': + return formatValue(label, 'language'); + + default: + return ; + } +} 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 ( + + {children} + + ); +} 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 ( + <> + + {allowSearch && } + + {children} + {allowDownload && } + {onClose && ( + + )} + + + + + {items && ( + + + {row => ( + + + + )} + + + {row => row?.visitors?.toLocaleString()} + + + {row => row?.visits?.toLocaleString()} + + + {row => row?.pageviews?.toLocaleString()} + + {showBounceDuration && [ + + {row => { + const n = (Math.min(row?.visits, row?.bounces) / row?.visits) * 100; + return `${Math.round(+n)}%`; + }} + , + + + {row => { + const n = row?.totaltime / row?.visits; + return `${+n < 0 ? '-' : ''}${formatShortTime(Math.abs(~~n), ['m', 's'], ' ')}`; + }} + , + ]} + + )} + + + + ); +} 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; + 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 ? : row.label; + }; + + return ( + + + {data && } + {showMore && limit && ( + + + + + + {formatMessage(labels.more)} + + + )} + + + ); +} 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 ( + + ); +} 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(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 ( + + ); +} 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 ( + + + {data && ( + <> + +   + {Array(24) + .fill(null) + .map((_, i) => { + const label = format(addHours(startOfDay(new Date()), i), 'haaa', { + locale: dateLocale, + }); + return ( + + + {label} + + + ); + })} + + {daysOfWeek.map((index: number) => { + const day = data[index]; + return ( + + + + {format(getDayOfWeekAsDate(index), 'EEE', { locale: dateLocale })} + + + {day?.map((count: number, j) => { + const pct = max ? count / max : 0; + return ( + + + + + + + {`${formatMessage( + labels.visitors, + )}: ${count}`} + + ); + })} + + ); + })} + + )} + + + ); +} 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 ( + + + + + {({ geographies }) => { + return geographies.map(geo => { + const code = ISO_COUNTRIES[geo.id]; + + return ( + handleHover(code)} + onMouseOut={() => setTooltipPopup(null)} + /> + ); + }); + }} + + + + {tooltip && {tooltip}} + + ); +} -- cgit v1.2.3