Skip to content

fix(plugin-dashboard,components,plugin-report): read stored date-only days through toDisplayDate (objectui#10844) - #10861

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-10844-date-only-zone-closure
Sep 27, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-10844-date-only-zone-closure

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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-DD string with the engine's own Date parse, which reads it as UTC midnight. So every viewer west of UTC saw the day before. Each site now reads the string through toDisplayDate (@object-ui/core, the objectui#10183 convention). Each site also has a zone pin registered in the PINS list of the objectui#10183 driver, scripts/__tests__/date-only-zone-pins-10183.test.ts. The driver runs each pin in a forks child under America/Los_Angeles (west) and Asia/Shanghai (the control). Each zone opens with a rig case, so a child whose zone did not move turns red.

Site Before, west of UTC After
plugin-dashboard DashboardFilterBar, rangeFrom / rangeTo Clicking 15 Sep commits 2026-09-15, then the calendar highlights the 14th. A range from 1 Oct opens on September Highlights the 15th. Opens on October
components date-picker renderer, an ISO string value 2024-01-15 is labelled "January 14th, 2024" and the calendar selects the 14th "January 15th, 2024". The calendar selects and opens on the 15th
plugin-report formatValue, the report cell's date face (found by the census) 2026-09-15 reads 2026-09-14 Reads 2026-09-15

The 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. ReportViewer uses this face for an untyped ISO-looking value and for an aggregated column, such as a max over 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, before format, selected and defaultMonth. Only a date-only string that names a real day goes through toDisplayDate. isRealCalendarDate from @object-ui/core makes that judgement. A Date, a date-time string and any other string reach date-fns unchanged.

Measured with date-fns directly, in node under TZ=UTC and TZ=America/Los_Angeles:

  • format('not a date', 'PPP') throws RangeError: Invalid time value in both zones. After this change the trigger still throws on such a value, exactly as before, because the value passes through untouched.
  • 2024-02-30 rolls to March 1st, 2024 in UTC and February 29th, 2024 in LA. It still reaches the engine rather than being refused. Refusing it would hand format an 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)

toDisplayDate refuses a day that its month does not have.

  • Dashboard range: a bound of 2026-02-30 used to roll into March. It now selects no day. A refused from opens the calendar on today, as an unparseable from already did. A valid from next to a refused to renders without throwing. All of this is pinned in the suite zone.
  • Report cell: such a value renders as the raw stored string. That is the cell's existing face for an unparseable value.

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 a Date, 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 empty git diff HEAD. In the vitest root config the pins resolve @object-ui/* to src through resolve.alias, so no dist is involved.

  1. DashboardFilterBar: both toDisplayDate(value.from/to) calls were changed back to new 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 to 716dea90092f.
  2. date-picker: const day = toPickerDay(value); was changed to const day = value;. The west date-only case failed (label: 'January 14th, 2024'). The instant case and the east control stayed green. Restored to f896cd9b9a0a.
  3. formatValue: toDisplayDate(value) was changed to new Date(value). The west case failed ('2026-09-14'). The instant case and the east control stayed green. Restored to 90b2aebb6231.

Acceptance notes

The census

Method, at base 4aebea0: git grep -n "new Date(" over non-test, non-story packages/**/*.ts(x) gave 348 lines, 262 of them with an argument. I also searched for Date.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 toDisplayDate or follows its convention:

  • core: utils/date-display.ts (the step itself); native-date-value.ts, where toDateInputValue passes a leading YYYY-MM-DD through verbatim.
  • fields: the index.tsx date cell (its title keeps the engine instant on purpose, documented in place); DateField; FormulaField; GridField (its datetime arm keeps the instant); lookupColumnDisplay ($date goes to formatDate).
  • plugin-detail DetailView; app-shell auditHistoryDisplay.
  • components: calendar.tsx; data-table (through formatDate).
  • Through formatDate: plugin-grid ObjectGrid, plugin-dashboard recordFields, and the plugin-gantt ObjectGantt tooltip.

