Skip to content

Commit dd9f223

Browse files
os-zhuangclaude
andauthored
feat(analytics): datetime date-bucket drill uses reference-tz midnight instants (#1752 follow-up) (#3272)
A `datetime` date dimension bucketed under a non-UTC reference tz 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). Emit those instants instead, closing the last gap from the initial #1752 change. - core: add `zonedDateStartToUtcMs(ymd, tz)` — the UTC instant a calendar day begins in a reference tz (inverse of calendarPartsInTz). DST-safe via Intl with a two-pass offset resolution; unset/UTC/invalid zone → plain UTC midnight. - service-analytics: emit drillRanges bounds by field temporal type (ADR-0053) — datetime → ISO instant at the tz's midnight (any tz, incl. DST); date → YYYY-MM-DD calendar (tz-naive). Unknown type stays UTC-only. objectui needs no change — the client already forwards arbitrary bound values into the drill filter and the filter[field][gte|lt] URL. Claude-Session: https://claude.ai/code/session_01RCXBnmMQTwjmFkjtpEe11f Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4b659d0 commit dd9f223

5 files changed

Lines changed: 162 additions & 22 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@objectstack/core': minor
3+
'@objectstack/service-analytics': minor
4+
---
5+
6+
feat(analytics): scope a datetime date-bucket drill to the reference-tz midnight instants (#1752 follow-up)
7+
8+
Closes the one gap left by the initial #1752 change: a `datetime` date dimension
9+
bucketed under a **non-UTC reference timezone** previously fell back to a superset
10+
drill (its bucket boundary is that tz's midnight *instant*, which `YYYY-MM-DD`
11+
calendar bounds can't express).
12+
13+
- **`@objectstack/core`** adds `zonedDateStartToUtcMs(ymd, tz)` — the UTC instant
14+
at which a calendar day begins in a reference timezone (the inverse of
15+
`calendarPartsInTz`). DST-safe: the offset is read from the platform tz
16+
database via `Intl`, with a two-pass resolution for the rare offset-boundary
17+
case; an unset/`'UTC'`/invalid zone returns plain UTC midnight.
18+
- **`@objectstack/service-analytics`** now emits `drillRanges` bounds per the
19+
field's temporal type (ADR-0053): a `datetime` field → ISO **instant** bounds
20+
at the reference tz's midnight (works under any tz, incl. DST); a `date` field
21+
`YYYY-MM-DD` calendar bounds (tz-naive, exact under any tz). An unknown field
22+
type is still emitted only under UTC and omitted (superset) under a non-UTC tz.
23+
24+
No objectui change is needed — the client already forwards whatever bound values
25+
the server sends into the drill filter and the `filter[field][gte|lt]` URL.
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// `zonedDateStartToUtcMs` turns a bucket's first calendar day (in the reference
4+
// timezone) into the UTC instant that day BEGINS — used to scope a `datetime`
5+
// date-bucket drill (#1752). It must be DST-safe: the offset comes from the tz
6+
// database, and midnight must land exactly on the day boundary in that zone.
7+
8+
import { describe, it, expect } from 'vitest';
9+
import { zonedDateStartToUtcMs, calendarPartsInTz } from './datetime.js';
10+
11+
const iso = (s: string) => Date.parse(s);
12+
13+
describe('zonedDateStartToUtcMs — exact boundaries', () => {
14+
it('UTC / unset / unknown zone → plain UTC midnight', () => {
15+
expect(zonedDateStartToUtcMs('2026-06-01', 'UTC')).toBe(iso('2026-06-01T00:00:00Z'));
16+
expect(zonedDateStartToUtcMs('2026-06-01')).toBe(iso('2026-06-01T00:00:00Z'));
17+
expect(zonedDateStartToUtcMs('2026-06-01', 'Not/AZone')).toBe(iso('2026-06-01T00:00:00Z'));
18+
});
19+
20+
it('fixed positive offset (Asia/Shanghai, +08, no DST)', () => {
21+
// Shanghai June 1 00:00 (+08) === May 31 16:00 UTC.
22+
expect(zonedDateStartToUtcMs('2026-06-01', 'Asia/Shanghai')).toBe(iso('2026-05-31T16:00:00Z'));
23+
});
24+
25+
it('DST zone — summer vs winter offset (America/New_York)', () => {
26+
// June → EDT (−04): midnight === 04:00 UTC.
27+
expect(zonedDateStartToUtcMs('2026-06-01', 'America/New_York')).toBe(iso('2026-06-01T04:00:00Z'));
28+
// January → EST (−05): midnight === 05:00 UTC.
29+
expect(zonedDateStartToUtcMs('2026-01-01', 'America/New_York')).toBe(iso('2026-01-01T05:00:00Z'));
30+
});
31+
32+
it('DST transition days — midnight is before the 2am switch, so uses the pre-switch offset', () => {
33+
// Spring-forward day (2026-03-08): midnight still EST (−05).
34+
expect(zonedDateStartToUtcMs('2026-03-08', 'America/New_York')).toBe(iso('2026-03-08T05:00:00Z'));
35+
// Fall-back day (2026-11-01): midnight still EDT (−04).
36+
expect(zonedDateStartToUtcMs('2026-11-01', 'America/New_York')).toBe(iso('2026-11-01T04:00:00Z'));
37+
});
38+
39+
it('unparseable input → NaN', () => {
40+
expect(Number.isNaN(zonedDateStartToUtcMs('2026-06', 'UTC'))).toBe(true);
41+
expect(Number.isNaN(zonedDateStartToUtcMs('nope', 'America/New_York'))).toBe(true);
42+
});
43+
});
44+
45+
describe('zonedDateStartToUtcMs — round-trips to the day boundary in the zone', () => {
46+
const cases: Array<[string, string]> = [
47+
['2026-06-01', 'Asia/Shanghai'],
48+
['2026-06-01', 'America/New_York'],
49+
['2026-01-01', 'America/New_York'],
50+
['2026-02-15', 'UTC'],
51+
];
52+
for (const [ymd, tz] of cases) {
53+
it(`${ymd} @ ${tz}: the instant is that calendar day, and 1ms earlier is the previous day`, () => {
54+
const ms = zonedDateStartToUtcMs(ymd, tz);
55+
const [y, m, d] = ymd.split('-').map(Number);
56+
// The instant falls on the target calendar day in `tz`…
57+
expect(calendarPartsInTz(new Date(ms), tz)).toEqual({ year: y, month: m, day: d });
58+
// …and one millisecond earlier is the day before → it is exactly midnight.
59+
expect(calendarPartsInTz(new Date(ms - 1), tz)).not.toEqual({ year: y, month: m, day: d });
60+
});
61+
}
62+
});

packages/core/src/utils/datetime.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,49 @@ export function calendarPartsInTzOrUtc(d: Date, tz?: string): CalendarParts {
5959
};
6060
}
6161

62+
/**
63+
* The UTC instant (epoch ms) at which calendar day `ymd` (`YYYY-MM-DD`) *begins*
64+
* in reference timezone `tz` — i.e. local **midnight** of that day rendered as a
65+
* UTC instant. The inverse direction of {@link calendarPartsInTz}.
66+
*
67+
* DST-safe: the zone offset is read from the platform tz database via
68+
* `Intl.DateTimeFormat` (never hand-computed), and a two-pass resolution settles
69+
* the rare case where the offset differs side-to-side of the target instant. An
70+
* unset, `'UTC'`, invalid, or unparseable input returns plain UTC midnight.
71+
*
72+
* Used by date-bucket drill ranges (#1752): a `datetime` field buckets on the
73+
* reference-tz calendar, so its bucket boundary is that tz's midnight instant.
74+
*/
75+
export function zonedDateStartToUtcMs(ymd: string, tz?: string): number {
76+
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(ymd);
77+
const wallAsUtc = m ? Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])) : NaN;
78+
if (!tz || tz === 'UTC' || Number.isNaN(wallAsUtc)) return wallAsUtc;
79+
try {
80+
// The tz offset (local − UTC, in ms) at instant `t`: read t's wall clock in
81+
// `tz`, re-interpret those parts as UTC, and subtract t.
82+
const offsetAt = (t: number): number => {
83+
const p = new Intl.DateTimeFormat('en-US', {
84+
timeZone: tz,
85+
hourCycle: 'h23',
86+
year: 'numeric',
87+
month: '2-digit',
88+
day: '2-digit',
89+
hour: '2-digit',
90+
minute: '2-digit',
91+
second: '2-digit',
92+
}).formatToParts(new Date(t));
93+
const g = (k: string) => Number(p.find((x) => x.type === k)?.value);
94+
return Date.UTC(g('year'), g('month') - 1, g('day'), g('hour'), g('minute'), g('second')) - t;
95+
};
96+
// Want U such that localParts(U) == midnight, i.e. U = wallAsUtc − offset(U).
97+
// Iterate from the zero-offset guess; converges in ≤2 steps off a DST edge.
98+
const off1 = offsetAt(wallAsUtc - offsetAt(wallAsUtc));
99+
return wallAsUtc - off1;
100+
} catch {
101+
return wallAsUtc; // unknown zone → UTC midnight
102+
}
103+
}
104+
62105
/**
63106
* Granularity of a canonical date-bucket key. Mirrors `@objectstack/spec`'s
64107
* `DateGranularity` enum but kept as a local literal union so this low-level

packages/services/service-analytics/src/__tests__/query-dataset.test.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ describe('AnalyticsService.queryDataset', () => {
247247
]);
248248
});
249249

250-
it('#1752 — omits the range for a datetime field under a non-UTC reference tz (superset fallback)', async () => {
250+
it('#1752 — a datetime field under a non-UTC reference tz drills the tz midnight instants', async () => {
251251
const q = DatasetSchema.parse({
252252
name: 'sales_dt', label: 'Sales', object: 'opportunity', include: [],
253253
dimensions: [{ name: 'closed_at', field: 'closed_at', type: 'date', dateGranularity: 'month' }],
@@ -257,7 +257,8 @@ describe('AnalyticsService.queryDataset', () => {
257257
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }),
258258
executeAggregate: async () => [{ closed_at: '2026-06', revenue: 100 }],
259259
// closed_at is a datetime instant → its month bucket boundary is that tz's
260-
// midnight, which YYYY-MM-DD calendar bounds can't express → omit (superset).
260+
// MIDNIGHT INSTANT. June/July 2026 in New York are EDT (−04), so local
261+
// midnight is 04:00 UTC.
261262
measureCurrency: (_o, f) => (f === 'closed_at' ? { type: 'datetime' } : undefined),
262263
getReadScope: (_o, ctx?: ExecutionContext) => (ctx?.tenantId ? { organization_id: ctx.tenantId } : undefined),
263264
});
@@ -266,8 +267,10 @@ describe('AnalyticsService.queryDataset', () => {
266267
{ dimensions: ['closed_at'], measures: ['revenue'], timezone: 'America/New_York' },
267268
{ tenantId: 'org_A' } as ExecutionContext,
268269
) as any;
269-
expect(result.drillRanges).toBeUndefined();
270-
expect(result.object).toBeUndefined();
270+
expect(result.object).toBe('opportunity');
271+
expect(result.drillRanges).toEqual([
272+
{ closed_at: { field: 'closed_at', gte: '2026-06-01T04:00:00.000Z', lt: '2026-07-01T04:00:00.000Z' } },
273+
]);
271274
});
272275

273276
it('#1752 — still emits the range for a tz-naive date field under a non-UTC tz', async () => {

packages/services/service-analytics/src/analytics-service.ts

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import type { Cube, FilterCondition } from '@objectstack/spec/data';
1111
import type { ExecutionContext } from '@objectstack/spec/kernel';
1212
import type { Dataset } from '@objectstack/spec/ui';
1313
import type { Logger } from '@objectstack/spec/contracts';
14-
import { createLogger, bucketKeyToCalendarRange } from '@objectstack/core';
14+
import { createLogger, bucketKeyToCalendarRange, zonedDateStartToUtcMs } from '@objectstack/core';
1515
import { CubeRegistry } from './cube-registry.js';
1616
import type { AnalyticsStrategy, DriverCapabilities, StrategyContext } from './strategies/types.js';
1717
import { NativeSQLStrategy } from './strategies/native-sql-strategy.js';
@@ -558,27 +558,34 @@ export class AnalyticsService implements IAnalyticsService {
558558
// still in `rows[i][dim]` here (this runs BEFORE label resolution rewrites
559559
// it to a display label).
560560
const rangeTz = selection.timezone ?? context?.timezone ?? 'UTC';
561-
const dateDims = selectedDims.filter(
562-
(d) => !!d.field && d.type === 'date' && !!d.dateGranularity,
563-
);
564-
// A tz-naive `date` field's calendar bounds are timezone-independent, so
565-
// they're exact under ANY reference tz. A `datetime` field buckets on the
566-
// reference tz's calendar — its boundaries are that tz's midnight instants,
567-
// which YYYY-MM-DD calendar bounds capture only under UTC. Under a non-UTC
568-
// tz we therefore emit ranges only for `date` fields and let `datetime`
569-
// dims fall back to a superset drill (host omits the range).
570-
const rangeDims =
571-
rangeTz === 'UTC'
572-
? dateDims
573-
: dateDims.filter(
574-
(d) => this.measureCurrency?.(dataset.object, d.field as string)?.type === 'date',
575-
);
561+
// Per drillable date+granularity dim, decide how to serialize its bounds
562+
// (ADR-0053 temporal semantics):
563+
// - `datetime` → the reference tz's MIDNIGHT INSTANT (ISO), because the
564+
// bucket is defined on that tz's calendar (works under any tz, incl. DST);
565+
// - `date` → `YYYY-MM-DD` calendar bounds, a tz-naive calendar day that is
566+
// exact under ANY reference tz;
567+
// - unknown field type → safe only under UTC (where the calendar day and
568+
// its instant coincide); under a non-UTC tz we can't tell whether to
569+
// shift, so the dim is omitted and the host drills a superset.
570+
const rangeDims: Array<{ d: (typeof selectedDims)[number]; instant: boolean }> = [];
571+
for (const d of selectedDims) {
572+
if (!d.field || d.type !== 'date' || !d.dateGranularity) continue;
573+
const ftype = this.measureCurrency?.(dataset.object, d.field as string)?.type;
574+
if (ftype === 'datetime') rangeDims.push({ d, instant: true });
575+
else if (ftype === 'date') rangeDims.push({ d, instant: false });
576+
else if (rangeTz === 'UTC') rangeDims.push({ d, instant: false });
577+
// else: unknown field type under a non-UTC reference tz → omit (superset).
578+
}
576579
if (rangeDims.length && result.rows.length) {
580+
const bound = (ymd: string, instant: boolean): string =>
581+
instant ? new Date(zonedDateStartToUtcMs(ymd, rangeTz)).toISOString() : ymd;
577582
(result as AnalyticsResultWithDrill).drillRanges = result.rows.map((row) => {
578583
const ranges: Record<string, { field: string; gte: string; lt: string }> = {};
579-
for (const d of rangeDims) {
584+
for (const { d, instant } of rangeDims) {
580585
const cal = bucketKeyToCalendarRange(row[d.name] as string, d.dateGranularity!);
581-
if (cal) ranges[d.name] = { field: d.field as string, gte: cal.start, lt: cal.end };
586+
if (cal) {
587+
ranges[d.name] = { field: d.field as string, gte: bound(cal.start, instant), lt: bound(cal.end, instant) };
588+
}
582589
}
583590
return ranges;
584591
});

0 commit comments

Comments
 (0)