Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/11693-served-timezone.md
Original file line number Diff line number Diff line change
@@ -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.
56 changes: 55 additions & 1 deletion apps/console/src/LocalizationFetchProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -201,4 +202,57 @@ describe('LocalizationFetchProvider', () => {
expect((globalThis.fetch as never as ReturnType<typeof vi.fn>)).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 <span data-testid="zone">{`${timezone ?? '∅'}|${formatDateTime(INSTANT, { locale: 'en-US' })}`}</span>;
}

function renderZoneProbe() {
return render(
<LocalizationFetchProvider endpoint={ENDPOINT}>
<ZoneProbe />
</LocalizationFetchProvider>,
);
}

afterEach(() => { setDisplayTimeZone(undefined); });

it('renders a datetime in the zone the server answers', async () => {
(globalThis.fetch as never as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue(
ok({ authenticated: true, currency: 'CNY', locale: 'en-US', timezone: null }),
);

renderZoneProbe();

await waitFor(() =>
expect((globalThis.fetch as never as ReturnType<typeof vi.fn>)).toHaveBeenCalledTimes(1),
);
await new Promise((r) => setTimeout(r, 20));
expect(screen.getByTestId('zone')).toHaveTextContent('∅|Sep 2, 2026, 03:00 AM');
});
});
});
16 changes: 12 additions & 4 deletions apps/console/src/LocalizationFetchProvider.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions content/docs/fields/datetime.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading