fix(plugin-dashboard,components,plugin-report): read stored date-only days through toDisplayDate (objectui#10844) - #10861
Conversation
…toDisplayDate (objectui#10844) The dashboard filter's custom range read its stored `yyyy-MM-dd` bounds with `new Date(...)`, and the `date-picker` renderer handed an authored ISO value to date-fns unparsed. Both parse a date-only string as UTC midnight, so west of UTC the calendar highlighted, opened on and labelled the day before the stored one. Both read sites now go through `toDisplayDate` (`@object-ui/core`). The date-picker routes only a real date-only day there; a Date, a date-time string and anything else pass unchanged, so what throws in the trigger's `format` is not widened. Each site gets a zone pin registered in the objectui#10183 driver, with a west case, an east control and a rig. Claude-Session: https://claude.ai/code/session_01MCg3z5cGrV51xEddpGcxEY Co-authored-by: Claude <noreply@anthropic.com>
… through toDisplayDate (objectui#10844) Found by the objectui#10844 census of date-only read sites. `formatValue` (the face `ReportViewer` gives an untyped ISO-looking value and an aggregated column) parsed with `new Date(value)` and read LOCAL getters, so a stored `2026-09-15` read `2026-09-14` west of UTC. The parse is now the shared step; a value it refuses renders as the raw stored string, the cell's existing unparseable face. Pinned under the objectui#10183 driver. Claude-Session: https://claude.ai/code/session_01MCg3z5cGrV51xEddpGcxEY Co-authored-by: Claude <noreply@anthropic.com>
…i#10844) Claude-Session: https://claude.ai/code/session_01MCg3z5cGrV51xEddpGcxEY Co-authored-by: Claude <noreply@anthropic.com>
…s nothing and does not throw (objectui#10844) The shared parse step refuses `2026-02-30` where the engine rolled it into March, so the refusal at this read site is pinned in the suite zone: the bound selects no day, a refused `from` opens on today, and a valid `from` beside a refused `to` renders. Claude-Session: https://claude.ai/code/session_01MCg3z5cGrV51xEddpGcxEY Co-authored-by: Claude <noreply@anthropic.com>
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
Contract reviewServed-tier: 126/126 Read: card objectui#10844 body and thread (triage ① Derived judgments1. DashboardFilterBar — RIGHT. At the head 2. date-picker 3. formatValue — RIGHT. 4. PINS registration — RIGHT. The driver diff adds exactly three 5. The census — RIGHT, with two notes. Verified at the head:
6. Prose and shape — RIGHT. Changeset frontmatter: ② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
Fixes #10844
Clause-②: no
Each date-only read goes through the existing
toDisplayDate. No declared key, export or accept set moves.What changed
This is the closure card for the date-only zone family. Three read sites parsed a stored date-only
YYYY-MM-DDstring with the engine's ownDateparse, which reads it as UTC midnight. So every viewer west of UTC saw the day before. Each site now reads the string throughtoDisplayDate(@object-ui/core, the objectui#10183 convention). Each site also has a zone pin registered in thePINSlist of the objectui#10183 driver,scripts/__tests__/date-only-zone-pins-10183.test.ts. The driver runs each pin in a forks child underAmerica/Los_Angeles(west) andAsia/Shanghai(the control). Each zone opens with a rig case, so a child whose zone did not move turns red.DashboardFilterBar,rangeFrom/rangeTo2026-09-15, then the calendar highlights the 14th. A range from 1 Oct opens on Septemberdate-pickerrenderer, an ISO stringvalue2024-01-15is labelled "January 14th, 2024" and the calendar selects the 14thformatValue, the report cell's date face (found by the census)2026-09-15reads2026-09-142026-09-15The file surface: the claim names the first two sites and "any further date-only read site the census finds". The third site,
packages/plugin-report/src/formatValue.ts, is one of those.ReportVieweruses this face for an untyped ISO-looking value and for an aggregated column, such as amaxover a date.The date-picker's read, and what an unparseable string does now
The decision is made at the renderer's read, in
toPickerDay, beforeformat,selectedanddefaultMonth. Only a date-only string that names a real day goes throughtoDisplayDate.isRealCalendarDatefrom@object-ui/coremakes that judgement. ADate, a date-time string and any other string reach date-fns unchanged.Measured with date-fns directly, in node under
TZ=UTCandTZ=America/Los_Angeles:format('not a date', 'PPP')throwsRangeError: Invalid time valuein both zones. After this change the trigger still throws on such a value, exactly as before, because the value passes through untouched.2024-02-30rolls toMarch 1st, 2024in UTC andFebruary 29th, 2024in LA. It still reaches the engine rather than being refused. Refusing it would handformatan Invalid Date and make the trigger throw where it rendered before. The pin asserts that it renders. That rolled face is recorded below and not changed here.Refusals that moved (the objectui#10026 ruling, on the shared step)
toDisplayDaterefuses a day that its month does not have.2026-02-30used to roll into March. It now selects no day. A refusedfromopens the calendar on today, as an unparseablefromalready did. A validfromnext to a refusedtorenders without throwing. All of this is pinned in the suite zone.Pins and ablation
The new pins are:
packages/plugin-dashboard/src/__tests__/DashboardFilterBar.dateOnlyZone-10844.test.tsx: west click round-trip, a range from 1 Oct, the east control, and the suite-zone refusal cases.packages/components/src/renderers/form/__tests__/date-picker.dateValueZone-10844.test.tsx: label, selected day and opening month for aDate, a date-only string and an instant, west and east.packages/plugin-report/src/__tests__/formatValue.dateOnlyZone-10844.test.ts: typed and sniffed, west and east.Each fix was committed first and then mutated with
ablation-replace.mjs, which asserts that the anchor hit once and that the blob changed. The restore was proven with blob equal to HEAD and an emptygit diff HEAD. In the vitest root config the pins resolve@object-ui/*tosrcthroughresolve.alias, so nodistis involved.DashboardFilterBar: bothtoDisplayDate(value.from/to)calls were changed back tonew Date(...). The driver went red on this pin only: 2 failed, 6 passed. The failing cases were the west click case (selected: ['2026-09-14']) and the 1 Oct case. The rig, fixture and east cases and the other pins stayed green, and the child exited 1. The blob was restored to716dea90092f.date-picker:const day = toPickerDay(value);was changed toconst day = value;. The west date-only case failed (label: 'January 14th, 2024'). The instant case and the east control stayed green. Restored tof896cd9b9a0a.formatValue:toDisplayDate(value)was changed tonew Date(value). The west case failed ('2026-09-14'). The instant case and the east control stayed green. Restored to90b2aebb6231.Acceptance notes
The census
Method, at base
4aebea0:git grep -n "new Date("over non-test, non-storypackages/**/*.ts(x)gave 348 lines, 262 of them with an argument. I also searched forDate.parse(and for the date-fns imports (format,parseISO,toDate). Each hit is below with its disposition.Fixed here. The three sites above.
Already reads through
toDisplayDateor follows its convention:utils/date-display.ts(the step itself);native-date-value.ts, wheretoDateInputValuepasses a leadingYYYY-MM-DDthrough verbatim.index.tsxdate cell (itstitlekeeps the engine instant on purpose, documented in place);DateField;FormulaField;GridField(its datetime arm keeps the instant);lookupColumnDisplay($dategoes toformatDate).DetailView; app-shellauditHistoryDisplay.calendar.tsx;data-table(throughformatDate).formatDate: plugin-gridObjectGrid, plugin-dashboardrecordFields, and the plugin-ganttObjectGantttooltip.Not date-only: audit, system and event instants.
Not a parse of a stored value:
Dateconstructor, inSafeExpressionParser.plugin-gantt/demo, and the sample events in calendar-view-renderer.Validity checks and orderings, not a face:
Date.parsevalidity guards: dashboard-filters, dataset-format, ImportWizard, importParsers, the ObjectGrid sniff, the activity feeds.Same class, not fixed here. Each of these shows a stored date-only day as the day before, west of UTC. This is read from the code and not measured in a browser.
start/end(new Date(startDate)), the calendar-view-rendererschema.dataevents, and the authoredcurrentDate(resolveAuthoredCurrentDate). CalendarView'sgetEventsForDatethen places a date-only event on the previous day's cell. This is not small. ObjectCalendar writes a moved or quick-created event back astoISOString()of thatDate, so a local-midnight read changes the instant it writes: east of UTC the ISO string names the previous day. The write path has to be decided together with the read.start/endand baselines, and the GanttViewstartDate/endDate/markers. This is not small: GanttView'stzShiftre-bases everyDateinto a configured zone, and the drag write-back istoISOString().dateBucketreadsstartOfDay(new Date(raw)), so west of UTC an item dated today lands in the Overdue bucket. The renderer'sformatDatereads local getters for its short and long faces but usestoISOString()for its default face, and the axis extent is a third read. This is not small: three reads across two files must agree, and fixing one alone leaves the timeline contradicting itself.formatTickreads a category of2026-09-01asAug 31/Aug 2026west of UTC. This is not small: a pin needs a rendered recharts axis. Also, its fallback printstoISOString(), which would name the previous day east of UTC once the parse is local.new Date(dateStr)and read local getters, while TODAY() and DATEADD's result are UTCtoISOString(). This is not small: routing only the parse would make DATEADD's UTC output name the previous day east of UTC. The formula date functions need one zone decision.formatDate,formatDateTime,formatRelativeTimeandformatDateSpecaccept a string. There is no caller in the repo (only the README), so these are recorded only.Adjacent, outside this card's read class:
new Date(v + 'T00:00:00').toISOString()for adatefield. That is local midnight as a UTC instant, whose date part is the previous day east of UTC. It is a write, not a read.2024-02-30still rolls forward (see above). That belongs to the objectui#10026 class, not to the zone class.Verification, at
a6757a2pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-report^...' --filter '@object-ui/plugin-dashboard^...' run build, exit 0). Thenpnpm --workspace-concurrency=2 --filter @object-ui/components --filter @object-ui/plugin-dashboard --filter @object-ui/plugin-report type-checkexited 0; each package echoestype-check$ tsc --noEmit && tsc -p tsconfig.test.jsonand prints Done. Eachtsconfig.test.jsonincludes thesrc/**/*.test.ts(x)glob.pnpm type-check:scriptsexited 0.pnpm exec vitest runon the driver plus the three pins: 4 files, 15 passed, 20 skipped. The 20 skipped are the zone cases, which run only in the driver's forks child, where every case must pass.bf2d6eb. The only later commit adds one case to the dashboard pin, and that pin ran ata6757a2.packages/plugin-dashboard/andpackages/plugin-report/: 167 files, 1549 passed, 13 skipped.packages/components/, run in two halves: 138 files (1312 passed) and 184 files (183 passed, 1 whole-file skip that already existed; 1825 tests passed).a6757a2.check-changeset-presence,-no-major,-claims,-fixedand-overwrite.check:pending-changeset-literals.check:new-line-citations, which printsVERDICT new-cross-file-line-citations: 0 new citation(s).check:control-bytes,check:test-path-roots, andcheck:vi-mock-specifiers/-inherit/-override-shape.check:phantom-deps,check:unused-depsandcheck:unreferenced-sources.check-governed-queue-guard --testover the changed paths answers "An ordinary pull request".eslint.config.jswith--no-inline-config --format jsonover the 7 changed code files: 0 errors and 22 warnings. The warnings are identical per file to base: date-picker 1, DashboardFilterBar 18, formatValue 3, driver 0. The config is not type-aware (noparserOptions.projectorprojectService), so this diff cannot move a verdict on an untouched file.Changeset:
.changeset/10844-date-only-zone-closure.md, apatchfor@object-ui/plugin-dashboard,@object-ui/componentsand@object-ui/plugin-report.Written by the
domain:ui#4dispatch, sessionhttps://claude.ai/code/session_01MCg3z5cGrV51xEddpGcxEY.Generated by Claude Code