Fix Postgresql/Clickhouse timezone inconsistencies. Closes #4541. Closes #3810.

This commit is contained in:
Francis Cao committed 2026-09-16 13:16:35 -07:00
1 parent c411c2043f
commit 587bfafb47
11 files changed
+226 -29

No files matched your search

+2 -2
View File
@@ -5,7 +5,7 @@ import { ChartTooltip } from '@/components/charts/ChartTooltip';
import { useLocale } from '@/components/hooks';
import { renderNumberLabels } from '@/lib/charts';
import { getThemeColors } from '@/lib/colors';
import { DATE_FORMATS, formatDate } from '@/lib/date';
import { DATE_FORMATS, formatDate, parseBackendDate } from '@/lib/date';
import { formatLongCurrency, formatLongNumber } from '@/lib/format';
const MemoChart = memo(Chart);
@@ -121,7 +121,7 @@ function BarChartComponent({
const nextTooltip = opacity
? {
title: formatDate(
new Date(dataPoints[0].raw?.d || dataPoints[0].raw?.x || dataPoints[0].raw),
parseBackendDate(dataPoints[0].raw?.d || dataPoints[0].raw?.x || dataPoints[0].raw),
dateFormats[unit],
locale,
),
@@ -0,0 +1,52 @@
import { renderHook } from '@testing-library/react';
import { describe, expect, test, vi } from 'vitest';
const useDateRangeMock = vi.fn();
const toUtcMock = vi.fn((date: Date) => date);
const localToUtcMock = vi.fn((date: Date) => date);
vi.mock('./useDateRange', () => ({
useDateRange: (...args: unknown[]) => useDateRangeMock(...args),
}));
vi.mock('./useTimezone', () => ({
useTimezone: () => ({
timezone: 'America/New_York',
toUtc: toUtcMock,
localToUtc: localToUtcMock,
canonicalizeTimezone: (tz: string) => tz,
}),
}));
import { useDateParameters } from './useDateParameters';
describe('useDateParameters', () => {
test('passes the profile timezone into useDateRange instead of ignoring it', () => {
useDateRangeMock.mockReturnValue({
dateRange: { startDate: new Date('2026-09-16T03:00:00Z'), endDate: new Date(), unit: 'hour' },
});
renderHook(() => useDateParameters());
// Regression test: this used to call useDateRange() with no arguments at all,
// silently computing "today" from the browser's own system clock instead of
// the profile timezone setting.
expect(useDateRangeMock).toHaveBeenCalledWith({ timezone: 'America/New_York' });
});
test('converts using the profile timezone, not the browser-local one', () => {
const startDate = new Date('2026-09-16T03:00:00Z');
const endDate = new Date('2026-09-17T03:00:00Z');
useDateRangeMock.mockReturnValue({ dateRange: { startDate, endDate, unit: 'day' } });
toUtcMock.mockClear();
localToUtcMock.mockClear();
renderHook(() => useDateParameters());
// Regression test: this used to call localToUtc (browser-system-timezone-aware)
// instead of toUtc (profile-timezone-aware) to produce the final startAt/endAt.
expect(toUtcMock).toHaveBeenCalledWith(startDate);
expect(toUtcMock).toHaveBeenCalledWith(endDate);
expect(localToUtcMock).not.toHaveBeenCalled();
});
});
+4 -4
View File
@@ -10,13 +10,13 @@ function safeToISOString(date: Date, fallback: Date): string {
}
export function useDateParameters() {
const { timezone, toUtc, canonicalizeTimezone } = useTimezone();
const {
dateRange: { startDate, endDate, unit },
} = useDateRange();
const { timezone, localToUtc, canonicalizeTimezone } = useTimezone();
} = useDateRange({ timezone });
const utcStart = localToUtc(startDate);
const utcEnd = localToUtc(endDate);
const utcStart = toUtc(startDate);
const utcEnd = toUtc(endDate);
const startAtValue = +utcStart;
const endAtValue = +utcEnd;
const isStartValid = Number.isFinite(startAtValue);
+30 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from 'vitest';
import { CLICKHOUSE_DATE_FORMATS } from './clickhouse';
import clickhouse, { CLICKHOUSE_DATE_FORMATS } from './clickhouse';
describe('CLICKHOUSE_DATE_FORMATS', () => {
test('uses date format tokens compatible with ClickHouse 22.8 and newer', () => {
@@ -10,3 +10,32 @@ describe('CLICKHOUSE_DATE_FORMATS', () => {
});
});
});
describe('getDateSQL bucket formatting', () => {
// Regression: getDateSQL used to return a native DateTime whose "iso" serialization
// is a genuine UTC instant - unlike Postgres's already-shifted-but-Z-labeled value,
// which the frontend assumes for every backend. formatDateTime makes it match.
test('formats an explicit "Z" via formatDateTime instead of relying on native DateTime serialization', () => {
expect(clickhouse.getDateSQL('website_event.created_at', 'hour', 'Asia/Tokyo')).toBe(
`formatDateTime(date_trunc('hour', website_event.created_at, 'Asia/Tokyo'), '%Y-%m-%dT%H:00:00Z', 'Asia/Tokyo')`,
);
});
test('omits the timezone argument to formatDateTime when no timezone is given', () => {
expect(clickhouse.getDateSQL('website_event.created_at', 'day')).toBe(
`formatDateTime(date_trunc('day', website_event.created_at), '%Y-%m-%dT00:00:00Z')`,
);
});
test('covers every bucket unit with an explicit "Z"', () => {
expect(clickhouse.getDateSQL('created_at', 'minute', 'UTC')).toBe(
`formatDateTime(date_trunc('minute', created_at, 'UTC'), '%Y-%m-%dT%R:00Z', 'UTC')`,
);
expect(clickhouse.getDateSQL('created_at', 'month', 'UTC')).toBe(
`formatDateTime(date_trunc('month', created_at, 'UTC'), '%Y-%m-01T00:00:00Z', 'UTC')`,
);
expect(clickhouse.getDateSQL('created_at', 'year', 'UTC')).toBe(
`formatDateTime(date_trunc('year', created_at, 'UTC'), '%Y-01-01T00:00:00Z', 'UTC')`,
);
});
});
+14 -2
View File
@@ -18,6 +18,17 @@ export const CLICKHOUSE_DATE_FORMATS = {
year: '%Y-01-01',
};
// Always Z-suffixed so JS parses the already-shifted local value as an unambiguous
// instant instead of re-interpreting it in the runtime's own timezone. Matches
// Postgres's getDateSQL convention - see prisma.ts's DATE_FORMATS.
const CLICKHOUSE_BUCKET_FORMATS = {
minute: '%Y-%m-%dT%R:00Z',
hour: '%Y-%m-%dT%H:00:00Z',
day: '%Y-%m-%dT00:00:00Z',
month: '%Y-%m-01T00:00:00Z',
year: '%Y-01-01T00:00:00Z',
};
const log = debug('umami:clickhouse');
const EQUALITY_OPERATORS: Operator[] = [OPERATORS.equals, OPERATORS.notEquals];
@@ -89,11 +100,12 @@ function getDateStringSQL(data: any, unit: string = 'utc', timezone?: string) {
function getDateSQL(field: string, unit: string, timezone?: string) {
const tz = normalizeTimezone(timezone);
const format = CLICKHOUSE_BUCKET_FORMATS[unit];
if (tz) {
return `toDateTime(date_trunc('${unit}', ${field}, '${tz}'), '${tz}')`;
return `formatDateTime(date_trunc('${unit}', ${field}, '${tz}'), '${format}', '${tz}')`;
}
return `toDateTime(date_trunc('${unit}', ${field}))`;
return `formatDateTime(date_trunc('${unit}', ${field}), '${format}')`;
}
function getSearchSQL(column: string, param: string = 'search'): string {
+41
View File
@@ -408,6 +408,47 @@ describe('generateTimeSeries', () => {
expect(series).toHaveLength(1);
expect(series[0].y).toBeNull();
});
test('matches backend-bucketed values without re-shifting them through the system zone', () => {
// Backend x is already the target-zone wall clock (e.g. Tokyo's day bucket),
// stamped with a literal "Z". minDate/maxDate carry that same wall clock via
// their local getters, per the "fake zoned" Date convention from parseDateRange.
const minDate = new Date(2026, 6, 2); // local wall clock: 2026-07-02 00:00
const data = [{ x: '2026-07-02T00:00:00Z', y: 42 }];
const series = generateTimeSeries(data, minDate, minDate, 'day', 'en-US');
expect(series).toHaveLength(1);
expect(series[0].x).toBe('2026-07-02');
expect(series[0].y).toBe(42);
});
test('does not double-shift hour buckets near a day boundary', () => {
// Regression guard: an earlier version of this fix re-applied toZonedTime(x, timezone)
// on top of the backend's already-shifted value, pushing a 23:00 bucket into the
// next day. The backend value must be read back as-is (via 'UTC'), not re-shifted.
const minDate = new Date(2026, 6, 2, 23); // local wall clock: 2026-07-02 23:00
const data = [{ x: '2026-07-02T23:00:00Z', y: 7 }];
const series = generateTimeSeries(data, minDate, minDate, 'hour', 'en-US');
expect(series).toHaveLength(1);
expect(series[0].x).toBe('2026-07-02 23');
expect(series[0].y).toBe(7);
});
test('does not reinterpret plain local bucket keys (no "Z") as UTC', () => {
// Regression guard: PropertyDateChart's bucket keys have no "Z" (plain local
// strings) and must be read as-is, not run through the "fake-Z" reinterpretation.
const minDate = new Date(2026, 6, 2, 23); // local wall clock: 2026-07-02 23:00
const data = [{ x: '2026-07-02T23:00:00', y: 7 }];
const series = generateTimeSeries(data, minDate, minDate, 'hour', 'en-US');
expect(series).toHaveLength(1);
expect(series[0].x).toBe('2026-07-02 23');
expect(series[0].y).toBe(7);
});
});
describe('getDateRangeValue / getMonthDateRangeValue', () => {
+12 -1
View File
@@ -350,6 +350,15 @@ export function formatDate(
});
}
// Backend bucket values are already zone-shifted and stamped with a literal "Z",
// so they must be read back via UTC getters, not the browser's own zone. Values
// without a "Z" (e.g. PropertyDateChart's own bucket keys) are read as-is.
export function parseBackendDate(value: string | number | Date): Date {
return typeof value === 'string' && value.endsWith('Z')
? toZonedTime(new Date(value), 'UTC')
: new Date(value);
}
export function generateTimeSeries(
data: { x: string; y: number; d?: string }[],
minDate: Date,
@@ -371,7 +380,9 @@ export function generateTimeSeries(
current = add(current, 1);
}
const lookup = new Map(data.map(({ x, y, d }) => [formatDate(x, fmt, locale), { x, y, d }]));
const lookup = new Map(
data.map(({ x, y, d }) => [formatDate(parseBackendDate(x), fmt, locale), { x, y, d }]),
);
return timeseries.map(t => {
const { x, y, d } = lookup.get(t) || {};
+48
View File
@@ -82,6 +82,54 @@ describe('getRawQueryClient', () => {
});
});
describe('getDateSQL timezone formatting', () => {
test('formats UTC (default) buckets with an explicit zone and a Z marker', () => {
// Never rely on the DB session's ambient timezone.
expect(prisma.getDateSQL('website_event.created_at', 'hour')).toBe(
`to_char(date_trunc('hour', website_event.created_at at time zone 'UTC'), 'YYYY-MM-DD"T"HH24:00:00"Z"')`,
);
expect(prisma.getDateSQL('website_event.created_at', 'hour', 'UTC')).toBe(
`to_char(date_trunc('hour', website_event.created_at at time zone 'UTC'), 'YYYY-MM-DD"T"HH24:00:00"Z"')`,
);
});
test('formats non-UTC buckets with the same explicit Z marker, not a bare timestamp', () => {
// Bare timestamps get misparsed as local-to-the-runtime, not the source zone.
expect(prisma.getDateSQL('website_event.created_at', 'hour', 'Asia/Tehran')).toBe(
`to_char(date_trunc('hour', website_event.created_at at time zone 'Asia/Tehran'), 'YYYY-MM-DD"T"HH24:00:00"Z"')`,
);
});
});
describe('getDateStringSQL timezone formatting', () => {
test('formats non-UTC second-precision values with an explicit Z marker', () => {
expect(prisma.getDateStringSQL('event_data.date_value', 'second', 'Asia/Tehran')).toBe(
`to_char(event_data.date_value at time zone 'Asia/Tehran', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`,
);
});
test('defaults to explicit UTC formatting regardless of unit when no timezone is given', () => {
expect(prisma.getDateStringSQL('event_data.date_value')).toBe(
`to_char(event_data.date_value at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`,
);
});
});
describe('getDateWeeklySQL timezone formatting', () => {
test('falls back to UTC instead of producing invalid SQL when no timezone is given', () => {
// Regression test: this used to interpolate `undefined` straight into `at time zone`.
expect(prisma.getDateWeeklySQL('website_event.created_at')).toBe(
`concat(extract(dow from (website_event.created_at at time zone 'UTC')), ':', to_char((website_event.created_at at time zone 'UTC'), 'HH24'))`,
);
});
test('uses the given timezone when one is provided', () => {
expect(prisma.getDateWeeklySQL('website_event.created_at', 'Asia/Tehran')).toBe(
`concat(extract(dow from (website_event.created_at at time zone 'Asia/Tehran')), ':', to_char((website_event.created_at at time zone 'Asia/Tehran'), 'HH24'))`,
);
});
});
describe('pagedRawQuery default ordering', () => {
function mockQueries(count = '4') {
const queryRaw = vi.mocked((prisma.client as any).$queryRawUnsafe);
+14 -18
View File
@@ -63,15 +63,9 @@ export function getRawQueryClient(
return client;
}
// Always Z-suffixed so JS parses the already-shifted local value as an unambiguous
// instant instead of re-interpreting it in the runtime's own timezone.
const DATE_FORMATS = {
minute: 'YYYY-MM-DD HH24:MI:00',
hour: 'YYYY-MM-DD HH24:00:00',
day: 'YYYY-MM-DD HH24:00:00',
month: 'YYYY-MM-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"',
@@ -81,7 +75,7 @@ const DATE_FORMATS_UTC = {
const DATE_STRING_FORMATS = {
utc: 'YYYY-MM-DD"T"HH24:MI:SS"Z"',
second: 'YYYY-MM-DD"T"HH24:MI:SS',
second: 'YYYY-MM-DD"T"HH24:MI:SS"Z"',
};
function isUtcTimezone(timezone?: string) {
@@ -101,11 +95,11 @@ function getCastColumnQuery(field: string, type: string): string {
}
function getDateSQL(field: string, unit: string, timezone?: string): string {
if (timezone && !isUtcTimezone(timezone)) {
return `to_char(date_trunc('${unit}', ${field} at time zone '${timezone}'), '${DATE_FORMATS[unit]}')`;
}
// Explicit `at time zone` even for UTC — `date_trunc` without one falls back to the
// DB session's ambient TimeZone setting, which this app never pins.
const tz = timezone && !isUtcTimezone(timezone) ? timezone : 'UTC';
return `to_char(date_trunc('${unit}', ${field}), '${DATE_FORMATS_UTC[unit]}')`;
return `to_char(date_trunc('${unit}', ${field} at time zone '${tz}'), '${DATE_FORMATS[unit]}')`;
}
function getDateStringSQL(
@@ -113,15 +107,17 @@ function getDateStringSQL(
unit: keyof typeof DATE_STRING_FORMATS = 'utc',
timezone?: string,
): string {
if (timezone && !isUtcTimezone(timezone)) {
return `to_char(${field} at time zone '${timezone}', '${DATE_STRING_FORMATS[unit]}')`;
}
const isUtc = !timezone || isUtcTimezone(timezone);
const tz = isUtc ? 'UTC' : timezone;
const format = isUtc ? DATE_STRING_FORMATS.utc : DATE_STRING_FORMATS[unit];
return `to_char(${field}, '${DATE_STRING_FORMATS.utc}')`;
return `to_char(${field} at time zone '${tz}', '${format}')`;
}
function getDateWeeklySQL(field: string, timezone?: string) {
return `concat(extract(dow from (${field} at time zone '${timezone}')), ':', to_char((${field} at time zone '${timezone}'), 'HH24'))`;
const tz = timezone && !isUtcTimezone(timezone) ? timezone : 'UTC';
return `concat(extract(dow from (${field} at time zone '${tz}')), ':', to_char((${field} at time zone '${tz}'), 'HH24'))`;
}
export function getTimestampSQL(field: string) {
+5
View File
@@ -3,6 +3,11 @@ import { fetchWebsite } from '@/lib/load';
import { getWebsiteSegment } from '@/queries/prisma';
import { getQueryFilters } from './request';
vi.hoisted(() => {
process.env.DATABASE_URL ??= 'postgresql://user:pass@localhost:5432/umami?schema=public';
delete process.env.DATABASE_REPLICA_URL;
});
vi.mock('@/lib/load', () => ({
fetchAccount: vi.fn(),
fetchWebsite: vi.fn(),
+4 -1
View File
@@ -133,7 +133,10 @@ async function clickhouseQuery(
user_activities AS (
select distinct
website_event.session_id as session_id,
toInt32((${getDateSQL('created_at', unit, timezone)} - cohort_items.cohort_date) / 86400) as day_number
toInt32(dateDiff('day',
toDate(parseDateTimeBestEffort(cohort_items.cohort_date)),
toDate(parseDateTimeBestEffort(${getDateSQL('created_at', unit, timezone)}))
)) as day_number
from website_event
join cohort_items
on website_event.session_id = cohort_items.session_id