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
28 changes: 28 additions & 0 deletions .changeset/tall-pumas-shave.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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(<TimelineRenderer schema={schema} />);
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(<TimelineRenderer schema={schema} />);
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月']);
});
});
254 changes: 254 additions & 0 deletions packages/plugin-timeline/src/__tests__/timeline-date-locale.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<I18nProvider
config={{ defaultLanguage: language, detectBrowserLanguage: false }}
persistLanguage={false}
>
<LocalizationProvider value={{ locale: tenantLocale }}>{node}</LocalizationProvider>
</I18nProvider>,
);
}

/** 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', <TimelineRenderer schema={gantt('hour', AUG_11_09, AUG_11_10)} />);
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', <TimelineRenderer schema={gantt('day', AUG_11, AUG_12)} />);
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', <TimelineRenderer schema={gantt('month', AUG_11, SEP_11)} />);
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', <TimelineRenderer schema={vertical('short', AUG_11)} />);
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', <TimelineRenderer schema={vertical('long', AUG_11)} />);
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', <TimelineRenderer schema={schema} />);
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', <TimelineRenderer schema={{ ...gantt('month', AUG_11, SEP_11), dateFormat: 'long' }} />);
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', <TimelineRenderer schema={gantt('hour', AUG_11_09, AUG_11_10)} />);
expect(container.textContent).toContain('Aug 11, 9 AM');
});

it('site :77 — the day gantt header', () => {
const { container } = renderSession('en', <TimelineRenderer schema={gantt('day', AUG_11, AUG_12)} />);
expect(container.textContent).toContain('Aug 11');
expect(container.textContent).toContain('Aug 12');
});

it('site :108 — the month gantt header', () => {
const { container } = renderSession('en', <TimelineRenderer schema={gantt('month', AUG_11, SEP_11)} />);
expect(container.textContent).toContain('Aug 2026');
expect(container.textContent).toContain('Sep 2026');
});

it('site :157 — the `short` item date', () => {
const { container } = renderSession('en', <TimelineRenderer schema={vertical('short', AUG_11)} />);
expect(container.textContent).toContain('8/11/2026');
});

it('site :160 — the `long` item date', () => {
const { container } = renderSession('en', <TimelineRenderer schema={vertical('long', AUG_11)} />);
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', <TimelineRenderer schema={gantt('week', AUG_11, AUG_12)} />);
expect(week.container.textContent).toContain('Week 1');
cleanup();

const quarter = renderSession('zh', <TimelineRenderer schema={gantt('quarter', AUG_11, SEP_11)} />);
expect(quarter.container.textContent).toContain('Q3 2026');
cleanup();

const year = renderSession('zh', <TimelineRenderer schema={gantt('year', AUG_11, SEP_11)} />);
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', <TimelineRenderer schema={vertical('iso', AUG_11)} />);
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', <TimelineRenderer schema={vertical('short', AUG_11)} />);
expect(screen.getByText('Beta Release')).toBeDefined();
expect(screen.getByText('Released beta version to testers')).toBeDefined();
cleanup();

renderSession('zh', <TimelineRenderer schema={gantt('month', AUG_11, SEP_11)} />);
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', <TimelineRenderer schema={vertical('long', AUG_11)} />, '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', <TimelineRenderer schema={gantt('month', AUG_11, SEP_11)} />, 'en');
expect(container.textContent).toContain('Aug 2026');
expect(container.textContent).not.toContain('2026年8月');
});
});
Loading
Loading