Skip to content

Commit d50f724

Browse files
feat(i18n): render instants in the served display zone (objectui#11693) (#11729)
Part of #11693 Clause-②: yes The console's localization fetch dropped the `timezone` that `GET /api/v1/auth/me/localization` answers. It now carries it, and every date and datetime face built on `@object-ui/core`'s date-display functions renders an instant in that zone. A date-only value keeps the day it names, and with no zone every face renders in the viewer's zone, as before. **Gate: this PR merges only after #11758 closes.** That is the director's ruling `6030273339` on #11693: letter B with gate B2, on the maintainer's delegation 「总监决裁」. The server answers `UTC` for a workspace that configured no zone, so this PR alone would move those workspaces' datetimes from the viewer's zone to UTC. #11758 first asks an administrator, once, to set the workspace timezone (pre-filled with the browser's zone, confirm to write). The PR stays draft until #11758 closes. Then the `domain:ui` seat re-checks it (`Unlock-action: re-check PR #11729` on #11693), runs the at-tier contract review, and queues it. Nothing in this PR changes for the ruling. (Gate line added by `domain:ui` seat 3, `session_01CGZy1BGCjdN5cXqL9cnvB8`.) ## Measured first, on `origin/main` `7300fca`, before the change 1. **Does any renderer already take a zone from elsewhere?** Search: `git grep -n -i -E 'time_?zone' -- 'packages/*/src/**' 'apps/console/src/**'` without tests and locale packs, 60 lines in 19 files. No renderer reads a session zone or a per-field zone. The hits that carry a zone are plugin-gantt's per-view `GanttConfig.timeZone` (its chart frame, `makeTzShift`), the report schedule's `timezone`, job and flow previews in metadata-admin, and the settings page's `iana_time_zone` domain. The rest are comments. Control: the same search hits the console's own `MeLocalizationResponse.timezone`, the declaration the card says is dropped, so it reaches the file in question. 2. **Which formatters render the console's datetimes?** `formatDate`, `formatDateTime`, `formatDateTimeCompactParts` and `formatRelativeDate` in `@object-ui/core` (`utils/date-display.ts`). The field cells and widgets, the data-table cell, gantt tooltips, grid and dashboard dates and the dataset measures all call them, threading `{ locale }` from `useDisplayLocale()` and no zone. The i18n formatters the card names (`utils/formatting.ts`, and `formatDateSpec`, which already takes a `timeZone`) have no caller outside `@object-ui/i18n` in source: `git grep -n -E '\b(formatDateTime|formatDateTimeCompactParts|formatDateSpec|applyLocaleConfig)\('` over non-test source. So the zone is implemented where the faces are, in core. Handing it to `formatDateSpec` would reach no renderer. 3. **What does the server answer?** objectstack `resolveCurrentUserLocalization` (`plugin-hono-server`, `current-user-endpoints.ts`): - `timezone` is the workspace's `localization.timezone` (platform default, then global, then tenant), typed "Always a string — the cascade's floor is `UTC`". - That floor is pinned by the test "with nothing configured: the time-zone floor answers, and currency is the one value that stays null". - The settings row is `Default timezone`, `default: 'UTC'`, described as the "IANA zone used to resolve today()/daysFromNow, analytics date buckets, and rendered datetimes". - There is no per-user zone: `sys_user` declares none, and the manifest puts per-user overrides out of scope for v1. ## The change - **`@object-ui/core` `date-display.ts`.** A display zone: `setDisplayTimeZone`, `getDisplayTimeZone`, `subscribeDisplayTimeZone`. - Every face renders an instant in it: both datetime faces, the date faces' day and current-year test, and the relative face's day count and "today". With none set, each face builds the same `Intl` options as before. - A date-only value never takes the zone. `toDisplayDate` and `toInclusiveEndDay` register the calendar-day `Date`s they build (a `WeakSet`), so the exemption holds when a caller hands that `Date` on, as `GridField`'s date cell does. - A name the runtime's `Intl` does not know clears the zone with a warning, rather than making every face throw. - **`@object-ui/i18n` `LocalizationContext.tsx`.** `LocalizationValue.timezone`. `LocalizationProvider` hands it to core in a layout effect and reads the applied zone back with `useSyncExternalStore`. Its context therefore changes only after the faces have the zone, and every consumer re-renders in it before paint. - **Console `LocalizationFetchProvider.tsx`.** Keeps `timezone` from the answer. - No renderer changed: the zone enters through the shared faces only. - Docs: the core and i18n READMEs, and the datetime field guide. Changeset: `minor` for core, i18n and the console. **Why one ambient zone rather than a `timeZone` option at each call site.** About twenty call sites in seven packages thread `{ locale }` today. Threading a zone the same way would leave any forgotten call site rendering the same instant in a second zone beside the first, which is objectui#4272's shape for the locale. It would also edit `packages/fields/src/widgets/*`, which objectui#11689 is editing in parallel. ## Tests, at `229db2b` - **New pins:** `date-display.displayZone-11693.test.ts` (core, 16), `LocalizationContext.timezone-11693.test.tsx` (i18n, 5), and two cases in the console's `LocalizationFetchProvider.test.tsx`. The suite runs in UTC, so UTC stands in for the browser's zone. - With `America/Los_Angeles` set, `2026-09-02T03:00Z` renders `Sep 1, 2026, 08:00 PM`. With none set, it renders `Sep 2, 2026, 03:00 AM`. - A date-only `2026-09-01` renders `Sep 1` under the west zone on every face, including the `Date` from `toDisplayDate` and the inclusive end day. Control: the same instant as a plain `Date` renders `Aug 31`. - **Core, i18n and the console fetch tests:** `pnpm exec vitest run packages/core/ packages/i18n/ apps/console/src/LocalizationFetchProvider.test.tsx apps/console/src/__tests__/localizationBootDedup.test.tsx` gives `Test Files 281 passed (281)`, `Tests 5257 passed | 40 skipped`. This includes `date-display.dateOnlyZone-10110`, which spawns real `TZ=America/Los_Angeles` and `TZ=Asia/Shanghai` children over this module, so the relative face's rewritten day arithmetic is measured unchanged outside UTC. - **Consumers:** 168 files in two runs, `82 passed | 2 skipped` and `83 passed | 1 skipped` (1116 and 1180 tests, 0 failed). They are every test that mounts `LocalizationProvider` or `LocalizationFetchProvider`, plus the date and datetime suites of the packages that call the faces (fields, components, plugin-grid, plugin-gantt, plugin-dashboard and others). - **Type-check:** `pnpm --filter @object-ui/core type-check` and the same for `@object-ui/i18n` exit 0. The console's is narrowed (below). - **Gates, all exit 0:** `check:control-bytes`, `check:test-path-roots`, `check:changeset-claims` (corpus: 1 changeset), `check:pending-changeset-literals`, `check:new-line-citations` (0 new), `check:doc-fences`, `check:doc-example-ids`, `check:doc-example-readers`, `check:doc-types`, `check:phantom-deps`, `check:self-import`, `check-changeset-presence`, and `check-governed-queue-guard --test` (NOT GOVERNED). - **Lint:** `eslint` on the six touched source and test files gives 0 errors. Its 2 warnings are on lines that predate this branch. - **Declared narrowing, so NOT MEASURED here (CI builds what they need):** the console `type-check`, `check:readme-exports`, `check:doc-snippets` and `check:doc-examples` read every package's built `dist/`, and 32 packages are unbuilt here (the three gates exit "could not run"). In their place: - The two console files and the two new README snippets, copied verbatim, compile with the console's own tsconfig against built core, i18n and auth: exit 0. Reverse check: a misspelt `timezone` key reds as TS2353, so the compile reads the rebuilt `LocalizationValue`. - `check:readme-exports --json`, filtered to the core and i18n READMEs: 0 findings, with 96 self-imports judged real and 0 fabricated. Control: the same filter finds 22 entries for app-shell's unbuilt README. ## Ablations One-time proofs, run through `ablation-replace.mjs` (the mutation must land on disk). Each was restored to `HEAD`, proved by blob hash and an empty `git diff HEAD`. | mutation | expected | observed | |---|---|---| | console drops `timezone` | the served-zone pin reds | 1 failed / 13 passed | | `toDisplayDate` stops registering its calendar day | the date-only pins read `Aug 31` or `Yesterday`; the inclusive-end pin and the control stay green | 4 failed / 12 passed, exactly those | | `toInclusiveEndDay` stops registering | its pin alone reds | 1 failed / 15 passed | | the default datetime face takes no zone | its two core pins and the two provider pins that render it red | 4 failed / 17 passed | | the provider does not hand the zone to core | three provider pins and the console pin red | 4 failed / 15 passed | | the context keyed on the prop zone, not the applied one | consumers render before core has the zone and are never re-rendered: the context says `America/Los_Angeles` while the face still renders `03:00 AM` | 3 failed / 2 passed | | i18n hands `setDisplayTimeZone` a number | i18n `type-check` reds from core's rebuilt declarations | TS2345 | The first attempt at the fourth row was refused by the tool and ran nothing, because its replacement text was a substring of its anchor. It was rerun with a distinct marker. ## Open question that gates the merge The card asks: "with none set, it renders in the browser's". The wire never says "none": a workspace that configured no zone is answered `UTC`, the cascade floor above. Merged alone, this PR moves every such workspace's datetimes from the viewer's zone to UTC. The client code here is the same under A, B and D; D adds one line in the fetch provider. - **A, the server says "none".** The endpoint answers `timezone: null` when only the default answered (the settings service reports `source: 'default'`). Server-side `TODAY()` and buckets keep their UTC floor, and unconfigured workspaces keep the viewer's zone. This reverses the endpoint's pinned "never null" for `timezone`. - **B, keep the floor.** The configured-or-default zone governs rendered datetimes, as the settings row's description already states. Unconfigured workspaces render in UTC, and display, `TODAY()` and analytics buckets share one zone. No further change. - **C, the client reads `UTC` as unset.** Not offered: a workspace that really chose UTC would render in the viewer's zone, and it is the consumer-side tolerance AGENTS.md #0.1 forbids. - **D, a separate wire signal** (for example `timezoneSource`). Additive, at the cost of one more field on the wire. **Recommendation: B.** It honours the zone the settings contract already declares for rendered datetimes. It leaves one zone for display, `TODAY()` and buckets, with no client-only rule that "null means the browser". It needs no further change. The cost is that unconfigured workspaces show UTC until an admin sets the zone. If that is unacceptable, the lever is the default (provisioning, or the manifest's `default`), not a client fallback. A is the choice if the unconfigured workspace's experience outweighs one shared zone. ## Acceptance notes - **Datetime renderers outside the shared faces do not take the zone.** Per the card, they are reported here, not special-cased. - Instrument: `git grep -n -E '\.toLocaleString\(|\.toLocaleTimeString\(|new Intl\.DateTimeFormat\(|\.toLocaleDateString\('` over non-test `packages/*/src` and `apps/console/src`, excluding `date-display.ts` and i18n's utils. It over-counts, because `toLocaleString` also matches numbers. - Examples: the record meta footer, comments, history and activity timelines, the audit log, gantt's chart frame and the calendar views. - In a workspace with a configured zone, these show browser-zone times beside zone-rendered cells; in gantt, that means a tooltip beside its bar. - **i18n's own date helpers** (`formatDate`, `formatDateTime`, `formatRelativeTime`, `formatDateSpec`) do not read the display zone. They have no renderer caller in this repository, and the i18n README now says so. - **The core formula `TODAY()`** still answers the UTC day. Its docblock names "a later card" for when the zone reaches the client; it now does (`getDisplayTimeZone()`). - **`vitest.config.mts`' header** says the one zone-aware surface takes its zone from metadata. It is no longer the only one; that is comment text, not edited here. - **Pre-existing and order-dependent:** `scripts/__tests__/date-only-zone-pins-10183.test.ts` reds on its `FormulaFunctions.dateOnlyZone-10866` file (`expected '2026-09-01 11:00' to be '2026-09-01 03:00'`). - Cause: the `unit` project runs with `isolate: false`. When vitest's results cache puts a file that leaves `TZ` east of UTC before it in a shared fork, the leaked `TZ` reaches it. - Two legs at `7300fca`: with a fresh cache it passes 27/27; with the cache this branch had, it fails identically. This branch's runs fail the same way, so this diff is not the cause. --- _Generated by [Claude Code](https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 92f4e2b commit d50f724

10 files changed

Lines changed: 628 additions & 33 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@object-ui/core': minor
3+
'@object-ui/i18n': minor
4+
'@object-ui/console': minor
5+
---
6+
7+
Dates and times render in the time zone the server answers for the signed-in workspace (objectui#11693).
8+
9+
`GET /api/v1/auth/me/localization` answers `timezone` beside `currency` and `locale`, and the console kept only the other two. It now carries all three, and every date and datetime face built on the shared date-display functions of `@object-ui/core` (field cells, grid and card dates, gantt tooltips, dataset measures, data-table cells) renders an instant in that zone. A date-only value still names the day it stores, whatever the zone. With no zone, every face renders in the viewer's own zone, as before.
10+
11+
The zone is whatever the endpoint answers. A server that answers a zone for a workspace that configured none (objectstack's localization cascade answers `UTC` in that case) moves that workspace's datetimes into that zone.
12+
13+
- **`@object-ui/core`**: `setDisplayTimeZone(timeZone)`, `getDisplayTimeZone()` and `subscribeDisplayTimeZone(listener)` declare and read the zone the date faces render instants in. An IANA name the runtime's `Intl` does not know clears it, with a console warning, instead of making every face throw.
14+
- **`@object-ui/i18n`**: `LocalizationValue` gains `timezone`. `LocalizationProvider` hands it to the date faces, and `useLocalization().timezone` reads back the zone they render in.
15+
- Renderers that format dates with their own `Intl` options rather than through the shared functions do not take the zone yet.

‎apps/console/src/LocalizationFetchProvider.test.tsx‎

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616

1717
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
1818
import { render, screen, waitFor } from '@testing-library/react';
19-
import { LOCALE_SEED_STORAGE_KEY, LOCALE_STORAGE_KEY } from '@object-ui/i18n';
19+
import { LOCALE_SEED_STORAGE_KEY, LOCALE_STORAGE_KEY, useLocalization } from '@object-ui/i18n';
20+
import { formatDateTime, setDisplayTimeZone } from '@object-ui/core';
2021
import { LocalizationFetchProvider } from './LocalizationFetchProvider';
2122
// Not on the `@object-ui/auth` barrel; the vitest alias maps that barrel to
2223
// `packages/auth/src`, so this deep path is the SAME module instance the
@@ -201,4 +202,57 @@ describe('LocalizationFetchProvider', () => {
201202
expect((globalThis.fetch as never as ReturnType<typeof vi.fn>)).toHaveBeenCalledTimes(1);
202203
});
203204
});
205+
206+
// objectui#11693: the answer carries `timezone` beside `currency` and
207+
// `locale`, and this provider used to keep only those two. The probe renders
208+
// a datetime through the central face the way a cell does — locale threaded,
209+
// zone not — so the served zone has to arrive through the provider alone.
210+
// The suite runs in UTC (objectui#8366), so UTC is the viewer's zone here.
211+
describe('the served time zone', () => {
212+
/** 03:00 on Sep 2nd in UTC, 11:00 on Sep 2nd in Shanghai. */
213+
const INSTANT = '2026-09-02T03:00:00.000Z';
214+
215+
function ZoneProbe() {
216+
const { timezone } = useLocalization();
217+
return <span data-testid="zone">{`${timezone ?? '∅'}|${formatDateTime(INSTANT, { locale: 'en-US' })}`}</span>;
218+
}
219+
220+
function renderZoneProbe() {
221+
return render(
222+
<LocalizationFetchProvider endpoint={ENDPOINT}>
223+
<ZoneProbe />
224+
</LocalizationFetchProvider>,
225+
);
226+
}
227+
228+
afterEach(() => { setDisplayTimeZone(undefined); });
229+
230+
it('renders a datetime in the zone the server answers', async () => {
231+
(globalThis.fetch as never as ReturnType<typeof vi.fn>).mockResolvedValue(
232+
ok({ authenticated: true, currency: null, locale: 'en-US', timezone: 'Asia/Shanghai' }),
233+
);
234+
235+
renderZoneProbe();
236+
237+
// Before the answer the viewer's zone renders, as it always did.
238+
expect(screen.getByTestId('zone')).toHaveTextContent('∅|Sep 2, 2026, 03:00 AM');
239+
await waitFor(() =>
240+
expect(screen.getByTestId('zone')).toHaveTextContent('Asia/Shanghai|Sep 2, 2026, 11:00 AM'),
241+
);
242+
});
243+
244+
it('an answer with no zone leaves the viewer\'s zone in place', async () => {
245+
(globalThis.fetch as never as ReturnType<typeof vi.fn>).mockResolvedValue(
246+
ok({ authenticated: true, currency: 'CNY', locale: 'en-US', timezone: null }),
247+
);
248+
249+
renderZoneProbe();
250+
251+
await waitFor(() =>
252+
expect((globalThis.fetch as never as ReturnType<typeof vi.fn>)).toHaveBeenCalledTimes(1),
253+
);
254+
await new Promise((r) => setTimeout(r, 20));
255+
expect(screen.getByTestId('zone')).toHaveTextContent('∅|Sep 2, 2026, 03:00 AM');
256+
});
257+
});
204258
});