Not date-only: audit, system and event instants.

  • app-shell: ConversationsSidebar, CloudConnectionPanel, InstalledListWidget, MarketplacePage, MarketplacePackagePage, AcceptInvitationPage, InvitationsPage, MembersPage, ActivityFeed, AiUsageIndicator, relativeTime, RecordApprovalsPanel, AuditPanel, ResourceHistoryPage, StudioHomePage, ExternalDatasourcePanel, FlowRunsPanel, JobPreview, CommitTimeline, RecordDetailView.
  • plugin-detail: ActivityTimeline, ConcurrentUpdateDialog, HistoryTimeline, RecordActivityTimeline, RecordComments, RecordMetaFooter, ThreadedReplies, record-history, recordActivityFeed.
  • Others: auth AuthProvider; collaboration CommentThread; components DebugPanel and ShareDialog; core SharingProtocol (a validity check only); plugin-ai NLQueryInput; plugin-chatbot AiPendingActionsInbox; plugin-designer VersionHistory; plugin-form occSave; plugin-grid ImportWizard (the job time); types http-retry.

Not a parse of a stored value:

  • Date arithmetic, copies and multi-argument constructors: core date-macros, the CalendarView grid, GanttView, scheduling, shifts, ResourceWorkload, and the timeline renderer's interpolation.
  • The expression engine's Date constructor, in SafeExpressionParser.
  • Demo and sample data: plugin-gantt/demo, and the sample events in calendar-view-renderer.
  • Comments.

Validity checks and orderings, not a face:

  • Date.parse validity guards: dashboard-filters, dataset-format, ImportWizard, importParsers, the ObjectGrid sniff, the activity feeds.
  • The validation engine's min/max comparison, where both sides parse the same way.
  • Timestamp sorts.

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.

  1. plugin-calendar. The reads are the ObjectCalendar event start / end (new Date(startDate)), the calendar-view-renderer schema.data events, and the authored currentDate (resolveAuthoredCurrentDate). CalendarView's getEventsForDate then places a date-only event on the previous day's cell. This is not small. ObjectCalendar writes a moved or quick-created event back as toISOString() of that Date, 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.
  2. plugin-gantt. The reads are the ObjectGantt task start / end and baselines, and the GanttView startDate / endDate / markers. This is not small: GanttView's tzShift re-bases every Date into a configured zone, and the drag write-back is toISOString().
  3. plugin-timeline. ObjectTimeline's dateBucket reads startOfDay(new Date(raw)), so west of UTC an item dated today lands in the Overdue bucket. The renderer's formatDate reads local getters for its short and long faces but uses toISOString() 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.
  4. plugin-charts. AdvancedChartImpl's formatTick reads a category of 2026-09-01 as Aug 31 / Aug 2026 west of UTC. This is not small: a pin needs a rendered recharts axis. Also, its fallback prints toISOString(), which would name the previous day east of UTC once the parse is local.
  5. core FormulaFunctions. DATEADD, DATEDIFF and DATEFORMAT parse with new Date(dateStr) and read local getters, while TODAY() and DATEADD's result are UTC toISOString(). 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.
  6. i18n. The published helpers formatDate, formatDateTime, formatRelativeTime and formatDateSpec accept a string. There is no caller in the repo (only the README), so these are recorded only.

Adjacent, outside this card's read class:

  • The plugin-detail InlineFieldInput date editor WRITES new Date(v + 'T00:00:00').toISOString() for a date field. 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.
  • The date-picker's 2024-02-30 still rolls forward (see above). That belongs to the objectui#10026 class, not to the zone class.
  • The custom-range select's reopen defect is objectui#10843, which is serial after this card. The select is not touched here.

