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
13 changes: 13 additions & 0 deletions .changeset/10866-timeline-charts-i18n-date-only.md
Original file line number Diff line number Diff line change
@@ -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, 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`.
33 changes: 32 additions & 1 deletion apps/console/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions content/docs/plugins/plugin-timeline.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
190 changes: 190 additions & 0 deletions packages/i18n/src/__tests__/formatting.dateOnlyZone-10866.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
[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');
});
});
27 changes: 24 additions & 3 deletions packages/i18n/src/utils/formatting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down
Loading
Loading