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
26 changes: 26 additions & 0 deletions .changeset/report-drill-down-range-filter.md
Original file line number Diff line number Diff line change
@@ -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.
117 changes: 117 additions & 0 deletions packages/core/src/utils/datetime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
89 changes: 89 additions & 0 deletions packages/objectql/src/date-bucket-range.test.ts
Original file line number Diff line number Diff line change
@@ -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
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading