From 1023b61da7285dfc442b615b304dcd2dd506a63e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 05:17:00 +0000 Subject: [PATCH 1/5] fix(plugin-timeline,plugin-charts,i18n): read a stored date-only day as that day in every zone (objectui#10866, slice 2) The timeline's date bucket, sort and item date, the chart's date-only x-axis tick, and @object-ui/i18n's formatDate / formatDateTime / formatRelativeTime / formatDateSpec parsed a YYYY-MM-DD value with the engine's own Date parse (UTC midnight), so every viewer west of UTC read the day before. Each now reads it through core's toDisplayDate; a value with a time part keeps its instant. The ISO faces (timeline iso, chart locale fallback) print a date-only value's day from local getters, since toISOString of the local midnight names the day before east of UTC. formatDateSpec applies its timeZone to an instant only. Four zone pins registered in the date-only zone-pin driver, each under America/Los_Angeles and Asia/Shanghai with an instant control. Claude-Session: https://claude.ai/code/session_01DuWo5bdP9SdVebamn99GGk Co-authored-by: Claude --- .../10866-timeline-charts-i18n-date-only.md | 13 ++ content/docs/plugins/plugin-timeline.mdx | 2 + packages/i18n/README.md | 6 + .../formatting.dateOnlyZone-10866.test.ts | 190 ++++++++++++++++ packages/i18n/src/utils/formatting.ts | 27 ++- packages/i18n/src/utils/spec-formatters.ts | 21 +- .../plugin-charts/src/AdvancedChartImpl.tsx | 22 +- ...ancedChartImpl.dateOnlyZone-10866.test.tsx | 167 +++++++++++++++ .../plugin-timeline/src/ObjectTimeline.tsx | 28 ++- ...ObjectTimeline.dateOnlyZone-10866.test.tsx | 202 ++++++++++++++++++ ...melineRenderer.dateOnlyZone-10866.test.tsx | 154 +++++++++++++ packages/plugin-timeline/src/renderer.tsx | 18 +- .../date-only-zone-pins-10183.test.ts | 12 +- 13 files changed, 845 insertions(+), 17 deletions(-) create mode 100644 .changeset/10866-timeline-charts-i18n-date-only.md create mode 100644 packages/i18n/src/__tests__/formatting.dateOnlyZone-10866.test.ts create mode 100644 packages/plugin-charts/src/__tests__/AdvancedChartImpl.dateOnlyZone-10866.test.tsx create mode 100644 packages/plugin-timeline/src/__tests__/ObjectTimeline.dateOnlyZone-10866.test.tsx create mode 100644 packages/plugin-timeline/src/__tests__/TimelineRenderer.dateOnlyZone-10866.test.tsx diff --git a/.changeset/10866-timeline-charts-i18n-date-only.md b/.changeset/10866-timeline-charts-i18n-date-only.md new file mode 100644 index 0000000000..d5f4b74a3b --- /dev/null +++ b/.changeset/10866-timeline-charts-i18n-date-only.md @@ -0,0 +1,13 @@ +--- +'@object-ui/plugin-timeline': patch +'@object-ui/plugin-charts': patch +'@object-ui/i18n': patch +--- + +fix(plugin-timeline,plugin-charts,i18n): a stored date-only day reads as that day in every viewer zone on the timeline, on a chart's date axis and in the published date helpers (objectui#10866, slice 2) + +These readers parsed a `YYYY-MM-DD` value with the engine's own `Date` parse, which reads it as UTC midnight, so every viewer west of UTC saw the day before. Each now reads the value through `toDisplayDate` from `@object-ui/core`, which rebuilds a date-only value at local midnight of the day it names. A value with a time part keeps its instant. + +- **Timeline.** The object timeline's date bucket, its sort and the item date the renderer prints read the value that way. West of UTC an item due today no longer sits under "Overdue", one due tomorrow no longer sits under "Today", and the `short` and `long` item faces print the stored day. The `iso` face prints a date-only value's day from local getters, and an instant's UTC day as before. A day its month does not have, such as `2026-02-30`, is no longer rolled into March: the item sits under "No date", sorts with the dateless items, and its `short` and `long` faces are the ones an unparsable value already had. An unparsable value now sorts with the dateless items too, and the `iso` face prints such a value as written where it used to throw. The gantt variant's axis headers and extent are not changed here. +- **Charts.** A date-only category on the x axis reads `Sep 1` for `2026-09-01`, and `Sep 2026` on a month-grained axis, in every zone. When the display locale is a tag `Intl` refuses, the tick still prints the stored day: it reads the local-midnight value with local getters, because its `toISOString()` would name the day before east of UTC. An instant's fallback keeps its UTC day. A date-only category naming a day its month does not have falls back to the raw category, the face any other non-date category gets, instead of the rolled day. +- **i18n helpers.** `formatDate`, `formatDateTime`, `formatRelativeTime` and `formatDateSpec` read a date-only string as the day it names: `formatDateTime` shows midnight of that day, and `formatRelativeTime` counts to the start of it. `formatDateSpec` applies its `timeZone` to an instant only, and formats a date-only value in the local zone, where the shared step's local midnight reads back as its day; a `timeZone` west of UTC no longer turns `2026-09-01` into August 31st for every viewer. All four return a day its month does not have as the raw string. `formatRelativeTime` now returns an unparsable value as its string, the face the other three already gave one, where it used to throw a `RangeError`. diff --git a/content/docs/plugins/plugin-timeline.mdx b/content/docs/plugins/plugin-timeline.mdx index 05383a6d93..401af7a7e3 100644 --- a/content/docs/plugins/plugin-timeline.mdx +++ b/content/docs/plugins/plugin-timeline.mdx @@ -326,6 +326,8 @@ const long: TimelineSchema['dateFormat'] = 'long' // "January 15, 2024" const iso: TimelineSchema['dateFormat'] = 'iso' // "2024-01-15" ``` +A date-only value (`2024-01-15`) names a calendar day, so every face prints that day in every viewer's time zone, and the object timeline buckets and sorts it as that day. A value with a time part is an instant: `short` and `long` print its day in the viewer's zone, and `iso` prints its UTC day. + ## Time Scales (Gantt) For Gantt-style timelines, choose the appropriate time scale: diff --git a/packages/i18n/README.md b/packages/i18n/README.md index 03d958f0d7..94cc261c68 100644 --- a/packages/i18n/README.md +++ b/packages/i18n/README.md @@ -163,6 +163,12 @@ formatNumber(1234567, { locale: 'de' }); // "1.234.567" formatRelativeTime(Date.now() - 3 * 86_400_000, 'en'); // "3 days ago" ``` +A date-only string such as `'2026-09-01'` names a calendar day: every date +helper reads it as that day in every viewer's time zone (`formatDateTime` shows +its midnight, and `formatRelativeTime` counts to the start of it). A string +with a time part is an instant, read in the viewer's zone. `formatDateSpec` +applies its `timeZone` to an instant only. + ### Built-in locales — one is resident, nine are fetched on demand The package entry re-exports **`en` only**. It is `fallbackLng`, it is the diff --git a/packages/i18n/src/__tests__/formatting.dateOnlyZone-10866.test.ts b/packages/i18n/src/__tests__/formatting.dateOnlyZone-10866.test.ts new file mode 100644 index 0000000000..e3f7f04e7b --- /dev/null +++ b/packages/i18n/src/__tests__/formatting.dateOnlyZone-10866.test.ts @@ -0,0 +1,190 @@ +/** + * 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#10866 — the published date helpers name the day a date-only value + * stores, in every zone. + * + * ── What was measured ─────────────────────────────────────────────────────── + * `formatDate`, `formatDateTime` and `formatRelativeTime` (`utils/formatting`) + * and `formatDateSpec` (`utils/spec-formatters`) parsed a string with the + * engine's own `Date` parse, which reads a date-only `2026-09-01` as UTC + * midnight. Every viewer west of UTC then read August 31st, and + * `formatDateSpec` given a `timeZone` west of UTC read August 31st for every + * viewer. The parse is now the shared step, `toDisplayDate` (`@object-ui/core`, + * the objectui#10183 convention), which tells the two shapes apart by the + * value: a date-only string is rebuilt at local midnight of the day it names, + * and a value with a time part keeps its instant. `formatDateSpec` hands its + * `timeZone` to an instant only: a date-only value names a day, and the + * shared step's result reads back as that day only in the local zone. + * + * A day its month does not have (`2026-02-30`) is refused by the shared step + * (objectui#10026), so each helper renders it as the raw string, the face all + * four already give an unparsable value (`formatRelativeTime` used to throw + * for one). + * + * ── ⚠️ 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. + * + * `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 { afterEach, describe, expect, it, vi } from 'vitest'; +import { formatDate, formatDateTime, formatRelativeTime } from '../utils/formatting'; +import { formatDateSpec } from '../utils/spec-formatters'; + +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 DAY = '2026-09-01'; +/** A fixed instant: 20:00 on the 1st in the west, 11:00 on the 2nd in the east, 23:00 on the 1st in New York. */ +const INSTANT = '2026-09-02T03:00:00.000Z'; +/** 10:00 on August 30th in each zone, so the relative phrases below read alike. */ +const TEN_AM: Record = { + [WEST]: '2026-08-30T17:00:00.000Z', + [EAST]: '2026-08-30T02:00:00.000Z', + UTC: '2026-08-30T10:00:00.000Z', +}; + +const EN = { locale: 'en-US' } as const; + +/** Freeze the clock at 10:00 local, 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(TEN_AM[zone ?? 'UTC'])); +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('i18n date helpers, in the suite zone (objectui#10866)', () => { + it('`formatDateSpec` given a `timeZone` west of UTC reads a date-only value as the day it stores', () => { + enter(); + expect(formatDateSpec(DAY, { dateStyle: 'medium', timeZone: 'America/New_York' }, 'en-US')).toBe('Sep 1, 2026'); + }); + + it('control: an instant is still read in the `timeZone` it is handed', () => { + enter(); + expect(formatDateSpec(INSTANT, { dateStyle: 'medium', timeZone: 'America/New_York' }, 'en-US')).toBe('Sep 1, 2026'); + expect(formatDateSpec(INSTANT, { dateStyle: 'medium', timeZone: 'Asia/Tokyo' }, 'en-US')).toBe('Sep 2, 2026'); + }); + + it('a day its month does not have renders as the raw string, not the rolled day', () => { + enter(); + expect(formatDate('2026-02-30', EN)).toBe('2026-02-30'); + expect(formatDateTime('2026-02-30', EN)).toBe('2026-02-30'); + expect(formatDateSpec('2026-02-30', { dateStyle: 'medium' }, 'en-US')).toBe('2026-02-30'); + expect(formatRelativeTime('2026-02-30', 'en-US')).toBe('2026-02-30'); + }); + + it('`formatRelativeTime` renders an unparsable value as its string rather than throwing', () => { + enter(); + expect(formatRelativeTime('not a date', 'en-US')).toBe('not a date'); + }); +}); + +describe.runIf(DRIVEN)('i18n date helpers west of UTC (objectui#10866)', () => { + 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(DAY).getDate()).toBe(31); + }); + + it('`formatDate` reads `2026-09-01` as September 1st', () => { + enter(WEST); + expect(formatDate(DAY, EN)).toBe('Sep 1, 2026'); + expect(formatDate(DAY, { ...EN, style: 'full' })).toBe('Tuesday, September 1, 2026'); + expect(formatDate(DAY, { ...EN, dateStyle: 'short' })).toBe('9/1/26'); + }); + + it('`formatDateTime` reads it as midnight of September 1st', () => { + enter(WEST); + expect(formatDateTime(DAY, EN)).toBe('Sep 1, 2026, 12:00 AM'); + }); + + it('`formatRelativeTime` counts to the start of that day', () => { + enter(WEST); + expect(formatRelativeTime(DAY, 'en-US')).toBe('in 2 days'); + expect(formatRelativeTime('2026-08-28', 'en-US')).toBe('2 days ago'); + }); + + it('`formatDateSpec` reads it as September 1st, with or without a `timeZone`', () => { + enter(WEST); + expect(formatDateSpec(DAY, { dateStyle: 'medium' }, 'en-US')).toBe('Sep 1, 2026'); + expect(formatDateSpec(DAY, { dateStyle: 'medium', timeZone: 'America/New_York' }, 'en-US')).toBe('Sep 1, 2026'); + expect(formatDateSpec(DAY, { dateStyle: 'medium', timeZone: 'Asia/Tokyo' }, 'en-US')).toBe('Sep 1, 2026'); + }); + + it('control: an instant keeps its local day here, the 1st, and its `timeZone` day', () => { + enter(WEST); + expect(formatDate(INSTANT, EN)).toBe('Sep 1, 2026'); + expect(formatDateTime(INSTANT, EN)).toBe('Sep 1, 2026, 8:00 PM'); + expect(formatRelativeTime('2026-08-31T17:00:00.000Z', 'en-US')).toBe('tomorrow'); + expect(formatDateSpec(INSTANT, { dateStyle: 'medium' }, 'en-US')).toBe('Sep 1, 2026'); + expect(formatDateSpec(INSTANT, { dateStyle: 'medium', timeZone: 'Asia/Tokyo' }, 'en-US')).toBe('Sep 2, 2026'); + }); +}); + +describe.runIf(DRIVEN)('i18n date helpers east of UTC, the control (objectui#10866)', () => { + it('rig: the zone really moved', () => { + enter(EAST); + expect(Intl.DateTimeFormat().resolvedOptions().timeZone).toBe(EAST); + expect(new Date(INSTANT).getHours()).toBe(11); + }); + + it('`formatDate` reads `2026-09-01` as September 1st, as it already did', () => { + enter(EAST); + expect(formatDate(DAY, EN)).toBe('Sep 1, 2026'); + expect(formatDate(DAY, { ...EN, style: 'full' })).toBe('Tuesday, September 1, 2026'); + expect(formatDate(DAY, { ...EN, dateStyle: 'short' })).toBe('9/1/26'); + }); + + it('`formatDateTime` reads it as midnight of September 1st', () => { + enter(EAST); + expect(formatDateTime(DAY, EN)).toBe('Sep 1, 2026, 12:00 AM'); + }); + + it('`formatRelativeTime` counts to the start of that day', () => { + enter(EAST); + expect(formatRelativeTime(DAY, 'en-US')).toBe('in 2 days'); + expect(formatRelativeTime('2026-08-28', 'en-US')).toBe('2 days ago'); + }); + + it('`formatDateSpec` reads it as September 1st, with or without a `timeZone`', () => { + enter(EAST); + expect(formatDateSpec(DAY, { dateStyle: 'medium' }, 'en-US')).toBe('Sep 1, 2026'); + expect(formatDateSpec(DAY, { dateStyle: 'medium', timeZone: 'America/New_York' }, 'en-US')).toBe('Sep 1, 2026'); + expect(formatDateSpec(DAY, { dateStyle: 'medium', timeZone: 'Asia/Tokyo' }, 'en-US')).toBe('Sep 1, 2026'); + }); + + it('control: an instant keeps its local day here, the 2nd, and its `timeZone` day', () => { + enter(EAST); + expect(formatDate(INSTANT, EN)).toBe('Sep 2, 2026'); + expect(formatDateTime(INSTANT, EN)).toBe('Sep 2, 2026, 11:00 AM'); + expect(formatRelativeTime('2026-08-31T17:00:00.000Z', 'en-US')).toBe('in 2 days'); + expect(formatDateSpec(INSTANT, { dateStyle: 'medium' }, 'en-US')).toBe('Sep 2, 2026'); + expect(formatDateSpec(INSTANT, { dateStyle: 'medium', timeZone: 'America/New_York' }, 'en-US')).toBe('Sep 1, 2026'); + }); +}); diff --git a/packages/i18n/src/utils/formatting.ts b/packages/i18n/src/utils/formatting.ts index 19f336868f..a290aed228 100644 --- a/packages/i18n/src/utils/formatting.ts +++ b/packages/i18n/src/utils/formatting.ts @@ -2,8 +2,21 @@ * @object-ui/i18n - Date and currency formatting utilities * * Uses the native Intl API for locale-aware formatting. + * + * Every date helper below reads a string through `toDisplayDate` + * (`@object-ui/core`), the one parse step behind the date faces + * (objectui#10110, objectui#10183). The engine's own parse reads a date-only + * `2026-09-01` as UTC midnight, so each helper named August 31st for every + * viewer west of UTC (objectui#10866). The shared step tells the two shapes + * apart by the value: a date-only string is rebuilt at local midnight of the + * day it names, and a value with a time part keeps its instant. It also + * refuses a day its month does not have (`2026-02-30`, objectui#10026), which + * each helper then renders as the raw string, its face for any unparsable + * value. */ +import { toDisplayDate } from '@object-ui/core'; + export interface DateFormatOptions { locale?: string; style?: 'short' | 'medium' | 'long' | 'full'; @@ -35,7 +48,7 @@ export function formatDate( options: DateFormatOptions = {}, ): string { const { locale = 'en', style = 'medium' } = options; - const d = date instanceof Date ? date : new Date(date); + const d = toDisplayDate(date); if (isNaN(d.getTime())) { return String(date); @@ -63,7 +76,7 @@ export function formatDateTime( options: DateFormatOptions = {}, ): string { const { locale = 'en', style = 'medium' } = options; - const d = date instanceof Date ? date : new Date(date); + const d = toDisplayDate(date); if (isNaN(d.getTime())) { return String(date); @@ -81,12 +94,20 @@ export function formatDateTime( /** * Format a relative time (e.g., "2 days ago", "in 3 hours") + * + * A date-only value counts from now to the START of the day it names (local + * midnight), the same distance in every zone at the same wall-clock time. + * An unparsable value comes back as its string, as it does from + * {@link formatDate}; `Intl.RelativeTimeFormat` would throw on it. */ export function formatRelativeTime( date: Date | string | number, locale = 'en', ): string { - const d = date instanceof Date ? date : new Date(date); + const d = toDisplayDate(date); + if (isNaN(d.getTime())) { + return String(date); + } const now = new Date(); const diffMs = d.getTime() - now.getTime(); const diffSec = Math.round(diffMs / 1000); diff --git a/packages/i18n/src/utils/spec-formatters.ts b/packages/i18n/src/utils/spec-formatters.ts index 3e9dc7859e..44314de897 100644 --- a/packages/i18n/src/utils/spec-formatters.ts +++ b/packages/i18n/src/utils/spec-formatters.ts @@ -30,6 +30,8 @@ * @module spec-formatters */ +import { isRealCalendarDate, toDisplayDate } from '@object-ui/core'; + // ============================================================================ // Plural rules // ============================================================================ @@ -115,6 +117,19 @@ export interface SpecDateFormat { /** * Format a date using a {@link SpecDateFormat} configuration. * + * A string is read through `toDisplayDate` (`@object-ui/core`), the one parse + * step behind the date faces (objectui#10866): a date-only `2026-09-01` names + * a calendar day and is rebuilt at local midnight of that day, while a value + * with a time part keeps its instant. The engine's own parse read the + * date-only form as UTC midnight, so it named August 31st west of UTC, and + * for every viewer once `timeZone` was west of UTC. + * + * `timeZone` therefore applies to an instant only. A date-only value carries + * no instant for a zone to place, and the local midnight the shared step + * built reads back as its day only in the local zone, so it is formatted + * there. A value the shared step refuses, such as a day its month does not + * have (objectui#10026), comes back as its string. + * * @example * ```ts * formatDateSpec(new Date(), { @@ -129,13 +144,15 @@ export function formatDateSpec( format: SpecDateFormat, locale = 'en', ): string { - const d = date instanceof Date ? date : new Date(date); + const d = toDisplayDate(date); if (isNaN(d.getTime())) return String(date); + // The value's shape tells the two apart, the same test the shared step makes. + const dateOnly = typeof date === 'string' && isRealCalendarDate(date); const options: Intl.DateTimeFormatOptions = {}; if (format.dateStyle) options.dateStyle = format.dateStyle; if (format.timeStyle) options.timeStyle = format.timeStyle; - if (format.timeZone) options.timeZone = format.timeZone; + if (format.timeZone && !dateOnly) options.timeZone = format.timeZone; if (format.hour12 !== undefined) options.hour12 = format.hour12; return new Intl.DateTimeFormat(locale, options).format(d); diff --git a/packages/plugin-charts/src/AdvancedChartImpl.tsx b/packages/plugin-charts/src/AdvancedChartImpl.tsx index ea7bf84dc9..6965130bfd 100644 --- a/packages/plugin-charts/src/AdvancedChartImpl.tsx +++ b/packages/plugin-charts/src/AdvancedChartImpl.tsx @@ -48,7 +48,7 @@ import { } from './ChartContainerImpl'; import { mapScatterClick, mapTreemapClick, mapSankeyClick } from './chartDrillEvents'; import { formatterFor, domainFor, ticksFor, RENDERABLE, SINGLE_VALUE_CHART_TYPES, TABULAR_CHART_TYPES, effectiveChartFamily, comboBaseFamily, placeYAxes, type NormalizedAxis, type NormalizedSeries, type ValueAxisSlot, type YAxisPlacement } from './normalizeChartSchema'; -import { buildCategoryRank, chartRowBucketId, type ChartSegmentClickEvent } from '@object-ui/core'; +import { buildCategoryRank, chartRowBucketId, isRealCalendarDate, toDateInputValue, toDisplayDate, type ChartSegmentClickEvent } from '@object-ui/core'; import { useDisplayLocale, useSafeTranslate } from '@object-ui/i18n'; // Default color fallback for chart series @@ -1708,9 +1708,20 @@ function AdvancedChartImplInner({ // Detect ISO 8601 date / datetime strings (YYYY-MM-DD or with time component) const isoLike = /^\d{4}-\d{2}-\d{2}/.test(str); if (isoLike) { - const d = new Date(str); + // The shared step, `toDisplayDate` (`@object-ui/core`, objectui#10866): + // the engine's own parse read a date-only `2026-09-01` as UTC midnight, + // so every viewer west of UTC read `Aug 31` on the axis. The shared step + // tells the two shapes apart by the value: a date-only category is + // local midnight of the day it names, and one with a time part keeps its + // instant. A value it refuses (a day its month does not have, + // objectui#10026) falls through to the raw category below. + const d = toDisplayDate(str); if (!Number.isNaN(d.getTime())) { - // Choose granularity based on data span: <= 31 days → MMM D, otherwise MMM YYYY + // Choose granularity based on data span: <= 62 days → MMM D, otherwise MMM YYYY. + // The span is a DURATION between two categories of one axis, so it + // keeps the engine parse: two date-only values are then both UTC + // midnights and a whole number of days apart, where two local + // midnights would be an hour off across a DST change. const span = data.length > 1 ? Math.abs(new Date(String(data[data.length - 1][xAxisKey] ?? '')).getTime() - new Date(String(data[0][xAxisKey] ?? '')).getTime()) @@ -1722,7 +1733,10 @@ function AdvancedChartImplInner({ } return d.toLocaleDateString(displayLocale, { month: 'short', year: 'numeric' }); } catch { - return d.toISOString().slice(0, 10); + // A locale `Intl` refuses. A date-only category prints its day from + // LOCAL getters (`toISOString()` of its local midnight would name + // the day before east of UTC); an instant keeps its UTC day. + return isRealCalendarDate(str) ? toDateInputValue(d) : d.toISOString().slice(0, 10); } } } diff --git a/packages/plugin-charts/src/__tests__/AdvancedChartImpl.dateOnlyZone-10866.test.tsx b/packages/plugin-charts/src/__tests__/AdvancedChartImpl.dateOnlyZone-10866.test.tsx new file mode 100644 index 0000000000..aac021122d --- /dev/null +++ b/packages/plugin-charts/src/__tests__/AdvancedChartImpl.dateOnlyZone-10866.test.tsx @@ -0,0 +1,167 @@ +/** + * 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#10866 — a chart's x-axis tick names the day a date-only category + * stores, in every zone. + * + * ── What was measured ─────────────────────────────────────────────────────── + * `AdvancedChartImpl`'s `formatTick` parsed an ISO-looking category with the + * engine's own `Date` parse, which reads a date-only `2026-09-01` as UTC + * midnight, and printed it with `toLocaleDateString`: every viewer west of + * UTC read `Aug 31` on the axis, and `Aug 2026` on a month-grained one. Its + * fallback for a locale `Intl` refuses printed `toISOString()`, which was + * right for a date-only value only because the parse was UTC too: once the + * parse is local midnight, the same print names the day before EAST of UTC. + * So the fallback rows below are green on the base as well, on purpose: they + * are the half a parse-only repair would break. + * + * The parse is now the shared step, `toDisplayDate` (`@object-ui/core`, the + * objectui#10183 convention). A date-only category prints its day from local + * getters on the fallback; a category with a time part keeps its instant, and + * its fallback keeps the instant's UTC day. + * + * ── ⚠️ 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. + * + * `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 { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render } from '@testing-library/react'; +import { LocalizationProvider } from '@object-ui/i18n'; + +vi.mock('recharts', async () => { + const actual = await vi.importActual('recharts'); + return { + ...actual, + ResponsiveContainer: ({ children }: any) => + React.cloneElement(children, { width: 480, height: 320 }), + }; +}); + +import AdvancedChartImpl from '../AdvancedChartImpl'; + +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'; + +/** Two date-only days: a span under the 62-day bound, so the ticks are `MMM D`. */ +const DAYS = ['2026-09-01', '2026-09-02']; +/** Two date-only month starts a quarter apart, so the ticks are `MMM YYYY`. */ +const MONTHS = ['2026-09-01', '2026-12-01']; +/** Two instants: 20:00 on the 1st and 2nd in the west, 11:00 on the 2nd and 3rd in the east. */ +const INSTANTS = ['2026-09-02T03:00:00.000Z', '2026-09-03T03:00:00.000Z']; + +/** A tag `Intl` refuses, which sends `formatTick` to its fallback. */ +const REFUSED_LOCALE = 'not a tag!'; + +/** Move this forked process into `zone`. */ +function enter(zone: string): void { + process.env.TZ = zone; +} + +afterEach(() => cleanup()); + +/** The x-axis tick labels a bar chart over `categories` draws. */ +function ticks(categories: string[], locale = 'en-US'): string[] { + const data = categories.map((day, i) => ({ day, count: i + 1 })); + const { container } = render( + + + , + ); + const out = Array.from( + container.querySelectorAll('.recharts-xAxis-tick-labels .recharts-cartesian-axis-tick-value'), + ).map((el) => el.textContent ?? ''); + cleanup(); + return out; +} + +describe('chart date ticks, in the suite zone (objectui#10866)', () => { + it('a date-only category reads the day it stores, on each face', () => { + expect(ticks(DAYS)).toEqual(['Sep 1', 'Sep 2']); + expect(ticks(MONTHS)).toEqual(['Sep 2026', 'Dec 2026']); + expect(ticks(DAYS, REFUSED_LOCALE)).toEqual(DAYS); + }); + + it('a day its month does not have is not rolled into March: it prints as written', () => { + expect(ticks(['2026-02-28', '2026-02-30'])).toEqual(['Feb 28', '2026-02-30']); + }); +}); + +describe.runIf(DRIVEN)('chart date ticks west of UTC (objectui#10866)', () => { + it('rig: the zone really moved', () => { + enter(WEST); + expect(Intl.DateTimeFormat().resolvedOptions().timeZone).toBe(WEST); + expect(new Date(INSTANTS[0]).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(DAYS[0]).getDate()).toBe(31); + }); + + it('`2026-09-01` reads `Sep 1`, and a month axis reads `Sep 2026`', () => { + enter(WEST); + expect(ticks(DAYS)).toEqual(['Sep 1', 'Sep 2']); + expect(ticks(MONTHS)).toEqual(['Sep 2026', 'Dec 2026']); + }); + + it('the fallback prints the day it stores', () => { + enter(WEST); + expect(ticks(DAYS, REFUSED_LOCALE)).toEqual(DAYS); + }); + + it('control: an instant keeps its local day here, and its fallback keeps its UTC day', () => { + enter(WEST); + expect(ticks(INSTANTS)).toEqual(['Sep 1', 'Sep 2']); + expect(ticks(INSTANTS, REFUSED_LOCALE)).toEqual(['2026-09-02', '2026-09-03']); + }); +}); + +describe.runIf(DRIVEN)('chart date ticks east of UTC, the control (objectui#10866)', () => { + it('rig: the zone really moved', () => { + enter(EAST); + expect(Intl.DateTimeFormat().resolvedOptions().timeZone).toBe(EAST); + expect(new Date(INSTANTS[0]).getHours()).toBe(11); + }); + + it('`2026-09-01` reads `Sep 1`, as it already did', () => { + enter(EAST); + expect(ticks(DAYS)).toEqual(['Sep 1', 'Sep 2']); + expect(ticks(MONTHS)).toEqual(['Sep 2026', 'Dec 2026']); + }); + + it('the fallback prints the day it stores, not the day before', () => { + enter(EAST); + expect(ticks(DAYS, REFUSED_LOCALE)).toEqual(DAYS); + }); + + it('control: an instant keeps its local day here, and its fallback keeps its UTC day', () => { + enter(EAST); + expect(ticks(INSTANTS)).toEqual(['Sep 2', 'Sep 3']); + expect(ticks(INSTANTS, REFUSED_LOCALE)).toEqual(['2026-09-02', '2026-09-03']); + }); +}); diff --git a/packages/plugin-timeline/src/ObjectTimeline.tsx b/packages/plugin-timeline/src/ObjectTimeline.tsx index c7aae01e6d..22640e491e 100644 --- a/packages/plugin-timeline/src/ObjectTimeline.tsx +++ b/packages/plugin-timeline/src/ObjectTimeline.tsx @@ -10,7 +10,7 @@ import React, { useEffect, useState, useCallback, useMemo, useRef } from 'react' import type { DataSource, TimelineSchema, ListViewTimelineConfig } from '@object-ui/types'; import { useDataScope, useNavigationOverlay, useSafeFieldLabel, useSettledSchema, useDataInvalidation, useFilterScope, useResolvedFilter } from '@object-ui/react'; import { NavigationOverlay } from '@object-ui/components'; -import { extractRecords, buildExpandFields, convertSortToQueryParams, createFieldColorResolver, recordDisplayValueAt } from '@object-ui/core'; +import { extractRecords, buildExpandFields, convertSortToQueryParams, createFieldColorResolver, recordDisplayValueAt, toDisplayDate } from '@object-ui/core'; import { usePermissions } from '@object-ui/permissions'; import { usePullToRefresh } from '@object-ui/mobile'; import { z } from 'zod'; @@ -663,11 +663,31 @@ export const ObjectTimeline: React.FC = ({ }; }); + // Every read of the start value below — this sort, the date bucket, and + // the renderer's item date — goes through the shared step, + // `toDisplayDate` (`@object-ui/core`, objectui#10866). The engine's own + // parse read a date-only `2026-10-06` as UTC midnight, so west of UTC an + // item due today bucketed as "Overdue" and sorted among instants at the + // wrong hour. The shared step tells the two shapes apart by the value: a + // date-only string is local midnight of the day it names, and a value + // with a time part keeps its instant. The three must read alike: the + // vertical renderer groups ADJACENT items, so a sort that disagreed with + // the buckets would split a bucket or order it wrongly. + // + // A value the shared step refuses (unparsable, or a day its month does + // not have, objectui#10026) sorts with the dateless items, as it buckets + // with them under "No date". + const sortKey = (raw: unknown): number => { + if (!raw) return Number.POSITIVE_INFINITY; + const ts = toDisplayDate(raw as string).getTime(); + return Number.isNaN(ts) ? Number.POSITIVE_INFINITY : ts; + }; + // Sort by start date ascending; nulls sink to the end so users see // upcoming work first. mapped.sort((a, b) => { - const ta = a.startDate ? new Date(a.startDate).getTime() : Number.POSITIVE_INFINITY; - const tb = b.startDate ? new Date(b.startDate).getTime() : Number.POSITIVE_INFINITY; + const ta = sortKey(a.startDate); + const tb = sortKey(b.startDate); return ta - tb; }); @@ -687,7 +707,7 @@ export const ObjectTimeline: React.FC = ({ const dateBucket = (raw: any): string => { if (!raw) return t('timeline.bucket.noDate'); - const ts = startOfDay(new Date(raw)); + const ts = startOfDay(toDisplayDate(raw)); if (Number.isNaN(ts)) return t('timeline.bucket.noDate'); if (ts < today) return t('timeline.bucket.overdue'); if (ts === today) return t('timeline.bucket.today'); diff --git a/packages/plugin-timeline/src/__tests__/ObjectTimeline.dateOnlyZone-10866.test.tsx b/packages/plugin-timeline/src/__tests__/ObjectTimeline.dateOnlyZone-10866.test.tsx new file mode 100644 index 0000000000..c74d1704bb --- /dev/null +++ b/packages/plugin-timeline/src/__tests__/ObjectTimeline.dateOnlyZone-10866.test.tsx @@ -0,0 +1,202 @@ +/** + * 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#10866 — `ObjectTimeline` buckets, orders and dates a stored + * date-only day as that day, in every zone. + * + * ── What was measured ─────────────────────────────────────────────────────── + * Three reads of the record's start value, across two files, used the + * engine's own `Date` parse, which reads a date-only `2026-10-06` as UTC + * midnight: + * + * - the date bucket (`startOfDay(new Date(raw))`), so west of UTC an item + * due today sat under "Overdue" and one due tomorrow under "Today"; + * - the sort (`new Date(a.startDate).getTime()`), which placed a date-only + * day at UTC midnight among instants; + * - the renderer's item date (`formatDate`), whose default `short` face read + * `10/5/2026` for it. + * + * All three now take the shared step, `toDisplayDate` (`@object-ui/core`, the + * objectui#10183 convention), which tells the two shapes apart by the value: + * a date-only string is local midnight of the day it names, and a value with + * a time part keeps its instant. The sort case holds one of each, because + * only a mix can show the sort and the buckets disagreeing: the vertical + * renderer groups ADJACENT items, so an order that disagrees with the buckets + * reads as "Today" above "Overdue". + * + * ── ⚠️ 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 React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen } from '@testing-library/react'; +import { LocalizationProvider } from '@object-ui/i18n'; +import { ObjectTimeline } from '../ObjectTimeline'; + +vi.mock('@object-ui/react', async (importOriginal) => { + const actual = await (importOriginal() as Promise>); + return { + ...actual, + useDataScope: () => undefined, + useNavigationOverlay: () => ({ + isOverlay: false, + handleClick: vi.fn(), + selectedRecord: null, + isOpen: false, + close: vi.fn(), + setIsOpen: vi.fn(), + mode: 'overlay', + view: undefined, + }), + }; +}); + +const DRIVEN = process.env.OBJECTUI_DATE_ZONE_CHILD === '1'; + +/** UTC-7 in October — the card's direction. */ +const WEST = 'America/Los_Angeles'; +/** UTC+8 — the control. */ +const EAST = 'Asia/Shanghai'; + +/** 05:00 on October 6th in the west, 20:00 on October 6th in the east: "today" is the 6th in both. */ +const CLOCK = '2026-10-06T12:00:00.000Z'; +/** A fixed instant: 21:00 on the 5th in the west, 12:00 on the 6th in the east. */ +const INSTANT = '2026-10-06T04:00:00.000Z'; + +const TODAY = '2026-10-06'; +const TOMORROW = '2026-10-07'; + +/** 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(); +}); + +type Row = { id: string; name: string; when: string }; + +/** + * Mount the timeline over `rows`, bound to `when`, and read back each bucket + * as `Bucket: title (date face), …` in render order. + */ +function feed(rows: Row[]): string[] { + // `data` is an undeclared passthrough prop on ObjectTimelineProps (the + // component reads it off the rest args, which is how ListView feeds it). + const props = { + schema: { + type: 'object-timeline', + objectName: 'task', + titleField: 'name', + timeline: { startDateField: 'when', titleField: 'name' }, + }, + data: rows, + } as unknown as React.ComponentProps; + render( + + + , + ); + screen.getByText(rows[0].name); + const out = Array.from(document.querySelectorAll('section')).map((section) => { + const bucket = section.querySelector('header > span:first-child')?.textContent ?? ''; + const items = Array.from(section.querySelectorAll('h3')).map((h3) => { + const time = h3.parentElement?.querySelector('time')?.textContent ?? ''; + return `${h3.textContent} (${time})`; + }); + return `${bucket}: ${items.join(', ')}`; + }); + cleanup(); + return out; +} + +const DAYS: Row[] = [ + { id: '1', name: 'Ship', when: TODAY }, + { id: '2', name: 'Review', when: TOMORROW }, +]; + +/** One date-only day and one instant that falls on the day before it west of UTC. */ +const MIXED: Row[] = [ + { id: '1', name: 'Day', when: TODAY }, + { id: '2', name: 'Instant', when: INSTANT }, +]; + +describe('ObjectTimeline date-only days, in the suite zone (objectui#10866)', () => { + it('a date-only value buckets and dates as the day it stores', () => { + enter(); + expect(feed(DAYS)).toEqual(['Today: Ship (10/6/2026)', 'Tomorrow: Review (10/7/2026)']); + }); +}); + +describe.runIf(DRIVEN)('ObjectTimeline date-only days west of UTC (objectui#10866)', () => { + 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(TODAY).getDate()).toBe(5); + }); + + it('an item due today sits under "Today" and reads the 6th, one due tomorrow under "Tomorrow"', () => { + enter(WEST); + expect(feed(DAYS)).toEqual(['Today: Ship (10/6/2026)', 'Tomorrow: Review (10/7/2026)']); + }); + + it('control: an instant alone keeps its local day, the evening of the 5th', () => { + enter(WEST); + expect(feed([MIXED[1]])).toEqual(['Overdue: Instant (10/5/2026)']); + }); + + it('the sort agrees with the buckets: an instant of the evening before comes first', () => { + enter(WEST); + expect(feed(MIXED)).toEqual(['Overdue: Instant (10/5/2026)', 'Today: Day (10/6/2026)']); + }); +}); + +describe.runIf(DRIVEN)('ObjectTimeline date-only days east of UTC, the control (objectui#10866)', () => { + it('rig: the zone really moved', () => { + enter(EAST); + expect(Intl.DateTimeFormat().resolvedOptions().timeZone).toBe(EAST); + expect(new Date(INSTANT).getHours()).toBe(12); + }); + + it('an item due today sits under "Today" and reads the 6th, as it already did', () => { + enter(EAST); + expect(feed(DAYS)).toEqual(['Today: Ship (10/6/2026)', 'Tomorrow: Review (10/7/2026)']); + }); + + it('control: an instant alone keeps its local day, the 6th', () => { + enter(EAST); + expect(feed([MIXED[1]])).toEqual(['Today: Instant (10/6/2026)']); + }); + + it('the day and the instant are both today, the day first', () => { + enter(EAST); + expect(feed(MIXED)).toEqual(['Today: Day (10/6/2026), Instant (10/6/2026)']); + }); +}); diff --git a/packages/plugin-timeline/src/__tests__/TimelineRenderer.dateOnlyZone-10866.test.tsx b/packages/plugin-timeline/src/__tests__/TimelineRenderer.dateOnlyZone-10866.test.tsx new file mode 100644 index 0000000000..1b172b0d85 --- /dev/null +++ b/packages/plugin-timeline/src/__tests__/TimelineRenderer.dateOnlyZone-10866.test.tsx @@ -0,0 +1,154 @@ +/** + * 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#10866 — the timeline renderer's item date names the day a + * date-only value stores, on each of its three faces, in every zone. + * + * ── What was measured ─────────────────────────────────────────────────────── + * The renderer's `formatDate` parsed the item's date with the engine's own + * `Date` parse, which reads a date-only `2026-10-06` as UTC midnight. Its + * `short` face (the default) and its `long` face read the value with local + * getters, so every viewer west of UTC saw October 5th. Its ISO face printed + * `toISOString()`, which was right for a date-only value only because the + * parse was UTC too: once the parse is local midnight, the same print names + * the day before EAST of UTC. So the ISO rows below are green on the base as + * well, on purpose: they are the half a parse-only repair would break. + * + * The parse is now the shared step, `toDisplayDate` (`@object-ui/core`, the + * objectui#10183 convention). A date-only value prints its ISO day from local + * getters; a value with a time part keeps its instant, and its ISO face keeps + * the instant's UTC day. A value the shared step refuses (a day its month does + * not have, objectui#10026) prints as written on the ISO face instead of + * throwing, and the `short` / `long` faces give it the face they give any + * unparsable value. + * + * ── ⚠️ 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. + * + * `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 { afterEach, describe, expect, it } from 'vitest'; +import { cleanup, render } from '@testing-library/react'; +import { LocalizationProvider } from '@object-ui/i18n'; +import { TimelineRenderer } from '../renderer'; + +const DRIVEN = process.env.OBJECTUI_DATE_ZONE_CHILD === '1'; + +/** UTC-7 in October — the card's direction. */ +const WEST = 'America/Los_Angeles'; +/** UTC+8 — the control. */ +const EAST = 'Asia/Shanghai'; + +const DAY = '2026-10-06'; +/** A fixed instant: 21:00 on the 5th in the west, 12:00 on the 6th in the east. */ +const INSTANT = '2026-10-06T04:00:00.000Z'; + +/** Move this forked process into `zone`. */ +function enter(zone: string): void { + process.env.TZ = zone; +} + +afterEach(() => cleanup()); + +/** The date face the vertical renderer prints for one item dated `time`. */ +function face(dateFormat: string, time: string): string { + const { container } = render( + + + , + ); + const text = container.querySelector('time')?.textContent ?? ''; + cleanup(); + return text; +} + +describe('timeline item date, in the suite zone (objectui#10866)', () => { + it('a date-only value prints the day it stores on every face', () => { + expect(face('short', DAY)).toBe('10/6/2026'); + expect(face('long', DAY)).toBe('October 6, 2026'); + expect(face('iso', DAY)).toBe(DAY); + }); + + it('a day its month does not have is not rolled into March, and the ISO face prints it as written', () => { + expect(face('short', '2026-02-30')).not.toBe('3/2/2026'); + expect(face('iso', '2026-02-30')).toBe('2026-02-30'); + }); + + it('the ISO face prints an unparsable value as written rather than throwing', () => { + expect(face('iso', 'not a date')).toBe('not a date'); + }); +}); + +describe.runIf(DRIVEN)('timeline item date west of UTC (objectui#10866)', () => { + 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(DAY).getDate()).toBe(5); + }); + + it('`2026-10-06` prints October 6th on the short and long faces', () => { + enter(WEST); + expect(face('short', DAY)).toBe('10/6/2026'); + expect(face('long', DAY)).toBe('October 6, 2026'); + }); + + it('`2026-10-06` prints itself on the ISO face', () => { + enter(WEST); + expect(face('iso', DAY)).toBe(DAY); + }); + + it('control: an instant keeps its local day here, the 5th, and its ISO face keeps its UTC day', () => { + enter(WEST); + expect(face('short', INSTANT)).toBe('10/5/2026'); + expect(face('long', INSTANT)).toBe('October 5, 2026'); + expect(face('iso', INSTANT)).toBe('2026-10-06'); + }); +}); + +describe.runIf(DRIVEN)('timeline item date east of UTC, the control (objectui#10866)', () => { + it('rig: the zone really moved', () => { + enter(EAST); + expect(Intl.DateTimeFormat().resolvedOptions().timeZone).toBe(EAST); + expect(new Date(INSTANT).getHours()).toBe(12); + }); + + it('`2026-10-06` prints October 6th on the short and long faces, as it already did', () => { + enter(EAST); + expect(face('short', DAY)).toBe('10/6/2026'); + expect(face('long', DAY)).toBe('October 6, 2026'); + }); + + it('`2026-10-06` prints itself on the ISO face, not the day before', () => { + enter(EAST); + expect(face('iso', DAY)).toBe(DAY); + }); + + it('control: an instant keeps its local day here, the 6th, and its ISO face keeps its UTC day', () => { + enter(EAST); + expect(face('short', INSTANT)).toBe('10/6/2026'); + expect(face('long', INSTANT)).toBe('October 6, 2026'); + expect(face('iso', INSTANT)).toBe('2026-10-06'); + }); +}); diff --git a/packages/plugin-timeline/src/renderer.tsx b/packages/plugin-timeline/src/renderer.tsx index f46f58bf9c..445a111477 100644 --- a/packages/plugin-timeline/src/renderer.tsx +++ b/packages/plugin-timeline/src/renderer.tsx @@ -7,7 +7,7 @@ */ import * as React from 'react'; -import { ComponentRegistry, toDomProps } from '@object-ui/core'; +import { ComponentRegistry, isRealCalendarDate, toDateInputValue, toDisplayDate, toDomProps } from '@object-ui/core'; import type { TimelineRenderSchema } from './renderHandoff'; import { Timeline, @@ -1179,9 +1179,21 @@ function calculateBarDimensions( * nothing to do with the user — while `'long'` passed a literal `'en-US'`. A * required parameter is what keeps a future branch from quietly reintroducing * either. `'iso'` is a machine format by definition and stays locale-free. + * + * The value is read through the shared step, `toDisplayDate` + * (`@object-ui/core`, objectui#10866), as `ObjectTimeline`'s bucket and sort + * read it. The engine's own parse read a date-only `2026-10-06` as UTC + * midnight, so the `short` and `long` faces printed October 5th west of UTC. + * The shared step tells the two shapes apart by the value: a date-only string + * is local midnight of the day it names, and a value with a time part keeps + * its instant. So the ISO face prints a date-only value from LOCAL getters + * (its `toISOString()` would name the day before east of UTC), and keeps an + * instant's UTC day. A value the shared step refuses (unparsable, or a day + * its month does not have, objectui#10026) prints as written on the ISO face, + * where `toISOString()` would throw. */ function formatDate(dateString: string, format: string | undefined, locale: string): string { - const date = new Date(dateString); + const date = toDisplayDate(dateString); if (format === 'short') { return date.toLocaleDateString(locale); } @@ -1192,6 +1204,8 @@ function formatDate(dateString: string, format: string | undefined, locale: stri day: 'numeric', }); } + if (Number.isNaN(date.getTime())) return String(dateString); + if (isRealCalendarDate(dateString)) return toDateInputValue(date); return date.toISOString().split('T')[0]; } diff --git a/scripts/__tests__/date-only-zone-pins-10183.test.ts b/scripts/__tests__/date-only-zone-pins-10183.test.ts index 7111ed2cd1..2d202bcba1 100644 --- a/scripts/__tests__/date-only-zone-pins-10183.test.ts +++ b/scripts/__tests__/date-only-zone-pins-10183.test.ts @@ -24,8 +24,9 @@ import { fileURLToPath } from 'node:url'; * 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. objectui#10866, its successor, added the calendar - * and the gantt, whose pins also hold the written payload of a `date` field; - * a later date-only read site registers here the same way. + * and the gantt, whose pins also hold the written payload of a `date` field, + * then the timeline, a chart's date axis and `@object-ui/i18n`'s published + * date helpers; a later date-only read site registers here the same way. * * ## Why a driver, and why the forks pool * @@ -83,6 +84,13 @@ const PINS = [ 'packages/plugin-gantt/src/__tests__/ObjectGantt.dateOnlyZone-10866.test.tsx', 'packages/plugin-gantt/src/__tests__/GanttView.dateOnlyZone-10866.test.tsx', 'packages/plugin-gantt/src/__tests__/scheduling.dateOnlyZone-10866.test.ts', + // objectui#10866, slice 2: the timeline's date bucket, sort and item date, + // the chart's date-only axis tick, and `@object-ui/i18n`'s published date + // helpers. + 'packages/plugin-timeline/src/__tests__/ObjectTimeline.dateOnlyZone-10866.test.tsx', + 'packages/plugin-timeline/src/__tests__/TimelineRenderer.dateOnlyZone-10866.test.tsx', + 'packages/plugin-charts/src/__tests__/AdvancedChartImpl.dateOnlyZone-10866.test.tsx', + 'packages/i18n/src/__tests__/formatting.dateOnlyZone-10866.test.ts', ] as const; /** The vitest CLI entry, resolved rather than assumed at a `node_modules` path. */ From 1aaf37f20fa2deb71039dd31b92316f4e53099a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 05:23:25 +0000 Subject: [PATCH 2/5] test(plugin-timeline): name node types in the test tsconfig for the date-only zone pins (objectui#10866) The pins set process.env.TZ in the forked child and read the driver's flag off process.env; TypeScript 6 includes no @types by default, so the test program named neither. packages/plugin-calendar/tsconfig.test.json names node for the same pins. Claude-Session: https://claude.ai/code/session_01DuWo5bdP9SdVebamn99GGk Co-authored-by: Claude --- packages/plugin-timeline/tsconfig.test.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/plugin-timeline/tsconfig.test.json b/packages/plugin-timeline/tsconfig.test.json index 1edf1cd83f..34025d78ed 100644 --- a/packages/plugin-timeline/tsconfig.test.json +++ b/packages/plugin-timeline/tsconfig.test.json @@ -9,6 +9,12 @@ // The package build emits `dist`; this project emits nothing, so it must // not inherit `composite` / `declaration` from the build config. "composite": false, + // `node` for the date-only zone pins (objectui#10866), which set + // `process.env.TZ` inside a forked child and read the driver's flag off + // `process.env`; `packages/plugin-calendar/tsconfig.test.json` names it for + // the same pins. It stays OUT of `tsconfig.json`: package SOURCE ships to + // browsers and must not compile against Node APIs. + "types": ["node"], // Drop the root tsconfig's source-tree `paths` so `@object-ui/*` and // `@objectstack/spec` resolve through the workspace dependency's built // `.d.ts` instead of pulling sibling sources in as program inputs (TS6059). From 8559187c4a1e12eabf5afa3d28ca90aec94d4fa3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 05:30:03 +0000 Subject: [PATCH 3/5] test(plugin-charts): type the recharts size mock in the date-only zone pin (objectui#10866) Claude-Session: https://claude.ai/code/session_01DuWo5bdP9SdVebamn99GGk Co-authored-by: Claude --- .../__tests__/AdvancedChartImpl.dateOnlyZone-10866.test.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/plugin-charts/src/__tests__/AdvancedChartImpl.dateOnlyZone-10866.test.tsx b/packages/plugin-charts/src/__tests__/AdvancedChartImpl.dateOnlyZone-10866.test.tsx index aac021122d..c4e8ee59e8 100644 --- a/packages/plugin-charts/src/__tests__/AdvancedChartImpl.dateOnlyZone-10866.test.tsx +++ b/packages/plugin-charts/src/__tests__/AdvancedChartImpl.dateOnlyZone-10866.test.tsx @@ -43,11 +43,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { cleanup, render } from '@testing-library/react'; import { LocalizationProvider } from '@object-ui/i18n'; +// A fixed size, so the axis lays out and draws its ticks in the test DOM. vi.mock('recharts', async () => { - const actual = await vi.importActual('recharts'); + const actual = await vi.importActual>('recharts'); return { ...actual, - ResponsiveContainer: ({ children }: any) => + ResponsiveContainer: ({ children }: { children: React.ReactElement<{ width?: number; height?: number }> }) => React.cloneElement(children, { width: 480, height: 320 }), }; }); From 27e0a2d2fff875583e20ac7d8ca9b175e965272a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 06:28:15 +0000 Subject: [PATCH 4/5] fix(console): narrow the i18n-runtime chunk group to its own regex (objectui#10866) packages/i18n now imports @object-ui/core at runtime (the date helpers read through toDisplayDate), and i18n-runtime outranks framework, so rolldown's default recursive capture wrote 54 of core's 81 modules and 2 of types/src into i18n-runtime; Bundle Analysis's membership half went red. includeDependenciesRecursively: false is the objectui#9345 repair the data-adapter paragraph documents; the circular-chunk caveat is re-checked in the group's comment. The eager-closure pin now names i18n-runtime beside data-adapter. Claude-Session: https://claude.ai/code/session_01DuWo5bdP9SdVebamn99GGk Co-authored-by: Claude --- apps/console/vite.config.ts | 33 ++++++++++++++++- .../check-eager-closure-budget.test.ts | 36 +++++++++++++------ 2 files changed, 57 insertions(+), 12 deletions(-) diff --git a/apps/console/vite.config.ts b/apps/console/vite.config.ts index b9afdeae39..3e9a3b88d4 100644 --- a/apps/console/vite.config.ts +++ b/apps/console/vite.config.ts @@ -1059,7 +1059,38 @@ export default defineConfig({ { name: 'i18n-locale-pt', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]locales[\\/]pt\.ts$/, priority: 84 }, { name: 'i18n-locale-ru', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]locales[\\/]ru\.ts$/, priority: 84 }, { name: 'i18n-locale-ar', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]locales[\\/]ar\.ts$/, priority: 84 }, - { name: 'i18n-runtime', test: /[\\/]packages[\\/]i18n[\\/]/, priority: 83 }, + // + // ⛔ `i18n-runtime` takes `includeDependenciesRecursively: false` + // for the reason `data-adapter` below does (objectui#9345, read out + // there in full): it outranks `framework` (83 over 80), and since + // objectui#10866 `packages/i18n` imports `@object-ui/core` at + // runtime (its date helpers read a value through `toDisplayDate`). + // With rolldown's default the group followed that import and took + // `framework`'s declared members. Measured on the console build of + // `8559187c4`, before the flag: 54 of `packages/core`'s 81 modules + // and 2 of `packages/types/src` written into `i18n-runtime`, and the + // membership half of `scripts/check-eager-closure-budget.mjs` red. + // With the flag the group holds what its `test` matches, and that + // half is green on the same tree. + // + // The circular-chunk caveat the `data-adapter` paragraph asks a new + // taker to re-check, re-checked on those builds rather than + // inherited (historical readings, ⛔ not re-derived by anything): + // - the flag adds no cycle. `framework` and `i18n-runtime` import + // each other with or without it; the cycle came with the new + // import (`@object-ui/react` imports the i18n runtime, which now + // imports `@object-ui/core`), and it is the shape `data-adapter` + // and `framework` already had on `9f0c84a44`, where + // `i18n-runtime` imported no `framework` chunk; + // - the build emits the same chunk population as `9f0c84a44`; + // - the `i18n-runtime` chunk reads its one `framework` binding + // inside a function body, never at module evaluation, and this + // build and the `9f0c84a44` one each loaded the console's sign-in + // page in Chromium with no page error. + // ⚠️ That last property is what keeps the cycle harmless, and + // nothing re-derives it: a `packages/i18n` module that reads a + // `core` value at module scope would need the order re-checked. + { name: 'i18n-runtime', test: /[\\/]packages[\\/]i18n[\\/]/, priority: 83, includeDependenciesRecursively: false }, // // ## `includeDependenciesRecursively: false` — the rule that decides // ## membership here, named (objectui#9345) diff --git a/scripts/__tests__/check-eager-closure-budget.test.ts b/scripts/__tests__/check-eager-closure-budget.test.ts index 75bc749449..fb8240b9c2 100644 --- a/scripts/__tests__/check-eager-closure-budget.test.ts +++ b/scripts/__tests__/check-eager-closure-budget.test.ts @@ -459,8 +459,9 @@ describe('chunk attribution (objectui#7399)', () => { * objectui#5388). Those are refused a verdict below rather than guessed at. * * ⚠️ The tail is an OPTIONS list and not a bare `}` — a group may carry - * options AFTER `priority`, and two do: `data-adapter` (objectui#9345) and - * `types-zod` (objectui#10065), both `includeDependenciesRecursively: false`. + * options AFTER `priority`, and three do: `data-adapter` (objectui#9345), + * `types-zod` (objectui#10065) and `i18n-runtime` (objectui#10866), each + * `includeDependenciesRecursively: false`. * Requiring the closing brace silently dropped such a group from this table * while every case below went on passing, which is the failure this parse's * own "matches nothing agrees with everything" note is about: a group this @@ -590,15 +591,28 @@ describe('chunk attribution (objectui#7399)', () => { * landed where the config says — is `evaluatePerChunkMembership`, which * needs a build; this one reds in a unit run. */ - it('narrows `data-adapter` to its own regex, so it cannot absorb `framework`s members', () => { - const dataAdapter = groups.find((g) => g.name === 'data-adapter'); - expect(dataAdapter).toBeDefined(); - expect(dataAdapter!.options).toContain('includeDependenciesRecursively: false'); - // The control: the parse can see an options tail at all, and does not - // report one where none is written. A tail-blind parse would satisfy the - // line above by reading `''` from every group. - expect(groups.find((g) => g.name === 'framework')!.options).toBe(''); - }); + /* + * objectui#10866 added the second group of this shape: `i18n-runtime` + * outranks `framework` too, and once `packages/i18n` imported + * `@object-ui/core` at runtime (its date helpers read a value through + * `toDisplayDate`) the default carried `core` into it — the membership + * half of the gate went red on the console build while this table read + * as correct. A group that comes to outrank `framework` and reach + * `core` through an import belongs in this list. + */ + it.each(['data-adapter', 'i18n-runtime'])( + 'narrows `%s` to its own regex, so it cannot absorb `framework`s members', + (name) => { + const group = groups.find((g) => g.name === name); + expect(group).toBeDefined(); + expect(group!.priority).toBeGreaterThan(groups.find((g) => g.name === 'framework')!.priority); + expect(group!.options).toContain('includeDependenciesRecursively: false'); + // The control: the parse can see an options tail at all, and does not + // report one where none is written. A tail-blind parse would satisfy the + // line above by reading `''` from every group. + expect(groups.find((g) => g.name === 'framework')!.options).toBe(''); + }, + ); it('leaves no second claimant at the winner`s priority', () => { for (const id of [LOCALE_MODULE, RESIDENT_LOCALE_MODULE, DATA_MODULE, ZOD_MODULE]) { From 52f4d10d0a508c593201ba58d46ad3caa5e018d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 06:29:04 +0000 Subject: [PATCH 5/5] docs(changeset): the timeline gantt variant's bar tooltip now names the stored day while its axis does not (objectui#10866) Claude-Session: https://claude.ai/code/session_01DuWo5bdP9SdVebamn99GGk Co-authored-by: Claude --- .changeset/10866-timeline-charts-i18n-date-only.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/10866-timeline-charts-i18n-date-only.md b/.changeset/10866-timeline-charts-i18n-date-only.md index d5f4b74a3b..bbc5085eba 100644 --- a/.changeset/10866-timeline-charts-i18n-date-only.md +++ b/.changeset/10866-timeline-charts-i18n-date-only.md @@ -8,6 +8,6 @@ fix(plugin-timeline,plugin-charts,i18n): a stored date-only day reads as that da These readers parsed a `YYYY-MM-DD` value with the engine's own `Date` parse, which reads it as UTC midnight, so every viewer west of UTC saw the day before. Each now reads the value through `toDisplayDate` from `@object-ui/core`, which rebuilds a date-only value at local midnight of the day it names. A value with a time part keeps its instant. -- **Timeline.** The object timeline's date bucket, its sort and the item date the renderer prints read the value that way. West of UTC an item due today no longer sits under "Overdue", one due tomorrow no longer sits under "Today", and the `short` and `long` item faces print the stored day. The `iso` face prints a date-only value's day from local getters, and an instant's UTC day as before. A day its month does not have, such as `2026-02-30`, is no longer rolled into March: the item sits under "No date", sorts with the dateless items, and its `short` and `long` faces are the ones an unparsable value already had. An unparsable value now sorts with the dateless items too, and the `iso` face prints such a value as written where it used to throw. The gantt variant's axis headers and extent are not changed here. +- **Timeline.** The object timeline's date bucket, its sort and the item date the renderer prints read the value that way. West of UTC an item due today no longer sits under "Overdue", one due tomorrow no longer sits under "Today", and the `short` and `long` item faces print the stored day. The `iso` face prints a date-only value's day from local getters, and an instant's UTC day as before. A day its month does not have, such as `2026-02-30`, is no longer rolled into March: the item sits under "No date", sorts with the dateless items, and its `short` and `long` faces are the ones an unparsable value already had. An unparsable value now sorts with the dateless items too, and the `iso` face prints such a value as written where it used to throw. The gantt variant's axis headers, extent and bar positions are not changed here, so west of UTC its bar tooltip, which prints through the same item-date function, now names the stored day while the axis above it still reads the day before, until a later slice of objectui#10866. - **Charts.** A date-only category on the x axis reads `Sep 1` for `2026-09-01`, and `Sep 2026` on a month-grained axis, in every zone. When the display locale is a tag `Intl` refuses, the tick still prints the stored day: it reads the local-midnight value with local getters, because its `toISOString()` would name the day before east of UTC. An instant's fallback keeps its UTC day. A date-only category naming a day its month does not have falls back to the raw category, the face any other non-date category gets, instead of the rolled day. - **i18n helpers.** `formatDate`, `formatDateTime`, `formatRelativeTime` and `formatDateSpec` read a date-only string as the day it names: `formatDateTime` shows midnight of that day, and `formatRelativeTime` counts to the start of it. `formatDateSpec` applies its `timeZone` to an instant only, and formats a date-only value in the local zone, where the shared step's local midnight reads back as its day; a `timeZone` west of UTC no longer turns `2026-09-01` into August 31st for every viewer. All four return a day its month does not have as the raw string. `formatRelativeTime` now returns an unparsable value as its string, the face the other three already gave one, where it used to throw a `RangeError`.