From 4a0736b2bf7579dde717ed28db11d6db480af067 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Wed, 10 Jun 2026 16:15:41 +0500 Subject: [PATCH] feat(service-analytics): bucket + format date dimensions (no more epoch millis) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Date dimensions on trend charts rendered the raw stored timestamp (`1777632968596`) because a plain selected date dimension grouped by the raw field and the value was never formatted. - DatasetExecutor: a selected date dimension that declares an explicit `dateGranularity` (single-entry compiled `granularities`) is promoted to a time dimension, so it buckets (one row per month/day/…) instead of one row per raw timestamp. Dimensions already carried by `selection.timeDimensions` (e.g. compareTo) are untouched. - dimension-labels: grouped date values are formatted to a sort-stable label per granularity (year `2026`, quarter `2026-Q2`, month `2026-04`, day/week `2026-04-15`); parses epoch-ms/epoch-s/ISO defensively and leaves non-dates unchanged. - showcase chart-gallery dataset: `created_at` gains `dateGranularity: 'month'` so the trend widgets bucket monthly. Browser-verified on the showcase Chart Gallery: "Tasks Created (monthly)" now shows `2026-05` / `2026-06` buckets. turbo build 72/72, test 123/123 green. Co-Authored-By: Claude Opus 4.8 --- .changeset/analytics-date-dimension-format.md | 16 +++++ .../src/datasets/chart-gallery.dataset.ts | 2 +- .../src/__tests__/dimension-labels.test.ts | 44 ++++++++++++-- .../src/analytics-service.ts | 2 +- .../service-analytics/src/dataset-executor.ts | 17 +++++- .../service-analytics/src/dimension-labels.ts | 58 ++++++++++++++++++- 6 files changed, 130 insertions(+), 9 deletions(-) create mode 100644 .changeset/analytics-date-dimension-format.md diff --git a/.changeset/analytics-date-dimension-format.md b/.changeset/analytics-date-dimension-format.md new file mode 100644 index 0000000000..4277950c4d --- /dev/null +++ b/.changeset/analytics-date-dimension-format.md @@ -0,0 +1,16 @@ +--- +"@objectstack/service-analytics": minor +--- + +Analytics now renders date dimensions as human bucket labels instead of raw +epoch millis, and buckets them by their declared granularity. + +- A date dimension with an explicit `dateGranularity` is now grouped by that + bucket (the executor promotes it to a time dimension), so a "monthly" trend + chart shows one point per month rather than one per raw timestamp. +- Grouped date values are formatted to a sort-stable label per granularity + (`year` → `2026`, `quarter` → `2026-Q2`, `month` → `2026-04`, `day`/`week` + → `2026-04-15`), so charts no longer show `1777632968596`. + +Pairs with the dimension display-label resolution (select option labels / lookup +names) shipped previously. diff --git a/examples/app-showcase/src/datasets/chart-gallery.dataset.ts b/examples/app-showcase/src/datasets/chart-gallery.dataset.ts index bf2a8ce6c1..55b39ce275 100644 --- a/examples/app-showcase/src/datasets/chart-gallery.dataset.ts +++ b/examples/app-showcase/src/datasets/chart-gallery.dataset.ts @@ -17,7 +17,7 @@ export const ShowcaseTaskDataset = defineDataset({ { name: 'status', label: 'Status', field: 'status', type: 'string' }, { name: 'priority', label: 'Priority', field: 'priority', type: 'string' }, { name: 'progress', label: 'Progress', field: 'progress', type: 'number' }, - { name: 'created_at', label: 'Created', field: 'created_at', type: 'date' }, + { name: 'created_at', label: 'Created', field: 'created_at', type: 'date', dateGranularity: 'month' }, ], measures: [ { name: 'task_count', label: 'Tasks', aggregate: 'count' }, diff --git a/packages/services/service-analytics/src/__tests__/dimension-labels.test.ts b/packages/services/service-analytics/src/__tests__/dimension-labels.test.ts index ee90d9663b..a980e91f39 100644 --- a/packages/services/service-analytics/src/__tests__/dimension-labels.test.ts +++ b/packages/services/service-analytics/src/__tests__/dimension-labels.test.ts @@ -6,6 +6,7 @@ import { AnalyticsService } from '../analytics-service.js'; import { resolveDimensionLabels, pickDisplayField, + formatDateBucket, type DimensionLabelDeps, type FieldMetaLite, } from '../dimension-labels.js'; @@ -73,10 +74,23 @@ describe('resolveDimensionLabels', () => { expect(rows).toEqual([{ account: 'orphan', budget_sum: 1 }]); }); - it('is a no-op for date / plain dimensions', async () => { - const rows = [{ created_at: '2026-01', task_count: 2 }]; - await resolveDimensionLabels('task', [{ name: 'created_at', field: 'created_at' }], rows, deps()); - expect(rows).toEqual([{ created_at: '2026-01', task_count: 2 }]); + it('formats a date dimension value to a human bucket label', async () => { + // epoch-ms for 2026-04-15T00:00:00Z + const ts = Date.UTC(2026, 3, 15); + const rows = [{ created_at: ts, task_count: 2 }]; + await resolveDimensionLabels( + 'task', + [{ name: 'created_at', field: 'created_at', type: 'date', dateGranularity: 'month' }], + rows, + deps(), + ); + expect(rows).toEqual([{ created_at: '2026-04', task_count: 2 }]); + }); + + it('is a no-op for a plain string dimension', async () => { + const rows = [{ progress: '50', task_count: 2 }]; + await resolveDimensionLabels('task', [{ name: 'progress', field: 'progress' }], rows, deps()); + expect(rows).toEqual([{ progress: '50', task_count: 2 }]); }); it('does nothing when the object is unknown to the engine', async () => { @@ -105,6 +119,28 @@ describe('resolveDimensionLabels', () => { }); }); +describe('formatDateBucket', () => { + const ts = Date.UTC(2026, 3, 15); // 2026-04-15 + it('formats per granularity', () => { + expect(formatDateBucket(ts, 'year')).toBe('2026'); + expect(formatDateBucket(ts, 'quarter')).toBe('2026-Q2'); + expect(formatDateBucket(ts, 'month')).toBe('2026-04'); + expect(formatDateBucket(ts, 'day')).toBe('2026-04-15'); + expect(formatDateBucket(ts, undefined)).toBe('2026-04-15'); + }); + it('parses epoch-ms numeric strings and ISO strings', () => { + expect(formatDateBucket(String(ts), 'month')).toBe('2026-04'); + expect(formatDateBucket('2026-04-15T10:00:00Z', 'month')).toBe('2026-04'); + }); + it('parses epoch-seconds', () => { + expect(formatDateBucket(String(Math.floor(ts / 1000)), 'month')).toBe('2026-04'); + }); + it('returns the input unchanged when not a parseable date', () => { + expect(formatDateBucket('not-a-date', 'month')).toBe('not-a-date'); + expect(formatDateBucket(null)).toBe(null); + }); +}); + describe('pickDisplayField', () => { it('prefers name > title > label', () => { expect(pickDisplayField({ title: { type: 'text' }, name: { type: 'text' } })).toBe('name'); diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index e23f61c892..fc2de3c9f7 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -333,7 +333,7 @@ export class AnalyticsService implements IAnalyticsService { const dims = selection.dimensions .map((name) => dataset.dimensions?.find((d) => d.name === name)) .filter((d): d is NonNullable => !!d?.field) - .map((d) => ({ name: d.name, field: d.field })); + .map((d) => ({ name: d.name, field: d.field, type: d.type, dateGranularity: d.dateGranularity })); if (dims.length) { try { await resolveDimensionLabels(dataset.object, dims, result.rows, this.labelResolver); diff --git a/packages/services/service-analytics/src/dataset-executor.ts b/packages/services/service-analytics/src/dataset-executor.ts index 96b09a1c3f..a564ce15a8 100644 --- a/packages/services/service-analytics/src/dataset-executor.ts +++ b/packages/services/service-analytics/src/dataset-executor.ts @@ -220,7 +220,22 @@ export class DatasetExecutor { timezone: opts.selection.timezone ?? 'UTC', }; if (opts.where) q.where = opts.where as Record; - if (opts.selection.timeDimensions) q.timeDimensions = opts.selection.timeDimensions; + // Bucket selected date dimensions that declare an explicit `dateGranularity` + // (the dataset compiled a single-entry `granularities`). Without this a date + // dimension groups by the raw timestamp — one bucket per row, rendering epoch + // millis on trend charts. A dimension already carried by `selection.timeDimensions` + // (e.g. compareTo) keeps its entry; we never override it. + const selTimeDims = opts.selection.timeDimensions ?? []; + const selDims = new Set(selTimeDims.map((t) => t.dimension)); + const explicitTimeDims: Array<{ dimension: string; granularity: string }> = []; + for (const name of opts.dimensions) { + const cd = compiled.cube.dimensions[name]; + if (cd?.type === 'time' && cd.granularities?.length === 1 && !selDims.has(name)) { + explicitTimeDims.push({ dimension: name, granularity: String(cd.granularities[0]) }); + } + } + const mergedTimeDims = [...selTimeDims, ...explicitTimeDims]; + if (mergedTimeDims.length > 0) q.timeDimensions = mergedTimeDims as AnalyticsQuery['timeDimensions']; if (opts.selection.order) q.order = opts.selection.order; if (opts.selection.limit != null) q.limit = opts.selection.limit; if (opts.selection.offset != null) q.offset = opts.selection.offset; diff --git a/packages/services/service-analytics/src/dimension-labels.ts b/packages/services/service-analytics/src/dimension-labels.ts index dbe28d32a5..fe62b56f5c 100644 --- a/packages/services/service-analytics/src/dimension-labels.ts +++ b/packages/services/service-analytics/src/dimension-labels.ts @@ -45,17 +45,59 @@ export interface DimensionLabelDeps { const LOOKUP_TYPES = new Set(['lookup', 'master_detail']); +/** 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 + * unchanged when it isn't a parseable date, so a non-date value never blanks. + * + * year → "2026" + * quarter → "2026-Q2" + * month → "2026-04" + * week → "2026-04-13" (ISO date of the bucket) + * day → "2026-04-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; + } + let d: Date; + if (value instanceof Date) d = value; + else if (typeof value === 'number') d = new Date(value); + else { + const s = String(value).trim(); + // Pure-digit strings are epoch millis (or seconds); otherwise let Date parse ISO. + 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())}`; + } +} + /** * Replace raw dimension values with display labels, in place. * * @param baseObject - the dataset's base object (where the dimension fields live) - * @param dims - selected dimensions as `{ name, field }` (row key = `name`) + * @param dims - selected dimensions as `{ name, field, type?, dateGranularity? }` + * (row key = `name`) * @param rows - result rows, mutated in place * @param deps - injected runtime capabilities */ export async function resolveDimensionLabels( baseObject: string, - dims: Array<{ name: string; field: string }>, + dims: Array<{ name: string; field: string; type?: string; dateGranularity?: DateGranularity | string }>, rows: Record[], deps: DimensionLabelDeps, ): Promise { @@ -65,6 +107,18 @@ export async function resolveDimensionLabels( for (const dim of dims) { const meta = fields[dim.field]; + + // ── date: epoch / ISO → human bucket label ──────────────────────── + // A date dimension's grouped value is a raw timestamp (or a bucket start); + // either way it must render as a readable date, not epoch millis. + if (dim.type === 'date' || (meta && meta.type === 'date')) { + for (const row of rows) { + const formatted = formatDateBucket(row[dim.name], dim.dateGranularity); + if (formatted != null) row[dim.name] = formatted; + } + continue; + } + if (!meta) continue; // ── select: value → option label ──────────────────────────────────