Skip to content
This repository was archived by the owner on Oct 26, 2026. It is now read-only.

Commit 58be55e

Browse files
os-steveclaude
andauthored
fix(fields): honour relative and short on a datetime cell, as the date cell already did (#8959)
`DateCellRenderer` and `DateTimeCellRenderer` are neighbours reading ONE authored `field.format` and handing it to two formatters with different vocabularies: `formatDate` honours `'short'` and `'relative'`, while `formatDateTime`'s `options.style` honours `'compact'` alone. So `format: 'relative'` painted the relative face on a `date` column and the verbose absolute face on a `datetime` one — no error, no warning, no fallback. Driven end to end through a real ObjectGrid column before any change, one row, one instant, the key authored on both fields: the `date` cell painted `In 2 days` and the `datetime` cell beside it painted `Sep 11, 2026, 09:30 AM`. The cell now SELECTS a formatter instead of threading one, which is the ruling objectui#8352 already made for `formatMeasureDate`'s datetime arm — the same defect class one surface over. No published signature moved, and no spelling that rendered before is refused now. Claude-Session: https://claude.ai/code/session_01MPaVWWMuWeT5LgB1qoXjVB Co-authored-by: Claude <noreply@anthropic.com>
1 parent 72bcd77 commit 58be55e

3 files changed

Lines changed: 394 additions & 1 deletion

File tree

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
---
2+
'@object-ui/fields': minor
3+
---
4+
5+
A `datetime` grid cell now honours the same authored `field.format` words a `date` cell already honoured (objectui#8853).
6+
7+
`DateCellRenderer` and `DateTimeCellRenderer` are neighbours reading ONE authored key
8+
and handing it to two formatters with different vocabularies: `formatDate` honours
9+
`'short'` and `'relative'`, `formatDateTime`'s `options.style` honours `'compact'`
10+
alone. So `format: 'relative'` painted the relative face on a `date` column and the
11+
verbose absolute face on a `datetime` one — no error, no warning, no fallback.
12+
Measured end to end through a real `ObjectGrid` column before anything was changed:
13+
one object, one row, one instant (`2026-09-11T09:30:00.000Z`, clock pinned to
14+
`2026-09-09T12:00:00.000Z`, `en-US`), `format: 'relative'` authored on both fields,
15+
the grid's own body cells read
16+
17+
["1Open", "Row One", "In 2 days", "Sep 11, 2026, 09:30 AM"]
18+
19+
with the `date` column honouring the key and the `datetime` column beside it dropping
20+
it. After this change the same run reads `["1Open", "Row One", "In 2 days", "In 2
21+
days"]`.
22+
23+
The cell now SELECTS a formatter instead of threading one, the way objectui#8352
24+
already ruled for `formatMeasureDate`'s datetime arm — the same defect class one
25+
surface over:
26+
27+
- `'relative'` resolves through `formatRelativeDate`, the same function the `date`
28+
cell reaches, so one calendar day reads the same phrase in either column.
29+
- `'short'` resolves to the dense face of this type, which for a `datetime` cell is
30+
the compact face it already paints — the time of day is kept, and it stays
31+
byte-identical to an unstyled cell.
32+
- every other string, `'compact'` and date patterns such as `'YYYY-MM-DD'` included,
33+
falls to the default face exactly as before.
34+
35+
**Behaviour change, spelled out.** Two authored spellings render differently than they
36+
did, and one of them loses a component:
37+
38+
- `format: 'relative'` on a `datetime` field: was the verbose absolute face
39+
(`Sep 11, 2026, 09:30 AM`), is now the relative phrase (`In 2 days`).
40+
- `format: 'short'` on a `datetime` field: was that same verbose face, is now the
41+
compact face (`9/11/2026 9:30 am`).
42+
- ⚠️ **Beyond the ±7-day window a `'relative'` datetime now shows no time of day.**
43+
`formatRelativeDate` falls back to an absolute DATE face out there, so
44+
`2026-09-20T09:30:00.000Z` renders `Sep 20` where it rendered
45+
`Sep 20, 2026, 09:30 AM` before. That window belongs to `formatRelativeDate` and is
46+
inherited rather than re-decided at the call site — re-deciding it would put a
47+
second copy of the convention in the renderer, which is objectui#4576. `'relative'`
48+
is day-granular by construction (it shows no time inside the window either), and
49+
any other fallback would make the two columns unequal again, which is the defect
50+
being closed.
51+
52+
Note what the delta is and is not: this renderer ignored both words outright before,
53+
so nothing was taken from a working feature — it starts honouring a request whose
54+
granularity is days. It reaches only a `datetime` field whose author actually wrote
55+
one of those two words. An unstyled `datetime` cell, an explicit `'compact'` one, an
56+
authored empty string, and any other spelling all render exactly as they did.
57+
58+
No published signature moved. `formatDateTime(value, options?)` is unchanged and
59+
still ignores `'relative'` and `'short'` in its `options.style` key — threading the
60+
authored word into it would have honoured `'compact'`, the one word the `date` cell
61+
does NOT honour, while still ignoring both words it does, which is the defect
62+
inverted rather than closed. No spelling that rendered before is refused now; adding
63+
a refusal would be a breaking narrowing of a published metadata surface.
64+
65+
`dueLike` is deliberately not threaded and is filed separately as objectui#8958: it
66+
is a different authored key whose affordance travels with red styling and a
67+
field-name heuristic, and acquiring a second key's behaviour while honouring the
68+
first would be an unruled change.
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectui#8853 — ONE authored `field.format`, one meaning, across the two
11+
* neighbouring date cell renderers.
12+
*
13+
* ## What was measured, before anything was changed
14+
*
15+
* Driven end to end through a REAL `ObjectGrid` column (not inferred from a
16+
* source read): one object with a `date` field and a `datetime` field, BOTH
17+
* authored `format: 'relative'`, one row, one instant
18+
* (`2026-09-11T09:30:00.000Z`), clock pinned to `2026-09-09T12:00:00.000Z`,
19+
* `en-US`. The grid's own body cells came back as
20+
*
21+
* ["1Open", "Row One", "In 2 days", "Sep 11, 2026, 09:30 AM"]
22+
* ^ date ^ datetime, same key, dropped
23+
*
24+
* and after the call-site mapping, as
25+
*
26+
* ["1Open", "Row One", "In 2 days", "In 2 days"]
27+
*
28+
* The defect's whole signature is a SILENT drop — the runtime accepts the key,
29+
* parses it, drops it, and renders something that still looks like a
30+
* legitimate date — so a green suite proves nothing by itself. Every case
31+
* below therefore asserts a rendered FACE, with controls that say what the
32+
* probe would have shown had it been fooled.
33+
*
34+
* ## The controls, and why each one is here
35+
*
36+
* - **Positive control** — the `date` cell, in the SAME run, on the SAME
37+
* instant. It already honoured `'relative'`, so it is what "honoured" looks
38+
* like; without it, a datetime assertion could be measuring a broken clock
39+
* or a dead locale channel rather than the vocabulary.
40+
* - **Negative control** — an OUT-OF-WINDOW value. `formatRelativeDate` falls
41+
* back to the absolute form beyond ±7 days, so out there `format:
42+
* 'relative'` and no format at all render IDENTICALLY on the fully working
43+
* `date` cell. That is the trap objectui#8352's card recorded: a probe built
44+
* on an out-of-window value reports "identical" on a working path and on a
45+
* broken one alike. The pin below asserts that collapse explicitly, so the
46+
* in-window choice everywhere else is a measured decision rather than luck.
47+
*
48+
* ## Directions
49+
*
50+
* Reverting the call-site mapping in `DateTimeCellRenderer` turns the repro,
51+
* the `'short'` face and the out-of-window datetime face RED, and leaves every
52+
* control and regression pin GREEN — the controls measure surfaces this change
53+
* does not touch, which is exactly why they can vouch for the run. Widening
54+
* `formatDateTime`'s published signature instead (the refused route) turns the
55+
* "published signature untouched" pins RED. Adding a refusal for a spelling
56+
* that renders today turns the regression pins RED.
57+
*/
58+
import { describe, it, expect, vi, afterEach } from 'vitest';
59+
import React from 'react';
60+
import { render, cleanup } from '@testing-library/react';
61+
import '@testing-library/jest-dom';
62+
import { I18nProvider, LocalizationProvider } from '@object-ui/i18n';
63+
import { DateCellRenderer, DateTimeCellRenderer, formatDateTime, formatDateTimeCompactParts } from '../index';
64+
65+
/** The pinned clock, and two values chosen on either side of the ±7-day window. */
66+
const CLOCK = '2026-09-09T12:00:00.000Z';
67+
/** +2 days: inside the window, so the relative face is a PHRASE and discriminates. */
68+
const IN_WINDOW = '2026-09-11T09:30:00.000Z';
69+
/** +11 days: outside it, where the relative face collapses onto the absolute one. */
70+
const OUT_OF_WINDOW = '2026-09-20T09:30:00.000Z';
71+
72+
/** `en-US` plus one NON-US locale, the same pairing the #7443 pins use. */
73+
const LOCALES = ['en-US', 'zh', 'de-DE'];
74+
75+
/**
76+
* Same session harness as `datetime-compact-style-7443.test.tsx`: the tag is
77+
* set as the TENANT locale because that is the channel objectui#4468 made
78+
* every date branch read, and `persistLanguage={false}` keeps each case on its
79+
* own language instead of inheriting the previous one's.
80+
*/
81+
function renderSession(locale: string, node: React.ReactElement) {
82+
return render(
83+
<I18nProvider
84+
config={{ defaultLanguage: 'en', detectBrowserLanguage: false }}
85+
persistLanguage={false}
86+
>
87+
<LocalizationProvider value={{ locale }}>{node}</LocalizationProvider>
88+
</I18nProvider>,
89+
);
90+
}
91+
92+
/** Render one cell at the pinned clock and return its text, nothing retained. */
93+
function faceOf(node: React.ReactElement, locale = 'en-US'): string {
94+
vi.useFakeTimers();
95+
vi.setSystemTime(new Date(CLOCK));
96+
try {
97+
const { container } = renderSession(locale, node);
98+
return container.textContent ?? '';
99+
} finally {
100+
cleanup();
101+
vi.useRealTimers();
102+
}
103+
}
104+
105+
const dateCell = (value: string, format?: string) => (
106+
<DateCellRenderer
107+
value={value}
108+
field={{ type: 'date', name: 'follow_up_on', ...(format === undefined ? {} : { format }) } as any}
109+
/>
110+
);
111+
112+
const dateTimeCell = (value: string, format?: string) => (
113+
<DateTimeCellRenderer
114+
value={value}
115+
field={{ type: 'datetime', name: 'follow_up_at', ...(format === undefined ? {} : { format }) } as any}
116+
/>
117+
);
118+
119+
/** The compact face as the cell paints it — two spans, so no separating space. */
120+
function compactCellText(value: string, locale: string): string {
121+
const parts = formatDateTimeCompactParts(value, { locale })!;
122+
return `${parts.date}${parts.time}`;
123+
}
124+
125+
afterEach(() => {
126+
cleanup();
127+
vi.useRealTimers();
128+
});
129+
130+
describe('THE REPRO — `format: relative` means the same thing in both cells (#8853)', () => {
131+
it('a datetime cell honours `relative`, and renders the phrase its date sibling renders', () => {
132+
const dateFace = faceOf(dateCell(IN_WINDOW, 'relative'));
133+
const dateTimeFace = faceOf(dateTimeCell(IN_WINDOW, 'relative'));
134+
135+
// The anchor, so a redesign that moved both sides together cannot pass.
136+
expect(dateFace).toBe('In 2 days');
137+
expect(dateTimeFace).toBe('In 2 days');
138+
// The claim itself: ONE authored key, ONE meaning, across the two cells.
139+
expect(dateTimeFace).toBe(dateFace);
140+
// And what it used to be, named — not merely "something else".
141+
expect(dateTimeFace).not.toBe('Sep 11, 2026, 09:30 AM');
142+
});
143+
144+
it.each(LOCALES)('%s — the two cells agree in a non-US locale too', (locale) => {
145+
expect(faceOf(dateTimeCell(IN_WINDOW, 'relative'), locale)).toBe(
146+
faceOf(dateCell(IN_WINDOW, 'relative'), locale),
147+
);
148+
});
149+
150+
it('`short` reaches the dense face of THIS type — the time of day is kept', () => {
151+
// objectui#8352 mapped `'short'` to the arm's own narrow face: `formatDate`'s
152+
// `'short'` for a date, the compact datetime face for a datetime. Here that
153+
// face is the one this cell already paints, so a measure tile and a grid
154+
// cell showing the same instant agree.
155+
expect(faceOf(dateTimeCell(IN_WINDOW, 'short'))).toBe(compactCellText(IN_WINDOW, 'en-US'));
156+
expect(faceOf(dateTimeCell(IN_WINDOW, 'short'))).toBe('9/11/20269:30 am');
157+
expect(faceOf(dateTimeCell(IN_WINDOW, 'short'))).not.toBe('Sep 11, 2026, 09:30 AM');
158+
});
159+
160+
it('the relative face is painted in ONE span, not the compact two', () => {
161+
vi.useFakeTimers();
162+
vi.setSystemTime(new Date(CLOCK));
163+
const { container } = renderSession('en-US', dateTimeCell(IN_WINDOW, 'relative'));
164+
expect(container.querySelectorAll('span > span')).toHaveLength(0);
165+
vi.useRealTimers();
166+
});
167+
});
168+
169+
describe('POSITIVE CONTROL — the date cell, unchanged, in the same run', () => {
170+
it('still honours `relative` and `short`, and still defaults to relative', () => {
171+
expect(faceOf(dateCell(IN_WINDOW, 'relative'))).toBe('In 2 days');
172+
expect(faceOf(dateCell(IN_WINDOW, 'short'))).toBe("Sep 11, '26");
173+
expect(faceOf(dateCell(IN_WINDOW, undefined))).toBe('In 2 days');
174+
});
175+
176+
it("`compact` stays inert on a date cell — the vocabulary was widened on ONE side", () => {
177+
// The reverse asymmetry the card names. Honouring it here would be a
178+
// second, unruled change: `'compact'` falls to the date arm's default
179+
// face, exactly as objectui#8352 declared for the measure path.
180+
expect(faceOf(dateCell(IN_WINDOW, 'compact'))).toBe('Sep 11');
181+
expect(faceOf(dateCell(IN_WINDOW, 'compact'))).toBe(faceOf(dateCell(IN_WINDOW, 'no-such-face')));
182+
});
183+
});
184+
185+
describe('NEGATIVE CONTROL — out of the ±7-day window the probe cannot discriminate', () => {
186+
it('on the WORKING date cell, `relative` and no format render identically out there', () => {
187+
// This is the trap. Had the cases above used this value, "identical"
188+
// would have been the answer on a working path and a broken one alike.
189+
const authored = faceOf(dateCell(OUT_OF_WINDOW, 'relative'));
190+
const absent = faceOf(dateCell(OUT_OF_WINDOW, undefined));
191+
expect(authored).toBe(absent);
192+
expect(authored).toBe('Sep 20');
193+
});
194+
195+
it('the in-window value DOES discriminate on that same cell — the probe has teeth', () => {
196+
expect(faceOf(dateCell(IN_WINDOW, 'relative'))).not.toBe('Sep 11');
197+
});
198+
199+
it('an out-of-window datetime renders the absolute DATE face — the declared cost', () => {
200+
// ⚠️ The time of day is gone. `formatRelativeDate`'s out-of-window branch
201+
// renders through `toLocaleDateString` and has no time component to add;
202+
// that window belongs to that function and is INHERITED here rather than
203+
// re-decided at this call site (objectui#4576). `'relative'` is day-granular
204+
// by construction, so its degraded form is a day face — and nothing was
205+
// taken from a working feature, since this cell ignored the word outright
206+
// before. Pinned so the cost cannot drift silently in either direction.
207+
expect(faceOf(dateTimeCell(OUT_OF_WINDOW, 'relative'))).toBe('Sep 20');
208+
expect(faceOf(dateTimeCell(OUT_OF_WINDOW, 'relative'))).toBe(
209+
faceOf(dateCell(OUT_OF_WINDOW, 'relative')),
210+
);
211+
});
212+
});
213+
214+
describe('REGRESSION — every datetime cell that rendered before renders the same', () => {
215+
it.each(LOCALES)('%s — no format is still the compact face', (locale) => {
216+
expect(faceOf(dateTimeCell(IN_WINDOW, undefined), locale)).toBe(
217+
compactCellText(IN_WINDOW, locale),
218+
);
219+
});
220+
221+
it('an explicit `compact` is still the compact face', () => {
222+
expect(faceOf(dateTimeCell(IN_WINDOW, 'compact'))).toBe(compactCellText(IN_WINDOW, 'en-US'));
223+
});
224+
225+
it('an authored EMPTY string is still the compact face, not the verbose one', () => {
226+
// `||`, not `??` — the spelling objectui#7443 chose so an authored empty
227+
// string stays on the compact face. The mapping above must not disturb it.
228+
expect(faceOf(dateTimeCell(IN_WINDOW, ''))).toBe(compactCellText(IN_WINDOW, 'en-US'));
229+
});
230+
231+
it('an unrecognised spelling still falls to the verbose default — no refusal was added', () => {
232+
// ⛔ Rejecting a currently-accepted spelling would be a breaking narrowing
233+
// of a published metadata surface. It renders, as it always did.
234+
expect(faceOf(dateTimeCell(IN_WINDOW, 'default'))).toBe('Sep 11, 2026, 09:30 AM');
235+
expect(faceOf(dateTimeCell(IN_WINDOW, 'YYYY-MM-DD'))).toBe('Sep 11, 2026, 09:30 AM');
236+
});
237+
238+
it('an absent field is still tolerated, on the compact face', () => {
239+
expect(faceOf(<DateTimeCellRenderer value={IN_WINDOW} field={undefined as any} />)).toBe(
240+
compactCellText(IN_WINDOW, 'en-US'),
241+
);
242+
});
243+
});
244+
245+
describe('the PUBLISHED signature was not moved — the mapping lives at the call site', () => {
246+
it('formatDateTime still declares exactly two parameters', () => {
247+
// `(value, options?)` is 2; the refused `(value, style?, options?)` is 3.
248+
expect(formatDateTime.length).toBe(2);
249+
});
250+
251+
it("formatDateTime's own vocabulary is untouched: it still ignores `relative` and `short`", () => {
252+
// The function was NOT widened. It answers the verbose face for both
253+
// words, and the CELL is what maps them — which is why this file and the
254+
// function's own pins can disagree about `'relative'` without either being
255+
// wrong.
256+
const verbose = formatDateTime(IN_WINDOW, { locale: 'en-US' });
257+
expect(formatDateTime(IN_WINDOW, { locale: 'en-US', style: 'relative' })).toBe(verbose);
258+
expect(formatDateTime(IN_WINDOW, { locale: 'en-US', style: 'short' })).toBe(verbose);
259+
expect(verbose).toBe('Sep 11, 2026, 09:30 AM');
260+
});
261+
});

0 commit comments

Comments
 (0)