diff --git a/.changeset/10844-date-only-zone-closure.md b/.changeset/10844-date-only-zone-closure.md
new file mode 100644
index 0000000000..03d2618e3d
--- /dev/null
+++ b/.changeset/10844-date-only-zone-closure.md
@@ -0,0 +1,36 @@
+---
+'@object-ui/plugin-dashboard': patch
+'@object-ui/components': patch
+'@object-ui/plugin-report': patch
+---
+
+fix(plugin-dashboard,components,plugin-report): three more read sites show the day a stored date-only value names, in every zone (objectui#10844)
+
+Each read below parsed a date-only `YYYY-MM-DD` string with the engine's own `Date`
+parse, which reads it as UTC midnight. West of UTC that is the evening before, so
+each face showed the previous day. Each now reads the value through `toDisplayDate`
+(`@object-ui/core`), which rebuilds a date-only string at local midnight of the day it
+names. For a real day, a viewer in UTC or east of it sees no change.
+
+- **Dashboard date filter, custom range.** The range calendar highlighted the day
+ before each stored bound and could open on the previous month: clicking 15 Sep stored
+ `2026-09-15` and then highlighted the 14th. It now highlights and opens on the stored
+ days. A bound naming a day its month does not have (`2026-02-30`) no longer rolls
+ into March: it selects no day, and a `from` like that opens the calendar on today, as
+ an unparseable `from` already did.
+- **`date-picker` renderer, an ISO string `value`.** An authored or bound
+ `value: '2024-01-15'` was labelled "January 14th, 2024" and selected the 14th. It now
+ labels and selects the 15th. Only a date-only string naming a real day is read this
+ way: a `Date`, a date-time string and any other string reach date-fns exactly as
+ before. So an unparseable string still makes the trigger's `format` throw, as it did
+ before, and a date-only string naming a nonexistent day is still rolled forward
+ rather than refused, because refusing it would make `format` throw where it rendered
+ before.
+- **Report cell date face (`formatValue`).** The `yyyy-MM-dd` face `ReportViewer` gives
+ an untyped ISO-looking value and an aggregated column read `2026-09-14` for a stored
+ `2026-09-15`. It now reads `2026-09-15`. A value naming a nonexistent day now renders
+ as the raw stored string, this face's existing answer for an unparseable value,
+ instead of a rolled-forward day.
+
+A date-time value (one with a time part) still renders in the viewer's zone at every
+site.
diff --git a/packages/components/src/renderers/form/__tests__/date-picker.dateValueZone-10844.test.tsx b/packages/components/src/renderers/form/__tests__/date-picker.dateValueZone-10844.test.tsx
new file mode 100644
index 0000000000..7a92073604
--- /dev/null
+++ b/packages/components/src/renderers/form/__tests__/date-picker.dateValueZone-10844.test.tsx
@@ -0,0 +1,155 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * objectui#10844 — the `date-picker` renderer labels and selects the day a
+ * date-only `value` names, the same day the equivalent `Date` names.
+ *
+ * ── What was measured ───────────────────────────────────────────────────────
+ * The renderer handed an authored ISO `value` to date-fns `format` and to the
+ * calendar's `selected` / `defaultMonth` untouched. Both parse a string with
+ * the engine's own `Date` parse, so a date-only string was UTC midnight read
+ * back in local time: `2024-01-15` was labelled "January 14th, 2024" in
+ * `America/Los_Angeles`, and the calendar selected the 14th, while
+ * `new Date(2024, 0, 15)` read the 15th in every zone. The read site now
+ * routes a real date-only day through `toDisplayDate` (`@object-ui/core`, the
+ * objectui#10183 convention), and passes everything else unchanged.
+ *
+ * ── ⚠️ The zone cases run ONLY when driven, in a FORKS child ────────────────
+ * `process.env.TZ` written inside a test of the normal run does not move the
+ * zone (the root config runs `pool: 'threads'`), so the zone cases are
+ * skipped there and `scripts/__tests__/date-only-zone-pins-10183.test.ts`
+ * runs them on the forks pool and fails unless every one ran and passed. Each
+ * zone opens with a rig case, so a child whose zone did not move reds instead
+ * of going quietly green. The suite-zone cases below run in the normal run
+ * too; they cannot tell the repair from its absence, because UTC is the one
+ * offset where the two parses agree, and they are here for the `Date` and
+ * date-time faces and for the value the repair leaves to the engine.
+ *
+ * `Asia/Shanghai` is the control: east of UTC the UTC-midnight parse already
+ * landed on the named day, and an hour-offset "repair" would break it.
+ */
+import React from 'react';
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { render, cleanup, fireEvent } from '@testing-library/react';
+import { ComponentRegistry } from '@object-ui/core';
+// Registers the renderers at module scope, NOT inside a hook (objectui#3010).
+import '../../../renderers';
+
+const DRIVEN = process.env.OBJECTUI_DATE_ZONE_CHILD === '1';
+
+/** UTC-8 in January — the card's direction. */
+const WEST = 'America/Los_Angeles';
+/** UTC+8 — the control. */
+const EAST = 'Asia/Shanghai';
+
+/** Frozen in another year than the value, so the calendar's opening month is the value's. */
+const CLOCK = '2026-09-03T10:35:00.000Z';
+const DATE_ONLY = '2024-01-15';
+/** A fixed instant: 20:00 on the 15th in the west, 12:00 on the 16th in the east. */
+const INSTANT = '2024-01-16T04:00:00.000Z';
+
+/** Freeze the clock, moving this forked process into `zone` first when given. */
+function enter(zone?: string): void {
+ if (zone) process.env.TZ = zone;
+ vi.useFakeTimers({ toFake: ['Date'] });
+ vi.setSystemTime(new Date(CLOCK));
+}
+
+afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+});
+
+interface Face {
+ /** The trigger's label. */
+ label: string;
+ /** The `data-day` of every selected cell, once the popover is open. */
+ selected: string[];
+ /** The caption of the month the calendar opens on. */
+ month: string;
+}
+
+/** Render the picker with `value`, read its label, open it and read the calendar. */
+function face(value: unknown): Face {
+ const DatePicker = ComponentRegistry.get('date-picker')!;
+ render();
+ const trigger = document.getElementById('dp-10844')!;
+ const label = (trigger.textContent ?? '').trim();
+ fireEvent.click(trigger);
+ const selected = Array.from(document.body.querySelectorAll('td[data-selected="true"]'))
+ .map((td) => td.getAttribute('data-day') ?? '');
+ const month = (document.body.querySelector('.rdp-caption_label')?.textContent ?? '').trim();
+ cleanup();
+ return { label, selected, month };
+}
+
+const JAN_15: Face = { label: 'January 15th, 2024', selected: ['2024-01-15'], month: 'January 2024' };
+const JAN_16: Face = { label: 'January 16th, 2024', selected: ['2024-01-16'], month: 'January 2024' };
+
+describe('date-picker value, in the suite zone (objectui#10844)', () => {
+ it('a local `Date` labels and selects the day it names (the reference face)', () => {
+ enter();
+ expect(face(new Date(2024, 0, 15))).toEqual(JAN_15);
+ });
+
+ it('a date-only string labels and selects that same day', () => {
+ enter();
+ expect(face(DATE_ONLY)).toEqual(JAN_15);
+ });
+
+ it('a date-only string naming no real day is left to the engine, and still renders', () => {
+ enter();
+ // Refusing it would hand the trigger's `format` an Invalid Date, which
+ // throws; the repair does not widen what throws.
+ expect(() => face('2024-02-30')).not.toThrow();
+ });
+});
+
+describe.runIf(DRIVEN)('date-picker date-only value west of UTC (objectui#10844)', () => {
+ it('rig: the zone really moved', () => {
+ enter(WEST);
+ expect(Intl.DateTimeFormat().resolvedOptions().timeZone).toBe(WEST);
+ expect(new Date(INSTANT).getHours()).toBe(20);
+ });
+
+ it('fixture validity: the engine parse of the string lands on the day before here', () => {
+ enter(WEST);
+ // Without this the cases below would be green for free.
+ expect(new Date(DATE_ONLY).getDate()).toBe(14);
+ });
+
+ it('`2024-01-15` labels, selects and opens on the 15th, as `new Date(2024, 0, 15)` does', () => {
+ enter(WEST);
+ expect(face(new Date(2024, 0, 15))).toEqual(JAN_15);
+ expect(face(DATE_ONLY)).toEqual(JAN_15);
+ });
+
+ it('an instant keeps its local day, the 15th here', () => {
+ enter(WEST);
+ expect(face(INSTANT)).toEqual(JAN_15);
+ });
+});
+
+describe.runIf(DRIVEN)('date-picker date-only value east of UTC, the control (objectui#10844)', () => {
+ it('rig: the zone really moved', () => {
+ enter(EAST);
+ expect(Intl.DateTimeFormat().resolvedOptions().timeZone).toBe(EAST);
+ expect(new Date(INSTANT).getHours()).toBe(12);
+ });
+
+ it('`2024-01-15` labels and selects the 15th, as it already did', () => {
+ enter(EAST);
+ expect(face(DATE_ONLY)).toEqual(JAN_15);
+ });
+
+ it('an instant keeps its local day, the 16th here', () => {
+ enter(EAST);
+ expect(face(INSTANT)).toEqual(JAN_16);
+ });
+});
diff --git a/packages/components/src/renderers/form/date-picker.tsx b/packages/components/src/renderers/form/date-picker.tsx
index f99605f013..f896cd9b9a 100644
--- a/packages/components/src/renderers/form/date-picker.tsx
+++ b/packages/components/src/renderers/form/date-picker.tsx
@@ -6,7 +6,7 @@
* LICENSE file in the root directory of this source tree.
*/
-import { ComponentRegistry } from '@object-ui/core';
+import { ComponentRegistry, isRealCalendarDate, toDisplayDate } from '@object-ui/core';
import type { DatePickerSchema } from '@object-ui/types';
import { Calendar, Button, Popover, PopoverTrigger, PopoverContent, Label } from '../../ui';
import { CalendarIcon } from 'lucide-react';
@@ -15,12 +15,43 @@ import { cn } from '../../lib/utils';
import { useDisplayDateLocale } from '../../lib/date-fns-locale';
import { toFormControlDomProps } from '../../lib/form-control-dom-props';
-ComponentRegistry.register('date-picker',
- ({ schema, className, value, onChange, ...props }: { schema: DatePickerSchema; className?: string; value?: Date; onChange?: (date: Date | undefined) => void; [key: string]: any }) => {
+/**
+ * The day the trigger labels and the calendar selects (objectui#10844).
+ *
+ * `value` is a `Date` once the calendar has been used (`onSelect` hands one
+ * back), but an authored or bound value arrives as the ISO string
+ * `DatePickerSchema.value` admits, and date-fns and react-day-picker parse a
+ * string with the engine's own `Date` parse. That reads a DATE-ONLY string as
+ * UTC midnight, so west of UTC `2024-01-15` was labelled and selected as
+ * January 14th. A date-only string naming a real day is therefore read through
+ * the shared parse step, which rebuilds it at LOCAL midnight of that day (the
+ * objectui#10183 convention).
+ *
+ * Everything else passes UNCHANGED, so the engine parses it exactly as before:
+ * a `Date`, a date-time string (an instant, shown in the viewer's zone), and a
+ * string that is no real date-only day. The last includes an unparseable
+ * string, on which the trigger's `format` throws as it always has, and a
+ * date-only string naming a day its month does not have (`2024-02-30`), which
+ * the engine still rolls forward. Refusing that one here, as `toDisplayDate`
+ * does, would hand `format` an Invalid Date and make it throw where it
+ * rendered before.
+ */
+function toPickerDay(value: Date | string | undefined): Date | string | undefined {
+ return typeof value === 'string' && isRealCalendarDate(value) ? toDisplayDate(value) : value;
+}
+
+ComponentRegistry.register('date-picker',
+ ({ schema, className, value, onChange, ...props }: { schema: DatePickerSchema; className?: string; value?: Date | string; onChange?: (date: Date | undefined) => void; [key: string]: any }) => {
// `schema.format` is a date-fns pattern, and it is spelled in the display
// locale: `PPP` and the textual tokens name months and weekdays in the
// session's language (objectui#10722).
const locale = useDisplayDateLocale();
+ const day = toPickerDay(value);
+ // The calendar's props are typed `Date`; a string left in `day` is one the
+ // engine parses, which react-day-picker's date-fns comparisons did with
+ // `value` before (see `toPickerDay`). The cast states that, it converts
+ // nothing.
+ const calendarDay = day as Date | undefined;
const handleSelect = (date: Date | undefined) => {
if (onChange) {
@@ -57,7 +88,7 @@ ComponentRegistry.register('date-picker',
{...toFormControlDomProps(triggerProps)}
>
- {value ? format(value, schema.format || 'PPP', { locale }) : {schema.placeholder || 'Pick a date'}}
+ {day ? format(day, schema.format || 'PPP', { locale }) : {schema.placeholder || 'Pick a date'}}
@@ -69,8 +100,8 @@ ComponentRegistry.register('date-picker',
*/}
diff --git a/packages/plugin-dashboard/src/DashboardFilterBar.tsx b/packages/plugin-dashboard/src/DashboardFilterBar.tsx
index 90eb75f156..716dea9009 100644
--- a/packages/plugin-dashboard/src/DashboardFilterBar.tsx
+++ b/packages/plugin-dashboard/src/DashboardFilterBar.tsx
@@ -35,6 +35,7 @@ import { CalendarIcon, RotateCcw } from 'lucide-react';
import { useSafeTranslate, useObjectTranslation, useSafeFieldLabel, pickLocalized } from '@object-ui/i18n';
import {
DATE_RANGE_PRESETS,
+ toDisplayDate,
type DashboardFilterDef,
type DateRangeValue,
} from '@object-ui/core';
@@ -130,8 +131,14 @@ function DateRangeFilter({ def, value, onChange }: { def: DashboardFilterDef; va
const presetLabel = (p: string) => tt(`dashboard.filters.range.${p}`, p.replace(/_/g, ' '));
const selectValue = value?.preset ?? (value?.from || value?.to ? CUSTOM_VALUE : ALL_VALUE);
- const rangeFrom = value?.from && !value.from.startsWith('{') ? new Date(value.from) : undefined;
- const rangeTo = value?.to && !value.to.startsWith('{') ? new Date(value.to) : undefined;
+ // The stored bounds are date-only (`toIsoDate` below writes the LOCAL day),
+ // so they are read back through the shared parse step, which rebuilds a
+ // date-only string at local midnight of the day it names. The engine's own
+ // parse read it as UTC midnight, and west of UTC the calendar highlighted
+ // and opened on the day before the one stored (objectui#10844, the
+ // objectui#10183 convention).
+ const rangeFrom = value?.from && !value.from.startsWith('{') ? toDisplayDate(value.from) : undefined;
+ const rangeTo = value?.to && !value.to.startsWith('{') ? toDisplayDate(value.to) : undefined;
// The range calendar opens on the month of its first day, and on today's
// with none (objectui#10799): react-day-picker's `selected` does not move the
// month it opens on. A `from` that names no instant is no first day:
diff --git a/packages/plugin-dashboard/src/__tests__/DashboardFilterBar.dateOnlyZone-10844.test.tsx b/packages/plugin-dashboard/src/__tests__/DashboardFilterBar.dateOnlyZone-10844.test.tsx
new file mode 100644
index 0000000000..4cc845b7ef
--- /dev/null
+++ b/packages/plugin-dashboard/src/__tests__/DashboardFilterBar.dateOnlyZone-10844.test.tsx
@@ -0,0 +1,176 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * objectui#10844 — the dashboard date filter's custom range highlights, and
+ * opens on, the days it stores.
+ *
+ * ── What was measured ───────────────────────────────────────────────────────
+ * The range popover commits its bounds as LOCAL `yyyy-MM-dd` days and read
+ * them back with `new Date(value.from)` / `new Date(value.to)`, the engine's
+ * own parse, which reads a date-only string as UTC midnight. West of UTC the
+ * calendar therefore highlighted the day before each stored bound: clicking
+ * 15 Sep committed `{ from: '2026-09-15', to: '2026-09-15' }` and the calendar
+ * then highlighted the 14th, and a range stored from 1 Oct opened on
+ * September. The two bounds are now read through `toDisplayDate`
+ * (`@object-ui/core`, the objectui#10183 convention).
+ *
+ * ── ⚠️ The zone cases run ONLY when driven, in a FORKS child ────────────────
+ * `process.env.TZ` written inside a test of the normal run does not move the
+ * zone (the root config runs `pool: 'threads'`), so the zone cases are
+ * skipped there and `scripts/__tests__/date-only-zone-pins-10183.test.ts`
+ * runs them on the forks pool and fails unless every one ran and passed. Each
+ * zone opens with a rig case, so a child whose zone did not move reds instead
+ * of going quietly green. The suite-zone cases run in the normal run too; they
+ * cannot tell the repair from its absence, because UTC is the one offset where
+ * the two parses agree.
+ *
+ * `Asia/Shanghai` is the control: east of UTC the UTC-midnight parse already
+ * landed on the named day, and an hour-offset "repair" would break it.
+ *
+ * ⚠️ The popover is rendered open, as in
+ * `DashboardFilterBar.defaultMonth-10799.test.tsx`: with a custom range
+ * already stored, the select's "Custom…" item is its current value and picking
+ * it again does not reopen the popover (objectui#10843, not this card).
+ */
+
+import * as React from 'react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { cleanup, fireEvent, render } from '@testing-library/react';
+import { resolveDashboardFilterDefs, type DateRangeValue } from '@object-ui/core';
+
+vi.mock('@object-ui/components', async (importOriginal) => {
+ const actual = await importOriginal();
+ const Passthrough = ({ children }: { children?: React.ReactNode }) => <>{children}>;
+ return { ...actual, Popover: Passthrough, PopoverTrigger: Passthrough, PopoverContent: Passthrough };
+});
+
+// Imported after the mock is declared (vitest hoists `vi.mock` anyway).
+import { DashboardFilterBar } from '../DashboardFilterBar';
+
+const DRIVEN = process.env.OBJECTUI_DATE_ZONE_CHILD === '1';
+
+/** UTC-7 in September — the card's direction. */
+const WEST = 'America/Los_Angeles';
+/** UTC+8 — the control. */
+const EAST = 'Asia/Shanghai';
+
+/** Frozen inside September 2026, so an empty range opens on the month clicked below. */
+const CLOCK = '2026-09-03T10:35:00.000Z';
+/** A fixed instant: 21:00 on the 15th in the west, 12:00 on the 16th in the east. */
+const INSTANT = '2026-09-16T04:00:00.000Z';
+
+/** Freeze the clock, moving this forked process into `zone` first when given. */
+function enter(zone?: string): void {
+ if (zone) process.env.TZ = zone;
+ vi.useFakeTimers({ toFake: ['Date'] });
+ vi.setSystemTime(new Date(CLOCK));
+}
+
+afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+});
+
+const defs = resolveDashboardFilterDefs({ dateRange: { field: 'created_at' } });
+
+interface Face {
+ /** The `data-day` of every highlighted cell. */
+ selected: string[];
+ /** The captions of the two months shown, the first being the one it opens on. */
+ months: string[];
+}
+
+function face(range: DateRangeValue | undefined): Face {
+ render();
+ const selected = Array.from(document.body.querySelectorAll('td[data-selected="true"]'))
+ .map((td) => td.getAttribute('data-day') ?? '');
+ const months = Array.from(document.body.querySelectorAll('.rdp-caption_label'))
+ .map((el) => (el.textContent ?? '').trim());
+ cleanup();
+ return { selected, months };
+}
+
+/** Click one day on an empty range and hand back what the filter committed. */
+function commitByClick(day: string): unknown {
+ const onChange = vi.fn();
+ render();
+ const button = document.body.querySelector(`td[data-day="${day}"] button`) as HTMLElement | null;
+ expect(button, `no day cell for ${day}`).not.toBeNull();
+ fireEvent.click(button!);
+ cleanup();
+ expect(onChange).toHaveBeenCalledTimes(1);
+ return onChange.mock.calls[0][1];
+}
+
+const SEPT_15: Face = { selected: ['2026-09-15'], months: ['September 2026', 'October 2026'] };
+const OCT_1_TO_3: Face = {
+ selected: ['2026-10-01', '2026-10-02', '2026-10-03'],
+ months: ['October 2026', 'November 2026'],
+};
+
+describe('DashboardFilterBar custom range, in the suite zone (objectui#10844)', () => {
+ it('a stored range highlights and opens on the days it names', () => {
+ enter();
+ expect(face({ from: '2026-09-15', to: '2026-09-15' })).toEqual(SEPT_15);
+ expect(face({ from: '2026-10-01', to: '2026-10-03' })).toEqual(OCT_1_TO_3);
+ });
+
+ it('a bound naming a day its month does not have selects no day, and nothing throws', () => {
+ enter();
+ // The shared step refuses `2026-02-30` (objectui#10026) where the engine
+ // rolled it into March; a refused `from` opens on today, as an
+ // unparseable one already did.
+ expect(face({ from: '2026-02-30', to: '2026-02-30' })).toEqual({
+ selected: [],
+ months: ['September 2026', 'October 2026'],
+ });
+ expect(() => face({ from: '2026-09-15', to: '2026-02-30' })).not.toThrow();
+ });
+});
+
+describe.runIf(DRIVEN)('DashboardFilterBar custom range west of UTC (objectui#10844)', () => {
+ it('rig: the zone really moved', () => {
+ enter(WEST);
+ expect(Intl.DateTimeFormat().resolvedOptions().timeZone).toBe(WEST);
+ expect(new Date(INSTANT).getHours()).toBe(21);
+ });
+
+ it('fixture validity: the engine parse of a bound lands on the day before here', () => {
+ enter(WEST);
+ // Without this the cases below would be green for free.
+ expect(new Date('2026-09-15').getDate()).toBe(14);
+ expect(new Date('2026-10-01').getMonth()).toBe(8);
+ });
+
+ it('clicking 15 Sep commits the 15th, and the committed range highlights the 15th', () => {
+ enter(WEST);
+ const committed = commitByClick('2026-09-15');
+ expect(committed).toEqual({ from: '2026-09-15', to: '2026-09-15' });
+ expect(face(committed as DateRangeValue)).toEqual(SEPT_15);
+ });
+
+ it('a range stored from 1 Oct highlights 1-3 Oct and opens on October', () => {
+ enter(WEST);
+ expect(face({ from: '2026-10-01', to: '2026-10-03' })).toEqual(OCT_1_TO_3);
+ });
+});
+
+describe.runIf(DRIVEN)('DashboardFilterBar custom range east of UTC, the control (objectui#10844)', () => {
+ it('rig: the zone really moved', () => {
+ enter(EAST);
+ expect(Intl.DateTimeFormat().resolvedOptions().timeZone).toBe(EAST);
+ expect(new Date(INSTANT).getHours()).toBe(12);
+ });
+
+ it('a stored range highlights and opens on the days it names, as it already did', () => {
+ enter(EAST);
+ expect(face({ from: '2026-09-15', to: '2026-09-15' })).toEqual(SEPT_15);
+ expect(face({ from: '2026-10-01', to: '2026-10-03' })).toEqual(OCT_1_TO_3);
+ });
+});
diff --git a/packages/plugin-report/src/__tests__/formatValue.dateOnlyZone-10844.test.ts b/packages/plugin-report/src/__tests__/formatValue.dateOnlyZone-10844.test.ts
new file mode 100644
index 0000000000..1943115061
--- /dev/null
+++ b/packages/plugin-report/src/__tests__/formatValue.dateOnlyZone-10844.test.ts
@@ -0,0 +1,108 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * objectui#10844 — the report cell's date face names the day a date-only
+ * value stores, in every zone.
+ *
+ * ── What was measured ───────────────────────────────────────────────────────
+ * `formatValue` is the face `ReportViewer` gives a column with no `type` whose
+ * value looks like an ISO date, and an aggregated column (a `min` / `max` over
+ * a date). It parsed the value with the engine's own `Date` parse and read it
+ * back with LOCAL getters, so a date-only `2026-09-15` (UTC midnight) read
+ * `2026-09-14` for every viewer west of UTC. The parse is now the shared step,
+ * `toDisplayDate` (`@object-ui/core`, the objectui#10183 convention). Found by
+ * the objectui#10844 census of date-only read sites.
+ *
+ * ── ⚠️ The zone cases run ONLY when driven, in a FORKS child ────────────────
+ * `process.env.TZ` written inside a test of the normal run does not move the
+ * zone (the root config runs `pool: 'threads'`), so the zone cases are
+ * skipped there and `scripts/__tests__/date-only-zone-pins-10183.test.ts`
+ * runs them on the forks pool and fails unless every one ran and passed. Each
+ * zone opens with a rig case, so a child whose zone did not move reds instead
+ * of going quietly green. The suite-zone cases run in the normal run too; they
+ * cannot tell the repair from its absence, because UTC is the one offset where
+ * the two parses agree.
+ *
+ * `Asia/Shanghai` is the control: east of UTC the UTC-midnight parse already
+ * landed on the named day, and an hour-offset "repair" would break it.
+ */
+import { describe, it, expect } from 'vitest';
+import type { ReportField } from '@object-ui/types';
+import { formatValue } from '../formatValue';
+
+const DRIVEN = process.env.OBJECTUI_DATE_ZONE_CHILD === '1';
+
+/** UTC-7 in September — the card's direction. */
+const WEST = 'America/Los_Angeles';
+/** UTC+8 — the control. */
+const EAST = 'Asia/Shanghai';
+
+const DATE_ONLY = '2026-09-15';
+/** A fixed instant: 21:00 on the 15th in the west, 12:00 on the 16th in the east. */
+const INSTANT = '2026-09-16T04:00:00.000Z';
+
+/** A typed date column, reached through an aggregation. */
+const DATE_FIELD = { name: 'due', label: 'Due', type: 'date', aggregation: 'max' } as ReportField;
+
+/** Move this forked process into `zone`. */
+function enter(zone: string): void {
+ process.env.TZ = zone;
+}
+
+describe('report date cell, in the suite zone (objectui#10844)', () => {
+ it('a date-only value reads the day it stores, typed or sniffed', () => {
+ expect(formatValue(DATE_ONLY, DATE_FIELD)).toBe(DATE_ONLY);
+ expect(formatValue(DATE_ONLY)).toBe(DATE_ONLY);
+ });
+});
+
+describe.runIf(DRIVEN)('report date cell west of UTC (objectui#10844)', () => {
+ it('rig: the zone really moved', () => {
+ enter(WEST);
+ expect(Intl.DateTimeFormat().resolvedOptions().timeZone).toBe(WEST);
+ expect(new Date(INSTANT).getHours()).toBe(21);
+ });
+
+ it('fixture validity: the engine parse of the string lands on the day before here', () => {
+ enter(WEST);
+ // Without this the cases below would be green for free.
+ expect(new Date(DATE_ONLY).getDate()).toBe(14);
+ });
+
+ it('`2026-09-15` reads the 15th, typed or sniffed', () => {
+ enter(WEST);
+ expect(formatValue(DATE_ONLY, DATE_FIELD)).toBe(DATE_ONLY);
+ expect(formatValue(DATE_ONLY)).toBe(DATE_ONLY);
+ });
+
+ it('an instant keeps its local day, the 15th here', () => {
+ enter(WEST);
+ expect(formatValue(INSTANT, DATE_FIELD)).toBe('2026-09-15');
+ expect(formatValue(INSTANT)).toBe('2026-09-15');
+ });
+});
+
+describe.runIf(DRIVEN)('report date cell east of UTC, the control (objectui#10844)', () => {
+ it('rig: the zone really moved', () => {
+ enter(EAST);
+ expect(Intl.DateTimeFormat().resolvedOptions().timeZone).toBe(EAST);
+ expect(new Date(INSTANT).getHours()).toBe(12);
+ });
+
+ it('`2026-09-15` reads the 15th, as it already did', () => {
+ enter(EAST);
+ expect(formatValue(DATE_ONLY, DATE_FIELD)).toBe(DATE_ONLY);
+ expect(formatValue(DATE_ONLY)).toBe(DATE_ONLY);
+ });
+
+ it('an instant keeps its local day, the 16th here', () => {
+ enter(EAST);
+ expect(formatValue(INSTANT, DATE_FIELD)).toBe('2026-09-16');
+ });
+});
diff --git a/packages/plugin-report/src/formatValue.ts b/packages/plugin-report/src/formatValue.ts
index 9606691eda..90b2aebb62 100644
--- a/packages/plugin-report/src/formatValue.ts
+++ b/packages/plugin-report/src/formatValue.ts
@@ -7,6 +7,7 @@
*/
import type { ReportField } from '@object-ui/types';
+import { toDisplayDate } from '@object-ui/core';
import { DISPLAY_LOCALE_LAST_RESORT, formatNumberInDisplayLocale } from './displayLocale';
@@ -78,10 +79,18 @@ function isISODateString(value: any): boolean {
/**
* Format a date value to a readable yyyy-MM-dd format.
+ *
+ * The value is parsed by the shared step, `toDisplayDate` (`@object-ui/core`),
+ * and read back with LOCAL getters. The engine's own parse read a date-only
+ * `2026-09-15` as UTC midnight, so every viewer west of UTC read `2026-09-14`
+ * in the report cell (objectui#10844, the objectui#10183 convention). A value
+ * the shared step refuses, such as a day its month does not have, renders as
+ * the raw stored string, this cell's face for an unparseable value
+ * (objectui#10026).
*/
function formatDate(value: any): string {
try {
- const date = new Date(value);
+ const date = toDisplayDate(value);
if (isNaN(date.getTime())) return String(value);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
diff --git a/scripts/__tests__/date-only-zone-pins-10183.test.ts b/scripts/__tests__/date-only-zone-pins-10183.test.ts
index 867af74b11..4fdd83ab3e 100644
--- a/scripts/__tests__/date-only-zone-pins-10183.test.ts
+++ b/scripts/__tests__/date-only-zone-pins-10183.test.ts
@@ -21,7 +21,10 @@ import { fileURLToPath } from 'node:url';
* colouring. Each has a pin beside it, listed in {@link PINS}.
* objectui#10293 added a fifth under the same convention: the `ui:calendar`
* primitive's selected date, which the renderer now coerces through the same
- * shared step.
+ * shared step. objectui#10844, the family's closure card, added the dashboard
+ * filter's custom range bounds, the `date-picker` renderer's value and the
+ * report cell's date face; a later date-only read site registers here the
+ * same way.
*
* ## Why a driver, and why the forks pool
*
@@ -65,6 +68,12 @@ const PINS = [
// objectui#10293: the `ui:calendar` primitive's selected date, the same
// date-only convention at a fifth read site.
'packages/components/src/renderers/form/__tests__/calendar.dateValueZone-10293.test.tsx',
+ // objectui#10844, the family's closure card: the dashboard filter's custom
+ // range bounds, the `date-picker` renderer's authored value, and the report
+ // cell's date face its census found.
+ 'packages/plugin-dashboard/src/__tests__/DashboardFilterBar.dateOnlyZone-10844.test.tsx',
+ 'packages/components/src/renderers/form/__tests__/date-picker.dateValueZone-10844.test.tsx',
+ 'packages/plugin-report/src/__tests__/formatValue.dateOnlyZone-10844.test.ts',
] as const;
/** The vitest CLI entry, resolved rather than assumed at a `node_modules` path. */