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
16 changes: 16 additions & 0 deletions .changeset/analytics-date-dimension-format.md
Original file line numberDiff line numberDiff line change
@@ -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.
Original file line numberDiff line numberDiff line change
Expand Up@@ -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' },
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,7 @@ import { AnalyticsService } from '../analytics-service.js';
import {
resolveDimensionLabels,
pickDisplayField,
formatDateBucket,
type DimensionLabelDeps,
type FieldMetaLite,
} from '../dimension-labels.js';
Expand DownExpand Up@@ -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 () => {
Expand DownExpand Up@@ -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');
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<typeof d> => !!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);
Expand Down
17 changes: 16 additions & 1 deletion packages/services/service-analytics/src/dataset-executor.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -220,7 +220,22 @@ export class DatasetExecutor {
timezone: opts.selection.timezone ?? 'UTC',
};
if (opts.where) q.where = opts.where as Record<string, unknown>;
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;
Expand Down
58 changes: 56 additions & 2 deletions packages/services/service-analytics/src/dimension-labels.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, unknown>[],
deps: DimensionLabelDeps,
): Promise<void> {
Expand All@@ -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 ──────────────────────────────────
Expand Down