Verification, at a6757a2

  • Type-check. I first built the 13-package dependency closure (pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-report^...' --filter '@object-ui/plugin-dashboard^...' run build, exit 0). Then pnpm --workspace-concurrency=2 --filter @object-ui/components --filter @object-ui/plugin-dashboard --filter @object-ui/plugin-report type-check exited 0; each package echoes type-check$ tsc --noEmit && tsc -p tsconfig.test.json and prints Done. Each tsconfig.test.json includes the src/**/*.test.ts(x) glob. pnpm type-check:scripts exited 0.
  • Driver and pins. pnpm exec vitest run on 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.
  • Package suites, at bf2d6eb. The only later commit adds one case to the dashboard pin, and that pin ran at a6757a2.
    • packages/plugin-dashboard/ and packages/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).
  • Gates, each exit 0 at a6757a2.
    • check-changeset-presence, -no-major, -claims, -fixed and -overwrite.
    • check:pending-changeset-literals.
    • check:new-line-citations, which prints VERDICT new-cross-file-line-citations: 0 new citation(s).
    • check:control-bytes, check:test-path-roots, and check:vi-mock-specifiers / -inherit / -override-shape.
    • check:phantom-deps, check:unused-deps and check:unreferenced-sources.
    • check-governed-queue-guard --test over the changed paths answers "An ordinary pull request".
  • ESLint, narrowed. The repo-wide run belongs to CI. I ran the root eslint.config.js with --no-inline-config --format json over 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 (no parserOptions.project or projectService), so this diff cannot move a verdict on an untouched file.

Changeset: .changeset/10844-date-only-zone-closure.md, a patch for @object-ui/plugin-dashboard, @object-ui/components and @object-ui/plugin-report.

Written by the domain:ui#4 dispatch, session https://claude.ai/code/session_01MCg3z5cGrV51xEddpGcxEY.


Generated by Claude Code

…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>
…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>
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 329 chunks) 3082.1 KB 3104.5 KB
Main entry chunk (gzip) 148.5 KB 350 KB
Entry file index-Ce51IahC.js —
Status PASS —

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

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 16.57KB 6.15KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 27.95KB 10.04KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.17KB 10.58KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.39KB 1.21KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.52KB 3.45KB
auth (index.js) 3.63KB 1.64KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 27.13KB 7.95KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 557.20KB 133.48KB
core (index.js) 9.92KB 3.93KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 225.01KB 62.50KB
fields (index.js) 261.26KB 66.37KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 2.59KB 1.22KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.24KB 2.27KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 39.40KB 12.91KB
i18n (translateFn.js) 0.20KB 0.18KB
i18n (useDisplayLocale.js) 3.52KB 1.76KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 39.27KB 11.09KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 6.62KB 2.45KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.33KB 3.07KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 16.01KB 3.93KB
plugin-calendar (index.js) 51.73KB 14.72KB
plugin-charts (index.js) 83.99KB 22.86KB
plugin-chatbot (index.js) 197.67KB 46.90KB
plugin-dashboard (index.js) 135.87KB 36.15KB
plugin-designer (index.js) 216.25KB 44.39KB
plugin-detail (index.js) 233.48KB 61.79KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 158.78KB 40.61KB
plugin-gantt (index.js) 169.83KB 41.99KB
plugin-grid (index.js) 218.66KB 59.90KB
plugin-kanban (index.js) 48.43KB 15.11KB
plugin-list (index.js) 114.78KB 28.44KB
plugin-map (index.js) 22.90KB 7.62KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 44.17KB 12.20KB
plugin-timeline (index.js) 30.91KB 9.05KB
plugin-tree (index.js) 11.21KB 3.89KB
plugin-view (index.js) 87.89KB 22.03KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.81KB 3.58KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 119.16KB 39.05KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.03KB 1.86KB
react (schema-input.js) 4.25KB 2.04KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.78KB 2.09KB
sdui-parser (codegen.js) 7.50KB 3.05KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 6.16KB 2.71KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.84KB 1.90KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 18.27KB 6.20KB
types (ai.js) 4.11KB 2.06KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.99KB 1.07KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 5.00KB 2.39KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 2.52KB 1.31KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 17.15KB 6.32KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: 126/126 CONTRACT_REVIEW_TIER
Head-sha: a6757a23884acd0b6b94b4b41d09d39017794465

