Skip to content

Commit 7101ca2

Browse files
os-zhuangclaude
andauthored
fix(analytics): apply the effective date granularity to labels and drill ranges (#3673)
`selection.dateGranularity` reached the GROUP BY but not the post-processing: the bucket-label formatter and the drill-range inverter both kept reading the DATASET dimension's default, so a query was grouped one way and described another. Found by driving the query in a browser, not by the unit tests — which passed throughout, because each site was correct in isolation. Against a dataset declaring `dateGranularity: 'month'`: - selection `year` → row labelled "1970-01", a year bucket re-formatted with the dataset's month granularity, its "2026" key re-read as 2026 milliseconds past the epoch; - selection `day` → day buckets re-labelled as months, collapsing ten distinct days into two duplicated keys; - selection `quarter`/`year`/`day`/`week` → `drillRanges` empty, silently removing drill-through from every bucketed chart. Granularity precedence now lives in one exported function, `resolveDimensionGranularity`, called from all three sites that must agree: the query's GROUP BY, the label formatter, and the range inverter. The drift was possible only because each resolved it independently — so the fix is the shared resolver, not three parallel patches. Also fixes two things this surfaced: - A dataset dimension declaring NO granularity but bucketed by the widget now gets drill ranges. The sidecar keyed off the dataset's own `dateGranularity`, so the case #3588 is actually about could never drill. - `formatDateBucket` no longer mistakes a bare year key for an epoch timestamp. "2026" is the only bucket key that collides with the pure-digit epoch heuristic; "2026-Q2", "2026-07" and "2026-07-15" all fail it. Idempotence over already-formatted keys is that function's stated contract — the year case just never held. Verified in a browser end to end: all five granularities now label correctly (year "2026", not "1970") and every one carries its half-open drill range. Claude-Session: https://claude.ai/code/session_01SEZLBGyNoJMLvtGPqsMPRC Co-authored-by: Claude <noreply@anthropic.com>
1 parent f163028 commit 7101ca2

5 files changed

Lines changed: 314 additions & 11 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"@objectstack/service-analytics": patch
3+
---
4+
5+
fix(analytics): apply the EFFECTIVE date granularity to bucket labels and drill ranges (#3588 follow-up)
6+
7+
`selection.dateGranularity` (shipped in #3652) reached the `GROUP BY` but not the
8+
post-processing: the bucket-label formatter and the drill-range inverter both
9+
kept reading the DATASET dimension's default. A query was grouped one way and
10+
described another. Found by driving a real dashboard query in a browser against
11+
a dataset whose dimension declares `dateGranularity: 'month'`:
12+
13+
- selection `year` → the row came back labelled **`1970-01`** — a year bucket
14+
re-formatted with the dataset's month granularity, its `"2026"` key re-read as
15+
2026 *milliseconds* past the epoch;
16+
- selection `day` → day buckets were re-labelled as months, so ten distinct days
17+
collapsed into two duplicated keys;
18+
- selection `quarter` / `year` / `day` / `week``drillRanges` came back empty,
19+
silently removing drill-through from every bucketed chart.
20+
21+
Granularity precedence now lives in one exported function,
22+
`resolveDimensionGranularity`, called from all three sites that must agree — the
23+
query's `GROUP BY`, the label formatter, and the range inverter. The drift was
24+
possible only because each site resolved it independently.
25+
26+
Two consequences beyond the override case:
27+
28+
- A dataset dimension that declares **no** granularity but is bucketed by the
29+
widget now gets drill ranges too. Previously the range sidecar keyed off the
30+
dataset's own `dateGranularity`, so this case — the one #3588 is actually
31+
about — could never drill.
32+
- `formatDateBucket` no longer mistakes a bare year key for an epoch timestamp.
33+
A year bucket's canonical key IS `"2026"`, which is the only bucket key that
34+
collides with the pure-digit epoch heuristic (`"2026-Q2"`, `"2026-07"` and
35+
`"2026-07-15"` all fail it). Being idempotent over already-formatted keys is
36+
that function's stated contract; the year case just never held.
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #3588 follow-up — the EFFECTIVE granularity must reach the post-processing,
5+
* not just the GROUP BY.
6+
*
7+
* `selection.dateGranularity` correctly overrode the dataset dimension's default
8+
* when compiling the query, but two post-processing steps kept reading the
9+
* DATASET default: the bucket-label formatter and the drill-range inverter. The
10+
* result was a query grouped one way and described another. Caught by driving a
11+
* real dashboard query in a browser against a dataset whose dimension declares
12+
* `dateGranularity: 'month'`:
13+
*
14+
* selection 'year' → row labelled "1970-01" (a year key re-parsed as epoch ms)
15+
* selection 'day' → day buckets re-labelled as months, so rows collapsed
16+
* to duplicate keys
17+
* selection 'quarter' → correct label, but `drillRanges` empty — the chart
18+
* silently lost drill-through
19+
*
20+
* These pin the three sites (query, labels, ranges) to one resolution.
21+
*/
22+
23+
import { describe, it, expect } from 'vitest';
24+
import { DatasetSchema } from '@objectstack/spec/ui';
25+
import type { ExecutionContext } from '@objectstack/spec/kernel';
26+
import { AnalyticsService } from '../analytics-service.js';
27+
import { resolveDimensionGranularity } from '../dataset-executor.js';
28+
29+
const CTX = { tenantId: 'org_A' } as ExecutionContext;
30+
31+
/** Mirrors the showcase dataset: the dimension declares a MONTH default. */
32+
const monthly = DatasetSchema.parse({
33+
name: 'task_metrics', label: 'Tasks', object: 'showcase_task', include: [],
34+
dimensions: [{ name: 'created_at', field: 'created_at', type: 'date', dateGranularity: 'month' }],
35+
measures: [{ name: 'task_count', aggregate: 'count' }],
36+
});
37+
38+
/** A dataset that declares NO granularity — the widget supplies it (the hotcrm case). */
39+
const bare = DatasetSchema.parse({
40+
name: 'account_metrics', label: 'Accounts', object: 'crm_account', include: [],
41+
dimensions: [{ name: 'created_at', field: 'created_at', type: 'date' }],
42+
measures: [{ name: 'account_count', aggregate: 'count' }],
43+
});
44+
45+
/**
46+
* Service whose aggregate returns the bucket key the DRIVER would produce for
47+
* the granularity actually requested — so a mismatch between the query's
48+
* granularity and the post-processing's shows up exactly as it did in the browser.
49+
*/
50+
function service(bucketByGranularity: Record<string, string>) {
51+
const seen: { granularity?: string } = {};
52+
const svc = new AnalyticsService({
53+
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }),
54+
executeAggregate: async (_o, options) => {
55+
const gb = (options.groupBy ?? []) as Array<string | { field: string; dateGranularity?: string }>;
56+
const g = gb.map((x) => (typeof x === 'string' ? undefined : x.dateGranularity)).find(Boolean);
57+
seen.granularity = g;
58+
return [{ created_at: bucketByGranularity[g ?? 'none'], task_count: 10, account_count: 10 }];
59+
},
60+
// `created_at` is a tz-naive calendar date → ranges are exact under any tz.
61+
measureCurrency: (_o, f) => (f === 'created_at' ? { type: 'date' } : undefined),
62+
});
63+
return { svc, seen };
64+
}
65+
66+
const BUCKETS = { day: '2026-07-15', week: '2026-W29', month: '2026-07', quarter: '2026-Q3', year: '2026' };
67+
68+
describe('#3588 — effective granularity reaches labels and drill ranges', () => {
69+
it('labels a YEAR bucket as the year, not as epoch month "1970-01"', async () => {
70+
const { svc, seen } = service(BUCKETS);
71+
const r = await svc.queryDataset(
72+
monthly,
73+
{ dimensions: ['created_at'], measures: ['task_count'], dateGranularity: 'year' },
74+
CTX,
75+
) as any;
76+
expect(seen.granularity).toBe('year');
77+
expect(r.rows[0].created_at).toBe('2026');
78+
expect(r.rows[0].created_at).not.toBe('1970-01');
79+
});
80+
81+
it('drills a YEAR bucket into the whole year', async () => {
82+
const { svc } = service(BUCKETS);
83+
const r = await svc.queryDataset(
84+
monthly,
85+
{ dimensions: ['created_at'], measures: ['task_count'], dateGranularity: 'year' },
86+
CTX,
87+
) as any;
88+
expect(r.drillRanges).toEqual([
89+
{ created_at: { field: 'created_at', gte: '2026-01-01', lt: '2027-01-01' } },
90+
]);
91+
});
92+
93+
it('drills a QUARTER bucket into that quarter — previously the range was dropped', async () => {
94+
const { svc } = service(BUCKETS);
95+
const r = await svc.queryDataset(
96+
monthly,
97+
{ dimensions: ['created_at'], measures: ['task_count'], dateGranularity: 'quarter' },
98+
CTX,
99+
) as any;
100+
expect(r.drillRanges).toEqual([
101+
{ created_at: { field: 'created_at', gte: '2026-07-01', lt: '2026-10-01' } },
102+
]);
103+
});
104+
105+
it('keeps a DAY bucket at day resolution instead of collapsing it to a month', async () => {
106+
const { svc } = service(BUCKETS);
107+
const r = await svc.queryDataset(
108+
monthly,
109+
{ dimensions: ['created_at'], measures: ['task_count'], dateGranularity: 'day' },
110+
CTX,
111+
) as any;
112+
expect(r.rows[0].created_at).toBe('2026-07-15');
113+
expect(r.drillRanges).toEqual([
114+
{ created_at: { field: 'created_at', gte: '2026-07-15', lt: '2026-07-16' } },
115+
]);
116+
});
117+
118+
it('still honours the dataset default when the selection asks for nothing', async () => {
119+
const { svc, seen } = service(BUCKETS);
120+
const r = await svc.queryDataset(monthly, { dimensions: ['created_at'], measures: ['task_count'] }, CTX) as any;
121+
expect(seen.granularity).toBe('month');
122+
expect(r.rows[0].created_at).toBe('2026-07');
123+
expect(r.drillRanges).toEqual([
124+
{ created_at: { field: 'created_at', gte: '2026-07-01', lt: '2026-08-01' } },
125+
]);
126+
});
127+
128+
it('gives a dataset that declares NO granularity both labels and ranges (#3588 case 1)', async () => {
129+
const { svc, seen } = service(BUCKETS);
130+
const r = await svc.queryDataset(
131+
bare,
132+
{ dimensions: ['created_at'], measures: ['account_count'], dateGranularity: 'month' },
133+
CTX,
134+
) as any;
135+
expect(seen.granularity).toBe('month');
136+
expect(r.rows[0].created_at).toBe('2026-07');
137+
// Before, a bare dataset dimension had no `dateGranularity` at all, so the
138+
// range sidecar was skipped and a bucketed chart could not drill.
139+
expect(r.drillRanges).toEqual([
140+
{ created_at: { field: 'created_at', gte: '2026-07-01', lt: '2026-08-01' } },
141+
]);
142+
});
143+
});
144+
145+
describe('resolveDimensionGranularity — the single source of precedence', () => {
146+
it('a stated timeDimensions granularity wins over everything', () => {
147+
expect(resolveDimensionGranularity(
148+
{ timeDimensions: [{ dimension: 'd', granularity: 'week' }], dateGranularity: 'year' }, 'd', 'month',
149+
)).toBe('week');
150+
});
151+
152+
it('the selection beats the dataset default', () => {
153+
expect(resolveDimensionGranularity({ dateGranularity: 'year' }, 'd', 'month')).toBe('year');
154+
});
155+
156+
it('the dataset default applies when the selection is silent', () => {
157+
expect(resolveDimensionGranularity({}, 'd', 'month')).toBe('month');
158+
});
159+
160+
it('a dateRange-only entry states a WINDOW, not a bucket — it must not suppress bucketing', () => {
161+
expect(resolveDimensionGranularity(
162+
{ timeDimensions: [{ dimension: 'd', dateRange: ['2026-01-01', '2026-12-31'] }], dateGranularity: 'month' },
163+
'd', undefined,
164+
)).toBe('month');
165+
});
166+
167+
it('returns undefined when nothing declares one', () => {
168+
expect(resolveDimensionGranularity({}, 'd', undefined)).toBeUndefined();
169+
});
170+
});
171+
172+
describe('formatDateBucket — a year key must survive its own re-labeling', () => {
173+
it('keeps a bare year key as the year, not epoch 1970', async () => {
174+
const { formatDateBucket } = await import('../dimension-labels.js');
175+
// Both forms drivers return for a `date_trunc('year', …)` bucket.
176+
expect(formatDateBucket('2026', 'year')).toBe('2026');
177+
expect(formatDateBucket(2026, 'year')).toBe('2026');
178+
// Idempotent: re-labeling an already-labelled key is a no-op.
179+
expect(formatDateBucket(formatDateBucket('2026', 'year'), 'year')).toBe('2026');
180+
});
181+
182+
it('still reads a real epoch value under year granularity', async () => {
183+
const { formatDateBucket } = await import('../dimension-labels.js');
184+
expect(formatDateBucket(Date.UTC(2026, 5, 15), 'year')).toBe('2026');
185+
expect(formatDateBucket('2026-06-15T00:00:00Z', 'year')).toBe('2026');
186+
});
187+
188+
it('leaves the other granularity keys alone (they never collided)', async () => {
189+
const { formatDateBucket } = await import('../dimension-labels.js');
190+
expect(formatDateBucket('2026-Q2', 'quarter')).toBe('2026-Q2');
191+
expect(formatDateBucket('2026-07', 'month')).toBe('2026-07');
192+
expect(formatDateBucket('2026-07-15', 'day')).toBe('2026-07-15');
193+
});
194+
});

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

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import type { AnalyticsStrategy, DriverCapabilities, StrategyContext } from './s
1717
import { NativeSQLStrategy } from './strategies/native-sql-strategy.js';
1818
import { ObjectQLStrategy } from './strategies/objectql-strategy.js';
1919
import { compileDataset, type CompiledDataset, type RelationshipResolver } from './dataset-compiler.js';
20-
import { DatasetExecutor } from './dataset-executor.js';
20+
import { DatasetExecutor, resolveDimensionGranularity, type DateGranularityValue } from './dataset-executor.js';
2121
import { resolveDimensionLabels, type DimensionLabelDeps } from './dimension-labels.js';
2222
import { evaluateAnalyticsQueryOverRows } from './preview-evaluator.js';
2323

@@ -567,22 +567,30 @@ export class AnalyticsService implements IAnalyticsService {
567567
// - unknown field type → safe only under UTC (where the calendar day and
568568
// its instant coincide); under a non-UTC tz we can't tell whether to
569569
// shift, so the dim is omitted and the host drills a superset.
570-
const rangeDims: Array<{ d: (typeof selectedDims)[number]; instant: boolean }> = [];
570+
// The bucket size to invert MUST be the one the query actually grouped by —
571+
// `selection.dateGranularity` overrides the dataset dimension's default
572+
// (#3588). Reading `d.dateGranularity` here meant a widget that bucketed by
573+
// quarter or year got its ranges computed from the dataset's month (or,
574+
// when the dataset declared none, dropped entirely) — so drilling a bucket
575+
// opened the wrong span, or the chart lost drill-through altogether.
576+
const rangeDims: Array<{ d: (typeof selectedDims)[number]; granularity: DateGranularityValue; instant: boolean }> = [];
571577
for (const d of selectedDims) {
572-
if (!d.field || d.type !== 'date' || !d.dateGranularity) continue;
578+
if (!d.field || d.type !== 'date') continue;
579+
const granularity = resolveDimensionGranularity(selection, d.name, d.dateGranularity);
580+
if (!granularity) continue;
573581
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 });
582+
if (ftype === 'datetime') rangeDims.push({ d, granularity, instant: true });
583+
else if (ftype === 'date') rangeDims.push({ d, granularity, instant: false });
584+
else if (rangeTz === 'UTC') rangeDims.push({ d, granularity, instant: false });
577585
// else: unknown field type under a non-UTC reference tz → omit (superset).
578586
}
579587
if (rangeDims.length && result.rows.length) {
580588
const bound = (ymd: string, instant: boolean): string =>
581589
instant ? new Date(zonedDateStartToUtcMs(ymd, rangeTz)).toISOString() : ymd;
582590
(result as AnalyticsResultWithDrill).drillRanges = result.rows.map((row) => {
583591
const ranges: Record<string, { field: string; gte: string; lt: string }> = {};
584-
for (const { d, instant } of rangeDims) {
585-
const cal = bucketKeyToCalendarRange(row[d.name] as string, d.dateGranularity!);
592+
for (const { d, granularity, instant } of rangeDims) {
593+
const cal = bucketKeyToCalendarRange(row[d.name] as string, granularity);
586594
if (cal) {
587595
ranges[d.name] = { field: d.field as string, gte: bound(cal.start, instant), lt: bound(cal.end, instant) };
588596
}
@@ -600,9 +608,18 @@ export class AnalyticsService implements IAnalyticsService {
600608
// dimension key verbatim, so this is the single place that turns a stored
601609
// value / FK id into the text a user expects to read.
602610
if (this.labelResolver && selectedDims.length) {
611+
// Same single-source rule as the drill ranges above: a date bucket must be
612+
// LABELLED with the granularity it was actually grouped by (#3588).
613+
// Formatting a `year` bucket with the dataset's `month` default rendered
614+
// it as "1970-01" — the year key re-parsed as an epoch millisecond count.
603615
const dims = selectedDims
604616
.filter((d) => !!d.field)
605-
.map((d) => ({ name: d.name, field: d.field, type: d.type, dateGranularity: d.dateGranularity }));
617+
.map((d) => ({
618+
name: d.name,
619+
field: d.field,
620+
type: d.type,
621+
dateGranularity: resolveDimensionGranularity(selection, d.name, d.dateGranularity),
622+
}));
606623
if (dims.length) {
607624
// #3602 — bind the referenced object's read scope to THIS request so the
608625
// label lookup (a per-record read of the related object) cannot surface a

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

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,52 @@ function computeDerived(d: DerivedMeasureSpec, row: Record<string, unknown>): nu
110110
}
111111
}
112112

113+
// ── date bucketing (#3588) ───────────────────────────────────────────────────
114+
115+
/** The date-bucket vocabulary shared by the dataset, the selection, and the
116+
* bucketing utilities in `@objectstack/core`. */
117+
export type DateGranularityValue = NonNullable<DatasetSelection['dateGranularity']>;
118+
119+
/**
120+
* The EFFECTIVE bucket size for one date dimension of a selection — the single
121+
* source of truth for granularity precedence.
122+
*
123+
* Precedence, per dimension:
124+
* 1. a `granularity` already stated on that dimension's `timeDimensions`
125+
* entry — never overridden;
126+
* 2. `selection.dateGranularity` — the presentation's choice, so a widget can
127+
* bucket by month without the dataset committing every consumer to it;
128+
* 3. `datasetDefault` — the dataset dimension's own `dateGranularity`.
129+
*
130+
* The unit of precedence is the GRANULARITY, not the entry: a `timeDimensions`
131+
* entry carrying only a `dateRange` (what `compareTo` needs) states a WINDOW,
132+
* not a bucket size, and must not suppress bucketing.
133+
*
134+
* **Why this is exported.** The bucket size chosen here decides three things
135+
* that MUST agree: the `GROUP BY` the query compiles to, the humanized label
136+
* each bucket key is rendered as, and the half-open `[gte, lt)` range a bucket
137+
* drills into. When the query layer resolved granularity and the post-processing
138+
* in `analytics-service` read the dataset default instead, they silently
139+
* disagreed for every selection that overrode it — a `year` query came back
140+
* labelled `1970-01` (a year bucket re-formatted as a month), a `day` query
141+
* collapsed to duplicate month labels, and `quarter`/`year` lost their drill
142+
* ranges entirely. One function, called from all three sites, is what stops
143+
* that drift recurring.
144+
*/
145+
export function resolveDimensionGranularity(
146+
selection: Pick<DatasetSelection, 'timeDimensions' | 'dateGranularity'>,
147+
dimension: string,
148+
datasetDefault?: string,
149+
): DateGranularityValue | undefined {
150+
// `timeDimensions[].granularity` and the compiled cube's `granularities` are
151+
// both typed as bare strings by their own layers (Cube.js heritage), but the
152+
// only values that reach here come from the dataset/selection granularity
153+
// vocabulary — the same five the bucketing utilities accept.
154+
const stated = (selection.timeDimensions ?? []).find((t) => t.dimension === dimension)?.granularity;
155+
if (stated) return stated as DateGranularityValue;
156+
return selection.dateGranularity ?? (datasetDefault as DateGranularityValue | undefined);
157+
}
158+
113159
// ── ordering + windowing (#3588) ─────────────────────────────────────────────
114160

115161
/**
@@ -463,12 +509,11 @@ export class DatasetExecutor {
463509
// no dimension key and every `__compare` column came back empty.
464510
const selTimeDims = opts.selection.timeDimensions ?? [];
465511
const selDims = new Set(selTimeDims.map((t) => t.dimension));
466-
const selectionGranularity = opts.selection.dateGranularity;
467512
const granularityFor = (name: string): string | undefined => {
468513
const cd = compiled.cube.dimensions[name];
469514
if (cd?.type !== 'time') return undefined;
470515
const datasetDefault = cd.granularities?.length === 1 ? String(cd.granularities[0]) : undefined;
471-
return selectionGranularity ?? datasetDefault;
516+
return resolveDimensionGranularity(opts.selection, name, datasetDefault);
472517
};
473518
// Fill in a bucket size for caller-supplied entries that named none.
474519
const resolvedTimeDims = selTimeDims.map((t) => {

0 commit comments

Comments
 (0)