Skip to content

Commit 544aca2

Browse files
fix(core,fields): refuse an impossible calendar day in date-times and in the sub-grid date columns (objectui#10301) (#10459)
Fixes #10301 Clause-②: no The shared date path now refuses a **date-time** written on a calendar day that does not exist (`2026-02-30T10:00:00Z`). It used to render as `Mar 2, 2026, 10:00 AM`. The sub-grid's `date` and `datetime` columns now reach that refusal too. This is the date-time half of the objectui#10026 ruling (`5749269396`, direction A: "a silently wrong day is the failure North Star rule 4 forbids"). Triage graded it an inherited branch (`5819858578`), so no new decision was needed. Implemented by the `os-dev` agent that the `domain:ui#4` seat dispatched, session `https://claude.ai/code/session_01BP8CMtACxTdLjqR6rhd33C`. ## What changed - **`toDisplayDate` (`@object-ui/core`) gains one arm.** A string matching `ISO_DATETIME_RE` whose first ten characters fail `isRealCalendarDate` becomes an Invalid Date, the same answer objectui#10026 gave the date-only spelling. The regex is spelled exactly like `dataset-format.ts`'s `ISO_DATETIME_RE`, for the reason the file already gives for `ISO_DATE_ONLY_RE`: one spelling per convention. `isRealCalendarDate` is still called only from `date-display.ts` and the filter builder. No consumer has a second realness check. - **`GridField`'s `temporalText`:** - The `date` arm no longer builds `new Date(y, m - 1, d)` by hand. It passes the verbatim `YYYY-MM-DD` to `toDisplayDate`. - The `datetime` arm takes its validity from `toDisplayDate`. It still formats the engine's `new Date(raw)`, the same split `DateTimeCellRenderer` uses. - A refused value shows the raw stored string, which is this surface's existing face for a value it cannot parse (objectui#3569). - **`DateTimeCellRenderer` needed no code change.** Since objectui#10299 its validity guard already reads `toDisplayDate`, so the new arm reaches it through that guard. Only its comment changed: it now names the date-time case, and it warns that the guard is the only thing standing between this cell and a rolled `Date`. Ablation 2 below proves that. - One changeset, `.changeset/10301-refuse-impossible-datetime-day.md` (`@object-ui/core` patch, `@object-ui/fields` patch). ### Two files outside the claimed surface (comment and prose only) Both edits correct a sentence that this change makes false. - `packages/core/src/utils/dataset-format.ts`: the `ISO_DATETIME_RE` docblock said `2026-02-30T09:30` "renders rolled over" and that refusing it was an open question. The measure's datetime arm now renders the refusal dash. A new pin covers this (`a dataset measure agrees with the list cell on the refusal`). - `.changeset/10026-refuse-impossible-calendar-day.md` (pending): its last sentence said "A value that carries a time … still rolls". It now points at this entry, the same way PR objectui#10299 corrected the objectui#10110 changeset. Only the prose changed; the front matter is untouched. `check-changeset-overwrite` reports this edit as its case 2, "correcting a declaration on purpose". ## Mechanism assumptions (from the dispatch), measured - **A1: timezone edge. Holds.** - The arm reads `value.slice(0, 10)` of the stored string, and no surface converts a string before the check: - the `datetime` cell passes `coerceToSafeValue(value)`, which leaves a string a string; - the sub-grid `date` arm uses `toDateInputValue`, which keeps a string's leading day verbatim; - the sub-grid `datetime` arm passes the raw string. - Only a `Date` or number input is converted, and neither can carry a nonexistent day. - A conversion before the check could only hide a refusal, never cause a false one: a parsed `Date` always names a real day. So the refusal pins also cover the direction A1 worries about. - Pinned: `2026-02-28T23:30:00-05:00` (March 1st 04:30 in UTC) is **not** refused, at three places: `toDisplayDate` (same instant as `Date.parse`), the `datetime` cell, and the sub-grid `datetime` column. - **A2: one parse step. Holds, and one premise is stale.** The card says `DateTimeCellRenderer` "hands its formatters an already-parsed `Date`". That is true, but its guard already reads `toDisplayDate`, so no rerouting was needed there. `temporalText` now reaches `toDisplayDate` on both arms. No consumer calls `isRealCalendarDate` a second time. - **A3: other date-time display paths in `@object-ui/fields` / `@object-ui/core`.** Grep for `new Date(` and for `toLocale*` / `Date.parse`, outside tests: - **Reach the refusal with no change:** - `DateField` and `FormulaField` (validity through `toDisplayDate`); - `DateTimeField` readonly (`formatDateTime` on the raw value); - `lookupColumnDisplay`'s `$date` fallback (`formatDate`); - `DateCellRenderer`, whose `title` uses `new Date(safe)` only after the guard; - the dataset measure's datetime arm. - **Not display paths:** `FormulaFunctions`, `validation-engine`, `date-macros`, `dashboard-filters`. - **Still rolls:** the editor adapter `toDateTimeInputValue` in `nativeDateValue.ts`. Measured: the editable `DateTimeField` shows `2026-03-02T10:00` for a stored `2026-02-30T10:00:00Z`, and so does the sub-grid's editable `datetime` cell. That is out of scope and goes to the report as a finding. - **A4: the objectui#10026 pins stay green.** `date-display.impossibleDay-10026.test.ts` and `date-carriers.impossibleDay-10026.test.tsx` are unchanged by this diff. They are green in every run below, including the ablation that deletes the new arm. ## Tests New pins: - `packages/core/src/utils/__tests__/date-display.impossibleDateTime-10301.test.ts`: `toDisplayDate`, the four formatters and the dataset measure, with the offset control and the positive control. - Positive control: `2026-02-28T10:00:00Z` renders `Feb 28, 2026, 10:00 AM`, and its compact parts are `2/28/2026` and `10:00 am`. - `packages/fields/src/__tests__/datetime-carriers.impossibleDay-10301.test.tsx`: five faces, each compared byte for byte with its own `not-a-date` output. The faces are the `datetime` cell, the `date` cell, readonly `DateTimeField`, readonly `DateField` and `FormulaField` with `return_type: date`. The file also covers the affordance, the offset control and the positive control. - `packages/fields/src/widgets/GridField.impossibleDay-10301.test.tsx`: the read-only table and list mode, `date` and `datetime` columns, plus controls. - An impossible day shows the stored string. - Two further `date`-column side effects of reading the shared step, measured on base sources before they were pinned: `2026-13-45` used to render `Feb 14, 2027` and now shows as stored; `0026-08-01` used to render `Aug 1, 1926` and now renders year 26, as the `date` cell does. Runs. All go through the shared verify lock and run in UTC, which `vitest.config.mts` pins. | run | tree | result | | --- | --- | --- | | premise | base sources, with a pre-commit draft of the new pins (one sub-grid face, 43 cases) | 29 failed, 14 passed (the controls). Rendered `Mar 2, 2026, 10:00 AM`, cell `3/2/2026 10:00 am`, sub-grid `Mar 2` | | fix | the same pre-commit draft of the pins, on the fixed sources | 3 files, 43 passed. Not re-run on the committed pin files, which hold 51 cases at the first commit (the second sub-grid face) and 55 at head (the out-of-range month and year-below-100 cases). The ablation rows below were run on the committed tree | | ablation 1: new arm deleted from `toDisplayDate` | committed tree | 27 failed: 5 core, 18 fields faces, 4 sub-grid `datetime`. Both objectui#10026 files green, and the sub-grid `date` cases green | | ablation 2: `datetime` cell guard back to `new Date(safe)` | committed tree | 6 failed, exactly the `datetime` cell cases. Core pins green | | ablation 3: sub-grid `date` arm back to `new Date(y, m - 1, d)` | committed tree | 12 failed, exactly the `date`-column cases in both faces | | ablation 4: sub-grid `datetime` guard back to `new Date(raw)` | committed tree | 4 failed, exactly the `datetime`-column cases | | union of 57 suites naming `toDisplayDate` / `isRealCalendarDate` / `DateTimeCellRenderer` / `GridField` / `temporalText`, plus the `date-display` / `dataset-format` suites | HEAD `436888655` | 53 files passed, 4 skipped; 1171 tests passed, 40 skipped | - Each ablation went through `ablation-replace.mjs`. The tool checked on disk that the anchor count went 1 to 0 and the blob changed, and after each run it restored the file to its HEAD blob with an empty `git diff HEAD`. - No build was involved: vitest aliases `@object-ui/core` to `packages/core/src`. - The 4 skipped files in the union run are the date-only zone pins. They skip in a normal run by design, and `scripts/__tests__/date-only-zone-pins-10183.test.ts`, which is in the union, drives them in a forked child, where they passed. ## Gates - **Type-check.** First the closure build, `pnpm --filter '@object-ui/fields^...' build` (11 projects, exit 0). Then `type-check` for `@object-ui/core` and `@object-ui/fields` (`tsc --noEmit && tsc -p tsconfig.test.json`), exit 0 for both. `--listFilesOnly` confirms that all three new test files are in the test programs. - **Lint, narrowed to the touched files.** - Population: each package's `lint` script is `eslint .` under the root `eslint.config.js`. The touched files are 3 in core and 4 in fields, a count taken from `--format json`. - Result: 0 errors. The warnings are the existing debt; CI sets no `--max-warnings`. - Why the narrowing excludes nothing: the config sets neither `parserOptions.project` nor `projectService`, so linting is not type-aware. The custom rules in `eslint-rules/` read no other file. So this diff cannot change the verdict on any untouched file. - **Changeset and text gates:** - `check-changeset-presence`, `check-changeset-no-major`, `check-changeset-fixed`, `check:pending-changeset-literals`, `check:control-bytes` and `check:test-path-roots`: exit 0. - `check:new-line-citations`: 0 new citations. - `check-changeset-overwrite`: report-only; it reports the objectui#10026 correction above. - `check:changeset-claims`: report-only. I read all six paragraphs it flagged (objectui#10183, #6625, #6694, #6837, #7166, #7178), and each is still true. I also searched the pending changesets for the changed symbols, and none makes a claim this change falsifies other than the objectui#10026 one. - **Governed surface.** `check-governed-queue-guard.mjs --test` over all 9 paths: NOT GOVERNED. - **Source-text anchor pins.** No test quotes a line this diff changes. ## Acceptance notes - **Boundary:** - A lowercase `t` separator (`2026-02-30t10:00:00z`) parses, but `ISO_DATETIME_RE` does not match it, so it still rolls. The regex is kept identical to the measure's. The measure's arm does not match it either, so it falls through to the raw string. - `2026-02-30T10`, with the hour only, is already rejected by the engine's own parse. - **Option C**, refusing at the producing boundary in objectstack: carrier: none. - `.changeset/dataset-format-8263-impossible-date-prose.md` names a test title that PR objectui#10299 renamed. It went stale with that PR, not this one. It has empty front matter and declares no release. I left it untouched. --- _Generated by [Claude Code](https://claude.ai/code/session_01BP8CMtACxTdLjqR6rhd33C)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a12f533 commit 544aca2

9 files changed

Lines changed: 471 additions & 29 deletions

‎.changeset/10026-refuse-impossible-calendar-day.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,4 @@ A date-only value naming a day its month does not have — `2026-02-30`, `2024-0
1313
- **A dataset measure agrees with the list cell on the refusal.** A date-only measure value of `2024-02-30` renders `—`, the same as the `date` cell beside it. The measure takes that answer from the shared path and makes no judgement of its own.
1414
- **`@object-ui/fields`:** the `date` and `datetime` cells, the readonly `DateField` and a date-returning `FormulaField` show the shared "No value" placeholder for such a day, exactly as for an unparsable value, instead of a bare dash.
1515

16-
A value that carries a time (`2026-02-30T10:00:00Z`) still rolls. This change covers date-only values only.
16+
This change covers date-only values only. (A value that carries a time, such as `2026-02-30T10:00:00Z`, is refused by the same path too — objectui#10301, a separate entry in this release.)
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/core': patch
3+
'@object-ui/fields': patch
4+
---
5+
6+
fix(core,fields): a date-time written on a calendar day that does not exist is refused, not rolled into another day
7+
8+
`2026-02-30T10:00:00Z` names a day February does not have. The engine parses it anyway and rolls the surplus forward, so it rendered as `Mar 2, 2026, 10:00 AM`: a real day nobody wrote, with nothing to say the stored value was wrong. objectui#10026 made the shared date path refuse such a day on a date-only value; this extends the same refusal to a value that carries a time.
9+
10+
- **`toDisplayDate` returns an Invalid Date for an ISO date-time whose leading `YYYY-MM-DD` is not a real calendar day** (the `isRealCalendarDate` judgement). The day is read from the stored string as written, so a real day written with an offset — `2026-02-28T23:30:00-05:00`, which is March 1st in UTC — is not refused, and a real date-time renders exactly as before. `formatDateTime`, `formatDate` and `formatRelativeDate` render `—` for such a value, `formatDateTimeCompactParts` returns `null`, and a dataset measure over it renders the same `—`: each function's existing answer for an unparsable value.
11+
- **`@object-ui/fields`:** the `datetime` and `date` cells, the readonly `date` widget and a date-returning `FormulaField` show the shared "No value" placeholder for such a value, and the readonly `datetime` widget shows `—`, exactly as each does for an unparsable value.
12+
- **The sub-grid (`GridField` / `LineItemsField`) `date` and `datetime` columns read the shared parse step instead of building their own `Date`.** A value that step refuses shows the stored string, this surface's existing face for a value it cannot parse: an impossible day in either column (`2026-02-30`, which the `date` column still rolled into March after objectui#10026), and an out-of-range month (`2026-13-45`, which the `date` column rendered as `Feb 14, 2027`). A `date` value with a year below 100 (`0026-08-01`) now renders that year, as the `date` cell does, instead of 1926.
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
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#10301 — the shared date path refuses a DATE-TIME spelled on a
11+
* calendar day that does not exist, the way objectui#10026 made it refuse a
12+
* date-only one.
13+
*
14+
* ── The defect ───────────────────────────────────────────────────────────
15+
* objectui#10026 taught `toDisplayDate` to hand back an Invalid Date for
16+
* `2026-02-30`, but only on the date-only shape. The engine parses
17+
* `2026-02-30T10:00:00Z` just as happily and rolls it into March 2nd, so
18+
* `formatDateTime` rendered `Mar 2, 2026, 10:00 AM`: a real day nobody wrote,
19+
* with nothing to say so. The maintainer's ruling on objectui#10026 ("a
20+
* silently wrong day is the failure North Star rule 4 forbids") covers it;
21+
* triage graded this card an inherited branch of that ruling.
22+
*
23+
* ── What "the day" is ─────────────────────────────────────────────────────
24+
* The judgement reads the LEADING `YYYY-MM-DD` of the stored string, the day
25+
* the value was written on, never the day the parsed instant lands on in some
26+
* zone. A parsed `Date` always names a real day, so a check made after any
27+
* conversion could only ever miss a refusal. The offset case below is the
28+
* control on the other side: a real day written with an offset that moves
29+
* the instant into March in UTC must not be refused.
30+
*
31+
* ── Directions, predicted before the run ───────────────────────────────────
32+
* pre-card sources every refusal case reds with a rendered March
33+
* date; the controls stay green
34+
* the new arm deleted same as pre-card
35+
* The date-only refusal pins of objectui#10026 (`impossibleDay-10026` beside
36+
* this file) are untouched by this card and must stay green.
37+
*/
38+
39+
import { describe, expect, it } from 'vitest';
40+
41+
import { formatMeasure } from '../dataset-format.js';
42+
import {
43+
formatDate,
44+
formatDateTime,
45+
formatDateTimeCompactParts,
46+
formatRelativeDate,
47+
toDisplayDate,
48+
} from '../date-display.js';
49+
50+
const EN = 'en-US';
51+
52+
/**
53+
* Date-times whose day does not exist, in the spellings the engine accepts:
54+
* `Z`, an offset, fractional seconds, no zone, and a space separator.
55+
*/
56+
const IMPOSSIBLE_DATETIMES = [
57+
'2026-02-30T10:00:00Z',
58+
'2024-02-31T08:15:00.000Z',
59+
'2025-02-29T10:00',
60+
'2026-04-31T23:59:59+09:00',
61+
'2026-02-30 10:00',
62+
] as const;
63+
64+
/** A real day written with an offset: 2026-03-01T04:30Z, in UTC. */
65+
const REAL_DAY_WITH_OFFSET = '2026-02-28T23:30:00-05:00';
66+
67+
/** The card's positive control: a real date-time. */
68+
const REAL_DATETIME = '2026-02-28T10:00:00Z';
69+
70+
describe('premise: the engine does not refuse these date-times (objectui#10301)', () => {
71+
it('parses each one, so the refusal has to come from the shared path', () => {
72+
for (const v of IMPOSSIBLE_DATETIMES) {
73+
expect(Number.isNaN(Date.parse(v)), v).toBe(false);
74+
}
75+
});
76+
});
77+
78+
describe('toDisplayDate refuses a date-time written on a nonexistent day (objectui#10301)', () => {
79+
it('hands back an Invalid Date, the answer the engine gives an unparsable value', () => {
80+
for (const v of IMPOSSIBLE_DATETIMES) {
81+
expect(Number.isNaN(toDisplayDate(v).getTime()), v).toBe(true);
82+
}
83+
});
84+
85+
it('control: a real date-time is the engine instant, unchanged', () => {
86+
expect(toDisplayDate(REAL_DATETIME).getTime()).toBe(Date.parse(REAL_DATETIME));
87+
expect(toDisplayDate('2024-02-29T10:00:00Z').getTime()).toBe(Date.parse('2024-02-29T10:00:00Z'));
88+
});
89+
90+
it('control: the day is read from the string, so an offset that moves the instant into March is not refused', () => {
91+
// In UTC this instant is March 1st, 04:30. The written day, February 28th,
92+
// is real, and the instant is kept exactly as the engine parsed it.
93+
expect(new Date(REAL_DAY_WITH_OFFSET).toISOString()).toBe('2026-03-01T04:30:00.000Z');
94+
expect(toDisplayDate(REAL_DAY_WITH_OFFSET).getTime()).toBe(Date.parse(REAL_DAY_WITH_OFFSET));
95+
});
96+
});
97+
98+
describe('every function on the shared path answers with its own unparsable face (objectui#10301)', () => {
99+
it('formatDateTime, on both of its faces', () => {
100+
for (const v of IMPOSSIBLE_DATETIMES) {
101+
expect(formatDateTime(v, { locale: EN }), v).toBe(formatDateTime('not-a-date', { locale: EN }));
102+
expect(formatDateTime(v, { locale: EN, style: 'compact' }), v).toBe(
103+
formatDateTime('not-a-date', { locale: EN, style: 'compact' }),
104+
);
105+
}
106+
});
107+
108+
it('formatDateTimeCompactParts answers null', () => {
109+
for (const v of IMPOSSIBLE_DATETIMES) {
110+
expect(formatDateTimeCompactParts(v, { locale: EN }), v).toBeNull();
111+
}
112+
});
113+
114+
it('formatDate and formatRelativeDate', () => {
115+
for (const v of IMPOSSIBLE_DATETIMES) {
116+
expect(formatDate(v, undefined, { locale: EN }), v).toBe(formatDate('not-a-date', undefined, { locale: EN }));
117+
expect(formatRelativeDate(v, { locale: EN }), v).toBe(formatRelativeDate('not-a-date', { locale: EN }));
118+
}
119+
});
120+
121+
it('a dataset measure agrees with the list cell on the refusal', () => {
122+
// The measure's datetime arm hands the string to `formatDateTime`, so it
123+
// inherits the refusal instead of judging the day itself.
124+
for (const v of IMPOSSIBLE_DATETIMES) {
125+
expect(formatMeasure(v, undefined, undefined, undefined, EN), v).toBe(formatDateTime('not-a-date', { locale: EN }));
126+
}
127+
});
128+
129+
it('control: a real date-time displays as before', () => {
130+
// This suite runs in UTC (`vitest.config.mts` pins it).
131+
expect(formatDateTime(REAL_DATETIME, { locale: EN })).toBe('Feb 28, 2026, 10:00 AM');
132+
expect(formatDateTimeCompactParts(REAL_DATETIME, { locale: EN })).toEqual({ date: '2/28/2026', time: '10:00 am' });
133+
});
134+
135+
it('control: a real day written with an offset renders its instant, not the marker', () => {
136+
expect(formatDateTime(REAL_DAY_WITH_OFFSET, { locale: EN })).toBe('Mar 1, 2026, 04:30 AM');
137+
});
138+
});

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -153,10 +153,10 @@ const ISO_DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;
153153
* pattern admits — such a value falls through untouched.
154154
*
155155
* ⛔ A DAY that overflows its month does NOT fall through. `2026-02-30T09:30`
156-
* parses, reaches the shared date path and renders rolled over: that path
157-
* refuses a nonexistent day only on a DATE-ONLY value (objectui#10026), and
158-
* whether it should refuse one spelled with a time is open on that card. See
159-
* the note inside {@link formatMeasureDate}.
156+
* parses and reaches the shared date path, which refuses it with the dash
157+
* `formatDateTime` renders for any unparsable value (objectui#10301, the
158+
* date-time half of objectui#10026). The refusal is that path's, not this
159+
* pattern's. See the note inside {@link formatMeasureDate}.
160160
*/
161161
const ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/;
162162

‎packages/core/src/utils/date-display.ts‎

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,18 @@ export interface DateDisplayOptions {
105105
*/
106106
const ISO_DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;
107107

108+
/**
109+
* An ISO date carrying a time part — `2026-08-01T09:30:00Z`, the same with a
110+
* space separator, with or without seconds or an offset. Matched only as far
111+
* as `HH:mm`, so its first ten characters are the `YYYY-MM-DD` the value was
112+
* written on.
113+
*
114+
* Spelled exactly as `dataset-format.ts`'s `ISO_DATETIME_RE`, which sniffs the
115+
* same shape to route a measure to its datetime arm, for the same reason as
116+
* {@link ISO_DATE_ONLY_RE} above: one convention, one spelling.
117+
*/
118+
const ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/;
119+
108120
/**
109121
* A date that exists — the pattern alone would accept `2024-02-31`.
110122
*
@@ -177,9 +189,10 @@ export function isRealCalendarDate(dateOnly: string): boolean {
177189
* parse already lands on the right day — the co-located suite drives both.
178190
* `GridField`'s sub-grid cell already parsed its own date-only values this
179191
* way before reaching `formatDate`; this is that treatment, moved to the one
180-
* place every caller passes through.
192+
* place every caller passes through (the sub-grid reads it from here since
193+
* objectui#10301).
181194
*
182-
* A value with a time part is untouched, in both spellings: it HAS an
195+
* A value with a time part is not rebuilt, in both spellings: it HAS an
183196
* instant, and rendering an instant in the viewer's zone is the whole point
184197
* of a `datetime`. The regex is what separates them, so the split is the
185198
* VALUE's shape and never the field's declared type, which this module (pure,
@@ -204,10 +217,15 @@ export function isRealCalendarDate(dateOnly: string): boolean {
204217
* objectui#4576 recorded. {@link isRealCalendarDate} is the one judgement,
205218
* shared with the filter builder's authoring boundary.
206219
*
207-
* ⚠️ A value that carries a TIME (`2026-02-30T10:00:00Z`) is not judged here
208-
* and still rolls. The ruling names date-only values; whether an instant
209-
* spelled on a nonexistent day is refused too is an open question on that
210-
* card, not a decision this step makes by itself.
220+
* A value that carries a TIME is refused the same way when the day it is
221+
* written on does not exist (objectui#10301): `2026-02-30T10:00:00Z` parses
222+
* too, and rendered `Mar 2, 2026, 10:00 AM` on every datetime face. Triage
223+
* graded that an inherited branch of the same ruling. The judgement reads the
224+
* value's leading `YYYY-MM-DD` AS WRITTEN, never the day its instant lands on
225+
* in some zone: a parsed `Date` always names a real day, so a check made after
226+
* any conversion could only miss the refusal, and a real day written with an
227+
* offset (`2026-02-28T23:30:00-05:00`, March 1st in UTC) is kept. A real
228+
* date-time is otherwise untouched — no rebuild, the engine's instant.
211229
*
212230
* ## Why it is exported (objectui#10183)
213231
*
@@ -227,6 +245,11 @@ export function isRealCalendarDate(dateOnly: string): boolean {
227245
*/
228246
export function toDisplayDate(value: string | Date | number): Date {
229247
const parsed = value instanceof Date ? value : new Date(value as any);
248+
// The engine rolls a date-time written on a nonexistent day forward too;
249+
// judge the day as written and refuse it (objectui#10301).
250+
if (typeof value === 'string' && ISO_DATETIME_RE.test(value) && !isRealCalendarDate(value.slice(0, 10))) {
251+
return new Date(NaN);
252+
}
230253
if (typeof value !== 'string' || !ISO_DATE_ONLY_RE.test(value) || isNaN(parsed.getTime())) {
231254
return parsed;
232255
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
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#10301 — a date-time written on a calendar day that does not exist
11+
* reaches the field faces as the refusal, not as a rolled March date.
12+
*
13+
* ── Why the `datetime` cell needs no code of its own ──────────────────────
14+
* `DateTimeCellRenderer` hands its formatters the engine's `new Date(value)`,
15+
* a `Date` no parse step can refuse, so on its own it would paint
16+
* `2026-02-30T10:00:00Z` as `3/2/2026 10:00 am`. What stops that is its
17+
* validity guard, which reads the shared parse step `toDisplayDate` (since
18+
* objectui#10026) and so returns `EmptyValue` before any formatter runs. This
19+
* card adds the date-time arm to that step; the cases below measure that it
20+
* reaches the cell through the guard, with no second realness check in the
21+
* renderer.
22+
*
23+
* ── Directions, predicted before the run ───────────────────────────────────
24+
* pre-card sources RED — a rolled compact face (`3/2/2026`)
25+
* the new arm deleted from the step RED — same as pre-card
26+
* the cell's guard back to `new Date(safe)`
27+
* RED — the rolled face again, although
28+
* the step refuses
29+
* controls green in all three
30+
*/
31+
32+
import React from 'react';
33+
import { afterEach, describe, expect, it } from 'vitest';
34+
import { cleanup, render } from '@testing-library/react';
35+
import '@testing-library/jest-dom';
36+
import { I18nProvider, LocalizationProvider } from '@object-ui/i18n';
37+
import { formatDateTimeCompactParts } from '@object-ui/core';
38+
import { getCellRenderer, resolveCellRendererType } from '../index';
39+
import { DateField } from '../widgets/DateField';
40+
import { DateTimeField } from '../widgets/DateTimeField';
41+
import { FormulaField } from '../widgets/FormulaField';
42+
43+
afterEach(() => cleanup());
44+
45+
const IMPOSSIBLE_DATETIMES = ['2026-02-30T10:00:00Z', '2024-02-31T08:15:00.000Z', '2025-02-29T10:00'] as const;
46+
47+
/** A real day written with an offset: 2026-03-01T04:30Z, in UTC. */
48+
const REAL_DAY_WITH_OFFSET = '2026-02-28T23:30:00-05:00';
49+
50+
/** The card's positive control. */
51+
const REAL_DATETIME = '2026-02-28T10:00:00Z';
52+
53+
function session(node: React.ReactNode) {
54+
return render(
55+
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }} persistLanguage={false}>
56+
<LocalizationProvider value={{ locale: undefined }}>{node}</LocalizationProvider>
57+
</I18nProvider>,
58+
);
59+
}
60+
61+
function renderCell(type: string, value: unknown): HTMLElement {
62+
const Renderer = getCellRenderer(resolveCellRendererType({ type }) || type);
63+
return session(<Renderer value={value as any} field={{ type, name: type } as any} />).container;
64+
}
65+
66+
const affordanceIn = (root: HTMLElement) => root.querySelector<HTMLElement>('[data-slot="empty-value"]');
67+
68+
/** Faces a date-time value reaches, each compared with its own unparsable face. */
69+
const FACES: ReadonlyArray<readonly [string, (value: unknown) => HTMLElement]> = [
70+
['DateTimeCellRenderer (`datetime` cell)', (value) => renderCell('datetime', value)],
71+
['DateCellRenderer (`date` cell holding a date-time)', (value) => renderCell('date', value)],
72+
[
73+
'DateTimeField (readonly `datetime` widget)',
74+
(value) =>
75+
session(
76+
<DateTimeField value={value as string} onChange={() => {}} field={{ type: 'datetime', name: 'at' } as any} readonly />,
77+
).container,
78+
],
79+
[
80+
'DateField (readonly `date` widget holding a date-time)',
81+
(value) =>
82+
session(
83+
<DateField value={value as string} onChange={() => {}} field={{ type: 'date', name: 'when' } as any} readonly />,
84+
).container,
85+
],
86+
[
87+
'FormulaField (`return_type: date`, a date-time result)',
88+
(value) =>
89+
session(
90+
<FormulaField value={value as any} onChange={() => {}} field={{ type: 'formula', name: 'c', return_type: 'date' } as any} />,
91+
).container,
92+
],
93+
];
94+
95+
describe.each(FACES)('objectui#10301 — %s', (_name, renderFace) => {
96+
it.each(IMPOSSIBLE_DATETIMES)('%s renders exactly what an unparsable value renders', (value) => {
97+
const refused = renderFace(value).innerHTML;
98+
cleanup();
99+
expect(refused).toBe(renderFace('not-a-date').innerHTML);
100+
});
101+
102+
it('control: a real date-time still renders a formatted face', () => {
103+
const root = renderFace(REAL_DATETIME);
104+
cleanup();
105+
expect(root.innerHTML).not.toBe(renderFace('not-a-date').innerHTML);
106+
});
107+
});
108+
109+
describe('objectui#10301 — the `datetime` cell', () => {
110+
it.each(IMPOSSIBLE_DATETIMES)('%s draws the shared affordance, not a rolled date', (value) => {
111+
const root = renderCell('datetime', value);
112+
expect(affordanceIn(root)).not.toBeNull();
113+
// The day the engine rolls `2026-02-30` into, on the compact face.
114+
expect(root.textContent).not.toContain('3/2/2026');
115+
});
116+
117+
it('control: a real date-time displays as before', () => {
118+
// This suite runs in UTC (`vitest.config.mts` pins it).
119+
const root = renderCell('datetime', REAL_DATETIME);
120+
expect(affordanceIn(root)).toBeNull();
121+
expect(root.textContent).toBe('2/28/202610:00 am');
122+
});
123+
124+
it('control: a real day written with an offset renders its instant, not the affordance', () => {
125+
const root = renderCell('datetime', REAL_DAY_WITH_OFFSET);
126+
expect(affordanceIn(root)).toBeNull();
127+
const parts = formatDateTimeCompactParts(new Date(REAL_DAY_WITH_OFFSET), { locale: 'en' });
128+
expect(root.textContent).toBe(`${parts!.date}${parts!.time}`);
129+
});
130+
});

0 commit comments

Comments
 (0)