diff --git a/.changeset/drill-range-datetime-tz.md b/.changeset/drill-range-datetime-tz.md new file mode 100644 index 0000000000..a145c09cdc --- /dev/null +++ b/.changeset/drill-range-datetime-tz.md @@ -0,0 +1,25 @@ +--- +'@objectstack/core': minor +'@objectstack/service-analytics': minor +--- + +feat(analytics): scope a datetime date-bucket drill to the reference-tz midnight instants (#1752 follow-up) + +Closes the one gap left by the initial #1752 change: a `datetime` date dimension +bucketed under a **non-UTC reference timezone** previously fell back to a superset +drill (its bucket boundary is that tz's midnight *instant*, which `YYYY-MM-DD` +calendar bounds can't express). + +- **`@objectstack/core`** adds `zonedDateStartToUtcMs(ymd, tz)` — the UTC instant + at which a calendar day begins in a reference timezone (the inverse of + `calendarPartsInTz`). DST-safe: the offset is read from the platform tz + database via `Intl`, with a two-pass resolution for the rare offset-boundary + case; an unset/`'UTC'`/invalid zone returns plain UTC midnight. +- **`@objectstack/service-analytics`** now emits `drillRanges` bounds per the + field's temporal type (ADR-0053): a `datetime` field → ISO **instant** bounds + at the reference tz's midnight (works under any tz, incl. DST); a `date` field + → `YYYY-MM-DD` calendar bounds (tz-naive, exact under any tz). An unknown field + type is still emitted only under UTC and omitted (superset) under a non-UTC tz. + +No objectui change is needed — the client already forwards whatever bound values +the server sends into the drill filter and the `filter[field][gte|lt]` URL. diff --git a/packages/core/src/utils/datetime.test.ts b/packages/core/src/utils/datetime.test.ts new file mode 100644 index 0000000000..577620f070 --- /dev/null +++ b/packages/core/src/utils/datetime.test.ts @@ -0,0 +1,62 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// `zonedDateStartToUtcMs` turns a bucket's first calendar day (in the reference +// timezone) into the UTC instant that day BEGINS — used to scope a `datetime` +// date-bucket drill (#1752). It must be DST-safe: the offset comes from the tz +// database, and midnight must land exactly on the day boundary in that zone. + +import { describe, it, expect } from 'vitest'; +import { zonedDateStartToUtcMs, calendarPartsInTz } from './datetime.js'; + +const iso = (s: string) => Date.parse(s); + +describe('zonedDateStartToUtcMs — exact boundaries', () => { + it('UTC / unset / unknown zone → plain UTC midnight', () => { + expect(zonedDateStartToUtcMs('2026-06-01', 'UTC')).toBe(iso('2026-06-01T00:00:00Z')); + expect(zonedDateStartToUtcMs('2026-06-01')).toBe(iso('2026-06-01T00:00:00Z')); + expect(zonedDateStartToUtcMs('2026-06-01', 'Not/AZone')).toBe(iso('2026-06-01T00:00:00Z')); + }); + + it('fixed positive offset (Asia/Shanghai, +08, no DST)', () => { + // Shanghai June 1 00:00 (+08) === May 31 16:00 UTC. + expect(zonedDateStartToUtcMs('2026-06-01', 'Asia/Shanghai')).toBe(iso('2026-05-31T16:00:00Z')); + }); + + it('DST zone — summer vs winter offset (America/New_York)', () => { + // June → EDT (−04): midnight === 04:00 UTC. + expect(zonedDateStartToUtcMs('2026-06-01', 'America/New_York')).toBe(iso('2026-06-01T04:00:00Z')); + // January → EST (−05): midnight === 05:00 UTC. + expect(zonedDateStartToUtcMs('2026-01-01', 'America/New_York')).toBe(iso('2026-01-01T05:00:00Z')); + }); + + it('DST transition days — midnight is before the 2am switch, so uses the pre-switch offset', () => { + // Spring-forward day (2026-03-08): midnight still EST (−05). + expect(zonedDateStartToUtcMs('2026-03-08', 'America/New_York')).toBe(iso('2026-03-08T05:00:00Z')); + // Fall-back day (2026-11-01): midnight still EDT (−04). + expect(zonedDateStartToUtcMs('2026-11-01', 'America/New_York')).toBe(iso('2026-11-01T04:00:00Z')); + }); + + it('unparseable input → NaN', () => { + expect(Number.isNaN(zonedDateStartToUtcMs('2026-06', 'UTC'))).toBe(true); + expect(Number.isNaN(zonedDateStartToUtcMs('nope', 'America/New_York'))).toBe(true); + }); +}); + +describe('zonedDateStartToUtcMs — round-trips to the day boundary in the zone', () => { + const cases: Array<[string, string]> = [ + ['2026-06-01', 'Asia/Shanghai'], + ['2026-06-01', 'America/New_York'], + ['2026-01-01', 'America/New_York'], + ['2026-02-15', 'UTC'], + ]; + for (const [ymd, tz] of cases) { + it(`${ymd} @ ${tz}: the instant is that calendar day, and 1ms earlier is the previous day`, () => { + const ms = zonedDateStartToUtcMs(ymd, tz); + const [y, m, d] = ymd.split('-').map(Number); + // The instant falls on the target calendar day in `tz`… + expect(calendarPartsInTz(new Date(ms), tz)).toEqual({ year: y, month: m, day: d }); + // …and one millisecond earlier is the day before → it is exactly midnight. + expect(calendarPartsInTz(new Date(ms - 1), tz)).not.toEqual({ year: y, month: m, day: d }); + }); + } +}); diff --git a/packages/core/src/utils/datetime.ts b/packages/core/src/utils/datetime.ts index e08230e4e8..08791cba0a 100644 --- a/packages/core/src/utils/datetime.ts +++ b/packages/core/src/utils/datetime.ts @@ -59,6 +59,49 @@ export function calendarPartsInTzOrUtc(d: Date, tz?: string): CalendarParts { }; } +/** + * The UTC instant (epoch ms) at which calendar day `ymd` (`YYYY-MM-DD`) *begins* + * in reference timezone `tz` — i.e. local **midnight** of that day rendered as a + * UTC instant. The inverse direction of {@link calendarPartsInTz}. + * + * DST-safe: the zone offset is read from the platform tz database via + * `Intl.DateTimeFormat` (never hand-computed), and a two-pass resolution settles + * the rare case where the offset differs side-to-side of the target instant. An + * unset, `'UTC'`, invalid, or unparseable input returns plain UTC midnight. + * + * Used by date-bucket drill ranges (#1752): a `datetime` field buckets on the + * reference-tz calendar, so its bucket boundary is that tz's midnight instant. + */ +export function zonedDateStartToUtcMs(ymd: string, tz?: string): number { + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(ymd); + const wallAsUtc = m ? Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])) : NaN; + if (!tz || tz === 'UTC' || Number.isNaN(wallAsUtc)) return wallAsUtc; + try { + // The tz offset (local − UTC, in ms) at instant `t`: read t's wall clock in + // `tz`, re-interpret those parts as UTC, and subtract t. + const offsetAt = (t: number): number => { + const p = new Intl.DateTimeFormat('en-US', { + timeZone: tz, + hourCycle: 'h23', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }).formatToParts(new Date(t)); + const g = (k: string) => Number(p.find((x) => x.type === k)?.value); + return Date.UTC(g('year'), g('month') - 1, g('day'), g('hour'), g('minute'), g('second')) - t; + }; + // Want U such that localParts(U) == midnight, i.e. U = wallAsUtc − offset(U). + // Iterate from the zero-offset guess; converges in ≤2 steps off a DST edge. + const off1 = offsetAt(wallAsUtc - offsetAt(wallAsUtc)); + return wallAsUtc - off1; + } catch { + return wallAsUtc; // unknown zone → UTC midnight + } +} + /** * Granularity of a canonical date-bucket key. Mirrors `@objectstack/spec`'s * `DateGranularity` enum but kept as a local literal union so this low-level diff --git a/packages/services/service-analytics/src/__tests__/query-dataset.test.ts b/packages/services/service-analytics/src/__tests__/query-dataset.test.ts index ed3ac64227..6f7f811075 100644 --- a/packages/services/service-analytics/src/__tests__/query-dataset.test.ts +++ b/packages/services/service-analytics/src/__tests__/query-dataset.test.ts @@ -247,7 +247,7 @@ describe('AnalyticsService.queryDataset', () => { ]); }); - it('#1752 — omits the range for a datetime field under a non-UTC reference tz (superset fallback)', async () => { + it('#1752 — a datetime field under a non-UTC reference tz drills the tz midnight instants', async () => { const q = DatasetSchema.parse({ name: 'sales_dt', label: 'Sales', object: 'opportunity', include: [], dimensions: [{ name: 'closed_at', field: 'closed_at', type: 'date', dateGranularity: 'month' }], @@ -257,7 +257,8 @@ describe('AnalyticsService.queryDataset', () => { queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), executeAggregate: async () => [{ closed_at: '2026-06', revenue: 100 }], // closed_at is a datetime instant → its month bucket boundary is that tz's - // midnight, which YYYY-MM-DD calendar bounds can't express → omit (superset). + // MIDNIGHT INSTANT. June/July 2026 in New York are EDT (−04), so local + // midnight is 04:00 UTC. measureCurrency: (_o, f) => (f === 'closed_at' ? { type: 'datetime' } : undefined), getReadScope: (_o, ctx?: ExecutionContext) => (ctx?.tenantId ? { organization_id: ctx.tenantId } : undefined), }); @@ -266,8 +267,10 @@ describe('AnalyticsService.queryDataset', () => { { dimensions: ['closed_at'], measures: ['revenue'], timezone: 'America/New_York' }, { tenantId: 'org_A' } as ExecutionContext, ) as any; - expect(result.drillRanges).toBeUndefined(); - expect(result.object).toBeUndefined(); + expect(result.object).toBe('opportunity'); + expect(result.drillRanges).toEqual([ + { closed_at: { field: 'closed_at', gte: '2026-06-01T04:00:00.000Z', lt: '2026-07-01T04:00:00.000Z' } }, + ]); }); it('#1752 — still emits the range for a tz-naive date field under a non-UTC tz', async () => { diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index 221fdf01f2..9a01611aa9 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -11,7 +11,7 @@ import type { Cube, FilterCondition } from '@objectstack/spec/data'; import type { ExecutionContext } from '@objectstack/spec/kernel'; import type { Dataset } from '@objectstack/spec/ui'; import type { Logger } from '@objectstack/spec/contracts'; -import { createLogger, bucketKeyToCalendarRange } from '@objectstack/core'; +import { createLogger, bucketKeyToCalendarRange, zonedDateStartToUtcMs } from '@objectstack/core'; import { CubeRegistry } from './cube-registry.js'; import type { AnalyticsStrategy, DriverCapabilities, StrategyContext } from './strategies/types.js'; import { NativeSQLStrategy } from './strategies/native-sql-strategy.js'; @@ -558,27 +558,34 @@ export class AnalyticsService implements IAnalyticsService { // still in `rows[i][dim]` here (this runs BEFORE label resolution rewrites // it to a display label). const rangeTz = selection.timezone ?? context?.timezone ?? 'UTC'; - const dateDims = selectedDims.filter( - (d) => !!d.field && d.type === 'date' && !!d.dateGranularity, - ); - // A tz-naive `date` field's calendar bounds are timezone-independent, so - // they're exact under ANY reference tz. A `datetime` field buckets on the - // reference tz's calendar — its boundaries are that tz's midnight instants, - // which YYYY-MM-DD calendar bounds capture only under UTC. Under a non-UTC - // tz we therefore emit ranges only for `date` fields and let `datetime` - // dims fall back to a superset drill (host omits the range). - const rangeDims = - rangeTz === 'UTC' - ? dateDims - : dateDims.filter( - (d) => this.measureCurrency?.(dataset.object, d.field as string)?.type === 'date', - ); + // Per drillable date+granularity dim, decide how to serialize its bounds + // (ADR-0053 temporal semantics): + // - `datetime` → the reference tz's MIDNIGHT INSTANT (ISO), because the + // bucket is defined on that tz's calendar (works under any tz, incl. DST); + // - `date` → `YYYY-MM-DD` calendar bounds, a tz-naive calendar day that is + // exact under ANY reference tz; + // - unknown field type → safe only under UTC (where the calendar day and + // its instant coincide); under a non-UTC tz we can't tell whether to + // shift, so the dim is omitted and the host drills a superset. + const rangeDims: Array<{ d: (typeof selectedDims)[number]; instant: boolean }> = []; + for (const d of selectedDims) { + if (!d.field || d.type !== 'date' || !d.dateGranularity) continue; + const ftype = this.measureCurrency?.(dataset.object, d.field as string)?.type; + if (ftype === 'datetime') rangeDims.push({ d, instant: true }); + else if (ftype === 'date') rangeDims.push({ d, instant: false }); + else if (rangeTz === 'UTC') rangeDims.push({ d, instant: false }); + // else: unknown field type under a non-UTC reference tz → omit (superset). + } if (rangeDims.length && result.rows.length) { + const bound = (ymd: string, instant: boolean): string => + instant ? new Date(zonedDateStartToUtcMs(ymd, rangeTz)).toISOString() : ymd; (result as AnalyticsResultWithDrill).drillRanges = result.rows.map((row) => { const ranges: Record = {}; - for (const d of rangeDims) { + for (const { d, instant } of rangeDims) { const cal = bucketKeyToCalendarRange(row[d.name] as string, d.dateGranularity!); - if (cal) ranges[d.name] = { field: d.field as string, gte: cal.start, lt: cal.end }; + if (cal) { + ranges[d.name] = { field: d.field as string, gte: bound(cal.start, instant), lt: bound(cal.end, instant) }; + } } return ranges; });