Skip to content

Commit 290e2f0

Browse files
os-zhuangclaude
andauthored
feat(analytics): date-range drill scope for granularity-bucketed date dimensions (#1752) (#3256)
A report/dashboard cell grouped by a `dateGranularity` date dimension ("2026-Q2") covers a SPAN of records, so drilling it needs a range (>= start AND < nextStart), which the equality drill contract (drillRawRows) can't express — date dims were excluded from drill metadata, so a drill landed on an unscoped superset. - core: add `bucketKeyToCalendarRange(key, granularity)`, the inverse of `bucketDateValue` — canonical bucket key → half-open [start, end) YYYY-MM-DD span. Pure, tz-naive calendar math; null for unbucketable/out-of-range keys. - service-analytics: emit a `drillRanges` sidecar (row-aligned, the range companion to drillRawRows) for date+granularity dims from the canonical key in the pre-label snapshot pass. datetime under a non-UTC tz is omitted (superset) until instant bounds land; a tz-naive date field is exact under any tz (ADR-0053). Round-trip parity test pins bucketKeyToCalendarRange to bucketDateValue. Claude-Session: https://claude.ai/code/session_01RCXBnmMQTwjmFkjtpEe11f Co-authored-by: Claude <noreply@anthropic.com>
1 parent 611402f commit 290e2f0

5 files changed

Lines changed: 385 additions & 1 deletion

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/core': minor
3+
'@objectstack/service-analytics': minor
4+
---
5+
6+
feat(analytics): emit a half-open date-range drill scope for granularity-bucketed date dimensions (#1752)
7+
8+
A report/dashboard cell grouped by a `dateGranularity` date dimension ("2026-Q2")
9+
covers a SPAN of records, so drilling it needs a range (`>= start AND < nextStart`),
10+
which the equality drill contract (`drillRawRows`) can't express — date dims were
11+
therefore excluded from drill metadata and a drill landed on an unscoped superset.
12+
13+
- **`@objectstack/core`** adds `bucketKeyToCalendarRange(key, granularity)`, the
14+
inverse of `bucketDateValue`: it turns a canonical bucket key into its half-open
15+
`[start, end)` calendar span (`YYYY-MM-DD`, `end` exclusive). Pure, timezone-naive
16+
calendar arithmetic; returns `null` for unbucketable / out-of-range keys so the
17+
caller falls back to an unscoped (superset) drill rather than emit a wrong bound.
18+
- **`@objectstack/service-analytics`** emits a `drillRanges` sidecar (aligned to
19+
`rows` by index — the range companion to `drillRawRows`) for `date` +
20+
`dateGranularity` dimensions, computed from the canonical bucket key in the
21+
pre-label-resolution snapshot pass. A `datetime` field under a non-UTC reference
22+
timezone is omitted (host drills a superset) until instant-boundary support
23+
lands; a tz-naive `date` field is exact under any timezone (ADR-0053).
24+
25+
Consumed by objectui's report drill-through to scope the drilled record list to the
26+
clicked time bucket.

packages/core/src/utils/datetime.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,120 @@ export function calendarPartsInTzOrUtc(d: Date, tz?: string): CalendarParts {
5858
day: d.getUTCDate(),
5959
};
6060
}
61+
62+
/**
63+
* Granularity of a canonical date-bucket key. Mirrors `@objectstack/spec`'s
64+
* `DateGranularity` enum but kept as a local literal union so this low-level
65+
* package needs no dependency on spec.
66+
*/
67+
export type BucketGranularity = 'day' | 'week' | 'month' | 'quarter' | 'year';
68+
69+
/**
70+
* ISO-8601 week label (Mon-start weeks, week 1 = the week of the first
71+
* Thursday) of a UTC calendar day. The forward-direction companion used to
72+
* *validate* a reconstructed week boundary; it mirrors the week branch of
73+
* `@objectstack/objectql`'s `bucketDateValue` (kept in lockstep by the
74+
* round-trip parity test in objectql).
75+
*/
76+
function isoWeekLabelUtc(d: Date): string {
77+
const target = new Date(d.getTime());
78+
const dayNum = (target.getUTCDay() + 6) % 7; // Mon=0..Sun=6
79+
target.setUTCDate(target.getUTCDate() - dayNum + 3); // shift to that week's Thursday
80+
const firstThursday = new Date(Date.UTC(target.getUTCFullYear(), 0, 4));
81+
const weekNo =
82+
1 +
83+
Math.round(
84+
((target.getTime() - firstThursday.getTime()) / 86400000 -
85+
3 +
86+
((firstThursday.getUTCDay() + 6) % 7)) /
87+
7,
88+
);
89+
return `${target.getUTCFullYear()}-W${String(weekNo).padStart(2, '0')}`;
90+
}
91+
92+
/**
93+
* The half-open calendar span `[start, end)` of a canonical date-bucket KEY,
94+
* as `YYYY-MM-DD` strings (`start` inclusive, `end` exclusive — the next
95+
* bucket's first day).
96+
*
97+
* The input MUST be the canonical key produced by `bucketDateValue` /
98+
* `buildDateBucketExpr` (`2026`, `2026-Q2`, `2026-06`, `2026-06-15`,
99+
* `2026-W23`) — NEVER a localized / humanized display label. The span is pure,
100+
* timezone-naive calendar arithmetic; a caller that needs instant bounds for a
101+
* `datetime` field in a reference timezone layers that on top (and, per
102+
* ADR-0053, a `date` field compares against these `YYYY-MM-DD` bounds directly).
103+
*
104+
* Returns `null` for the null/empty bucket, an unparseable key, or a key that
105+
* is shape-valid but out of range (e.g. `2026-13`, a `-W53` in a 52-week year,
106+
* `2026-02-30`). Callers drop the range and fall back to an unscoped (superset)
107+
* drill rather than emit a wrong bound.
108+
*/
109+
export function bucketKeyToCalendarRange(
110+
key: string,
111+
granularity: BucketGranularity,
112+
): { start: string; end: string } | null {
113+
if (typeof key !== 'string' || key.length === 0) return null;
114+
const fmt = (dt: Date) =>
115+
`${String(dt.getUTCFullYear()).padStart(4, '0')}-${String(dt.getUTCMonth() + 1).padStart(
116+
2,
117+
'0',
118+
)}-${String(dt.getUTCDate()).padStart(2, '0')}`;
119+
120+
switch (granularity) {
121+
case 'year': {
122+
const m = /^(\d{4})$/.exec(key);
123+
if (!m) return null;
124+
const y = Number(m[1]);
125+
return { start: fmt(new Date(Date.UTC(y, 0, 1))), end: fmt(new Date(Date.UTC(y + 1, 0, 1))) };
126+
}
127+
case 'quarter': {
128+
const m = /^(\d{4})-Q([1-4])$/.exec(key);
129+
if (!m) return null;
130+
const y = Number(m[1]);
131+
const startMonth = (Number(m[2]) - 1) * 3; // Q1→0, Q2→3, Q3→6, Q4→9
132+
return {
133+
start: fmt(new Date(Date.UTC(y, startMonth, 1))),
134+
end: fmt(new Date(Date.UTC(y, startMonth + 3, 1))), // Date.UTC rolls Q4 into next year
135+
};
136+
}
137+
case 'month': {
138+
const m = /^(\d{4})-(\d{2})$/.exec(key);
139+
if (!m) return null;
140+
const mo = Number(m[2]);
141+
if (mo < 1 || mo > 12) return null;
142+
const y = Number(m[1]);
143+
return {
144+
start: fmt(new Date(Date.UTC(y, mo - 1, 1))),
145+
end: fmt(new Date(Date.UTC(y, mo, 1))),
146+
};
147+
}
148+
case 'day': {
149+
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(key);
150+
if (!m) return null;
151+
const y = Number(m[1]);
152+
const mo = Number(m[2]);
153+
const d = Number(m[3]);
154+
const start = new Date(Date.UTC(y, mo - 1, d));
155+
if (fmt(start) !== key) return null; // reject an impossible day that rolled over
156+
return { start: key, end: fmt(new Date(Date.UTC(y, mo - 1, d + 1))) };
157+
}
158+
case 'week': {
159+
const m = /^(\d{4})-W(\d{2})$/.exec(key);
160+
if (!m) return null;
161+
const isoYear = Number(m[1]);
162+
const week = Number(m[2]);
163+
if (week < 1 || week > 53) return null;
164+
// Monday of ISO week 1 is the Monday on/before Jan 4; add (week-1) weeks.
165+
const jan4 = new Date(Date.UTC(isoYear, 0, 4));
166+
const jan4Dow = (jan4.getUTCDay() + 6) % 7; // Mon=0..Sun=6
167+
const start = new Date(jan4.getTime());
168+
start.setUTCDate(jan4.getUTCDate() - jan4Dow + (week - 1) * 7);
169+
if (isoWeekLabelUtc(start) !== key) return null; // reject -W53 overflow etc.
170+
const end = new Date(start.getTime());
171+
end.setUTCDate(start.getUTCDate() + 7);
172+
return { start: fmt(start), end: fmt(end) };
173+
}
174+
default:
175+
return null;
176+
}
177+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Round-trip parity: `bucketKeyToCalendarRange` (@objectstack/core) is the
4+
// INVERSE of `bucketDateValue` (this package). A drill on a date-bucketed
5+
// report cell turns the bucket KEY back into the half-open `[start, end)` span
6+
// of records that fall in it, so the two MUST agree on every boundary — any
7+
// drift silently mis-scopes the drilled record list. This test pins them
8+
// together (mirrors the driver-sql `buildDateBucketExpr` parity harness).
9+
10+
import { describe, it, expect } from 'vitest';
11+
import { bucketKeyToCalendarRange, type BucketGranularity } from '@objectstack/core';
12+
import { bucketDateValue } from './in-memory-aggregation.js';
13+
14+
const DAY_MS = 86_400_000;
15+
const utcMidnight = (ymd: string) => Date.parse(`${ymd}T00:00:00.000Z`);
16+
17+
// Instants chosen to hit cross-year / cross-quarter / cross-month and the ISO
18+
// week boundary (2024-12-30 is ISO week 2025-W01). Same spirit as the driver
19+
// parity fixture.
20+
const INSTANTS = [
21+
'2024-01-15T10:00:00Z',
22+
'2024-04-01T00:00:00Z',
23+
'2024-06-30T23:59:59Z',
24+
'2024-07-01T00:00:00Z',
25+
'2024-12-30T12:00:00Z', // ISO 2025-W01
26+
'2025-01-01T00:00:00Z',
27+
'2025-05-19T09:00:00Z',
28+
'2026-02-15T00:00:00Z',
29+
'2026-12-31T23:00:00Z',
30+
];
31+
const GRANULARITIES: BucketGranularity[] = ['day', 'week', 'month', 'quarter', 'year'];
32+
33+
describe('bucketKeyToCalendarRange ↔ bucketDateValue round-trip (UTC)', () => {
34+
for (const iso of INSTANTS) {
35+
for (const g of GRANULARITIES) {
36+
it(`${iso} @ ${g}`, () => {
37+
const d = new Date(iso);
38+
const key = bucketDateValue(d, g); // no tz → UTC key
39+
const range = bucketKeyToCalendarRange(key, g);
40+
expect(range, `range for key ${key}`).not.toBeNull();
41+
const startMs = utcMidnight(range!.start);
42+
const endMs = utcMidnight(range!.end);
43+
44+
// the instant lies inside the half-open span
45+
expect(startMs).toBeLessThanOrEqual(d.getTime());
46+
expect(d.getTime()).toBeLessThan(endMs);
47+
48+
// start is the FIRST day of this bucket; end is the next bucket's start
49+
expect(bucketDateValue(new Date(startMs), g)).toBe(key);
50+
expect(bucketDateValue(new Date(endMs - 1), g)).toBe(key); // last instant still in bucket
51+
expect(bucketDateValue(new Date(endMs), g)).not.toBe(key); // boundary belongs to next bucket
52+
53+
// half-open width sanity for fixed-width granularities
54+
if (g === 'day') expect(endMs - startMs).toBe(DAY_MS);
55+
if (g === 'week') expect(endMs - startMs).toBe(7 * DAY_MS);
56+
});
57+
}
58+
}
59+
});
60+
61+
describe('bucketKeyToCalendarRange exact boundaries', () => {
62+
it('year / quarter / month / day / week', () => {
63+
expect(bucketKeyToCalendarRange('2026', 'year')).toEqual({ start: '2026-01-01', end: '2027-01-01' });
64+
expect(bucketKeyToCalendarRange('2026-Q1', 'quarter')).toEqual({ start: '2026-01-01', end: '2026-04-01' });
65+
expect(bucketKeyToCalendarRange('2026-Q2', 'quarter')).toEqual({ start: '2026-04-01', end: '2026-07-01' });
66+
expect(bucketKeyToCalendarRange('2026-Q4', 'quarter')).toEqual({ start: '2026-10-01', end: '2027-01-01' });
67+
expect(bucketKeyToCalendarRange('2026-12', 'month')).toEqual({ start: '2026-12-01', end: '2027-01-01' });
68+
expect(bucketKeyToCalendarRange('2024-02', 'month')).toEqual({ start: '2024-02-01', end: '2024-03-01' });
69+
expect(bucketKeyToCalendarRange('2024-02-29', 'day')).toEqual({ start: '2024-02-29', end: '2024-03-01' });
70+
expect(bucketKeyToCalendarRange('2025-W01', 'week')).toEqual({ start: '2024-12-30', end: '2025-01-06' });
71+
});
72+
});
73+
74+
describe('bucketKeyToCalendarRange rejects unbucketable / out-of-range keys → null (superset fallback)', () => {
75+
it('null and empty buckets', () => {
76+
expect(bucketKeyToCalendarRange('(null)', 'month')).toBeNull();
77+
expect(bucketKeyToCalendarRange('', 'day')).toBeNull();
78+
});
79+
it('shape mismatch vs. granularity', () => {
80+
expect(bucketKeyToCalendarRange('2026', 'month')).toBeNull();
81+
expect(bucketKeyToCalendarRange('2026-06', 'day')).toBeNull();
82+
expect(bucketKeyToCalendarRange('2026-Q2', 'month')).toBeNull();
83+
});
84+
it('out-of-range fields', () => {
85+
expect(bucketKeyToCalendarRange('2026-13', 'month')).toBeNull(); // month 13
86+
expect(bucketKeyToCalendarRange('2026-02-30', 'day')).toBeNull(); // impossible day
87+
expect(bucketKeyToCalendarRange('2025-W53', 'week')).toBeNull(); // 2025 has 52 ISO weeks
88+
});
89+
});

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

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,105 @@ describe('AnalyticsService.queryDataset', () => {
192192
expect(result.dimensionFields).toBeUndefined();
193193
expect(result.object).toBeUndefined();
194194
expect(result.drillRawRows).toBeUndefined();
195+
// …and, absent a `dateGranularity`, no RANGE sidecar either (it's not a bucket).
196+
expect(result.drillRanges).toBeUndefined();
197+
});
198+
199+
// ── #1752 — half-open date-RANGE drill sidecar for granularity buckets ─────
200+
// Granularity bucketing runs through the ObjectQL aggregate path (the native
201+
// SQL strategy declines it), so these drive `executeAggregate` and mock the
202+
// already-bucketed rows (the strategy lowers the dim to a `dateGranularity`
203+
// groupBy). Dimension name == field so the mocked row key is unambiguous.
204+
it('#1752 — emits a half-open date range for a granularity-bucketed date dimension', async () => {
205+
const q = DatasetSchema.parse({
206+
name: 'sales_q', label: 'Sales', object: 'opportunity', include: [],
207+
dimensions: [{ name: 'close_date', field: 'close_date', type: 'date', dateGranularity: 'quarter' }],
208+
measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount' }],
209+
});
210+
const svc = new AnalyticsService({
211+
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }),
212+
executeAggregate: async () => [{ close_date: '2026-Q2', revenue: 100 }],
213+
getReadScope: (_o, ctx?: ExecutionContext) => (ctx?.tenantId ? { organization_id: ctx.tenantId } : undefined),
214+
});
215+
const result = await svc.queryDataset(q, { dimensions: ['close_date'], measures: ['revenue'] }, { tenantId: 'org_A' } as ExecutionContext) as any;
216+
// The date bucket "2026-Q2" drills into the SPAN [2026-04-01, 2026-07-01).
217+
expect(result.object).toBe('opportunity');
218+
expect(result.drillRanges).toEqual([
219+
{ close_date: { field: 'close_date', gte: '2026-04-01', lt: '2026-07-01' } },
220+
]);
221+
// Range is its OWN channel — the date dim is still absent from the equality sidecar.
222+
expect(result.dimensionFields).toBeUndefined();
223+
expect(result.drillRawRows).toBeUndefined();
224+
});
225+
226+
it('#1752 — a matrix "X by time" report carries BOTH the equality dim and the date range', async () => {
227+
const mx = DatasetSchema.parse({
228+
name: 'pipe_mx', label: 'Pipe', object: 'opportunity', include: [],
229+
dimensions: [
230+
{ name: 'stage', field: 'stage', type: 'string' },
231+
{ name: 'close_date', field: 'close_date', type: 'date', dateGranularity: 'month' },
232+
],
233+
measures: [{ name: 'cnt', aggregate: 'count' }],
234+
});
235+
const svc = new AnalyticsService({
236+
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }),
237+
executeAggregate: async () => [{ stage: 'qualification', close_date: '2026-06', cnt: 3 }],
238+
getReadScope: (_o, ctx?: ExecutionContext) => (ctx?.tenantId ? { organization_id: ctx.tenantId } : undefined),
239+
});
240+
const result = await svc.queryDataset(mx, { dimensions: ['stage', 'close_date'], measures: ['cnt'] }, { tenantId: 'org_A' } as ExecutionContext) as any;
241+
expect(result.object).toBe('opportunity');
242+
// The non-date dim drills by equality; the date dim drills by range — side by side.
243+
expect(result.dimensionFields).toEqual({ stage: 'stage' });
244+
expect(result.drillRawRows).toEqual([{ stage: 'qualification' }]);
245+
expect(result.drillRanges).toEqual([
246+
{ close_date: { field: 'close_date', gte: '2026-06-01', lt: '2026-07-01' } },
247+
]);
248+
});
249+
250+
it('#1752 — omits the range for a datetime field under a non-UTC reference tz (superset fallback)', async () => {
251+
const q = DatasetSchema.parse({
252+
name: 'sales_dt', label: 'Sales', object: 'opportunity', include: [],
253+
dimensions: [{ name: 'closed_at', field: 'closed_at', type: 'date', dateGranularity: 'month' }],
254+
measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount' }],
255+
});
256+
const svc = new AnalyticsService({
257+
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }),
258+
executeAggregate: async () => [{ closed_at: '2026-06', revenue: 100 }],
259+
// 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).
261+
measureCurrency: (_o, f) => (f === 'closed_at' ? { type: 'datetime' } : undefined),
262+
getReadScope: (_o, ctx?: ExecutionContext) => (ctx?.tenantId ? { organization_id: ctx.tenantId } : undefined),
263+
});
264+
const result = await svc.queryDataset(
265+
q,
266+
{ dimensions: ['closed_at'], measures: ['revenue'], timezone: 'America/New_York' },
267+
{ tenantId: 'org_A' } as ExecutionContext,
268+
) as any;
269+
expect(result.drillRanges).toBeUndefined();
270+
expect(result.object).toBeUndefined();
271+
});
272+
273+
it('#1752 — still emits the range for a tz-naive date field under a non-UTC tz', async () => {
274+
const q = DatasetSchema.parse({
275+
name: 'sales_d_tz', label: 'Sales', object: 'opportunity', include: [],
276+
dimensions: [{ name: 'close_date', field: 'close_date', type: 'date', dateGranularity: 'month' }],
277+
measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount' }],
278+
});
279+
const svc = new AnalyticsService({
280+
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }),
281+
executeAggregate: async () => [{ close_date: '2026-06', revenue: 100 }],
282+
measureCurrency: (_o, f) => (f === 'close_date' ? { type: 'date' } : undefined),
283+
getReadScope: (_o, ctx?: ExecutionContext) => (ctx?.tenantId ? { organization_id: ctx.tenantId } : undefined),
284+
});
285+
const result = await svc.queryDataset(
286+
q,
287+
{ dimensions: ['close_date'], measures: ['revenue'], timezone: 'America/New_York' },
288+
{ tenantId: 'org_A' } as ExecutionContext,
289+
) as any;
290+
// A `date` is a tz-naive calendar day (ADR-0053) → bounds are exact under any tz.
291+
expect(result.drillRanges).toEqual([
292+
{ close_date: { field: 'close_date', gte: '2026-06-01', lt: '2026-07-01' } },
293+
]);
195294
});
196295

197296
it('marks a LOOKUP dimension drillable, exposing the raw FK for exact-match drill', async () => {

0 commit comments

Comments
 (0)