Read: card objectui#10844 body and thread (triage 5859979628, claim 5860160735; the dev report 5860645315 was read as a list of claims to test, not as evidence); objectui#10110, objectui#10183 (body, 5808988744, the claim, the dev report, ACCEPT 5816731550), PR objectui#10266 body and file list, objectui#10026 (body, ruling 5749269396, the dev report, seat answers 5818142672, ACCEPT 5818323394); PR objectui#10861 body, file list (8 files) and its diff against merge-base 4aebea0; head sources of DashboardFilterBar.tsx, date-picker.tsx (head and base), formatValue.ts, ReportViewer.tsx (renderCellValue and the summary tiles), ReportExportEngine.ts, date-display.ts (toDisplayDate, isRealCalendarDate, formatDate), DatePickerSchema in packages/types/src/form.ts, the driver and the three new pins, vitest.config.mts (alias map, pool, TZ), .changeset/config.json, the new changeset and the pending changesets naming touched files (10799-calendar-default-month, 10722-date-fns-display-locale, 10020-report-display-locale; 10132, 5458 and 10789 checked for date sentences); objectui AGENTS.md §5 (#7 No-Touch ui/, #11) and §9 (changeset presence, 测试纪律, 版本号策略); the census candidates in plugin-calendar, plugin-gantt, plugin-timeline, plugin-charts, core FormulaFunctions.ts, i18n formatting.ts and spec-formatters.ts, plugin-detail InlineFieldInput.tsx and HistoryTimeline.tsx, custom/date-picker.tsx, native-date-value.ts, fields/src/index.tsx, ObjectGrid.tsx, AiPendingActionsInbox.tsx, apps/console ApprovalsInboxPage.tsx, plus a git grep of new Date(, Date.parse( and date-fns over packages/*/src and apps/console/src at the head; objectstack examples/app-showcase/src/data/objects/task.object.ts and ui/views/task.view.ts; the head's check-runs (two polls). Ran once, in a throwaway git worktree at the head under the scratch dir with an offline pnpm install (3.7s), through os-verify-lock.sh slot review-10861: the driver scripts/__tests__/date-only-zone-pins-10183.test.ts — 1 file, 9 passed, child exit 0, 18.39s on a shared box, lock held 20s, waited 0s. Also two node probes (date-fns format, isSameDay, startOfMonth; and toDisplayDate / isRealCalendarDate from core src) under TZ=UTC, America/Los_Angeles, Asia/Shanghai. Worktree removed without --force.

① Derived judgments

1. DashboardFilterBar — RIGHT. At the head rangeFrom and rangeTo read toDisplayDate(value.from) and toDisplayDate(value.to) behind the unchanged !value.from.startsWith('{') macro guard. toIsoDate is untouched and still writes the LOCAL day from getFullYear, getMonth, getDate, and onSelect still commits through it, so the write side is unchanged and local. West of UTC: in the driver's forks child under America/Los_Angeles, the case "clicking 15 Sep commits the 15th, and the committed range highlights the 15th" passed (committed from: '2026-09-15', to: '2026-09-15'; selected: ['2026-09-15']; captions September 2026, October 2026), and "a range stored from 1 Oct highlights 1-3 Oct and opens on October" passed (captions October 2026, November 2026). The pin's fixture-validity case pins that the engine parse of 2026-09-15 reads the 14th there and that 2026-10-01 reads month 8, so base fails both cases. A refused bound (2026-02-30): toDisplayDate returns an Invalid Date (probed as raw in all three zones), which reaches the calendar's selected from/to pair; the pre-existing openingMonth guard (!Number.isNaN(rangeFrom.getTime())) withholds it from defaultMonth, so the calendar opens on today (frozen clock: September 2026), where base rolled it into March and opened on March. Nothing throws: the suite-zone case asserts selected: [] with captions September 2026, October 2026 for a refused pair, and not.toThrow for a valid from beside a refused to; those cases also ran inside the driver child. No path hands an Invalid Date to date-fns in a throwing position: defaultMonth is guarded and react-day-picker's selected matching answers false on it (measured by the pin, not assumed). The trigger label from rangeLabel still prints the raw stored strings, unchanged.

