fix chart and timezone issues, pass consistent dates to DB.
Some checks failed
Create docker images (cloud) / Build, push, and deploy (push) Waiting to run
Node.js CI / build (postgresql, 18.18, 10) (push) Has been cancelled

Closes #3700
This commit is contained in:
Francis Cao 2025-11-13 15:52:24 -08:00
parent 81bedec6d5
commit 6751bf88bb
10 changed files with 157 additions and 79 deletions

View file

@ -1,5 +1,5 @@
import { LoadingPanel } from '@/components/common/LoadingPanel'; import { LoadingPanel } from '@/components/common/LoadingPanel';
import { useDateRange } from '@/components/hooks'; import { useDateRange, useTimezone } from '@/components/hooks';
import { useWebsitePageviewsQuery } from '@/components/hooks/queries/useWebsitePageviewsQuery'; import { useWebsitePageviewsQuery } from '@/components/hooks/queries/useWebsitePageviewsQuery';
import { PageviewsChart } from '@/components/metrics/PageviewsChart'; import { PageviewsChart } from '@/components/metrics/PageviewsChart';
import { useMemo } from 'react'; import { useMemo } from 'react';
@ -11,7 +11,8 @@ export function WebsiteChart({
websiteId: string; websiteId: string;
compareMode?: boolean; compareMode?: boolean;
}) { }) {
const { dateRange, dateCompare } = useDateRange(); const { timezone } = useTimezone();
const { dateRange, dateCompare } = useDateRange({ timezone: timezone });
const { startDate, endDate, unit, value } = dateRange; const { startDate, endDate, unit, value } = dateRange;
const { data, isLoading, isFetching, error } = useWebsitePageviewsQuery({ const { data, isLoading, isFetching, error } = useWebsitePageviewsQuery({
websiteId, websiteId,

View file

@ -5,13 +5,13 @@ export function useDateParameters() {
const { const {
dateRange: { startDate, endDate, unit }, dateRange: { startDate, endDate, unit },
} = useDateRange(); } = useDateRange();
const { timezone, toUtc, canonicalizeTimezone } = useTimezone(); const { timezone, localToUtc, canonicalizeTimezone } = useTimezone();
return { return {
startAt: +toUtc(startDate), startAt: +localToUtc(startDate),
endAt: +toUtc(endDate), endAt: +localToUtc(endDate),
startDate: toUtc(startDate).toISOString(), startDate: localToUtc(startDate).toISOString(),
endDate: toUtc(endDate).toISOString(), endDate: localToUtc(endDate).toISOString(),
unit, unit,
timezone: canonicalizeTimezone(timezone), timezone: canonicalizeTimezone(timezone),
}; };

View file

@ -5,7 +5,7 @@ import { useLocale } from '@/components/hooks/useLocale';
import { DATE_RANGE_CONFIG, DEFAULT_DATE_RANGE_VALUE } from '@/lib/constants'; import { DATE_RANGE_CONFIG, DEFAULT_DATE_RANGE_VALUE } from '@/lib/constants';
import { getItem } from '@/lib/storage'; import { getItem } from '@/lib/storage';
export function useDateRange(options: { ignoreOffset?: boolean } = {}) { export function useDateRange(options: { ignoreOffset?: boolean; timezone?: string } = {}) {
const { const {
query: { date = '', offset = 0, compare = 'prev' }, query: { date = '', offset = 0, compare = 'prev' },
} = useNavigation(); } = useNavigation();
@ -15,6 +15,7 @@ export function useDateRange(options: { ignoreOffset?: boolean } = {}) {
const dateRangeObject = parseDateRange( const dateRangeObject = parseDateRange(
date || getItem(DATE_RANGE_CONFIG) || DEFAULT_DATE_RANGE_VALUE, date || getItem(DATE_RANGE_CONFIG) || DEFAULT_DATE_RANGE_VALUE,
locale, locale,
options.timezone,
); );
return !options.ignoreOffset && offset return !options.ignoreOffset && offset

View file

@ -3,11 +3,13 @@ import { TIMEZONE_CONFIG, TIMEZONE_LEGACY } from '@/lib/constants';
import { formatInTimeZone, zonedTimeToUtc, utcToZonedTime } from 'date-fns-tz'; import { formatInTimeZone, zonedTimeToUtc, utcToZonedTime } from 'date-fns-tz';
import { useApp, setTimezone } from '@/store/app'; import { useApp, setTimezone } from '@/store/app';
import { useLocale } from './useLocale'; import { useLocale } from './useLocale';
import { getTimezone } from '@/lib/date';
const selector = (state: { timezone: string }) => state.timezone; const selector = (state: { timezone: string }) => state.timezone;
export function useTimezone() { export function useTimezone() {
const timezone = useApp(selector); const timezone = useApp(selector);
const localTimeZone = getTimezone();
const { dateLocale } = useLocale(); const { dateLocale } = useLocale();
const saveTimezone = (value: string) => { const saveTimezone = (value: string) => {
@ -26,6 +28,38 @@ export function useTimezone() {
); );
}; };
const formatSeriesTimezone = (data: any, column: string, timezone: string) => {
return data.map(item => {
const date = new Date(item[column]);
const format = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
hour12: false,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
const parts = format.formatToParts(date);
const get = type => parts.find(p => p.type === type)?.value;
const year = get('year');
const month = get('month');
const day = get('day');
const hour = get('hour');
const minute = get('minute');
const second = get('second');
return {
...item,
[column]: `${year}-${month}-${day} ${hour}:${minute}:${second}`,
};
});
};
const toUtc = (date: Date | string | number) => { const toUtc = (date: Date | string | number) => {
return zonedTimeToUtc(date, timezone); return zonedTimeToUtc(date, timezone);
}; };
@ -34,16 +68,28 @@ export function useTimezone() {
return utcToZonedTime(date, timezone); return utcToZonedTime(date, timezone);
}; };
const localToUtc = (date: Date | string | number) => {
return zonedTimeToUtc(date, localTimeZone);
};
const localFromUtc = (date: Date | string | number) => {
return utcToZonedTime(date, localTimeZone);
};
const canonicalizeTimezone = (timezone: string): string => { const canonicalizeTimezone = (timezone: string): string => {
return TIMEZONE_LEGACY[timezone] ?? timezone; return TIMEZONE_LEGACY[timezone] ?? timezone;
}; };
return { return {
timezone, timezone,
saveTimezone, localTimeZone,
formatTimezoneDate,
toUtc, toUtc,
fromUtc, fromUtc,
localToUtc,
localFromUtc,
saveTimezone,
formatTimezoneDate,
formatSeriesTimezone,
canonicalizeTimezone, canonicalizeTimezone,
}; };
} }

View file

@ -1,6 +1,11 @@
import { BarChart, BarChartProps } from '@/components/charts/BarChart'; import { BarChart, BarChartProps } from '@/components/charts/BarChart';
import { LoadingPanel } from '@/components/common/LoadingPanel'; import { LoadingPanel } from '@/components/common/LoadingPanel';
import { useDateRange, useLocale, useWebsiteEventsSeriesQuery } from '@/components/hooks'; import {
useDateRange,
useLocale,
useTimezone,
useWebsiteEventsSeriesQuery,
} from '@/components/hooks';
import { renderDateLabels } from '@/lib/charts'; import { renderDateLabels } from '@/lib/charts';
import { CHART_COLORS } from '@/lib/constants'; import { CHART_COLORS } from '@/lib/constants';
import { generateTimeSeries } from '@/lib/date'; import { generateTimeSeries } from '@/lib/date';
@ -13,9 +18,10 @@ export interface EventsChartProps extends BarChartProps {
} }
export function EventsChart({ websiteId, focusLabel }: EventsChartProps) { export function EventsChart({ websiteId, focusLabel }: EventsChartProps) {
const { timezone } = useTimezone();
const { const {
dateRange: { startDate, endDate, unit }, dateRange: { startDate, endDate, unit },
} = useDateRange(); } = useDateRange({ timezone: timezone });
const { locale, dateLocale } = useLocale(); const { locale, dateLocale } = useLocale();
const { data, isLoading, error } = useWebsiteEventsSeriesQuery(websiteId); const { data, isLoading, error } = useWebsiteEventsSeriesQuery(websiteId);
const [label, setLabel] = useState<string>(focusLabel); const [label, setLabel] = useState<string>(focusLabel);
@ -33,6 +39,17 @@ export function EventsChart({ websiteId, focusLabel }: EventsChartProps) {
return obj; return obj;
}, {}); }, {});
if (!map || Object.keys(map).length === 0) {
return {
datasets: [
{
data: generateTimeSeries([], startDate, endDate, unit, dateLocale),
lineTension: 0,
borderWidth: 1,
},
],
};
} else {
return { return {
datasets: Object.keys(map).map((key, index) => { datasets: Object.keys(map).map((key, index) => {
const color = colord(CHART_COLORS[index % CHART_COLORS.length]); const color = colord(CHART_COLORS[index % CHART_COLORS.length]);
@ -47,6 +64,7 @@ export function EventsChart({ websiteId, focusLabel }: EventsChartProps) {
}), }),
focusLabel, focusLabel,
}; };
}
}, [data, startDate, endDate, unit, focusLabel]); }, [data, startDate, endDate, unit, focusLabel]);
useEffect(() => { useEffect(() => {

View file

@ -3,6 +3,7 @@ import { startOfMinute, subMinutes, isBefore } from 'date-fns';
import { PageviewsChart } from './PageviewsChart'; import { PageviewsChart } from './PageviewsChart';
import { DEFAULT_ANIMATION_DURATION, REALTIME_RANGE } from '@/lib/constants'; import { DEFAULT_ANIMATION_DURATION, REALTIME_RANGE } from '@/lib/constants';
import { RealtimeData } from '@/lib/types'; import { RealtimeData } from '@/lib/types';
import { useTimezone } from '@/components/hooks';
export interface RealtimeChartProps { export interface RealtimeChartProps {
data: RealtimeData; data: RealtimeData;
@ -11,6 +12,7 @@ export interface RealtimeChartProps {
} }
export function RealtimeChart({ data, unit, ...props }: RealtimeChartProps) { export function RealtimeChart({ data, unit, ...props }: RealtimeChartProps) {
const { formatSeriesTimezone, fromUtc, timezone } = useTimezone();
const endDate = startOfMinute(new Date()); const endDate = startOfMinute(new Date());
const startDate = subMinutes(endDate, REALTIME_RANGE); const startDate = subMinutes(endDate, REALTIME_RANGE);
const prevEndDate = useRef(endDate); const prevEndDate = useRef(endDate);
@ -21,8 +23,8 @@ export function RealtimeChart({ data, unit, ...props }: RealtimeChartProps) {
} }
return { return {
pageviews: data.series.views, pageviews: formatSeriesTimezone(data.series.views, 'x', timezone),
sessions: data.series.visitors, sessions: formatSeriesTimezone(data.series.visitors, 'x', timezone),
}; };
}, [data, startDate, endDate, unit]); }, [data, startDate, endDate, unit]);
@ -38,8 +40,8 @@ export function RealtimeChart({ data, unit, ...props }: RealtimeChartProps) {
return ( return (
<PageviewsChart <PageviewsChart
{...props} {...props}
minDate={startDate} minDate={fromUtc(startDate)}
maxDate={endDate} maxDate={fromUtc(endDate)}
unit={unit} unit={unit}
data={chartData} data={chartData}
animationDuration={animationDuration} animationDuration={animationDuration}

View file

@ -51,12 +51,12 @@ function getUTCString(date?: Date | string | number) {
return formatInTimeZone(date || new Date(), 'UTC', 'yyyy-MM-dd HH:mm:ss'); return formatInTimeZone(date || new Date(), 'UTC', 'yyyy-MM-dd HH:mm:ss');
} }
function getDateStringSQL(data: any, unit: string = 'utc', timezone?: string) { function getDateStringSQL(field: string, unit: string, timezone?: string) {
if (timezone) { if (timezone) {
return `formatDateTime(${data}, '${CLICKHOUSE_DATE_FORMATS[unit]}', '${timezone}')`; return `formatDateTime(${field}, '${CLICKHOUSE_DATE_FORMATS[unit]}', '${timezone}')`;
} }
return `formatDateTime(${data}, '${CLICKHOUSE_DATE_FORMATS[unit]}')`; return `formatDateTime(${field}, '${CLICKHOUSE_DATE_FORMATS[unit]}')`;
} }
function getDateSQL(field: string, unit: string, timezone?: string) { function getDateSQL(field: string, unit: string, timezone?: string) {

View file

@ -1,44 +1,45 @@
import {
addMinutes,
addHours,
addDays,
addMonths,
addYears,
subMinutes,
subHours,
subDays,
subMonths,
subYears,
startOfMinute,
startOfHour,
startOfDay,
startOfWeek,
startOfMonth,
startOfYear,
endOfHour,
endOfDay,
endOfWeek,
endOfMonth,
endOfYear,
differenceInMinutes,
differenceInHours,
differenceInCalendarDays,
differenceInCalendarWeeks,
differenceInCalendarMonths,
differenceInCalendarYears,
format,
max,
min,
isDate,
addWeeks,
subWeeks,
endOfMinute,
isSameDay,
isBefore,
isEqual,
} from 'date-fns';
import { getDateLocale } from '@/lib/lang'; import { getDateLocale } from '@/lib/lang';
import { DateRange } from '@/lib/types'; import { DateRange } from '@/lib/types';
import {
addDays,
addHours,
addMinutes,
addMonths,
addWeeks,
addYears,
differenceInCalendarDays,
differenceInCalendarMonths,
differenceInCalendarWeeks,
differenceInCalendarYears,
differenceInHours,
differenceInMinutes,
endOfDay,
endOfHour,
endOfMinute,
endOfMonth,
endOfWeek,
endOfYear,
format,
isBefore,
isDate,
isEqual,
isSameDay,
max,
min,
startOfDay,
startOfHour,
startOfMinute,
startOfMonth,
startOfWeek,
startOfYear,
subDays,
subHours,
subMinutes,
subMonths,
subWeeks,
subYears,
} from 'date-fns';
import { utcToZonedTime } from 'date-fns-tz';
export const TIME_UNIT = { export const TIME_UNIT = {
minute: 'minute', minute: 'minute',
@ -135,7 +136,7 @@ export function parseDateValue(value: string) {
return { num: +num, unit }; return { num: +num, unit };
} }
export function parseDateRange(value: string, locale = 'en-US'): DateRange { export function parseDateRange(value: string, locale = 'en-US', timezone?: string): DateRange {
if (typeof value !== 'string') { if (typeof value !== 'string') {
return null; return null;
} }
@ -156,7 +157,8 @@ export function parseDateRange(value: string, locale = 'en-US'): DateRange {
}; };
} }
const now = new Date(); const date = new Date();
const now = timezone ? utcToZonedTime(date, timezone) : date;
const dateLocale = getDateLocale(locale); const dateLocale = getDateLocale(locale);
const { num = 1, unit } = parseDateValue(value); const { num = 1, unit } = parseDateValue(value);

View file

@ -27,6 +27,14 @@ const DATE_FORMATS = {
year: 'YYYY-01-01 HH24:00:00', year: 'YYYY-01-01 HH24:00:00',
}; };
const DATE_FORMATS_UTC = {
minute: 'YYYY-MM-DD"T"HH24:MI:00"Z"',
hour: 'YYYY-MM-DD"T"HH24:00:00"Z"',
day: 'YYYY-MM-DD"T"HH24:00:00"Z"',
month: 'YYYY-MM-01"T"HH24:00:00"Z"',
year: 'YYYY-01-01"T"HH24:00:00"Z"',
};
function getAddIntervalQuery(field: string, interval: string): string { function getAddIntervalQuery(field: string, interval: string): string {
return `${field} + interval '${interval}'`; return `${field} + interval '${interval}'`;
} }
@ -40,11 +48,11 @@ function getCastColumnQuery(field: string, type: string): string {
} }
function getDateSQL(field: string, unit: string, timezone?: string): string { function getDateSQL(field: string, unit: string, timezone?: string): string {
if (timezone) { if (timezone && timezone !== 'utc') {
return `to_char(date_trunc('${unit}', ${field} at time zone '${timezone}'), '${DATE_FORMATS[unit]}')`; return `to_char(date_trunc('${unit}', ${field} at time zone '${timezone}'), '${DATE_FORMATS[unit]}')`;
} }
return `to_char(date_trunc('${unit}', ${field}), '${DATE_FORMATS[unit]}')`; return `to_char(date_trunc('${unit}', ${field}), '${DATE_FORMATS_UTC[unit]}')`;
} }
function getDateWeeklySQL(field: string, timezone?: string) { function getDateWeeklySQL(field: string, timezone?: string) {

View file

@ -1,7 +1,7 @@
import { getPageviewStats } from '@/queries/sql/pageviews/getPageviewStats';
import { getRealtimeActivity } from '@/queries/sql/getRealtimeActivity';
import { getSessionStats } from '@/queries/sql/sessions/getSessionStats';
import { QueryFilters } from '@/lib/types'; import { QueryFilters } from '@/lib/types';
import { getRealtimeActivity } from '@/queries/sql/getRealtimeActivity';
import { getPageviewStats } from '@/queries/sql/pageviews/getPageviewStats';
import { getSessionStats } from '@/queries/sql/sessions/getSessionStats';
function increment(data: object, key: string) { function increment(data: object, key: string) {
if (key) { if (key) {