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
54 changes: 54 additions & 0 deletions .changeset/measure-datetime-format-8352.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
---
'@object-ui/core': patch
---

A dataset measure over a `datetime` field now honours `measure.format`, like one over a `date` field already did (objectui#8352).

`formatMeasureDate` routes a date-shaped measure value down one of two arms. The
date arm threaded `format` into `formatDate`'s style parameter; the datetime arm
called `formatDateTime(v, { locale })` and dropped `format` on the floor. That was
structural rather than a threading slip — `formatDateTime(value, options?)` has no
style parameter — so `format: 'relative'` on a `Field.datetime` measure could not be
honoured even in principle, and rendered the absolute face with no error, no warning
and no fallback. Measured on a real browser boot against the showcase app in both
`zh-CN` and `en-US`: `min(created_at)` five days old read `2026年9月1日 00:00` with
and without `format: 'relative'`, while `min(due_date)` — a `date` field, same page
load, same tile — moved from `9月8日` to `后天`.

The datetime arm now selects a formatter instead of threading one, so both arms
honour the same two words and nothing else:

- `'relative'` resolves through `formatRelativeDate`, the same function the date arm
reaches, so the same calendar day reads the same phrase for either field type. The
±7-day window and its fallback belong to that function and are inherited here, not
re-decided at the call site. ⚠️ **Beyond ±7 days both arms render an absolute face,
and on the datetime arm that face changed:** it is now the DATE face (`Oct 19`)
where before this fix it was the DATETIME face (`Oct 19, 2026, 09:30 AM`) — the
time of day is gone. `formatRelativeDate`'s out-of-window branch calls
`formatDate(date, undefined, …)`, which renders through `toLocaleDateString` and
has no time component to add. That is the intended shape rather than a rough edge:
`'relative'` is day-granular by construction — it shows no time inside the window
either — so its degraded form is a day face on both arms, and any other fallback
would make the two arms unequal again, which is the defect this release fixes. Note
what the delta is and is not: before this fix the datetime arm ignored `format`
outright, so nothing was taken away from a working feature — the arm started
honouring a request whose granularity is days. It reaches only a measure whose
author actually wrote `format: 'relative'`; an unstyled datetime measure, or one
asking for any other style, renders exactly as it did.
- `'short'` resolves to the dense narrow-card face of the value's own type —
`formatDateTime`'s `'compact'` for a datetime, which keeps the time of day and is
byte-identical to what every `datetime` grid cell already paints.
- every other string, `'compact'` and date patterns such as `'YYYY-MM-DD'` included,
falls to that arm's default face, exactly as before.

No published signature moved. Threading `format` into `formatDateTime`'s
`options.style` key would have honoured `'compact'` — the one word the date arm does
*not* honour — while still ignoring both words it does, so the call site maps the
vocabulary explicitly and a test pins that it does.

The docblocks on `formatMeasure` and `formatMeasureDate` are now qualified per arm.
The previous wording ("`format: 'short'` and `format: 'relative'` are honoured") was
true of the date arm and read as though it covered both; a triage pass took it as
evidence the defect was already fixed and downgraded the card, which a driven browser
run then refuted. The arms agree now, but the undifferentiated sentence is not coming
back — it is what hid the disagreement.
217 changes: 215 additions & 2 deletions packages/core/src/utils/__tests__/dataset-format.date.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,15 @@
* of them ISO-shaped and parseable — and 0 expected movers missed.
*/

import { describe, it, expect } from 'vitest';
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';

import { formatMeasure } from '../dataset-format.js';
import { formatDate, formatDateTime } from '../date-display.js';
import {
formatDate,
formatDateTime,
formatDateTimeCompactParts,
formatRelativeDate,
} 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';
Expand Down Expand Up @@ -165,3 +170,211 @@ describe('formatMeasure leaves every non-date value exactly where it was (object
);
});
});

