diff --git a/src/app/(main)/websites/[websiteId]/WebsiteChart.tsx b/src/app/(main)/websites/[websiteId]/WebsiteChart.tsx index 9a6338cc..4e176072 100644 --- a/src/app/(main)/websites/[websiteId]/WebsiteChart.tsx +++ b/src/app/(main)/websites/[websiteId]/WebsiteChart.tsx @@ -16,7 +16,7 @@ export function WebsiteChart({ const { startDate, endDate, unit, value } = dateRange; const { data, isLoading, isFetching, error } = useWebsitePageviewsQuery({ websiteId, - compareMode: compareMode ? dateCompare : undefined, + compare: compareMode ? dateCompare : undefined, }); const { pageviews, sessions, compare } = (data || {}) as any; diff --git a/src/app/(main)/websites/[websiteId]/WebsiteCompareTables.tsx b/src/app/(main)/websites/[websiteId]/WebsiteCompareTables.tsx index 9b633cb3..c3474dfe 100644 --- a/src/app/(main)/websites/[websiteId]/WebsiteCompareTables.tsx +++ b/src/app/(main)/websites/[websiteId]/WebsiteCompareTables.tsx @@ -129,7 +129,13 @@ export function WebsiteCompareTables({ websiteId }: { websiteId: string }) { const value = y - prev; const change = Math.abs(((y - prev) / prev) * 100); - return !isNaN(change) && {formatNumber(change)}%; + return ( + !isNaN(change) && ( + + {formatNumber(change)}% + + ) + ); }; const { startDate, endDate } = getCompareDate( @@ -147,7 +153,7 @@ export function WebsiteCompareTables({ websiteId }: { websiteId: string }) { - + {formatMessage(labels.previous)} @@ -160,7 +166,7 @@ export function WebsiteCompareTables({ websiteId }: { websiteId: string }) { params={params} /> - + {formatMessage(labels.current)} diff --git a/src/components/charts/BarChart.tsx b/src/components/charts/BarChart.tsx index 345197bd..8cbf5f8d 100644 --- a/src/components/charts/BarChart.tsx +++ b/src/components/charts/BarChart.tsx @@ -5,7 +5,7 @@ import { Chart, ChartProps } from '@/components/charts/Chart'; import { useLocale } from '@/components/hooks'; import { renderNumberLabels } from '@/lib/charts'; import { getThemeColors } from '@/lib/colors'; -import { formatDate, formatDateByUnit } from '@/lib/date'; +import { formatDate, DATE_FORMATS } from '@/lib/date'; import { formatLongCurrency, formatLongNumber } from '@/lib/format'; const dateFormats = { @@ -37,7 +37,7 @@ export function BarChart({ renderXLabel, renderYLabel, unit, - XAxisType = 'time', + XAxisType = 'timeseries', YAxisType = 'linear', stacked = false, minDate, @@ -57,8 +57,8 @@ export function BarChart({ x: { type: XAxisType, stacked: true, - min: formatDateByUnit(minDate, unit), - max: formatDateByUnit(maxDate, unit), + min: formatDate(minDate, DATE_FORMATS[unit], locale), + max: formatDate(maxDate, DATE_FORMATS[unit], locale), offset: true, time: { unit, diff --git a/src/components/hooks/queries/useWebsitePageviewsQuery.ts b/src/components/hooks/queries/useWebsitePageviewsQuery.ts index d4793052..f815f41a 100644 --- a/src/components/hooks/queries/useWebsitePageviewsQuery.ts +++ b/src/components/hooks/queries/useWebsitePageviewsQuery.ts @@ -8,15 +8,15 @@ export interface WebsitePageviewsData { } export function useWebsitePageviewsQuery( - { websiteId, compareMode }: { websiteId: string; compareMode?: string }, + { websiteId, compare }: { websiteId: string; compare?: string }, options?: ReactQueryOptions, ) { const { get, useQuery } = useApi(); const filterParams = useFilterParams(websiteId); return useQuery({ - queryKey: ['websites:pageviews', { websiteId, compareMode, ...filterParams }], - queryFn: () => get(`/websites/${websiteId}/pageviews`, { compareMode, ...filterParams }), + queryKey: ['websites:pageviews', { websiteId, compare, ...filterParams }], + queryFn: () => get(`/websites/${websiteId}/pageviews`, { compare, ...filterParams }), enabled: !!websiteId, ...options, }); diff --git a/src/components/metrics/MetricsTable.tsx b/src/components/metrics/MetricsTable.tsx index 556cbc73..3cebe697 100644 --- a/src/components/metrics/MetricsTable.tsx +++ b/src/components/metrics/MetricsTable.tsx @@ -1,4 +1,4 @@ -import { ReactNode, useMemo, useState } from 'react'; +import { ReactNode, useEffect, useMemo, useState } from 'react'; import { Icon, Text, SearchField, Row, Column } from '@umami/react-zen'; import { LinkButton } from '@/components/common/LinkButton'; import { DEFAULT_ANIMATION_DURATION } from '@/lib/constants'; @@ -11,7 +11,6 @@ import { LoadingPanel } from '@/components/common/LoadingPanel'; export interface MetricsTableProps extends ListTableProps { websiteId: string; type?: string; - className?: string; dataFilter?: (data: any) => any; limit?: number; delay?: number; @@ -20,13 +19,14 @@ export interface MetricsTableProps extends ListTableProps { searchFormattedValues?: boolean; showMore?: boolean; params?: { [key: string]: any }; + onDataLoad?: (data: any) => any; + className?: string; children?: ReactNode; } export function MetricsTable({ websiteId, type, - className, dataFilter, limit, delay = null, @@ -34,6 +34,8 @@ export function MetricsTable({ searchFormattedValues = false, showMore = true, params, + onDataLoad, + className, children, ...props }: MetricsTableProps) { @@ -79,9 +81,15 @@ export function MetricsTable({ return []; }, [data, dataFilter, search, limit, formatValue, type]); + useEffect(() => { + if (data) { + onDataLoad?.(data); + } + }, [data]); + return ( - + {allowSearch && } {children} diff --git a/src/components/metrics/PageviewsChart.tsx b/src/components/metrics/PageviewsChart.tsx index 5160cb5b..155c1ff7 100644 --- a/src/components/metrics/PageviewsChart.tsx +++ b/src/components/metrics/PageviewsChart.tsx @@ -4,7 +4,7 @@ import { BarChart, BarChartProps } from '@/components/charts/BarChart'; import { useLocale, useMessages } from '@/components/hooks'; import { renderDateLabels } from '@/lib/charts'; import { getThemeColors } from '@/lib/colors'; -import { formatDateByUnit } from '@/lib/date'; +import { generateTimeSeries } from '@/lib/date'; export interface PageviewsChartProps extends BarChartProps { data: { @@ -18,7 +18,7 @@ export interface PageviewsChartProps extends BarChartProps { unit: string; } -export function PageviewsChart({ data, unit, ...props }: PageviewsChartProps) { +export function PageviewsChart({ data, unit, minDate, maxDate, ...props }: PageviewsChartProps) { const { formatMessage, labels } = useMessages(); const { theme } = useTheme(); const { locale, dateLocale } = useLocale(); @@ -32,7 +32,7 @@ export function PageviewsChart({ data, unit, ...props }: PageviewsChartProps) { datasets: [ { label: formatMessage(labels.visitors), - data: convertDataset(data.sessions, unit, dateLocale), + data: generateTimeSeries(data.sessions, minDate, maxDate, unit, dateLocale), borderWidth: 1, barPercentage: 0.9, categoryPercentage: 0.9, @@ -41,7 +41,7 @@ export function PageviewsChart({ data, unit, ...props }: PageviewsChartProps) { }, { label: formatMessage(labels.views), - data: convertDataset(data.pageviews, unit, dateLocale), + data: generateTimeSeries(data.pageviews, minDate, maxDate, unit, dateLocale), barPercentage: 0.9, categoryPercentage: 0.9, borderWidth: 1, @@ -53,7 +53,13 @@ export function PageviewsChart({ data, unit, ...props }: PageviewsChartProps) { { type: 'line', label: `${formatMessage(labels.views)} (${formatMessage(labels.previous)})`, - data: data.compare.pageviews, + data: generateTimeSeries( + data.compare.pageviews, + minDate, + maxDate, + unit, + dateLocale, + ), borderWidth: 2, backgroundColor: '#8601B0', borderColor: '#8601B0', @@ -62,7 +68,7 @@ export function PageviewsChart({ data, unit, ...props }: PageviewsChartProps) { { type: 'line', label: `${formatMessage(labels.visitors)} (${formatMessage(labels.previous)})`, - data: data.compare.sessions, + data: generateTimeSeries(data.compare.sessions, minDate, maxDate, unit, dateLocale), borderWidth: 2, backgroundColor: '#f15bb5', borderColor: '#f15bb5', @@ -81,12 +87,10 @@ export function PageviewsChart({ data, unit, ...props }: PageviewsChartProps) { {...props} chartData={chartData} unit={unit} + minDate={minDate} + maxDate={maxDate} renderXLabel={renderXLabel} height="400px" /> ); } - -function convertDataset(data: { x: string; y: number }[], unit: string, locale?: any) { - return data.map(d => ({ ...d, d: d.x, x: formatDateByUnit(d.x, unit, locale) })); -} diff --git a/src/lib/date.ts b/src/lib/date.ts index 9a4cc783..6c0d77fc 100644 --- a/src/lib/date.ts +++ b/src/lib/date.ts @@ -34,7 +34,8 @@ import { subWeeks, endOfMinute, isSameDay, - parseISO, + isBefore, + isEqual, } from 'date-fns'; import { getDateLocale } from '@/lib/lang'; import { DateRange } from '@/lib/types'; @@ -48,19 +49,7 @@ export const TIME_UNIT = { year: 'year', }; -export const CUSTOM_FORMATS = { - 'en-US': { - p: 'ha', - pp: 'h:mm:ss', - }, - 'fr-FR': { - 'M/d': 'd/M', - 'MMM d': 'd MMM', - 'EEE M/d': 'EEE d/M', - }, -}; - -const DATE_FUNCTIONS = { +export const DATE_FUNCTIONS = { minute: { diff: differenceInMinutes, add: addMinutes, @@ -105,6 +94,15 @@ const DATE_FUNCTIONS = { }, }; +export const DATE_FORMATS = { + minute: 'yyyy-MM-dd HH:mm', + hour: 'yyyy-MM-dd HH', + day: 'yyyy-MM-dd', + week: "yyyy-'W'II", + month: 'yyyy-MM', + year: 'yyyy', +}; + export function isValidTimezone(timezone: string) { try { Intl.DateTimeFormat(undefined, { timeZone: timezone }); @@ -283,52 +281,6 @@ export function getMinimumUnit(startDate: number | Date, endDate: number | Date) return 'year'; } -export function formatDateByUnit(dateInput: string | Date, unit: string, locale?: any) { - const date = typeof dateInput === 'string' ? parseISO(dateInput) : dateInput; - - switch (unit) { - case 'minute': - return format(startOfMinute(date), 'yyyy-MM-dd HH:mm'); - case 'hour': - return format(startOfHour(date), 'yyyy-MM-dd HH'); - case 'day': - return format(startOfDay(date), 'yyyy-MM-dd'); - case 'week': - return format(startOfWeek(date, { locale }), "yyyy-'W'II"); - case 'month': - return format(startOfMonth(date), 'yyyy-MM'); - case 'year': - return format(startOfYear(date), 'yyyy'); - default: - return format(startOfDay(date), 'yyyy-MM-dd'); - } -} - -export function getDateArray(data: any[], startDate: Date, endDate: Date, unit: string) { - const arr = []; - const { diff, add, start } = DATE_FUNCTIONS[unit]; - const n = diff(endDate, startDate); - - for (let i = 0; i <= n; i++) { - const t = start(add(startDate, i)); - const y = data.find(({ x }) => start(new Date(x)).getTime() === t.getTime())?.y || 0; - - arr.push({ x: t, y }); - } - - return arr; -} - -export function formatDate(date: string | number | Date, str: string, locale = 'en-US') { - return format( - typeof date === 'string' ? new Date(date) : date, - CUSTOM_FORMATS?.[locale]?.[str] || str, - { - locale: getDateLocale(locale), - }, - ); -} - export function maxDate(...args: Date[]) { return max(args.filter(n => isDate(n))); } @@ -337,15 +289,6 @@ export function minDate(...args: any[]) { return min(args.filter(n => isDate(n))); } -export function getLocalTime(t: string | number | Date) { - return addMinutes(new Date(t), new Date().getTimezoneOffset()); -} - -export function getDateLength(startDate: Date, endDate: Date, unit: string | number) { - const { diff } = DATE_FUNCTIONS[unit]; - return diff(endDate, startDate) + 1; -} - export function getCompareDate(compare: string, startDate: Date, endDate: Date) { if (compare === 'yoy') { return { startDate: subYears(startDate, 1), endDate: subYears(endDate, 1) }; @@ -368,3 +311,39 @@ export function getDayOfWeekAsDate(dayOfWeek: number) { return currentDate; } + +export function formatDate(date: string | number | Date, dateFormat: string, locale = 'en-US') { + return format(typeof date === 'string' ? new Date(date) : date, dateFormat, { + locale: getDateLocale(locale), + }); +} + +export function generateTimeSeries( + data: { x: string; y: number }[], + minDate: Date, + maxDate: Date, + unit: string, + locale: string, +) { + const add = DATE_FUNCTIONS[unit].add; + const start = DATE_FUNCTIONS[unit].start; + const fmt = DATE_FORMATS[unit]; + + let current = start(minDate); + const end = start(maxDate); + + const timeseries: string[] = []; + + while (isBefore(current, end) || isEqual(current, end)) { + timeseries.push(formatDate(current, fmt, locale)); + current = add(current, 1); + } + + const lookup = new Map(data.map(({ x, y }) => [formatDate(x, fmt, locale), { x, y }])); + + return timeseries.map(t => { + const { x, y } = lookup.get(t) || {}; + + return { x: t, d: x, y: y ?? null }; + }); +}