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
8 changes: 8 additions & 0 deletions .changeset/20867-analytics-year-keys.md
Original file line number Diff line number Diff line change
@@ -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`).
Original file line number Diff line number Diff line change
@@ -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<readonly [string, Record<Granularity, string>]> = [
['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<string, unknown>[]) =>
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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
});
Expand Down
48 changes: 29 additions & 19 deletions packages/services/service-analytics/src/dimension-labels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand All @@ -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);
}

/**
Expand Down
Loading
Loading