/**
* objectui#8352 — the DATETIME arm honours `format` too.
*
* Everything in the describe above exercises `ISO_DATE` only. That was the
* blind spot: `formatMeasureDate` has two arms, `format` was threaded into the
* date one and DROPPED on the datetime one, and a suite that never passed a
* datetime through the `format` cases could not see it. A `Field.date` yields
* `2026-09-08` and takes the first arm; a `Field.datetime` yields
* `2026-09-01T00:00:00.000Z` and takes the second.
*
* ── ⚠️ Why the clock is pinned, and why these values are not the file's ─────
* `formatRelativeDate` falls back to the absolute form beyond a ±7-day window,
* so an OUT-OF-WINDOW value renders identically whether `format` is honoured
* or dropped. Both readings that missed this defect were taken that way —
* objectstack#15768's datum (`2026-07-04`) and the first dev probe
* (`2026-07-28`) were 40–60 days old, so neither could fail in either
* direction. `ISO_DATE` / `ISO_DATETIME` above are 2024 values and are
* out-of-window by construction: they are the WRONG instrument for this card
* and are deliberately not reused here.
*
* The window is relative to "now", so the cases below need a fixed "now" —
* `vi.setSystemTime` with `toFake: ['Date']`, the convention already used in
* `plugin-timeline` / `plugin-map`. The suite also pins `TZ=UTC`
* (`vitest.config.mts`, objectui#8366), so a `Z`-suffixed instant and the
* local calendar day `formatRelativeDate` computes from it cannot disagree.
*
* ── Directions, MEASURED by ablating the fix four ways ──────────────────────
* Predicted first, then run; two predictions were wrong and these are the
* observed columns, not the predicted ones. A = the pre-#8352 arm (`format`
* dropped); B1 = wire the arm to `formatRelativeDate` unconditionally; B2 =
* hand-roll the relative phrase, skipping the ±7-day window; C = thread
* `format` into `formatDateTime`'s `options.style`.
*
* A B1 B2 C
* leg 1 in-window datetime RED RED RED RED
* leg 2 date-only control green green green green ← the rig check:
* no ablation of the DATETIME arm can move
* it, which is what makes it a control
* leg 3 out-of-window datetime RED green RED RED
* decisive pair RED green RED RED
* `short` parity RED RED green RED
* vocabulary equality RED RED green RED
* pass-through anti-pin green green green RED
* unstyled default guard green RED green green
*
* ⭐ What each row adds, read off those columns. Leg 1 moves for all four, so
* it DETECTS every ablation — but it does not distinguish them, and each fake
* fix leaves something green that a trimmed suite would have relied on: B1
* leaves leg 3 and the decisive pair green (its relative face is correct
* inside the window), B2 leaves the `short` and vocabulary cases green (it
* only touches the relative branch). The two must-not-move guards are where
* the fake fixes separate from each other: B1 is the ONLY column that reddens
* the unstyled-default guard, C the ONLY one that reddens the pass-through
* anti-pin. Neither fake fix is hypothetical shape-fitting — both are what
* "just make it relative" looks like when written quickly, and C is the
* one-line change the signature invites.
*
* ⚠️ Leg 3 was predicted green-on-both-sides and measured RED under A. The
* prediction assumed the pre-fix arm rendered the same absolute face this one
* does; it does not — out of the window the honoured reading renders the
* absolute DATE face (`formatRelativeDate`'s own fallback) while the pre-fix
* arm rendered the absolute DATETIME face. The card's "indistinguishable out
* of window" trap is about the BROWSER comparison it was measured in —
* `format` absent beside `format: 'relative'`, which pre-fix were the same
* bytes — and that reading is pinned on its own in leg 3's second assertion.
*/
describe('the DATETIME arm honours `format`, like the date arm (objectui#8352)', () => {
/** Noon UTC, so the pinned day is unambiguous under the suite's `TZ=UTC`. */
const NOW = new Date('2026-09-09T12:00:00Z');

beforeAll(() => {
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(NOW);
});
afterAll(() => vi.useRealTimers());

/** `days` from the pinned now, as the wire spells each field type. */
const at = (days: number, withTime: boolean) => {
const d = new Date(NOW);
d.setUTCDate(d.getUTCDate() + days);
const day = d.toISOString().slice(0, 10);
return withTime ? `${day}T09:30:00.000Z` : day;
};

/** +2 days: inside the ±7-day window, so the relative face is REACHABLE. */
const IN_WINDOW_DATETIME = at(2, true); // 2026-09-11T09:30:00.000Z
const IN_WINDOW_DATE = at(2, false); // 2026-09-11
/** +40 days: outside it, so the relative style renders the absolute face. */
const OUT_OF_WINDOW_DATETIME = at(40, true); // 2026-10-19T09:30:00.000Z

const measure = (v: string, format?: string) => formatMeasure(v, format, undefined, undefined, EN);

// ── Leg 1 ────────────────────────────────────────────────────────────────
it('⭐ leg 1 — an IN-WINDOW datetime with `relative` renders the RELATIVE face', () => {
// This is the assertion the card's driven browser run made by hand, and
// the one line of this file that was red before the fix: the value used to
// render `Sep 11, 2026, 09:30 AM` no matter what `format` said.
const relative = measure(IN_WINDOW_DATETIME, 'relative');

expect(relative).toBe(formatRelativeDate(IN_WINDOW_DATETIME, { locale: EN }));
// It MOVED. Equality with the shared path alone would also hold for an arm
// that ignored `format`, if the path happened to agree — this does not.
expect(relative).not.toBe(measure(IN_WINDOW_DATETIME));
});

// ── Leg 2 ────────────────────────────────────────────────────────────────
it('⭐ leg 2 — the date-only positive control renders the relative face', () => {
// The RIG check, and only that: same run, same entry point, same window.
// It is green on BOTH sides of the fix by design — the date arm always
// honoured `relative`, and that asymmetry IS the defect — so it fails only
// if the harness itself is disconnected, in which case legs 1 and 3 say
// nothing. Deliberately carries no cross-arm assertion: mixing one in
// costs this leg the one property that makes it a control, which is that
// the defect cannot move it. (Measured: it did carry one, and ablating the
// fix turned it red — the diagnostic separation the three legs exist for,
// lost. The cross-arm pair is its own case below.)
const dateOnly = measure(IN_WINDOW_DATE, 'relative');
expect(dateOnly).toBe(formatDate(IN_WINDOW_DATE, 'relative', { locale: EN }));
expect(dateOnly).not.toBe(measure(IN_WINDOW_DATE));
});

it('⭐ the decisive pair — the same day reads the same on either field type', () => {
// The card's measured symptom, as one assertion: `min(created_at)` (a
// `Field.datetime`) and `min(due_date)` (a `Field.date`) five days apart
// from the same page load, one reading `后天` and the other an absolute
// datetime. Both arms now reach the same phrase for the same calendar day.
expect(measure(IN_WINDOW_DATETIME, 'relative')).toBe(measure(IN_WINDOW_DATE, 'relative'));
});

// ── Leg 3 ────────────────────────────────────────────────────────────────
it('⭐ leg 3 — an OUT-OF-WINDOW datetime with `relative` renders the ABSOLUTE face', () => {
// The falsifier. A fix that produces a relative phrase itself — rather
// than routing to `formatRelativeDate` and inheriting its ±7-day window —
// passes legs 1 and 2 and fails exactly here. `formatDate(v, undefined,…)`
// IS the absolute face by definition, so this compares against the shared
// path rather than pinning a literal.
const out = measure(OUT_OF_WINDOW_DATETIME, 'relative');
expect(out).toBe(formatDate(OUT_OF_WINDOW_DATETIME, undefined, { locale: EN }));
expect(out).toBe(formatRelativeDate(OUT_OF_WINDOW_DATETIME, { locale: EN }));

// ⚠️ And the trap stated out loud: out of the window the honoured and the
// dropped readings of `format` are INDISTINGUISHABLE on the date arm. A
// suite that only ever measured here would go green over the defect.
expect(measure(at(40, false), 'relative')).toBe(measure(at(40, false)));
});

// ── Ruling: the honoured set must EQUAL the date arm's, no more, no less ──
it('honours `short` — the dense face of its OWN type, keeping the time', () => {
const short = measure(IN_WINDOW_DATETIME, 'short');
expect(short).toBe(formatDateTime(IN_WINDOW_DATETIME, { locale: EN, style: 'compact' }));
expect(short).not.toBe(measure(IN_WINDOW_DATETIME));
// Byte-identical to what a `datetime` CELL paints for the same instant, so
// a measure tile and a grid cell cannot drift (objectui#4576).
const parts = formatDateTimeCompactParts(new Date(IN_WINDOW_DATETIME), { locale: EN });
expect(short).toBe(`${parts!.date} ${parts!.time}`);
});

it('⭐ honours EXACTLY the two words the date arm honours — measured, both arms', () => {
// Ruling ② of the dispatch, as an assertion rather than a claim: the two
// arms must accept the SAME vocabulary. `'compact'` is the interesting
// entry — it is `formatDateTime`'s own style key, so the tempting
// one-line "fix" (thread `format` into `options.style`) would honour it
// HERE and nowhere on the date arm. That would be the defect inverted:
// the one word the date arm ignores, honoured; both words it honours,
// still dropped.
const VOCABULARY = [undefined, 'short', 'relative', 'compact', 'YYYY-MM-DD', ''];
const movedOn = (v: string) =>
VOCABULARY.filter((f) => f !== undefined && measure(v, f) !== measure(v));

expect(movedOn(IN_WINDOW_DATE)).toEqual(['short', 'relative']);
expect(movedOn(IN_WINDOW_DATETIME)).toEqual(['short', 'relative']);
expect(movedOn(IN_WINDOW_DATETIME)).toEqual(movedOn(IN_WINDOW_DATE));
});

it('⭐ does NOT thread `format` into the `style` key of `formatDateTime`', () => {
// The anti-pin for the inverted fix above, stated on the value that would
// expose it. `'compact'` must reach the DEFAULT face here, not the compact
// one — otherwise this path speaks a vocabulary no list cell speaks.
expect(measure(IN_WINDOW_DATETIME, 'compact')).toBe(measure(IN_WINDOW_DATETIME));
expect(measure(IN_WINDOW_DATETIME, 'compact')).not.toBe(
formatDateTime(IN_WINDOW_DATETIME, { locale: EN, style: 'compact' }),
);
});

it('⚠️ does NOT interpret a date PATTERN on this arm either', () => {
expect(measure(IN_WINDOW_DATETIME, 'YYYY-MM-DD')).toBe(measure(IN_WINDOW_DATETIME));
});

// ── The guard against the other fake fix ──────────────────────────────────
it('⭐ leaves an UNSTYLED datetime on the verbose default face', () => {
// Wiring the arm to `formatRelativeDate` unconditionally passes legs 1 and
// 2 as well. It fails here: with no `format`, an in-window datetime must
// still render the full absolute face, time included.
expect(measure(IN_WINDOW_DATETIME)).toBe(formatDateTime(IN_WINDOW_DATETIME, { locale: EN }));
expect(measure(IN_WINDOW_DATETIME)).not.toBe(
formatRelativeDate(IN_WINDOW_DATETIME, { locale: EN }),
);
});

it('follows the display locale on every honoured style', () => {
for (const style of ['short', 'relative', undefined]) {
expect(measure(IN_WINDOW_DATETIME, style)).not.toBe(
formatMeasure(IN_WINDOW_DATETIME, style, undefined, undefined, 'de-DE'),
);
}
});
});
Loading
Loading