From 4b1f26d71c37da1303e60005840920ef52fb41f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 14:05:45 +0000 Subject: [PATCH 1/3] fix(core): honour `measure.format` on the datetime arm of a date-shaped measure `formatMeasureDate` threaded `format` into `formatDate`'s style parameter on the date arm and dropped it on the datetime arm, which called `formatDateTime(v, { locale })`. 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 silently. The datetime arm now selects a formatter instead of threading one, so both arms honour exactly `'short'` and `'relative'` and nothing else. `'relative'` routes to `formatRelativeDate`, inheriting its +/-7-day window rather than re-deciding it here; `'short'` takes the dense face of the value's own type, which keeps the time of day and matches what a datetime grid cell paints. No published signature moved. Threading `format` into `formatDateTime`'s `options.style` would have honoured `'compact'` -- the one word the date arm does not honour -- while still ignoring both words it does; the call site maps the vocabulary explicitly and a test pins that it does. Both docblocks are now qualified per arm. The previous undifferentiated wording was true of the date arm only, and a triage pass read it as evidence the defect was fixed. Refs objectui#8352 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01611D6ZaRaMmwTNQmSbk8MH --- .changeset/measure-datetime-format-8352.md | 41 ++++ .../__tests__/dataset-format.date.test.ts | 184 +++++++++++++++++- packages/core/src/utils/dataset-format.ts | 106 ++++++++-- 3 files changed, 310 insertions(+), 21 deletions(-) create mode 100644 .changeset/measure-datetime-format-8352.md diff --git a/.changeset/measure-datetime-format-8352.md b/.changeset/measure-datetime-format-8352.md new file mode 100644 index 0000000000..8271213d16 --- /dev/null +++ b/.changeset/measure-datetime-format-8352.md @@ -0,0 +1,41 @@ +--- +'@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. Its + ±7-day fallback is inherited rather than re-decided at the call site: beyond that + window both arms render the absolute face, which is unchanged behaviour. +- `'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. diff --git a/packages/core/src/utils/__tests__/dataset-format.date.test.ts b/packages/core/src/utils/__tests__/dataset-format.date.test.ts index 28e3dfffe6..863b4af269 100644 --- a/packages/core/src/utils/__tests__/dataset-format.date.test.ts +++ b/packages/core/src/utils/__tests__/dataset-format.date.test.ts @@ -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'; @@ -165,3 +170,178 @@ 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, predicted in writing BEFORE the run ───────────────────────── + * leg 1 in-window datetime RED pre-fix — renders the absolute face + * leg 2 date-only control GREEN on both sides — it is the RIG check, + * not the fix; it fails only if the harness + * itself is disconnected + * leg 3 out-of-window datetime GREEN on both sides for the SHIPPED fix, and + * the only leg that goes RED for a fake fix + * that hand-rolls a relative phrase instead of + * routing to `formatRelativeDate` — legs 1+2 + * pass for one of those + * the must-not-move cases GREEN on both sides — they are the guard + * against the OTHER fake fix, wiring the arm + * to `formatRelativeDate` unconditionally + */ +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 SAME relative face', () => { + // The rig check, in the same run and through the same entry point: if this + // fails, the harness is disconnected and legs 1 and 3 say nothing. It is + // green on both sides of the fix by design — the date arm always honoured + // `relative`, and that asymmetry IS the defect. + 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)); + + // ⭐ The decisive pair from the card, now agreeing: the same calendar day + // reaches the same phrase whether the field is a `date` or a `datetime`. + // Pre-fix these two differed — one read `In 2 days`, the other an absolute + // datetime — which is the entire user-visible symptom. + expect(measure(IN_WINDOW_DATETIME, 'relative')).toBe(dateOnly); + }); + + // ── 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'), + ); + } + }); +}); diff --git a/packages/core/src/utils/dataset-format.ts b/packages/core/src/utils/dataset-format.ts index ee67e70495..78753b00aa 100644 --- a/packages/core/src/utils/dataset-format.ts +++ b/packages/core/src/utils/dataset-format.ts @@ -21,7 +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'; +import { formatDate, formatDateTime, formatRelativeDate } from './date-display.js'; import { resolveMeasureLabel, type BuiltinAggregateLabels } from './chart-series.js'; /** @@ -175,12 +175,48 @@ const ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/; * `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}. + * ── `format` is honoured on BOTH arms, by different means (objectui#8352) ── + * The accepted vocabulary is `'short'` and `'relative'` on EITHER arm, and + * every other string — including `'compact'`, including a date PATTERN such as + * `'YYYY-MM-DD'` — falls to that arm's default face. The two arms reach that + * one vocabulary differently, and the difference is the whole of #8352: + * + * - The DATE arm threads `format` onward, into `formatDate`'s positional + * STYLE parameter. That is the same mapping `DateCellRenderer` makes from + * `field.format` and the same one `plugin-dashboard`'s `recordFields` + * makes for a date-shaped format. + * - The DATETIME arm SELECTS a formatter above, because it has nothing to + * thread into: `formatDateTime(value, options?)` has no style parameter, + * so before #8352 this arm could not honour `format` even in principle — + * it dropped it, and `format: 'relative'` on a `Field.datetime` measure + * rendered the absolute face with no diagnostic. + * + * ⚠️ Threading `format` into `formatDateTime`'s `options.style` is NOT the fix + * and was measured before this one was written: that key's vocabulary is + * `'compact'` alone, so the pass-through would honour the one word the date + * arm does NOT honour while still ignoring both words it does — the defect + * inverted, not closed. Widening the signature was refused separately: it is a + * published one (see {@link formatDateTime}'s note on why the positional shape + * was refused there), and the parity #8352 asks for is reachable from the call + * site without touching it. + * + * Which FACE each honoured word selects is per-arm, because the two arms + * render different types and a datetime's own resolution is part of its datum: + * + * - `'relative'` → `formatRelativeDate` on both arms, the same function, so + * an in-window value reads `In 2 days` in either column. Its ±7-day + * fallback is inherited rather than re-decided here — an out-of-window + * value renders the absolute DATE face on both arms. Re-deciding that + * window at this call site would put a second copy of the convention in + * this file, which is objectui#4576 exactly. + * - `'short'` → the dense narrow-card face of the arm's own type: + * `formatDate`'s `'short'` (`Sep 11, '26`) for a date, `formatDateTime`'s + * `'compact'` (`9/11/2026 9:30 am`) for a datetime. The datetime face + * keeps the time, and it is byte-identical to what every `datetime` CELL + * paints, so a measure tile and a grid cell showing the same instant + * agree — objectui#4576's rule again, in the other direction. + * + * 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; @@ -192,7 +228,15 @@ function formatMeasureDate(v: unknown, format: string | undefined, locale: strin 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 }); + if (Number.isNaN(Date.parse(v))) return undefined; + // The datetime arm honours the SAME two words the date arm does, by + // choosing a formatter here (objectui#8352). It cannot thread `format` + // onward the way the date arm does, because `formatDateTime(value, + // options?)` takes no style parameter — see this function's docblock for + // why that is answered HERE and not by widening a published signature. + if (format === 'relative') return formatRelativeDate(v, { locale }); + if (format === 'short') return formatDateTime(v, { locale, style: 'compact' }); + return formatDateTime(v, { locale }); } return undefined; } @@ -246,18 +290,42 @@ function formatMeasureDate(v: unknown, format: string | undefined, locale: strin * 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 + * assumed, and stated PER ARM — a date-shaped measure takes one of two arms in + * {@link formatMeasureDate}, and this paragraph used to describe only one of + * them while reading as though it described both. That cost a card: an + * objectui triage pass read the undifferentiated claim, concluded the defect + * was already fixed, and downgraded objectui#8352, which a driven browser run + * then refuted. The two arms agree TODAY, but only because #8352 made them; a + * universal sentence here is what hid the disagreement before, so it is not + * coming back even now that it would be true. + * + * The shared date path takes a named STYLE, not a date pattern. On BOTH arms + * the honoured vocabulary is `'short'` and `'relative'`: + * + * - `Field.date` (`2026-09-08`) — honoured by threading `format` into + * `formatDate`'s style parameter. The same words `DateCellRenderer` + * honours from `field.format` for the same field. + * - `Field.datetime` (`2026-09-01T00:00:00.000Z`) — honoured by SELECTING a + * formatter, because `formatDateTime` has no style parameter to thread + * into (objectui#8352). Before that card this arm honoured NOTHING: it + * dropped `format` entirely, so `'relative'` rendered the absolute face. + * + * Every other string falls to that arm's default face — 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. + * renders the locale default, and so does `'compact'`, which is + * `formatDateTime`'s own vocabulary but not this path's. The pattern 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 either card. + * + * ⚠️ `'relative'` renders the ABSOLUTE face for a value more than ±7 days from + * today, on both arms, by `formatRelativeDate`'s own design. That is correct + * output, not a regression — but it also means an out-of-window value is + * useless for testing this path, because it renders identically whether + * `format` is honoured or dropped. Both readings that missed #8352 were taken + * on 40–60-day-old data. */ export function formatMeasure( v: unknown, From f4fdbbb4494ff14d40e4dc45669a04f1d5da6609 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 14:11:16 +0000 Subject: [PATCH 2/3] test(core): split the rig control off the decisive pair, record measured ablation directions The three-leg pin's leg 2 is the positive control -- it exists to show the harness is connected, so no ablation of the DATETIME arm may move it. As first written it also carried the cross-arm assertion, and ablating the fix turned it red, which costs the leg the one property that makes it a control. The cross-arm pair is now its own case. The predicted-directions block is replaced by the directions actually measured, across four ablations: the pre-#8352 arm, two shapes of "just make it relative", and the one-line pass-through into `formatDateTime`'s `options.style`. Two predictions were wrong and are corrected rather than restated -- leg 3 was predicted green on both sides and measured red against the pre-fix arm, because out of the window the honoured reading renders the absolute DATE face while the pre-fix arm rendered the absolute DATETIME face. Refs objectui#8352 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01611D6ZaRaMmwTNQmSbk8MH --- .../__tests__/dataset-format.date.test.ts | 79 +++++++++++++------ 1 file changed, 56 insertions(+), 23 deletions(-) diff --git a/packages/core/src/utils/__tests__/dataset-format.date.test.ts b/packages/core/src/utils/__tests__/dataset-format.date.test.ts index 863b4af269..d3d4b0a6b8 100644 --- a/packages/core/src/utils/__tests__/dataset-format.date.test.ts +++ b/packages/core/src/utils/__tests__/dataset-format.date.test.ts @@ -197,19 +197,45 @@ describe('formatMeasure leaves every non-date value exactly where it was (object * (`vitest.config.mts`, objectui#8366), so a `Z`-suffixed instant and the * local calendar day `formatRelativeDate` computes from it cannot disagree. * - * ── Directions, predicted in writing BEFORE the run ───────────────────────── - * leg 1 in-window datetime RED pre-fix — renders the absolute face - * leg 2 date-only control GREEN on both sides — it is the RIG check, - * not the fix; it fails only if the harness - * itself is disconnected - * leg 3 out-of-window datetime GREEN on both sides for the SHIPPED fix, and - * the only leg that goes RED for a fake fix - * that hand-rolls a relative phrase instead of - * routing to `formatRelativeDate` — legs 1+2 - * pass for one of those - * the must-not-move cases GREEN on both sides — they are the guard - * against the OTHER fake fix, wiring the arm - * to `formatRelativeDate` unconditionally + * ── 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`. */ @@ -251,20 +277,27 @@ describe('the DATETIME arm honours `format`, like the date arm (objectui#8352)', }); // ── Leg 2 ──────────────────────────────────────────────────────────────── - it('⭐ leg 2 — the date-only positive control renders the SAME relative face', () => { - // The rig check, in the same run and through the same entry point: if this - // fails, the harness is disconnected and legs 1 and 3 say nothing. It is - // green on both sides of the fix by design — the date arm always honoured - // `relative`, and that asymmetry IS the defect. + 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)); + }); - // ⭐ The decisive pair from the card, now agreeing: the same calendar day - // reaches the same phrase whether the field is a `date` or a `datetime`. - // Pre-fix these two differed — one read `In 2 days`, the other an absolute - // datetime — which is the entire user-visible symptom. - expect(measure(IN_WINDOW_DATETIME, 'relative')).toBe(dateOnly); + 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 ──────────────────────────────────────────────────────────────── From ad641ce86f0a6940e5b52c125932ed6072bb0f7c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 14:21:50 +0000 Subject: [PATCH 3/3] docs(changeset): the out-of-window datetime face CHANGED -- say so per arm The relative bullet claimed that beyond the +/-7-day window "both arms render the absolute face, which is unchanged behaviour". True of the date arm, false of the datetime one, and read as though it covered both -- the exact shape this release's own last paragraph indicts, committed in its own third bullet. The changeset is the one thing a downstream consumer reads to learn whether their UI moved, so a per-arm claim that is only half true is the worst place for it. Re-measured at date-display.ts:125 and :152 before rewriting: `absoluteFallbackOptions` only strips `style` and adds no time component, and the out-of-window branch calls `formatDate(date, undefined, ...)`, which renders through `toLocaleDateString`. So an out-of-window datetime with `format: 'relative'` renders `Oct 19` where the pre-fix arm rendered `Oct 19, 2026, 09:30 AM`. Prose only -- no behaviour, code or test change. The behaviour is correct and is what ruling 2 produced: the honoured sets must be equal to the date arm's, and `'relative'` is day-granular, so its degraded form is a day face on either arm. The bullet now names the delta, gives that reason, and records that it reaches only a measure whose author actually asked for `format: 'relative'`. Refs objectui#8352 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01611D6ZaRaMmwTNQmSbk8MH --- .changeset/measure-datetime-format-8352.md | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/.changeset/measure-datetime-format-8352.md b/.changeset/measure-datetime-format-8352.md index 8271213d16..53ca4f2c67 100644 --- a/.changeset/measure-datetime-format-8352.md +++ b/.changeset/measure-datetime-format-8352.md @@ -19,9 +19,22 @@ 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. Its - ±7-day fallback is inherited rather than re-decided at the call site: beyond that - window both arms render the absolute face, which is unchanged behaviour. + 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.