Skip to content

Commit eb97ce6

Browse files
fix(plugin-timeline,plugin-charts,i18n): a stored date-only day reads as that day in every zone on the timeline, a chart's date axis and the i18n date helpers (objectui#10866, slice 2) (#10901)
Part of #10866 Clause-②: no Slice 2 of the date-only zone family's carrier card: site 3 (`plugin-timeline`), site 4 (`plugin-charts`) and site 6 (the `@object-ui/i18n` date helpers), per triage `5861646819`, seat 2's release note `5863305580` and claim `5863746430`. Site 5 (core `FormulaFunctions`) is measured, not edited: the measurement and a recommendation are below. The card stays open. Why `Clause-②: no`: display reads of stored date-only values stop shifting a day west of UTC. No authored key, accepted document or public type moves. ## What changed Every read below now takes the shared step, `toDisplayDate` from `@object-ui/core` (the objectui#10183 convention). It tells the two shapes apart by the VALUE, never by a declared field type: a date-only `YYYY-MM-DD` string is rebuilt at local midnight of the day it names, and a value with a time part keeps its instant (the engine parse). - **Site 3, `plugin-timeline`.** - `ObjectTimeline`: the date bucket (`startOfDay(toDisplayDate(raw))`) and the sort (a `sortKey` over `toDisplayDate`) now read alike. The vertical renderer groups ADJACENT items, so a sort that disagreed with the buckets ordered "Today" above "Overdue" for a mix of shapes. A value the shared step refuses (unparsable, or a day its month does not have) sorts with the dateless items, where it already bucketed ("No date"). - The renderer's `formatDate`: the `short` and `long` faces read the local-midnight value. The `iso` face prints a date-only value from local getters (core `toDateInputValue`), because `toISOString()` of a local midnight names the day before east of UTC; an instant keeps its UTC day. A refused value prints as written on `iso`, where `toISOString()` threw `RangeError: Invalid time value` during render. - **Site 4, `plugin-charts`.** `AdvancedChartImpl`'s `formatTick` reads the category through `toDisplayDate`. Its fallback for a locale `Intl` refuses prints a date-only category from local getters and keeps an instant's UTC day (`isRealCalendarDate` on the category is the split). The span that picks `MMM D` or `MMM YYYY` keeps the engine parse on purpose: it is a duration between two categories, a whole number of days for two UTC midnights, where two local midnights are an hour off across a DST change and a 62-day span would flip faces. A refused category falls through to the raw category. - **Site 6, the i18n helpers.** `formatDate`, `formatDateTime`, `formatRelativeTime` (`utils/formatting.ts`) and `formatDateSpec` (`utils/spec-formatters.ts`) parse through `toDisplayDate`. - `formatDateSpec` applies `timeZone` to an instant only (`isRealCalendarDate` on the string is the split, the same test the shared step makes). A date-only value is formatted in the local zone, the only zone where the shared step's local midnight reads back as its day; `toDisplayDate`'s own contract says to read its result with local getters or local-zone `Intl` only. - `formatRelativeTime` returns an unparsable value as its string, the face the other three already give, instead of throwing. It is needed here: the shared step refuses `2026-02-30`, which the engine used to roll. - Dependency direction, checked before editing: `@object-ui/i18n` already depends on `@object-ui/core` (it re-exports `number-display` from there) and `core` imports nothing from `i18n`. No cycle, and no copy of the helper. - **Pins.** Four files registered in `PINS` of `scripts/__tests__/date-only-zone-pins-10183.test.ts` (plus one header sentence): `ObjectTimeline.dateOnlyZone-10866`, `TimelineRenderer.dateOnlyZone-10866`, `AdvancedChartImpl.dateOnlyZone-10866` and `formatting.dateOnlyZone-10866`. Each runs under `America/Los_Angeles` and `Asia/Shanghai`, each zone opens with a rig case and a fixture-validity case, and each carries an instant control. Suite-zone cases run in the normal run too. - `packages/plugin-timeline/tsconfig.test.json` names `node` types for the pins' `process.env` (TypeScript 6 includes no `@types` by default; `packages/plugin-calendar/tsconfig.test.json` names it for the same pins). - **Console chunking (fix round 2, contract review `5864479592` item 1; claim amended at `5864501516`).** `apps/console/vite.config.ts`: the `i18n-runtime` manual-chunk group takes `includeDependenciesRecursively: false`. This slice gives `packages/i18n` its first runtime import of `@object-ui/core`, `i18n-runtime` outranks `framework` (83 over 80), and rolldown's default recursive capture followed the import and wrote `framework`'s declared members into `i18n-runtime`, which turned `Bundle Analysis`'s membership half red. The flag is the objectui#9345 repair the config's `data-adapter` paragraph documents; the group's new comment records the circular-chunk re-check that paragraph asks for (readings below). `scripts/__tests__/check-eager-closure-budget.test.ts` pins it: the `data-adapter` options case is now one case per group, `data-adapter` and `i18n-runtime`, each asserting it outranks `framework` and declares the flag. `toDisplayDate` keeps its one home in core; nothing is copied into i18n. - Docs: one paragraph in `content/docs/plugins/plugin-timeline.mdx` ("Date Formatting") and one in `packages/i18n/README.md` ("Formatting Utilities"). - Changeset `.changeset/10866-timeline-charts-i18n-date-only.md`: `patch` for `@object-ui/plugin-timeline`, `@object-ui/plugin-charts` and `@object-ui/i18n`. Fix round 2 extends its gantt sentence (review item ③a): the variant's axis headers, extent and bar positions are unchanged, so west of UTC its bar tooltip now names the stored day while the axis above it still reads the day before, until a later slice. The console chunking change moves no published file, so it needs no declaration (`check-changeset-presence` counts the same 9 source files of 3 released packages). ## Readings, base `b8e09415c` against head, in both zones Measured by a throwaway harness on the forks pool with the pins' own fixtures, once over the base sources and once over head (the harness file was deleted; the pins hold the same values). Timeline, clock `2026-10-06T12:00Z` ("today" is October 6th in both zones), `en-US`: | reading | LA base | LA head | Shanghai base | Shanghai head | |:--|:--|:--|:--|:--| | feed of `2026-10-06` and `2026-10-07` | Overdue: Ship (10/5/2026) · Today: Review (10/6/2026) | Today: Ship (10/6/2026) · Tomorrow: Review (10/7/2026) | Today: Ship (10/6/2026) · Tomorrow: Review (10/7/2026) | same | | feed of day `2026-10-06` and instant `2026-10-06T04:00Z` | Overdue: Day (10/5/2026), Instant (10/5/2026) | Overdue: Instant (10/5/2026) · Today: Day (10/6/2026) | Today: Day (10/6/2026), Instant (10/6/2026) | same | | item `short` / `long`, `2026-10-06` | 10/5/2026 / October 5, 2026 | 10/6/2026 / October 6, 2026 | 10/6/2026 / October 6, 2026 | same | | item `iso`, `2026-10-06` | 2026-10-06 | 2026-10-06 | 2026-10-06 | 2026-10-06 | | item `iso`, `2026-02-30` | 2026-03-02 | 2026-02-30 | 2026-03-02 | 2026-02-30 | Chart x-axis ticks, `en-US`: | categories | LA base | LA head | Shanghai base | Shanghai head | |:--|:--|:--|:--|:--| | `2026-09-01`, `2026-09-02` | Aug 31, Sep 1 | Sep 1, Sep 2 | Sep 1, Sep 2 | Sep 1, Sep 2 | | `2026-09-01`, `2026-12-01` | Aug 2026, Nov 2026 | Sep 2026, Dec 2026 | Sep 2026, Dec 2026 | same | | days, refused locale (fallback) | 2026-09-01, 2026-09-02 | same | same | same | | instants `2026-09-02T03:00Z`, `2026-09-03T03:00Z` | Sep 1, Sep 2 | same | Sep 2, Sep 3 | same | | instants, refused locale (fallback) | 2026-09-02, 2026-09-03 | same | same | same | | `2026-02-28`, `2026-02-30` | Feb 27, Mar 1 | Feb 28, 2026-02-30 | Feb 28, Mar 2 | Feb 28, 2026-02-30 | The two fallback rows are green on base by construction: the old parse was UTC, so its `toISOString()` was right. They are the half a parse-only repair breaks east of UTC; see the second ablation below. i18n helpers, clock 10:00 on August 30th local in each zone, `en-US`: | call | LA base | LA head | Shanghai base | Shanghai head | |:--|:--|:--|:--|:--| | `formatDate('2026-09-01')` | Aug 31, 2026 | Sep 1, 2026 | Sep 1, 2026 | Sep 1, 2026 | | `formatDateTime('2026-09-01')` | Aug 31, 2026, 5:00 PM | Sep 1, 2026, 12:00 AM | Sep 1, 2026, 8:00 AM | Sep 1, 2026, 12:00 AM | | `formatRelativeTime('2026-09-01')` | tomorrow | in 2 days | in 2 days | in 2 days | | `formatDateSpec('2026-09-01', medium)` | Aug 31, 2026 | Sep 1, 2026 | Sep 1, 2026 | Sep 1, 2026 | | same with `timeZone: 'America/New_York'` | Aug 31, 2026 | Sep 1, 2026 | Aug 31, 2026 | Sep 1, 2026 | | `formatDate('2026-09-02T03:00:00.000Z')` (instant) | Sep 1, 2026 | same | Sep 2, 2026 | same | | `formatDate('2026-02-30')` | Mar 1, 2026 | 2026-02-30 | Mar 2, 2026 | 2026-02-30 | | `formatRelativeTime('not a date')` | throws RangeError | not a date | throws RangeError | not a date | The Shanghai `formatDateTime` row moves on the time only (8:00 AM to 12:00 AM): the day was already right there, and a date-only value is now midnight of its day in every zone. ## Tests and gates ### Fix round 2, at head `52f4d10d0` - **CI on `52f4d10d0`:** all 43 check-runs completed, 40 success, 3 skipped, 0 failed. `Bundle Analysis` success (run `36386759427`); its budget comment `5864690342` reads PASS, eager closure 3095.7 KB of 3104.5 KB. - **`Bundle Analysis` reproduced locally first.** Console build under os-verify-lock (`cd apps/console && pnpm exec vite build`; the gate reads only that step's reports, so the package `tsc` and `build:plugin` steps were not run), then `node scripts/check-eager-closure-budget.mjs`: - at `8559187c4`, before the flag: exit 1, byte halves green (3096.3 KB of 3104.5 KB), membership red: "`packages/core` is declared in `framework` but 54 in `i18n-runtime` (27 of its 81 modules landed in `framework`)" and "`packages/types` ... `packages/types/src` 2 in `i18n-runtime`". The reviewer's mechanism holds; the capture is 54 modules of core's barrel slice, not two. - at `52f4d10d0`: exit 0, every half green: "Console eager closure is 3095.5 KB gzipped across 329 of 2445 chunks (budget: 3104.5 KB, headroom: 9.0 KB)"; membership `packages/core` 81 modules in `framework`, `packages/types` 13 in `framework` and its `src/zod` subtree 9 in `types-zod`. - control, a build of `main` `9f0c84a44` in a separate worktree: exit 0, 3095.6 KB across 329 of 2445 chunks. - **Circular-chunk caveat, re-checked** (the `data-adapter` paragraph asks a new taker of the flag to): on both builds of this branch `framework` and `i18n-runtime` import each other, flag or no flag; on `main` `i18n-runtime` imports no `framework` chunk, and `data-adapter` and `framework` import each other there already. So the cycle comes with this slice's i18n-to-core import, not with the flag, and it has the shape `data-adapter` already has. The flagged build's JS chunk names match `main`'s one for one (2445 each, empty diff). The built `i18n-runtime` chunk imports one `framework` binding and reads it only inside the `formatRelativeTime` function body, never at module evaluation. Browser smoke: `vite preview` of the flagged build and of the `main` build, each loaded in Chromium (Playwright): the sign-in page rendered with 0 page errors, and each logged 12 console errors; the eight the probe printed for each build are the same failed resource loads with no backend running (502, one 404). Both preview servers were stopped by their recorded PIDs. - **The pin, ablated** (objectstack `scripts/ablation-replace.mjs`, wrap mode, the `i18n-runtime` anchor `priority: 83, includeDependenciesRecursively: false },` replaced by `priority: 83 },`, counted 1 to 0, blob `3e9a3b88d4c6` to `df2d1a47a29f`): exactly the new `i18n-runtime` row red, `Tests 1 failed | 177 passed (178)`; restored, blob equal to HEAD, `git diff HEAD` empty. - **Suites (os-verify-lock):** the 17 `scripts/__tests__` suites whose corpus holds `apps/console/vite.config.ts`, run by name (`check-changeset-presence`, `check-eager-closure-budget`, `console-vite-alias-closure-4925`, `cross-file-line-citation-census`, `scripts-type-check`, `side-effects-declaration-consistency`, `turbo-build-inputs`, `turbo-lint-inputs`, `turbo-task-guard-coverage`, `turbo-test-inputs`, `vite-declared-lazy-views`, `vite-ineffective-dynamic-imports`, `vite-objectstack-client-dist`, `vite-objectstack-spec-dist`, `vite-types-zod-lazy`, `vitest-config-alias-targets-3944`, `vitest-config-type-check`): `Test Files 17 passed (17)`, `Tests 945 passed (945)`. Every `scripts/__tests__` suite: `Test Files 177 passed | 2 skipped (179)`, `Tests 5327 passed | 2 skipped (5329)`. `pnpm exec vitest run packages/plugin-timeline/ packages/plugin-charts/ packages/i18n/ scripts/__tests__/date-only-zone-pins-10183.test.ts`: `Test Files 193 passed (193)`, `Tests 2576 passed | 40 skipped (2616)`. Each VERDICT command-exit 0. - **Type-check:** closure rebuilt; the three packages' `type-check` echo `tsc --noEmit && tsc -p tsconfig.test.json` and `Done`; `pnpm type-check:scripts` and `pnpm type-check:vitest-config` exit 0 (VERDICT command-exit 0). - **Gates, exit 0:** `check-changeset-presence` (the same 9 source files of 3 released packages, 1 changeset), `-no-major`, `-fixed`, `-overwrite`, `check-pending-changeset-literals`, `check-control-bytes`, `check-new-cross-file-line-citations` (0 new citations), `check-governed-queue-guard --test` over 16 paths (NOT GOVERNED). `check-changeset-claims` (report-only) adds `6681-declared-lazy-marketplace-routes`, which names `apps/console/vite.config.ts` for its marketplace co-tenant groups; this change does not touch them, and it stays true. On the local console build, `check-eager-locale-catalogues` (NOT MEASURED in round 1) and `check-sdui-registration-pins` exit 0. - **ESLint, narrowed** to the two files this round adds to the diff: `apps/console/vite.config.ts` and `check-eager-closure-budget.test.ts`, 0 errors and 0 warnings each, as at `8559187c4`. ### Round 1, at `8559187c4` - **Red first, base sources plus the four pins** (os-verify-lock, `pnpm exec vitest run scripts/__tests__/date-only-zone-pins-10183.test.ts`): `Tests 5 failed | 13 passed (18)`, VERDICT command-exit 1. The 5 are the four new pin rows and the child-exit row; the 13 existing pins stayed green. - **Green:** driver plus the four pins in the suite zone, `Test Files 5 passed (5)`, `Tests 28 passed | 40 skipped (68)`, VERDICT command-exit 0 (the 40 skipped are the zone cases, which run inside the driver's forks child). - **Reverse validation after commit** (the five source files checked out from base, driver run, restored with `git checkout HEAD --` on absolute paths): the base blobs proven on disk before the run, `Tests 5 failed | 13 passed (18)`, then each restored blob equal to its HEAD blob and `git diff HEAD` empty. - **Ablation of the ISO arms** (objectstack `scripts/ablation-replace.mjs`, wrap mode, both anchors counted 1 to 0 with changed blobs): the timeline `iso` local-getter line deleted and the chart fallback reduced to `toISOString()`. Measured direction: exactly the two Shanghai rows went red (`expected '2026-10-05' to be '2026-10-06'`; `expected [ '2026-08-31', '2026-09-01' ] to deeply equal [ '2026-09-01', '2026-09-02' ]`), `Tests 3 failed | 15 passed (18)`; every LA row stayed green. Both files restored, blob equal to HEAD, `git diff HEAD` empty. - **Package suites** (os-verify-lock): `pnpm exec vitest run packages/plugin-timeline/ packages/plugin-charts/ packages/i18n/ scripts/__tests__/date-only-zone-pins-10183.test.ts`, `Test Files 193 passed (193)`, `Tests 2576 passed | 40 skipped (2616)`, VERDICT command-exit 0. The i18n suite includes the objectui#9909 machine-locale census. - **Every `scripts/__tests__` gate** (os-verify-lock, `pnpm exec vitest run scripts/__tests__/ --maxWorkers=2`, at head `8559187c4`): `Test Files 177 passed | 2 skipped (179)`, `Tests 5326 passed | 2 skipped (5328)`, VERDICT command-exit 0. - **Type-check:** dependency closure built (`--filter 'PKG^...'` for each of the three packages, VERDICT command-exit 0); `pnpm --workspace-concurrency=2 --filter @object-ui/plugin-timeline --filter @object-ui/plugin-charts --filter @object-ui/i18n run type-check` echoes `tsc --noEmit && tsc -p tsconfig.test.json` and `Done` for each; `pnpm type-check:scripts` exit 0. `--listFiles` on each test program lists the new pins (2, 1, 1). - **Gates, exit 0:** `check-changeset-presence` (9 source files of 3 released packages, 1 changeset), `-no-major`, `-fixed`, `-overwrite`, `check-pending-changeset-literals`, `check-new-cross-file-line-citations` (`VERDICT new-cross-file-line-citations: 0 new citation(s)`), `check-control-bytes`, `check-unreferenced-sources`, `check-test-path-roots`, `check-vi-mock-specifiers` / `-inherit` / `-override-shape`, `check-phantom-dependencies`, `check-unused-dependencies`, `check-i18n-call-site-keys`, `check-i18n-en-drift`, `check-i18n-dead-keys`, `check-doc-fence-languages`, `check-doc-links`, `check-package-self-import`, `check-governed-queue-guard --test` over the 14 paths (NOT GOVERNED). A control-byte self-scan over the changed files: no hits. - **`check-changeset-claims`** (report-only) names three pending changesets that cite a touched file: `7546-chart-series-keys-declared` (series keys in `AdvancedChartImpl.tsx`), `8632-malformed-picklist-option-loud` and `9615-locale-count-ten` (both cite `packages/i18n/README.md` sections this change does not touch). Each read; none becomes false. - **ESLint, narrowed:** root `eslint.config.js` with `--no-inline-config --format json` over the 10 changed code files (10 results in the JSON): 0 errors; the three modified sources keep their base warning counts, measured with `--stdin` on the base blobs (`AdvancedChartImpl.tsx` 44, `ObjectTimeline.tsx` 28, `renderer.tsx` 14); the new files and the driver 0. The config sets no `parserOptions.project` or `projectService`, so the rules are not type-aware and this diff cannot move any untouched file's verdict. - **NOT MEASURED:** `check-readme-exports` and `check-doc-snippet-types` (prerequisite: every package built; this change adds prose only, no fenced block, to the README and the doc); `check-eager-locale-catalogues` (prerequisite: a console build; measured in fix round 2, above). CI builds and runs them. ## Site 5, `FormulaFunctions`: measurement only, no edit Measured with `@objectstack/formula` 17.4.0, the version installed here, and read against objectstack `packages/formula/src/stdlib.ts` and ADR-0053 D1; process zone `America/Los_Angeles` and `Asia/Shanghai`, `now` pinned at `2026-09-28T05:30Z` and `2026-09-28T20:00Z`. - **Server (CEL stdlib).** There is no `TODAY()` or `DATEADD` by those names; the equivalents are `today()`, `daysFromNow(n)`, `addDays(d, n)`, `addMonths(d, n)` and `daysBetween(a, b)`. A calendar day is a `Date` at UTC midnight of that day (ADR-0053 D1), a date-only string hydrates to UTC midnight, arithmetic runs in UTC, `addMonths` clamps to the month's last day, and `today()` is the calendar day in `EvalContext.timezone` (default UTC) as UTC midnight. No result depends on the process zone. For a CEL default on a `date` field the engine stores such a result as its UTC day (objectql `normalizeExpressionDefault` through `resolveNowDefault`). - `today()` at 05:30Z: `2026-09-28T00:00Z` with the zone unset or Shanghai, `2026-09-27T00:00Z` with Los Angeles; at 20:00Z, `2026-09-29T00:00Z` with Shanghai. - `addDays('2026-09-01', 1)`: `2026-09-02T00:00Z` everywhere; `addDays('2026-11-01', 1)`: `2026-11-02T00:00Z`; `addMonths('2026-01-31', 1)`: `2026-02-28T00:00Z`. - **Core `FormulaFunctions`.** `TODAY()` is the UTC day as `YYYY-MM-DD` (`2026-09-28` in both zones at both clocks), so it agrees with the server only while the reference zone is UTC. `DATEADD` parses the UTC midnight, moves it with LOCAL setters and returns an instant string: - `DATEADD('2026-09-01', 1, 'day')` gives `2026-09-02T00:00:00.000Z` in both zones (agrees); - `DATEADD('2026-11-01', 1, 'day')` in LA gives `2026-11-02T01:00:00.000Z`, and `DATEADD('2026-03-08', 1, 'day')` in LA gives `2026-03-08T23:00:00.000Z` (the server: `2026-11-02` and `2026-03-09` at midnight UTC); - `DATEADD('2026-09-01', 1, 'month')` in LA gives `2026-10-02T00:00:00.000Z` (Shanghai and the server: October 1st); - `DATEADD('2026-01-31', 1, 'month')` gives `2026-03-03T00:00:00.000Z` in every zone, UTC included (the server clamps to February 28th); - `DATEFORMAT('2026-09-01', 'YYYY-MM-DD')` gives `2026-08-31` in LA; `DATEDIFF('2025-12-31', '2026-01-01', 'year')` gives 0 in LA and 1 in Shanghai and UTC. - Output shape: a date-only argument comes back as an instant string, which the display path (`toDisplayDate`) then reads as an instant, a day early west of UTC. - **Recommendation.** The day arithmetic can agree with the server without a new statement, because ADR-0053 D1 already states the model: read a date-only argument at UTC midnight, add days and months with UTC setters (months clamped as `addMonths` does), format with UTC getters, and hand a date-only argument back as `YYYY-MM-DD` (the spec's calendar-day stored form) and an instant as an instant. ONE thing needs a statement, because the server takes it from a context the client does not have: the zone `TODAY()` uses in the browser. Proposed wording for the seat: "ADR-0053's compute-tz axis covers client-evaluated formulas: `TODAY()` in `@object-ui/core` names the calendar day in the same reference timezone the server's `EvalContext.timezone` resolves for that user, and UTC when none is available to the client." Recommended because ADR-0053 puts `today()` on the compute-tz axis (execution context), not the render-tz axis (the viewer), and a client formula that disagrees with the server's saved answer is the worse split; the cost is that `TODAY()` keeps disagreeing with the viewer's own "today" (the timeline's buckets) until that timezone reaches the client. ## Acceptance notes - **The timeline's gantt-variant axis is not changed** (the card's site-3 line "the axis extent parses then prints `toISOString()`"). `generateTimeScaleHeaders`, `calculateDateRange`, `calculateBarDimensions`, the `findUnusableGanttDate` validity gate and the min-over-max inversion guard still use the engine parse, and `emptyGanttDateRange` (the empty plan's axis) still takes today as the UTC day (`new Date().toISOString().split('T')[0]`), not the viewer's. Measured on head: `generateTimeScaleHeaders('day', '2026-10-05', '2026-10-07', 'en-US')` reads Oct 4, Oct 5, Oct 6 in LA (Oct 5, Oct 6, Oct 7 in Shanghai); `'month'` over `2026-10-01` to `2026-12-31` reads Sep 2026, Oct 2026, Nov 2026 in LA. The composed timeline refuses `variant: 'gantt'` (objectui#6655), so only authored rows reach it; `examples/schema-catalog/src/schemas/plugin-timeline/gantt-style-timeline.json` authors date-only rows. Left for a later slice of this card: routing it means the validity gate refuses a day its month does not have and the datetime axis labels move to the viewer's day, two decisions of their own. The bar tooltip on that variant uses `formatDate`, so it now reads the stored day while the headers and bar positions do not: west of UTC a header can read Oct 4 over a bar whose tooltip says 10/5/2026. The changeset says so (fix round 2). - **Site 6 does have an in-repo caller.** The card says none; `packages/app-shell/src/utils/relativeTime.ts` (`timeAgo`, for notification and activity rows) calls `formatRelativeTime` with epoch milliseconds from an instant, which never takes the date-only arm. Its behaviour does not move. - `formatRelativeTime` still counts from now to an instant, now local midnight for a date-only value: at 01:00 on August 31st, `2026-09-01` reads "in 23 hours" (derived, not measured), which is what a UTC viewer always read at 01:00 UTC. Calendar-day phrasing is a separate decision. - `formatDateSpec` with a `timeStyle` and a date-only value prints midnight in the viewer's zone (and, for `long` or `full`, that zone's name). - Remaining engine parses in the touched packages, each deliberate: the chart span (a duration, above); the "now" clocks; the gantt-axis reads (above). ## Serial - PR objectui#10892 merged as `b8e09415c` at 04:59:24Z, two minutes after the claim. This branch was fast-forwarded onto it before any edit, and `plugin-charts/src/ChartRenderer.tsx` and `plugin-timeline/src/useTimelineTranslation.ts` are not touched, so nothing near its hunks had to be left. - PR objectui#10896 touches `packages/i18n/src/locales/*`; this PR touches no locale file. - `git merge-tree --write-tree` of head `52f4d10d0` against `main` `8522396c0`: clean (exit 0); nothing `main` gained since the branch base touches these 16 paths. Implemented by the dev agent for `domain:ui` seat #1, session `https://claude.ai/code/session_01DuWo5bdP9SdVebamn99GGk`. --- _Generated by [Claude Code](https://claude.ai/code/session_01DuWo5bdP9SdVebamn99GGk)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 99e936b commit eb97ce6

