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. */