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
16 changes: 16 additions & 0 deletions .changeset/timeline-gantt-bucket-vocabulary-4520.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -643,6 +643,13 @@ const ar = {
noDate: "بدون تاريخ",
unassigned: "غير معين",
},
scale: {
week: "الأسبوع {{n}}",
quarter: "الربع {{quarter}} {{year}}",
},
gantt: {
rowLabel: "العناصر",
},
},
gantt: {
viewMode: {
Expand Down
7 changes: 7 additions & 0 deletions packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
7 changes: 7 additions & 0 deletions packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,13 @@ const en = {
noDate: 'No date',
unassigned: 'Unassigned',
},
scale: {
week: 'Week {{n}}',
quarter: 'Q{{quarter}} {{year}}',
},
gantt: {
rowLabel: 'Items',
},
},
gantt: {
column: {
Expand Down
7 changes: 7 additions & 0 deletions packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
7 changes: 7 additions & 0 deletions packages/i18n/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
7 changes: 7 additions & 0 deletions packages/i18n/src/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,13 @@ const ja = {
noDate: "日付なし",
unassigned: "未割当",
},
scale: {
week: "第{{n}}週",
quarter: "{{year}}年第{{quarter}}四半期",
},
gantt: {
rowLabel: "項目",
},
},
gantt: {
viewMode: {
Expand Down
7 changes: 7 additions & 0 deletions packages/i18n/src/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,13 @@ const ko = {
noDate: "날짜 없음",
unassigned: "미할당",
},
scale: {
week: "{{n}}주차",
quarter: "{{year}}년 {{quarter}}분기",
},
gantt: {
rowLabel: "항목",
},
},
gantt: {
viewMode: {
Expand Down
7 changes: 7 additions & 0 deletions packages/i18n/src/locales/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
7 changes: 7 additions & 0 deletions packages/i18n/src/locales/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,13 @@ const ru = {
noDate: "Без даты",
unassigned: "Не назначено",
},
scale: {
week: "Неделя {{n}}",
quarter: "{{quarter}} кв. {{year}}",
},
gantt: {
rowLabel: "Элементы",
},
},
gantt: {
viewMode: {
Expand Down
7 changes: 7 additions & 0 deletions packages/i18n/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,13 @@ const zh = {
noDate: '无日期',
unassigned: '未分配',
},
scale: {
week: '第 {{n}} 周',
quarter: '{{year}}年第{{quarter}}季度',
},
gantt: {
rowLabel: '条目',
},
},
gantt: {
column: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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', <TimelineRenderer schema={gantt('week', AUG_11, AUG_12)} />);
expect(week.container.textContent).toContain('Week 1');
expect(week.container.textContent).toContain('第 1 周');
cleanup();

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

const year = renderSession('zh', <TimelineRenderer schema={gantt('year', AUG_11, SEP_11)} />);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => `${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<string, unknown>) => `${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();
});
});
Loading
Loading