16 files changed

Lines changed: 909 additions & 29 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@object-ui/plugin-timeline': patch
3+
'@object-ui/plugin-charts': patch
4+
'@object-ui/i18n': patch
5+
---
6+
7+
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)
8+
9+
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.
10+
11+
- **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.
12+
- **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.
13+
- **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`.

‎apps/console/vite.config.ts‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1059,7 +1059,38 @@ export default defineConfig({
10591059
{ name: 'i18n-locale-pt', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]locales[\\/]pt\.ts$/, priority: 84 },
10601060
{ name: 'i18n-locale-ru', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]locales[\\/]ru\.ts$/, priority: 84 },
10611061
{ name: 'i18n-locale-ar', test: /[\\/]packages[\\/]i18n[\\/]src[\\/]locales[\\/]ar\.ts$/, priority: 84 },
1062-
{ name: 'i18n-runtime', test: /[\\/]packages[\\/]i18n[\\/]/, priority: 83 },
1062+
//
1063+
// ⛔ `i18n-runtime` takes `includeDependenciesRecursively: false`
1064+
// for the reason `data-adapter` below does (objectui#9345, read out
1065+
// there in full): it outranks `framework` (83 over 80), and since
1066+
// objectui#10866 `packages/i18n` imports `@object-ui/core` at
1067+
// runtime (its date helpers read a value through `toDisplayDate`).
1068+
// With rolldown's default the group followed that import and took
1069+
// `framework`'s declared members. Measured on the console build of
1070+
// `8559187c4`, before the flag: 54 of `packages/core`'s 81 modules
1071+
// and 2 of `packages/types/src` written into `i18n-runtime`, and the
1072+
// membership half of `scripts/check-eager-closure-budget.mjs` red.
1073+
// With the flag the group holds what its `test` matches, and that
1074+
// half is green on the same tree.
1075+
//
1076+
// The circular-chunk caveat the `data-adapter` paragraph asks a new
1077+
// taker to re-check, re-checked on those builds rather than
1078+
// inherited (historical readings, ⛔ not re-derived by anything):
1079+
// - the flag adds no cycle. `framework` and `i18n-runtime` import
1080+
// each other with or without it; the cycle came with the new
1081+
// import (`@object-ui/react` imports the i18n runtime, which now
1082+
// imports `@object-ui/core`), and it is the shape `data-adapter`
1083+
// and `framework` already had on `9f0c84a44`, where
1084+
// `i18n-runtime` imported no `framework` chunk;
1085+
// - the build emits the same chunk population as `9f0c84a44`;
1086+
// - the `i18n-runtime` chunk reads its one `framework` binding
1087+
// inside a function body, never at module evaluation, and this
1088+
// build and the `9f0c84a44` one each loaded the console's sign-in
1089+
// page in Chromium with no page error.
1090+
// ⚠️ That last property is what keeps the cycle harmless, and
1091+
// nothing re-derives it: a `packages/i18n` module that reads a
1092+
// `core` value at module scope would need the order re-checked.
1093+
{ name: 'i18n-runtime', test: /[\\/]packages[\\/]i18n[\\/]/, priority: 83, includeDependenciesRecursively: false },
10631094
//
10641095
// ## `includeDependenciesRecursively: false` — the rule that decides
10651096
// ## membership here, named (objectui#9345)

‎content/docs/plugins/plugin-timeline.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,8 @@ const long: TimelineSchema['dateFormat'] = 'long' // "January 15, 2024"
326326
const iso: TimelineSchema['dateFormat'] = 'iso' // "2024-01-15"
327327
```
328328

