Skip to content

Commit 48c19bd

Browse files
fix(core,fields): render a dataset measure over a date field as a date (#7442)
`formatMeasure` opened with `if (typeof v !== 'number') return String(v)`, placed before `format` was ever read. A `min` / `max` measure over a date or datetime field therefore printed its stored value verbatim -- a 24-character ISO string in the KPI tile's `text-2xl font-semibold`, wrapping to two lines -- and the `format` that `DatasetMeasureSchema` accepts could never be read on that path. A date-shaped value now routes to the date display path ahead of that short-circuit, so all four dataset-bound surfaces are served at once. No second date formatter was written. `formatDate`, `formatDateTime`, `formatRelativeDate` and `DateDisplayOptions` MOVED from `@object-ui/fields`' barrel down into `@object-ui/core` (`utils/date-display.ts`) -- the same remedy objectui#4576 applied to `formatDisplayNumber`, for the same reason: `core` is the React-free engine and could not import from a React package, so the alternative was a parallel date convention in `dataset-format.ts`, which is the drift #4576 already paid for once in percent. `@object-ui/fields` re-exports all four names unchanged and a reference-identity test pins that the cell renderer and the measure formatter call the same function object. Numeric measures are byte-identical: 33,696 argument forms compared against a verbatim copy of the pre-fix function, and the only values that moved were the four ISO-shaped, parseable ones. Claude-Session: https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC Co-authored-by: Claude <noreply@anthropic.com>
1 parent 77cb489 commit 48c19bd

8 files changed

Lines changed: 665 additions & 121 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
'@object-ui/core': patch
3+
'@object-ui/fields': patch
4+
---
5+
6+
Render a dataset measure over a date field as a date (objectui#7178, maintainer
7+
ruling 2026-09-02, director summon #8 — option A).
8+
9+
`formatMeasure` opened with `if (typeof v !== 'number') return String(v)`,
10+
placed **before** `format` was ever read. So a `min` / `max` measure over a date
11+
or datetime field printed its stored value verbatim — a 24-character ISO string
12+
in the KPI tile's `text-2xl font-semibold`, wrapping to two lines — and the
13+
`format` that `DatasetMeasureSchema` accepts was unreachable for those values.
14+
A date-shaped value now routes to the date display path before that
15+
short-circuit, so all four dataset-bound surfaces are served at once: the metric
16+
tile, chart values, dataset table cells, and the metadata-admin dataset preview.
17+
18+
`min` / `max` over a date stays a legal measure; nothing in `@objectstack/spec`
19+
narrows. `PivotTable` takes a `number` outright and is unchanged.
20+
21+
**No second date formatter was written.** `formatDate`, `formatDateTime`,
22+
`formatRelativeDate` and `DateDisplayOptions` MOVED from `@object-ui/fields`'
23+
barrel down into `@object-ui/core` (`utils/date-display.ts`), which is the same
24+
remedy objectui#4576 applied to `formatDisplayNumber` and for the same reason:
25+
`core` is the React-free engine and could not import from a React package, so
26+
the alternative was a parallel date convention in `dataset-format.ts` — exactly
27+
the drift that once had a list cell rendering `1.234,5 %` beside a dashboard
28+
measure's `1.234,5%`. `@object-ui/fields` re-exports all four names unchanged,
29+
so no consumer's import path or behaviour changes, and a reference-identity test
30+
pins that the cell renderer and the measure formatter call the same function.
31+
32+
**What `format` can say for a date measure, measured rather than assumed.** The
33+
shared date path takes a named STYLE, not a date pattern: `'short'` and
34+
`'relative'` are honoured — the same words `DateCellRenderer` honours from
35+
`field.format` — while a pattern such as `'YYYY-MM-DD'` renders the locale
36+
default. That limit is unchanged by this release (`plugin-dashboard`'s
37+
`recordFields` already routed a date-shaped `format` into the same style slot)
38+
and is now pinned by a test instead of being silent.
39+
40+
**Numeric measures are byte-identical.** 33,696 argument forms
41+
(value × format × currency × percentScale × locale) were compared against a
42+
verbatim copy of the pre-fix function: the only values that moved were the four
43+
ISO-shaped, parseable ones. Numbers, numeric strings (`'1751612400000'`,
44+
`'2026'`), the nullish em dash, arbitrary prose and non-strings all render
45+
exactly as before.

‎packages/core/src/index.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,11 @@ export * from './utils/chart-presentation.js';
8282
// `dataset-format` below from reaching it (so the two drifted). `@object-ui/i18n`
8383
// re-exports these names unchanged, so both import paths name the same symbol.
8484
export * from './utils/number-display.js';
85+
// The ONE date-display path (objectui#7178) — the same story one type over.
86+
// It lived in `@object-ui/fields`' React barrel, which `dataset-format`
87+
// below could not import, so a date-valued measure rendered its raw ISO
88+
// string. `@object-ui/fields` re-exports these names unchanged.
89+
export * from './utils/date-display.js';
8590
export * from './utils/dataset-format.js';
8691
// Pivot lookup-key encoders, shared by every cross-tab renderer so the
8792
// dashboard widget and the report renderer key their buckets identically
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectui#7178 — a dataset measure over a date field renders as a date.
5+
*
6+
* `formatMeasure` opened with `if (typeof v !== 'number') return String(v)`,
7+
* placed BEFORE `format` was read. So a `min` / `max` over a date or datetime
8+
* field printed its stored value verbatim — a 24-character ISO string in the
9+
* KPI tile's `text-2xl font-semibold`, wrapped over two lines — and the
10+
* `format` that `DatasetMeasureSchema` accepts was unreachable for it.
11+
*
12+
* ── What these cases pin, and what they deliberately do NOT ─────────────────
13+
* The fix is a ROUTE, not a formatter: every byte of date rendering comes from
14+
* `../date-display.ts`, which is where `@object-ui/fields`' `formatDate` /
15+
* `formatDateTime` now live and what the `date` cell renderer, `ObjectGrid`'s
16+
* date cells and `ObjectGantt`'s tooltips call. So the cases below assert
17+
* AGREEMENT WITH THAT PATH rather than literal strings wherever the path's own
18+
* output is the contract — a literal would pin this file's opinion of a date,
19+
* which is exactly the second convention objectui#4576 says not to create.
20+
* (`DatasetWidget.dateMeasure.test.tsx` closes the loop on the rendered
21+
* surfaces, and `@object-ui/fields`' `date-display.reexport-identity.test.ts`
22+
* pins that the cell's `formatDate` and this one are the same function.)
23+
*
24+
* ── Directions, predicted in writing BEFORE the run ─────────────────────────
25+
* the two date cases RED pre-fix — they render the raw ISO string
26+
* the `format`-as-style cases RED pre-fix — `format` was never read here
27+
* ⭐ every must-not-move case GREEN on both sides. These are the guard,
28+
* not the fix: the new branch runs ahead of
29+
* the `String(v)` short-circuit, so it has to
30+
* be shown NOT to capture numbers, numeric
31+
* strings, the nullish em dash, or prose.
32+
*
33+
* The full before/after sweep is recorded in the PR: 33,696 argument forms
34+
* (value x format x currency x percentScale x locale) compared against a
35+
* verbatim copy of the pre-fix function, 4 distinct values moved — every one
36+
* of them ISO-shaped and parseable — and 0 expected movers missed.
37+
*/
38+
39+
import { describe, it, expect } from 'vitest';
40+
41+
import { formatMeasure } from '../dataset-format.js';
42+
import { formatDate, formatDateTime } from '../date-display.js';
43+
44+
/** A NON-current year, so `formatDate`'s "drop the year" branch is not in play. */
45+
const ISO_DATE = '2024-07-04';
46+
const ISO_DATETIME = '2024-07-04T07:00:00.000Z';
47+
const EN = 'en-US';
48+
49+
describe('formatMeasure routes a date-shaped measure through the shared date path (objectui#7178)', () => {
50+
it('renders a datetime measure as a datetime, not as its raw ISO string', () => {
51+
const out = formatMeasure(ISO_DATETIME, undefined, undefined, undefined, EN);
52+
expect(out).not.toBe(ISO_DATETIME);
53+
expect(out).toBe(formatDateTime(ISO_DATETIME, { locale: EN }));
54+
});
55+
56+
it('renders a date-only measure as a date, not as its raw ISO string', () => {
57+
const out = formatMeasure(ISO_DATE, undefined, undefined, undefined, EN);
58+
expect(out).not.toBe(ISO_DATE);
59+
expect(out).toBe(formatDate(ISO_DATE, undefined, { locale: EN }));
60+
});
61+
62+
it('accepts the space-separated ISO spelling a backend may send', () => {
63+
const spaced = '2024-07-04 07:00:00';
64+
expect(formatMeasure(spaced, undefined, undefined, undefined, EN)).toBe(
65+
formatDateTime(spaced, { locale: EN }),
66+
);
67+
});
68+
69+
it('follows the display locale, like every other measure', () => {
70+
const de = formatMeasure(ISO_DATETIME, undefined, undefined, undefined, 'de-DE');
71+
const en = formatMeasure(ISO_DATETIME, undefined, undefined, undefined, EN);
72+
expect(de).not.toBe(en);
73+
expect(de).toBe(formatDateTime(ISO_DATETIME, { locale: 'de-DE' }));
74+
});
75+
});
76+
77+
/**
78+
* The finding's reusable half: `DatasetMeasureSchema.format` is an open string
79+
* that parses and, on this path, could never be read. It is read now — as the
80+
* shared path's STYLE vocabulary, which is what that path actually accepts.
81+
* A date PATTERN is not part of that vocabulary, and these cases say so out
82+
* loud rather than leaving it silent, because "silent" is the whole card.
83+
*/
84+
describe('what `format` can and cannot say for a date measure (objectui#7178)', () => {
85+
it('honours `short` — the same word `DateCellRenderer` honours from `field.format`', () => {
86+
expect(formatMeasure(ISO_DATE, 'short', undefined, undefined, EN)).toBe(
87+
formatDate(ISO_DATE, 'short', { locale: EN }),
88+
);
89+
expect(formatMeasure(ISO_DATE, 'short', undefined, undefined, EN)).not.toBe(
90+
formatMeasure(ISO_DATE, undefined, undefined, undefined, EN),
91+
);
92+
});
93+
94+
it('honours `relative`', () => {
95+
expect(formatMeasure(ISO_DATE, 'relative', undefined, undefined, EN)).toBe(
96+
formatDate(ISO_DATE, 'relative', { locale: EN }),
97+
);
98+
});
99+
100+
it('⚠️ does NOT interpret a date PATTERN — `YYYY-MM-DD` renders the locale default', () => {
101+
// Measured, not assumed, and stated in the PR body: the shared date path
102+
// takes a named style, not a pattern. This case exists so the limit is
103+
// pinned rather than rediscovered — and so that teaching the shared path a
104+
// pattern grammar later has a test that must be updated deliberately.
105+
const patterned = formatMeasure(ISO_DATE, 'YYYY-MM-DD', undefined, undefined, EN);
106+
expect(patterned).not.toBe('2024-07-04');
107+
expect(patterned).toBe(formatMeasure(ISO_DATE, undefined, undefined, undefined, EN));
108+
});
109+
});
110+
111+
/**
112+
* ⭐ The regression guard. The date branch runs ahead of the `String(v)`
113+
* short-circuit, so every one of these has to be shown NOT to reach it.
114+
*/
115+
describe('formatMeasure leaves every non-date value exactly where it was (objectui#7178)', () => {
116+
it('formats numbers through the numeral path, untouched', () => {
117+
expect(formatMeasure(1234.5, '0.0', undefined, undefined, EN)).toBe('1,234.5');
118+
expect(formatMeasure(1234.5, '0.00', 'EUR', undefined, EN)).toBe('€1,234.50');
119+
expect(formatMeasure(0.75, '0.0%', undefined, 'fraction', EN)).toBe('75.0%');
120+
expect(formatMeasure(42, undefined, undefined, undefined, EN)).toBe('42');
121+
expect(formatMeasure(2026, undefined, undefined, undefined, EN)).toBe('2026');
122+
});
123+
124+
it('⭐ never reads a NUMERIC STRING as a date', () => {
125+
// `1751612400000` is epoch milliseconds for a real instant, and `2026` is a
126+
// year. Both are what a count or an id looks like coming back from a
127+
// measure, and `Date.parse` would take either if it were asked.
128+
for (const v of ['0', '42', '1234.5', '-1234.5', '1751612400000', '2026', '1e21']) {
129+
expect(formatMeasure(v, undefined, undefined, undefined, EN)).toBe(v);
130+
expect(formatMeasure(v, 'YYYY-MM-DD', undefined, undefined, EN)).toBe(v);
131+
}
132+
});
133+
134+
it('keeps the nullish em dash', () => {
135+
expect(formatMeasure(null)).toBe('—');
136+
expect(formatMeasure(undefined)).toBe('—');
137+
expect(formatMeasure(null, 'YYYY-MM-DD', undefined, undefined, EN)).toBe('—');
138+
});
139+
140+
it('falls through to `String(v)` for an arbitrary non-numeric, non-date value', () => {
141+
for (const v of ['Acme Corp', 'N/A', '', 'March 5, 2026', '2026-07', '2026/07/04', '04-07-2026']) {
142+
expect(formatMeasure(v, undefined, undefined, undefined, EN)).toBe(String(v));
143+
}
144+
expect(formatMeasure(true, undefined, undefined, undefined, EN)).toBe('true');
145+
});
146+
147+
it('leaves an ISO-SHAPED but unparseable value alone rather than showing an em dash', () => {
148+
// `formatDate` answers `—` for an unparseable value. Routing one there
149+
// would REPLACE a raw string the author can still debug with a dash that
150+
// says nothing, so the parse guard keeps these on the old path.
151+
for (const v of ['2026-13-45', '2024-07-04T99:99']) {
152+
expect(formatMeasure(v, undefined, undefined, undefined, EN)).toBe(v);
153+
}
154+
});
155+
156+
it('agrees with the list cell on a rolled-over date instead of second-guessing it', () => {
157+
// `Date.parse('2024-02-30')` is NOT NaN — V8 rolls it to March 1/2. Every
158+
// date surface in the repo builds its `Date` the same way, so this renders
159+
// as the same day a list cell shows for the same stored string. Pinned
160+
// because it is the one place agreement looks like a bug.
161+
const rolled = '2024-02-30';
162+
expect(Number.isNaN(Date.parse(rolled))).toBe(false);
163+
expect(formatMeasure(rolled, undefined, undefined, undefined, EN)).toBe(
164+
formatDate(rolled, undefined, { locale: EN }),
165+
);
166+
});
167+
});

‎packages/core/src/utils/dataset-format.ts‎

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import type { AnalyticsResult } from '@objectstack/spec/contracts';
2121
import type { PercentScale } from '@objectstack/spec/data';
2222

2323
import { formatDisplayNumber, type DisplayNumberFormatOptions } from './number-display.js';
24+
import { formatDate, formatDateTime } from './date-display.js';
2425

2526
/**
2627
* Column metadata the analytics server returns alongside the rows — the spec's
@@ -130,6 +131,71 @@ function formatNumberInLocale(
130131
return formatDisplayNumber(value, { ...options, locale });
131132
}
132133

134+
/**
135+
* ISO calendar date with NO time part — `2026-07-04`.
136+
*
137+
* Deliberately anchored at both ends and deliberately narrower than
138+
* `Date.parse`, which also accepts locale prose (`March 5, 2026`), bare years
139+
* (`2026`) and — the one that matters here — plain NUMERALS. A measure value
140+
* is untyped by the time it reaches {@link formatMeasure}, so the shape test
141+
* IS the type test, and a loose one would capture the numeric strings and
142+
* counts this formatter must leave exactly as they are.
143+
*/
144+
const ISO_DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;
145+
146+
/**
147+
* ISO date carrying a time part — `2026-07-04T07:00:00.000Z`, or the same with
148+
* a space separator, with or without seconds/offset. Matched only as far as
149+
* `HH:mm`; `Date.parse` decides the rest, so a well-shaped impossible instant
150+
* still falls through untouched.
151+
*/
152+
const ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/;
153+
154+
/**
155+
* Render a date-shaped measure value through the display path a list cell
156+
* already uses for that field, or return `undefined` to say "not a date" and
157+
* leave the caller's own fallthrough in charge.
158+
*
159+
* ── Why this is a ROUTE and not a formatter (objectui#7178) ──
160+
* Every line of date rendering below happens in `./date-display.ts`, which is
161+
* where `@object-ui/fields`' `formatDate` / `formatDateTime` now live and what
162+
* `DateCellRenderer`, `ObjectGrid`'s date cells and `ObjectGantt`'s tooltips
163+
* call. Writing the date convention HERE instead is the mistake this file's
164+
* own header is about: objectui#4576, where a percent convention had one copy
165+
* in a list cell and another in this function, both correct, and a German
166+
* session read `1.234,5 %` in the cell beside `1.234,5%` in the measure. One
167+
* date convention, one home, nothing to drift.
168+
*
169+
* The date/datetime split mirrors `ObjectGantt.tsx`'s sniffed-ISO dispatch
170+
* verbatim, because that site is already the repo's answer to this exact
171+
* situation: an ISO string arriving with NO field type attached. A measure is
172+
* that situation by construction — the analytics result carries a column, not
173+
* a field definition — so the absolute locale form is taken rather than
174+
* `DateCellRenderer`'s `|| 'relative'` default, which is keyed on knowing the
175+
* value is a `date` FIELD in a row.
176+
*
177+
* `format` is threaded into `formatDate`'s STYLE parameter, which is the same
178+
* mapping `DateCellRenderer` makes from `field.format` and the same one
179+
* `plugin-dashboard`'s `recordFields` already makes for a date-shaped format.
180+
* That vocabulary is `'short'` and `'relative'`; a date PATTERN such as
181+
* `'YYYY-MM-DD'` is not part of it and renders as the locale default. See the
182+
* `format` note on {@link formatMeasure}.
183+
*/
184+
function formatMeasureDate(v: unknown, format: string | undefined, locale: string | undefined): string | undefined {
185+
if (typeof v !== 'string') return undefined;
186+
// `Date.parse` guards both arms so a well-shaped impossible date
187+
// (`2026-02-30`) is NOT swallowed into the em dash `formatDate` returns for
188+
// an unparseable value — it keeps falling through to `String(v)`, exactly as
189+
// it does today. Only a value that is genuinely a date changes.
190+
if (ISO_DATE_ONLY_RE.test(v)) {
191+
return Number.isNaN(Date.parse(v)) ? undefined : formatDate(v, format, { locale });
192+
}
193+
if (ISO_DATETIME_RE.test(v)) {
194+
return Number.isNaN(Date.parse(v)) ? undefined : formatDateTime(v, { locale });
195+
}
196+
return undefined;
197+
}
198+
133199
/**
134200
* Format a MEASURE value. Currency comes from the field's declared `currency`
135201
* (locale-correct symbol via `Intl`), NOT from a "$" baked into the format
@@ -169,6 +235,28 @@ function formatNumberInLocale(
169235
* to carry is gone, and with it the drift that duplicate produced — see the
170236
* percent note in the body, and {@link percentDisplayValue}'s promise, which
171237
* is true again.
238+
*
239+
* ── Date-valued measures (objectui#7178) ──
240+
* `min` / `max` over a date or datetime field is a legitimate measure, and it
241+
* used to render its stored value verbatim: the guard above returned
242+
* `String(v)` for anything non-numeric BEFORE `format` was read, so a KPI tile
243+
* showed `2026-07-04T07:00:00.000Z` in `text-2xl` and wrapped it over two
244+
* lines. A date-shaped value now routes through {@link formatMeasureDate} to
245+
* the display path list cells use; nothing about the numeric path moved.
246+
*
247+
* ⚠️ What `format` can and cannot say for those values, measured rather than
248+
* assumed. The shared date path takes a named STYLE, not a date pattern:
249+
* `formatDate`'s vocabulary is `'short'` and `'relative'`, and every other
250+
* string falls to its default locale-medium branch. So `format: 'short'` and
251+
* `format: 'relative'` are honoured — the same words `DateCellRenderer`
252+
* honours from `field.format` for the same field — while a PATTERN like
253+
* `format: 'YYYY-MM-DD'` is accepted by the schema, reaches this function, and
254+
* renders the locale default. That last part is not new behaviour introduced
255+
* here: `plugin-dashboard`'s `recordFields` already routes a date-shaped
256+
* `format` into the same style slot and gets the same locale default. Closing
257+
* it would mean teaching the shared path a pattern grammar, which is a change
258+
* to the path itself and to every list cell that reads it — not a measure
259+
* concern, and not this card.
172260
*/
173261
export function formatMeasure(
174262
v: unknown,
@@ -178,7 +266,18 @@ export function formatMeasure(
178266
locale?: string,
179267
): string {
180268
if (v == null) return '—';
181-
if (typeof v !== 'number') return String(v);
269+
if (typeof v !== 'number') {
270+
// Ahead of the `String(v)` short-circuit, which is what made `format` dead
271+
// on this path and printed a `min`/`max` over a datetime as a raw
272+
// 24-character ISO string (objectui#7178). Deliberately NOT ahead of the
273+
// `typeof` test itself: above that line the argument can be a bare number,
274+
// and any date test generous enough to consider one would have to decide
275+
// whether `1751612400000` is epoch milliseconds — which is how a measure
276+
// that counts things starts rendering as a date. Numbers reach the numeral
277+
// formatter below byte for byte as before.
278+
const asDate = formatMeasureDate(v, format, locale);
279+
return asDate ?? String(v);
280+
}
182281

183282
const decimals = format ? (format.split('.')[1]?.match(/0/g)?.length ?? 0) : undefined;
184283

0 commit comments

Comments
 (0)