diff --git a/.changeset/report-drill-down-range-filter.md b/.changeset/report-drill-down-range-filter.md new file mode 100644 index 0000000000..0de1b7e12a --- /dev/null +++ b/.changeset/report-drill-down-range-filter.md @@ -0,0 +1,26 @@ +--- +'@objectstack/core': minor +'@objectstack/service-analytics': minor +--- + +feat(analytics): emit a half-open date-range drill scope for granularity-bucketed date dimensions (#1752) + +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 +therefore excluded from drill metadata and a drill landed on an unscoped superset. + +- **`@objectstack/core`** adds `bucketKeyToCalendarRange(key, granularity)`, the + inverse of `bucketDateValue`: it turns a canonical bucket key into its half-open + `[start, end)` calendar span (`YYYY-MM-DD`, `end` exclusive). Pure, timezone-naive + calendar arithmetic; returns `null` for unbucketable / out-of-range keys so the + caller falls back to an unscoped (superset) drill rather than emit a wrong bound. +- **`@objectstack/service-analytics`** emits a `drillRanges` sidecar (aligned to + `rows` by index — the range companion to `drillRawRows`) for `date` + + `dateGranularity` dimensions, computed from the canonical bucket key in the + pre-label-resolution snapshot pass. A `datetime` field under a non-UTC reference + timezone is omitted (host drills a superset) until instant-boundary support + lands; a tz-naive `date` field is exact under any timezone (ADR-0053). + +Consumed by objectui's report drill-through to scope the drilled record list to the +clicked time bucket. diff --git a/packages/core/src/utils/datetime.ts b/packages/core/src/utils/datetime.ts index ec6ca4c32d..e08230e4e8 100644 --- a/packages/core/src/utils/datetime.ts +++ b/packages/core/src/utils/datetime.ts @@ -58,3 +58,120 @@ export function calendarPartsInTzOrUtc(d: Date, tz?: string): CalendarParts { day: d.getUTCDate(), }; } + +/** + * Granularity of a canonical date-bucket key. Mirrors `@objectstack/spec`'s + * `DateGranularity` enum but kept as a local literal union so this low-level + * package needs no dependency on spec. + */ +export type BucketGranularity = 'day' | 'week' | 'month' | 'quarter' | 'year'; + +/** + * ISO-8601 week label (Mon-start weeks, week 1 = the week of the first + * Thursday) of a UTC calendar day. The forward-direction companion used to + * *validate* a reconstructed week boundary; it mirrors the week branch of + * `@objectstack/objectql`'s `bucketDateValue` (kept in lockstep by the + * round-trip parity test in objectql). + */ +function isoWeekLabelUtc(d: Date): string { + const target = new Date(d.getTime()); + const dayNum = (target.getUTCDay() + 6) % 7; // Mon=0..Sun=6 + target.setUTCDate(target.getUTCDate() - dayNum + 3); // shift to that week's Thursday + const firstThursday = new Date(Date.UTC(target.getUTCFullYear(), 0, 4)); + const weekNo = + 1 + + Math.round( + ((target.getTime() - firstThursday.getTime()) / 86400000 - + 3 + + ((firstThursday.getUTCDay() + 6) % 7)) / + 7, + ); + return `${target.getUTCFullYear()}-W${String(weekNo).padStart(2, '0')}`; +} + +/** + * The half-open calendar span `[start, end)` of a canonical date-bucket KEY, + * as `YYYY-MM-DD` strings (`start` inclusive, `end` exclusive — the next + * bucket's first day). + * + * The input MUST be the canonical key produced by `bucketDateValue` / + * `buildDateBucketExpr` (`2026`, `2026-Q2`, `2026-06`, `2026-06-15`, + * `2026-W23`) — NEVER a localized / humanized display label. The span is pure, + * timezone-naive calendar arithmetic; a caller that needs instant bounds for a + * `datetime` field in a reference timezone layers that on top (and, per + * ADR-0053, a `date` field compares against these `YYYY-MM-DD` bounds directly). + * + * Returns `null` for the null/empty bucket, an unparseable key, or a key that + * is shape-valid but out of range (e.g. `2026-13`, a `-W53` in a 52-week year, + * `2026-02-30`). Callers drop the range and fall back to an unscoped (superset) + * drill rather than emit a wrong bound. + */ +export function bucketKeyToCalendarRange( + key: string, + granularity: BucketGranularity, +): { start: string; end: string } | null { + if (typeof key !== 'string' || key.length === 0) return null; + const fmt = (dt: Date) => + `${String(dt.getUTCFullYear()).padStart(4, '0')}-${String(dt.getUTCMonth() + 1).padStart( + 2, + '0', + )}-${String(dt.getUTCDate()).padStart(2, '0')}`; + + switch (granularity) { + case 'year': { + const m = /^(\d{4})$/.exec(key); + if (!m) return null; + const y = Number(m[1]); + return { start: fmt(new Date(Date.UTC(y, 0, 1))), end: fmt(new Date(Date.UTC(y + 1, 0, 1))) }; + } + case 'quarter': { + const m = /^(\d{4})-Q([1-4])$/.exec(key); + if (!m) return null; + const y = Number(m[1]); + const startMonth = (Number(m[2]) - 1) * 3; // Q1→0, Q2→3, Q3→6, Q4→9 + return { + start: fmt(new Date(Date.UTC(y, startMonth, 1))), + end: fmt(new Date(Date.UTC(y, startMonth + 3, 1))), // Date.UTC rolls Q4 into next year + }; + } + case 'month': { + const m = /^(\d{4})-(\d{2})$/.exec(key); + if (!m) return null; + const mo = Number(m[2]); + if (mo < 1 || mo > 12) return null; + const y = Number(m[1]); + return { + start: fmt(new Date(Date.UTC(y, mo - 1, 1))), + end: fmt(new Date(Date.UTC(y, mo, 1))), + }; + } + case 'day': { + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(key); + if (!m) return null; + const y = Number(m[1]); + const mo = Number(m[2]); + const d = Number(m[3]); + const start = new Date(Date.UTC(y, mo - 1, d)); + if (fmt(start) !== key) return null; // reject an impossible day that rolled over + return { start: key, end: fmt(new Date(Date.UTC(y, mo - 1, d + 1))) }; + } + case 'week': { + const m = /^(\d{4})-W(\d{2})$/.exec(key); + if (!m) return null; + const isoYear = Number(m[1]); + const week = Number(m[2]); + if (week < 1 || week > 53) return null; + // Monday of ISO week 1 is the Monday on/before Jan 4; add (week-1) weeks. + const jan4 = new Date(Date.UTC(isoYear, 0, 4)); + const jan4Dow = (jan4.getUTCDay() + 6) % 7; // Mon=0..Sun=6 + const start = new Date(jan4.getTime()); + start.setUTCDate(jan4.getUTCDate() - jan4Dow + (week - 1) * 7); + if (isoWeekLabelUtc(start) !== key) return null; // reject -W53 overflow etc. + const end = new Date(start.getTime()); + end.setUTCDate(start.getUTCDate() + 7); + return { start: fmt(start), end: fmt(end) }; + } + default: + return null; + } +} diff --git a/packages/objectql/src/date-bucket-range.test.ts b/packages/objectql/src/date-bucket-range.test.ts new file mode 100644 index 0000000000..cb5db037f8 --- /dev/null +++ b/packages/objectql/src/date-bucket-range.test.ts @@ -0,0 +1,89 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Round-trip parity: `bucketKeyToCalendarRange` (@objectstack/core) is the +// INVERSE of `bucketDateValue` (this package). A drill on a date-bucketed +// report cell turns the bucket KEY back into the half-open `[start, end)` span +// of records that fall in it, so the two MUST agree on every boundary — any +// drift silently mis-scopes the drilled record list. This test pins them +// together (mirrors the driver-sql `buildDateBucketExpr` parity harness). + +import { describe, it, expect } from 'vitest'; +import { bucketKeyToCalendarRange, type BucketGranularity } from '@objectstack/core'; +import { bucketDateValue } from './in-memory-aggregation.js'; + +const DAY_MS = 86_400_000; +const utcMidnight = (ymd: string) => Date.parse(`${ymd}T00:00:00.000Z`); + +// Instants chosen to hit cross-year / cross-quarter / cross-month and the ISO +// week boundary (2024-12-30 is ISO week 2025-W01). Same spirit as the driver +// parity fixture. +const INSTANTS = [ + '2024-01-15T10:00:00Z', + '2024-04-01T00:00:00Z', + '2024-06-30T23:59:59Z', + '2024-07-01T00:00:00Z', + '2024-12-30T12:00:00Z', // ISO 2025-W01 + '2025-01-01T00:00:00Z', + '2025-05-19T09:00:00Z', + '2026-02-15T00:00:00Z', + '2026-12-31T23:00:00Z', +]; +const GRANULARITIES: BucketGranularity[] = ['day', 'week', 'month', 'quarter', 'year']; + +describe('bucketKeyToCalendarRange ↔ bucketDateValue round-trip (UTC)', () => { + for (const iso of INSTANTS) { + for (const g of GRANULARITIES) { + it(`${iso} @ ${g}`, () => { + const d = new Date(iso); + const key = bucketDateValue(d, g); // no tz → UTC key + const range = bucketKeyToCalendarRange(key, g); + expect(range, `range for key ${key}`).not.toBeNull(); + const startMs = utcMidnight(range!.start); + const endMs = utcMidnight(range!.end); + + // the instant lies inside the half-open span + expect(startMs).toBeLessThanOrEqual(d.getTime()); + expect(d.getTime()).toBeLessThan(endMs); + + // start is the FIRST day of this bucket; end is the next bucket's start + expect(bucketDateValue(new Date(startMs), g)).toBe(key); + expect(bucketDateValue(new Date(endMs - 1), g)).toBe(key); // last instant still in bucket + expect(bucketDateValue(new Date(endMs), g)).not.toBe(key); // boundary belongs to next bucket + + // half-open width sanity for fixed-width granularities + if (g === 'day') expect(endMs - startMs).toBe(DAY_MS); + if (g === 'week') expect(endMs - startMs).toBe(7 * DAY_MS); + }); + } + } +}); + +describe('bucketKeyToCalendarRange exact boundaries', () => { + it('year / quarter / month / day / week', () => { + expect(bucketKeyToCalendarRange('2026', 'year')).toEqual({ start: '2026-01-01', end: '2027-01-01' }); + expect(bucketKeyToCalendarRange('2026-Q1', 'quarter')).toEqual({ start: '2026-01-01', end: '2026-04-01' }); + expect(bucketKeyToCalendarRange('2026-Q2', 'quarter')).toEqual({ start: '2026-04-01', end: '2026-07-01' }); + expect(bucketKeyToCalendarRange('2026-Q4', 'quarter')).toEqual({ start: '2026-10-01', end: '2027-01-01' }); + expect(bucketKeyToCalendarRange('2026-12', 'month')).toEqual({ start: '2026-12-01', end: '2027-01-01' }); + expect(bucketKeyToCalendarRange('2024-02', 'month')).toEqual({ start: '2024-02-01', end: '2024-03-01' }); + expect(bucketKeyToCalendarRange('2024-02-29', 'day')).toEqual({ start: '2024-02-29', end: '2024-03-01' }); + expect(bucketKeyToCalendarRange('2025-W01', 'week')).toEqual({ start: '2024-12-30', end: '2025-01-06' }); + }); +}); + +describe('bucketKeyToCalendarRange rejects unbucketable / out-of-range keys → null (superset fallback)', () => { + it('null and empty buckets', () => { + expect(bucketKeyToCalendarRange('(null)', 'month')).toBeNull(); + expect(bucketKeyToCalendarRange('', 'day')).toBeNull(); + }); + it('shape mismatch vs. granularity', () => { + expect(bucketKeyToCalendarRange('2026', 'month')).toBeNull(); + expect(bucketKeyToCalendarRange('2026-06', 'day')).toBeNull(); + expect(bucketKeyToCalendarRange('2026-Q2', 'month')).toBeNull(); + }); + it('out-of-range fields', () => { + expect(bucketKeyToCalendarRange('2026-13', 'month')).toBeNull(); // month 13 + expect(bucketKeyToCalendarRange('2026-02-30', 'day')).toBeNull(); // impossible day + expect(bucketKeyToCalendarRange('2025-W53', 'week')).toBeNull(); // 2025 has 52 ISO weeks + }); +}); 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 80015df66c..ed3ac64227 100644 --- a/packages/services/service-analytics/src/__tests__/query-dataset.test.ts +++ b/packages/services/service-analytics/src/__tests__/query-dataset.test.ts @@ -192,6 +192,105 @@ describe('AnalyticsService.queryDataset', () => { expect(result.dimensionFields).toBeUndefined(); expect(result.object).toBeUndefined(); expect(result.drillRawRows).toBeUndefined(); + // …and, absent a `dateGranularity`, no RANGE sidecar either (it's not a bucket). + expect(result.drillRanges).toBeUndefined(); + }); + + // ── #1752 — half-open date-RANGE drill sidecar for granularity buckets ───── + // Granularity bucketing runs through the ObjectQL aggregate path (the native + // SQL strategy declines it), so these drive `executeAggregate` and mock the + // already-bucketed rows (the strategy lowers the dim to a `dateGranularity` + // groupBy). Dimension name == field so the mocked row key is unambiguous. + it('#1752 — emits a half-open date range for a granularity-bucketed date dimension', async () => { + const q = DatasetSchema.parse({ + name: 'sales_q', label: 'Sales', object: 'opportunity', include: [], + dimensions: [{ name: 'close_date', field: 'close_date', type: 'date', dateGranularity: 'quarter' }], + measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount' }], + }); + const svc = new AnalyticsService({ + queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), + executeAggregate: async () => [{ close_date: '2026-Q2', revenue: 100 }], + getReadScope: (_o, ctx?: ExecutionContext) => (ctx?.tenantId ? { organization_id: ctx.tenantId } : undefined), + }); + const result = await svc.queryDataset(q, { dimensions: ['close_date'], measures: ['revenue'] }, { tenantId: 'org_A' } as ExecutionContext) as any; + // The date bucket "2026-Q2" drills into the SPAN [2026-04-01, 2026-07-01). + expect(result.object).toBe('opportunity'); + expect(result.drillRanges).toEqual([ + { close_date: { field: 'close_date', gte: '2026-04-01', lt: '2026-07-01' } }, + ]); + // Range is its OWN channel — the date dim is still absent from the equality sidecar. + expect(result.dimensionFields).toBeUndefined(); + expect(result.drillRawRows).toBeUndefined(); + }); + + it('#1752 — a matrix "X by time" report carries BOTH the equality dim and the date range', async () => { + const mx = DatasetSchema.parse({ + name: 'pipe_mx', label: 'Pipe', object: 'opportunity', include: [], + dimensions: [ + { name: 'stage', field: 'stage', type: 'string' }, + { name: 'close_date', field: 'close_date', type: 'date', dateGranularity: 'month' }, + ], + measures: [{ name: 'cnt', aggregate: 'count' }], + }); + const svc = new AnalyticsService({ + queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), + executeAggregate: async () => [{ stage: 'qualification', close_date: '2026-06', cnt: 3 }], + getReadScope: (_o, ctx?: ExecutionContext) => (ctx?.tenantId ? { organization_id: ctx.tenantId } : undefined), + }); + const result = await svc.queryDataset(mx, { dimensions: ['stage', 'close_date'], measures: ['cnt'] }, { tenantId: 'org_A' } as ExecutionContext) as any; + expect(result.object).toBe('opportunity'); + // The non-date dim drills by equality; the date dim drills by range — side by side. + expect(result.dimensionFields).toEqual({ stage: 'stage' }); + expect(result.drillRawRows).toEqual([{ stage: 'qualification' }]); + expect(result.drillRanges).toEqual([ + { close_date: { field: 'close_date', gte: '2026-06-01', lt: '2026-07-01' } }, + ]); + }); + + it('#1752 — omits the range for a datetime field under a non-UTC reference tz (superset fallback)', async () => { + const q = DatasetSchema.parse({ + name: 'sales_dt', label: 'Sales', object: 'opportunity', include: [], + dimensions: [{ name: 'closed_at', field: 'closed_at', type: 'date', dateGranularity: 'month' }], + measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount' }], + }); + const svc = new AnalyticsService({ + 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). + measureCurrency: (_o, f) => (f === 'closed_at' ? { type: 'datetime' } : undefined), + getReadScope: (_o, ctx?: ExecutionContext) => (ctx?.tenantId ? { organization_id: ctx.tenantId } : undefined), + }); + const result = await svc.queryDataset( + q, + { dimensions: ['closed_at'], measures: ['revenue'], timezone: 'America/New_York' }, + { tenantId: 'org_A' } as ExecutionContext, + ) as any; + expect(result.drillRanges).toBeUndefined(); + expect(result.object).toBeUndefined(); + }); + + it('#1752 — still emits the range for a tz-naive date field under a non-UTC tz', async () => { + const q = DatasetSchema.parse({ + name: 'sales_d_tz', label: 'Sales', object: 'opportunity', include: [], + dimensions: [{ name: 'close_date', field: 'close_date', type: 'date', dateGranularity: 'month' }], + measures: [{ name: 'revenue', aggregate: 'sum', field: 'amount' }], + }); + const svc = new AnalyticsService({ + queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), + executeAggregate: async () => [{ close_date: '2026-06', revenue: 100 }], + measureCurrency: (_o, f) => (f === 'close_date' ? { type: 'date' } : undefined), + getReadScope: (_o, ctx?: ExecutionContext) => (ctx?.tenantId ? { organization_id: ctx.tenantId } : undefined), + }); + const result = await svc.queryDataset( + q, + { dimensions: ['close_date'], measures: ['revenue'], timezone: 'America/New_York' }, + { tenantId: 'org_A' } as ExecutionContext, + ) as any; + // A `date` is a tz-naive calendar day (ADR-0053) → bounds are exact under any tz. + expect(result.drillRanges).toEqual([ + { close_date: { field: 'close_date', gte: '2026-06-01', lt: '2026-07-01' } }, + ]); }); it('marks a LOOKUP dimension drillable, exposing the raw FK for exact-match drill', async () => { diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index 3cb871bc38..221fdf01f2 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 } from '@objectstack/core'; +import { createLogger, bucketKeyToCalendarRange } 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'; @@ -50,6 +50,20 @@ type AnalyticsResultWithDrill = AnalyticsResult & { * intact and correctly drills the whole (unfiltered) object. */ drillRawTotals?: Array>>; + /** + * #1752 — half-open date-range drill scope per row, the RANGE companion to + * `drillRawRows` (which handles equality dims). A time-bucketed date + * dimension (`dateGranularity`) groups a SPAN of records into one bucket + * ("2026-Q2"), so its drill needs `[gte, lt)`, not equality — the humanized + * bucket can't be exact-matched (which is why date dims are excluded from + * `dimensionFields`/`drillRawRows`). Aligned to `rows` by index; each entry + * maps a drillable date-dimension NAME → `{ field, gte, lt }` with `gte` + * inclusive and `lt` exclusive (bounds as `YYYY-MM-DD`). Present only for + * buckets whose boundaries are unambiguous — a `datetime` field under a + * non-UTC reference timezone is omitted (host drills an unscoped superset) + * until instant-boundary support lands. + */ + drillRanges?: Array>; }; /** @@ -535,6 +549,45 @@ export class AnalyticsService implements IAnalyticsService { } } + // #1752 — date-range drill scope. A `dateGranularity` dimension groups a + // SPAN of records into one bucket, so drilling it needs a half-open range + // `[gte, lt)`, which the equality `drillRawRows` sidecar can't express + // (that's exactly why date dims are excluded from `drillDims` above). Emit + // a parallel range sidecar computed — via the shared inverse util so server + // and client agree on boundaries — from the canonical bucket KEY, which is + // 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', + ); + if (rangeDims.length && result.rows.length) { + (result as AnalyticsResultWithDrill).drillRanges = result.rows.map((row) => { + const ranges: Record = {}; + for (const d 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 }; + } + return ranges; + }); + // The equality drill block sets `object` only when a NON-date drill dim + // exists; a report grouped ONLY by time still needs the base object so the + // host can open its list. Safe to (re)set to the same dataset object. + (result as AnalyticsResultWithDrill).object = dataset.object; + } + // ADR-0021 — resolve grouped dimension values to human display labels // (select option label, lookup related-record name). Charts render the // dimension key verbatim, so this is the single place that turns a stored