From 748c4bd5b992d373e41690ff4fbf118eaafaf702 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 23:16:52 +0000 Subject: [PATCH 1/2] test(service-analytics): pin the two bucket-key readers to core's writer for a year below 1000 (red) formatDateBucket labels every key bucketDateKey writes as written (0050, 0050-06, 0050-06-15), and bucketDate delegates to bucketDateKey, so a draft preview keys 0050-06-15 as the published path does, the ISO week label included. A 2026 control on each. Red at this commit: the fix follows. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude --- ...bucket-key-readers-four-digit-year.test.ts | 156 ++++++++++++++++++ .../__tests__/week-key-year-below-100.test.ts | 37 +++-- 2 files changed, 177 insertions(+), 16 deletions(-) create mode 100644 packages/services/service-analytics/src/__tests__/bucket-key-readers-four-digit-year.test.ts diff --git a/packages/services/service-analytics/src/__tests__/bucket-key-readers-four-digit-year.test.ts b/packages/services/service-analytics/src/__tests__/bucket-key-readers-four-digit-year.test.ts new file mode 100644 index 00000000000..e70fc98fd81 --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/bucket-key-readers-four-digit-year.test.ts @@ -0,0 +1,156 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20867] This package's two readers of a date-bucket key agree with + * `@objectstack/core`'s one writer, `bucketDateKey`, for a year in 0001..0999. + * + * - `formatDateBucket` (`dimension-labels.ts`) relabels a date dimension's + * grouped key on the published `queryDataset` path. Its year check admitted + * only 1000..9999, so the key `0050` was read as epoch seconds and labelled + * `1970`; a month or day key was re-spelled with its year unpadded (`50-06`, + * `50-06-15`). It now labels every key the writer writes as written, and + * what it relabels is spelled by the writer. + * - `bucketDate` (`preview-evaluator.ts`) keys a drafted seed row on the draft + * preview path. It spelled every key itself: the year unpadded (`50`, + * `50-Q2`, `50-06`, `50-06-15`), and the week as the Monday's + * `YYYY-MM-DD` where the runtime writes the ISO week label (`0050-W24`, + * `2026-W25`). It now delegates to `bucketDateKey`, so the preview adopts the + * runtime's ISO week label and a drafted chart keys a row the way the same + * dataset does once published. + * + * Pins: 0050 (with 0050-01-01, whose ISO week is `0049-W52`) and 0999, with a + * 2026 control. Every expected key is spelled literally. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { bucketDateKey } from '@objectstack/core'; +import { applyInMemoryAggregation } from '@objectstack/objectql'; +import { DatasetSchema } from '@objectstack/spec/ui'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { AnalyticsService } from '../analytics-service.js'; +import { formatDateBucket } from '../dimension-labels.js'; +import { bucketDate } from '../preview-evaluator.js'; + +const GRANULARITIES = ['year', 'quarter', 'month', 'week', 'day'] as const; +type Granularity = (typeof GRANULARITIES)[number]; + +/** The key the writer gives each instant, spelled literally (the reference). */ +const WRITTEN: ReadonlyArray]> = [ + ['0050-06-15T10:00:00.000Z', { year: '0050', quarter: '0050-Q2', month: '0050-06', week: '0050-W24', day: '0050-06-15' }], + ['0050-01-01T10:00:00.000Z', { year: '0050', quarter: '0050-Q1', month: '0050-01', week: '0049-W52', day: '0050-01-01' }], + ['0999-06-15T10:00:00.000Z', { year: '0999', quarter: '0999-Q2', month: '0999-06', week: '0999-W24', day: '0999-06-15' }], + // The control. + ['2026-06-15T10:00:00.000Z', { year: '2026', quarter: '2026-Q2', month: '2026-06', week: '2026-W25', day: '2026-06-15' }], +]; + +describe('[#20867] the reference: core writes these keys', () => { + it.each(WRITTEN)('%s', (instant, keys) => { + for (const g of GRANULARITIES) expect(bucketDateKey(instant, g), g).toBe(keys[g]); + }); +}); + +describe('[#20867] formatDateBucket labels every key the writer writes as written', () => { + it.each([ + ['0050', 'year'], + ['0050-06', 'month'], + ['0050-06-15', 'day'], + ] as const)('%s (%s) is labelled as written', (key, g) => { + expect(formatDateBucket(key, g)).toBe(key); + }); + + it.each(WRITTEN)('every granularity of %s', (_instant, keys) => { + for (const g of GRANULARITIES) expect(formatDateBucket(keys[g], g), g).toBe(keys[g]); + }); + + it.each([ + // A raw value a date dimension groups by when no bucket was applied; the + // label is the key the writer gives the same instant in UTC. + ['0050-06-15T10:00:00.000Z', { year: '0050', quarter: '0050-Q2', month: '0050-06', day: '0050-06-15' }], + ['0050-06-15', { year: '0050', quarter: '0050-Q2', month: '0050-06', day: '0050-06-15' }], + // The control. + ['2026-06-15T10:00:00.000Z', { year: '2026', quarter: '2026-Q2', month: '2026-06', day: '2026-06-15' }], + ] as const)('relabels the raw value %s with the year in four digits', (raw, keys) => { + for (const g of ['year', 'quarter', 'month', 'day'] as const) expect(formatDateBucket(raw, g), g).toBe(keys[g]); + expect(formatDateBucket(raw, undefined)).toBe(keys.day); + }); +}); + +describe('[#20867] bucketDate delegates to the writer', () => { + it.each(WRITTEN)('%s', (instant, keys) => { + const ms = Date.parse(instant); + for (const g of GRANULARITIES) { + expect(bucketDate(instant, g), g).toBe(keys[g]); + // The other two forms the writer reads, and the published path buckets. + expect(bucketDate(ms, g), `${g} (epoch ms)`).toBe(keys[g]); + expect(bucketDate(new Date(ms), g), `${g} (Date)`).toBe(keys[g]); + } + }); + + it('resolves the calendar day in the reference zone, as the writer does', () => { + // Sunday 0050-01-02 in UTC is Monday 0050-01-03 in Asia/Shanghai. + expect(bucketDate('0050-01-02T20:00:00.000Z', 'week', 'UTC')).toBe('0049-W52'); + expect(bucketDate('0050-01-02T20:00:00.000Z', 'week', 'Asia/Shanghai')).toBe('0050-W01'); + expect(bucketDate('0050-01-02T20:00:00.000Z', 'day', 'Asia/Shanghai')).toBe('0050-01-03'); + // The control. + expect(bucketDate('2026-06-14T20:00:00.000Z', 'week', 'Asia/Shanghai')).toBe('2026-W25'); + }); +}); + +describe('[#20867] a draft preview keys 0050-06-15 as the published path does', () => { + const CTX = { tenantId: 'org_A' } as ExecutionContext; + const DATASET = DatasetSchema.parse({ + name: 'expense_ds', label: 'Expense', object: 'expense', include: [], + dimensions: [{ name: 'spent_on', field: 'spent_on', type: 'date', dateGranularity: 'month' }], + measures: [{ name: 'expense_count', aggregate: 'count' }], + }); + // A `date` value and a `datetime` value on the same calendar day, and the control. + const ROWS = [ + { spent_on: '0050-06-15', amount: 10 }, + { spent_on: '0050-06-15T10:00:00.000Z', amount: 20 }, + { spent_on: '2026-06-15', amount: 30 }, + ]; + + /** The draft preview: the seed rows, evaluated in memory. */ + const preview = new AnalyticsService({ draftRowsResolver: vi.fn(async () => ROWS) }); + + /** + * The published path over the same rows: the ObjectQL strategy, the engine's + * in-memory grouping (`@objectstack/objectql`) as `executeAggregate`, and the + * dimension labels `queryDataset` resolves after grouping. + */ + const published = new AnalyticsService({ + queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), + executeAggregate: async (_object, options) => + applyInMemoryAggregation( + ROWS, + { + groupBy: options.groupBy, + aggregations: (options.aggregations ?? []).map((a) => ({ function: a.method, field: a.field, alias: a.alias })), + }, + options.timezone, + ), + sourceFieldMeta: (_o, f) => (f === 'spent_on' ? { type: 'date' } : undefined), + labelResolver: { + getObjectFields: (o) => (o === 'expense' ? { spent_on: { type: 'date' } } : undefined), + fetchRecordLabels: async () => new Map(), + }, + }); + + const keysOf = (rows: Record[]) => + rows.map((r) => [r.spent_on, r.expense_count]).sort((a, b) => String(a[0]).localeCompare(String(b[0]))); + + it.each([ + ['year', '0050', '2026'], + ['quarter', '0050-Q2', '2026-Q2'], + ['month', '0050-06', '2026-06'], + ['week', '0050-W24', '2026-W25'], + ['day', '0050-06-15', '2026-06-15'], + ] as const)('%s: %s, and %s for the control', async (g, key, control) => { + const selection = { dimensions: ['spent_on'], measures: ['expense_count'], dateGranularity: g }; + const drafted = await preview.queryDataset(DATASET, selection, CTX, { previewDrafts: true }); + const live = await published.queryDataset(DATASET, selection, CTX); + const expected = [[key, 2], [control, 1]]; + expect(keysOf(live.rows), 'published').toEqual(expected); + expect(keysOf(drafted.rows), 'preview').toEqual(expected); + }); +}); diff --git a/packages/services/service-analytics/src/__tests__/week-key-year-below-100.test.ts b/packages/services/service-analytics/src/__tests__/week-key-year-below-100.test.ts index 4751f4ce592..efd1ed7c8ea 100644 --- a/packages/services/service-analytics/src/__tests__/week-key-year-below-100.test.ts +++ b/packages/services/service-analytics/src/__tests__/week-key-year-below-100.test.ts @@ -6,7 +6,10 @@ * * - The preview evaluator's `week` key (`bucketDate`) built the day with * `Date.UTC(year, …)`, which reads a year from 0 to 99 as 1900 + year, so a - * row on 0050-06-15 was keyed to a Monday in 1950. + * row on 0050-06-15 was keyed to a Monday in 1950. [#20867] It delegates to + * core's `bucketDateKey` now, so its week key is the runtime's ISO week label + * (`0050-W24`), no longer the Monday's `YYYY-MM-DD`; the pins below read the + * label, and `bucket-key-readers-four-digit-year.test.ts` pins the rest. * - The dataset executor's `compareTo` alignment counts bucket ordinals from a * bound's day (core's `zonedDateStartToUtcMs`, the same remap) and mints a * week key back from an ordinal (then a private week rule, whose January 4 @@ -36,26 +39,28 @@ describe.each(HOSTS)('on a %s host', (host) => { expect(Intl.DateTimeFormat().resolvedOptions().timeZone).toBe(host); }); - describe('[#20599] preview bucketDate(week) names the Monday of a day in 0001..0099', () => { + describe('[#20599] preview bucketDate(week) names the ISO week of a day in 0001..0099', () => { it.each([ - ['0001-01-03T10:00:00.000Z', '0001-01-01'], - ['0050-01-01T10:00:00.000Z', '0049-12-27'], - ['0050-06-15T10:00:00.000Z', '0050-06-13'], - ['0099-12-31T10:00:00.000Z', '0099-12-28'], - ['0100-01-06T10:00:00.000Z', '0100-01-04'], - ['2026-06-17T10:00:00.000Z', '2026-06-15'], - ])('%s in UTC is the week of %s', (instant, monday) => { - expect(bucketDate(instant, 'week')).toBe(monday); - expect(bucketDate(instant, 'week', 'UTC')).toBe(monday); + // [#20867] The ISO week label; the Monday each week starts on is in + // the comment. + ['0001-01-03T10:00:00.000Z', '0001-W01'], // Monday 0001-01-01 + ['0050-01-01T10:00:00.000Z', '0049-W52'], // Monday 0049-12-27 + ['0050-06-15T10:00:00.000Z', '0050-W24'], // Monday 0050-06-13 + ['0099-12-31T10:00:00.000Z', '0099-W53'], // Monday 0099-12-28 + ['0100-01-06T10:00:00.000Z', '0100-W01'], // Monday 0100-01-04 + ['2026-06-17T10:00:00.000Z', '2026-W25'], // Monday 2026-06-15 + ])('%s in UTC is in week %s', (instant, week) => { + expect(bucketDate(instant, 'week')).toBe(week); + expect(bucketDate(instant, 'week', 'UTC')).toBe(week); }); it.each([ // Sunday 0050-01-02 in UTC is Monday 0050-01-03 in Shanghai. - ['0050-01-02T20:00:00.000Z', '0049-12-27', '0050-01-03'], - ['0001-01-07T20:00:00.000Z', '0001-01-01', '0001-01-08'], - ['0099-12-31T20:00:00.000Z', '0099-12-28', '0099-12-28'], - ['2026-06-14T20:00:00.000Z', '2026-06-08', '2026-06-15'], - ])('%s is the week of %s in UTC and of %s in Asia/Shanghai', (instant, utc, shanghai) => { + ['0050-01-02T20:00:00.000Z', '0049-W52', '0050-W01'], + ['0001-01-07T20:00:00.000Z', '0001-W01', '0001-W02'], + ['0099-12-31T20:00:00.000Z', '0099-W53', '0099-W53'], + ['2026-06-14T20:00:00.000Z', '2026-W24', '2026-W25'], + ])('%s is in week %s in UTC and in week %s in Asia/Shanghai', (instant, utc, shanghai) => { expect(bucketDate(instant, 'week')).toBe(utc); expect(bucketDate(instant, 'week', 'Asia/Shanghai')).toBe(shanghai); }); From 25ba591849c1aaad8956e0dd21423189c7aed620 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 23:18:36 +0000 Subject: [PATCH 2/2] fix(service-analytics): the two bucket-key readers match core's writer for a year below 1000, and the preview keys a week by its ISO label formatDateBucket labels a key bucketDateKey writes as written, recognised by core's bucketKeyToCalendarRange (0050 was labelled 1970, 0050-06 became 50-06), and relabels a raw value through bucketDateKey in UTC. bucketDate delegates to bucketDateKey: no second padding, and the draft preview now writes the runtime's ISO week label, so a drafted chart keys a row as the published path does. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude --- .changeset/20867-analytics-year-keys.md | 8 +++ .../service-analytics/src/dimension-labels.ts | 48 ++++++++++------- .../src/preview-evaluator.ts | 51 ++++++++----------- 3 files changed, 59 insertions(+), 48 deletions(-) create mode 100644 .changeset/20867-analytics-year-keys.md diff --git a/.changeset/20867-analytics-year-keys.md b/.changeset/20867-analytics-year-keys.md new file mode 100644 index 00000000000..9ca1736e81e --- /dev/null +++ b/.changeset/20867-analytics-year-keys.md @@ -0,0 +1,8 @@ +--- +'@objectstack/service-analytics': patch +--- + +A dataset's date dimension reads the bucket key `@objectstack/core`'s `bucketDateKey` writes, with its year in four digits, and a draft preview keys a row the way the same dataset does once published. + +- **Dimension labels (`queryDataset`).** A date dimension's grouped key is labelled as written. The year key `0050` was labelled `1970` (read as epoch seconds, because the year check admitted only 1000..9999), and a month or day key lost its padding (`0050-06` became `50-06`, `0050-06-15` became `50-06-15`). A raw date value is relabelled with the year in four digits too. A year from 1000 to 9999 is labelled as before. +- **Draft preview (`queryDataset` with `previewDrafts`).** Drafted seed rows are keyed by `bucketDateKey` itself, the key the published path's grouping writes. For 0050-06-15 the preview answered `50`, `50-Q2`, `50-06` and `50-06-15`; it now answers `0050`, `0050-Q2`, `0050-06` and `0050-06-15`. A `week` bucket is now the ISO week label (`2026-W25`), no longer the Monday's date (`2026-06-15`), so a weekly `compareTo` in the preview merges each comparison row onto its week, as the published path does. An epoch-milliseconds value is bucketed by its instant (it was the empty bucket), and a `Date` in 0001..0999 by its own year (a `Date` in 0050 keyed `1950`). diff --git a/packages/services/service-analytics/src/dimension-labels.ts b/packages/services/service-analytics/src/dimension-labels.ts index 682193a14bc..50578e3e0ed 100644 --- a/packages/services/service-analytics/src/dimension-labels.ts +++ b/packages/services/service-analytics/src/dimension-labels.ts @@ -39,6 +39,7 @@ import type { ExecutionContext } from '@objectstack/spec/kernel'; import { referenceTargetOf } from '@objectstack/spec/data'; +import { bucketDateKey, bucketKeyToCalendarRange, isBucketGranularity } from '@objectstack/core'; /** The minimal field shape this resolver needs. */ export interface FieldMetaLite { @@ -290,8 +291,6 @@ export function withLabelFetchCache(deps: DimensionLabelDeps): DimensionLabelDep /** Date-dimension granularity (mirrors the dataset `dateGranularity` enum). */ export type DateGranularity = 'day' | 'week' | 'month' | 'quarter' | 'year'; -const pad = (n: number) => String(n).padStart(2, '0'); - /** * Format a raw date value (epoch-ms number, numeric string, ISO string, or * Date) to a human, sort-stable bucket label per granularity. Returns the input @@ -308,18 +307,35 @@ const pad = (n: number) => String(n).padStart(2, '0'); * here it is *already* the reference-zone bucket (often a label string like * "2026-Q2"). Re-applying a timezone here would shift an already-correct * `YYYY-MM-DD` day bucket by a day — this is a pure, idempotent re-labeler. + * + * [#20867] It reads the keys `@objectstack/core`'s `bucketDateKey` writes and + * spells none itself. A key that writer writes at `granularity` — the year in + * four digits, `0050` / `0050-06` / `0050-06-15` included — is recognised by + * core's one reader of those keys, `bucketKeyToCalendarRange`, and returned as + * written. A raw value is relabelled by `bucketDateKey` itself, in UTC (a + * `week` or unstated granularity as the value's own `day` key, as before). + * Before, the year check admitted only 1000..9999, so the key `0050` was read + * as epoch seconds (`1970`), and a relabelled year below 1000 lost its padding + * (`50-06`, `50-06-15`). */ export function formatDateBucket(value: unknown, granularity?: DateGranularity | string): unknown { if (value == null || value instanceof Date === false) { if (typeof value !== 'number' && typeof value !== 'string') return value; } - // A YEAR bucket's canonical key IS the bare year ("2026" / 2026) — which the - // epoch heuristic below would read as 2026 milliseconds and relabel "1970". - // Being idempotent over already-formatted bucket keys is this function's whole - // contract, and every other granularity's key already survives the round trip - // ("2026-Q2", "2026-07", "2026-07-15" all fail the pure-digit test); only the - // year key collides with it. Recognised before parsing, for both the string - // and numeric forms drivers return. + // [#20867] A key the writer writes at this granularity is labelled as + // written: this function is idempotent over bucket keys, and core's reader, + // not a second pattern here, decides what one is. + if ( + typeof value === 'string' && + isBucketGranularity(granularity) && + bucketKeyToCalendarRange(value, granularity) !== null + ) { + return value; + } + // The NUMERIC year key: a year bucket's key is the bare year, and a driver + // may answer it as a number (2026), which the epoch heuristic below would + // read as 2026 milliseconds and relabel "1970". Recognised before parsing; + // the string form is recognised above. if (granularity === 'year') { const y = typeof value === 'number' ? value : Number(String(value).trim()); if (Number.isInteger(y) && y >= 1000 && y <= 9999) return String(y); @@ -333,16 +349,10 @@ export function formatDateBucket(value: unknown, granularity?: DateGranularity | d = /^\d+$/.test(s) ? new Date(Number(s) < 1e12 ? Number(s) * 1000 : Number(s)) : new Date(s); } if (Number.isNaN(d.getTime())) return value; - const y = d.getUTCFullYear(); - const m = d.getUTCMonth(); // 0-11 - switch (granularity) { - case 'year': return String(y); - case 'quarter': return `${y}-Q${Math.floor(m / 3) + 1}`; - case 'month': return `${y}-${pad(m + 1)}`; - case 'week': - case 'day': - default: return `${y}-${pad(m + 1)}-${pad(d.getUTCDate())}`; - } + // [#20867] Spelled by the writer, in UTC (no zone: see above). + const labelGranularity = + isBucketGranularity(granularity) && granularity !== 'week' ? granularity : 'day'; + return bucketDateKey(d, labelGranularity); } /** diff --git a/packages/services/service-analytics/src/preview-evaluator.ts b/packages/services/service-analytics/src/preview-evaluator.ts index 522ef30f91f..5eafd898fef 100644 --- a/packages/services/service-analytics/src/preview-evaluator.ts +++ b/packages/services/service-analytics/src/preview-evaluator.ts @@ -31,13 +31,13 @@ // condition is the one this face evaluates. import { - calendarPartsInTzOrUtc, + bucketDateKey, nextUtcCalendarDay, resolveAnalyticsDateRangeString, utcInstantMs, isUnboundedAbove, compensatedSum, - wallClockToUtcMs, + type BucketGranularity, } from '@objectstack/core'; import { explicitDateRangeWindow } from './date-range-array-arm.js'; // [#19810] The `where` door's refusal envelope — `INVALID_FILTER` / 400, @@ -361,32 +361,25 @@ export function matchesWhere(row: Row, where: Record | undefine // ── Time bucketing ────────────────────────────────────────────────────────── -export function bucketDate(value: unknown, granularity: string, timezone?: string): string | null { - const d = new Date(String(value)); - if (Number.isNaN(d.getTime())) return null; - // ADR-0053 Phase 2: resolve the calendar day in the reference zone so an - // instant near a tz day-boundary buckets where a user in that zone expects. - // Unset / 'UTC' / invalid keeps the historical UTC bucketing. - const { year: y, month, day: dayNum } = calendarPartsInTzOrUtc(d, timezone); - const m = `${month}`.padStart(2, '0'); - const day = `${dayNum}`.padStart(2, '0'); - switch (granularity) { - case 'year': return `${y}`; - case 'quarter': return `${y}-Q${Math.floor((month - 1) / 3) + 1}`; - case 'month': return `${y}-${m}`; - case 'week': { - // Build a UTC date from the zone-shifted parts, then step back to Monday. - // [#20599] Through core's `wallClockToUtcMs`, never `Date.UTC`, which - // reads a year from 0 to 99 as 1900 + year. - const monday = new Date(wallClockToUtcMs({ year: y, month, day: dayNum })); - const dow = (monday.getUTCDay() + 6) % 7; // Monday=0 - monday.setUTCDate(monday.getUTCDate() - dow); - return monday.toISOString().slice(0, 10); - } - case 'day': - default: - return `${y}-${m}-${day}`; - } +/** + * [#20867] The bucket key of one drafted row's date value: `@objectstack/core`'s + * `bucketDateKey`, the writer the published path's in-memory grouping delegates + * to and whose key the drivers' bucket SQL answers. So a draft preview keys a + * row as the same dataset does once published — the year in four digits + * (`0050-06`), the ISO week label (`2026-W25`), an epoch-ms number or a `Date` + * read as its instant — and this face spells no key of its own. + * + * ADR-0053 Phase 2: the calendar day is resolved in the reference `timezone` + * (unset / `'UTC'` / invalid keeps UTC), inside the writer. + * + * It replaced a local spelling of every granularity, which wrote a year below + * 1000 unpadded (`50`, `50-Q2`, `50-06`, `50-06-15`), keyed a week by its + * Monday's `YYYY-MM-DD` where the runtime writes the ISO week label, re-parsed + * a `Date` through its string form (a `Date` in 0050 keyed `1950`), and read an + * epoch-ms number as unparseable (the empty bucket). + */ +export function bucketDate(value: unknown, granularity: BucketGranularity, timezone?: string): string | null { + return bucketDateKey(value, granularity, timezone); } // ── Aggregation ───────────────────────────────────────────────────────────── @@ -741,7 +734,7 @@ export function evaluateAnalyticsQueryOverRows( const dim = cube.dimensions?.[name]; const field = String(dim?.sql ?? name); const raw = r[field]; - const gran = granByDim.get(name) ?? (dim?.type === 'time' && dim.granularities?.length === 1 ? String(dim.granularities[0]) : undefined); + const gran = granByDim.get(name) ?? (dim?.type === 'time' && dim.granularities?.length === 1 ? dim.granularities[0] : undefined); values[name] = gran ? bucketDate(raw, gran, timezone) : (raw ?? null); } return { key: JSON.stringify(values), values };