This commit is contained in:
1 parent
c411c2043f
commit
587bfafb47
11 files changed
+226
-29
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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) || {};
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user