diff --git a/.changeset/tall-pumas-shave.md b/.changeset/tall-pumas-shave.md
new file mode 100644
index 0000000000..9055273ba0
--- /dev/null
+++ b/.changeset/tall-pumas-shave.md
@@ -0,0 +1,28 @@
+---
+'@object-ui/plugin-timeline': minor
+---
+
+fix(plugin-timeline): dates follow the active locale instead of a hardcoded en-US
+
+A `zh` console rendered a fully Chinese timeline widget whose axis read
+`Aug 11` / `Sep 2026` and whose item dates read `August 11, 2026`
+(objectui#4513). `renderer.tsx` handed `Intl` a literal `'en-US'` at four sites
+— the hour, day and month gantt headers, and the `long` item date — so nothing
+a user or a tenant configured could reach them.
+
+A fifth site was the same defect spelled as an omission: the `short` item date
+called `toLocaleDateString()` with no tag at all, which means the *machine's*
+locale. It agreed with the other four only by the accident of an en-US runner,
+and rendered a third locale on anyone else's machine.
+
+All five now resolve through `useDisplayLocale()` from `@object-ui/i18n`
+(tenant regional default → active UI language → `en`) — the one channel every
+field, number and currency renderer already uses, converged there in
+objectui#4468. The locale is read once in `TimelineRenderer` and threaded into
+the two module-level date helpers, which cannot host a hook themselves.
+
+English output is byte-identical at all five sites: `'en'` and the retired
+`'en-US'` produce the same forms, and `generateTimeScaleHeaders` gained an
+optional trailing `locale` parameter that defaults to `'en'`, so existing
+three-argument callers are unaffected. The locale-free header vocabularies
+(`Week n`, `Qn YYYY`, `YYYY`) and all non-date rendering are untouched.
diff --git a/packages/plugin-timeline/src/__tests__/timeline-date-locale-fallback.test.tsx b/packages/plugin-timeline/src/__tests__/timeline-date-locale-fallback.test.tsx
new file mode 100644
index 0000000000..402fc7e34d
--- /dev/null
+++ b/packages/plugin-timeline/src/__tests__/timeline-date-locale-fallback.test.tsx
@@ -0,0 +1,96 @@
+/**
+ * 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#4513 — the timeline's date sites with NO provider mounted.
+ *
+ * ── Why this is a separate file (objectui#4514) ──────────────────────────
+ * Nothing here mounts an `I18nProvider`, and that is load-bearing rather than
+ * incidental. `useObjectTranslation()` outside a provider reports the language
+ * of react-i18next's GLOBAL instance, and every `I18nProvider` mounted in a
+ * file leaves that global on whatever language it was given. A provider-less
+ * assertion written after a `zh` case in the same file therefore resolves
+ * `'zh'` — a fact about test ordering, not about the fallback. The session
+ * cases live in `timeline-date-locale.test.tsx`; these stay alone.
+ *
+ * ── What this pins, and what it deliberately cannot ──────────────────────
+ * `useDisplayLocale()`'s third step is a concrete `'en'`, never `undefined`.
+ * That matters because `undefined` is what `Intl` reads as "the MACHINE's
+ * locale" — the exact defect at `renderer.tsx:157` before this change, where a
+ * bare `toLocaleDateString()` made an embedded timeline render in whatever
+ * locale the viewer's machine happened to be set to.
+ *
+ * ⚠️ These cases are green on BOTH sides of the fix on an en-US runner, by
+ * construction: `'en'` and the machine's `en-US` agree on all of these forms,
+ * which is precisely why the old code could ship looking correct. They are a
+ * must-not-change pin, NOT the red-first evidence — the red-first half is the
+ * `zh session` block in the sibling file, which is the only place the machine
+ * locale and the session locale disagree. Spelled `'en'` explicitly rather
+ * than as a bare `toLocale*` call computed here, so the expectation does not
+ * silently follow the runner the way the code under test used to.
+ */
+
+import React from 'react';
+import { render } from '@testing-library/react';
+import { describe, it, expect } from 'vitest';
+import { TimelineRenderer, generateTimeScaleHeaders } from '../renderer';
+
+const AUG_11 = '2026-08-11T00:00:00';
+const SEP_11 = '2026-09-11T00:00:00';
+
+describe('no provider mounted — the resolver’s last resort is a concrete `en`', () => {
+ it('the item date renders `en`, not the machine locale', () => {
+ const schema = {
+ type: 'timeline',
+ variant: 'vertical',
+ dateFormat: 'long',
+ items: [{ time: AUG_11, title: 'Beta Release' }],
+ } as any;
+ const { container } = render();
+ expect(container.textContent).toContain(
+ new Date(AUG_11).toLocaleDateString('en', { year: 'numeric', month: 'long', day: 'numeric' }),
+ );
+ });
+
+ it('the gantt axis renders `en`', () => {
+ const schema = {
+ type: 'timeline',
+ variant: 'gantt',
+ scale: 'month',
+ minDate: AUG_11,
+ maxDate: SEP_11,
+ items: [{ label: 'Backend', items: [{ title: 'API Design', startDate: AUG_11, endDate: SEP_11 }] }],
+ } as any;
+ const { container } = render();
+ expect(container.textContent).toContain('Aug 2026');
+ });
+});
+
+describe('generateTimeScaleHeaders — the exported helper called without a locale', () => {
+ /**
+ * The helper is exported (the spec-parity test drives it directly), so its
+ * locale parameter is optional and defaults to `'en'` — the same concrete
+ * last resort the hook resolves to, so an existing 3-argument call site
+ * keeps producing exactly what the retired `'en-US'` literal produced.
+ *
+ * This is a pure function with no React in it, so unlike the renders above
+ * it is not sensitive to provider state at all — it is safe in any file.
+ */
+ it('defaults to `en`, byte-identical to the retired `en-US` literal', () => {
+ expect(generateTimeScaleHeaders('month', AUG_11, SEP_11)).toEqual(['Aug 2026', 'Sep 2026']);
+ expect(generateTimeScaleHeaders('day', AUG_11, '2026-08-12T00:00:00')).toEqual(['Aug 11', 'Aug 12']);
+ expect(generateTimeScaleHeaders('hour', '2026-08-11T09:00:00', '2026-08-11T10:00:00')).toEqual([
+ 'Aug 11, 9 AM',
+ 'Aug 11, 10 AM',
+ ]);
+ });
+
+ it('honours an explicit locale when one is passed', () => {
+ expect(generateTimeScaleHeaders('month', AUG_11, SEP_11, 'zh')).toEqual(['2026年8月', '2026年9月']);
+ });
+});
diff --git a/packages/plugin-timeline/src/__tests__/timeline-date-locale.test.tsx b/packages/plugin-timeline/src/__tests__/timeline-date-locale.test.tsx
new file mode 100644
index 0000000000..da92888925
--- /dev/null
+++ b/packages/plugin-timeline/src/__tests__/timeline-date-locale.test.tsx
@@ -0,0 +1,254 @@
+/**
+ * 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#4513 — every date the timeline renders follows the ACTIVE locale.
+ *
+ * ── The measured defect ──────────────────────────────────────────────────
+ * `renderer.tsx` handed `Intl` a literal `'en-US'` at four sites and nothing
+ * at all at a fifth, so a fully Chinese timeline widget rendered an English
+ * axis. The five, as the #4468 census recorded them:
+ *
+ * :71 hour header `toLocaleString('en-US', { month, day, hour })`
+ * :77 day header `toLocaleDateString('en-US', { month, day })`
+ * :108 month header `toLocaleDateString('en-US', { month, year })`
+ * :157 short item `toLocaleDateString()` ← the omission, not a literal
+ * :160 long item `toLocaleDateString('en-US', { year, month, day })`
+ *
+ * `:157` is the same defect spelled the other way: a bare call means "the
+ * MACHINE's locale", so it agreed with the other four only by the accident of
+ * an en-US runner, and would have rendered a third locale on anyone's laptop.
+ *
+ * ── The fix ──────────────────────────────────────────────────────────────
+ * All five resolve through `useDisplayLocale()` (tenant regional default →
+ * active UI language → `'en'`), the one channel `@object-ui/fields` was
+ * converged onto in objectui#4468 / PR #4512. `'zh'` is a well-formed BCP-47
+ * subtag, so `Intl` takes it verbatim — there is no mapping table here or
+ * anywhere else. The two date helpers are module-level functions, so the
+ * locale is read once in `TimelineRenderer` and threaded down.
+ *
+ * ── Directions ───────────────────────────────────────────────────────────
+ * Reverting `renderer.tsx` to `origin/main` and keeping this file turns every
+ * `zh session` case RED and leaves every `en session` case GREEN. The `en`
+ * cases are green on BOTH sides deliberately — they are the must-not-change
+ * half: `'en'` and the retired `'en-US'` produce byte-identical output at all
+ * five sites, so English rendering must not move by one character.
+ *
+ * The locale-free headers (`Week n`, `Qn YYYY`, `YYYY`) and the non-date
+ * rendering (titles, descriptions, row labels) are likewise green on both
+ * sides: they never went through `Intl`, and this file pins that the change
+ * did not disturb them.
+ *
+ * The provider-LESS last resort is deliberately not measured here — see
+ * `timeline-date-locale-fallback.test.tsx` for why it cannot be.
+ */
+
+import React from 'react';
+import { render, screen, cleanup } from '@testing-library/react';
+import { describe, it, expect, afterEach } from 'vitest';
+import { I18nProvider, LocalizationProvider } from '@object-ui/i18n';
+import { TimelineRenderer } from '../renderer';
+
+/**
+ * Dates are spelled with an explicit local time-of-day rather than as bare
+ * `YYYY-MM-DD`, which `Date` parses as UTC midnight: a runner west of UTC
+ * would otherwise render the neighbouring day and the pins would read as
+ * locale failures when they were timezone failures.
+ */
+const AUG_11_09 = '2026-08-11T09:00:00';
+const AUG_11_10 = '2026-08-11T10:00:00';
+const AUG_11 = '2026-08-11T00:00:00';
+const AUG_12 = '2026-08-12T00:00:00';
+const SEP_11 = '2026-09-11T00:00:00';
+
+/**
+ * A session: the UI language the user picked, plus the tenant's regional
+ * default (usually absent — the state the card was measured in).
+ *
+ * `persistLanguage={false}` keeps each case on its own language instead of
+ * inheriting whatever the previous one wrote to `localStorage`.
+ */
+function renderSession(language: string, node: React.ReactNode, tenantLocale?: string) {
+ return render(
+
+ {node}
+ ,
+ );
+}
+
+/** A gantt schema pinned to an explicit range, so the axis never depends on
+ * today's date. */
+const gantt = (scale: string, minDate: string, maxDate: string) =>
+ ({
+ type: 'timeline',
+ variant: 'gantt',
+ scale,
+ minDate,
+ maxDate,
+ rowLabel: 'Projects',
+ items: [{ label: 'Backend', items: [{ title: 'API Design', startDate: minDate, endDate: maxDate }] }],
+ }) as any;
+
+/** A vertical schema whose single item carries one date, formatted by
+ * `dateFormat`. */
+const vertical = (dateFormat: string, time: string) =>
+ ({
+ type: 'timeline',
+ variant: 'vertical',
+ dateFormat,
+ items: [{ time, title: 'Beta Release', description: 'Released beta version to testers' }],
+ }) as any;
+
+afterEach(() => cleanup());
+
+describe('zh session — every date site renders Chinese (objectui#4513)', () => {
+ it('site :71 — the hour-granularity gantt header', () => {
+ const { container } = renderSession('zh', );
+ expect(container.textContent).toContain('8月11日 9时');
+ expect(container.textContent).not.toContain('Aug 11, 9 AM');
+ });
+
+ it('site :77 — the day gantt header', () => {
+ const { container } = renderSession('zh', );
+ expect(container.textContent).toContain('8月11日');
+ expect(container.textContent).not.toContain('Aug 11');
+ });
+
+ it('site :108 — the month gantt header (the renderer default scale)', () => {
+ const { container } = renderSession('zh', );
+ expect(container.textContent).toContain('2026年8月');
+ expect(container.textContent).toContain('2026年9月');
+ expect(container.textContent).not.toContain('Aug 2026');
+ });
+
+ it("site :157 — the `short` item date, the bare call that read the MACHINE's locale", () => {
+ const { container } = renderSession('zh', );
+ expect(container.textContent).toContain('2026/8/11');
+ expect(container.textContent).not.toContain('8/11/2026');
+ });
+
+ it('site :160 — the `long` item date', () => {
+ const { container } = renderSession('zh', );
+ expect(container.textContent).toContain('2026年8月11日');
+ expect(container.textContent).not.toContain('August 11, 2026');
+ });
+
+ it('the horizontal variant formats its item dates through the same channel', () => {
+ const schema = {
+ type: 'timeline',
+ variant: 'horizontal',
+ dateFormat: 'long',
+ items: [{ time: AUG_11, title: 'Q3' }],
+ } as any;
+ const { container } = renderSession('zh', );
+ expect(container.textContent).toContain('2026年8月11日');
+ expect(container.textContent).not.toContain('August 11, 2026');
+ });
+
+ it('the gantt bar tooltip — a date site that renders into an attribute, not text', () => {
+ renderSession('zh', );
+ const bar = document.querySelector('[title*="API Design"]');
+ expect(bar?.getAttribute('title')).toContain('2026年8月11日');
+ expect(bar?.getAttribute('title')).not.toContain('August 11, 2026');
+ });
+});
+
+describe('en session — output is byte-identical to the retired en-US (must-not-change)', () => {
+ it('site :71 — the hour-granularity gantt header', () => {
+ const { container } = renderSession('en', );
+ expect(container.textContent).toContain('Aug 11, 9 AM');
+ });
+
+ it('site :77 — the day gantt header', () => {
+ const { container } = renderSession('en', );
+ expect(container.textContent).toContain('Aug 11');
+ expect(container.textContent).toContain('Aug 12');
+ });
+
+ it('site :108 — the month gantt header', () => {
+ const { container } = renderSession('en', );
+ expect(container.textContent).toContain('Aug 2026');
+ expect(container.textContent).toContain('Sep 2026');
+ });
+
+ it('site :157 — the `short` item date', () => {
+ const { container } = renderSession('en', );
+ expect(container.textContent).toContain('8/11/2026');
+ });
+
+ it('site :160 — the `long` item date', () => {
+ const { container } = renderSession('en', );
+ expect(container.textContent).toContain('August 11, 2026');
+ });
+});
+
+describe('non-date rendering is undisturbed (green both sides)', () => {
+ it('the locale-free header vocabularies stay exactly as they were — zh', () => {
+ // `Week n` / `Qn YYYY` / `YYYY` never went through `Intl`, so threading a
+ // locale must not touch them. (The first two ARE English on a zh axis, but
+ // they need the package's translate channel rather than a locale tag —
+ // filed as objectui#4520. Converting them here would be an unrelated
+ // behavior change, and this case pins that #4513 left them alone.)
+ const week = renderSession('zh', );
+ expect(week.container.textContent).toContain('Week 1');
+ cleanup();
+
+ const quarter = renderSession('zh', );
+ expect(quarter.container.textContent).toContain('Q3 2026');
+ cleanup();
+
+ const year = renderSession('zh', );
+ expect(year.container.textContent).toContain('2026');
+ });
+
+ it('the `iso` date format is not a locale format and must not become one', () => {
+ // `dateFormat: 'iso'` returns `toISOString().split('T')[0]`. It is a
+ // machine format by definition; a zh session must still see the ISO day.
+ const { container } = renderSession('zh', );
+ expect(container.textContent).toContain('2026-08-11');
+ });
+
+ it('titles, descriptions and row labels are untouched — zh', () => {
+ // `getByText` throws when the node is absent, so it carries the assertion;
+ // `toBeDefined()` is the package's spelling for it (jest-dom's matchers are
+ // loaded by `vitest.setup.ts` at runtime but not declared to `tsc -p
+ // tsconfig.test.json`, so no test here uses them).
+ renderSession('zh', );
+ expect(screen.getByText('Beta Release')).toBeDefined();
+ expect(screen.getByText('Released beta version to testers')).toBeDefined();
+ cleanup();
+
+ renderSession('zh', );
+ expect(screen.getByText('Projects')).toBeDefined();
+ expect(screen.getByText('Backend')).toBeDefined();
+ });
+});
+
+describe('channel precedence is the resolver’s, not this renderer’s (green both sides)', () => {
+ /**
+ * `useDisplayLocale()` puts the TENANT locale above the UI language: an org
+ * that configured `en` means it, even for a user reading Chinese chrome.
+ * Pinned here so a future "simplification" that reads the UI language
+ * directly is caught — the point of the card is one resolver, not one hook
+ * call.
+ */
+ it('an explicit tenant locale outranks the active UI language', () => {
+ const { container } = renderSession('zh', , 'en');
+ expect(container.textContent).toContain('August 11, 2026');
+ expect(container.textContent).not.toContain('2026年8月11日');
+ });
+
+ it('the tenant locale reaches the gantt axis too, not only the item dates', () => {
+ const { container } = renderSession('zh', , 'en');
+ expect(container.textContent).toContain('Aug 2026');
+ expect(container.textContent).not.toContain('2026年8月');
+ });
+});
diff --git a/packages/plugin-timeline/src/renderer.tsx b/packages/plugin-timeline/src/renderer.tsx
index 4b94837743..616c1888c4 100644
--- a/packages/plugin-timeline/src/renderer.tsx
+++ b/packages/plugin-timeline/src/renderer.tsx
@@ -29,6 +29,7 @@ import {
TimelineGanttBarContent,
} from './index';
import { renderChildren, cn } from '@object-ui/components';
+import { useDisplayLocale } from '@object-ui/i18n';
// Constants
/**
@@ -58,8 +59,21 @@ export function resolveTimelineScale(schema: { scale?: unknown; timeScale?: unkn
* scale produces a non-empty header row — `hour` / `quarter` / `year` used to
* fall through the month/week/day chain and return `[]`, blanking the axis
* (#2942). Exported for the spec-parity test.
+ *
+ * `locale` is threaded in rather than read here: this is a pure function, and
+ * the session's locale lives behind a hook (#4513). The three `Intl` branches
+ * below used to pass a literal `'en-US'`, so a fully Chinese timeline rendered
+ * an English axis. The default is `'en'` — the same concrete last resort
+ * `useDisplayLocale()` falls back to, and byte-identical to the retired
+ * `'en-US'` at all three sites — so the existing 3-argument call sites keep
+ * producing exactly what they produced before.
*/
-export function generateTimeScaleHeaders(scale: string, minDate: string, maxDate: string): string[] {
+export function generateTimeScaleHeaders(
+ scale: string,
+ minDate: string,
+ maxDate: string,
+ locale: string = 'en',
+): string[] {
const headers: string[] = [];
const start = new Date(minDate);
const end = new Date(maxDate);
@@ -68,13 +82,13 @@ export function generateTimeScaleHeaders(scale: string, minDate: string, maxDate
switch (scale) {
case 'hour':
while (current <= end) {
- headers.push(current.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric' }));
+ headers.push(current.toLocaleString(locale, { month: 'short', day: 'numeric', hour: 'numeric' }));
current.setHours(current.getHours() + 1);
}
break;
case 'day':
while (current <= end) {
- headers.push(current.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }));
+ headers.push(current.toLocaleDateString(locale, { month: 'short', day: 'numeric' }));
current.setDate(current.getDate() + 1);
}
break;
@@ -105,7 +119,7 @@ export function generateTimeScaleHeaders(scale: string, minDate: string, maxDate
case 'month':
default:
while (current <= end) {
- headers.push(current.toLocaleDateString('en-US', { month: 'short', year: 'numeric' }));
+ headers.push(current.toLocaleDateString(locale, { month: 'short', year: 'numeric' }));
current.setMonth(current.getMonth() + 1);
}
break;
@@ -150,14 +164,24 @@ function calculateBarDimensions(
};
}
-// Helper function to format date
-function formatDate(dateString: string, format?: string): string {
+/**
+ * Format one item date for display.
+ *
+ * `locale` is a REQUIRED parameter, not an optional one (#4513): this helper is
+ * module-private, every call site sits inside `TimelineRenderer`, and the two
+ * ways it used to get a locale were both wrong in the same session. `'short'`
+ * passed nothing at all — and no tag means the MACHINE's locale, which has
+ * nothing to do with the user — while `'long'` passed a literal `'en-US'`. A
+ * required parameter is what keeps a future branch from quietly reintroducing
+ * either. `'iso'` is a machine format by definition and stays locale-free.
+ */
+function formatDate(dateString: string, format: string | undefined, locale: string): string {
const date = new Date(dateString);
if (format === 'short') {
- return date.toLocaleDateString();
+ return date.toLocaleDateString(locale);
}
if (format === 'long') {
- return date.toLocaleDateString('en-US', {
+ return date.toLocaleDateString(locale, {
year: 'numeric',
month: 'long',
day: 'numeric',
@@ -211,6 +235,14 @@ export const TimelineRenderer = ({ schema, className, ...props }: { schema: Time
onItemClick,
} = schema;
+ // The one locale channel every renderer in this repo resolves through:
+ // tenant regional default → active UI language → 'en' (#4513, the channel
+ // #4468 / PR #4512 converged `@object-ui/fields` onto). Read once here,
+ // above every variant's early return so the hook count can never depend on
+ // `variant`, and threaded down — `formatDate` and `generateTimeScaleHeaders`
+ // are module-level functions and cannot host a hook themselves.
+ const displayLocale = useDisplayLocale();
+
// Vertical Timeline
if (variant === 'vertical') {
// Detect whether the data was annotated with a `group` key
@@ -230,10 +262,10 @@ export const TimelineRenderer = ({ schema, className, ...props }: { schema: Time
? { backgroundColor: `${item.color}33`, borderColor: item.color }
: undefined;
const dateLabel = item.time
- ? formatDate(item.time, dateFormat)
- : (item.startDate ? formatDate(item.startDate, dateFormat) : '');
+ ? formatDate(item.time, dateFormat, displayLocale)
+ : (item.startDate ? formatDate(item.startDate, dateFormat, displayLocale) : '');
const endLabel = item.endDate && item.endDate !== item.startDate
- ? formatDate(item.endDate, dateFormat)
+ ? formatDate(item.endDate, dateFormat, displayLocale)
: '';
const meta = Array.isArray(item.meta) ? item.meta : [];
return (
@@ -318,7 +350,7 @@ export const TimelineRenderer = ({ schema, className, ...props }: { schema: Time
{item.time && (
- {formatDate(item.time, dateFormat)}
+ {formatDate(item.time, dateFormat, displayLocale)}
)}
{item.title && {item.title}}
@@ -353,6 +385,7 @@ export const TimelineRenderer = ({ schema, className, ...props }: { schema: Time
resolveTimelineScale(schema as { scale?: unknown; timeScale?: unknown }),
minDate,
maxDate,
+ displayLocale,
);
return (
@@ -408,7 +441,7 @@ export const TimelineRenderer = ({ schema, className, ...props }: { schema: Time
width={dimensions.width}
variant={item.variant || 'default'}
onClick={() => onItemClick?.(item, row, rowIndex, itemIndex)}
- title={`${item.title || ''}\n${formatDate(item.startDate, dateFormat)} - ${formatDate(item.endDate, dateFormat)}`}
+ title={`${item.title || ''}\n${formatDate(item.startDate, dateFormat, displayLocale)} - ${formatDate(item.endDate, dateFormat, displayLocale)}`}
>
{item.title}