2. date-picker toPickerDay — RIGHT. Head: return typeof value === 'string' && isRealCalendarDate(value) ? toDisplayDate(value) : value;. isRealCalendarDate is false for anything but a YYYY-MM-DD string whose year, month and day read back, so a Date (its typeof is 'object'), a date-time string (fails ISO_DATE_ONLY_RE) and any other string pass through as the same reference; the trigger formats day, the calendar takes calendarDay (a type-only cast), and the muted class still reads !value. Base called format(value, …) and passed value to defaultMonth and selected directly. For 'not a date' head and base hand date-fns the identical string: probed format('not a date', 'PPP') throws RangeError: Invalid time value in UTC, LA and Shanghai, so the trigger throws exactly as before, and (as pending changeset 10799 says of both pickers) before any calendar mounts. For '2024-02-30': isRealCalendarDate answers false, the string passes unchanged, and date-fns rolls it zone-dependently — probed March 1st, 2024 in UTC and Shanghai, February 29th, 2024 in LA — as the PR says; the pin asserts that face('2024-02-30') does not throw (an expect(...).not.toThrow() over the render), a render and not a refusal. The west case fails on base: probed format('2024-01-15', 'PPP') in LA is January 14th, 2024; the pin expects January 15th, 2024, selected: ['2024-01-15'], month January 2024. date-fns is ^4.4.0 and react-day-picker ^10.0.1 in packages/components/package.json, where a string input takes the engine parse, matching the card's measurement. Nothing under packages/components/src/ui/ is in the file list.

3. formatValue — RIGHT. ReportViewer.renderCellValue reaches formatValue on two paths: if (field.aggregation) return formatValue(value, field, displayLocale) and the untyped fall-through return formatValue(value, field, displayLocale); a typed non-aggregated column goes to getCellRenderer (the fields cells, repaired by the earlier cards). The summary tiles call formatValue(computeAggregation(field.name, field.aggregation), field, displayLocale). Inside, type === 'date' || format === 'date' || isISODateString(value) routes to the private formatDate, which now parses with toDisplayDate and reads local getters — a genuine date-only read of a stored day, typed (through an aggregation) or sniffed. Invalid-string face: base returned String(value) when the engine parse was NaN; head returns it when toDisplayDate is NaN, and for every string that is not date-only shaped toDisplayDate returns the engine's own parse (probed 'not a date', '2026-13-01', '2026-09-15xyz' → raw in all zones), so every string base rendered raw still renders raw. What changes: a date-only nonexistent day (2026-02-30: base 2026-03-02, head the raw 2026-02-30, probed) and, through the shared step's objectui#10301 branch, a date-time written on a nonexistent day (2026-02-30T10:00:00Z: base 2026-03-02, head raw). The changeset's "A value naming a nonexistent day now renders as the raw stored string" covers both; the PR body's prose names the date-only case only (noted under ③, not a defect). A real date-time keeps its local day (2026-09-16T04:00:00.000Z → 2026-09-15 in LA and 2026-09-16 in UTC and Shanghai, probed and pinned). Other callers: formatValue is exported from packages/plugin-report/src/index.tsx; in-repo the only caller is ReportViewer (three sites); ReportExportEngine does not call it. The signature (value, field?, locale = DISPLAY_LOCALE_LAST_RESORT) is unchanged.

4. PINS registration — RIGHT. The driver diff adds exactly three PINS entries and header prose; timeout: 360_000 on the describe, the spawn's timeout: 300_000, --pool=forks, OBJECTUI_DATE_ZONE_CHILD=1 and the assertions (collected, ran at least one case, every case passed, child exit 0) are unchanged. Each new pin has describe.runIf(DRIVEN) blocks for America/Los_Angeles and Asia/Shanghai, each opening with rig: the zone really moved (Intl.DateTimeFormat().resolvedOptions().timeZone plus the local hour of a fixed instant: 21 and 12 for the September instant, 20 and 12 for the January one), a west fixture validity case pinning the engine's day-before, and west cases base fails: dashboard selected: ['2026-09-15'] and October 2026 (base: 2026-09-14 and September 2026), date-picker January 15th, 2024 (base: January 14th, 2024, probed), formatValue 2026-09-15 (base: 2026-09-14). The PR's three ablation legs name exactly these cases. The only skip in the parent is the driver convention's runIf(DRIVEN). My driver run: Test Files 1 passed (1), Tests 9 passed (9) — the eight registered pins, including the three new ones, and the child-exit case.

5. The census — RIGHT, with two notes. Verified at the head:

  • plugin-calendar. ObjectCalendar builds const start = new Date(startDate) and end: endDate ? new Date(endDate) : undefined from record[startDateField] / record[endDateField]; calendar-view-renderer builds start: new Date(record[startField]) and resolveAuthoredCurrentDate parses new Date(raw); CalendarView.getEventsForDate compares new Date(event.start) against a local day. Producer: objectstack examples/app-showcase/src/data/objects/task.object.ts declares due_date: Field.date, start_date: Field.date, end_date: Field.date (and created_at: Field.datetime); ui/views/task.view.ts binds calendar: { startDateField: 'due_date', titleField: 'title', colorField: 'status' } in the switcher and in the named calendar list view; the seed fills due_date with the calendar-day helpers daysFromNow / daysAgo. Write-back: the drop handler patches [startDateField]: newStart.toISOString() (and endDateField), and quick-create writes quickCreate.start.toISOString() — a UTC instant into a Field.date, so the PR is right that read and write must be decided together.
  • plugin-gantt. ObjectGantt maps start: startDate ? new Date(startDate) : new Date(), end likewise, baselines new Date(baselineStartRaw); GanttView parses startDate, endDate and m.date and re-bases every Date through tzShift; write-back patch[startDateField] = changes.start.toISOString(). Producer: the same task object's start_date / end_date, bound by task.view.ts gantt: { startDateField: 'start_date', endDateField: 'end_date', ... }.
  • plugin-timeline. ObjectTimeline.dateBucket reads startOfDay(new Date(raw)) and the sort reads new Date(a.startDate).getTime(); renderer.formatDate reads new Date(dateString) with local faces for short and long and toISOString().split('T')[0] by default; the extent parses new Date(d).getTime() then prints toISOString(). The showcase binds created_at (a datetime), so no showcase date-only producer, as the PR says.
  • plugin-charts. AdvancedChartImpl.formatTick parses new Date(str) for ^\d{4}-\d{2}-\d{2} then toLocaleDateString, falling back to toISOString().slice(0, 10).
  • core. FormulaFunctions DATEADD, DATEDIFF and DATEFORMAT parse new Date(dateStr) and read local getters; TODAY, NOW and DATEADD emit toISOString().
  • i18n. formatting.ts formatDate, formatDateTime, formatRelativeTime and spec-formatters.ts formatDateSpec parse new Date(date); the only in-repo mention outside the package is packages/i18n/README.md.
  • Adjacent write. InlineFieldInput.tsx: onChange(v ? new Date(v + 'T00:00:00').toISOString() : v).
    Missing from the list: no same-class site found. Every other new Date(x) or Date.parse(x) hit in packages/*/src is an audit or event instant (app-shell pages, plugin-detail HistoryTimeline and RecordMetaFooter on created_at, the activity feeds, the chatbot inbox, designer, ai, collaboration, auth), arithmetic or a copy (date-macros, the CalendarView grid, GanttView, scheduling, shifts, ResourceWorkload, timeline interpolation), a validity guard ahead of the shared path (data-table, the ObjectGrid sniff, dashboard-filters, dataset-format, ImportWizard, importParsers), the validation engine's symmetric min/max compare, or already on toDisplayDate (date-display, native-date-value's verbatim date-only pass-through, the fields cells and widgets, DetailView, auditHistoryDisplay, calendar.tsx, data-table, GridField's documented datetime arm). apps/console ApprovalsInboxPage.formatPayloadValue parses only ^\d{4}-\d{2}-\d{2}T\d{2} strings and returns a date-only string raw, so it is not a member. Already correct among the six listed: none — each still parses with the engine at the head. Notes: (i) packages/components/src/custom/date-picker.tsx (DatePicker, exported through custom/index.ts) takes date?: Date and hands it to date-fns format and to selected / defaultMonth; its contract is a Date, it parses no string in-repo and has no in-repo caller, so it is outside the card's class as the card defines it, but a host passing an ISO string despite the type hits the same engine parse — one line for the successor card. (ii) No same-class site in a file this PR touched is left unfixed: DashboardFilterBar.tsx has no other date parse, date-picker.tsx has the one read site, formatValue.ts has the one.

6. Prose and shape — RIGHT. Changeset frontmatter: '@object-ui/plugin-dashboard': patch, '@object-ui/components': patch, '@object-ui/plugin-report': patch; no major. Each sentence holds at the head: the three reads parsed with the engine before and read through toDisplayDate now (dashboard and report fully; date-picker for a real date-only day, and its bullet says exactly that); "For a real day, a viewer in UTC or east of it sees no change" (the east controls pass and would pass on base); the dashboard bullet's refusal behaviour (2026-02-30 selects no day and opens on today "as an unparseable from already did") is the openingMonth guard and the suite-zone pin; the date-picker bullet's partition, throw-preservation and roll-preservation are the code and the probes; the report bullet's 2026-09-14 to 2026-09-15 and nonexistent-day raw face are the probes; "A date-time value (one with a time part) still renders in the viewer's zone at every site" holds because toDisplayDate returns the engine instant for a date-time string. PR body line 1 is Fixes #10844; Clause-②: no on line 3 holds: no export added or removed (toDisplayDate and isRealCalendarDate already existed), formatValue's signature unchanged, DatePickerSchema.value already Date | string, and the widened value?: Date | string sits on the registered renderer's inline props type, not on a published signature; no accept set moved (every previously accepted input is still accepted; formatValue changes a face, not what it takes). The pending changesets naming touched files stay true: 10799 ("the two date pickers ... an invalid value already fails in their trigger's label, before any calendar mounts"; "a range from that does not parse ... on today", which a refused bound now joins), 10722 (the display-locale read is untouched), 10020 (formatValue's optional third parameter untouched). No No-Touch ui/ file; Line Citation Gate green.

② Semver level

patch for @object-ui/plugin-dashboard, @object-ui/components and @object-ui/plugin-report, as declared; @object-ui/core untouched. No new or removed export and no signature change; the only behaviour beyond the zone repair is a display face on already-accepted input (2026-02-30 in the dashboard range and the report cell), the class the objectui#10026 landing shipped as patch on its consumers. No major (§9 版本号策略; Changeset Bump Policy green).

③ Boundary flags

  • CI on a6757a2. Poll 1 at 2026-09-27T23:08:47Z: 42 check-runs — 29 success, 3 skipped by design (Test (coverage), the coverage shard matrix entry, dependabot), 10 in progress (Test (shard 1/8) through Test (shard 8/8), Type Check, Spec Main Shape Gate). Poll 2 at 2026-09-27T23:14:58Z: 43 check-runs — 40 success, 3 skipped, 0 failed, 0 in progress. The 40 successes: Action Ref Convention, Build & E2E, Build Docs, Bundle Analysis, Changeset Bump Policy, Changeset Claim Re-read, Changeset Declaration, Changeset Fixed Group Check, Changeset Overwrite Report, Control Byte Scan, Doc Component Type Check, Doc Example Id Check, Doc Fence Language Check, Doc Snippet Type Check, Docs Route Eager Closure Check, Governed Surface Queue Guard, Inert vi.mock Specifier Check, Internal Docs Link Check, Line Citation Gate, Lint, Live E2E (informational), Pre-Install Import Graph Check, README Export Check, Shell Escape Residue Scan, Skill Eval Token Check, Skill Example Check, Skill Guide Path Check, Spec Main Shape Gate, Test, Test (dist pins), Test (shard 1/8) to Test (shard 8/8), Type Check, label. The 3 skipped: Test (coverage), the coverage shard matrix entry, dependabot.
  • Behind base. Merge-base 4aebea0. origin/main moved during this review to fec3b1a (docs: objectui#10803 citations, docs: cite landing commits where pending changesets and source comments name fourteen objectui issues that answer 404 (objectui#10803) #10854) and then e239161 (docs(plugin-timeline) README type: 'timeline', docs(plugin-timeline): spell the README's schema-driven node type: 'timeline' (objectui#10824) #10858). The file sets are disjoint from the PR's eight files, and neither touches date-display.ts. mergeable_state read behind at poll 1 (with mergeable: true) and unknown (recomputing after the push to main) at poll 2. The PR is a draft.
  • Files (8): .changeset/10844-date-only-zone-closure.md (+36); packages/components/src/renderers/form/__tests__/date-picker.dateValueZone-10844.test.tsx (+155); packages/components/src/renderers/form/date-picker.tsx (+37/-6); packages/plugin-dashboard/src/DashboardFilterBar.tsx (+9/-2); packages/plugin-dashboard/src/__tests__/DashboardFilterBar.dateOnlyZone-10844.test.tsx (+176); packages/plugin-report/src/__tests__/formatValue.dateOnlyZone-10844.test.ts (+108); packages/plugin-report/src/formatValue.ts (+10/-1); scripts/__tests__/date-only-zone-pins-10183.test.ts (+10/-1). None under packages/components/src/ui/. formatValue.ts lies outside the two named sites and inside the claim's "any further date-only read site the census finds in packages/**".
  • Scope. Fixes #10844 closes the family's closure card while six same-class members remain only in the PR's Acceptance notes; the dev's open_questions[0] puts that to the seat (its option A is a successor closure card). A seat decision, not a defect in this diff; the successor card can start from the list in ①5, which this review verified, plus the custom/date-picker.tsx note.
  • Prose nit, not a defect. The PR body's "Report cell: such a value renders as the raw stored string" names the date-only nonexistent day only; the same shared step also turns a date-time written on a nonexistent day (2026-02-30T10:00:00Z) from 2026-03-02 into the raw string in that cell. The changeset's wording already covers it.
  • Reviewer's own runs: the driver once (locked, held 20s, waited 0s), two node probe scripts; no repo gate, no whole suite, no GitHub write, no MCP GitHub tool; the throwaway worktree under the scratch dir was removed without --force.

Implemented-by: claude/issue-10844-date-only-zone-closure
Reviewed-by: session_01MCg3z5cGrV51xEddpGcxEY

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 27, 2026 23:24
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 27, 2026
Merged via the queue into main with commit a60539b Sep 27, 2026
45 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-10844-date-only-zone-closure branch September 27, 2026 23:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment