Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .changeset/10844-date-only-zone-closure.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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(<DatePicker schema={{ type: 'date-picker', id: 'dp-10844' }} value={value} />);
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);
});
});
43 changes: 37 additions & 6 deletions packages/components/src/renderers/form/date-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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) {
Expand Down Expand Up @@ -57,7 +88,7 @@ ComponentRegistry.register('date-picker',
{...toFormControlDomProps(triggerProps)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{value ? format(value, schema.format || 'PPP', { locale }) : <span>{schema.placeholder || 'Pick a date'}</span>}
{day ? format(day, schema.format || 'PPP', { locale }) : <span>{schema.placeholder || 'Pick a date'}</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
Expand All @@ -69,8 +100,8 @@ ComponentRegistry.register('date-picker',
*/}
<Calendar
mode="single"
defaultMonth={value}
selected={value}
defaultMonth={calendarDay}
selected={calendarDay}
onSelect={handleSelect}
autoFocus
/>
Expand Down
11 changes: 9 additions & 2 deletions packages/plugin-dashboard/src/DashboardFilterBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading