Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .changeset/drill-range-datetime-tz.md
Original file line number Diff line number Diff line change
@@ -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.
62 changes: 62 additions & 0 deletions packages/core/src/utils/datetime.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
}
});
43 changes: 43 additions & 0 deletions packages/core/src/utils/datetime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' }],
Expand All @@ -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),
});
Expand All @@ -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 () => {
Expand Down
43 changes: 25 additions & 18 deletions packages/services/service-analytics/src/analytics-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string, { field: string; gte: string; lt: string }> = {};
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;
});
Expand Down