Skip to content

Commit 525b813

Browse files
fix(service-analytics): a date-bucket key's two readers match core's writer for a year below 1000, and the draft preview keys a week by its ISO label (#20867) (#20971)
Fixes #20867 Clause-②: no ## What changes Triage direction 5912878801, as ruled: the two `service-analytics` readers of a date-bucket key match `@objectstack/core`'s one writer, `bucketDateKey`. Nothing in `packages/core`, `packages/spec` or `packages/objectql` moves. - **`dimension-labels.ts` `formatDateBucket`** (the dimension labels `queryDataset` resolves after grouping). A string that is a key the writer writes at the stated granularity is returned as written. What counts as one is decided by core's one reader of those keys, `bucketKeyToCalendarRange`, not by a second pattern here. A raw value (epoch ms, ISO text, `Date`) is relabelled by `bucketDateKey` itself, in UTC, so no label here spells a year of its own. A `week` or unstated granularity keeps labelling a raw value as its own `day` key, as before. The numeric-year branch (a driver answering `2026` as a number) is unchanged. - **`preview-evaluator.ts` `bucketDate`** (the draft preview, `queryDataset` with `previewDrafts`). It is now `bucketDateKey(value, granularity, timezone)`, typed with core's `BucketGranularity`. No second padding. Its caller passes the cube dimension's single granularity as typed, where it used to pass `String(...)` of it. `bucketDateKey`, `bucketKeyToCalendarRange`, `isBucketGranularity` and `BucketGranularity` are exports of `@objectstack/core` (`src/utils/datetime.ts`, re-exported by `src/index.ts`), a runtime dependency of this package that `dataset-executor.ts` and `analytics-service.ts` already import them from. ## Before and after Base `f6ccca4a44`, head `25ba591849`. Function level, `TZ=UTC`, read with a scratch `tsx` script over the package's own sources and core's built `dist`. The reference column is `bucketDateKey`. | input | driver | reference | before | after | |:--|:--|:--|:--|:--| | key `0050` | `formatDateBucket(key, 'year')` | `0050` | `1970` | `0050` | | key `0050-06` | `formatDateBucket(key, 'month')` | `0050-06` | `50-06` | `0050-06` | | key `0050-06-15` | `formatDateBucket(key, 'day')` | `0050-06-15` | `50-06-15` | `0050-06-15` | | key `0999` | `formatDateBucket(key, 'year')` | `0999` | `1970` | `0999` | | keys `0050-Q2`, `0050-W24`, `0049-W52` | `formatDateBucket` | as written | as written | as written | | raw `0050-06-15T10:00:00.000Z` | `formatDateBucket(raw, year / quarter / month / day)` | `0050` · `0050-Q2` · `0050-06` · `0050-06-15` | `50` · `50-Q2` · `50-06` · `50-06-15` | `0050` · `0050-Q2` · `0050-06` · `0050-06-15` | | `2026` keys and raw values (control) | `formatDateBucket` | | unchanged | unchanged | | `0050-06-15T10:00:00.000Z` | `bucketDate` year / quarter / month / week / day | `0050` · `0050-Q2` · `0050-06` · `0050-W24` · `0050-06-15` | `50` · `50-Q2` · `50-06` · `0050-06-13` · `50-06-15` | as the reference | | `0050-01-01T10:00:00.000Z` | `bucketDate` week | `0049-W52` | `0049-12-27` | `0049-W52` | | `0999-06-15T10:00:00.000Z` | `bucketDate` year / month | `0999` · `0999-06` | `999` · `999-06` | as the reference | | `2026-06-15T10:00:00.000Z` (control) | `bucketDate` week | `2026-W25` | `2026-06-15` | `2026-W25` | | `2026-06-15T10:00:00.000Z` (control) | `bucketDate` year / quarter / month / day | `2026` · `2026-Q2` · `2026-06` · `2026-06-15` | the same | the same | | epoch ms of `2026-06-15T10Z` / `0050-06-15T10Z` | `bucketDate`, every granularity | the key | `null` (the empty bucket) | the key | | `Date` of `0050-06-15T10Z` | `bucketDate` year / day | `0050` · `0050-06-15` | `1950` · `1950-06-15` | as the reference | And at the `queryDataset` level, over the same three rows (`0050-06-15`, `0050-06-15T10:00:00.000Z`, `2026-06-15`). The published leg is the ObjectQL strategy over the engine's in-memory grouping (`@objectstack/objectql` `applyInMemoryAggregation`) followed by the dimension labels. The preview leg is `previewDrafts` over the same rows as the seed draft. | granularity | published, before | preview, before | both, after | |:--|:--|:--|:--| | year | `1970`:2 · `2026`:1 | `50`:2 · `2026`:1 | `0050`:2 · `2026`:1 | | quarter | `0050-Q2`:2 · `2026-Q2`:1 | `50-Q2`:2 · `2026-Q2`:1 | `0050-Q2`:2 · `2026-Q2`:1 | | month | `50-06`:2 · `2026-06`:1 | `50-06`:2 · `2026-06`:1 | `0050-06`:2 · `2026-06`:1 | | week | `0050-W24`:2 · `2026-W25`:1 | `0050-06-13`:2 · `2026-06-15`:1 | `0050-W24`:2 · `2026-W25`:1 | | day | `50-06-15`:2 · `2026-06-15`:1 | `50-06-15`:2 · `2026-06-15`:1 | `0050-06-15`:2 · `2026-06-15`:1 | ## The preview's week key (triage's third bullet): measured, and adopted - **Measured at base:** the preview wrote the Monday's `YYYY-MM-DD` (`2026-06-15`) where the runtime writes the ISO week label (`2026-W25`), for every year, the 2026 control included. - **Adopted:** `bucketDate` delegating to `bucketDateKey` writes the ISO week label. The difference is not kept. - **Why it matters beyond the label:** the preview runs the same `DatasetExecutor`, whose `compareTo` alignment reads a key through `bucketKeyToCalendarRange`, and that reader answers `null` for a Monday date at `week`. Measured with one row on `2026-06-15` and one on `2025-06-16`, a `previousYear` comparison over the window `2026-06-15`..`2026-06-21`: - published, before and after: one row, `2026-W25`, current 1, compare 1; - preview, before: two rows, `2026-06-15` (1 / 0) and `2025-06-16` (0 / 1), the comparison appended as a foreign row; - preview, after: one row, `2026-W25` (1 / 1), as published. - The two existing preview week pins (`week-key-year-below-100.test.ts`, [#20599]) pinned the Monday date. They now read the ISO label for the same instants, and the Monday each week starts on stays in a comment. ## Clause-② measured `no`, with no arm. The package's public entry (`src/index.ts`, `exports` `.` only) exports neither function, so no export is added, removed or retyped. What changes is published output: the labels `resolveDimensionLabels` / `queryDataset` give a key or a raw value in 0001..0999, and the preview's keys. Each now equals what the published path answers or the writer writes. No input that was answered before is refused now. The public door parses `timeDimensions[].granularity` and `dateGranularity` against `DatasetSelectionSchema` (read at schema level: `hour` refused, `week` accepted), so `bucketDate`'s narrowed parameter type admits every value that door lets through. The line parses as `no` under `scripts/pm/clause2-line.mjs` `readClause2Line`. ## Pins `src/__tests__/bucket-key-readers-four-digit-year.test.ts` (new), every expected key spelled literally, 0050 / 0050-01-01 / 0999 with a 2026 control: - `0050`, `0050-06` and `0050-06-15` label as written, and so does every key the writer gives each instant at each granularity; - a raw value in 0050 is relabelled with the year in four digits; - `bucketDate` equals the writer for ISO text, epoch ms and a `Date`, and in `Asia/Shanghai` (`0049-W52` in UTC is `0050-W01` there); - a draft preview keys `0050-06-15` as the published path does, at every granularity, with the 2026 control. Committed red first (`748c4bd5b9`): 38 failed / 28 passed of 66 across the new file and the two updated preview week pins. The fix is `25ba591849`. ## Ablation At `25ba591849`, one locked script. The subjects are imported from `src/` by relative path, so no build is involved. Every mutation went through `scripts/ablation-replace.mjs` (anchor 1 to 0, blob changed) and was restored by it: the blob equals HEAD's (`dimension-labels.ts` `50578e3e0edc`, `preview-evaluator.ts` `5eafd898fefc`) and `git diff HEAD` is empty. Pins run: the new file and `week-key-year-below-100.test.ts`, 66 tests. | leg | mutation | red | |:--|:--|:--| | R | both files written back to base `f6ccca4a44` (blobs `682193a14bc0` / `522ef30f91fb` on disk) | 38 / 66, as at the red commit | | A1 | `formatDateBucket`'s key recognition deleted | 5: the `0050` / `0999` year key reads `1970` (the key pin, three every-granularity pins, and the published leg at `year`); the 2026-only cases stay green | | A2 | its relabel spelled locally, year unpadded | 2: the two raw-value pins; keys still pass through the recogniser | | A3a | `bucketDate` strips the year's leading zeros | 25; the 2026 `bucketDate` control green | | A3b | `bucketDate` keys a week as a day | 26, the six 2026 week cases included | After all legs: both blobs equal HEAD's, `git status --porcelain` empty, 66 / 66 green. ## Verification (head `25ba591849`) - **Suites** (under the shared lock, `--maxWorkers=2`): `@objectstack/service-analytics` `vitest run`, 147 files / 3398 tests passed. `typecheck` (`tsc --noEmit`) exits 0, and `tsc --listFiles` lists both pin files, so the typecheck covers them. - **Build:** the tested dependency closure, `turbo run build --filter='@objectstack/service-analytics^...' --concurrency=1` (14 / 14), then the package itself (exit 0; `check-dts-emitted` 2 / 2). `dist/index.d.ts` names neither function. - **Gates:** `node scripts/pm/dispatch-gates.mjs --commands` re-derived at this head: 61 commands, identical to the dispatch-time list. All 61 ran in one locked sequential script, with the four roster families the derivation marks (`check-changeset-fixed`, `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`). 63 exited 0 and 2 exited 3. `--ran`, with each exit code recorded: 61 derived, 59 run, 2 NOT MEASURED, 0 unrun. - NOT MEASURED: `check:dual-build-cjs-loads` and `check:type-check-debt`, both PREREQUISITE NOT MET (exit 3). They read every workspace package's built output, and this run built only the tested closure, as the dispatch's resource rule requires. CI's lint job builds the whole closure before them. - `check-changeset-no-major` exited 0 ("no `major` bump"). Its Clause-② level axis reads the PR payload, so it had no input locally. - **Lint (narrowed, proven):** `eslint --no-inline-config --format json` over the 4 changed `.ts` files. The JSON reports 4 files, 0 errors, 0 warnings, and none ignored. `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`, stated in the config itself), so this diff cannot move a verdict on an untouched file. The full `pnpm lint` is CI's. ## Acceptance notes - **Not measured:** an HTTP request through `POST /analytics/dataset/query`. The readings above are at function level and at `queryDataset` level, over the engine's in-memory grouping. No SQL driver ran; SQLite and PostgreSQL already pad their keys (measured in PR #20865). The door's refusal of `hour` is read from `DatasetSelectionSchema.safeParse`, not from the route. - **Not measured locally:** the two gates above, and CI's Test Core, Dogfood, Build Core and Temporal Conformance jobs and the workspace type-check lanes. - **Unchanged, noted:** `formatDateBucket` still reads a NUMERIC year below 1000 (`50` under `year`) as epoch milliseconds (`1970`). No named producer answers a numeric year key: SQLite `strftime`, MySQL `date_format`, PostgreSQL `to_char` and MongoDB `$dateToString` all answer text, and the text key `0050` is now recognised. - **Unchanged, noted:** a raw instant under `week` still labels as its own day key (`YYYY-MM-DD`). A week bucket's key is the ISO label on every producer, and that key is recognised and kept. - **Changed at function level only:** `bucketDate` given a granularity outside the five now answers `bucketDateKey`'s echo of the raw value, where it answered the day key. Its type admits only the five, and `DatasetSelectionSchema` refuses `hour` (schema-level reading), so no public path reaches it. It is also the answer the engine's in-memory grouping gives, since `bucketDateValue` delegates to the same writer. - **Same family, other surface:** #20602 (`/export`) remains its own card and is not addressed here. --- _Generated by [Claude Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 212d613 commit 525b813

5 files changed

Lines changed: 236 additions & 64 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@objectstack/service-analytics': patch
3+
---
4+
5+
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.
6+
7+
- **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.
8+
- **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`).
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#20867] This package's two readers of a date-bucket key agree with
5+
* `@objectstack/core`'s one writer, `bucketDateKey`, for a year in 0001..0999.
6+
*
7+
* - `formatDateBucket` (`dimension-labels.ts`) relabels a date dimension's
8+
* grouped key on the published `queryDataset` path. Its year check admitted
9+
* only 1000..9999, so the key `0050` was read as epoch seconds and labelled
10+
* `1970`; a month or day key was re-spelled with its year unpadded (`50-06`,
11+
* `50-06-15`). It now labels every key the writer writes as written, and
12+
* what it relabels is spelled by the writer.
13+
* - `bucketDate` (`preview-evaluator.ts`) keys a drafted seed row on the draft
14+
* preview path. It spelled every key itself: the year unpadded (`50`,
15+
* `50-Q2`, `50-06`, `50-06-15`), and the week as the Monday's
16+
* `YYYY-MM-DD` where the runtime writes the ISO week label (`0050-W24`,
17+
* `2026-W25`). It now delegates to `bucketDateKey`, so the preview adopts the
18+
* runtime's ISO week label and a drafted chart keys a row the way the same
19+
* dataset does once published.
20+
*
21+
* Pins: 0050 (with 0050-01-01, whose ISO week is `0049-W52`) and 0999, with a
22+
* 2026 control. Every expected key is spelled literally.
23+
*/
24+
25+
import { describe, it, expect, vi } from 'vitest';
26+
import { bucketDateKey } from '@objectstack/core';
27+
import { applyInMemoryAggregation } from '@objectstack/objectql';
28+
import { DatasetSchema } from '@objectstack/spec/ui';
29+
import type { ExecutionContext } from '@objectstack/spec/kernel';
30+
import { AnalyticsService } from '../analytics-service.js';
31+
import { formatDateBucket } from '../dimension-labels.js';
32+
import { bucketDate } from '../preview-evaluator.js';
33+
34+
const GRANULARITIES = ['year', 'quarter', 'month', 'week', 'day'] as const;
35+
type Granularity = (typeof GRANULARITIES)[number];
36+
37+
/** The key the writer gives each instant, spelled literally (the reference). */
38+
const WRITTEN: ReadonlyArray<readonly [string, Record<Granularity, string>]> = [
39+
['0050-06-15T10:00:00.000Z', { year: '0050', quarter: '0050-Q2', month: '0050-06', week: '0050-W24', day: '0050-06-15' }],
40+
['0050-01-01T10:00:00.000Z', { year: '0050', quarter: '0050-Q1', month: '0050-01', week: '0049-W52', day: '0050-01-01' }],
41+
['0999-06-15T10:00:00.000Z', { year: '0999', quarter: '0999-Q2', month: '0999-06', week: '0999-W24', day: '0999-06-15' }],
42+
// The control.
43+
['2026-06-15T10:00:00.000Z', { year: '2026', quarter: '2026-Q2', month: '2026-06', week: '2026-W25', day: '2026-06-15' }],
44+
];
45+
46+
describe('[#20867] the reference: core writes these keys', () => {
47+
it.each(WRITTEN)('%s', (instant, keys) => {
48+
for (const g of GRANULARITIES) expect(bucketDateKey(instant, g), g).toBe(keys[g]);
49+
});
50+
});
51+
52+
describe('[#20867] formatDateBucket labels every key the writer writes as written', () => {
53+
it.each([
54+
['0050', 'year'],
55+
['0050-06', 'month'],
56+
['0050-06-15', 'day'],
57+
] as const)('%s (%s) is labelled as written', (key, g) => {
58+
expect(formatDateBucket(key, g)).toBe(key);
59+
});
60+
61+
it.each(WRITTEN)('every granularity of %s', (_instant, keys) => {
62+
for (const g of GRANULARITIES) expect(formatDateBucket(keys[g], g), g).toBe(keys[g]);
63+
});
64+
65+
it.each([
66+
// A raw value a date dimension groups by when no bucket was applied; the
67+
// label is the key the writer gives the same instant in UTC.
68+
['0050-06-15T10:00:00.000Z', { year: '0050', quarter: '0050-Q2', month: '0050-06', day: '0050-06-15' }],
69+
['0050-06-15', { year: '0050', quarter: '0050-Q2', month: '0050-06', day: '0050-06-15' }],
70+
// The control.
71+
['2026-06-15T10:00:00.000Z', { year: '2026', quarter: '2026-Q2', month: '2026-06', day: '2026-06-15' }],
72+
] as const)('relabels the raw value %s with the year in four digits', (raw, keys) => {
73+
for (const g of ['year', 'quarter', 'month', 'day'] as const) expect(formatDateBucket(raw, g), g).toBe(keys[g]);
74+
expect(formatDateBucket(raw, undefined)).toBe(keys.day);
75+
});
76+
});
77+
78+
describe('[#20867] bucketDate delegates to the writer', () => {
79+
it.each(WRITTEN)('%s', (instant, keys) => {
80+
const ms = Date.parse(instant);
81+
for (const g of GRANULARITIES) {
82+
expect(bucketDate(instant, g), g).toBe(keys[g]);
83+
// The other two forms the writer reads, and the published path buckets.
84+
expect(bucketDate(ms, g), `${g} (epoch ms)`).toBe(keys[g]);
85+
expect(bucketDate(new Date(ms), g), `${g} (Date)`).toBe(keys[g]);
86+
}
87+
});
88+
89+
it('resolves the calendar day in the reference zone, as the writer does', () => {
90+
// Sunday 0050-01-02 in UTC is Monday 0050-01-03 in Asia/Shanghai.
91+
expect(bucketDate('0050-01-02T20:00:00.000Z', 'week', 'UTC')).toBe('0049-W52');
92+
expect(bucketDate('0050-01-02T20:00:00.000Z', 'week', 'Asia/Shanghai')).toBe('0050-W01');
93+
expect(bucketDate('0050-01-02T20:00:00.000Z', 'day', 'Asia/Shanghai')).toBe('0050-01-03');
94+
// The control.
95+
expect(bucketDate('2026-06-14T20:00:00.000Z', 'week', 'Asia/Shanghai')).toBe('2026-W25');
96+
});
97+
});
98+
99+
describe('[#20867] a draft preview keys 0050-06-15 as the published path does', () => {
100+
const CTX = { tenantId: 'org_A' } as ExecutionContext;
101+
const DATASET = DatasetSchema.parse({
102+
name: 'expense_ds', label: 'Expense', object: 'expense', include: [],
103+
dimensions: [{ name: 'spent_on', field: 'spent_on', type: 'date', dateGranularity: 'month' }],
104+
measures: [{ name: 'expense_count', aggregate: 'count' }],
105+
});
106+
// A `date` value and a `datetime` value on the same calendar day, and the control.
107+
const ROWS = [
108+
{ spent_on: '0050-06-15', amount: 10 },
109+
{ spent_on: '0050-06-15T10:00:00.000Z', amount: 20 },
110+
{ spent_on: '2026-06-15', amount: 30 },
111+
];
112+
113+
/** The draft preview: the seed rows, evaluated in memory. */
114+
const preview = new AnalyticsService({ draftRowsResolver: vi.fn(async () => ROWS) });
115+
116+
/**
117+
* The published path over the same rows: the ObjectQL strategy, the engine's
118+
* in-memory grouping (`@objectstack/objectql`) as `executeAggregate`, and the
119+
* dimension labels `queryDataset` resolves after grouping.
120+
*/
121+
const published = new AnalyticsService({
122+
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }),
123+
executeAggregate: async (_object, options) =>
124+
applyInMemoryAggregation(
125+
ROWS,
126+
{
127+
groupBy: options.groupBy,
128+
aggregations: (options.aggregations ?? []).map((a) => ({ function: a.method, field: a.field, alias: a.alias })),
129+
},
130+
options.timezone,
131+
),
132+
sourceFieldMeta: (_o, f) => (f === 'spent_on' ? { type: 'date' } : undefined),
133+
labelResolver: {
134+
getObjectFields: (o) => (o === 'expense' ? { spent_on: { type: 'date' } } : undefined),
135+
fetchRecordLabels: async () => new Map(),
136+
},
137+
});
138+
139+
const keysOf = (rows: Record<string, unknown>[]) =>
140+
rows.map((r) => [r.spent_on, r.expense_count]).sort((a, b) => String(a[0]).localeCompare(String(b[0])));
141+
142+
it.each([
143+
['year', '0050', '2026'],
144+
['quarter', '0050-Q2', '2026-Q2'],
145+
['month', '0050-06', '2026-06'],
146+
['week', '0050-W24', '2026-W25'],
147+
['day', '0050-06-15', '2026-06-15'],
148+
] as const)('%s: %s, and %s for the control', async (g, key, control) => {
149+
const selection = { dimensions: ['spent_on'], measures: ['expense_count'], dateGranularity: g };
150+
const drafted = await preview.queryDataset(DATASET, selection, CTX, { previewDrafts: true });
151+
const live = await published.queryDataset(DATASET, selection, CTX);
152+
const expected = [[key, 2], [control, 1]];
153+
expect(keysOf(live.rows), 'published').toEqual(expected);
154+
expect(keysOf(drafted.rows), 'preview').toEqual(expected);
155+
});
156+
});

‎packages/services/service-analytics/src/__tests__/week-key-year-below-100.test.ts‎

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@
66
*
77
* - The preview evaluator's `week` key (`bucketDate`) built the day with
88
* `Date.UTC(year, …)`, which reads a year from 0 to 99 as 1900 + year, so a
9-
* row on 0050-06-15 was keyed to a Monday in 1950.
9+
* row on 0050-06-15 was keyed to a Monday in 1950. [#20867] It delegates to
10+
* core's `bucketDateKey` now, so its week key is the runtime's ISO week label
11+
* (`0050-W24`), no longer the Monday's `YYYY-MM-DD`; the pins below read the
12+
* label, and `bucket-key-readers-four-digit-year.test.ts` pins the rest.
1013
* - The dataset executor's `compareTo` alignment counts bucket ordinals from a
1114
* bound's day (core's `zonedDateStartToUtcMs`, the same remap) and mints a
1215
* 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) => {
3639
expect(Intl.DateTimeFormat().resolvedOptions().timeZone).toBe(host);
3740
});
3841

39-
describe('[#20599] preview bucketDate(week) names the Monday of a day in 0001..0099', () => {
42+
describe('[#20599] preview bucketDate(week) names the ISO week of a day in 0001..0099', () => {
4043
it.each([
41-
['0001-01-03T10:00:00.000Z', '0001-01-01'],
42-
['0050-01-01T10:00:00.000Z', '0049-12-27'],
43-
['0050-06-15T10:00:00.000Z', '0050-06-13'],
44-
['0099-12-31T10:00:00.000Z', '0099-12-28'],
45-
['0100-01-06T10:00:00.000Z', '0100-01-04'],
46-
['2026-06-17T10:00:00.000Z', '2026-06-15'],
47-
])('%s in UTC is the week of %s', (instant, monday) => {
48-
expect(bucketDate(instant, 'week')).toBe(monday);
49-
expect(bucketDate(instant, 'week', 'UTC')).toBe(monday);
44+
// [#20867] The ISO week label; the Monday each week starts on is in
45+
// the comment.
46+
['0001-01-03T10:00:00.000Z', '0001-W01'], // Monday 0001-01-01
47+
['0050-01-01T10:00:00.000Z', '0049-W52'], // Monday 0049-12-27
48+
['0050-06-15T10:00:00.000Z', '0050-W24'], // Monday 0050-06-13
49+
['0099-12-31T10:00:00.000Z', '0099-W53'], // Monday 0099-12-28
50+
['0100-01-06T10:00:00.000Z', '0100-W01'], // Monday 0100-01-04
51+
['2026-06-17T10:00:00.000Z', '2026-W25'], // Monday 2026-06-15
52+
])('%s in UTC is in week %s', (instant, week) => {
53+
expect(bucketDate(instant, 'week')).toBe(week);
54+
expect(bucketDate(instant, 'week', 'UTC')).toBe(week);
5055
});
5156

5257
it.each([
5358
// Sunday 0050-01-02 in UTC is Monday 0050-01-03 in Shanghai.
54-
['0050-01-02T20:00:00.000Z', '0049-12-27', '0050-01-03'],
55-
['0001-01-07T20:00:00.000Z', '0001-01-01', '0001-01-08'],
56-
['0099-12-31T20:00:00.000Z', '0099-12-28', '0099-12-28'],
57-
['2026-06-14T20:00:00.000Z', '2026-06-08', '2026-06-15'],
58-
])('%s is the week of %s in UTC and of %s in Asia/Shanghai', (instant, utc, shanghai) => {
59+
['0050-01-02T20:00:00.000Z', '0049-W52', '0050-W01'],
60+
['0001-01-07T20:00:00.000Z', '0001-W01', '0001-W02'],
61+
['0099-12-31T20:00:00.000Z', '0099-W53', '0099-W53'],
62+
['2026-06-14T20:00:00.000Z', '2026-W24', '2026-W25'],
63+
])('%s is in week %s in UTC and in week %s in Asia/Shanghai', (instant, utc, shanghai) => {
5964
expect(bucketDate(instant, 'week')).toBe(utc);
6065
expect(bucketDate(instant, 'week', 'Asia/Shanghai')).toBe(shanghai);
6166
});

‎packages/services/service-analytics/src/dimension-labels.ts‎

Lines changed: 29 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939

4040
import type { ExecutionContext } from '@objectstack/spec/kernel';
4141
import { referenceTargetOf } from '@objectstack/spec/data';
42+
import { bucketDateKey, bucketKeyToCalendarRange, isBucketGranularity } from '@objectstack/core';
4243

4344
/** The minimal field shape this resolver needs. */
4445
export interface FieldMetaLite {
@@ -290,8 +291,6 @@ export function withLabelFetchCache(deps: DimensionLabelDeps): DimensionLabelDep
290291
/** Date-dimension granularity (mirrors the dataset `dateGranularity` enum). */
291292
export type DateGranularity = 'day' | 'week' | 'month' | 'quarter' | 'year';
292293

293-
const pad = (n: number) => String(n).padStart(2, '0');
294-
295294
/**
296295
* Format a raw date value (epoch-ms number, numeric string, ISO string, or
297296
* 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');
308307
* here it is *already* the reference-zone bucket (often a label string like
309308
* "2026-Q2"). Re-applying a timezone here would shift an already-correct
310309
* `YYYY-MM-DD` day bucket by a day — this is a pure, idempotent re-labeler.
310+
*
311+
* [#20867] It reads the keys `@objectstack/core`'s `bucketDateKey` writes and
312+
* spells none itself. A key that writer writes at `granularity` — the year in
313+
* four digits, `0050` / `0050-06` / `0050-06-15` included — is recognised by
314+
* core's one reader of those keys, `bucketKeyToCalendarRange`, and returned as
315+
* written. A raw value is relabelled by `bucketDateKey` itself, in UTC (a
316+
* `week` or unstated granularity as the value's own `day` key, as before).
317+
* Before, the year check admitted only 1000..9999, so the key `0050` was read
318+
* as epoch seconds (`1970`), and a relabelled year below 1000 lost its padding
319+
* (`50-06`, `50-06-15`).
311320
*/
312321
export function formatDateBucket(value: unknown, granularity?: DateGranularity | string): unknown {
313322
if (value == null || value instanceof Date === false) {
314323
if (typeof value !== 'number' && typeof value !== 'string') return value;
315324
}
316-
// A YEAR bucket's canonical key IS the bare year ("2026" / 2026) — which the
317-
// epoch heuristic below would read as 2026 milliseconds and relabel "1970".
318-
// Being idempotent over already-formatted bucket keys is this function's whole
319-
// contract, and every other granularity's key already survives the round trip
320-
// ("2026-Q2", "2026-07", "2026-07-15" all fail the pure-digit test); only the
321-
// year key collides with it. Recognised before parsing, for both the string
322-
// and numeric forms drivers return.
325+
// [#20867] A key the writer writes at this granularity is labelled as
326+
// written: this function is idempotent over bucket keys, and core's reader,
327+
// not a second pattern here, decides what one is.
328+
if (
329+
typeof value === 'string' &&
330+
isBucketGranularity(granularity) &&
331+
bucketKeyToCalendarRange(value, granularity) !== null
332+
) {
333+
return value;
334+
}
335+
// The NUMERIC year key: a year bucket's key is the bare year, and a driver
336+
// may answer it as a number (2026), which the epoch heuristic below would
337+
// read as 2026 milliseconds and relabel "1970". Recognised before parsing;
338+
// the string form is recognised above.
323339
if (granularity === 'year') {
324340
const y = typeof value === 'number' ? value : Number(String(value).trim());
325341
if (Number.isInteger(y) && y >= 1000 && y <= 9999) return String(y);
@@ -333,16 +349,10 @@ export function formatDateBucket(value: unknown, granularity?: DateGranularity |
333349
d = /^\d+$/.test(s) ? new Date(Number(s) < 1e12 ? Number(s) * 1000 : Number(s)) : new Date(s);
334350
}
335351
if (Number.isNaN(d.getTime())) return value;
336-
const y = d.getUTCFullYear();
337-
const m = d.getUTCMonth(); // 0-11
338-
switch (granularity) {
339-
case 'year': return String(y);
340-
case 'quarter': return `${y}-Q${Math.floor(m / 3) + 1}`;
341-
case 'month': return `${y}-${pad(m + 1)}`;
342-
case 'week':
343-
case 'day':
344-
default: return `${y}-${pad(m + 1)}-${pad(d.getUTCDate())}`;
345-
}
352+
// [#20867] Spelled by the writer, in UTC (no zone: see above).
353+
const labelGranularity =
354+
isBucketGranularity(granularity) && granularity !== 'week' ? granularity : 'day';
355+
return bucketDateKey(d, labelGranularity);
346356
}
347357

348358
/**

0 commit comments

Comments
 (0)