329+
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.
330+
329331
## Time Scales (Gantt)
330332

331333
For Gantt-style timelines, choose the appropriate time scale:

‎packages/i18n/README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,12 @@ formatNumber(1234567, { locale: 'de' }); // "1.234.567"
163163
formatRelativeTime(Date.now() - 3 * 86_400_000, 'en'); // "3 days ago"
164164
```
165165

166+
A date-only string such as `'2026-09-01'` names a calendar day: every date
167+
helper reads it as that day in every viewer's time zone (`formatDateTime` shows
168+
its midnight, and `formatRelativeTime` counts to the start of it). A string
169+
with a time part is an instant, read in the viewer's zone. `formatDateSpec`
170+
applies its `timeZone` to an instant only.
171+
166172
### Built-in locales — one is resident, nine are fetched on demand
167173

168174
The package entry re-exports **`en` only**. It is `fallbackLng`, it is the
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectui#10866 — the published date helpers name the day a date-only value
11+
* stores, in every zone.
12+
*
13+
* ── What was measured ───────────────────────────────────────────────────────
14+
* `formatDate`, `formatDateTime` and `formatRelativeTime` (`utils/formatting`)
15+
* and `formatDateSpec` (`utils/spec-formatters`) parsed a string with the
16+
* engine's own `Date` parse, which reads a date-only `2026-09-01` as UTC
17+
* midnight. Every viewer west of UTC then read August 31st, and
18+
* `formatDateSpec` given a `timeZone` west of UTC read August 31st for every
19+
* viewer. The parse is now the shared step, `toDisplayDate` (`@object-ui/core`,
20+
* the objectui#10183 convention), which tells the two shapes apart by the
21+
* value: a date-only string is rebuilt at local midnight of the day it names,
22+
* and a value with a time part keeps its instant. `formatDateSpec` hands its
23+
* `timeZone` to an instant only: a date-only value names a day, and the
24+
* shared step's result reads back as that day only in the local zone.
25+
*
26+
* A day its month does not have (`2026-02-30`) is refused by the shared step
27+
* (objectui#10026), so each helper renders it as the raw string, the face all
28+
* four already give an unparsable value (`formatRelativeTime` used to throw
29+
* for one).
30+
*
31+
* ── ⚠️ The zone cases run ONLY when driven, in a FORKS child ────────────────
32+
* `process.env.TZ` written inside a test of the normal run does not move the
33+
* zone (the root config runs `pool: 'threads'`), so the zone cases are
34+
* skipped there and `scripts/__tests__/date-only-zone-pins-10183.test.ts`
35+
* runs them on the forks pool and fails unless every one ran and passed. Each
36+
* zone opens with a rig case, so a child whose zone did not move reds instead
37+
* of going quietly green. The suite-zone cases run in the normal run too.
38+
*
39+
* `Asia/Shanghai` is the control: east of UTC the UTC-midnight parse already
40+
* landed on the named day, and an hour-offset "repair" would break it.
41+
*/
42+
import { afterEach, describe, expect, it, vi } from 'vitest';
43+
import { formatDate, formatDateTime, formatRelativeTime } from '../utils/formatting';
44+
import { formatDateSpec } from '../utils/spec-formatters';
45+
46+
const DRIVEN = process.env.OBJECTUI_DATE_ZONE_CHILD === '1';
47+
48+
/** UTC-7 in September — the card's direction. */
49+
const WEST = 'America/Los_Angeles';
50+
/** UTC+8 — the control. */
51+
const EAST = 'Asia/Shanghai';
52+
53+
const DAY = '2026-09-01';
54+
/** 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. */
55+
const INSTANT = '2026-09-02T03:00:00.000Z';
56+
/** 10:00 on August 30th in each zone, so the relative phrases below read alike. */
57+
const TEN_AM: Record<string, string> = {
58+
[WEST]: '2026-08-30T17:00:00.000Z',
59+
[EAST]: '2026-08-30T02:00:00.000Z',
60+
UTC: '2026-08-30T10:00:00.000Z',
61+
};
62+
63+
const EN = { locale: 'en-US' } as const;
64+
65+
/** Freeze the clock at 10:00 local, moving this forked process into `zone` first when given. */
66+
function enter(zone?: string): void {
67+
if (zone) process.env.TZ = zone;
68+
vi.useFakeTimers({ toFake: ['Date'] });
69+
vi.setSystemTime(new Date(TEN_AM[zone ?? 'UTC']));
70+
}
71+
72+
afterEach(() => {
73+
vi.useRealTimers();
74+
});
75+
76+
describe('i18n date helpers, in the suite zone (objectui#10866)', () => {
77+
it('`formatDateSpec` given a `timeZone` west of UTC reads a date-only value as the day it stores', () => {
78+
enter();
79+
expect(formatDateSpec(DAY, { dateStyle: 'medium', timeZone: 'America/New_York' }, 'en-US')).toBe('Sep 1, 2026');
80+
});
81+
82+
it('control: an instant is still read in the `timeZone` it is handed', () => {
83+
enter();
84+
expect(formatDateSpec(INSTANT, { dateStyle: 'medium', timeZone: 'America/New_York' }, 'en-US')).toBe('Sep 1, 2026');
85+
expect(formatDateSpec(INSTANT, { dateStyle: 'medium', timeZone: 'Asia/Tokyo' }, 'en-US')).toBe('Sep 2, 2026');
86+
});
87+
88+
it('a day its month does not have renders as the raw string, not the rolled day', () => {
89+
enter();
90+
expect(formatDate('2026-02-30', EN)).toBe('2026-02-30');
91+
expect(formatDateTime('2026-02-30', EN)).toBe('2026-02-30');
92+
expect(formatDateSpec('2026-02-30', { dateStyle: 'medium' }, 'en-US')).toBe('2026-02-30');
93+
expect(formatRelativeTime('2026-02-30', 'en-US')).toBe('2026-02-30');
94+
});
95+
96+
it('`formatRelativeTime` renders an unparsable value as its string rather than throwing', () => {
97+
enter();
98+
expect(formatRelativeTime('not a date', 'en-US')).toBe('not a date');
99+
});
100+
});
101+
102+
describe.runIf(DRIVEN)('i18n date helpers west of UTC (objectui#10866)', () => {
103+
it('rig: the zone really moved', () => {
104+
enter(WEST);
105+
expect(Intl.DateTimeFormat().resolvedOptions().timeZone).toBe(WEST);
106+
expect(new Date(INSTANT).getHours()).toBe(20);
107+
});
108+
109+
it('fixture validity: the engine parse of the string lands on the day before here', () => {
110+
enter(WEST);
111+
// Without this the cases below would be green for free.
112+
expect(new Date(DAY).getDate()).toBe(31);
113+
});
114+
115+
it('`formatDate` reads `2026-09-01` as September 1st', () => {
116+
enter(WEST);
117+
expect(formatDate(DAY, EN)).toBe('Sep 1, 2026');
118+
expect(formatDate(DAY, { ...EN, style: 'full' })).toBe('Tuesday, September 1, 2026');
119+
expect(formatDate(DAY, { ...EN, dateStyle: 'short' })).toBe('9/1/26');
120+
});
121+
122+
it('`formatDateTime` reads it as midnight of September 1st', () => {
123+
enter(WEST);
124+
expect(formatDateTime(DAY, EN)).toBe('Sep 1, 2026, 12:00 AM');
125+
});
126+
127+
it('`formatRelativeTime` counts to the start of that day', () => {
128+
enter(WEST);
129+
expect(formatRelativeTime(DAY, 'en-US')).toBe('in 2 days');
130+
expect(formatRelativeTime('2026-08-28', 'en-US')).toBe('2 days ago');
131+
});
132+
133+
it('`formatDateSpec` reads it as September 1st, with or without a `timeZone`', () => {
134+
enter(WEST);
135+
expect(formatDateSpec(DAY, { dateStyle: 'medium' }, 'en-US')).toBe('Sep 1, 2026');
136+
expect(formatDateSpec(DAY, { dateStyle: 'medium', timeZone: 'America/New_York' }, 'en-US')).toBe('Sep 1, 2026');
137+
expect(formatDateSpec(DAY, { dateStyle: 'medium', timeZone: 'Asia/Tokyo' }, 'en-US')).toBe('Sep 1, 2026');
138+
});
139+
140+
it('control: an instant keeps its local day here, the 1st, and its `timeZone` day', () => {
141+
enter(WEST);
142+
expect(formatDate(INSTANT, EN)).toBe('Sep 1, 2026');
143+
expect(formatDateTime(INSTANT, EN)).toBe('Sep 1, 2026, 8:00 PM');
144+
expect(formatRelativeTime('2026-08-31T17:00:00.000Z', 'en-US')).toBe('tomorrow');
145+
expect(formatDateSpec(INSTANT, { dateStyle: 'medium' }, 'en-US')).toBe('Sep 1, 2026');
146+
expect(formatDateSpec(INSTANT, { dateStyle: 'medium', timeZone: 'Asia/Tokyo' }, 'en-US')).toBe('Sep 2, 2026');
147+
});
148+
});
149+
150+
describe.runIf(DRIVEN)('i18n date helpers east of UTC, the control (objectui#10866)', () => {
151+
it('rig: the zone really moved', () => {
152+
enter(EAST);
153+
expect(Intl.DateTimeFormat().resolvedOptions().timeZone).toBe(EAST);
154+
expect(new Date(INSTANT).getHours()).toBe(11);
155+
});
156+
157+
it('`formatDate` reads `2026-09-01` as September 1st, as it already did', () => {
158+
enter(EAST);
159+
expect(formatDate(DAY, EN)).toBe('Sep 1, 2026');
160+
expect(formatDate(DAY, { ...EN, style: 'full' })).toBe('Tuesday, September 1, 2026');
161+
expect(formatDate(DAY, { ...EN, dateStyle: 'short' })).toBe('9/1/26');
162+
});
163+
164+
it('`formatDateTime` reads it as midnight of September 1st', () => {
165+
enter(EAST);
166+
expect(formatDateTime(DAY, EN)).toBe('Sep 1, 2026, 12:00 AM');
167+
});
168+
169+
it('`formatRelativeTime` counts to the start of that day', () => {
170+
enter(EAST);
171+
expect(formatRelativeTime(DAY, 'en-US')).toBe('in 2 days');
172+
expect(formatRelativeTime('2026-08-28', 'en-US')).toBe('2 days ago');
173+
});
174+
175+
it('`formatDateSpec` reads it as September 1st, with or without a `timeZone`', () => {
176+
enter(EAST);
177+
expect(formatDateSpec(DAY, { dateStyle: 'medium' }, 'en-US')).toBe('Sep 1, 2026');
178+
expect(formatDateSpec(DAY, { dateStyle: 'medium', timeZone: 'America/New_York' }, 'en-US')).toBe('Sep 1, 2026');
179+
expect(formatDateSpec(DAY, { dateStyle: 'medium', timeZone: 'Asia/Tokyo' }, 'en-US')).toBe('Sep 1, 2026');
180+
});
181+
182+
it('control: an instant keeps its local day here, the 2nd, and its `timeZone` day', () => {
183+
enter(EAST);
184+
expect(formatDate(INSTANT, EN)).toBe('Sep 2, 2026');
185+
expect(formatDateTime(INSTANT, EN)).toBe('Sep 2, 2026, 11:00 AM');
186+
expect(formatRelativeTime('2026-08-31T17:00:00.000Z', 'en-US')).toBe('in 2 days');
187+
expect(formatDateSpec(INSTANT, { dateStyle: 'medium' }, 'en-US')).toBe('Sep 2, 2026');
188+
expect(formatDateSpec(INSTANT, { dateStyle: 'medium', timeZone: 'America/New_York' }, 'en-US')).toBe('Sep 1, 2026');
189+
});
190+
});

‎packages/i18n/src/utils/formatting.ts‎

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,21 @@
22
* @object-ui/i18n - Date and currency formatting utilities
33
*
44
* Uses the native Intl API for locale-aware formatting.
5+
*
6+
* Every date helper below reads a string through `toDisplayDate`
7+
* (`@object-ui/core`), the one parse step behind the date faces
8+
* (objectui#10110, objectui#10183). The engine's own parse reads a date-only
9+
* `2026-09-01` as UTC midnight, so each helper named August 31st for every
10+
* viewer west of UTC (objectui#10866). The shared step tells the two shapes
11+
* apart by the value: a date-only string is rebuilt at local midnight of the
12+
* day it names, and a value with a time part keeps its instant. It also
13+
* refuses a day its month does not have (`2026-02-30`, objectui#10026), which
14+
* each helper then renders as the raw string, its face for any unparsable
15+
* value.
516
*/
617

18+
import { toDisplayDate } from '@object-ui/core';
19+
720
export interface DateFormatOptions {
821
locale?: string;
922
style?: 'short' | 'medium' | 'long' | 'full';
@@ -35,7 +48,7 @@ export function formatDate(
3548
options: DateFormatOptions = {},
3649
): string {
3750
const { locale = 'en', style = 'medium' } = options;
38-
const d = date instanceof Date ? date : new Date(date);
51+
const d = toDisplayDate(date);
3952

4053
if (isNaN(d.getTime())) {
4154
return String(date);
@@ -63,7 +76,7 @@ export function formatDateTime(
6376
options: DateFormatOptions = {},
6477
): string {
6578
const { locale = 'en', style = 'medium' } = options;
66-
const d = date instanceof Date ? date : new Date(date);
79+
const d = toDisplayDate(date);
6780

6881
if (isNaN(d.getTime())) {
6982
return String(date);
@@ -81,12 +94,20 @@ export function formatDateTime(
8194

8295
/**
8396
* Format a relative time (e.g., "2 days ago", "in 3 hours")
97+
*
98+
* A date-only value counts from now to the START of the day it names (local
99+
* midnight), the same distance in every zone at the same wall-clock time.
100+
* An unparsable value comes back as its string, as it does from
101+
* {@link formatDate}; `Intl.RelativeTimeFormat` would throw on it.
84102
*/
85103
export function formatRelativeTime(
86104
date: Date | string | number,
87105
locale = 'en',
88106
): string {
89-
const d = date instanceof Date ? date : new Date(date);
107+
const d = toDisplayDate(date);
108+
if (isNaN(d.getTime())) {
109+
return String(date);
110+
}
90111
const now = new Date();
91112
const diffMs = d.getTime() - now.getTime();
92113
const diffSec = Math.round(diffMs / 1000);

0 commit comments

Comments
 (0)