From 9bdfdc9c6e3c15f4e5995472d646c5e249a84b8b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 14:49:16 +0000 Subject: [PATCH 1/4] feat(i18n): render instants in the served display zone (objectui#11693) The console's localization fetch dropped the `timezone` that `GET /api/v1/auth/me/localization` answers. It now carries it in `LocalizationValue`, and `LocalizationProvider` hands it to the one date-display path in `@object-ui/core` (`setDisplayTimeZone`), which every date and datetime face reads. An instant renders in that zone; a date-only value keeps the day it names, including when a caller hands on the `Date` that `toDisplayDate` built for it. With no zone set, every face renders in the viewer's zone, as before. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- .../console/src/LocalizationFetchProvider.tsx | 16 +- packages/core/src/utils/date-display.ts | 168 ++++++++++++++++-- packages/i18n/src/LocalizationContext.tsx | 53 ++++-- 3 files changed, 206 insertions(+), 31 deletions(-) diff --git a/apps/console/src/LocalizationFetchProvider.tsx b/apps/console/src/LocalizationFetchProvider.tsx index c526a6dec9..43d4bc9ee3 100644 --- a/apps/console/src/LocalizationFetchProvider.tsx +++ b/apps/console/src/LocalizationFetchProvider.tsx @@ -1,8 +1,9 @@ /** * LocalizationFetchProvider — loads the caller's resolved regional defaults - * (currency / locale) from `GET /api/v1/auth/me/localization` (ADR-0053) and - * feeds the pure `LocalizationProvider` so every field / measure renderer can - * resolve a currency code down to the org default. + * (currency / locale / timezone) from `GET /api/v1/auth/me/localization` + * (ADR-0053) and feeds the pure `LocalizationProvider` so every field / + * measure renderer can resolve a currency code down to the org default, and + * every date face renders an instant in the served zone (objectui#11693). * * Cosmetic, NOT fail-closed: while loading or on error it renders children with * an empty value (no tenant default → renderers show a plain number), so a slow @@ -105,7 +106,14 @@ export function LocalizationFetchProvider({ cacheLanguageSeed(json.locale); } if (cancelled) return; - setValue({ currency: json.currency ?? undefined, locale: json.locale ?? undefined }); + // All three keys of the answer. `timezone` used to be dropped here + // (objectui#11693), so no face could render in the zone the server + // resolved; `LocalizationProvider` hands it to the date faces. + setValue({ + currency: json.currency ?? undefined, + locale: json.locale ?? undefined, + timezone: json.timezone ?? undefined, + }); return; } catch (err) { // A real answer about this caller (401/403/404/500) will not change on diff --git a/packages/core/src/utils/date-display.ts b/packages/core/src/utils/date-display.ts index 360b6af24f..d4e15750e0 100644 --- a/packages/core/src/utils/date-display.ts +++ b/packages/core/src/utils/date-display.ts @@ -43,21 +43,139 @@ * It is `formatDateTime`'s `'compact'` style now, byte-identical to what the * cell rendered before. * - * Pure by construction (no React, no i18n): the only ambient inputs are `Intl` - * and the clock, and the one phrase `Intl` cannot produce ("Overdue Nd") comes + * No React, no i18n: the ambient inputs are `Intl`, the clock and the display + * zone below, and the one phrase `Intl` cannot produce ("Overdue Nd") comes * in through the INJECTED `options.t`, the same way `buildDatasetFieldHelpers` * in `dataset-format.ts` takes `fieldLabel`. * * ⚠️ There is a third ambient input, and it is the one this module has to - * decide about rather than pass on: the VIEWER's timezone. A value carrying a - * time is an instant and renders in that zone; a DATE-ONLY value names a - * calendar day, carries no instant, and must render as that day everywhere. + * decide about rather than pass on: the zone an instant renders in. A value + * carrying a time is an instant and renders in the DISPLAY ZONE — the zone the + * host declared through {@link setDisplayTimeZone} (objectui#11693), else the + * viewer's own; a DATE-ONLY value names a calendar day, carries no instant, + * and must render as that day everywhere, whatever the display zone. * `toDisplayDate` below is the single parse step that tells the two apart — * every function here goes through it (objectui#10110), and so does every * caller elsewhere that needs a face or a day comparison none of these * functions produce (objectui#10183). */ +// ── The display zone (objectui#11693) ────────────────────────────────────── + +/** + * The zone every instant face below renders in, when the host has declared + * one; `undefined` means the viewer's own zone (the runtime default), which is + * what every face rendered in before objectui#11693. + */ +let displayTimeZone: string | undefined; +const displayTimeZoneListeners = new Set<() => void>(); + +/** `true` when this runtime's `Intl` knows `timeZone`. */ +function isKnownTimeZone(timeZone: string): boolean { + try { + new Intl.DateTimeFormat('en-US', { timeZone }); + return true; + } catch { + return false; + } +} + +/** + * Declare the zone instants render in — the host's half of objectui#11693. + * + * ## Why the zone enters HERE and nowhere else + * + * Every date and datetime face in the console is a function of this module + * (see the header), and each caller threads only its `locale`. The zone the + * signed-in workspace configures (`localization.timezone`, served by + * `GET /api/v1/auth/me/localization`) is the same for every face on the + * page, so it is set once, here, and every face reads it — rather than + * threaded through each call site, where one forgotten caller renders the + * same instant in a second zone beside the first (objectui#4272 is that + * defect for the locale). `LocalizationProvider` (`@object-ui/i18n`) is the + * host that calls this; a renderer never does. + * + * `undefined` (or an empty string) clears it: instants render in the + * viewer's zone again. A name this runtime's `Intl` does not know — a zone + * newer than the browser's time-zone data, say — clears it too, with a + * warning, rather than make every face throw the `RangeError` `Intl` raises + * for it. + * + * ⛔ A date-only value never takes the zone; see {@link toDisplayDate}. + */ +export function setDisplayTimeZone(timeZone: string | undefined): void { + let next: string | undefined; + if (timeZone) { + if (isKnownTimeZone(timeZone)) next = timeZone; + else console.warn(`[date-display] unknown time zone "${timeZone}"; instants render in the viewer's zone`); + } + if (next === displayTimeZone) return; + displayTimeZone = next; + for (const listener of [...displayTimeZoneListeners]) listener(); +} + +/** The zone {@link setDisplayTimeZone} declared, or `undefined` for the viewer's own. */ +export function getDisplayTimeZone(): string | undefined { + return displayTimeZone; +} + +/** + * Be told when the display zone changes; returns the unsubscribe. The shape is + * React's `useSyncExternalStore` contract, which is how `LocalizationProvider` + * re-renders its consumers once the zone they format in has moved. + */ +export function subscribeDisplayTimeZone(listener: () => void): () => void { + displayTimeZoneListeners.add(listener); + return () => { + displayTimeZoneListeners.delete(listener); + }; +} + +/** + * The `Date`s this module built to NAME A CALENDAR DAY — {@link toDisplayDate}'s + * local midnight for a date-only value, and {@link toInclusiveEndDay}'s day. + * + * The faces below must read such a `Date` in the local zone (its own docblock + * says so), never in the display zone: west of the viewer it would name the + * day before. A string still says what it is, but callers hand these `Date`s + * on — `GridField`'s date cell formats the one `toDisplayDate` returned, and + * the functions here hand theirs to each other — so the fact travels with the + * object rather than with the string it came from. + */ +const calendarDays = new WeakSet(); + +/** The zone a parsed value renders in: the display zone for an instant, the viewer's for a calendar day. */ +function zoneFor(date: Date): string | undefined { + return calendarDays.has(date) ? undefined : displayTimeZone; +} + +/** `Intl` options naming `timeZone`, or none, so a face with no zone keeps its exact options bag. */ +function inZone(timeZone: string | undefined): Intl.DateTimeFormatOptions { + return timeZone ? { timeZone } : {}; +} + +const dayPartsFormats = new Map(); + +/** The calendar day `date` falls on in `timeZone` (the viewer's when `undefined`); `month` is 0-based. */ +function dayIn(date: Date, timeZone: string | undefined): { year: number; month: number; day: number } { + if (!timeZone) return { year: date.getFullYear(), month: date.getMonth(), day: date.getDate() }; + let format = dayPartsFormats.get(timeZone); + if (!format) { + format = new Intl.DateTimeFormat('en-US', { timeZone, year: 'numeric', month: 'numeric', day: 'numeric' }); + dayPartsFormats.set(timeZone, format); + } + const parts = format.formatToParts(date); + const part = (type: Intl.DateTimeFormatPartTypes) => Number(parts.find((p) => p.type === type)?.value); + return { year: part('year'), month: part('month') - 1, day: part('day') }; +} + +/** A day as a whole number of days, so two days subtract to their distance with no DST hour in it. */ +function dayNumber({ year, month, day }: { year: number; month: number; day: number }): number { + const probe = new Date(0); + probe.setUTCFullYear(year, month, day); + return Math.round(probe.getTime() / 86_400_000); +} + /** * Options shared by {@link formatDate} / {@link formatRelativeDate} / * {@link formatDateTime}. One bag, and each function reads the keys it needs: @@ -243,7 +361,9 @@ export function isRealCalendarDate(dateOnly: string): boolean { * ⛔ Read the result with LOCAL getters or local-zone `Intl` formatting only. * For a date-only value it is local midnight of the named day, so its * `toISOString()` / UTC getters name the previous day east of UTC — hand - * those the stored value, never this. + * those the stored value, never this. The faces in this module know such a + * `Date` when they are handed one and keep it out of the display zone + * (objectui#11693, see `calendarDays`). */ export function toDisplayDate(value: string | Date | number): Date { const parsed = value instanceof Date ? value : new Date(value as any); @@ -264,6 +384,7 @@ export function toDisplayDate(value: string | Date | number): Date { // Setting the year back is co-extensive with that legacy mapping and a // no-op on every other year. local.setFullYear(year); + calendarDays.add(local); return local; } @@ -329,6 +450,7 @@ export function toDisplayEndDate(value: string | Date | number): Date { export function toInclusiveEndDay(end: Date): Date { const day = new Date(end.getTime() - 1); day.setHours(0, 0, 0, 0); + calendarDays.add(day); return day; } @@ -383,17 +505,18 @@ function absoluteFallbackOptions(options?: DateDisplayOptions): DateDisplayOptio * past dates render as plain "N days ago" instead. The overdue phrase has no * `Intl` equivalent, so it resolves through `options.t` (key * `fields.relativeDate.overdue`) with an English fallback. + * + * Days are counted in the display zone (objectui#11693): "today" is the + * display zone's today, and an instant falls on its day there. A date-only + * value keeps the day it names and is counted from that same today, so an + * instant and a date-only value on one day read one phrase. */ export function formatRelativeDate(value: string | Date | number, options?: DateDisplayOptions): string { if (value === null || value === undefined || value === '') return '—'; const date = toDisplayDate(value); if (!(date instanceof Date) || isNaN(date.getTime())) return '—'; - const now = new Date(); - const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - const startOfDate = new Date(date.getFullYear(), date.getMonth(), date.getDate()); - const diffMs = startOfDate.getTime() - startOfToday.getTime(); - const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24)); + const diffDays = dayNumber(dayIn(date, zoneFor(date))) - dayNumber(dayIn(new Date(), displayTimeZone)); // Beyond the ±7-day window, fall back to the absolute (already localized) form. if (diffDays < -7 || diffDays > 7) return formatDate(date, undefined, absoluteFallbackOptions(options)); @@ -448,6 +571,9 @@ export function formatDate(value: string | Date | number, style?: string, option if (!(date instanceof Date) || isNaN(date.getTime())) return '—'; const effectiveStyle = style ?? options?.style; + // An instant names its day in the display zone; a calendar day is its own + // day in every zone (objectui#11693). + const zone = zoneFor(date); if (effectiveStyle === 'short') { // Compact format for mobile: "Jan 15, '24" / "1月 15, '24". @@ -456,10 +582,9 @@ export function formatDate(value: string | Date | number, style?: string, option // cards, not a locale-derived one. The tag comes from `options.locale` // like the default branch below — hardcoding `'en-US'` here made this the // one branch that ignored a locale its caller had threaded (objectui#4272). - const month = date.toLocaleDateString(options?.locale, { month: 'short' }); - const day = date.getDate(); - const year = String(date.getFullYear()).slice(-2); - return `${month} ${day}, '${year}`; + const month = date.toLocaleDateString(options?.locale, { month: 'short', ...inZone(zone) }); + const { day, year } = dayIn(date, zone); + return `${month} ${day}, '${String(year).slice(-2)}`; } if (effectiveStyle === 'relative') { @@ -471,11 +596,12 @@ export function formatDate(value: string | Date | number, style?: string, option // because the year is rarely useful for in-progress records and the // verbose "2026年7月21日" form crowds cards and table cells. Past- / // future-year dates keep the year so users can disambiguate. - const isCurrentYear = date.getFullYear() === new Date().getFullYear(); + const isCurrentYear = dayIn(date, zone).year === dayIn(new Date(), displayTimeZone).year; return date.toLocaleDateString(options?.locale, { year: isCurrentYear ? undefined : 'numeric', month: 'short', day: 'numeric', + ...inZone(zone), }); } @@ -501,12 +627,16 @@ export function formatDateTimeCompactParts( if (value === null || value === undefined || value === '') return null; const date = toDisplayDate(value); if (!(date instanceof Date) || isNaN(date.getTime())) return null; + // Both halves in the display zone, so the day and the time of one instant + // never come from two zones (objectui#11693). + const zone = zoneFor(date); return { date: date.toLocaleDateString(options?.locale, { month: 'numeric', day: 'numeric', year: 'numeric', + ...inZone(zone), }), // `hour12` stays declared: this is the compact Airtable-style cell, and // the 12-hour face is its design, not a locale artefact. Locales that @@ -515,6 +645,7 @@ export function formatDateTimeCompactParts( hour: 'numeric', minute: '2-digit', hour12: true, + ...inZone(zone), }).toLowerCase(), }; } @@ -543,6 +674,10 @@ export function formatDateTimeCompactParts( * hard it tried — it always handed `Intl` an `undefined` tag, i.e. the * MACHINE's locale, which is neither of the repo's two locale channels. * Callers should pass the tag from `useDisplayLocale()`. + * + * The zone is NOT an option: an instant renders in the display zone the host + * declared with {@link setDisplayTimeZone}, else the viewer's, on every face + * this module produces (objectui#11693). */ export function formatDateTime(value: string | Date | number, options?: DateDisplayOptions): string { if (value === null || value === undefined || value === '') return '—'; @@ -560,5 +695,6 @@ export function formatDateTime(value: string | Date | number, options?: DateDisp day: 'numeric', hour: '2-digit', minute: '2-digit', + ...inZone(zoneFor(date)), }); } diff --git a/packages/i18n/src/LocalizationContext.tsx b/packages/i18n/src/LocalizationContext.tsx index c1182910ee..5bfbdb1e37 100644 --- a/packages/i18n/src/LocalizationContext.tsx +++ b/packages/i18n/src/LocalizationContext.tsx @@ -8,32 +8,56 @@ /** * LocalizationContext — the tenant's resolved REGIONAL defaults (currency, - * locale) for the current request, surfaced to every renderer. + * locale, time zone) for the current request, surfaced to every renderer. * * This is the client half of ADR-0053: the framework resolves a workspace - * `localization.currency` / `locale` onto each request's ExecutionContext and - * exposes them at `GET /api/v1/auth/me/localization`. A fetching wrapper (in - * app-shell) loads them once and feeds this PURE context, so low-level field / - * measure renderers can resolve a currency code down to the org default — - * instead of hard-coding `$`/`¥`/`USD` or degrading to a bare number — without - * any of them depending on app-shell or making their own fetch. + * `localization.currency` / `locale` / `timezone` onto each request's + * ExecutionContext and exposes them at `GET /api/v1/auth/me/localization`. A + * fetching wrapper (in app-shell) loads them once and feeds this PURE context, + * so low-level field / measure renderers can resolve a currency code down to + * the org default — instead of hard-coding `$`/`¥`/`USD` or degrading to a + * bare number — without any of them depending on app-shell or making their own + * fetch. * * Undefined values mean "no tenant default known" → consumers render a plain - * number (the existing safe behavior). + * number (the existing safe behavior), and instants render in the viewer's + * own zone. */ import * as React from 'react'; +import { getDisplayTimeZone, setDisplayTimeZone, subscribeDisplayTimeZone } from '@object-ui/core'; export interface LocalizationValue { /** Tenant default currency (ISO 4217), or undefined when unconfigured. */ currency?: string; /** Tenant/display locale (BCP-47) for Intl formatting, or undefined. */ locale?: string; + /** + * The zone instants render in (an IANA name), or undefined for the viewer's + * own (objectui#11693). The provider hands it to the central date faces in + * `@object-ui/core`; read back from {@link useLocalization}, it is the zone + * those faces render in, so a name the runtime does not know reads back + * `undefined`. + */ + timezone?: string; } const LocalizationCtx = React.createContext({}); -/** Pure provider — the fetching/loading concern lives in the app shell. */ +/** + * Pure provider — the fetching/loading concern lives in the app shell. + * + * It is also the ONE place the display zone enters the date faces + * (objectui#11693): `value.timezone` is handed to `setDisplayTimeZone` in + * `@object-ui/core`, which every date and datetime face reads, so no renderer + * threads a zone of its own. The hand-off runs in a layout effect, and the + * zone the context carries is read back from core through + * `useSyncExternalStore`, so the context changes only AFTER the faces have the + * new zone: every consumer then re-renders in it, before the browser paints. + * + * The display zone is one per page, as the date faces are: a second provider + * with a different zone would race this one for it, and unmounting clears it. + */ export function LocalizationProvider({ value, children, @@ -41,10 +65,17 @@ export function LocalizationProvider({ value: LocalizationValue; children: React.ReactNode; }) { + const { timezone } = value; + React.useLayoutEffect(() => { + setDisplayTimeZone(timezone); + return () => setDisplayTimeZone(undefined); + }, [timezone]); + const appliedZone = React.useSyncExternalStore(subscribeDisplayTimeZone, getDisplayTimeZone, getDisplayTimeZone); + // Stable identity unless the resolved values actually change. const memo = React.useMemo( - () => ({ currency: value.currency, locale: value.locale }), - [value.currency, value.locale], + () => ({ currency: value.currency, locale: value.locale, timezone: appliedZone }), + [value.currency, value.locale, appliedZone], ); return {children}; } From b4494993117282c8fe14ba5dd5352ac4089cb6f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 14:52:33 +0000 Subject: [PATCH 2/4] test: pin the display zone on the date faces, the provider and the fetch (objectui#11693) A datetime renders in the set zone and in the viewer's zone when none is set; a date-only value names its day under a zone west of the viewer, including the Date toDisplayDate hands on; the provider re-renders its consumers when the zone arrives after the first render; the console carries the served zone. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- .../src/LocalizationFetchProvider.test.tsx | 56 ++++- .../date-display.displayZone-11693.test.ts | 194 ++++++++++++++++++ ...ocalizationContext.timezone-11693.test.tsx | 85 ++++++++ 3 files changed, 334 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/utils/__tests__/date-display.displayZone-11693.test.ts create mode 100644 packages/i18n/src/__tests__/LocalizationContext.timezone-11693.test.tsx diff --git a/apps/console/src/LocalizationFetchProvider.test.tsx b/apps/console/src/LocalizationFetchProvider.test.tsx index 8044cb82ea..b46266c7d6 100644 --- a/apps/console/src/LocalizationFetchProvider.test.tsx +++ b/apps/console/src/LocalizationFetchProvider.test.tsx @@ -16,7 +16,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; -import { LOCALE_SEED_STORAGE_KEY, LOCALE_STORAGE_KEY } from '@object-ui/i18n'; +import { LOCALE_SEED_STORAGE_KEY, LOCALE_STORAGE_KEY, useLocalization } from '@object-ui/i18n'; +import { formatDateTime, setDisplayTimeZone } from '@object-ui/core'; import { LocalizationFetchProvider } from './LocalizationFetchProvider'; // Not on the `@object-ui/auth` barrel; the vitest alias maps that barrel to // `packages/auth/src`, so this deep path is the SAME module instance the @@ -201,4 +202,57 @@ describe('LocalizationFetchProvider', () => { expect((globalThis.fetch as never as ReturnType)).toHaveBeenCalledTimes(1); }); }); + + // objectui#11693: the answer carries `timezone` beside `currency` and + // `locale`, and this provider used to keep only those two. The probe renders + // a datetime through the central face the way a cell does — locale threaded, + // zone not — so the served zone has to arrive through the provider alone. + // The suite runs in UTC (objectui#8366), so UTC is the viewer's zone here. + describe('the served time zone', () => { + /** 03:00 on Sep 2nd in UTC, 11:00 on Sep 2nd in Shanghai. */ + const INSTANT = '2026-09-02T03:00:00.000Z'; + + function ZoneProbe() { + const { timezone } = useLocalization(); + return {`${timezone ?? '∅'}|${formatDateTime(INSTANT, { locale: 'en-US' })}`}; + } + + function renderZoneProbe() { + return render( + + + , + ); + } + + afterEach(() => { setDisplayTimeZone(undefined); }); + + it('renders a datetime in the zone the server answers', async () => { + (globalThis.fetch as never as ReturnType).mockResolvedValue( + ok({ authenticated: true, currency: null, locale: 'en-US', timezone: 'Asia/Shanghai' }), + ); + + renderZoneProbe(); + + // Before the answer the viewer's zone renders, as it always did. + expect(screen.getByTestId('zone')).toHaveTextContent('∅|Sep 2, 2026, 03:00 AM'); + await waitFor(() => + expect(screen.getByTestId('zone')).toHaveTextContent('Asia/Shanghai|Sep 2, 2026, 11:00 AM'), + ); + }); + + it('an answer with no zone leaves the viewer\'s zone in place', async () => { + (globalThis.fetch as never as ReturnType).mockResolvedValue( + ok({ authenticated: true, currency: 'CNY', locale: 'en-US', timezone: null }), + ); + + renderZoneProbe(); + + await waitFor(() => + expect((globalThis.fetch as never as ReturnType)).toHaveBeenCalledTimes(1), + ); + await new Promise((r) => setTimeout(r, 20)); + expect(screen.getByTestId('zone')).toHaveTextContent('∅|Sep 2, 2026, 03:00 AM'); + }); + }); }); diff --git a/packages/core/src/utils/__tests__/date-display.displayZone-11693.test.ts b/packages/core/src/utils/__tests__/date-display.displayZone-11693.test.ts new file mode 100644 index 0000000000..ed7efaa299 --- /dev/null +++ b/packages/core/src/utils/__tests__/date-display.displayZone-11693.test.ts @@ -0,0 +1,194 @@ +/** + * 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#11693 — an instant renders in the display zone the host declared, + * and in the viewer's zone when none is declared; a date-only value names its + * day in every case. + * + * The console's localization fetch dropped the `timezone` the server answers, + * so every face rendered in the viewer's zone whatever the workspace + * configured. The zone now enters through `setDisplayTimeZone`, the one input + * every face in this module reads, and nowhere else. + * + * ── How "the viewer's zone" is driven ────────────────────────────────────── + * It is not driven: the suite runs in UTC (`vitest.config.mts`, + * objectui#8366), so the viewer's zone here IS UTC, and the rig case below + * reds if that ever stops being true. The display zone is the explicit + * per-case input that config's header asks non-UTC coverage to use. + * `America/Los_Angeles` (UTC-7 in September) is the discriminating direction + * for the date-only pins: a date-only value is built at UTC midnight here, + * which is the evening BEFORE in Los Angeles, so a zone that wrongly reached + * it would name August 31st. Each of those pins carries that control. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + formatDate, + formatDateTime, + formatDateTimeCompactParts, + formatRelativeDate, + getDisplayTimeZone, + setDisplayTimeZone, + subscribeDisplayTimeZone, + toDisplayDate, + toDisplayEndDate, + toInclusiveEndDay, +} from '../date-display'; + +const WEST = 'America/Los_Angeles'; +const EAST = 'Asia/Shanghai'; +const L = 'en-US'; +/** 03:00 on Sep 2nd in UTC, 20:00 on Sep 1st in Los Angeles, 11:00 on Sep 2nd in Shanghai. */ +const INSTANT = '2026-09-02T03:00:00.000Z'; +const DAY = '2026-09-01'; + +afterEach(() => { + setDisplayTimeZone(undefined); + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe('rig (objectui#11693)', () => { + it('the viewer zone this suite renders in is UTC, unlike both display zones used below', () => { + expect(new Intl.DateTimeFormat().resolvedOptions().timeZone).toBe('UTC'); + }); +}); + +describe('an instant renders in the display zone when one is set, else in the viewer zone (objectui#11693)', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(INSTANT)); + }); + + it('formatDateTime default face', () => { + expect(formatDateTime(INSTANT, { locale: L })).toBe('Sep 2, 2026, 03:00 AM'); + setDisplayTimeZone(WEST); + expect(formatDateTime(INSTANT, { locale: L })).toBe('Sep 1, 2026, 08:00 PM'); + setDisplayTimeZone(EAST); + expect(formatDateTime(INSTANT, { locale: L })).toBe('Sep 2, 2026, 11:00 AM'); + }); + + it('the compact face and its two halves take ONE zone', () => { + expect(formatDateTimeCompactParts(INSTANT, { locale: L })).toEqual({ date: '9/2/2026', time: '3:00 am' }); + expect(formatDateTime(INSTANT, { locale: L, style: 'compact' })).toBe('9/2/2026 3:00 am'); + setDisplayTimeZone(WEST); + expect(formatDateTimeCompactParts(INSTANT, { locale: L })).toEqual({ date: '9/1/2026', time: '8:00 pm' }); + expect(formatDateTime(INSTANT, { locale: L, style: 'compact' })).toBe('9/1/2026 8:00 pm'); + }); + + it('a Date instant takes the zone too, as the datetime cell hands one', () => { + setDisplayTimeZone(WEST); + expect(formatDateTime(new Date(INSTANT), { locale: L })).toBe('Sep 1, 2026, 08:00 PM'); + }); + + it('formatDate names the day the instant falls on in the zone, on the default and the short face', () => { + expect(formatDate(INSTANT, undefined, { locale: L })).toBe('Sep 2'); + expect(formatDate(INSTANT, 'short', { locale: L })).toBe("Sep 2, '26"); + setDisplayTimeZone(WEST); + expect(formatDate(INSTANT, undefined, { locale: L })).toBe('Sep 1'); + expect(formatDate(INSTANT, 'short', { locale: L })).toBe("Sep 1, '26"); + }); + + it('the current year is the zone\'s current year', () => { + // 03:00 on Jan 1st 2027 in UTC is still Dec 31st 2026 in Los Angeles. + vi.setSystemTime(new Date('2027-01-01T03:00:00.000Z')); + const lastEvening = '2026-12-31T20:00:00.000Z'; + expect(formatDate(lastEvening, undefined, { locale: L })).toBe('Dec 31, 2026'); + setDisplayTimeZone(WEST); + expect(formatDate(lastEvening, undefined, { locale: L })).toBe('Dec 31'); + }); + + it('formatRelativeDate counts days from the zone\'s today, to the day the instant falls on there', () => { + // 23:00 on Sep 1st in UTC: yesterday from UTC's Sep 2nd, today in Los Angeles. + expect(formatRelativeDate('2026-09-01T23:00:00.000Z', { locale: L })).toBe('Yesterday'); + // 20:00 on Sep 2nd in UTC: today in UTC, 04:00 on Sep 3rd in Shanghai. + expect(formatRelativeDate('2026-09-02T20:00:00.000Z', { locale: L })).toBe('Today'); + setDisplayTimeZone(WEST); + expect(formatRelativeDate('2026-09-01T23:00:00.000Z', { locale: L })).toBe('Today'); + setDisplayTimeZone(EAST); + expect(formatRelativeDate('2026-09-02T20:00:00.000Z', { locale: L })).toBe('Tomorrow'); + }); +}); + +describe('a date-only value names its day whatever the display zone (objectui#11693)', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(INSTANT)); + setDisplayTimeZone(WEST); + }); + + it('control: the same instant, as a plain Date, names August 31st in the zone', () => { + // What every pin below would read if the zone reached a calendar day. + const sameInstant = new Date(toDisplayDate(DAY).getTime()); + expect(formatDate(sameInstant, undefined, { locale: L })).toBe('Aug 31'); + }); + + it('the string, on every face', () => { + expect(formatDate(DAY, undefined, { locale: L })).toBe('Sep 1'); + expect(formatDate(DAY, 'short', { locale: L })).toBe("Sep 1, '26"); + expect(formatDateTime(DAY, { locale: L })).toBe('Sep 1, 2026, 12:00 AM'); + expect(formatDateTimeCompactParts(DAY, { locale: L })).toEqual({ date: '9/1/2026', time: '12:00 am' }); + }); + + it('the Date toDisplayDate built for it, as the sub-grid date cell hands one on', () => { + expect(formatDate(toDisplayDate(DAY), undefined, { locale: L })).toBe('Sep 1'); + }); + + it('the day toInclusiveEndDay names for a date-only end', () => { + expect(formatDate(toInclusiveEndDay(toDisplayEndDate(DAY)), undefined, { locale: L })).toBe('Sep 1'); + }); + + it('the relative face counts it from the zone\'s today without moving the day', () => { + // Today is Sep 1st in Los Angeles, Sep 2nd in UTC. + expect(formatRelativeDate(DAY, { locale: L })).toBe('Today'); + expect(formatDate(DAY, 'relative', { locale: L })).toBe('Today'); + setDisplayTimeZone(undefined); + expect(formatRelativeDate(DAY, { locale: L })).toBe('Yesterday'); + }); + + it('beyond the relative window, the absolute fallback still names the day', () => { + vi.setSystemTime(new Date('2026-09-30T03:00:00.000Z')); + expect(formatRelativeDate(DAY, { locale: L })).toBe('Sep 1'); + }); +}); + +describe('setDisplayTimeZone (objectui#11693)', () => { + it('reads back what it was given, and clears on undefined or an empty string', () => { + expect(getDisplayTimeZone()).toBeUndefined(); + setDisplayTimeZone(WEST); + expect(getDisplayTimeZone()).toBe(WEST); + setDisplayTimeZone(''); + expect(getDisplayTimeZone()).toBeUndefined(); + setDisplayTimeZone(EAST); + setDisplayTimeZone(undefined); + expect(getDisplayTimeZone()).toBeUndefined(); + }); + + it('a name this runtime does not know clears the zone with a warning, so no face throws', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + setDisplayTimeZone(WEST); + setDisplayTimeZone('Middle/Earth'); + expect(getDisplayTimeZone()).toBeUndefined(); + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0][0])).toContain('Middle/Earth'); + expect(formatDateTime(INSTANT, { locale: L })).toBe('Sep 2, 2026, 03:00 AM'); + }); + + it('tells a subscriber about a change, once, and not about a repeat', () => { + const listener = vi.fn(); + const unsubscribe = subscribeDisplayTimeZone(listener); + setDisplayTimeZone(WEST); + setDisplayTimeZone(WEST); + expect(listener).toHaveBeenCalledTimes(1); + setDisplayTimeZone(undefined); + expect(listener).toHaveBeenCalledTimes(2); + unsubscribe(); + setDisplayTimeZone(EAST); + expect(listener).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/i18n/src/__tests__/LocalizationContext.timezone-11693.test.tsx b/packages/i18n/src/__tests__/LocalizationContext.timezone-11693.test.tsx new file mode 100644 index 0000000000..e5b9298bf4 --- /dev/null +++ b/packages/i18n/src/__tests__/LocalizationContext.timezone-11693.test.tsx @@ -0,0 +1,85 @@ +/** + * 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#11693 — `LocalizationValue.timezone` reaches the date faces. + * + * `LocalizationProvider` is the one place the zone enters them: it hands + * `value.timezone` to `setDisplayTimeZone` (`@object-ui/core`), and its + * context carries the zone back only once the faces have it. The probe below + * renders a datetime through the central face exactly as a cell does (locale + * threaded, zone not), so these pin the whole hand-off, including the case + * the console meets: the zone ARRIVES after the first render, when the + * localization fetch answers. + * + * The suite runs in UTC (`vitest.config.mts`, objectui#8366), so UTC is the + * viewer's zone here, and `America/Los_Angeles` a zone unlike it. + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { formatDateTime, getDisplayTimeZone, setDisplayTimeZone } from '@object-ui/core'; +import { LocalizationProvider, useLocalization, type LocalizationValue } from '../LocalizationContext'; + +/** 03:00 on Sep 2nd in UTC, 20:00 on Sep 1st in Los Angeles. */ +const INSTANT = '2026-09-02T03:00:00.000Z'; +const WEST = 'America/Los_Angeles'; + +function Probe() { + const { timezone } = useLocalization(); + return {`${timezone ?? '∅'}|${formatDateTime(INSTANT, { locale: 'en-US' })}`}; +} + +function renderWith(value: LocalizationValue) { + return render( + + + , + ); +} + +afterEach(() => { + setDisplayTimeZone(undefined); + vi.restoreAllMocks(); +}); + +describe('LocalizationProvider hands the time zone to the date faces (objectui#11693)', () => { + it('a zone unlike the viewer\'s: a datetime renders in the set zone', () => { + renderWith({ locale: 'en-US', timezone: WEST }); + expect(screen.getByTestId('probe')).toHaveTextContent(`${WEST}|Sep 1, 2026, 08:00 PM`); + }); + + it('no zone: a datetime renders in the viewer\'s zone', () => { + renderWith({ locale: 'en-US' }); + expect(screen.getByTestId('probe')).toHaveTextContent('∅|Sep 2, 2026, 03:00 AM'); + }); + + it('a zone that arrives after the first render re-renders every consumer in it', () => { + const { rerender } = renderWith({}); + expect(screen.getByTestId('probe')).toHaveTextContent('∅|Sep 2, 2026, 03:00 AM'); + rerender( + + + , + ); + expect(screen.getByTestId('probe')).toHaveTextContent(`${WEST}|Sep 1, 2026, 08:00 PM`); + }); + + it('unmounting the provider clears the zone', () => { + const { unmount } = renderWith({ timezone: WEST }); + expect(getDisplayTimeZone()).toBe(WEST); + unmount(); + expect(getDisplayTimeZone()).toBeUndefined(); + }); + + it('a name the runtime does not know reads back as no zone, and the faces stay in the viewer\'s', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + renderWith({ timezone: 'Middle/Earth' }); + expect(screen.getByTestId('probe')).toHaveTextContent('∅|Sep 2, 2026, 03:00 AM'); + }); +}); From f6f6d0966a768bb51369a4bd76dfc759563f4622 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 15:16:56 +0000 Subject: [PATCH 3/4] docs: the display zone, its provider and its changeset (objectui#11693) Core's README documents setDisplayTimeZone and its two readers, i18n's documents LocalizationProvider with the timezone it now carries (and says its own date helpers do not read the display zone), and the datetime field guide says which zone a datetime renders in. The changeset is minor for core, i18n and the console. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- .changeset/11693-served-timezone.md | 15 +++++++++++++++ content/docs/fields/datetime.mdx | 8 ++++++++ packages/core/README.md | 27 +++++++++++++++++++++++++++ packages/i18n/README.md | 29 ++++++++++++++++++++++++++++- 4 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 .changeset/11693-served-timezone.md diff --git a/.changeset/11693-served-timezone.md b/.changeset/11693-served-timezone.md new file mode 100644 index 0000000000..2eff19a3fb --- /dev/null +++ b/.changeset/11693-served-timezone.md @@ -0,0 +1,15 @@ +--- +'@object-ui/core': minor +'@object-ui/i18n': minor +'@object-ui/console': minor +--- + +Dates and times render in the time zone the server answers for the signed-in workspace (objectui#11693). + +`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. + +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. + +- **`@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. +- **`@object-ui/i18n`**: `LocalizationValue` gains `timezone`. `LocalizationProvider` hands it to the date faces, and `useLocalization().timezone` reads back the zone they render in. +- Renderers that format dates with their own `Intl` options rather than through the shared functions do not take the zone yet. diff --git a/content/docs/fields/datetime.mdx b/content/docs/fields/datetime.mdx index f3671001eb..61fccd3415 100644 --- a/content/docs/fields/datetime.mdx +++ b/content/docs/fields/datetime.mdx @@ -92,6 +92,14 @@ import { DateTimeCellRenderer } from '@object-ui/fields'; // Renders: Mar 15, 2024, 02:30 PM ``` +## Time Zone + +A datetime is an instant, and it renders in the workspace's time zone: the +`timezone` that `GET /api/v1/auth/me/localization` answers, which the console +hands to every date face through `LocalizationProvider`. Without one it renders +in the viewer's own zone. A date-only value is a calendar day and shows the +same day in every zone. + ## Use Cases - **Event Scheduling**: Meeting times, appointments diff --git a/packages/core/README.md b/packages/core/README.md index 18a22db353..c7902f7d46 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -223,6 +223,33 @@ captureUpdateUndoData(['config'], { id: 't1', config: { id: 'c1', mode: 'strict' - `fields` is required. Pass `undefined` only when the caller has no field definitions for the object; nothing is then treated as a relation. +### Display time zone (`setDisplayTimeZone`) + +The date and datetime faces this package exports (`formatDate`, +`formatDateTime`, `formatDateTimeCompactParts`, `formatRelativeDate`) render +an instant in one display zone. A host declares it once; no caller passes a +zone, so every face on the page agrees. + +```typescript +import { formatDateTime, setDisplayTimeZone } from '@object-ui/core' + +formatDateTime('2026-09-02T03:00:00Z', { locale: 'en-US' }) // the viewer's zone +setDisplayTimeZone('America/Los_Angeles') +formatDateTime('2026-09-02T03:00:00Z', { locale: 'en-US' }) // "Sep 1, 2026, 08:00 PM" +formatDateTime('2026-09-01', { locale: 'en-US' }) // "Sep 1, 2026, 12:00 AM", in every zone +setDisplayTimeZone(undefined) // back to the viewer's zone +``` + +- A date-only value names a calendar day and keeps it in every zone, including + the `Date` that `toDisplayDate` builds for one and a caller hands on. +- "Today" for the relative face is the display zone's today. +- An IANA name the runtime's `Intl` does not know clears the zone, with a + console warning. +- `getDisplayTimeZone()` reads it back, and `subscribeDisplayTimeZone(listener)` + reports changes in React's `useSyncExternalStore` shape. In a React tree, + `LocalizationProvider` from `@object-ui/i18n` sets it from its `timezone` + value; a renderer never calls the setter. + ## Philosophy This package is designed to be **framework-agnostic**. It contains: diff --git a/packages/i18n/README.md b/packages/i18n/README.md index 02a9349edd..44c79166b4 100644 --- a/packages/i18n/README.md +++ b/packages/i18n/README.md @@ -196,7 +196,34 @@ 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. +applies its `timeZone` to an instant only. These helpers do not read the +display zone below; the field cells and measures format through +`@object-ui/core`'s date faces, which do. + +### Regional defaults (`LocalizationProvider`) + +`LocalizationProvider` carries the workspace's resolved regional defaults to +every renderer below it, and `useLocalization()` reads them (`{}` outside a +provider). The console fills it from `GET /api/v1/auth/me/localization`. + +```tsx +import { LocalizationProvider, useLocalization } from '@object-ui/i18n'; + + + {children} +; +``` + +- `currency` (ISO 4217) is the default a currency field without its own code + renders in. +- `locale` (BCP-47) outranks the UI language for number and date formatting; + read the effective tag with `useDisplayLocale()`. +- `timezone` (IANA) is the zone instants render in. The provider hands it to + `setDisplayTimeZone` in `@object-ui/core`, so every date and datetime face + takes it without any renderer passing a zone, and `useLocalization().timezone` + reads back the zone those faces use (`undefined` for a name the runtime does + not know). Without one, instants render in the viewer's zone. There is one + display zone per page, and unmounting the provider clears it. #### The first day of the week From 229db2b023402bf18cac0bfd06b8810d45c86895 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 15:20:23 +0000 Subject: [PATCH 4/4] docs(i18n): make the LocalizationProvider snippet compile on its own (objectui#11693) Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- packages/i18n/README.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/i18n/README.md b/packages/i18n/README.md index 44c79166b4..0312759156 100644 --- a/packages/i18n/README.md +++ b/packages/i18n/README.md @@ -207,11 +207,21 @@ every renderer below it, and `useLocalization()` reads them (`{}` outside a provider). The console fills it from `GET /api/v1/auth/me/localization`. ```tsx +import type { ReactNode } from 'react'; import { LocalizationProvider, useLocalization } from '@object-ui/i18n'; - - {children} -; +export function Regional({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +export function ZoneLabel() { + const { timezone } = useLocalization(); + return {timezone ?? 'viewer zone'}; +} ``` - `currency` (ISO 4217) is the default a currency field without its own code