‎apps/console/src/LocalizationFetchProvider.tsx‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
/**
22
* LocalizationFetchProvider — loads the caller's resolved regional defaults
3-
* (currency / locale) from `GET /api/v1/auth/me/localization` (ADR-0053) and
4-
* feeds the pure `LocalizationProvider` so every field / measure renderer can
5-
* resolve a currency code down to the org default.
3+
* (currency / locale / timezone) from `GET /api/v1/auth/me/localization`
4+
* (ADR-0053) and feeds the pure `LocalizationProvider` so every field /
5+
* measure renderer can resolve a currency code down to the org default, and
6+
* every date face renders an instant in the served zone (objectui#11693).
67
*
78
* Cosmetic, NOT fail-closed: while loading or on error it renders children with
89
* an empty value (no tenant default → renderers show a plain number), so a slow
@@ -105,7 +106,14 @@ export function LocalizationFetchProvider({
105106
cacheLanguageSeed(json.locale);
106107
}
107108
if (cancelled) return;
108-
setValue({ currency: json.currency ?? undefined, locale: json.locale ?? undefined });
109+
// All three keys of the answer. `timezone` used to be dropped here
110+
// (objectui#11693), so no face could render in the zone the server
111+
// resolved; `LocalizationProvider` hands it to the date faces.
112+
setValue({
113+
currency: json.currency ?? undefined,
114+
locale: json.locale ?? undefined,
115+
timezone: json.timezone ?? undefined,
116+
});
109117
return;
110118
} catch (err) {
111119
// A real answer about this caller (401/403/404/500) will not change on

‎content/docs/fields/datetime.mdx‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,14 @@ import { DateTimeCellRenderer } from '@object-ui/fields';
9292
// Renders: Mar 15, 2024, 02:30 PM
9393
```
9494

95+
## Time Zone
96+
97+
A datetime is an instant, and it renders in the workspace's time zone: the
98+
`timezone` that `GET /api/v1/auth/me/localization` answers, which the console
99+
hands to every date face through `LocalizationProvider`. Without one it renders
100+
in the viewer's own zone. A date-only value is a calendar day and shows the
101+
same day in every zone.
102+
95103
## Use Cases
96104

97105
- **Event Scheduling**: Meeting times, appointments

‎packages/core/README.md‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,33 @@ captureUpdateUndoData(['config'], { id: 't1', config: { id: 'c1', mode: 'strict'
223223
- `fields` is required. Pass `undefined` only when the caller has no field
224224
definitions for the object; nothing is then treated as a relation.
225225

226+
### Display time zone (`setDisplayTimeZone`)
227+
228+
The date and datetime faces this package exports (`formatDate`,
229+
`formatDateTime`, `formatDateTimeCompactParts`, `formatRelativeDate`) render
230+
an instant in one display zone. A host declares it once; no caller passes a
231+
zone, so every face on the page agrees.
232+
233+
```typescript
234+
import { formatDateTime, setDisplayTimeZone } from '@object-ui/core'
235+
236+
formatDateTime('2026-09-02T03:00:00Z', { locale: 'en-US' }) // the viewer's zone
237+
setDisplayTimeZone('America/Los_Angeles')
238+
formatDateTime('2026-09-02T03:00:00Z', { locale: 'en-US' }) // "Sep 1, 2026, 08:00 PM"
239+
formatDateTime('2026-09-01', { locale: 'en-US' }) // "Sep 1, 2026, 12:00 AM", in every zone
240+
setDisplayTimeZone(undefined) // back to the viewer's zone
241+
```
242+
243+
- A date-only value names a calendar day and keeps it in every zone, including
244+
the `Date` that `toDisplayDate` builds for one and a caller hands on.
245+
- "Today" for the relative face is the display zone's today.
246+
- An IANA name the runtime's `Intl` does not know clears the zone, with a
247+
console warning.
248+
- `getDisplayTimeZone()` reads it back, and `subscribeDisplayTimeZone(listener)`
249+
reports changes in React's `useSyncExternalStore` shape. In a React tree,
250+
`LocalizationProvider` from `@object-ui/i18n` sets it from its `timezone`
251+
value; a renderer never calls the setter.
252+
226253
## Philosophy
227254

228255
This package is designed to be **framework-agnostic**. It contains:
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
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#11693 — an instant renders in the display zone the host declared,
11+
* and in the viewer's zone when none is declared; a date-only value names its
12+
* day in every case.
13+
*
14+
* The console's localization fetch dropped the `timezone` the server answers,
15+
* so every face rendered in the viewer's zone whatever the workspace
16+
* configured. The zone now enters through `setDisplayTimeZone`, the one input
17+
* every face in this module reads, and nowhere else.
18+
*
19+
* ── How "the viewer's zone" is driven ──────────────────────────────────────
20+
* It is not driven: the suite runs in UTC (`vitest.config.mts`,
21+
* objectui#8366), so the viewer's zone here IS UTC, and the rig case below
22+
* reds if that ever stops being true. The display zone is the explicit
23+
* per-case input that config's header asks non-UTC coverage to use.
24+
* `America/Los_Angeles` (UTC-7 in September) is the discriminating direction
25+
* for the date-only pins: a date-only value is built at UTC midnight here,
26+
* which is the evening BEFORE in Los Angeles, so a zone that wrongly reached
27+
* it would name August 31st. Each of those pins carries that control.
28+
*/
29+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
30+
import {
31+
formatDate,
32+
formatDateTime,
33+
formatDateTimeCompactParts,
34+
formatRelativeDate,
35+
getDisplayTimeZone,
36+
setDisplayTimeZone,
37+
subscribeDisplayTimeZone,
38+
toDisplayDate,
39+
toDisplayEndDate,
40+
toInclusiveEndDay,
41+
} from '../date-display';
42+
43+
const WEST = 'America/Los_Angeles';
44+
const EAST = 'Asia/Shanghai';
45+
const L = 'en-US';
46+
/** 03:00 on Sep 2nd in UTC, 20:00 on Sep 1st in Los Angeles, 11:00 on Sep 2nd in Shanghai. */
47+
const INSTANT = '2026-09-02T03:00:00.000Z';
48+
const DAY = '2026-09-01';
49+
50+
afterEach(() => {
51+
setDisplayTimeZone(undefined);
52+
vi.useRealTimers();
53+
vi.restoreAllMocks();
54+
});
55+
56+
describe('rig (objectui#11693)', () => {
57+
it('the viewer zone this suite renders in is UTC, unlike both display zones used below', () => {
58+
expect(new Intl.DateTimeFormat().resolvedOptions().timeZone).toBe('UTC');
59+
});
60+
});
61+
62+
describe('an instant renders in the display zone when one is set, else in the viewer zone (objectui#11693)', () => {
63+
beforeEach(() => {
64+
vi.useFakeTimers();
65+
vi.setSystemTime(new Date(INSTANT));
66+
});
67+
68+
it('formatDateTime default face', () => {
69+
expect(formatDateTime(INSTANT, { locale: L })).toBe('Sep 2, 2026, 03:00 AM');
70+
setDisplayTimeZone(WEST);
71+
expect(formatDateTime(INSTANT, { locale: L })).toBe('Sep 1, 2026, 08:00 PM');
72+
setDisplayTimeZone(EAST);
73+
expect(formatDateTime(INSTANT, { locale: L })).toBe('Sep 2, 2026, 11:00 AM');
74+
});
75+
76+
it('the compact face and its two halves take ONE zone', () => {
77+
expect(formatDateTimeCompactParts(INSTANT, { locale: L })).toEqual({ date: '9/2/2026', time: '3:00 am' });
78+
expect(formatDateTime(INSTANT, { locale: L, style: 'compact' })).toBe('9/2/2026 3:00 am');
79+
setDisplayTimeZone(WEST);
80+
expect(formatDateTimeCompactParts(INSTANT, { locale: L })).toEqual({ date: '9/1/2026', time: '8:00 pm' });
81+
expect(formatDateTime(INSTANT, { locale: L, style: 'compact' })).toBe('9/1/2026 8:00 pm');
82+
});
83+
84+
it('a Date instant takes the zone too, as the datetime cell hands one', () => {
85+
setDisplayTimeZone(WEST);
86+
expect(formatDateTime(new Date(INSTANT), { locale: L })).toBe('Sep 1, 2026, 08:00 PM');
87+
});
88+
89+
it('formatDate names the day the instant falls on in the zone, on the default and the short face', () => {
90+
expect(formatDate(INSTANT, undefined, { locale: L })).toBe('Sep 2');
91+
expect(formatDate(INSTANT, 'short', { locale: L })).toBe("Sep 2, '26");
92+
setDisplayTimeZone(WEST);
93+
expect(formatDate(INSTANT, undefined, { locale: L })).toBe('Sep 1');
94+
expect(formatDate(INSTANT, 'short', { locale: L })).toBe("Sep 1, '26");
95+
});
96+
97+
it('the current year is the zone\'s current year', () => {
98+
// 03:00 on Jan 1st 2027 in UTC is still Dec 31st 2026 in Los Angeles.
99+
vi.setSystemTime(new Date('2027-01-01T03:00:00.000Z'));
100+
const lastEvening = '2026-12-31T20:00:00.000Z';
101+
expect(formatDate(lastEvening, undefined, { locale: L })).toBe('Dec 31, 2026');
102+
setDisplayTimeZone(WEST);
103+
expect(formatDate(lastEvening, undefined, { locale: L })).toBe('Dec 31');
104+
});
105+
106+
it('formatRelativeDate counts days from the zone\'s today, to the day the instant falls on there', () => {
107+
// 23:00 on Sep 1st in UTC: yesterday from UTC's Sep 2nd, today in Los Angeles.
108+
expect(formatRelativeDate('2026-09-01T23:00:00.000Z', { locale: L })).toBe('Yesterday');
109+
// 20:00 on Sep 2nd in UTC: today in UTC, 04:00 on Sep 3rd in Shanghai.
110+
expect(formatRelativeDate('2026-09-02T20:00:00.000Z', { locale: L })).toBe('Today');
111+
setDisplayTimeZone(WEST);
112+
expect(formatRelativeDate('2026-09-01T23:00:00.000Z', { locale: L })).toBe('Today');
113+
setDisplayTimeZone(EAST);
114+
expect(formatRelativeDate('2026-09-02T20:00:00.000Z', { locale: L })).toBe('Tomorrow');
115+
});
116+
});
117+
118+
describe('a date-only value names its day whatever the display zone (objectui#11693)', () => {
119+
beforeEach(() => {
120+
vi.useFakeTimers();
121+
vi.setSystemTime(new Date(INSTANT));
122+
setDisplayTimeZone(WEST);
123+
});
124+
125+
it('control: the same instant, as a plain Date, names August 31st in the zone', () => {
126+
// What every pin below would read if the zone reached a calendar day.
127+
const sameInstant = new Date(toDisplayDate(DAY).getTime());
128+
expect(formatDate(sameInstant, undefined, { locale: L })).toBe('Aug 31');
129+
});
130+
131+
it('the string, on every face', () => {
132+
expect(formatDate(DAY, undefined, { locale: L })).toBe('Sep 1');
133+
expect(formatDate(DAY, 'short', { locale: L })).toBe("Sep 1, '26");
134+
expect(formatDateTime(DAY, { locale: L })).toBe('Sep 1, 2026, 12:00 AM');
135+
expect(formatDateTimeCompactParts(DAY, { locale: L })).toEqual({ date: '9/1/2026', time: '12:00 am' });
136+
});
137+
138+
it('the Date toDisplayDate built for it, as the sub-grid date cell hands one on', () => {
139+
expect(formatDate(toDisplayDate(DAY), undefined, { locale: L })).toBe('Sep 1');
140+
});
141+
142+
it('the day toInclusiveEndDay names for a date-only end', () => {
143+
expect(formatDate(toInclusiveEndDay(toDisplayEndDate(DAY)), undefined, { locale: L })).toBe('Sep 1');
144+
});
145+
146+
it('the relative face counts it from the zone\'s today without moving the day', () => {
147+
// Today is Sep 1st in Los Angeles, Sep 2nd in UTC.
148+
expect(formatRelativeDate(DAY, { locale: L })).toBe('Today');
149+
expect(formatDate(DAY, 'relative', { locale: L })).toBe('Today');
150+
setDisplayTimeZone(undefined);
151+
expect(formatRelativeDate(DAY, { locale: L })).toBe('Yesterday');
152+
});
153+
154+
it('beyond the relative window, the absolute fallback still names the day', () => {
155+
vi.setSystemTime(new Date('2026-09-30T03:00:00.000Z'));
156+
expect(formatRelativeDate(DAY, { locale: L })).toBe('Sep 1');
157+
});
158+
});
159+
160+
describe('setDisplayTimeZone (objectui#11693)', () => {
161+
it('reads back what it was given, and clears on undefined or an empty string', () => {
162+
expect(getDisplayTimeZone()).toBeUndefined();
163+
setDisplayTimeZone(WEST);
164+
expect(getDisplayTimeZone()).toBe(WEST);
165+
setDisplayTimeZone('');
166+
expect(getDisplayTimeZone()).toBeUndefined();
167+
setDisplayTimeZone(EAST);
168+
setDisplayTimeZone(undefined);
169+
expect(getDisplayTimeZone()).toBeUndefined();
170+
});
171+
172+
it('a name this runtime does not know clears the zone with a warning, so no face throws', () => {
173+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
174+
setDisplayTimeZone(WEST);
175+
setDisplayTimeZone('Middle/Earth');
176+
expect(getDisplayTimeZone()).toBeUndefined();
177+
expect(warn).toHaveBeenCalledTimes(1);
178+
expect(String(warn.mock.calls[0][0])).toContain('Middle/Earth');
179+
expect(formatDateTime(INSTANT, { locale: L })).toBe('Sep 2, 2026, 03:00 AM');
180+
});
181+
182+
it('tells a subscriber about a change, once, and not about a repeat', () => {
183+
const listener = vi.fn();
184+
const unsubscribe = subscribeDisplayTimeZone(listener);
185+
setDisplayTimeZone(WEST);
186+
setDisplayTimeZone(WEST);
187+
expect(listener).toHaveBeenCalledTimes(1);
188+
setDisplayTimeZone(undefined);
189+
expect(listener).toHaveBeenCalledTimes(2);
190+
unsubscribe();
191+
setDisplayTimeZone(EAST);
192+
expect(listener).toHaveBeenCalledTimes(2);
193+
});
194+
});

0 commit comments

Comments
 (0)