From 8cd8f0ddb34dfed337fbfb2a85b20ba88247e922 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 05:15:47 +0000 Subject: [PATCH] fix(plugin-timeline): gantt bucket labels and the Items default speak the session language (#4520) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit objectui#4513 routed every `Intl` call in the timeline renderer through `useDisplayLocale()`, so a zh session renders `2026年8月` on the month axis. Three sibling strings in the same renderer never went through `Intl` at all and stayed English beside it: the `week` header (`Week 1`), the `quarter` header (`Q3 2026`), and the gantt row-label default (`Items`). These are a translation concern, not a locale-resolver one — a locale TAG formats a date, only a TRANSLATION spells a word. All three now resolve through the package's existing channel (`useTimelineTranslation` / `TIMELINE_DEFAULT_TRANSLATIONS`, already used for `timeline.bucket.*`) under three new keys carried by all ten locale packs: `timeline.scale.week`, `timeline.scale.quarter`, `timeline.gantt.rowLabel`. The numbers ride the channel's own `{{hole}}` parameters rather than concatenation, because word order belongs to the translation: zh puts the year first (`2026年第3季度`), which no `Q${q} ${year}` template can produce. `generateTimeScaleHeaders` is a pure exported function and cannot host a hook, so `t` is threaded in as an optional 5th parameter on the seam #4513 opened for `locale`, defaulting to the package's own defaults table. English output is byte-identical and existing 3-/4-argument call sites are unaffected. #4513's suite deliberately pinned `Week 1` / `Q3 2026` staying English to keep that card's scope honest; this card moves exactly those two expectations. The year-only `2026` header is a bare number and stays. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3 --- .../timeline-gantt-bucket-vocabulary-4520.md | 16 ++ packages/i18n/src/locales/ar.ts | 7 + packages/i18n/src/locales/de.ts | 7 + packages/i18n/src/locales/en.ts | 7 + packages/i18n/src/locales/es.ts | 7 + packages/i18n/src/locales/fr.ts | 7 + packages/i18n/src/locales/ja.ts | 7 + packages/i18n/src/locales/ko.ts | 7 + packages/i18n/src/locales/pt.ts | 7 + packages/i18n/src/locales/ru.ts | 7 + packages/i18n/src/locales/zh.ts | 7 + .../__tests__/timeline-date-locale.test.tsx | 30 ++- ...timeline-scale-vocabulary-defaults.test.ts | 115 ++++++++++ .../timeline-scale-vocabulary.test.tsx | 199 ++++++++++++++++++ packages/plugin-timeline/src/renderer.tsx | 40 +++- .../src/useTimelineTranslation.ts | 40 ++++ 16 files changed, 498 insertions(+), 12 deletions(-) create mode 100644 .changeset/timeline-gantt-bucket-vocabulary-4520.md create mode 100644 packages/plugin-timeline/src/__tests__/timeline-scale-vocabulary-defaults.test.ts create mode 100644 packages/plugin-timeline/src/__tests__/timeline-scale-vocabulary.test.tsx diff --git a/.changeset/timeline-gantt-bucket-vocabulary-4520.md b/.changeset/timeline-gantt-bucket-vocabulary-4520.md new file mode 100644 index 0000000000..3adbbd7a51 --- /dev/null +++ b/.changeset/timeline-gantt-bucket-vocabulary-4520.md @@ -0,0 +1,16 @@ +--- +'@object-ui/plugin-timeline': minor +'@object-ui/i18n': minor +--- + +The timeline's gantt bucket labels and its row-label default speak the session language + +objectui#4513 routed every `Intl` call in the timeline renderer through `useDisplayLocale()`, so a Chinese session renders `2026年8月` on the month axis and `2026年8月11日` on item dates. Three sibling strings in the same renderer never went through `Intl` at all and stayed English on that same Chinese axis: the `week` header (`Week 1`), the `quarter` header (`Q3 2026`), and the gantt row-label column default (`Items`). The half-fixed state was the visible one — a Chinese date axis with English bucket labels beside it. + +They are a translation concern rather than a locale-resolver one, and that distinction is the fix: a locale TAG formats a date, only a TRANSLATION spells a word. All three now resolve through the package's existing channel — `useTimelineTranslation` / `TIMELINE_DEFAULT_TRANSLATIONS`, the `createSafeTranslation` factory `ObjectTimeline` already uses for `timeline.bucket.*` — under three new keys carried by all ten locale packs: `timeline.scale.week`, `timeline.scale.quarter`, `timeline.gantt.rowLabel`. + +The week number and the quarter/year ride the channel's own `{{hole}}` parameters rather than being concatenated, because the word order belongs to the translation: Chinese puts the year first (`2026年第3季度`), which no `Q${q} ${year}` template can produce at all. Only the row-label DEFAULT moved — an author who writes `rowLabel` still supplies their own string, and the `year` scale stays a bare `String(getFullYear())` with no vocabulary in it to translate. + +English output is byte-identical to the retired literals: the `en` pack values are the same two templates the code used to interpolate by hand. `generateTimeScaleHeaders` is a pure exported function and cannot host a hook, so the translate fn is threaded in as an optional fifth parameter on the seam #4513 opened for `locale`, defaulting to the package's own defaults table — the same lookup the channel serves with no `I18nProvider` mounted. Existing three- and four-argument call sites are unaffected. + +One consequence is worth stating because it looks like a bug and is not: dates and vocabulary resolve through different channels on purpose. `useDisplayLocale()` puts the tenant's regional default first (how this organization writes dates), while `t` follows the UI language (what this user reads). A tenant configured `en` whose user reads Chinese chrome therefore sees `Aug 2026` beside `第 1 周` — the same split `timeline.bucket.*` has always had. diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 538b902544..d981fd3c5d 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -643,6 +643,13 @@ const ar = { noDate: "بدون تاريخ", unassigned: "غير معين", }, + scale: { + week: "الأسبوع {{n}}", + quarter: "الربع {{quarter}} {{year}}", + }, + gantt: { + rowLabel: "العناصر", + }, }, gantt: { viewMode: { diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index e352fcba90..5b4f89d59e 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -639,6 +639,13 @@ const de = { noDate: "Kein Datum", unassigned: "Nicht zugewiesen", }, + scale: { + week: "Woche {{n}}", + quarter: "Q{{quarter}} {{year}}", + }, + gantt: { + rowLabel: "Elemente", + }, }, gantt: { viewMode: { diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 13a8b83a19..5ced11f9ce 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -688,6 +688,13 @@ const en = { noDate: 'No date', unassigned: 'Unassigned', }, + scale: { + week: 'Week {{n}}', + quarter: 'Q{{quarter}} {{year}}', + }, + gantt: { + rowLabel: 'Items', + }, }, gantt: { column: { diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 6842347830..ac17a150e7 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -643,6 +643,13 @@ const es = { noDate: "Sin fecha", unassigned: "Sin asignar", }, + scale: { + week: "Semana {{n}}", + quarter: "T{{quarter}} {{year}}", + }, + gantt: { + rowLabel: "Elementos", + }, }, gantt: { viewMode: { diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index ec23eb7f52..c5dc6f608e 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -639,6 +639,13 @@ const fr = { noDate: "Sans date", unassigned: "Non attribué", }, + scale: { + week: "Semaine {{n}}", + quarter: "T{{quarter}} {{year}}", + }, + gantt: { + rowLabel: "Éléments", + }, }, gantt: { viewMode: { diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 52f280d7e5..607dfe8009 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -639,6 +639,13 @@ const ja = { noDate: "日付なし", unassigned: "未割当", }, + scale: { + week: "第{{n}}週", + quarter: "{{year}}年第{{quarter}}四半期", + }, + gantt: { + rowLabel: "項目", + }, }, gantt: { viewMode: { diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index fd68981f32..f34f332a5f 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -639,6 +639,13 @@ const ko = { noDate: "날짜 없음", unassigned: "미할당", }, + scale: { + week: "{{n}}주차", + quarter: "{{year}}년 {{quarter}}분기", + }, + gantt: { + rowLabel: "항목", + }, }, gantt: { viewMode: { diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 54884d96fd..d18848c6b6 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -638,6 +638,13 @@ const pt = { noDate: "Sem data", unassigned: "Não atribuído", }, + scale: { + week: "Semana {{n}}", + quarter: "T{{quarter}} {{year}}", + }, + gantt: { + rowLabel: "Itens", + }, }, gantt: { viewMode: { diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index c8fa5fb2db..3835b56f5d 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -645,6 +645,13 @@ const ru = { noDate: "Без даты", unassigned: "Не назначено", }, + scale: { + week: "Неделя {{n}}", + quarter: "{{quarter}} кв. {{year}}", + }, + gantt: { + rowLabel: "Элементы", + }, }, gantt: { viewMode: { diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 1ba9dda48b..14ccc16da8 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -660,6 +660,13 @@ const zh = { noDate: '无日期', unassigned: '未分配', }, + scale: { + week: '第 {{n}} 周', + quarter: '{{year}}年第{{quarter}}季度', + }, + gantt: { + rowLabel: '条目', + }, }, gantt: { column: { diff --git a/packages/plugin-timeline/src/__tests__/timeline-date-locale.test.tsx b/packages/plugin-timeline/src/__tests__/timeline-date-locale.test.tsx index da92888925..70bd6635e7 100644 --- a/packages/plugin-timeline/src/__tests__/timeline-date-locale.test.tsx +++ b/packages/plugin-timeline/src/__tests__/timeline-date-locale.test.tsx @@ -39,10 +39,13 @@ * 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 + * The locale-free headers (the week / quarter / year buckets) 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. + * did not disturb them. Their two English spellings were later routed through + * the package's TRANSLATE channel by objectui#4520 — a different channel from + * the one this card owns — so those two expected strings have moved once, + * deliberately; see the case itself. * * The provider-LESS last resort is deliberately not measured here — see * `timeline-date-locale-fallback.test.tsx` for why it cannot be. @@ -191,18 +194,27 @@ describe('en session — output is byte-identical to the retired en-US (must-not }); describe('non-date rendering is undisturbed (green both sides)', () => { - it('the locale-free header vocabularies stay exactly as they were — zh', () => { + it('the locale-free header vocabularies do not go through `Intl` — 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.) + // locale must not touch them — which is what this case pins, on both sides + // of #4513. + // + // ⚠️ AUTHORIZED PIN MOVE (objectui#4520). The first two expectations read + // `Week 1` / `Q3 2026` when #4513 wrote them: that card was scoped to the + // five `Intl` sites, so it deliberately pinned the two bucket labels + // STAYING English to keep its own scope honest, and filed the English as + // objectui#4520. #4520 then routed them through the package's translate + // channel, so the expected strings — and only they — move here. What the + // case asserts is unchanged: threading a LOCALE did not touch them, and a + // locale tag is still not what spells them. The `year` header below stays + // exactly as it was; `String(getFullYear())` is a bare number with no + // vocabulary in it, so no channel has anything to say about it. const week = renderSession('zh', ); - expect(week.container.textContent).toContain('Week 1'); + expect(week.container.textContent).toContain('第 1 周'); cleanup(); const quarter = renderSession('zh', ); - expect(quarter.container.textContent).toContain('Q3 2026'); + expect(quarter.container.textContent).toContain('2026年第3季度'); cleanup(); const year = renderSession('zh', ); diff --git a/packages/plugin-timeline/src/__tests__/timeline-scale-vocabulary-defaults.test.ts b/packages/plugin-timeline/src/__tests__/timeline-scale-vocabulary-defaults.test.ts new file mode 100644 index 0000000000..abb5378303 --- /dev/null +++ b/packages/plugin-timeline/src/__tests__/timeline-scale-vocabulary-defaults.test.ts @@ -0,0 +1,115 @@ +/** + * 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#4520 — `generateTimeScaleHeaders` as a pure function: the translate + * seam and its English last resort. + * + * ── Why this file mounts nothing ───────────────────────────────────────── + * There is no React here at all — no `render`, no provider, not even an import + * of one. That is deliberate (objectui#4514): the sibling + * `timeline-scale-vocabulary.test.tsx` mounts an `I18nProvider` in every case, + * and every mounted provider leaves react-i18next's GLOBAL instance on the + * language it was given, so a provider-less assertion sharing that file would + * resolve `'zh'` and pin test ordering instead of the fallback. Keeping the + * pure-function cases in their own file makes the separation structural rather + * than a comment someone has to remember. + * + * ── What the seam is ───────────────────────────────────────────────────── + * `generateTimeScaleHeaders` is a pure exported function, so it cannot host a + * hook. objectui#4513 threaded the resolved `locale` string in as a parameter + * with an `'en'` default; this card threads the translate fn the same way, for + * the same reason and with the same shape — the default is the package's own + * `TIMELINE_DEFAULT_TRANSLATIONS` table, which is exactly what + * `createSafeTranslation` serves on a provider-less host. So an existing + * 3- or 4-argument call site keeps producing byte-identical English. + * + * ── Directions ─────────────────────────────────────────────────────────── + * The English cases are GREEN on both sides of the fix by construction — the + * `en` pack values are byte-identical to the retired `Week ${n}` / + * `Q${q} ${year}` literals, which is the whole point of the must-not-change + * half. The RED-first evidence here is the seam itself: before the change the + * function takes no translate fn, so the 5th argument is silently dropped and + * the explicit-`t` cases fail. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { generateTimeScaleHeaders } from '../renderer'; +import { TIMELINE_DEFAULT_TRANSLATIONS } from '../useTimelineTranslation'; + +const AUG_11 = '2026-08-11T00:00:00'; +const AUG_12 = '2026-08-12T00:00:00'; +const SEP_11 = '2026-09-11T00:00:00'; + +describe('the English last resort is byte-identical to the retired literals', () => { + it('the 3-argument week call still reads `Week n`', () => { + expect(generateTimeScaleHeaders('week', AUG_11, AUG_12)).toEqual(['Week 1']); + expect(generateTimeScaleHeaders('week', AUG_11, SEP_11)).toEqual([ + 'Week 1', + 'Week 2', + 'Week 3', + 'Week 4', + 'Week 5', + ]); + }); + + it('the 3-argument quarter call still reads `Qn YYYY` (the objectui#2942 pin)', () => { + expect(generateTimeScaleHeaders('quarter', '2026-01-15', '2026-08-01')).toEqual([ + 'Q1 2026', + 'Q2 2026', + 'Q3 2026', + ]); + }); + + it('the year scale carries no vocabulary and is untouched', () => { + expect(generateTimeScaleHeaders('year', '2025-06-01', '2027-01-01')).toEqual(['2025', '2026', '2027']); + }); + + it('the defaults table states those same two strings, so the seam has one source', () => { + // If these drift, the provider-less host and the `en` provider render + // different axes — the failure `createSafeTranslation` exists to prevent. + expect(TIMELINE_DEFAULT_TRANSLATIONS['timeline.scale.week']).toBe('Week {{n}}'); + expect(TIMELINE_DEFAULT_TRANSLATIONS['timeline.scale.quarter']).toBe('Q{{quarter}} {{year}}'); + expect(TIMELINE_DEFAULT_TRANSLATIONS['timeline.gantt.rowLabel']).toBe('Items'); + }); +}); + +describe('an explicit translate fn drives both bucket scales', () => { + it('the week bucket asks the channel, and passes the number as a parameter', () => { + const t = vi.fn((key: string, params?: Record) => `${key}:${JSON.stringify(params)}`); + const headers = generateTimeScaleHeaders('week', AUG_11, SEP_11, 'zh', t); + + expect(headers[0]).toBe('timeline.scale.week:{"n":1}'); + expect(headers[1]).toBe('timeline.scale.week:{"n":2}'); + // The number reaches the channel as DATA, never spliced into the key or + // concatenated onto the returned string. + expect(t).toHaveBeenCalledWith('timeline.scale.week', { n: 1 }); + }); + + it('the quarter bucket passes the quarter and the year as separate parameters', () => { + const t = vi.fn((key: string, params?: Record) => `${key}:${JSON.stringify(params)}`); + const headers = generateTimeScaleHeaders('quarter', '2026-11-15T00:00:00', '2027-02-01T00:00:00', 'zh', t); + + expect(t).toHaveBeenCalledWith('timeline.scale.quarter', { quarter: 4, year: 2026 }); + expect(t).toHaveBeenCalledWith('timeline.scale.quarter', { quarter: 1, year: 2027 }); + expect(headers).toEqual([ + 'timeline.scale.quarter:{"quarter":4,"year":2026}', + 'timeline.scale.quarter:{"quarter":1,"year":2027}', + ]); + }); + + it('the date scales do NOT go through the translate channel — they are `Intl`', () => { + // The division of labour from objectui#4513: a locale TAG formats dates, a + // translation spells vocabulary. A date scale asking `t()` would be this + // card overreaching into the other one's territory. + const t = vi.fn((key: string) => `TRANSLATED:${key}`); + expect(generateTimeScaleHeaders('month', AUG_11, SEP_11, 'zh', t)).toEqual(['2026年8月', '2026年9月']); + expect(generateTimeScaleHeaders('year', '2025-06-01', '2027-01-01', 'zh', t)).toEqual(['2025', '2026', '2027']); + expect(t).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/plugin-timeline/src/__tests__/timeline-scale-vocabulary.test.tsx b/packages/plugin-timeline/src/__tests__/timeline-scale-vocabulary.test.tsx new file mode 100644 index 0000000000..186dd95627 --- /dev/null +++ b/packages/plugin-timeline/src/__tests__/timeline-scale-vocabulary.test.tsx @@ -0,0 +1,199 @@ +/** + * 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#4520 — the gantt BUCKET vocabulary and the row-label default speak + * the session language. + * + * ── The measured defect ────────────────────────────────────────────────── + * objectui#4513 threaded `useDisplayLocale()` through every `Intl` call in + * `renderer.tsx`, so a `zh` session renders `2026年8月` on the month axis and + * `2026年8月11日` on item dates. Three sibling strings in the same renderer + * never went through `Intl` at all and stayed English on that same Chinese + * axis: + * + * week header `Week ${n}` -> `Week 1` + * quarter header `Q${q} ${year}` -> `Q3 2026` + * row label `schema.rowLabel || 'Items'` -> `Items` + * + * The half-fixed state is the visible one: a Chinese date axis with English + * bucket labels beside it. + * + * ── Why this is a translation card and not a locale-resolver one ────────── + * No `Intl` call is involved in any of the three. A locale TAG cannot spell + * `第 1 周`; only a translation can. So these resolve through the package's + * own channel — `useTimelineTranslation` / `TIMELINE_DEFAULT_TRANSLATIONS`, + * the `createSafeTranslation` factory `ObjectTimeline` already uses for + * `timeline.bucket.*` — and the numbers ride the channel's own `{{hole}}` + * parameter mechanism rather than being concatenated into the string. + * + * ── The two channels, and why they can disagree ─────────────────────────── + * They are deliberately different resolvers and this file pins that: + * + * dates -> `useDisplayLocale()` : tenant regional default -> UI language -> `en` + * vocabulary -> the translate channel : the UI language the user reads + * + * A tenant that configured the `en` regional default while the user reads + * Chinese chrome therefore renders `Aug 2026` beside `第 1 周`. That is the + * repo's established split, not a defect of this change — `timeline.bucket.*` + * has behaved this way in `ObjectTimeline` all along — and objectui#4513's + * precedence pin is about DATES, which this card must not move. + * + * ── Directions ─────────────────────────────────────────────────────────── + * Reverting `renderer.tsx` to its pre-#4520 state turns the three `zh session` + * vocabulary cases RED and leaves every `en session` case GREEN. The `en` half + * is the must-not-change pin: the translated `en` values are byte-identical to + * the retired hardcoded literals, so English rendering must not move by one + * character. + * + * ⚠️ Every case here mounts an `I18nProvider`. Provider-LESS assertions must + * NOT be added to this file (objectui#4514): `useObjectTranslation()` outside a + * provider reports the language of react-i18next's GLOBAL instance, which every + * mounted provider leaves on whatever language it was given, so a provider-less + * case written after a `zh` case here would resolve `'zh'` and pin test + * ordering rather than the fallback. The channel's provider-less last resort + * lives in `timeline-scale-vocabulary-defaults.test.ts`, which mounts nothing + * and renders nothing. + */ + +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'; + +/** + * Explicit local time-of-day rather than bare `YYYY-MM-DD`, which `Date` parses + * as UTC midnight: a runner west of UTC would render the neighbouring day and + * the pins would read as vocabulary failures when they were timezone failures. + */ +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). */ +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. `rowLabel` is left UNSET — the defect is in the default, and an + * author who supplies one supplies their own string (pinned separately below). + */ +const gantt = (scale: string, minDate: string, maxDate: string) => + ({ + type: 'timeline', + variant: 'gantt', + scale, + minDate, + maxDate, + items: [{ label: 'Backend', items: [{ title: 'API Design', startDate: minDate, endDate: maxDate }] }], + }) as any; + +afterEach(() => cleanup()); + +describe('zh session — the gantt bucket vocabulary is Chinese (objectui#4520)', () => { + it('the week header reads the translated bucket, not `Week n`', () => { + const { container } = renderSession('zh', ); + expect(container.textContent).toContain('第 1 周'); + expect(container.textContent).not.toContain('Week 1'); + }); + + it('the week number rides the channel’s parameter mechanism across several buckets', () => { + // Three weeks of range: if the number were concatenated rather than + // interpolated, the second and third buckets would be the giveaway. + const { container } = renderSession('zh', ); + expect(container.textContent).toContain('第 1 周'); + expect(container.textContent).toContain('第 2 周'); + expect(container.textContent).not.toContain('Week'); + }); + + it('the quarter header reads the translated bucket, not `Qn YYYY`', () => { + const { container } = renderSession('zh', ); + expect(container.textContent).toContain('2026年第3季度'); + expect(container.textContent).not.toContain('Q3 2026'); + }); + + it('the quarter number and the year are separate parameters, not one string', () => { + // A range crossing a year boundary: `zh` puts the year FIRST, so a value + // built by concatenating `Q${q} ${year}` cannot produce this ordering at + // all — this is the case that fails if the interpolation is faked. + const { container } = renderSession('zh', ); + expect(container.textContent).toContain('2026年第4季度'); + expect(container.textContent).toContain('2027年第1季度'); + }); + + it('the unset row label reads the translated default, not `Items`', () => { + const { container } = renderSession('zh', ); + expect(container.textContent).toContain('条目'); + expect(container.textContent).not.toContain('Items'); + }); +}); + +describe('en session — byte-identical to the retired literals (must-not-change)', () => { + it('the week header still reads `Week 1`', () => { + const { container } = renderSession('en', ); + expect(container.textContent).toContain('Week 1'); + }); + + it('the week counter still increments per bucket', () => { + const { container } = renderSession('en', ); + expect(container.textContent).toContain('Week 1'); + expect(container.textContent).toContain('Week 2'); + }); + + it('the quarter header still reads `Q3 2026`', () => { + const { container } = renderSession('en', ); + expect(container.textContent).toContain('Q3 2026'); + }); + + it('the unset row label still reads `Items`', () => { + const { container } = renderSession('en', ); + expect(container.textContent).toContain('Items'); + }); +}); + +describe('what this card must NOT move (green both sides)', () => { + it('the year header is a bare number and stays one — zh', () => { + // `String(getFullYear())` carries no vocabulary at all. Translating it + // would be inventing a bucket label the card did not ask for. + const { container } = renderSession('zh', ); + expect(container.textContent).toContain('2026'); + }); + + it('an author-supplied rowLabel wins over the translated default — zh', () => { + // Only the DEFAULT was hardcoded. An author who writes `rowLabel` supplies + // their own string and no translation may overwrite it. + const schema = { ...gantt('month', AUG_11, SEP_11), rowLabel: 'Projects' }; + renderSession('zh', ); + expect(screen.getByText('Projects')).toBeDefined(); + }); + + it('the date axis still follows the DATE channel, tenant first (objectui#4513)', () => { + // The tenant configured `en`; the user reads `zh`. Dates are the tenant's + // (`Aug 2026`), vocabulary is the user's — two resolvers, by design. + const { container } = renderSession('zh', , 'en'); + expect(container.textContent).toContain('Aug 2026'); + expect(container.textContent).not.toContain('2026年8月'); + }); + + it('non-date, non-bucket rendering is untouched — zh', () => { + renderSession('zh', ); + expect(screen.getByText('Backend')).toBeDefined(); + expect(screen.getByText('API Design')).toBeDefined(); + }); +}); diff --git a/packages/plugin-timeline/src/renderer.tsx b/packages/plugin-timeline/src/renderer.tsx index 616c1888c4..29e4865a45 100644 --- a/packages/plugin-timeline/src/renderer.tsx +++ b/packages/plugin-timeline/src/renderer.tsx @@ -30,6 +30,11 @@ import { } from './index'; import { renderChildren, cn } from '@object-ui/components'; import { useDisplayLocale } from '@object-ui/i18n'; +import { + useTimelineTranslation, + translateTimelineDefault, + type TimelineTranslate, +} from './useTimelineTranslation'; // Constants /** @@ -67,12 +72,22 @@ export function resolveTimelineScale(schema: { scale?: unknown; timeScale?: unkn * `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. + * + * `t` is threaded on the same seam and for the same reason (#4520). The two are + * different kinds of dependency and each covers what the other cannot: a locale + * TAG formats a date, a TRANSLATION spells a word. The `week` and `quarter` + * branches never touched `Intl`, so #4513 left them reading `Week 1` / `Q3 + * 2026` on an axis that had just become Chinese. Its default is the package's + * own defaults table, which is what the channel serves with no `I18nProvider` + * mounted, so 3- and 4-argument call sites keep producing byte-identical + * English. */ export function generateTimeScaleHeaders( scale: string, minDate: string, maxDate: string, locale: string = 'en', + t: TimelineTranslate = translateTimelineDefault, ): string[] { const headers: string[] = []; const start = new Date(minDate); @@ -95,7 +110,7 @@ export function generateTimeScaleHeaders( case 'week': { let week = 1; while (current <= end) { - headers.push(`Week ${week++}`); + headers.push(t('timeline.scale.week', { n: week++ })); current.setDate(current.getDate() + 7); } break; @@ -103,7 +118,12 @@ export function generateTimeScaleHeaders( case 'quarter': current.setMonth(Math.floor(current.getMonth() / 3) * 3, 1); while (current <= end) { - headers.push(`Q${Math.floor(current.getMonth() / 3) + 1} ${current.getFullYear()}`); + headers.push( + t('timeline.scale.quarter', { + quarter: Math.floor(current.getMonth() / 3) + 1, + year: current.getFullYear(), + }), + ); current.setMonth(current.getMonth() + 3); } break; @@ -243,6 +263,19 @@ export const TimelineRenderer = ({ schema, className, ...props }: { schema: Time // are module-level functions and cannot host a hook themselves. const displayLocale = useDisplayLocale(); + // The package's translate channel, read on the same terms and for the same + // structural reason as the locale above: one read, above every variant's + // early return, threaded down into the module-level helpers that cannot + // host a hook (#4520). + // + // It is a SECOND channel, not a duplicate of the first, and the two are + // allowed to disagree: `useDisplayLocale()` puts the tenant's regional + // default first (how this org writes dates), while `t` follows the UI + // language (what this user reads). A tenant configured `en` with a user + // reading Chinese chrome therefore renders `Aug 2026` beside `第 1 周` — + // the same split `timeline.bucket.*` has always had in `ObjectTimeline`. + const { t } = useTimelineTranslation(); + // Vertical Timeline if (variant === 'vertical') { // Detect whether the data was annotated with a `group` key @@ -386,6 +419,7 @@ export const TimelineRenderer = ({ schema, className, ...props }: { schema: Time minDate, maxDate, displayLocale, + t, ); return ( @@ -394,7 +428,7 @@ export const TimelineRenderer = ({ schema, className, ...props }: { schema: Time - {schema.rowLabel || 'Items'} + {schema.rowLabel || t('timeline.gantt.rowLabel')} diff --git a/packages/plugin-timeline/src/useTimelineTranslation.ts b/packages/plugin-timeline/src/useTimelineTranslation.ts index 43096e36cc..dae3484f97 100644 --- a/packages/plugin-timeline/src/useTimelineTranslation.ts +++ b/packages/plugin-timeline/src/useTimelineTranslation.ts @@ -28,10 +28,50 @@ export const TIMELINE_DEFAULT_TRANSLATIONS: Record = { 'timeline.relative.yesterday': 'Yesterday', 'timeline.relative.inDays': 'In {{n}} days', 'timeline.relative.daysAgo': '{{n}} days ago', + // Gantt axis vocabulary (objectui#4520). These were `Week ${n}` and + // `Q${q} ${year}` template literals in `renderer.tsx`, so a zh session read + // an English bucket label beside the Chinese date axis objectui#4513 had just + // fixed. The numbers are `{{holes}}` rather than concatenation because the + // word order is the translation's to choose: `zh` puts the year first + // (`2026年第3季度`), which no `Q${q} ${year}` template can produce. + 'timeline.scale.week': 'Week {{n}}', + 'timeline.scale.quarter': 'Q{{quarter}} {{year}}', + 'timeline.gantt.rowLabel': 'Items', }; const TEST_KEY = 'timeline.bucket.today'; +/** + * The translate fn shape this package threads into its pure helpers. + * + * `generateTimeScaleHeaders` is a module-level function and cannot host a hook, + * so the component reads the channel and passes `t` down — the same seam + * objectui#4513 opened for the resolved `locale` string, for the same reason. + */ +export type TimelineTranslate = (key: string, params?: Record) => string; + +/** + * The English last resort for call sites that have no `t` to hand: the exported + * helper's default parameter, and nothing else. + * + * It is the same lookup `createSafeTranslation` serves on a provider-less host, + * spelled here because that one is a hook and this is a pure function. Both + * halves are deliberate mirrors of `useSafeTranslation.ts` and must stay that + * way — a divergence would only ever show up on hosts with no `I18nProvider`, + * where we are least likely to see it: + * + * - `||` (not `??`) for the chain, so an empty string falls through to the + * key exactly as the hook's `defaults[key] || …` chain does. + * - `split().join()` (not `replace`/`replaceAll`) for interpolation, which is + * literal on both sides and substitutes EVERY occurrence — i18next's own + * behaviour, and the objectui#3418 fix. + */ +export const translateTimelineDefault: TimelineTranslate = (key, params) => + Object.entries(params ?? {}).reduce( + (value, [name, v]) => value.split(`{{${name}}}`).join(String(v)), + TIMELINE_DEFAULT_TRANSLATIONS[key] || key, + ); + /** * Was a local re-implementation that wrapped the hook in try/catch — a * rules-of-hooks violation (objectui#2879, same class as #2595/#2596).