diff --git a/src/components/charts/BarChart.tsx b/src/components/charts/BarChart.tsx index 9bf3f9fc0..8eda99a25 100644 --- a/src/components/charts/BarChart.tsx +++ b/src/components/charts/BarChart.tsx @@ -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, ), diff --git a/src/components/hooks/useDateParameters.test.ts b/src/components/hooks/useDateParameters.test.ts new file mode 100644 index 000000000..5bbbce187 --- /dev/null +++ b/src/components/hooks/useDateParameters.test.ts @@ -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(); + }); +}); diff --git a/src/components/hooks/useDateParameters.ts b/src/components/hooks/useDateParameters.ts index a16949017..2003be62a 100644 --- a/src/components/hooks/useDateParameters.ts +++ b/src/components/hooks/useDateParameters.ts @@ -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); diff --git a/src/lib/clickhouse.test.ts b/src/lib/clickhouse.test.ts index 1fcdedd5f..6c8c7e0b2 100644 --- a/src/lib/clickhouse.test.ts +++ b/src/lib/clickhouse.test.ts @@ -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')`, + ); + }); +}); diff --git a/src/lib/clickhouse.ts b/src/lib/clickhouse.ts index d5fdbc10f..c26444c1d 100644 --- a/src/lib/clickhouse.ts +++ b/src/lib/clickhouse.ts @@ -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 { diff --git a/src/lib/date.test.ts b/src/lib/date.test.ts index 0fa818d20..12b6982cf 100644 --- a/src/lib/date.test.ts +++ b/src/lib/date.test.ts @@ -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', () => { diff --git a/src/lib/date.ts b/src/lib/date.ts index 82fba23f8..0b8bc2ca4 100644 --- a/src/lib/date.ts +++ b/src/lib/date.ts @@ -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) || {}; diff --git a/src/lib/prisma.test.ts b/src/lib/prisma.test.ts index cbb417bea..8052c79e6 100644 --- a/src/lib/prisma.test.ts +++ b/src/lib/prisma.test.ts @@ -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); diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts index 11dc2a189..d07df4bf3 100644 --- a/src/lib/prisma.ts +++ b/src/lib/prisma.ts @@ -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) { diff --git a/src/lib/request.test.ts b/src/lib/request.test.ts index a23550dec..9a955e214 100644 --- a/src/lib/request.test.ts +++ b/src/lib/request.test.ts @@ -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(), diff --git a/src/queries/sql/retention/getRetention.ts b/src/queries/sql/retention/getRetention.ts index a48710381..874dad604 100644 --- a/src/queries/sql/retention/getRetention.ts +++ b/src/queries/sql/retention/getRetention.ts @@ -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