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
45 changes: 45 additions & 0 deletions .changeset/7178-format-measure-date.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
---
'@object-ui/core': patch
'@object-ui/fields': patch
---

Render a dataset measure over a date field as a date (objectui#7178, maintainer
ruling 2026-09-02, director summon #8 — option A).

`formatMeasure` opened with `if (typeof v !== 'number') return String(v)`,
placed **before** `format` was ever read. So a `min` / `max` measure over a date
or datetime field 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 was unreachable for those values.
A date-shaped value now routes to the date display path before that
short-circuit, so all four dataset-bound surfaces are served at once: the metric
tile, chart values, dataset table cells, and the metadata-admin dataset preview.

`min` / `max` over a date stays a legal measure; nothing in `@objectstack/spec`
narrows. `PivotTable` takes a `number` outright and is unchanged.

**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`), which is the same
remedy objectui#4576 applied to `formatDisplayNumber` and 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` — exactly
the drift that once had a list cell rendering `1.234,5 %` beside a dashboard
measure's `1.234,5%`. `@object-ui/fields` re-exports all four names unchanged,
so no consumer's import path or behaviour changes, and a reference-identity test
pins that the cell renderer and the measure formatter call the same function.

**What `format` can say for a date measure, measured rather than assumed.** The
shared date path takes a named STYLE, not a date pattern: `'short'` and
`'relative'` are honoured — the same words `DateCellRenderer` honours from
`field.format` — while a pattern such as `'YYYY-MM-DD'` renders the locale
default. That limit is unchanged by this release (`plugin-dashboard`'s
`recordFields` already routed a date-shaped `format` into the same style slot)
and is now pinned by a test instead of being silent.

**Numeric measures are byte-identical.** 33,696 argument forms
(value × format × currency × percentScale × locale) were compared against a
verbatim copy of the pre-fix function: the only values that moved were the four
ISO-shaped, parseable ones. Numbers, numeric strings (`'1751612400000'`,
`'2026'`), the nullish em dash, arbitrary prose and non-strings all render
exactly as before.
5 changes: 5 additions & 0 deletions packages/core/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,6 +82,11 @@ export * from './utils/chart-presentation.js';
// `dataset-format` below from reaching it (so the two drifted). `@object-ui/i18n`
// re-exports these names unchanged, so both import paths name the same symbol.
export * from './utils/number-display.js';
// The ONE date-display path (objectui#7178) — the same story one type over.
// It lived in `@object-ui/fields`' React barrel, which `dataset-format`
// below could not import, so a date-valued measure rendered its raw ISO
// string. `@object-ui/fields` re-exports these names unchanged.
export * from './utils/date-display.js';
export * from './utils/dataset-format.js';
// Pivot lookup-key encoders, shared by every cross-tab renderer so the
// dashboard widget and the report renderer key their buckets identically
Expand Down
167 changes: 167 additions & 0 deletions packages/core/src/utils/__tests__/dataset-format.date.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#7178 — a dataset measure over a date field renders as a date.
*
* `formatMeasure` opened with `if (typeof v !== 'number') return String(v)`,
* placed BEFORE `format` was read. So a `min` / `max` over a date or datetime
* field printed its stored value verbatim — a 24-character ISO string in the
* KPI tile's `text-2xl font-semibold`, wrapped over two lines — and the
* `format` that `DatasetMeasureSchema` accepts was unreachable for it.
*
* ── What these cases pin, and what they deliberately do NOT ─────────────────
* The fix is a ROUTE, not a formatter: every byte of date rendering comes from
* `../date-display.ts`, which is where `@object-ui/fields`' `formatDate` /
* `formatDateTime` now live and what the `date` cell renderer, `ObjectGrid`'s
* date cells and `ObjectGantt`'s tooltips call. So the cases below assert
* AGREEMENT WITH THAT PATH rather than literal strings wherever the path's own
* output is the contract — a literal would pin this file's opinion of a date,
* which is exactly the second convention objectui#4576 says not to create.
* (`DatasetWidget.dateMeasure.test.tsx` closes the loop on the rendered
* surfaces, and `@object-ui/fields`' `date-display.reexport-identity.test.ts`
* pins that the cell's `formatDate` and this one are the same function.)
*
* ── Directions, predicted in writing BEFORE the run ─────────────────────────
* the two date cases RED pre-fix — they render the raw ISO string
* the `format`-as-style cases RED pre-fix — `format` was never read here
* ⭐ every must-not-move case GREEN on both sides. These are the guard,
* not the fix: the new branch runs ahead of
* the `String(v)` short-circuit, so it has to
* be shown NOT to capture numbers, numeric
* strings, the nullish em dash, or prose.
*
* The full before/after sweep is recorded in the PR: 33,696 argument forms
* (value x format x currency x percentScale x locale) compared against a
* verbatim copy of the pre-fix function, 4 distinct values moved — every one
* of them ISO-shaped and parseable — and 0 expected movers missed.
*/

import { describe, it, expect } from 'vitest';

import { formatMeasure } from '../dataset-format.js';
import { formatDate, formatDateTime } from '../date-display.js';

/** A NON-current year, so `formatDate`'s "drop the year" branch is not in play. */
const ISO_DATE = '2024-07-04';
const ISO_DATETIME = '2024-07-04T07:00:00.000Z';
const EN = 'en-US';

describe('formatMeasure routes a date-shaped measure through the shared date path (objectui#7178)', () => {
it('renders a datetime measure as a datetime, not as its raw ISO string', () => {
const out = formatMeasure(ISO_DATETIME, undefined, undefined, undefined, EN);
expect(out).not.toBe(ISO_DATETIME);
expect(out).toBe(formatDateTime(ISO_DATETIME, { locale: EN }));
});

it('renders a date-only measure as a date, not as its raw ISO string', () => {
const out = formatMeasure(ISO_DATE, undefined, undefined, undefined, EN);
expect(out).not.toBe(ISO_DATE);
expect(out).toBe(formatDate(ISO_DATE, undefined, { locale: EN }));
});

it('accepts the space-separated ISO spelling a backend may send', () => {
const spaced = '2024-07-04 07:00:00';
expect(formatMeasure(spaced, undefined, undefined, undefined, EN)).toBe(
formatDateTime(spaced, { locale: EN }),
);
});

it('follows the display locale, like every other measure', () => {
const de = formatMeasure(ISO_DATETIME, undefined, undefined, undefined, 'de-DE');
const en = formatMeasure(ISO_DATETIME, undefined, undefined, undefined, EN);
expect(de).not.toBe(en);
expect(de).toBe(formatDateTime(ISO_DATETIME, { locale: 'de-DE' }));
});
});

/**
* The finding's reusable half: `DatasetMeasureSchema.format` is an open string
* that parses and, on this path, could never be read. It is read now — as the
* shared path's STYLE vocabulary, which is what that path actually accepts.
* A date PATTERN is not part of that vocabulary, and these cases say so out
* loud rather than leaving it silent, because "silent" is the whole card.
*/
describe('what `format` can and cannot say for a date measure (objectui#7178)', () => {
it('honours `short` — the same word `DateCellRenderer` honours from `field.format`', () => {
expect(formatMeasure(ISO_DATE, 'short', undefined, undefined, EN)).toBe(
formatDate(ISO_DATE, 'short', { locale: EN }),
);
expect(formatMeasure(ISO_DATE, 'short', undefined, undefined, EN)).not.toBe(
formatMeasure(ISO_DATE, undefined, undefined, undefined, EN),
);
});

it('honours `relative`', () => {
expect(formatMeasure(ISO_DATE, 'relative', undefined, undefined, EN)).toBe(
formatDate(ISO_DATE, 'relative', { locale: EN }),
);
});

it('⚠️ does NOT interpret a date PATTERN — `YYYY-MM-DD` renders the locale default', () => {
// Measured, not assumed, and stated in the PR body: the shared date path
// takes a named style, not a pattern. This case exists so the limit is
// pinned rather than rediscovered — and so that teaching the shared path a
// pattern grammar later has a test that must be updated deliberately.
const patterned = formatMeasure(ISO_DATE, 'YYYY-MM-DD', undefined, undefined, EN);
expect(patterned).not.toBe('2024-07-04');
expect(patterned).toBe(formatMeasure(ISO_DATE, undefined, undefined, undefined, EN));
});
});

/**
* ⭐ The regression guard. The date branch runs ahead of the `String(v)`
* short-circuit, so every one of these has to be shown NOT to reach it.
*/
describe('formatMeasure leaves every non-date value exactly where it was (objectui#7178)', () => {
it('formats numbers through the numeral path, untouched', () => {
expect(formatMeasure(1234.5, '0.0', undefined, undefined, EN)).toBe('1,234.5');
expect(formatMeasure(1234.5, '0.00', 'EUR', undefined, EN)).toBe('€1,234.50');
expect(formatMeasure(0.75, '0.0%', undefined, 'fraction', EN)).toBe('75.0%');
expect(formatMeasure(42, undefined, undefined, undefined, EN)).toBe('42');
expect(formatMeasure(2026, undefined, undefined, undefined, EN)).toBe('2026');
});

it('⭐ never reads a NUMERIC STRING as a date', () => {
// `1751612400000` is epoch milliseconds for a real instant, and `2026` is a
// year. Both are what a count or an id looks like coming back from a
// measure, and `Date.parse` would take either if it were asked.
for (const v of ['0', '42', '1234.5', '-1234.5', '1751612400000', '2026', '1e21']) {
expect(formatMeasure(v, undefined, undefined, undefined, EN)).toBe(v);
expect(formatMeasure(v, 'YYYY-MM-DD', undefined, undefined, EN)).toBe(v);
}
});

it('keeps the nullish em dash', () => {
expect(formatMeasure(null)).toBe('—');
expect(formatMeasure(undefined)).toBe('—');
expect(formatMeasure(null, 'YYYY-MM-DD', undefined, undefined, EN)).toBe('—');
});

it('falls through to `String(v)` for an arbitrary non-numeric, non-date value', () => {
for (const v of ['Acme Corp', 'N/A', '', 'March 5, 2026', '2026-07', '2026/07/04', '04-07-2026']) {
expect(formatMeasure(v, undefined, undefined, undefined, EN)).toBe(String(v));
}
expect(formatMeasure(true, undefined, undefined, undefined, EN)).toBe('true');
});

it('leaves an ISO-SHAPED but unparseable value alone rather than showing an em dash', () => {
// `formatDate` answers `—` for an unparseable value. Routing one there
// would REPLACE a raw string the author can still debug with a dash that
// says nothing, so the parse guard keeps these on the old path.
for (const v of ['2026-13-45', '2024-07-04T99:99']) {
expect(formatMeasure(v, undefined, undefined, undefined, EN)).toBe(v);
}
});

it('agrees with the list cell on a rolled-over date instead of second-guessing it', () => {
// `Date.parse('2024-02-30')` is NOT NaN — V8 rolls it to March 1/2. Every
// date surface in the repo builds its `Date` the same way, so this renders
// as the same day a list cell shows for the same stored string. Pinned
// because it is the one place agreement looks like a bug.
const rolled = '2024-02-30';
expect(Number.isNaN(Date.parse(rolled))).toBe(false);
expect(formatMeasure(rolled, undefined, undefined, undefined, EN)).toBe(
formatDate(rolled, undefined, { locale: EN }),
);
});
});
101 changes: 100 additions & 1 deletion packages/core/src/utils/dataset-format.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,7 @@ import type { AnalyticsResult } from '@objectstack/spec/contracts';
import type { PercentScale } from '@objectstack/spec/data';

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

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

/**
* ISO calendar date with NO time part — `2026-07-04`.
*
* Deliberately anchored at both ends and deliberately narrower than
* `Date.parse`, which also accepts locale prose (`March 5, 2026`), bare years
* (`2026`) and — the one that matters here — plain NUMERALS. A measure value
* is untyped by the time it reaches {@link formatMeasure}, so the shape test
* IS the type test, and a loose one would capture the numeric strings and
* counts this formatter must leave exactly as they are.
*/
const ISO_DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;

/**
* ISO date carrying a time part — `2026-07-04T07:00:00.000Z`, or the same with
* a space separator, with or without seconds/offset. Matched only as far as
* `HH:mm`; `Date.parse` decides the rest, so a well-shaped impossible instant
* still falls through untouched.
*/
const ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/;

/**
* Render a date-shaped measure value through the display path a list cell
* already uses for that field, or return `undefined` to say "not a date" and
* leave the caller's own fallthrough in charge.
*
* ── Why this is a ROUTE and not a formatter (objectui#7178) ──
* Every line of date rendering below happens in `./date-display.ts`, which is
* where `@object-ui/fields`' `formatDate` / `formatDateTime` now live and what
* `DateCellRenderer`, `ObjectGrid`'s date cells and `ObjectGantt`'s tooltips
* call. Writing the date convention HERE instead is the mistake this file's
* own header is about: objectui#4576, where a percent convention had one copy
* in a list cell and another in this function, both correct, and a German
* session read `1.234,5 %` in the cell beside `1.234,5%` in the measure. One
* date convention, one home, nothing to drift.
*
* The date/datetime split mirrors `ObjectGantt.tsx`'s sniffed-ISO dispatch
* verbatim, because that site is already the repo's answer to this exact
* situation: an ISO string arriving with NO field type attached. A measure is
* that situation by construction — the analytics result carries a column, not
* a field definition — so the absolute locale form is taken rather than
* `DateCellRenderer`'s `|| 'relative'` default, which is keyed on knowing the
* value is a `date` FIELD in a row.
*
* `format` is threaded into `formatDate`'s STYLE parameter, which is the same
* mapping `DateCellRenderer` makes from `field.format` and the same one
* `plugin-dashboard`'s `recordFields` already makes for a date-shaped format.
* That vocabulary is `'short'` and `'relative'`; a date PATTERN such as
* `'YYYY-MM-DD'` is not part of it and renders as the locale default. See the
* `format` note on {@link formatMeasure}.
*/
function formatMeasureDate(v: unknown, format: string | undefined, locale: string | undefined): string | undefined {
if (typeof v !== 'string') return undefined;
// `Date.parse` guards both arms so a well-shaped impossible date
// (`2026-02-30`) is NOT swallowed into the em dash `formatDate` returns for
// an unparseable value — it keeps falling through to `String(v)`, exactly as
// it does today. Only a value that is genuinely a date changes.
if (ISO_DATE_ONLY_RE.test(v)) {
return Number.isNaN(Date.parse(v)) ? undefined : formatDate(v, format, { locale });
}
if (ISO_DATETIME_RE.test(v)) {
return Number.isNaN(Date.parse(v)) ? undefined : formatDateTime(v, { locale });
}
return undefined;
}

/**
* Format a MEASURE value. Currency comes from the field's declared `currency`
* (locale-correct symbol via `Intl`), NOT from a "$" baked into the format
Expand DownExpand Up@@ -169,6 +235,28 @@ function formatNumberInLocale(
* to carry is gone, and with it the drift that duplicate produced — see the
* percent note in the body, and {@link percentDisplayValue}'s promise, which
* is true again.
*
* ── Date-valued measures (objectui#7178) ──
* `min` / `max` over a date or datetime field is a legitimate measure, and it
* used to render its stored value verbatim: the guard above returned
* `String(v)` for anything non-numeric BEFORE `format` was read, so a KPI tile
* showed `2026-07-04T07:00:00.000Z` in `text-2xl` and wrapped it over two
* lines. A date-shaped value now routes through {@link formatMeasureDate} to
* the display path list cells use; nothing about the numeric path moved.
*
* ⚠️ What `format` can and cannot say for those values, measured rather than
* assumed. The shared date path takes a named STYLE, not a date pattern:
* `formatDate`'s vocabulary is `'short'` and `'relative'`, and every other
* string falls to its default locale-medium branch. So `format: 'short'` and
* `format: 'relative'` are honoured — the same words `DateCellRenderer`
* honours from `field.format` for the same field — while a PATTERN like
* `format: 'YYYY-MM-DD'` is accepted by the schema, reaches this function, and
* renders the locale default. That last part is not new behaviour introduced
* here: `plugin-dashboard`'s `recordFields` already routes a date-shaped
* `format` into the same style slot and gets the same locale default. Closing
* it would mean teaching the shared path a pattern grammar, which is a change
* to the path itself and to every list cell that reads it — not a measure
* concern, and not this card.
*/
export function formatMeasure(
v: unknown,
Expand All@@ -178,7 +266,18 @@ export function formatMeasure(
locale?: string,
): string {
if (v == null) return '—';
if (typeof v !== 'number') return String(v);
if (typeof v !== 'number') {
// Ahead of the `String(v)` short-circuit, which is what made `format` dead
// on this path and printed a `min`/`max` over a datetime as a raw
// 24-character ISO string (objectui#7178). Deliberately NOT ahead of the
// `typeof` test itself: above that line the argument can be a bare number,
// and any date test generous enough to consider one would have to decide
// whether `1751612400000` is epoch milliseconds — which is how a measure
// that counts things starts rendering as a date. Numbers reach the numeral
// formatter below byte for byte as before.
const asDate = formatMeasureDate(v, format, locale);
return asDate ?? String(v);
}

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

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all \u003cpre\u003e\u003ccode\u003e blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks"); } } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); } })(); (function(){ try { var __m = "github.com"; var __re = new RegExp('^' + "github\\.com" + '
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
45 changes: 45 additions & 0 deletions .changeset/7178-format-measure-date.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
---
'@object-ui/core': patch
'@object-ui/fields': patch
---

Render a dataset measure over a date field as a date (objectui#7178, maintainer
ruling 2026-09-02, director summon #8 — option A).

`formatMeasure` opened with `if (typeof v !== 'number') return String(v)`,
placed **before** `format` was ever read. So a `min` / `max` measure over a date
or datetime field 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 was unreachable for those values.
A date-shaped value now routes to the date display path before that
short-circuit, so all four dataset-bound surfaces are served at once: the metric
tile, chart values, dataset table cells, and the metadata-admin dataset preview.

`min` / `max` over a date stays a legal measure; nothing in `@objectstack/spec`
narrows. `PivotTable` takes a `number` outright and is unchanged.

**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`), which is the same
remedy objectui#4576 applied to `formatDisplayNumber` and 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` — exactly
the drift that once had a list cell rendering `1.234,5 %` beside a dashboard
measure's `1.234,5%`. `@object-ui/fields` re-exports all four names unchanged,
so no consumer's import path or behaviour changes, and a reference-identity test
pins that the cell renderer and the measure formatter call the same function.

**What `format` can say for a date measure, measured rather than assumed.** The
shared date path takes a named STYLE, not a date pattern: `'short'` and
`'relative'` are honoured — the same words `DateCellRenderer` honours from
`field.format` — while a pattern such as `'YYYY-MM-DD'` renders the locale
default. That limit is unchanged by this release (`plugin-dashboard`'s
`recordFields` already routed a date-shaped `format` into the same style slot)
and is now pinned by a test instead of being silent.

**Numeric measures are byte-identical.** 33,696 argument forms
(value × format × currency × percentScale × locale) were compared against a
verbatim copy of the pre-fix function: the only values that moved were the four
ISO-shaped, parseable ones. Numbers, numeric strings (`'1751612400000'`,
`'2026'`), the nullish em dash, arbitrary prose and non-strings all render
exactly as before.
5 changes: 5 additions & 0 deletions packages/core/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,6 +82,11 @@ export * from './utils/chart-presentation.js';
// `dataset-format` below from reaching it (so the two drifted). `@object-ui/i18n`
// re-exports these names unchanged, so both import paths name the same symbol.
export * from './utils/number-display.js';
// The ONE date-display path (objectui#7178) — the same story one type over.
// It lived in `@object-ui/fields`' React barrel, which `dataset-format`
// below could not import, so a date-valued measure rendered its raw ISO
// string. `@object-ui/fields` re-exports these names unchanged.
export * from './utils/date-display.js';
export * from './utils/dataset-format.js';
// Pivot lookup-key encoders, shared by every cross-tab renderer so the
// dashboard widget and the report renderer key their buckets identically
Expand Down
167 changes: 167 additions & 0 deletions packages/core/src/utils/__tests__/dataset-format.date.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#7178 — a dataset measure over a date field renders as a date.
*
* `formatMeasure` opened with `if (typeof v !== 'number') return String(v)`,
* placed BEFORE `format` was read. So a `min` / `max` over a date or datetime
* field printed its stored value verbatim — a 24-character ISO string in the
* KPI tile's `text-2xl font-semibold`, wrapped over two lines — and the
* `format` that `DatasetMeasureSchema` accepts was unreachable for it.
*
* ── What these cases pin, and what they deliberately do NOT ─────────────────
* The fix is a ROUTE, not a formatter: every byte of date rendering comes from
* `../date-display.ts`, which is where `@object-ui/fields`' `formatDate` /
* `formatDateTime` now live and what the `date` cell renderer, `ObjectGrid`'s
* date cells and `ObjectGantt`'s tooltips call. So the cases below assert
* AGREEMENT WITH THAT PATH rather than literal strings wherever the path's own
* output is the contract — a literal would pin this file's opinion of a date,
* which is exactly the second convention objectui#4576 says not to create.
* (`DatasetWidget.dateMeasure.test.tsx` closes the loop on the rendered
* surfaces, and `@object-ui/fields`' `date-display.reexport-identity.test.ts`
* pins that the cell's `formatDate` and this one are the same function.)
*
* ── Directions, predicted in writing BEFORE the run ─────────────────────────
* the two date cases RED pre-fix — they render the raw ISO string
* the `format`-as-style cases RED pre-fix — `format` was never read here
* ⭐ every must-not-move case GREEN on both sides. These are the guard,
* not the fix: the new branch runs ahead of
* the `String(v)` short-circuit, so it has to
* be shown NOT to capture numbers, numeric
* strings, the nullish em dash, or prose.
*
* The full before/after sweep is recorded in the PR: 33,696 argument forms
* (value x format x currency x percentScale x locale) compared against a
* verbatim copy of the pre-fix function, 4 distinct values moved — every one
* of them ISO-shaped and parseable — and 0 expected movers missed.
*/

import { describe, it, expect } from 'vitest';

import { formatMeasure } from '../dataset-format.js';
import { formatDate, formatDateTime } from '../date-display.js';

/** A NON-current year, so `formatDate`'s "drop the year" branch is not in play. */
const ISO_DATE = '2024-07-04';
const ISO_DATETIME = '2024-07-04T07:00:00.000Z';
const EN = 'en-US';

describe('formatMeasure routes a date-shaped measure through the shared date path (objectui#7178)', () => {
it('renders a datetime measure as a datetime, not as its raw ISO string', () => {
const out = formatMeasure(ISO_DATETIME, undefined, undefined, undefined, EN);
expect(out).not.toBe(ISO_DATETIME);
expect(out).toBe(formatDateTime(ISO_DATETIME, { locale: EN }));
});

it('renders a date-only measure as a date, not as its raw ISO string', () => {
const out = formatMeasure(ISO_DATE, undefined, undefined, undefined, EN);
expect(out).not.toBe(ISO_DATE);
expect(out).toBe(formatDate(ISO_DATE, undefined, { locale: EN }));
});

it('accepts the space-separated ISO spelling a backend may send', () => {
const spaced = '2024-07-04 07:00:00';
expect(formatMeasure(spaced, undefined, undefined, undefined, EN)).toBe(
formatDateTime(spaced, { locale: EN }),
);
});

it('follows the display locale, like every other measure', () => {
const de = formatMeasure(ISO_DATETIME, undefined, undefined, undefined, 'de-DE');
const en = formatMeasure(ISO_DATETIME, undefined, undefined, undefined, EN);
expect(de).not.toBe(en);
expect(de).toBe(formatDateTime(ISO_DATETIME, { locale: 'de-DE' }));
});
});

/**
* The finding's reusable half: `DatasetMeasureSchema.format` is an open string
* that parses and, on this path, could never be read. It is read now — as the
* shared path's STYLE vocabulary, which is what that path actually accepts.
* A date PATTERN is not part of that vocabulary, and these cases say so out
* loud rather than leaving it silent, because "silent" is the whole card.
*/
describe('what `format` can and cannot say for a date measure (objectui#7178)', () => {
it('honours `short` — the same word `DateCellRenderer` honours from `field.format`', () => {
expect(formatMeasure(ISO_DATE, 'short', undefined, undefined, EN)).toBe(
formatDate(ISO_DATE, 'short', { locale: EN }),
);
expect(formatMeasure(ISO_DATE, 'short', undefined, undefined, EN)).not.toBe(
formatMeasure(ISO_DATE, undefined, undefined, undefined, EN),
);
});

it('honours `relative`', () => {
expect(formatMeasure(ISO_DATE, 'relative', undefined, undefined, EN)).toBe(
formatDate(ISO_DATE, 'relative', { locale: EN }),
);
});

it('⚠️ does NOT interpret a date PATTERN — `YYYY-MM-DD` renders the locale default', () => {
// Measured, not assumed, and stated in the PR body: the shared date path
// takes a named style, not a pattern. This case exists so the limit is
// pinned rather than rediscovered — and so that teaching the shared path a
// pattern grammar later has a test that must be updated deliberately.
const patterned = formatMeasure(ISO_DATE, 'YYYY-MM-DD', undefined, undefined, EN);
expect(patterned).not.toBe('2024-07-04');
expect(patterned).toBe(formatMeasure(ISO_DATE, undefined, undefined, undefined, EN));
});
});

/**
* ⭐ The regression guard. The date branch runs ahead of the `String(v)`
* short-circuit, so every one of these has to be shown NOT to reach it.
*/
describe('formatMeasure leaves every non-date value exactly where it was (objectui#7178)', () => {
it('formats numbers through the numeral path, untouched', () => {
expect(formatMeasure(1234.5, '0.0', undefined, undefined, EN)).toBe('1,234.5');
expect(formatMeasure(1234.5, '0.00', 'EUR', undefined, EN)).toBe('€1,234.50');
expect(formatMeasure(0.75, '0.0%', undefined, 'fraction', EN)).toBe('75.0%');
expect(formatMeasure(42, undefined, undefined, undefined, EN)).toBe('42');
expect(formatMeasure(2026, undefined, undefined, undefined, EN)).toBe('2026');
});

it('⭐ never reads a NUMERIC STRING as a date', () => {
// `1751612400000` is epoch milliseconds for a real instant, and `2026` is a
// year. Both are what a count or an id looks like coming back from a
// measure, and `Date.parse` would take either if it were asked.
for (const v of ['0', '42', '1234.5', '-1234.5', '1751612400000', '2026', '1e21']) {
expect(formatMeasure(v, undefined, undefined, undefined, EN)).toBe(v);
expect(formatMeasure(v, 'YYYY-MM-DD', undefined, undefined, EN)).toBe(v);
}
});

it('keeps the nullish em dash', () => {
expect(formatMeasure(null)).toBe('—');
expect(formatMeasure(undefined)).toBe('—');
expect(formatMeasure(null, 'YYYY-MM-DD', undefined, undefined, EN)).toBe('—');
});

it('falls through to `String(v)` for an arbitrary non-numeric, non-date value', () => {
for (const v of ['Acme Corp', 'N/A', '', 'March 5, 2026', '2026-07', '2026/07/04', '04-07-2026']) {
expect(formatMeasure(v, undefined, undefined, undefined, EN)).toBe(String(v));
}
expect(formatMeasure(true, undefined, undefined, undefined, EN)).toBe('true');
});

it('leaves an ISO-SHAPED but unparseable value alone rather than showing an em dash', () => {
// `formatDate` answers `—` for an unparseable value. Routing one there
// would REPLACE a raw string the author can still debug with a dash that
// says nothing, so the parse guard keeps these on the old path.
for (const v of ['2026-13-45', '2024-07-04T99:99']) {
expect(formatMeasure(v, undefined, undefined, undefined, EN)).toBe(v);
}
});

it('agrees with the list cell on a rolled-over date instead of second-guessing it', () => {
// `Date.parse('2024-02-30')` is NOT NaN — V8 rolls it to March 1/2. Every
// date surface in the repo builds its `Date` the same way, so this renders
// as the same day a list cell shows for the same stored string. Pinned
// because it is the one place agreement looks like a bug.
const rolled = '2024-02-30';
expect(Number.isNaN(Date.parse(rolled))).toBe(false);
expect(formatMeasure(rolled, undefined, undefined, undefined, EN)).toBe(
formatDate(rolled, undefined, { locale: EN }),
);
});
});
101 changes: 100 additions & 1 deletion packages/core/src/utils/dataset-format.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,7 @@ import type { AnalyticsResult } from '@objectstack/spec/contracts';
import type { PercentScale } from '@objectstack/spec/data';

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

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

/**
* ISO calendar date with NO time part — `2026-07-04`.
*
* Deliberately anchored at both ends and deliberately narrower than
* `Date.parse`, which also accepts locale prose (`March 5, 2026`), bare years
* (`2026`) and — the one that matters here — plain NUMERALS. A measure value
* is untyped by the time it reaches {@link formatMeasure}, so the shape test
* IS the type test, and a loose one would capture the numeric strings and
* counts this formatter must leave exactly as they are.
*/
const ISO_DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;

/**
* ISO date carrying a time part — `2026-07-04T07:00:00.000Z`, or the same with
* a space separator, with or without seconds/offset. Matched only as far as
* `HH:mm`; `Date.parse` decides the rest, so a well-shaped impossible instant
* still falls through untouched.
*/
const ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/;

/**
* Render a date-shaped measure value through the display path a list cell
* already uses for that field, or return `undefined` to say "not a date" and
* leave the caller's own fallthrough in charge.
*
* ── Why this is a ROUTE and not a formatter (objectui#7178) ──
* Every line of date rendering below happens in `./date-display.ts`, which is
* where `@object-ui/fields`' `formatDate` / `formatDateTime` now live and what
* `DateCellRenderer`, `ObjectGrid`'s date cells and `ObjectGantt`'s tooltips
* call. Writing the date convention HERE instead is the mistake this file's
* own header is about: objectui#4576, where a percent convention had one copy
* in a list cell and another in this function, both correct, and a German
* session read `1.234,5 %` in the cell beside `1.234,5%` in the measure. One
* date convention, one home, nothing to drift.
*
* The date/datetime split mirrors `ObjectGantt.tsx`'s sniffed-ISO dispatch
* verbatim, because that site is already the repo's answer to this exact
* situation: an ISO string arriving with NO field type attached. A measure is
* that situation by construction — the analytics result carries a column, not
* a field definition — so the absolute locale form is taken rather than
* `DateCellRenderer`'s `|| 'relative'` default, which is keyed on knowing the
* value is a `date` FIELD in a row.
*
* `format` is threaded into `formatDate`'s STYLE parameter, which is the same
* mapping `DateCellRenderer` makes from `field.format` and the same one
* `plugin-dashboard`'s `recordFields` already makes for a date-shaped format.
* That vocabulary is `'short'` and `'relative'`; a date PATTERN such as
* `'YYYY-MM-DD'` is not part of it and renders as the locale default. See the
* `format` note on {@link formatMeasure}.
*/
function formatMeasureDate(v: unknown, format: string | undefined, locale: string | undefined): string | undefined {
if (typeof v !== 'string') return undefined;
// `Date.parse` guards both arms so a well-shaped impossible date
// (`2026-02-30`) is NOT swallowed into the em dash `formatDate` returns for
// an unparseable value — it keeps falling through to `String(v)`, exactly as
// it does today. Only a value that is genuinely a date changes.
if (ISO_DATE_ONLY_RE.test(v)) {
return Number.isNaN(Date.parse(v)) ? undefined : formatDate(v, format, { locale });
}
if (ISO_DATETIME_RE.test(v)) {
return Number.isNaN(Date.parse(v)) ? undefined : formatDateTime(v, { locale });
}
return undefined;
}

/**
* Format a MEASURE value. Currency comes from the field's declared `currency`
* (locale-correct symbol via `Intl`), NOT from a "$" baked into the format
Expand DownExpand Up@@ -169,6 +235,28 @@ function formatNumberInLocale(
* to carry is gone, and with it the drift that duplicate produced — see the
* percent note in the body, and {@link percentDisplayValue}'s promise, which
* is true again.
*
* ── Date-valued measures (objectui#7178) ──
* `min` / `max` over a date or datetime field is a legitimate measure, and it
* used to render its stored value verbatim: the guard above returned
* `String(v)` for anything non-numeric BEFORE `format` was read, so a KPI tile
* showed `2026-07-04T07:00:00.000Z` in `text-2xl` and wrapped it over two
* lines. A date-shaped value now routes through {@link formatMeasureDate} to
* the display path list cells use; nothing about the numeric path moved.
*
* ⚠️ What `format` can and cannot say for those values, measured rather than
* assumed. The shared date path takes a named STYLE, not a date pattern:
* `formatDate`'s vocabulary is `'short'` and `'relative'`, and every other
* string falls to its default locale-medium branch. So `format: 'short'` and
* `format: 'relative'` are honoured — the same words `DateCellRenderer`
* honours from `field.format` for the same field — while a PATTERN like
* `format: 'YYYY-MM-DD'` is accepted by the schema, reaches this function, and
* renders the locale default. That last part is not new behaviour introduced
* here: `plugin-dashboard`'s `recordFields` already routes a date-shaped
* `format` into the same style slot and gets the same locale default. Closing
* it would mean teaching the shared path a pattern grammar, which is a change
* to the path itself and to every list cell that reads it — not a measure
* concern, and not this card.
*/
export function formatMeasure(
v: unknown,
Expand All@@ -178,7 +266,18 @@ export function formatMeasure(
locale?: string,
): string {
if (v == null) return '—';
if (typeof v !== 'number') return String(v);
if (typeof v !== 'number') {
// Ahead of the `String(v)` short-circuit, which is what made `format` dead
// on this path and printed a `min`/`max` over a datetime as a raw
// 24-character ISO string (objectui#7178). Deliberately NOT ahead of the
// `typeof` test itself: above that line the argument can be a bare number,
// and any date test generous enough to consider one would have to decide
// whether `1751612400000` is epoch milliseconds — which is how a measure
// that counts things starts rendering as a date. Numbers reach the numeral
// formatter below byte for byte as before.
const asDate = formatMeasureDate(v, format, locale);
return asDate ?? String(v);
}

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

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
45 changes: 45 additions & 0 deletions .changeset/7178-format-measure-date.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
---
'@object-ui/core': patch
'@object-ui/fields': patch
---

Render a dataset measure over a date field as a date (objectui#7178, maintainer
ruling 2026-09-02, director summon #8 — option A).

`formatMeasure` opened with `if (typeof v !== 'number') return String(v)`,
placed **before** `format` was ever read. So a `min` / `max` measure over a date
or datetime field 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 was unreachable for those values.
A date-shaped value now routes to the date display path before that
short-circuit, so all four dataset-bound surfaces are served at once: the metric
tile, chart values, dataset table cells, and the metadata-admin dataset preview.

`min` / `max` over a date stays a legal measure; nothing in `@objectstack/spec`
narrows. `PivotTable` takes a `number` outright and is unchanged.

**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`), which is the same
remedy objectui#4576 applied to `formatDisplayNumber` and 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` — exactly
the drift that once had a list cell rendering `1.234,5 %` beside a dashboard
measure's `1.234,5%`. `@object-ui/fields` re-exports all four names unchanged,
so no consumer's import path or behaviour changes, and a reference-identity test
pins that the cell renderer and the measure formatter call the same function.

**What `format` can say for a date measure, measured rather than assumed.** The
shared date path takes a named STYLE, not a date pattern: `'short'` and
`'relative'` are honoured — the same words `DateCellRenderer` honours from
`field.format` — while a pattern such as `'YYYY-MM-DD'` renders the locale
default. That limit is unchanged by this release (`plugin-dashboard`'s
`recordFields` already routed a date-shaped `format` into the same style slot)
and is now pinned by a test instead of being silent.

**Numeric measures are byte-identical.** 33,696 argument forms
(value × format × currency × percentScale × locale) were compared against a
verbatim copy of the pre-fix function: the only values that moved were the four
ISO-shaped, parseable ones. Numbers, numeric strings (`'1751612400000'`,
`'2026'`), the nullish em dash, arbitrary prose and non-strings all render
exactly as before.
5 changes: 5 additions & 0 deletions packages/core/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,6 +82,11 @@ export * from './utils/chart-presentation.js';
// `dataset-format` below from reaching it (so the two drifted). `@object-ui/i18n`
// re-exports these names unchanged, so both import paths name the same symbol.
export * from './utils/number-display.js';
// The ONE date-display path (objectui#7178) — the same story one type over.
// It lived in `@object-ui/fields`' React barrel, which `dataset-format`
// below could not import, so a date-valued measure rendered its raw ISO
// string. `@object-ui/fields` re-exports these names unchanged.
export * from './utils/date-display.js';
export * from './utils/dataset-format.js';
// Pivot lookup-key encoders, shared by every cross-tab renderer so the
// dashboard widget and the report renderer key their buckets identically
Expand Down
167 changes: 167 additions & 0 deletions packages/core/src/utils/__tests__/dataset-format.date.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#7178 — a dataset measure over a date field renders as a date.
*
* `formatMeasure` opened with `if (typeof v !== 'number') return String(v)`,
* placed BEFORE `format` was read. So a `min` / `max` over a date or datetime
* field printed its stored value verbatim — a 24-character ISO string in the
* KPI tile's `text-2xl font-semibold`, wrapped over two lines — and the
* `format` that `DatasetMeasureSchema` accepts was unreachable for it.
*
* ── What these cases pin, and what they deliberately do NOT ─────────────────
* The fix is a ROUTE, not a formatter: every byte of date rendering comes from
* `../date-display.ts`, which is where `@object-ui/fields`' `formatDate` /
* `formatDateTime` now live and what the `date` cell renderer, `ObjectGrid`'s
* date cells and `ObjectGantt`'s tooltips call. So the cases below assert
* AGREEMENT WITH THAT PATH rather than literal strings wherever the path's own
* output is the contract — a literal would pin this file's opinion of a date,
* which is exactly the second convention objectui#4576 says not to create.
* (`DatasetWidget.dateMeasure.test.tsx` closes the loop on the rendered
* surfaces, and `@object-ui/fields`' `date-display.reexport-identity.test.ts`
* pins that the cell's `formatDate` and this one are the same function.)
*
* ── Directions, predicted in writing BEFORE the run ─────────────────────────
* the two date cases RED pre-fix — they render the raw ISO string
* the `format`-as-style cases RED pre-fix — `format` was never read here
* ⭐ every must-not-move case GREEN on both sides. These are the guard,
* not the fix: the new branch runs ahead of
* the `String(v)` short-circuit, so it has to
* be shown NOT to capture numbers, numeric
* strings, the nullish em dash, or prose.
*
* The full before/after sweep is recorded in the PR: 33,696 argument forms
* (value x format x currency x percentScale x locale) compared against a
* verbatim copy of the pre-fix function, 4 distinct values moved — every one
* of them ISO-shaped and parseable — and 0 expected movers missed.
*/

import { describe, it, expect } from 'vitest';

import { formatMeasure } from '../dataset-format.js';
import { formatDate, formatDateTime } from '../date-display.js';

/** A NON-current year, so `formatDate`'s "drop the year" branch is not in play. */
const ISO_DATE = '2024-07-04';
const ISO_DATETIME = '2024-07-04T07:00:00.000Z';
const EN = 'en-US';

describe('formatMeasure routes a date-shaped measure through the shared date path (objectui#7178)', () => {
it('renders a datetime measure as a datetime, not as its raw ISO string', () => {
const out = formatMeasure(ISO_DATETIME, undefined, undefined, undefined, EN);
expect(out).not.toBe(ISO_DATETIME);
expect(out).toBe(formatDateTime(ISO_DATETIME, { locale: EN }));
});

it('renders a date-only measure as a date, not as its raw ISO string', () => {
const out = formatMeasure(ISO_DATE, undefined, undefined, undefined, EN);
expect(out).not.toBe(ISO_DATE);
expect(out).toBe(formatDate(ISO_DATE, undefined, { locale: EN }));
});

it('accepts the space-separated ISO spelling a backend may send', () => {
const spaced = '2024-07-04 07:00:00';
expect(formatMeasure(spaced, undefined, undefined, undefined, EN)).toBe(
formatDateTime(spaced, { locale: EN }),
);
});

it('follows the display locale, like every other measure', () => {
const de = formatMeasure(ISO_DATETIME, undefined, undefined, undefined, 'de-DE');
const en = formatMeasure(ISO_DATETIME, undefined, undefined, undefined, EN);
expect(de).not.toBe(en);
expect(de).toBe(formatDateTime(ISO_DATETIME, { locale: 'de-DE' }));
});
});

/**
* The finding's reusable half: `DatasetMeasureSchema.format` is an open string
* that parses and, on this path, could never be read. It is read now — as the
* shared path's STYLE vocabulary, which is what that path actually accepts.
* A date PATTERN is not part of that vocabulary, and these cases say so out
* loud rather than leaving it silent, because "silent" is the whole card.
*/
describe('what `format` can and cannot say for a date measure (objectui#7178)', () => {
it('honours `short` — the same word `DateCellRenderer` honours from `field.format`', () => {
expect(formatMeasure(ISO_DATE, 'short', undefined, undefined, EN)).toBe(
formatDate(ISO_DATE, 'short', { locale: EN }),
);
expect(formatMeasure(ISO_DATE, 'short', undefined, undefined, EN)).not.toBe(
formatMeasure(ISO_DATE, undefined, undefined, undefined, EN),
);
});

it('honours `relative`', () => {
expect(formatMeasure(ISO_DATE, 'relative', undefined, undefined, EN)).toBe(
formatDate(ISO_DATE, 'relative', { locale: EN }),
);
});

it('⚠️ does NOT interpret a date PATTERN — `YYYY-MM-DD` renders the locale default', () => {
// Measured, not assumed, and stated in the PR body: the shared date path
// takes a named style, not a pattern. This case exists so the limit is
// pinned rather than rediscovered — and so that teaching the shared path a
// pattern grammar later has a test that must be updated deliberately.
const patterned = formatMeasure(ISO_DATE, 'YYYY-MM-DD', undefined, undefined, EN);
expect(patterned).not.toBe('2024-07-04');
expect(patterned).toBe(formatMeasure(ISO_DATE, undefined, undefined, undefined, EN));
});
});

/**
* ⭐ The regression guard. The date branch runs ahead of the `String(v)`
* short-circuit, so every one of these has to be shown NOT to reach it.
*/
describe('formatMeasure leaves every non-date value exactly where it was (objectui#7178)', () => {
it('formats numbers through the numeral path, untouched', () => {
expect(formatMeasure(1234.5, '0.0', undefined, undefined, EN)).toBe('1,234.5');
expect(formatMeasure(1234.5, '0.00', 'EUR', undefined, EN)).toBe('€1,234.50');
expect(formatMeasure(0.75, '0.0%', undefined, 'fraction', EN)).toBe('75.0%');
expect(formatMeasure(42, undefined, undefined, undefined, EN)).toBe('42');
expect(formatMeasure(2026, undefined, undefined, undefined, EN)).toBe('2026');
});

it('⭐ never reads a NUMERIC STRING as a date', () => {
// `1751612400000` is epoch milliseconds for a real instant, and `2026` is a
// year. Both are what a count or an id looks like coming back from a
// measure, and `Date.parse` would take either if it were asked.
for (const v of ['0', '42', '1234.5', '-1234.5', '1751612400000', '2026', '1e21']) {
expect(formatMeasure(v, undefined, undefined, undefined, EN)).toBe(v);
expect(formatMeasure(v, 'YYYY-MM-DD', undefined, undefined, EN)).toBe(v);
}
});

it('keeps the nullish em dash', () => {
expect(formatMeasure(null)).toBe('—');
expect(formatMeasure(undefined)).toBe('—');
expect(formatMeasure(null, 'YYYY-MM-DD', undefined, undefined, EN)).toBe('—');
});

it('falls through to `String(v)` for an arbitrary non-numeric, non-date value', () => {
for (const v of ['Acme Corp', 'N/A', '', 'March 5, 2026', '2026-07', '2026/07/04', '04-07-2026']) {
expect(formatMeasure(v, undefined, undefined, undefined, EN)).toBe(String(v));
}
expect(formatMeasure(true, undefined, undefined, undefined, EN)).toBe('true');
});

it('leaves an ISO-SHAPED but unparseable value alone rather than showing an em dash', () => {
// `formatDate` answers `—` for an unparseable value. Routing one there
// would REPLACE a raw string the author can still debug with a dash that
// says nothing, so the parse guard keeps these on the old path.
for (const v of ['2026-13-45', '2024-07-04T99:99']) {
expect(formatMeasure(v, undefined, undefined, undefined, EN)).toBe(v);
}
});

it('agrees with the list cell on a rolled-over date instead of second-guessing it', () => {
// `Date.parse('2024-02-30')` is NOT NaN — V8 rolls it to March 1/2. Every
// date surface in the repo builds its `Date` the same way, so this renders
// as the same day a list cell shows for the same stored string. Pinned
// because it is the one place agreement looks like a bug.
const rolled = '2024-02-30';
expect(Number.isNaN(Date.parse(rolled))).toBe(false);
expect(formatMeasure(rolled, undefined, undefined, undefined, EN)).toBe(
formatDate(rolled, undefined, { locale: EN }),
);
});
});
101 changes: 100 additions & 1 deletion packages/core/src/utils/dataset-format.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,7 @@ import type { AnalyticsResult } from '@objectstack/spec/contracts';
import type { PercentScale } from '@objectstack/spec/data';

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

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

/**
* ISO calendar date with NO time part — `2026-07-04`.
*
* Deliberately anchored at both ends and deliberately narrower than
* `Date.parse`, which also accepts locale prose (`March 5, 2026`), bare years
* (`2026`) and — the one that matters here — plain NUMERALS. A measure value
* is untyped by the time it reaches {@link formatMeasure}, so the shape test
* IS the type test, and a loose one would capture the numeric strings and
* counts this formatter must leave exactly as they are.
*/
const ISO_DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;

/**
* ISO date carrying a time part — `2026-07-04T07:00:00.000Z`, or the same with
* a space separator, with or without seconds/offset. Matched only as far as
* `HH:mm`; `Date.parse` decides the rest, so a well-shaped impossible instant
* still falls through untouched.
*/
const ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/;

/**
* Render a date-shaped measure value through the display path a list cell
* already uses for that field, or return `undefined` to say "not a date" and
* leave the caller's own fallthrough in charge.
*
* ── Why this is a ROUTE and not a formatter (objectui#7178) ──
* Every line of date rendering below happens in `./date-display.ts`, which is
* where `@object-ui/fields`' `formatDate` / `formatDateTime` now live and what
* `DateCellRenderer`, `ObjectGrid`'s date cells and `ObjectGantt`'s tooltips
* call. Writing the date convention HERE instead is the mistake this file's
* own header is about: objectui#4576, where a percent convention had one copy
* in a list cell and another in this function, both correct, and a German
* session read `1.234,5 %` in the cell beside `1.234,5%` in the measure. One
* date convention, one home, nothing to drift.
*
* The date/datetime split mirrors `ObjectGantt.tsx`'s sniffed-ISO dispatch
* verbatim, because that site is already the repo's answer to this exact
* situation: an ISO string arriving with NO field type attached. A measure is
* that situation by construction — the analytics result carries a column, not
* a field definition — so the absolute locale form is taken rather than
* `DateCellRenderer`'s `|| 'relative'` default, which is keyed on knowing the
* value is a `date` FIELD in a row.
*
* `format` is threaded into `formatDate`'s STYLE parameter, which is the same
* mapping `DateCellRenderer` makes from `field.format` and the same one
* `plugin-dashboard`'s `recordFields` already makes for a date-shaped format.
* That vocabulary is `'short'` and `'relative'`; a date PATTERN such as
* `'YYYY-MM-DD'` is not part of it and renders as the locale default. See the
* `format` note on {@link formatMeasure}.
*/
function formatMeasureDate(v: unknown, format: string | undefined, locale: string | undefined): string | undefined {
if (typeof v !== 'string') return undefined;
// `Date.parse` guards both arms so a well-shaped impossible date
// (`2026-02-30`) is NOT swallowed into the em dash `formatDate` returns for
// an unparseable value — it keeps falling through to `String(v)`, exactly as
// it does today. Only a value that is genuinely a date changes.
if (ISO_DATE_ONLY_RE.test(v)) {
return Number.isNaN(Date.parse(v)) ? undefined : formatDate(v, format, { locale });
}
if (ISO_DATETIME_RE.test(v)) {
return Number.isNaN(Date.parse(v)) ? undefined : formatDateTime(v, { locale });
}
return undefined;
}

/**
* Format a MEASURE value. Currency comes from the field's declared `currency`
* (locale-correct symbol via `Intl`), NOT from a "$" baked into the format
Expand DownExpand Up@@ -169,6 +235,28 @@ function formatNumberInLocale(
* to carry is gone, and with it the drift that duplicate produced — see the
* percent note in the body, and {@link percentDisplayValue}'s promise, which
* is true again.
*
* ── Date-valued measures (objectui#7178) ──
* `min` / `max` over a date or datetime field is a legitimate measure, and it
* used to render its stored value verbatim: the guard above returned
* `String(v)` for anything non-numeric BEFORE `format` was read, so a KPI tile
* showed `2026-07-04T07:00:00.000Z` in `text-2xl` and wrapped it over two
* lines. A date-shaped value now routes through {@link formatMeasureDate} to
* the display path list cells use; nothing about the numeric path moved.
*
* ⚠️ What `format` can and cannot say for those values, measured rather than
* assumed. The shared date path takes a named STYLE, not a date pattern:
* `formatDate`'s vocabulary is `'short'` and `'relative'`, and every other
* string falls to its default locale-medium branch. So `format: 'short'` and
* `format: 'relative'` are honoured — the same words `DateCellRenderer`
* honours from `field.format` for the same field — while a PATTERN like
* `format: 'YYYY-MM-DD'` is accepted by the schema, reaches this function, and
* renders the locale default. That last part is not new behaviour introduced
* here: `plugin-dashboard`'s `recordFields` already routes a date-shaped
* `format` into the same style slot and gets the same locale default. Closing
* it would mean teaching the shared path a pattern grammar, which is a change
* to the path itself and to every list cell that reads it — not a measure
* concern, and not this card.
*/
export function formatMeasure(
v: unknown,
Expand All@@ -178,7 +266,18 @@ export function formatMeasure(
locale?: string,
): string {
if (v == null) return '—';
if (typeof v !== 'number') return String(v);
if (typeof v !== 'number') {
// Ahead of the `String(v)` short-circuit, which is what made `format` dead
// on this path and printed a `min`/`max` over a datetime as a raw
// 24-character ISO string (objectui#7178). Deliberately NOT ahead of the
// `typeof` test itself: above that line the argument can be a bare number,
// and any date test generous enough to consider one would have to decide
// whether `1751612400000` is epoch milliseconds — which is how a measure
// that counts things starts rendering as a date. Numbers reach the numeral
// formatter below byte for byte as before.
const asDate = formatMeasureDate(v, format, locale);
return asDate ?? String(v);
}

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

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length \u003e 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
45 changes: 45 additions & 0 deletions .changeset/7178-format-measure-date.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
---
'@object-ui/core': patch
'@object-ui/fields': patch
---

Render a dataset measure over a date field as a date (objectui#7178, maintainer
ruling 2026-09-02, director summon #8 — option A).

`formatMeasure` opened with `if (typeof v !== 'number') return String(v)`,
placed **before** `format` was ever read. So a `min` / `max` measure over a date
or datetime field 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 was unreachable for those values.
A date-shaped value now routes to the date display path before that
short-circuit, so all four dataset-bound surfaces are served at once: the metric
tile, chart values, dataset table cells, and the metadata-admin dataset preview.

`min` / `max` over a date stays a legal measure; nothing in `@objectstack/spec`
narrows. `PivotTable` takes a `number` outright and is unchanged.

**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`), which is the same
remedy objectui#4576 applied to `formatDisplayNumber` and 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` — exactly
the drift that once had a list cell rendering `1.234,5 %` beside a dashboard
measure's `1.234,5%`. `@object-ui/fields` re-exports all four names unchanged,
so no consumer's import path or behaviour changes, and a reference-identity test
pins that the cell renderer and the measure formatter call the same function.

**What `format` can say for a date measure, measured rather than assumed.** The
shared date path takes a named STYLE, not a date pattern: `'short'` and
`'relative'` are honoured — the same words `DateCellRenderer` honours from
`field.format` — while a pattern such as `'YYYY-MM-DD'` renders the locale
default. That limit is unchanged by this release (`plugin-dashboard`'s
`recordFields` already routed a date-shaped `format` into the same style slot)
and is now pinned by a test instead of being silent.

**Numeric measures are byte-identical.** 33,696 argument forms
(value × format × currency × percentScale × locale) were compared against a
verbatim copy of the pre-fix function: the only values that moved were the four
ISO-shaped, parseable ones. Numbers, numeric strings (`'1751612400000'`,
`'2026'`), the nullish em dash, arbitrary prose and non-strings all render
exactly as before.
5 changes: 5 additions & 0 deletions packages/core/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,6 +82,11 @@ export * from './utils/chart-presentation.js';
// `dataset-format` below from reaching it (so the two drifted). `@object-ui/i18n`
// re-exports these names unchanged, so both import paths name the same symbol.
export * from './utils/number-display.js';
// The ONE date-display path (objectui#7178) — the same story one type over.
// It lived in `@object-ui/fields`' React barrel, which `dataset-format`
// below could not import, so a date-valued measure rendered its raw ISO
// string. `@object-ui/fields` re-exports these names unchanged.
export * from './utils/date-display.js';
export * from './utils/dataset-format.js';
// Pivot lookup-key encoders, shared by every cross-tab renderer so the
// dashboard widget and the report renderer key their buckets identically
Expand Down
167 changes: 167 additions & 0 deletions packages/core/src/utils/__tests__/dataset-format.date.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#7178 — a dataset measure over a date field renders as a date.
*
* `formatMeasure` opened with `if (typeof v !== 'number') return String(v)`,
* placed BEFORE `format` was read. So a `min` / `max` over a date or datetime
* field printed its stored value verbatim — a 24-character ISO string in the
* KPI tile's `text-2xl font-semibold`, wrapped over two lines — and the
* `format` that `DatasetMeasureSchema` accepts was unreachable for it.
*
* ── What these cases pin, and what they deliberately do NOT ─────────────────
* The fix is a ROUTE, not a formatter: every byte of date rendering comes from
* `../date-display.ts`, which is where `@object-ui/fields`' `formatDate` /
* `formatDateTime` now live and what the `date` cell renderer, `ObjectGrid`'s
* date cells and `ObjectGantt`'s tooltips call. So the cases below assert
* AGREEMENT WITH THAT PATH rather than literal strings wherever the path's own
* output is the contract — a literal would pin this file's opinion of a date,
* which is exactly the second convention objectui#4576 says not to create.
* (`DatasetWidget.dateMeasure.test.tsx` closes the loop on the rendered
* surfaces, and `@object-ui/fields`' `date-display.reexport-identity.test.ts`
* pins that the cell's `formatDate` and this one are the same function.)
*
* ── Directions, predicted in writing BEFORE the run ─────────────────────────
* the two date cases RED pre-fix — they render the raw ISO string
* the `format`-as-style cases RED pre-fix — `format` was never read here
* ⭐ every must-not-move case GREEN on both sides. These are the guard,
* not the fix: the new branch runs ahead of
* the `String(v)` short-circuit, so it has to
* be shown NOT to capture numbers, numeric
* strings, the nullish em dash, or prose.
*
* The full before/after sweep is recorded in the PR: 33,696 argument forms
* (value x format x currency x percentScale x locale) compared against a
* verbatim copy of the pre-fix function, 4 distinct values moved — every one
* of them ISO-shaped and parseable — and 0 expected movers missed.
*/

import { describe, it, expect } from 'vitest';

import { formatMeasure } from '../dataset-format.js';
import { formatDate, formatDateTime } from '../date-display.js';

/** A NON-current year, so `formatDate`'s "drop the year" branch is not in play. */
const ISO_DATE = '2024-07-04';
const ISO_DATETIME = '2024-07-04T07:00:00.000Z';
const EN = 'en-US';

describe('formatMeasure routes a date-shaped measure through the shared date path (objectui#7178)', () => {
it('renders a datetime measure as a datetime, not as its raw ISO string', () => {
const out = formatMeasure(ISO_DATETIME, undefined, undefined, undefined, EN);
expect(out).not.toBe(ISO_DATETIME);
expect(out).toBe(formatDateTime(ISO_DATETIME, { locale: EN }));
});

it('renders a date-only measure as a date, not as its raw ISO string', () => {
const out = formatMeasure(ISO_DATE, undefined, undefined, undefined, EN);
expect(out).not.toBe(ISO_DATE);
expect(out).toBe(formatDate(ISO_DATE, undefined, { locale: EN }));
});

it('accepts the space-separated ISO spelling a backend may send', () => {
const spaced = '2024-07-04 07:00:00';
expect(formatMeasure(spaced, undefined, undefined, undefined, EN)).toBe(
formatDateTime(spaced, { locale: EN }),
);
});

it('follows the display locale, like every other measure', () => {
const de = formatMeasure(ISO_DATETIME, undefined, undefined, undefined, 'de-DE');
const en = formatMeasure(ISO_DATETIME, undefined, undefined, undefined, EN);
expect(de).not.toBe(en);
expect(de).toBe(formatDateTime(ISO_DATETIME, { locale: 'de-DE' }));
});
});

/**
* The finding's reusable half: `DatasetMeasureSchema.format` is an open string
* that parses and, on this path, could never be read. It is read now — as the
* shared path's STYLE vocabulary, which is what that path actually accepts.
* A date PATTERN is not part of that vocabulary, and these cases say so out
* loud rather than leaving it silent, because "silent" is the whole card.
*/
describe('what `format` can and cannot say for a date measure (objectui#7178)', () => {
it('honours `short` — the same word `DateCellRenderer` honours from `field.format`', () => {
expect(formatMeasure(ISO_DATE, 'short', undefined, undefined, EN)).toBe(
formatDate(ISO_DATE, 'short', { locale: EN }),
);
expect(formatMeasure(ISO_DATE, 'short', undefined, undefined, EN)).not.toBe(
formatMeasure(ISO_DATE, undefined, undefined, undefined, EN),
);
});

it('honours `relative`', () => {
expect(formatMeasure(ISO_DATE, 'relative', undefined, undefined, EN)).toBe(
formatDate(ISO_DATE, 'relative', { locale: EN }),
);
});

it('⚠️ does NOT interpret a date PATTERN — `YYYY-MM-DD` renders the locale default', () => {
// Measured, not assumed, and stated in the PR body: the shared date path
// takes a named style, not a pattern. This case exists so the limit is
// pinned rather than rediscovered — and so that teaching the shared path a
// pattern grammar later has a test that must be updated deliberately.
const patterned = formatMeasure(ISO_DATE, 'YYYY-MM-DD', undefined, undefined, EN);
expect(patterned).not.toBe('2024-07-04');
expect(patterned).toBe(formatMeasure(ISO_DATE, undefined, undefined, undefined, EN));
});
});

/**
* ⭐ The regression guard. The date branch runs ahead of the `String(v)`
* short-circuit, so every one of these has to be shown NOT to reach it.
*/
describe('formatMeasure leaves every non-date value exactly where it was (objectui#7178)', () => {
it('formats numbers through the numeral path, untouched', () => {
expect(formatMeasure(1234.5, '0.0', undefined, undefined, EN)).toBe('1,234.5');
expect(formatMeasure(1234.5, '0.00', 'EUR', undefined, EN)).toBe('€1,234.50');
expect(formatMeasure(0.75, '0.0%', undefined, 'fraction', EN)).toBe('75.0%');
expect(formatMeasure(42, undefined, undefined, undefined, EN)).toBe('42');
expect(formatMeasure(2026, undefined, undefined, undefined, EN)).toBe('2026');
});

it('⭐ never reads a NUMERIC STRING as a date', () => {
// `1751612400000` is epoch milliseconds for a real instant, and `2026` is a
// year. Both are what a count or an id looks like coming back from a
// measure, and `Date.parse` would take either if it were asked.
for (const v of ['0', '42', '1234.5', '-1234.5', '1751612400000', '2026', '1e21']) {
expect(formatMeasure(v, undefined, undefined, undefined, EN)).toBe(v);
expect(formatMeasure(v, 'YYYY-MM-DD', undefined, undefined, EN)).toBe(v);
}
});

it('keeps the nullish em dash', () => {
expect(formatMeasure(null)).toBe('—');
expect(formatMeasure(undefined)).toBe('—');
expect(formatMeasure(null, 'YYYY-MM-DD', undefined, undefined, EN)).toBe('—');
});

it('falls through to `String(v)` for an arbitrary non-numeric, non-date value', () => {
for (const v of ['Acme Corp', 'N/A', '', 'March 5, 2026', '2026-07', '2026/07/04', '04-07-2026']) {
expect(formatMeasure(v, undefined, undefined, undefined, EN)).toBe(String(v));
}
expect(formatMeasure(true, undefined, undefined, undefined, EN)).toBe('true');
});

it('leaves an ISO-SHAPED but unparseable value alone rather than showing an em dash', () => {
// `formatDate` answers `—` for an unparseable value. Routing one there
// would REPLACE a raw string the author can still debug with a dash that
// says nothing, so the parse guard keeps these on the old path.
for (const v of ['2026-13-45', '2024-07-04T99:99']) {
expect(formatMeasure(v, undefined, undefined, undefined, EN)).toBe(v);
}
});

it('agrees with the list cell on a rolled-over date instead of second-guessing it', () => {
// `Date.parse('2024-02-30')` is NOT NaN — V8 rolls it to March 1/2. Every
// date surface in the repo builds its `Date` the same way, so this renders
// as the same day a list cell shows for the same stored string. Pinned
// because it is the one place agreement looks like a bug.
const rolled = '2024-02-30';
expect(Number.isNaN(Date.parse(rolled))).toBe(false);
expect(formatMeasure(rolled, undefined, undefined, undefined, EN)).toBe(
formatDate(rolled, undefined, { locale: EN }),
);
});
});
101 changes: 100 additions & 1 deletion packages/core/src/utils/dataset-format.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,7 @@ import type { AnalyticsResult } from '@objectstack/spec/contracts';
import type { PercentScale } from '@objectstack/spec/data';

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

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

/**
* ISO calendar date with NO time part — `2026-07-04`.
*
* Deliberately anchored at both ends and deliberately narrower than
* `Date.parse`, which also accepts locale prose (`March 5, 2026`), bare years
* (`2026`) and — the one that matters here — plain NUMERALS. A measure value
* is untyped by the time it reaches {@link formatMeasure}, so the shape test
* IS the type test, and a loose one would capture the numeric strings and
* counts this formatter must leave exactly as they are.
*/
const ISO_DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;

/**
* ISO date carrying a time part — `2026-07-04T07:00:00.000Z`, or the same with
* a space separator, with or without seconds/offset. Matched only as far as
* `HH:mm`; `Date.parse` decides the rest, so a well-shaped impossible instant
* still falls through untouched.
*/
const ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/;

/**
* Render a date-shaped measure value through the display path a list cell
* already uses for that field, or return `undefined` to say "not a date" and
* leave the caller's own fallthrough in charge.
*
* ── Why this is a ROUTE and not a formatter (objectui#7178) ──
* Every line of date rendering below happens in `./date-display.ts`, which is
* where `@object-ui/fields`' `formatDate` / `formatDateTime` now live and what
* `DateCellRenderer`, `ObjectGrid`'s date cells and `ObjectGantt`'s tooltips
* call. Writing the date convention HERE instead is the mistake this file's
* own header is about: objectui#4576, where a percent convention had one copy
* in a list cell and another in this function, both correct, and a German
* session read `1.234,5 %` in the cell beside `1.234,5%` in the measure. One
* date convention, one home, nothing to drift.
*
* The date/datetime split mirrors `ObjectGantt.tsx`'s sniffed-ISO dispatch
* verbatim, because that site is already the repo's answer to this exact
* situation: an ISO string arriving with NO field type attached. A measure is
* that situation by construction — the analytics result carries a column, not
* a field definition — so the absolute locale form is taken rather than
* `DateCellRenderer`'s `|| 'relative'` default, which is keyed on knowing the
* value is a `date` FIELD in a row.
*
* `format` is threaded into `formatDate`'s STYLE parameter, which is the same
* mapping `DateCellRenderer` makes from `field.format` and the same one
* `plugin-dashboard`'s `recordFields` already makes for a date-shaped format.
* That vocabulary is `'short'` and `'relative'`; a date PATTERN such as
* `'YYYY-MM-DD'` is not part of it and renders as the locale default. See the
* `format` note on {@link formatMeasure}.
*/
function formatMeasureDate(v: unknown, format: string | undefined, locale: string | undefined): string | undefined {
if (typeof v !== 'string') return undefined;
// `Date.parse` guards both arms so a well-shaped impossible date
// (`2026-02-30`) is NOT swallowed into the em dash `formatDate` returns for
// an unparseable value — it keeps falling through to `String(v)`, exactly as
// it does today. Only a value that is genuinely a date changes.
if (ISO_DATE_ONLY_RE.test(v)) {
return Number.isNaN(Date.parse(v)) ? undefined : formatDate(v, format, { locale });
}
if (ISO_DATETIME_RE.test(v)) {
return Number.isNaN(Date.parse(v)) ? undefined : formatDateTime(v, { locale });
}
return undefined;
}

/**
* Format a MEASURE value. Currency comes from the field's declared `currency`
* (locale-correct symbol via `Intl`), NOT from a "$" baked into the format
Expand DownExpand Up@@ -169,6 +235,28 @@ function formatNumberInLocale(
* to carry is gone, and with it the drift that duplicate produced — see the
* percent note in the body, and {@link percentDisplayValue}'s promise, which
* is true again.
*
* ── Date-valued measures (objectui#7178) ──
* `min` / `max` over a date or datetime field is a legitimate measure, and it
* used to render its stored value verbatim: the guard above returned
* `String(v)` for anything non-numeric BEFORE `format` was read, so a KPI tile
* showed `2026-07-04T07:00:00.000Z` in `text-2xl` and wrapped it over two
* lines. A date-shaped value now routes through {@link formatMeasureDate} to
* the display path list cells use; nothing about the numeric path moved.
*
* ⚠️ What `format` can and cannot say for those values, measured rather than
* assumed. The shared date path takes a named STYLE, not a date pattern:
* `formatDate`'s vocabulary is `'short'` and `'relative'`, and every other
* string falls to its default locale-medium branch. So `format: 'short'` and
* `format: 'relative'` are honoured — the same words `DateCellRenderer`
* honours from `field.format` for the same field — while a PATTERN like
* `format: 'YYYY-MM-DD'` is accepted by the schema, reaches this function, and
* renders the locale default. That last part is not new behaviour introduced
* here: `plugin-dashboard`'s `recordFields` already routes a date-shaped
* `format` into the same style slot and gets the same locale default. Closing
* it would mean teaching the shared path a pattern grammar, which is a change
* to the path itself and to every list cell that reads it — not a measure
* concern, and not this card.
*/
export function formatMeasure(
v: unknown,
Expand All@@ -178,7 +266,18 @@ export function formatMeasure(
locale?: string,
): string {
if (v == null) return '—';
if (typeof v !== 'number') return String(v);
if (typeof v !== 'number') {
// Ahead of the `String(v)` short-circuit, which is what made `format` dead
// on this path and printed a `min`/`max` over a datetime as a raw
// 24-character ISO string (objectui#7178). Deliberately NOT ahead of the
// `typeof` test itself: above that line the argument can be a bare number,
// and any date test generous enough to consider one would have to decide
// whether `1751612400000` is epoch milliseconds — which is how a measure
// that counts things starts rendering as a date. Numbers reach the numeral
// formatter below byte for byte as before.
const asDate = formatMeasureDate(v, format, locale);
return asDate ?? String(v);
}

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

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
45 changes: 45 additions & 0 deletions .changeset/7178-format-measure-date.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
---
'@object-ui/core': patch
'@object-ui/fields': patch
---

Render a dataset measure over a date field as a date (objectui#7178, maintainer
ruling 2026-09-02, director summon #8 — option A).

`formatMeasure` opened with `if (typeof v !== 'number') return String(v)`,
placed **before** `format` was ever read. So a `min` / `max` measure over a date
or datetime field 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 was unreachable for those values.
A date-shaped value now routes to the date display path before that
short-circuit, so all four dataset-bound surfaces are served at once: the metric
tile, chart values, dataset table cells, and the metadata-admin dataset preview.

`min` / `max` over a date stays a legal measure; nothing in `@objectstack/spec`
narrows. `PivotTable` takes a `number` outright and is unchanged.

**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`), which is the same
remedy objectui#4576 applied to `formatDisplayNumber` and 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` — exactly
the drift that once had a list cell rendering `1.234,5 %` beside a dashboard
measure's `1.234,5%`. `@object-ui/fields` re-exports all four names unchanged,
so no consumer's import path or behaviour changes, and a reference-identity test
pins that the cell renderer and the measure formatter call the same function.

**What `format` can say for a date measure, measured rather than assumed.** The
shared date path takes a named STYLE, not a date pattern: `'short'` and
`'relative'` are honoured — the same words `DateCellRenderer` honours from
`field.format` — while a pattern such as `'YYYY-MM-DD'` renders the locale
default. That limit is unchanged by this release (`plugin-dashboard`'s
`recordFields` already routed a date-shaped `format` into the same style slot)
and is now pinned by a test instead of being silent.

**Numeric measures are byte-identical.** 33,696 argument forms
(value × format × currency × percentScale × locale) were compared against a
verbatim copy of the pre-fix function: the only values that moved were the four
ISO-shaped, parseable ones. Numbers, numeric strings (`'1751612400000'`,
`'2026'`), the nullish em dash, arbitrary prose and non-strings all render
exactly as before.
5 changes: 5 additions & 0 deletions packages/core/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,6 +82,11 @@ export * from './utils/chart-presentation.js';
// `dataset-format` below from reaching it (so the two drifted). `@object-ui/i18n`
// re-exports these names unchanged, so both import paths name the same symbol.
export * from './utils/number-display.js';
// The ONE date-display path (objectui#7178) — the same story one type over.
// It lived in `@object-ui/fields`' React barrel, which `dataset-format`
// below could not import, so a date-valued measure rendered its raw ISO
// string. `@object-ui/fields` re-exports these names unchanged.
export * from './utils/date-display.js';
export * from './utils/dataset-format.js';
// Pivot lookup-key encoders, shared by every cross-tab renderer so the
// dashboard widget and the report renderer key their buckets identically
Expand Down
167 changes: 167 additions & 0 deletions packages/core/src/utils/__tests__/dataset-format.date.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#7178 — a dataset measure over a date field renders as a date.
*
* `formatMeasure` opened with `if (typeof v !== 'number') return String(v)`,
* placed BEFORE `format` was read. So a `min` / `max` over a date or datetime
* field printed its stored value verbatim — a 24-character ISO string in the
* KPI tile's `text-2xl font-semibold`, wrapped over two lines — and the
* `format` that `DatasetMeasureSchema` accepts was unreachable for it.
*
* ── What these cases pin, and what they deliberately do NOT ─────────────────
* The fix is a ROUTE, not a formatter: every byte of date rendering comes from
* `../date-display.ts`, which is where `@object-ui/fields`' `formatDate` /
* `formatDateTime` now live and what the `date` cell renderer, `ObjectGrid`'s
* date cells and `ObjectGantt`'s tooltips call. So the cases below assert
* AGREEMENT WITH THAT PATH rather than literal strings wherever the path's own
* output is the contract — a literal would pin this file's opinion of a date,
* which is exactly the second convention objectui#4576 says not to create.
* (`DatasetWidget.dateMeasure.test.tsx` closes the loop on the rendered
* surfaces, and `@object-ui/fields`' `date-display.reexport-identity.test.ts`
* pins that the cell's `formatDate` and this one are the same function.)
*
* ── Directions, predicted in writing BEFORE the run ─────────────────────────
* the two date cases RED pre-fix — they render the raw ISO string
* the `format`-as-style cases RED pre-fix — `format` was never read here
* ⭐ every must-not-move case GREEN on both sides. These are the guard,
* not the fix: the new branch runs ahead of
* the `String(v)` short-circuit, so it has to
* be shown NOT to capture numbers, numeric
* strings, the nullish em dash, or prose.
*
* The full before/after sweep is recorded in the PR: 33,696 argument forms
* (value x format x currency x percentScale x locale) compared against a
* verbatim copy of the pre-fix function, 4 distinct values moved — every one
* of them ISO-shaped and parseable — and 0 expected movers missed.
*/

import { describe, it, expect } from 'vitest';

import { formatMeasure } from '../dataset-format.js';
import { formatDate, formatDateTime } from '../date-display.js';

/** A NON-current year, so `formatDate`'s "drop the year" branch is not in play. */
const ISO_DATE = '2024-07-04';
const ISO_DATETIME = '2024-07-04T07:00:00.000Z';
const EN = 'en-US';

describe('formatMeasure routes a date-shaped measure through the shared date path (objectui#7178)', () => {
it('renders a datetime measure as a datetime, not as its raw ISO string', () => {
const out = formatMeasure(ISO_DATETIME, undefined, undefined, undefined, EN);
expect(out).not.toBe(ISO_DATETIME);
expect(out).toBe(formatDateTime(ISO_DATETIME, { locale: EN }));
});

it('renders a date-only measure as a date, not as its raw ISO string', () => {
const out = formatMeasure(ISO_DATE, undefined, undefined, undefined, EN);
expect(out).not.toBe(ISO_DATE);
expect(out).toBe(formatDate(ISO_DATE, undefined, { locale: EN }));
});

it('accepts the space-separated ISO spelling a backend may send', () => {
const spaced = '2024-07-04 07:00:00';
expect(formatMeasure(spaced, undefined, undefined, undefined, EN)).toBe(
formatDateTime(spaced, { locale: EN }),
);
});

it('follows the display locale, like every other measure', () => {
const de = formatMeasure(ISO_DATETIME, undefined, undefined, undefined, 'de-DE');
const en = formatMeasure(ISO_DATETIME, undefined, undefined, undefined, EN);
expect(de).not.toBe(en);
expect(de).toBe(formatDateTime(ISO_DATETIME, { locale: 'de-DE' }));
});
});

/**
* The finding's reusable half: `DatasetMeasureSchema.format` is an open string
* that parses and, on this path, could never be read. It is read now — as the
* shared path's STYLE vocabulary, which is what that path actually accepts.
* A date PATTERN is not part of that vocabulary, and these cases say so out
* loud rather than leaving it silent, because "silent" is the whole card.
*/
describe('what `format` can and cannot say for a date measure (objectui#7178)', () => {
it('honours `short` — the same word `DateCellRenderer` honours from `field.format`', () => {
expect(formatMeasure(ISO_DATE, 'short', undefined, undefined, EN)).toBe(
formatDate(ISO_DATE, 'short', { locale: EN }),
);
expect(formatMeasure(ISO_DATE, 'short', undefined, undefined, EN)).not.toBe(
formatMeasure(ISO_DATE, undefined, undefined, undefined, EN),
);
});

it('honours `relative`', () => {
expect(formatMeasure(ISO_DATE, 'relative', undefined, undefined, EN)).toBe(
formatDate(ISO_DATE, 'relative', { locale: EN }),
);
});

it('⚠️ does NOT interpret a date PATTERN — `YYYY-MM-DD` renders the locale default', () => {
// Measured, not assumed, and stated in the PR body: the shared date path
// takes a named style, not a pattern. This case exists so the limit is
// pinned rather than rediscovered — and so that teaching the shared path a
// pattern grammar later has a test that must be updated deliberately.
const patterned = formatMeasure(ISO_DATE, 'YYYY-MM-DD', undefined, undefined, EN);
expect(patterned).not.toBe('2024-07-04');
expect(patterned).toBe(formatMeasure(ISO_DATE, undefined, undefined, undefined, EN));
});
});

/**
* ⭐ The regression guard. The date branch runs ahead of the `String(v)`
* short-circuit, so every one of these has to be shown NOT to reach it.
*/
describe('formatMeasure leaves every non-date value exactly where it was (objectui#7178)', () => {
it('formats numbers through the numeral path, untouched', () => {
expect(formatMeasure(1234.5, '0.0', undefined, undefined, EN)).toBe('1,234.5');
expect(formatMeasure(1234.5, '0.00', 'EUR', undefined, EN)).toBe('€1,234.50');
expect(formatMeasure(0.75, '0.0%', undefined, 'fraction', EN)).toBe('75.0%');
expect(formatMeasure(42, undefined, undefined, undefined, EN)).toBe('42');
expect(formatMeasure(2026, undefined, undefined, undefined, EN)).toBe('2026');
});

it('⭐ never reads a NUMERIC STRING as a date', () => {
// `1751612400000` is epoch milliseconds for a real instant, and `2026` is a
// year. Both are what a count or an id looks like coming back from a
// measure, and `Date.parse` would take either if it were asked.
for (const v of ['0', '42', '1234.5', '-1234.5', '1751612400000', '2026', '1e21']) {
expect(formatMeasure(v, undefined, undefined, undefined, EN)).toBe(v);
expect(formatMeasure(v, 'YYYY-MM-DD', undefined, undefined, EN)).toBe(v);
}
});

it('keeps the nullish em dash', () => {
expect(formatMeasure(null)).toBe('—');
expect(formatMeasure(undefined)).toBe('—');
expect(formatMeasure(null, 'YYYY-MM-DD', undefined, undefined, EN)).toBe('—');
});

it('falls through to `String(v)` for an arbitrary non-numeric, non-date value', () => {
for (const v of ['Acme Corp', 'N/A', '', 'March 5, 2026', '2026-07', '2026/07/04', '04-07-2026']) {
expect(formatMeasure(v, undefined, undefined, undefined, EN)).toBe(String(v));
}
expect(formatMeasure(true, undefined, undefined, undefined, EN)).toBe('true');
});

it('leaves an ISO-SHAPED but unparseable value alone rather than showing an em dash', () => {
// `formatDate` answers `—` for an unparseable value. Routing one there
// would REPLACE a raw string the author can still debug with a dash that
// says nothing, so the parse guard keeps these on the old path.
for (const v of ['2026-13-45', '2024-07-04T99:99']) {
expect(formatMeasure(v, undefined, undefined, undefined, EN)).toBe(v);
}
});

it('agrees with the list cell on a rolled-over date instead of second-guessing it', () => {
// `Date.parse('2024-02-30')` is NOT NaN — V8 rolls it to March 1/2. Every
// date surface in the repo builds its `Date` the same way, so this renders
// as the same day a list cell shows for the same stored string. Pinned
// because it is the one place agreement looks like a bug.
const rolled = '2024-02-30';
expect(Number.isNaN(Date.parse(rolled))).toBe(false);
expect(formatMeasure(rolled, undefined, undefined, undefined, EN)).toBe(
formatDate(rolled, undefined, { locale: EN }),
);
});
});
101 changes: 100 additions & 1 deletion packages/core/src/utils/dataset-format.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,7 @@ import type { AnalyticsResult } from '@objectstack/spec/contracts';
import type { PercentScale } from '@objectstack/spec/data';

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

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

/**
* ISO calendar date with NO time part — `2026-07-04`.
*
* Deliberately anchored at both ends and deliberately narrower than
* `Date.parse`, which also accepts locale prose (`March 5, 2026`), bare years
* (`2026`) and — the one that matters here — plain NUMERALS. A measure value
* is untyped by the time it reaches {@link formatMeasure}, so the shape test
* IS the type test, and a loose one would capture the numeric strings and
* counts this formatter must leave exactly as they are.
*/
const ISO_DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;

/**
* ISO date carrying a time part — `2026-07-04T07:00:00.000Z`, or the same with
* a space separator, with or without seconds/offset. Matched only as far as
* `HH:mm`; `Date.parse` decides the rest, so a well-shaped impossible instant
* still falls through untouched.
*/
const ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/;

/**
* Render a date-shaped measure value through the display path a list cell
* already uses for that field, or return `undefined` to say "not a date" and
* leave the caller's own fallthrough in charge.
*
* ── Why this is a ROUTE and not a formatter (objectui#7178) ──
* Every line of date rendering below happens in `./date-display.ts`, which is
* where `@object-ui/fields`' `formatDate` / `formatDateTime` now live and what
* `DateCellRenderer`, `ObjectGrid`'s date cells and `ObjectGantt`'s tooltips
* call. Writing the date convention HERE instead is the mistake this file's
* own header is about: objectui#4576, where a percent convention had one copy
* in a list cell and another in this function, both correct, and a German
* session read `1.234,5 %` in the cell beside `1.234,5%` in the measure. One
* date convention, one home, nothing to drift.
*
* The date/datetime split mirrors `ObjectGantt.tsx`'s sniffed-ISO dispatch
* verbatim, because that site is already the repo's answer to this exact
* situation: an ISO string arriving with NO field type attached. A measure is
* that situation by construction — the analytics result carries a column, not
* a field definition — so the absolute locale form is taken rather than
* `DateCellRenderer`'s `|| 'relative'` default, which is keyed on knowing the
* value is a `date` FIELD in a row.
*
* `format` is threaded into `formatDate`'s STYLE parameter, which is the same
* mapping `DateCellRenderer` makes from `field.format` and the same one
* `plugin-dashboard`'s `recordFields` already makes for a date-shaped format.
* That vocabulary is `'short'` and `'relative'`; a date PATTERN such as
* `'YYYY-MM-DD'` is not part of it and renders as the locale default. See the
* `format` note on {@link formatMeasure}.
*/
function formatMeasureDate(v: unknown, format: string | undefined, locale: string | undefined): string | undefined {
if (typeof v !== 'string') return undefined;
// `Date.parse` guards both arms so a well-shaped impossible date
// (`2026-02-30`) is NOT swallowed into the em dash `formatDate` returns for
// an unparseable value — it keeps falling through to `String(v)`, exactly as
// it does today. Only a value that is genuinely a date changes.
if (ISO_DATE_ONLY_RE.test(v)) {
return Number.isNaN(Date.parse(v)) ? undefined : formatDate(v, format, { locale });
}
if (ISO_DATETIME_RE.test(v)) {
return Number.isNaN(Date.parse(v)) ? undefined : formatDateTime(v, { locale });
}
return undefined;
}

/**
* Format a MEASURE value. Currency comes from the field's declared `currency`
* (locale-correct symbol via `Intl`), NOT from a "$" baked into the format
Expand DownExpand Up@@ -169,6 +235,28 @@ function formatNumberInLocale(
* to carry is gone, and with it the drift that duplicate produced — see the
* percent note in the body, and {@link percentDisplayValue}'s promise, which
* is true again.
*
* ── Date-valued measures (objectui#7178) ──
* `min` / `max` over a date or datetime field is a legitimate measure, and it
* used to render its stored value verbatim: the guard above returned
* `String(v)` for anything non-numeric BEFORE `format` was read, so a KPI tile
* showed `2026-07-04T07:00:00.000Z` in `text-2xl` and wrapped it over two
* lines. A date-shaped value now routes through {@link formatMeasureDate} to
* the display path list cells use; nothing about the numeric path moved.
*
* ⚠️ What `format` can and cannot say for those values, measured rather than
* assumed. The shared date path takes a named STYLE, not a date pattern:
* `formatDate`'s vocabulary is `'short'` and `'relative'`, and every other
* string falls to its default locale-medium branch. So `format: 'short'` and
* `format: 'relative'` are honoured — the same words `DateCellRenderer`
* honours from `field.format` for the same field — while a PATTERN like
* `format: 'YYYY-MM-DD'` is accepted by the schema, reaches this function, and
* renders the locale default. That last part is not new behaviour introduced
* here: `plugin-dashboard`'s `recordFields` already routes a date-shaped
* `format` into the same style slot and gets the same locale default. Closing
* it would mean teaching the shared path a pattern grammar, which is a change
* to the path itself and to every list cell that reads it — not a measure
* concern, and not this card.
*/
export function formatMeasure(
v: unknown,
Expand All@@ -178,7 +266,18 @@ export function formatMeasure(
locale?: string,
): string {
if (v == null) return '—';
if (typeof v !== 'number') return String(v);
if (typeof v !== 'number') {
// Ahead of the `String(v)` short-circuit, which is what made `format` dead
// on this path and printed a `min`/`max` over a datetime as a raw
// 24-character ISO string (objectui#7178). Deliberately NOT ahead of the
// `typeof` test itself: above that line the argument can be a bare number,
// and any date test generous enough to consider one would have to decide
// whether `1751612400000` is epoch milliseconds — which is how a measure
// that counts things starts rendering as a date. Numbers reach the numeral
// formatter below byte for byte as before.
const asDate = formatMeasureDate(v, format, locale);
return asDate ?? String(v);
}

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

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
45 changes: 45 additions & 0 deletions .changeset/7178-format-measure-date.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
---
'@object-ui/core': patch
'@object-ui/fields': patch
---

Render a dataset measure over a date field as a date (objectui#7178, maintainer
ruling 2026-09-02, director summon #8 — option A).

`formatMeasure` opened with `if (typeof v !== 'number') return String(v)`,
placed **before** `format` was ever read. So a `min` / `max` measure over a date
or datetime field 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 was unreachable for those values.
A date-shaped value now routes to the date display path before that
short-circuit, so all four dataset-bound surfaces are served at once: the metric
tile, chart values, dataset table cells, and the metadata-admin dataset preview.

`min` / `max` over a date stays a legal measure; nothing in `@objectstack/spec`
narrows. `PivotTable` takes a `number` outright and is unchanged.

**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`), which is the same
remedy objectui#4576 applied to `formatDisplayNumber` and 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` — exactly
the drift that once had a list cell rendering `1.234,5 %` beside a dashboard
measure's `1.234,5%`. `@object-ui/fields` re-exports all four names unchanged,
so no consumer's import path or behaviour changes, and a reference-identity test
pins that the cell renderer and the measure formatter call the same function.

**What `format` can say for a date measure, measured rather than assumed.** The
shared date path takes a named STYLE, not a date pattern: `'short'` and
`'relative'` are honoured — the same words `DateCellRenderer` honours from
`field.format` — while a pattern such as `'YYYY-MM-DD'` renders the locale
default. That limit is unchanged by this release (`plugin-dashboard`'s
`recordFields` already routed a date-shaped `format` into the same style slot)
and is now pinned by a test instead of being silent.

**Numeric measures are byte-identical.** 33,696 argument forms
(value × format × currency × percentScale × locale) were compared against a
verbatim copy of the pre-fix function: the only values that moved were the four
ISO-shaped, parseable ones. Numbers, numeric strings (`'1751612400000'`,
`'2026'`), the nullish em dash, arbitrary prose and non-strings all render
exactly as before.
5 changes: 5 additions & 0 deletions packages/core/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,6 +82,11 @@ export * from './utils/chart-presentation.js';
// `dataset-format` below from reaching it (so the two drifted). `@object-ui/i18n`
// re-exports these names unchanged, so both import paths name the same symbol.
export * from './utils/number-display.js';
// The ONE date-display path (objectui#7178) — the same story one type over.
// It lived in `@object-ui/fields`' React barrel, which `dataset-format`
// below could not import, so a date-valued measure rendered its raw ISO
// string. `@object-ui/fields` re-exports these names unchanged.
export * from './utils/date-display.js';
export * from './utils/dataset-format.js';
// Pivot lookup-key encoders, shared by every cross-tab renderer so the
// dashboard widget and the report renderer key their buckets identically
Expand Down
167 changes: 167 additions & 0 deletions packages/core/src/utils/__tests__/dataset-format.date.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#7178 — a dataset measure over a date field renders as a date.
*
* `formatMeasure` opened with `if (typeof v !== 'number') return String(v)`,
* placed BEFORE `format` was read. So a `min` / `max` over a date or datetime
* field printed its stored value verbatim — a 24-character ISO string in the
* KPI tile's `text-2xl font-semibold`, wrapped over two lines — and the
* `format` that `DatasetMeasureSchema` accepts was unreachable for it.
*
* ── What these cases pin, and what they deliberately do NOT ─────────────────
* The fix is a ROUTE, not a formatter: every byte of date rendering comes from
* `../date-display.ts`, which is where `@object-ui/fields`' `formatDate` /
* `formatDateTime` now live and what the `date` cell renderer, `ObjectGrid`'s
* date cells and `ObjectGantt`'s tooltips call. So the cases below assert
* AGREEMENT WITH THAT PATH rather than literal strings wherever the path's own
* output is the contract — a literal would pin this file's opinion of a date,
* which is exactly the second convention objectui#4576 says not to create.
* (`DatasetWidget.dateMeasure.test.tsx` closes the loop on the rendered
* surfaces, and `@object-ui/fields`' `date-display.reexport-identity.test.ts`
* pins that the cell's `formatDate` and this one are the same function.)
*
* ── Directions, predicted in writing BEFORE the run ─────────────────────────
* the two date cases RED pre-fix — they render the raw ISO string
* the `format`-as-style cases RED pre-fix — `format` was never read here
* ⭐ every must-not-move case GREEN on both sides. These are the guard,
* not the fix: the new branch runs ahead of
* the `String(v)` short-circuit, so it has to
* be shown NOT to capture numbers, numeric
* strings, the nullish em dash, or prose.
*
* The full before/after sweep is recorded in the PR: 33,696 argument forms
* (value x format x currency x percentScale x locale) compared against a
* verbatim copy of the pre-fix function, 4 distinct values moved — every one
* of them ISO-shaped and parseable — and 0 expected movers missed.
*/

import { describe, it, expect } from 'vitest';

import { formatMeasure } from '../dataset-format.js';
import { formatDate, formatDateTime } from '../date-display.js';

/** A NON-current year, so `formatDate`'s "drop the year" branch is not in play. */
const ISO_DATE = '2024-07-04';
const ISO_DATETIME = '2024-07-04T07:00:00.000Z';
const EN = 'en-US';

describe('formatMeasure routes a date-shaped measure through the shared date path (objectui#7178)', () => {
it('renders a datetime measure as a datetime, not as its raw ISO string', () => {
const out = formatMeasure(ISO_DATETIME, undefined, undefined, undefined, EN);
expect(out).not.toBe(ISO_DATETIME);
expect(out).toBe(formatDateTime(ISO_DATETIME, { locale: EN }));
});

it('renders a date-only measure as a date, not as its raw ISO string', () => {
const out = formatMeasure(ISO_DATE, undefined, undefined, undefined, EN);
expect(out).not.toBe(ISO_DATE);
expect(out).toBe(formatDate(ISO_DATE, undefined, { locale: EN }));
});

it('accepts the space-separated ISO spelling a backend may send', () => {
const spaced = '2024-07-04 07:00:00';
expect(formatMeasure(spaced, undefined, undefined, undefined, EN)).toBe(
formatDateTime(spaced, { locale: EN }),
);
});

it('follows the display locale, like every other measure', () => {
const de = formatMeasure(ISO_DATETIME, undefined, undefined, undefined, 'de-DE');
const en = formatMeasure(ISO_DATETIME, undefined, undefined, undefined, EN);
expect(de).not.toBe(en);
expect(de).toBe(formatDateTime(ISO_DATETIME, { locale: 'de-DE' }));
});
});

/**
* The finding's reusable half: `DatasetMeasureSchema.format` is an open string
* that parses and, on this path, could never be read. It is read now — as the
* shared path's STYLE vocabulary, which is what that path actually accepts.
* A date PATTERN is not part of that vocabulary, and these cases say so out
* loud rather than leaving it silent, because "silent" is the whole card.
*/
describe('what `format` can and cannot say for a date measure (objectui#7178)', () => {
it('honours `short` — the same word `DateCellRenderer` honours from `field.format`', () => {
expect(formatMeasure(ISO_DATE, 'short', undefined, undefined, EN)).toBe(
formatDate(ISO_DATE, 'short', { locale: EN }),
);
expect(formatMeasure(ISO_DATE, 'short', undefined, undefined, EN)).not.toBe(
formatMeasure(ISO_DATE, undefined, undefined, undefined, EN),
);
});

it('honours `relative`', () => {
expect(formatMeasure(ISO_DATE, 'relative', undefined, undefined, EN)).toBe(
formatDate(ISO_DATE, 'relative', { locale: EN }),
);
});

it('⚠️ does NOT interpret a date PATTERN — `YYYY-MM-DD` renders the locale default', () => {
// Measured, not assumed, and stated in the PR body: the shared date path
// takes a named style, not a pattern. This case exists so the limit is
// pinned rather than rediscovered — and so that teaching the shared path a
// pattern grammar later has a test that must be updated deliberately.
const patterned = formatMeasure(ISO_DATE, 'YYYY-MM-DD', undefined, undefined, EN);
expect(patterned).not.toBe('2024-07-04');
expect(patterned).toBe(formatMeasure(ISO_DATE, undefined, undefined, undefined, EN));
});
});

/**
* ⭐ The regression guard. The date branch runs ahead of the `String(v)`
* short-circuit, so every one of these has to be shown NOT to reach it.
*/
describe('formatMeasure leaves every non-date value exactly where it was (objectui#7178)', () => {
it('formats numbers through the numeral path, untouched', () => {
expect(formatMeasure(1234.5, '0.0', undefined, undefined, EN)).toBe('1,234.5');
expect(formatMeasure(1234.5, '0.00', 'EUR', undefined, EN)).toBe('€1,234.50');
expect(formatMeasure(0.75, '0.0%', undefined, 'fraction', EN)).toBe('75.0%');
expect(formatMeasure(42, undefined, undefined, undefined, EN)).toBe('42');
expect(formatMeasure(2026, undefined, undefined, undefined, EN)).toBe('2026');
});

it('⭐ never reads a NUMERIC STRING as a date', () => {
// `1751612400000` is epoch milliseconds for a real instant, and `2026` is a
// year. Both are what a count or an id looks like coming back from a
// measure, and `Date.parse` would take either if it were asked.
for (const v of ['0', '42', '1234.5', '-1234.5', '1751612400000', '2026', '1e21']) {
expect(formatMeasure(v, undefined, undefined, undefined, EN)).toBe(v);
expect(formatMeasure(v, 'YYYY-MM-DD', undefined, undefined, EN)).toBe(v);
}
});

it('keeps the nullish em dash', () => {
expect(formatMeasure(null)).toBe('—');
expect(formatMeasure(undefined)).toBe('—');
expect(formatMeasure(null, 'YYYY-MM-DD', undefined, undefined, EN)).toBe('—');
});

it('falls through to `String(v)` for an arbitrary non-numeric, non-date value', () => {
for (const v of ['Acme Corp', 'N/A', '', 'March 5, 2026', '2026-07', '2026/07/04', '04-07-2026']) {
expect(formatMeasure(v, undefined, undefined, undefined, EN)).toBe(String(v));
}
expect(formatMeasure(true, undefined, undefined, undefined, EN)).toBe('true');
});

it('leaves an ISO-SHAPED but unparseable value alone rather than showing an em dash', () => {
// `formatDate` answers `—` for an unparseable value. Routing one there
// would REPLACE a raw string the author can still debug with a dash that
// says nothing, so the parse guard keeps these on the old path.
for (const v of ['2026-13-45', '2024-07-04T99:99']) {
expect(formatMeasure(v, undefined, undefined, undefined, EN)).toBe(v);
}
});

it('agrees with the list cell on a rolled-over date instead of second-guessing it', () => {
// `Date.parse('2024-02-30')` is NOT NaN — V8 rolls it to March 1/2. Every
// date surface in the repo builds its `Date` the same way, so this renders
// as the same day a list cell shows for the same stored string. Pinned
// because it is the one place agreement looks like a bug.
const rolled = '2024-02-30';
expect(Number.isNaN(Date.parse(rolled))).toBe(false);
expect(formatMeasure(rolled, undefined, undefined, undefined, EN)).toBe(
formatDate(rolled, undefined, { locale: EN }),
);
});
});
101 changes: 100 additions & 1 deletion packages/core/src/utils/dataset-format.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,7 @@ import type { AnalyticsResult } from '@objectstack/spec/contracts';
import type { PercentScale } from '@objectstack/spec/data';

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

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

/**
* ISO calendar date with NO time part — `2026-07-04`.
*
* Deliberately anchored at both ends and deliberately narrower than
* `Date.parse`, which also accepts locale prose (`March 5, 2026`), bare years
* (`2026`) and — the one that matters here — plain NUMERALS. A measure value
* is untyped by the time it reaches {@link formatMeasure}, so the shape test
* IS the type test, and a loose one would capture the numeric strings and
* counts this formatter must leave exactly as they are.
*/
const ISO_DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;

/**
* ISO date carrying a time part — `2026-07-04T07:00:00.000Z`, or the same with
* a space separator, with or without seconds/offset. Matched only as far as
* `HH:mm`; `Date.parse` decides the rest, so a well-shaped impossible instant
* still falls through untouched.
*/
const ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/;

/**
* Render a date-shaped measure value through the display path a list cell
* already uses for that field, or return `undefined` to say "not a date" and
* leave the caller's own fallthrough in charge.
*
* ── Why this is a ROUTE and not a formatter (objectui#7178) ──
* Every line of date rendering below happens in `./date-display.ts`, which is
* where `@object-ui/fields`' `formatDate` / `formatDateTime` now live and what
* `DateCellRenderer`, `ObjectGrid`'s date cells and `ObjectGantt`'s tooltips
* call. Writing the date convention HERE instead is the mistake this file's
* own header is about: objectui#4576, where a percent convention had one copy
* in a list cell and another in this function, both correct, and a German
* session read `1.234,5 %` in the cell beside `1.234,5%` in the measure. One
* date convention, one home, nothing to drift.
*
* The date/datetime split mirrors `ObjectGantt.tsx`'s sniffed-ISO dispatch
* verbatim, because that site is already the repo's answer to this exact
* situation: an ISO string arriving with NO field type attached. A measure is
* that situation by construction — the analytics result carries a column, not
* a field definition — so the absolute locale form is taken rather than
* `DateCellRenderer`'s `|| 'relative'` default, which is keyed on knowing the
* value is a `date` FIELD in a row.
*
* `format` is threaded into `formatDate`'s STYLE parameter, which is the same
* mapping `DateCellRenderer` makes from `field.format` and the same one
* `plugin-dashboard`'s `recordFields` already makes for a date-shaped format.
* That vocabulary is `'short'` and `'relative'`; a date PATTERN such as
* `'YYYY-MM-DD'` is not part of it and renders as the locale default. See the
* `format` note on {@link formatMeasure}.
*/
function formatMeasureDate(v: unknown, format: string | undefined, locale: string | undefined): string | undefined {
if (typeof v !== 'string') return undefined;
// `Date.parse` guards both arms so a well-shaped impossible date
// (`2026-02-30`) is NOT swallowed into the em dash `formatDate` returns for
// an unparseable value — it keeps falling through to `String(v)`, exactly as
// it does today. Only a value that is genuinely a date changes.
if (ISO_DATE_ONLY_RE.test(v)) {
return Number.isNaN(Date.parse(v)) ? undefined : formatDate(v, format, { locale });
}
if (ISO_DATETIME_RE.test(v)) {
return Number.isNaN(Date.parse(v)) ? undefined : formatDateTime(v, { locale });
}
return undefined;
}

/**
* Format a MEASURE value. Currency comes from the field's declared `currency`
* (locale-correct symbol via `Intl`), NOT from a "$" baked into the format
Expand DownExpand Up@@ -169,6 +235,28 @@ function formatNumberInLocale(
* to carry is gone, and with it the drift that duplicate produced — see the
* percent note in the body, and {@link percentDisplayValue}'s promise, which
* is true again.
*
* ── Date-valued measures (objectui#7178) ──
* `min` / `max` over a date or datetime field is a legitimate measure, and it
* used to render its stored value verbatim: the guard above returned
* `String(v)` for anything non-numeric BEFORE `format` was read, so a KPI tile
* showed `2026-07-04T07:00:00.000Z` in `text-2xl` and wrapped it over two
* lines. A date-shaped value now routes through {@link formatMeasureDate} to
* the display path list cells use; nothing about the numeric path moved.
*
* ⚠️ What `format` can and cannot say for those values, measured rather than
* assumed. The shared date path takes a named STYLE, not a date pattern:
* `formatDate`'s vocabulary is `'short'` and `'relative'`, and every other
* string falls to its default locale-medium branch. So `format: 'short'` and
* `format: 'relative'` are honoured — the same words `DateCellRenderer`
* honours from `field.format` for the same field — while a PATTERN like
* `format: 'YYYY-MM-DD'` is accepted by the schema, reaches this function, and
* renders the locale default. That last part is not new behaviour introduced
* here: `plugin-dashboard`'s `recordFields` already routes a date-shaped
* `format` into the same style slot and gets the same locale default. Closing
* it would mean teaching the shared path a pattern grammar, which is a change
* to the path itself and to every list cell that reads it — not a measure
* concern, and not this card.
*/
export function formatMeasure(
v: unknown,
Expand All@@ -178,7 +266,18 @@ export function formatMeasure(
locale?: string,
): string {
if (v == null) return '—';
if (typeof v !== 'number') return String(v);
if (typeof v !== 'number') {
// Ahead of the `String(v)` short-circuit, which is what made `format` dead
// on this path and printed a `min`/`max` over a datetime as a raw
// 24-character ISO string (objectui#7178). Deliberately NOT ahead of the
// `typeof` test itself: above that line the argument can be a bare number,
// and any date test generous enough to consider one would have to decide
// whether `1751612400000` is epoch milliseconds — which is how a measure
// that counts things starts rendering as a date. Numbers reach the numeral
// formatter below byte for byte as before.
const asDate = formatMeasureDate(v, format, locale);
return asDate ?? String(v);
}

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

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
45 changes: 45 additions & 0 deletions .changeset/7178-format-measure-date.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
---
'@object-ui/core': patch
'@object-ui/fields': patch
---

Render a dataset measure over a date field as a date (objectui#7178, maintainer
ruling 2026-09-02, director summon #8 — option A).

`formatMeasure` opened with `if (typeof v !== 'number') return String(v)`,
placed **before** `format` was ever read. So a `min` / `max` measure over a date
or datetime field 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 was unreachable for those values.
A date-shaped value now routes to the date display path before that
short-circuit, so all four dataset-bound surfaces are served at once: the metric
tile, chart values, dataset table cells, and the metadata-admin dataset preview.

`min` / `max` over a date stays a legal measure; nothing in `@objectstack/spec`
narrows. `PivotTable` takes a `number` outright and is unchanged.

**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`), which is the same
remedy objectui#4576 applied to `formatDisplayNumber` and 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` — exactly
the drift that once had a list cell rendering `1.234,5 %` beside a dashboard
measure's `1.234,5%`. `@object-ui/fields` re-exports all four names unchanged,
so no consumer's import path or behaviour changes, and a reference-identity test
pins that the cell renderer and the measure formatter call the same function.

**What `format` can say for a date measure, measured rather than assumed.** The
shared date path takes a named STYLE, not a date pattern: `'short'` and
`'relative'` are honoured — the same words `DateCellRenderer` honours from
`field.format` — while a pattern such as `'YYYY-MM-DD'` renders the locale
default. That limit is unchanged by this release (`plugin-dashboard`'s
`recordFields` already routed a date-shaped `format` into the same style slot)
and is now pinned by a test instead of being silent.

**Numeric measures are byte-identical.** 33,696 argument forms
(value × format × currency × percentScale × locale) were compared against a
verbatim copy of the pre-fix function: the only values that moved were the four
ISO-shaped, parseable ones. Numbers, numeric strings (`'1751612400000'`,
`'2026'`), the nullish em dash, arbitrary prose and non-strings all render
exactly as before.
5 changes: 5 additions & 0 deletions packages/core/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,6 +82,11 @@ export * from './utils/chart-presentation.js';
// `dataset-format` below from reaching it (so the two drifted). `@object-ui/i18n`
// re-exports these names unchanged, so both import paths name the same symbol.
export * from './utils/number-display.js';
// The ONE date-display path (objectui#7178) — the same story one type over.
// It lived in `@object-ui/fields`' React barrel, which `dataset-format`
// below could not import, so a date-valued measure rendered its raw ISO
// string. `@object-ui/fields` re-exports these names unchanged.
export * from './utils/date-display.js';
export * from './utils/dataset-format.js';
// Pivot lookup-key encoders, shared by every cross-tab renderer so the
// dashboard widget and the report renderer key their buckets identically
Expand Down
167 changes: 167 additions & 0 deletions packages/core/src/utils/__tests__/dataset-format.date.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#7178 — a dataset measure over a date field renders as a date.
*
* `formatMeasure` opened with `if (typeof v !== 'number') return String(v)`,
* placed BEFORE `format` was read. So a `min` / `max` over a date or datetime
* field printed its stored value verbatim — a 24-character ISO string in the
* KPI tile's `text-2xl font-semibold`, wrapped over two lines — and the
* `format` that `DatasetMeasureSchema` accepts was unreachable for it.
*
* ── What these cases pin, and what they deliberately do NOT ─────────────────
* The fix is a ROUTE, not a formatter: every byte of date rendering comes from
* `../date-display.ts`, which is where `@object-ui/fields`' `formatDate` /
* `formatDateTime` now live and what the `date` cell renderer, `ObjectGrid`'s
* date cells and `ObjectGantt`'s tooltips call. So the cases below assert
* AGREEMENT WITH THAT PATH rather than literal strings wherever the path's own
* output is the contract — a literal would pin this file's opinion of a date,
* which is exactly the second convention objectui#4576 says not to create.
* (`DatasetWidget.dateMeasure.test.tsx` closes the loop on the rendered
* surfaces, and `@object-ui/fields`' `date-display.reexport-identity.test.ts`
* pins that the cell's `formatDate` and this one are the same function.)
*
* ── Directions, predicted in writing BEFORE the run ─────────────────────────
* the two date cases RED pre-fix — they render the raw ISO string
* the `format`-as-style cases RED pre-fix — `format` was never read here
* ⭐ every must-not-move case GREEN on both sides. These are the guard,
* not the fix: the new branch runs ahead of
* the `String(v)` short-circuit, so it has to
* be shown NOT to capture numbers, numeric
* strings, the nullish em dash, or prose.
*
* The full before/after sweep is recorded in the PR: 33,696 argument forms
* (value x format x currency x percentScale x locale) compared against a
* verbatim copy of the pre-fix function, 4 distinct values moved — every one
* of them ISO-shaped and parseable — and 0 expected movers missed.
*/

import { describe, it, expect } from 'vitest';

import { formatMeasure } from '../dataset-format.js';
import { formatDate, formatDateTime } from '../date-display.js';

/** A NON-current year, so `formatDate`'s "drop the year" branch is not in play. */
const ISO_DATE = '2024-07-04';
const ISO_DATETIME = '2024-07-04T07:00:00.000Z';
const EN = 'en-US';

describe('formatMeasure routes a date-shaped measure through the shared date path (objectui#7178)', () => {
it('renders a datetime measure as a datetime, not as its raw ISO string', () => {
const out = formatMeasure(ISO_DATETIME, undefined, undefined, undefined, EN);
expect(out).not.toBe(ISO_DATETIME);
expect(out).toBe(formatDateTime(ISO_DATETIME, { locale: EN }));
});

it('renders a date-only measure as a date, not as its raw ISO string', () => {
const out = formatMeasure(ISO_DATE, undefined, undefined, undefined, EN);
expect(out).not.toBe(ISO_DATE);
expect(out).toBe(formatDate(ISO_DATE, undefined, { locale: EN }));
});

it('accepts the space-separated ISO spelling a backend may send', () => {
const spaced = '2024-07-04 07:00:00';
expect(formatMeasure(spaced, undefined, undefined, undefined, EN)).toBe(
formatDateTime(spaced, { locale: EN }),
);
});

it('follows the display locale, like every other measure', () => {
const de = formatMeasure(ISO_DATETIME, undefined, undefined, undefined, 'de-DE');
const en = formatMeasure(ISO_DATETIME, undefined, undefined, undefined, EN);
expect(de).not.toBe(en);
expect(de).toBe(formatDateTime(ISO_DATETIME, { locale: 'de-DE' }));
});
});

/**
* The finding's reusable half: `DatasetMeasureSchema.format` is an open string
* that parses and, on this path, could never be read. It is read now — as the
* shared path's STYLE vocabulary, which is what that path actually accepts.
* A date PATTERN is not part of that vocabulary, and these cases say so out
* loud rather than leaving it silent, because "silent" is the whole card.
*/
describe('what `format` can and cannot say for a date measure (objectui#7178)', () => {
it('honours `short` — the same word `DateCellRenderer` honours from `field.format`', () => {
expect(formatMeasure(ISO_DATE, 'short', undefined, undefined, EN)).toBe(
formatDate(ISO_DATE, 'short', { locale: EN }),
);
expect(formatMeasure(ISO_DATE, 'short', undefined, undefined, EN)).not.toBe(
formatMeasure(ISO_DATE, undefined, undefined, undefined, EN),
);
});

it('honours `relative`', () => {
expect(formatMeasure(ISO_DATE, 'relative', undefined, undefined, EN)).toBe(
formatDate(ISO_DATE, 'relative', { locale: EN }),
);
});

it('⚠️ does NOT interpret a date PATTERN — `YYYY-MM-DD` renders the locale default', () => {
// Measured, not assumed, and stated in the PR body: the shared date path
// takes a named style, not a pattern. This case exists so the limit is
// pinned rather than rediscovered — and so that teaching the shared path a
// pattern grammar later has a test that must be updated deliberately.
const patterned = formatMeasure(ISO_DATE, 'YYYY-MM-DD', undefined, undefined, EN);
expect(patterned).not.toBe('2024-07-04');
expect(patterned).toBe(formatMeasure(ISO_DATE, undefined, undefined, undefined, EN));
});
});

/**
* ⭐ The regression guard. The date branch runs ahead of the `String(v)`
* short-circuit, so every one of these has to be shown NOT to reach it.
*/
describe('formatMeasure leaves every non-date value exactly where it was (objectui#7178)', () => {
it('formats numbers through the numeral path, untouched', () => {
expect(formatMeasure(1234.5, '0.0', undefined, undefined, EN)).toBe('1,234.5');
expect(formatMeasure(1234.5, '0.00', 'EUR', undefined, EN)).toBe('€1,234.50');
expect(formatMeasure(0.75, '0.0%', undefined, 'fraction', EN)).toBe('75.0%');
expect(formatMeasure(42, undefined, undefined, undefined, EN)).toBe('42');
expect(formatMeasure(2026, undefined, undefined, undefined, EN)).toBe('2026');
});

it('⭐ never reads a NUMERIC STRING as a date', () => {
// `1751612400000` is epoch milliseconds for a real instant, and `2026` is a
// year. Both are what a count or an id looks like coming back from a
// measure, and `Date.parse` would take either if it were asked.
for (const v of ['0', '42', '1234.5', '-1234.5', '1751612400000', '2026', '1e21']) {
expect(formatMeasure(v, undefined, undefined, undefined, EN)).toBe(v);
expect(formatMeasure(v, 'YYYY-MM-DD', undefined, undefined, EN)).toBe(v);
}
});

it('keeps the nullish em dash', () => {
expect(formatMeasure(null)).toBe('—');
expect(formatMeasure(undefined)).toBe('—');
expect(formatMeasure(null, 'YYYY-MM-DD', undefined, undefined, EN)).toBe('—');
});

it('falls through to `String(v)` for an arbitrary non-numeric, non-date value', () => {
for (const v of ['Acme Corp', 'N/A', '', 'March 5, 2026', '2026-07', '2026/07/04', '04-07-2026']) {
expect(formatMeasure(v, undefined, undefined, undefined, EN)).toBe(String(v));
}
expect(formatMeasure(true, undefined, undefined, undefined, EN)).toBe('true');
});

it('leaves an ISO-SHAPED but unparseable value alone rather than showing an em dash', () => {
// `formatDate` answers `—` for an unparseable value. Routing one there
// would REPLACE a raw string the author can still debug with a dash that
// says nothing, so the parse guard keeps these on the old path.
for (const v of ['2026-13-45', '2024-07-04T99:99']) {
expect(formatMeasure(v, undefined, undefined, undefined, EN)).toBe(v);
}
});

it('agrees with the list cell on a rolled-over date instead of second-guessing it', () => {
// `Date.parse('2024-02-30')` is NOT NaN — V8 rolls it to March 1/2. Every
// date surface in the repo builds its `Date` the same way, so this renders
// as the same day a list cell shows for the same stored string. Pinned
// because it is the one place agreement looks like a bug.
const rolled = '2024-02-30';
expect(Number.isNaN(Date.parse(rolled))).toBe(false);
expect(formatMeasure(rolled, undefined, undefined, undefined, EN)).toBe(
formatDate(rolled, undefined, { locale: EN }),
);
});
});
101 changes: 100 additions & 1 deletion packages/core/src/utils/dataset-format.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,7 @@ import type { AnalyticsResult } from '@objectstack/spec/contracts';
import type { PercentScale } from '@objectstack/spec/data';

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

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

/**
* ISO calendar date with NO time part — `2026-07-04`.
*
* Deliberately anchored at both ends and deliberately narrower than
* `Date.parse`, which also accepts locale prose (`March 5, 2026`), bare years
* (`2026`) and — the one that matters here — plain NUMERALS. A measure value
* is untyped by the time it reaches {@link formatMeasure}, so the shape test
* IS the type test, and a loose one would capture the numeric strings and
* counts this formatter must leave exactly as they are.
*/
const ISO_DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;

/**
* ISO date carrying a time part — `2026-07-04T07:00:00.000Z`, or the same with
* a space separator, with or without seconds/offset. Matched only as far as
* `HH:mm`; `Date.parse` decides the rest, so a well-shaped impossible instant
* still falls through untouched.
*/
const ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/;

/**
* Render a date-shaped measure value through the display path a list cell
* already uses for that field, or return `undefined` to say "not a date" and
* leave the caller's own fallthrough in charge.
*
* ── Why this is a ROUTE and not a formatter (objectui#7178) ──
* Every line of date rendering below happens in `./date-display.ts`, which is
* where `@object-ui/fields`' `formatDate` / `formatDateTime` now live and what
* `DateCellRenderer`, `ObjectGrid`'s date cells and `ObjectGantt`'s tooltips
* call. Writing the date convention HERE instead is the mistake this file's
* own header is about: objectui#4576, where a percent convention had one copy
* in a list cell and another in this function, both correct, and a German
* session read `1.234,5 %` in the cell beside `1.234,5%` in the measure. One
* date convention, one home, nothing to drift.
*
* The date/datetime split mirrors `ObjectGantt.tsx`'s sniffed-ISO dispatch
* verbatim, because that site is already the repo's answer to this exact
* situation: an ISO string arriving with NO field type attached. A measure is
* that situation by construction — the analytics result carries a column, not
* a field definition — so the absolute locale form is taken rather than
* `DateCellRenderer`'s `|| 'relative'` default, which is keyed on knowing the
* value is a `date` FIELD in a row.
*
* `format` is threaded into `formatDate`'s STYLE parameter, which is the same
* mapping `DateCellRenderer` makes from `field.format` and the same one
* `plugin-dashboard`'s `recordFields` already makes for a date-shaped format.
* That vocabulary is `'short'` and `'relative'`; a date PATTERN such as
* `'YYYY-MM-DD'` is not part of it and renders as the locale default. See the
* `format` note on {@link formatMeasure}.
*/
function formatMeasureDate(v: unknown, format: string | undefined, locale: string | undefined): string | undefined {
if (typeof v !== 'string') return undefined;
// `Date.parse` guards both arms so a well-shaped impossible date
// (`2026-02-30`) is NOT swallowed into the em dash `formatDate` returns for
// an unparseable value — it keeps falling through to `String(v)`, exactly as
// it does today. Only a value that is genuinely a date changes.
if (ISO_DATE_ONLY_RE.test(v)) {
return Number.isNaN(Date.parse(v)) ? undefined : formatDate(v, format, { locale });
}
if (ISO_DATETIME_RE.test(v)) {
return Number.isNaN(Date.parse(v)) ? undefined : formatDateTime(v, { locale });
}
return undefined;
}

/**
* Format a MEASURE value. Currency comes from the field's declared `currency`
* (locale-correct symbol via `Intl`), NOT from a "$" baked into the format
Expand DownExpand Up@@ -169,6 +235,28 @@ function formatNumberInLocale(
* to carry is gone, and with it the drift that duplicate produced — see the
* percent note in the body, and {@link percentDisplayValue}'s promise, which
* is true again.
*
* ── Date-valued measures (objectui#7178) ──
* `min` / `max` over a date or datetime field is a legitimate measure, and it
* used to render its stored value verbatim: the guard above returned
* `String(v)` for anything non-numeric BEFORE `format` was read, so a KPI tile
* showed `2026-07-04T07:00:00.000Z` in `text-2xl` and wrapped it over two
* lines. A date-shaped value now routes through {@link formatMeasureDate} to
* the display path list cells use; nothing about the numeric path moved.
*
* ⚠️ What `format` can and cannot say for those values, measured rather than
* assumed. The shared date path takes a named STYLE, not a date pattern:
* `formatDate`'s vocabulary is `'short'` and `'relative'`, and every other
* string falls to its default locale-medium branch. So `format: 'short'` and
* `format: 'relative'` are honoured — the same words `DateCellRenderer`
* honours from `field.format` for the same field — while a PATTERN like
* `format: 'YYYY-MM-DD'` is accepted by the schema, reaches this function, and
* renders the locale default. That last part is not new behaviour introduced
* here: `plugin-dashboard`'s `recordFields` already routes a date-shaped
* `format` into the same style slot and gets the same locale default. Closing
* it would mean teaching the shared path a pattern grammar, which is a change
* to the path itself and to every list cell that reads it — not a measure
* concern, and not this card.
*/
export function formatMeasure(
v: unknown,
Expand All@@ -178,7 +266,18 @@ export function formatMeasure(
locale?: string,
): string {
if (v == null) return '—';
if (typeof v !== 'number') return String(v);
if (typeof v !== 'number') {
// Ahead of the `String(v)` short-circuit, which is what made `format` dead
// on this path and printed a `min`/`max` over a datetime as a raw
// 24-character ISO string (objectui#7178). Deliberately NOT ahead of the
// `typeof` test itself: above that line the argument can be a bare number,
// and any date test generous enough to consider one would have to decide
// whether `1751612400000` is epoch milliseconds — which is how a measure
// that counts things starts rendering as a date. Numbers reach the numeral
// formatter below byte for byte as before.
const asDate = formatMeasureDate(v, format, locale);
return asDate ?? String(v);
}

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

Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
45 changes: 45 additions & 0 deletions .changeset/7178-format-measure-date.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
---
'@object-ui/core': patch
'@object-ui/fields': patch
---

Render a dataset measure over a date field as a date (objectui#7178, maintainer
ruling 2026-09-02, director summon #8 — option A).

`formatMeasure` opened with `if (typeof v !== 'number') return String(v)`,
placed **before** `format` was ever read. So a `min` / `max` measure over a date
or datetime field 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 was unreachable for those values.
A date-shaped value now routes to the date display path before that
short-circuit, so all four dataset-bound surfaces are served at once: the metric
tile, chart values, dataset table cells, and the metadata-admin dataset preview.

`min` / `max` over a date stays a legal measure; nothing in `@objectstack/spec`
narrows. `PivotTable` takes a `number` outright and is unchanged.

**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`), which is the same
remedy objectui#4576 applied to `formatDisplayNumber` and 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` — exactly
the drift that once had a list cell rendering `1.234,5 %` beside a dashboard
measure's `1.234,5%`. `@object-ui/fields` re-exports all four names unchanged,
so no consumer's import path or behaviour changes, and a reference-identity test
pins that the cell renderer and the measure formatter call the same function.

**What `format` can say for a date measure, measured rather than assumed.** The
shared date path takes a named STYLE, not a date pattern: `'short'` and
`'relative'` are honoured — the same words `DateCellRenderer` honours from
`field.format` — while a pattern such as `'YYYY-MM-DD'` renders the locale
default. That limit is unchanged by this release (`plugin-dashboard`'s
`recordFields` already routed a date-shaped `format` into the same style slot)
and is now pinned by a test instead of being silent.

**Numeric measures are byte-identical.** 33,696 argument forms
(value × format × currency × percentScale × locale) were compared against a
verbatim copy of the pre-fix function: the only values that moved were the four
ISO-shaped, parseable ones. Numbers, numeric strings (`'1751612400000'`,
`'2026'`), the nullish em dash, arbitrary prose and non-strings all render
exactly as before.
5 changes: 5 additions & 0 deletions packages/core/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -82,6 +82,11 @@ export * from './utils/chart-presentation.js';
// `dataset-format` below from reaching it (so the two drifted). `@object-ui/i18n`
// re-exports these names unchanged, so both import paths name the same symbol.
export * from './utils/number-display.js';
// The ONE date-display path (objectui#7178) — the same story one type over.
// It lived in `@object-ui/fields`' React barrel, which `dataset-format`
// below could not import, so a date-valued measure rendered its raw ISO
// string. `@object-ui/fields` re-exports these names unchanged.
export * from './utils/date-display.js';
export * from './utils/dataset-format.js';
// Pivot lookup-key encoders, shared by every cross-tab renderer so the
// dashboard widget and the report renderer key their buckets identically
Expand Down
167 changes: 167 additions & 0 deletions packages/core/src/utils/__tests__/dataset-format.date.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#7178 — a dataset measure over a date field renders as a date.
*
* `formatMeasure` opened with `if (typeof v !== 'number') return String(v)`,
* placed BEFORE `format` was read. So a `min` / `max` over a date or datetime
* field printed its stored value verbatim — a 24-character ISO string in the
* KPI tile's `text-2xl font-semibold`, wrapped over two lines — and the
* `format` that `DatasetMeasureSchema` accepts was unreachable for it.
*
* ── What these cases pin, and what they deliberately do NOT ─────────────────
* The fix is a ROUTE, not a formatter: every byte of date rendering comes from
* `../date-display.ts`, which is where `@object-ui/fields`' `formatDate` /
* `formatDateTime` now live and what the `date` cell renderer, `ObjectGrid`'s
* date cells and `ObjectGantt`'s tooltips call. So the cases below assert
* AGREEMENT WITH THAT PATH rather than literal strings wherever the path's own
* output is the contract — a literal would pin this file's opinion of a date,
* which is exactly the second convention objectui#4576 says not to create.
* (`DatasetWidget.dateMeasure.test.tsx` closes the loop on the rendered
* surfaces, and `@object-ui/fields`' `date-display.reexport-identity.test.ts`
* pins that the cell's `formatDate` and this one are the same function.)
*
* ── Directions, predicted in writing BEFORE the run ─────────────────────────
* the two date cases RED pre-fix — they render the raw ISO string
* the `format`-as-style cases RED pre-fix — `format` was never read here
* ⭐ every must-not-move case GREEN on both sides. These are the guard,
* not the fix: the new branch runs ahead of
* the `String(v)` short-circuit, so it has to
* be shown NOT to capture numbers, numeric
* strings, the nullish em dash, or prose.
*
* The full before/after sweep is recorded in the PR: 33,696 argument forms
* (value x format x currency x percentScale x locale) compared against a
* verbatim copy of the pre-fix function, 4 distinct values moved — every one
* of them ISO-shaped and parseable — and 0 expected movers missed.
*/

import { describe, it, expect } from 'vitest';

import { formatMeasure } from '../dataset-format.js';
import { formatDate, formatDateTime } from '../date-display.js';

/** A NON-current year, so `formatDate`'s "drop the year" branch is not in play. */
const ISO_DATE = '2024-07-04';
const ISO_DATETIME = '2024-07-04T07:00:00.000Z';
const EN = 'en-US';

describe('formatMeasure routes a date-shaped measure through the shared date path (objectui#7178)', () => {
it('renders a datetime measure as a datetime, not as its raw ISO string', () => {
const out = formatMeasure(ISO_DATETIME, undefined, undefined, undefined, EN);
expect(out).not.toBe(ISO_DATETIME);
expect(out).toBe(formatDateTime(ISO_DATETIME, { locale: EN }));
});

it('renders a date-only measure as a date, not as its raw ISO string', () => {
const out = formatMeasure(ISO_DATE, undefined, undefined, undefined, EN);
expect(out).not.toBe(ISO_DATE);
expect(out).toBe(formatDate(ISO_DATE, undefined, { locale: EN }));
});

it('accepts the space-separated ISO spelling a backend may send', () => {
const spaced = '2024-07-04 07:00:00';
expect(formatMeasure(spaced, undefined, undefined, undefined, EN)).toBe(
formatDateTime(spaced, { locale: EN }),
);
});

it('follows the display locale, like every other measure', () => {
const de = formatMeasure(ISO_DATETIME, undefined, undefined, undefined, 'de-DE');
const en = formatMeasure(ISO_DATETIME, undefined, undefined, undefined, EN);
expect(de).not.toBe(en);
expect(de).toBe(formatDateTime(ISO_DATETIME, { locale: 'de-DE' }));
});
});

/**
* The finding's reusable half: `DatasetMeasureSchema.format` is an open string
* that parses and, on this path, could never be read. It is read now — as the
* shared path's STYLE vocabulary, which is what that path actually accepts.
* A date PATTERN is not part of that vocabulary, and these cases say so out
* loud rather than leaving it silent, because "silent" is the whole card.
*/
describe('what `format` can and cannot say for a date measure (objectui#7178)', () => {
it('honours `short` — the same word `DateCellRenderer` honours from `field.format`', () => {
expect(formatMeasure(ISO_DATE, 'short', undefined, undefined, EN)).toBe(
formatDate(ISO_DATE, 'short', { locale: EN }),
);
expect(formatMeasure(ISO_DATE, 'short', undefined, undefined, EN)).not.toBe(
formatMeasure(ISO_DATE, undefined, undefined, undefined, EN),
);
});

it('honours `relative`', () => {
expect(formatMeasure(ISO_DATE, 'relative', undefined, undefined, EN)).toBe(
formatDate(ISO_DATE, 'relative', { locale: EN }),
);
});

it('⚠️ does NOT interpret a date PATTERN — `YYYY-MM-DD` renders the locale default', () => {
// Measured, not assumed, and stated in the PR body: the shared date path
// takes a named style, not a pattern. This case exists so the limit is
// pinned rather than rediscovered — and so that teaching the shared path a
// pattern grammar later has a test that must be updated deliberately.
const patterned = formatMeasure(ISO_DATE, 'YYYY-MM-DD', undefined, undefined, EN);
expect(patterned).not.toBe('2024-07-04');
expect(patterned).toBe(formatMeasure(ISO_DATE, undefined, undefined, undefined, EN));
});
});

/**
* ⭐ The regression guard. The date branch runs ahead of the `String(v)`
* short-circuit, so every one of these has to be shown NOT to reach it.
*/
describe('formatMeasure leaves every non-date value exactly where it was (objectui#7178)', () => {
it('formats numbers through the numeral path, untouched', () => {
expect(formatMeasure(1234.5, '0.0', undefined, undefined, EN)).toBe('1,234.5');
expect(formatMeasure(1234.5, '0.00', 'EUR', undefined, EN)).toBe('€1,234.50');
expect(formatMeasure(0.75, '0.0%', undefined, 'fraction', EN)).toBe('75.0%');
expect(formatMeasure(42, undefined, undefined, undefined, EN)).toBe('42');
expect(formatMeasure(2026, undefined, undefined, undefined, EN)).toBe('2026');
});

it('⭐ never reads a NUMERIC STRING as a date', () => {
// `1751612400000` is epoch milliseconds for a real instant, and `2026` is a
// year. Both are what a count or an id looks like coming back from a
// measure, and `Date.parse` would take either if it were asked.
for (const v of ['0', '42', '1234.5', '-1234.5', '1751612400000', '2026', '1e21']) {
expect(formatMeasure(v, undefined, undefined, undefined, EN)).toBe(v);
expect(formatMeasure(v, 'YYYY-MM-DD', undefined, undefined, EN)).toBe(v);
}
});

it('keeps the nullish em dash', () => {
expect(formatMeasure(null)).toBe('—');
expect(formatMeasure(undefined)).toBe('—');
expect(formatMeasure(null, 'YYYY-MM-DD', undefined, undefined, EN)).toBe('—');
});

it('falls through to `String(v)` for an arbitrary non-numeric, non-date value', () => {
for (const v of ['Acme Corp', 'N/A', '', 'March 5, 2026', '2026-07', '2026/07/04', '04-07-2026']) {
expect(formatMeasure(v, undefined, undefined, undefined, EN)).toBe(String(v));
}
expect(formatMeasure(true, undefined, undefined, undefined, EN)).toBe('true');
});

it('leaves an ISO-SHAPED but unparseable value alone rather than showing an em dash', () => {
// `formatDate` answers `—` for an unparseable value. Routing one there
// would REPLACE a raw string the author can still debug with a dash that
// says nothing, so the parse guard keeps these on the old path.
for (const v of ['2026-13-45', '2024-07-04T99:99']) {
expect(formatMeasure(v, undefined, undefined, undefined, EN)).toBe(v);
}
});

it('agrees with the list cell on a rolled-over date instead of second-guessing it', () => {
// `Date.parse('2024-02-30')` is NOT NaN — V8 rolls it to March 1/2. Every
// date surface in the repo builds its `Date` the same way, so this renders
// as the same day a list cell shows for the same stored string. Pinned
// because it is the one place agreement looks like a bug.
const rolled = '2024-02-30';
expect(Number.isNaN(Date.parse(rolled))).toBe(false);
expect(formatMeasure(rolled, undefined, undefined, undefined, EN)).toBe(
formatDate(rolled, undefined, { locale: EN }),
);
});
});
101 changes: 100 additions & 1 deletion packages/core/src/utils/dataset-format.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,7 @@ import type { AnalyticsResult } from '@objectstack/spec/contracts';
import type { PercentScale } from '@objectstack/spec/data';

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

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

/**
* ISO calendar date with NO time part — `2026-07-04`.
*
* Deliberately anchored at both ends and deliberately narrower than
* `Date.parse`, which also accepts locale prose (`March 5, 2026`), bare years
* (`2026`) and — the one that matters here — plain NUMERALS. A measure value
* is untyped by the time it reaches {@link formatMeasure}, so the shape test
* IS the type test, and a loose one would capture the numeric strings and
* counts this formatter must leave exactly as they are.
*/
const ISO_DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;

/**
* ISO date carrying a time part — `2026-07-04T07:00:00.000Z`, or the same with
* a space separator, with or without seconds/offset. Matched only as far as
* `HH:mm`; `Date.parse` decides the rest, so a well-shaped impossible instant
* still falls through untouched.
*/
const ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/;

/**
* Render a date-shaped measure value through the display path a list cell
* already uses for that field, or return `undefined` to say "not a date" and
* leave the caller's own fallthrough in charge.
*
* ── Why this is a ROUTE and not a formatter (objectui#7178) ──
* Every line of date rendering below happens in `./date-display.ts`, which is
* where `@object-ui/fields`' `formatDate` / `formatDateTime` now live and what
* `DateCellRenderer`, `ObjectGrid`'s date cells and `ObjectGantt`'s tooltips
* call. Writing the date convention HERE instead is the mistake this file's
* own header is about: objectui#4576, where a percent convention had one copy
* in a list cell and another in this function, both correct, and a German
* session read `1.234,5 %` in the cell beside `1.234,5%` in the measure. One
* date convention, one home, nothing to drift.
*
* The date/datetime split mirrors `ObjectGantt.tsx`'s sniffed-ISO dispatch
* verbatim, because that site is already the repo's answer to this exact
* situation: an ISO string arriving with NO field type attached. A measure is
* that situation by construction — the analytics result carries a column, not
* a field definition — so the absolute locale form is taken rather than
* `DateCellRenderer`'s `|| 'relative'` default, which is keyed on knowing the
* value is a `date` FIELD in a row.
*
* `format` is threaded into `formatDate`'s STYLE parameter, which is the same
* mapping `DateCellRenderer` makes from `field.format` and the same one
* `plugin-dashboard`'s `recordFields` already makes for a date-shaped format.
* That vocabulary is `'short'` and `'relative'`; a date PATTERN such as
* `'YYYY-MM-DD'` is not part of it and renders as the locale default. See the
* `format` note on {@link formatMeasure}.
*/
function formatMeasureDate(v: unknown, format: string | undefined, locale: string | undefined): string | undefined {
if (typeof v !== 'string') return undefined;
// `Date.parse` guards both arms so a well-shaped impossible date
// (`2026-02-30`) is NOT swallowed into the em dash `formatDate` returns for
// an unparseable value — it keeps falling through to `String(v)`, exactly as
// it does today. Only a value that is genuinely a date changes.
if (ISO_DATE_ONLY_RE.test(v)) {
return Number.isNaN(Date.parse(v)) ? undefined : formatDate(v, format, { locale });
}
if (ISO_DATETIME_RE.test(v)) {
return Number.isNaN(Date.parse(v)) ? undefined : formatDateTime(v, { locale });
}
return undefined;
}

/**
* Format a MEASURE value. Currency comes from the field's declared `currency`
* (locale-correct symbol via `Intl`), NOT from a "$" baked into the format
Expand DownExpand Up@@ -169,6 +235,28 @@ function formatNumberInLocale(
* to carry is gone, and with it the drift that duplicate produced — see the
* percent note in the body, and {@link percentDisplayValue}'s promise, which
* is true again.
*
* ── Date-valued measures (objectui#7178) ──
* `min` / `max` over a date or datetime field is a legitimate measure, and it
* used to render its stored value verbatim: the guard above returned
* `String(v)` for anything non-numeric BEFORE `format` was read, so a KPI tile
* showed `2026-07-04T07:00:00.000Z` in `text-2xl` and wrapped it over two
* lines. A date-shaped value now routes through {@link formatMeasureDate} to
* the display path list cells use; nothing about the numeric path moved.
*
* ⚠️ What `format` can and cannot say for those values, measured rather than
* assumed. The shared date path takes a named STYLE, not a date pattern:
* `formatDate`'s vocabulary is `'short'` and `'relative'`, and every other
* string falls to its default locale-medium branch. So `format: 'short'` and
* `format: 'relative'` are honoured — the same words `DateCellRenderer`
* honours from `field.format` for the same field — while a PATTERN like
* `format: 'YYYY-MM-DD'` is accepted by the schema, reaches this function, and
* renders the locale default. That last part is not new behaviour introduced
* here: `plugin-dashboard`'s `recordFields` already routes a date-shaped
* `format` into the same style slot and gets the same locale default. Closing
* it would mean teaching the shared path a pattern grammar, which is a change
* to the path itself and to every list cell that reads it — not a measure
* concern, and not this card.
*/
export function formatMeasure(
v: unknown,
Expand All@@ -178,7 +266,18 @@ export function formatMeasure(
locale?: string,
): string {
if (v == null) return '—';
if (typeof v !== 'number') return String(v);
if (typeof v !== 'number') {
// Ahead of the `String(v)` short-circuit, which is what made `format` dead
// on this path and printed a `min`/`max` over a datetime as a raw
// 24-character ISO string (objectui#7178). Deliberately NOT ahead of the
// `typeof` test itself: above that line the argument can be a bare number,
// and any date test generous enough to consider one would have to decide
// whether `1751612400000` is epoch milliseconds — which is how a measure
// that counts things starts rendering as a date. Numbers reach the numeral
// formatter below byte for byte as before.
const asDate = formatMeasureDate(v, format, locale);
return asDate ?? String(v);
}

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

Expand Down
Loading
Loading