From 54750cca3c048d7bc3472f742e5bd2b19886f42b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Thu, 16 Jul 2026 05:56:53 -0700 Subject: [PATCH] fix(fields): localize relative-date humanize via Intl.RelativeTimeFormat (framework#3040) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The near-today (±7d) window of date fields rendered hardcoded English (Today/Tomorrow/Yesterday/In N days/Nd ago/Overdue Nd) regardless of the workspace locale. Now: - formatRelativeDate resolves the generic phrases through Intl.RelativeTimeFormat(locale, { numeric: 'auto' }) (sentence-cased), falling back to English literals on an invalid locale tag. - The due-like "Overdue Nd" wording (no Intl equivalent) resolves through the i18n bundle key fields.relativeDate.overdue with an English fallback; the key is added to all 10 built-in locales. - DateCellRenderer feeds the ADR-0053 tenant locale (useLocalization) and the i18n translate fn into formatDate/formatRelativeDate, covering list columns, detail header chips, and detail body alike. - formatDate's default absolute path also honors options.locale. --- .../src/__tests__/DateCellRenderer.test.tsx | 31 +++++++- packages/fields/src/index.tsx | 73 +++++++++++++++---- packages/i18n/src/locales/ar.ts | 3 + packages/i18n/src/locales/de.ts | 3 + packages/i18n/src/locales/en.ts | 3 + packages/i18n/src/locales/es.ts | 3 + packages/i18n/src/locales/fr.ts | 3 + packages/i18n/src/locales/ja.ts | 3 + packages/i18n/src/locales/ko.ts | 3 + packages/i18n/src/locales/pt.ts | 3 + packages/i18n/src/locales/ru.ts | 3 + packages/i18n/src/locales/zh.ts | 3 + 12 files changed, 116 insertions(+), 18 deletions(-) diff --git a/packages/fields/src/__tests__/DateCellRenderer.test.tsx b/packages/fields/src/__tests__/DateCellRenderer.test.tsx index b7878c492..53aec68b1 100644 --- a/packages/fields/src/__tests__/DateCellRenderer.test.tsx +++ b/packages/fields/src/__tests__/DateCellRenderer.test.tsx @@ -23,16 +23,39 @@ describe('formatRelativeDate', () => { expect(formatRelativeDate(daysAgo(3), { dueLike: true })).toBe('Overdue 3d'); }); - it('renders neutral "Nd ago" for a past date that is not due-like', () => { - expect(formatRelativeDate(daysAgo(3))).toBe('3d ago'); - expect(formatRelativeDate(daysAgo(3), { dueLike: false })).toBe('3d ago'); + it('renders neutral "N days ago" for a past date that is not due-like', () => { + expect(formatRelativeDate(daysAgo(3))).toBe('3 days ago'); + expect(formatRelativeDate(daysAgo(3), { dueLike: false })).toBe('3 days ago'); + }); + + it('localizes the near-today window per locale (framework#3040)', () => { + expect(formatRelativeDate(daysAgo(0), { locale: 'zh-CN' })).toBe('今天'); + expect(formatRelativeDate(daysAgo(-1), { locale: 'zh-CN' })).toBe('明天'); + expect(formatRelativeDate(daysAgo(1), { locale: 'zh-CN' })).toBe('昨天'); + expect(formatRelativeDate(daysAgo(-3), { locale: 'zh-CN' })).toBe('3天后'); + expect(formatRelativeDate(daysAgo(3), { locale: 'zh-CN' })).toBe('3天前'); + // English output is sentence-cased. + expect(formatRelativeDate(daysAgo(-1))).toBe('Tomorrow'); + expect(formatRelativeDate(daysAgo(-3))).toBe('In 3 days'); + }); + + it('resolves the overdue phrase through the i18n translate fn when provided', () => { + const t = (key: string, params?: Record) => + key === 'fields.relativeDate.overdue' ? `逾期 ${params?.count} 天` : key; + expect(formatRelativeDate(daysAgo(3), { dueLike: true, t })).toBe('逾期 3 天'); + // A t() that misses (returns the key) keeps the English fallback. + expect(formatRelativeDate(daysAgo(3), { dueLike: true, t: (k) => k })).toBe('Overdue 3d'); + }); + + it('degrades to English when the locale tag is invalid', () => { + expect(formatRelativeDate(daysAgo(3), { locale: 'not a locale' })).toBe('3 days ago'); }); }); describe('DateCellRenderer', () => { it('does not label a past start_date as "Overdue" (regression: field-role-agnostic formatter)', () => { render(); - expect(screen.getByText('6d ago')).toBeInTheDocument(); + expect(screen.getByText('6 days ago')).toBeInTheDocument(); expect(screen.queryByText(/Overdue/)).not.toBeInTheDocument(); }); diff --git a/packages/fields/src/index.tsx b/packages/fields/src/index.tsx index d5f499dc7..3d93842fe 100644 --- a/packages/fields/src/index.tsx +++ b/packages/fields/src/index.tsx @@ -271,6 +271,20 @@ function useFieldLabel() { } } +/** + * Raw translate fn (with interpolation params) for cell-level strings, or + * undefined when no I18nProvider is mounted — callers keep their English + * fallback in that case. + */ +function useFieldTranslate(): ((key: string, params?: Record) => string) | undefined { + try { + const { t } = useObjectTranslation(); + return t as (key: string, params?: Record) => string; + } catch { + return undefined; + } +} + import { TextField } from './widgets/TextField'; import { NumberField } from './widgets/NumberField'; import { BooleanField } from './widgets/BooleanField'; @@ -465,14 +479,43 @@ export function humanizeLabel(value: string): string { return value.replace(/[_-]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); } +/** Options shared by {@link formatDate} / {@link formatRelativeDate}. */ +export interface DateDisplayOptions { + dueLike?: boolean; + /** BCP-47 display locale (ADR-0053 tenant default); falls back to the runtime locale. */ + locale?: string; + /** i18n translate fn for phrases `Intl` can't produce (the "Overdue Nd" wording). */ + t?: (key: string, params?: Record) => string; +} + +/** + * Localized day-granularity relative phrase ("Tomorrow", "3 days ago", "明天", + * "3天前"), sentence-cased for locales whose `Intl` output starts lowercase. + */ +function formatRelativeDays(diffDays: number, locale?: string): string { + try { + const phrase = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(diffDays, 'day'); + return phrase.charAt(0).toUpperCase() + phrase.slice(1); + } catch { + // Invalid locale tag — degrade to English rather than crash the cell. + if (diffDays === 0) return 'Today'; + if (diffDays === 1) return 'Tomorrow'; + if (diffDays === -1) return 'Yesterday'; + return diffDays > 0 ? `In ${diffDays} days` : `${Math.abs(diffDays)} days ago`; + } +} + /** - * Format date as relative time (e.g., "2 days ago", "Today", "Overdue 3d") + * Format date as relative time (e.g., "3 days ago", "Today", "Overdue 3d"), + * localized via `Intl.RelativeTimeFormat` (objectstack-ai/framework#3040). * * `dueLike` gates the "Overdue" wording — a past `start_date`/`created_at` * isn't overdue, only a past due/deadline-semantic field is. Non-due-like - * past dates render as "Nd ago" instead. + * past dates render as plain "N days ago" instead. The overdue phrase has no + * `Intl` equivalent, so it resolves through `options.t` (key + * `fields.relativeDate.overdue`) with an English fallback. */ -export function formatRelativeDate(value: string | Date | number, options?: { dueLike?: boolean }): string { +export function formatRelativeDate(value: string | Date | number, options?: DateDisplayOptions): string { if (value === null || value === undefined || value === '') return '—'; const date = value instanceof Date ? value : new Date(value as any); if (!(date instanceof Date) || isNaN(date.getTime())) return '—'; @@ -483,22 +526,22 @@ export function formatRelativeDate(value: string | Date | number, options?: { du const diffMs = startOfDate.getTime() - startOfToday.getTime(); const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24)); - if (diffDays === 0) return 'Today'; - if (diffDays === 1) return 'Tomorrow'; - if (diffDays === -1) return 'Yesterday'; - if (diffDays < -1) { + // Beyond the ±7-day window, fall back to the absolute (already localized) form. + if (diffDays < -7 || diffDays > 7) return formatDate(date, undefined, options); + + if (diffDays < -1 && options?.dueLike) { const absDays = Math.abs(diffDays); - if (absDays <= 7) return options?.dueLike ? `Overdue ${absDays}d` : `${absDays}d ago`; - return formatDate(date); + const key = 'fields.relativeDate.overdue'; + const translated = options.t?.(key, { count: absDays }); + return translated && translated !== key ? translated : `Overdue ${absDays}d`; } - if (diffDays > 1 && diffDays <= 7) return `In ${diffDays} days`; - return formatDate(date); + return formatRelativeDays(diffDays, options?.locale); } /** * Format date value */ -export function formatDate(value: string | Date | number, style?: string, options?: { dueLike?: boolean }): string { +export function formatDate(value: string | Date | number, style?: string, options?: DateDisplayOptions): string { if (value === null || value === undefined || value === '') return '—'; const date = value instanceof Date ? value : new Date(value as any); if (!(date instanceof Date) || isNaN(date.getTime())) return '—'; @@ -521,7 +564,7 @@ export function formatDate(value: string | Date | number, style?: string, option // verbose "2026年7月21日" form crowds cards and table cells. Past- / // future-year dates keep the year so users can disambiguate. const isCurrentYear = date.getFullYear() === new Date().getFullYear(); - return date.toLocaleDateString(undefined, { + return date.toLocaleDateString(options?.locale, { year: isCurrentYear ? undefined : 'numeric', month: 'short', day: 'numeric', @@ -702,6 +745,8 @@ export function BooleanCellRenderer({ value, field }: CellRendererProps): React. * Date field cell renderer */ export function DateCellRenderer({ value, field }: CellRendererProps): React.ReactElement { + const { locale } = useLocalization(); + const t = useFieldTranslate(); if (!value) return ; const safe = coerceToSafeValue(value); const dateField = field as any; @@ -714,7 +759,7 @@ export function DateCellRenderer({ value, field }: CellRendererProps): React.Rea const dueLike = dateField?.dueLike === true || /(^|_)(due|deadline|expires?|expiry|expiration|expected_close|target_close|sla|return_by|renewal|next_action)(_|$)/.test(fieldName); - const formatted = formatDate(safe as string | Date, style, { dueLike }); + const formatted = formatDate(safe as string | Date, style, { dueLike, locale, t }); const date = safe != null ? new Date(safe as string | number) : null; const isValidDate = date !== null && !isNaN(date.getTime()); diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 3143c6c7b..143cc8560 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -105,6 +105,9 @@ const ar = { deleteSuccess: "تم حذف {{object}}", }, fields: { + relativeDate: { + overdue: "متأخر {{count}} يوم", + }, richText: { format: "التنسيق: {{format}}", basicEditorHint: "محرر النص الغني (أساسي)", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 02261ff0f..82fceef69 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -105,6 +105,9 @@ const de = { deleteSuccess: "{{object}} erfolgreich gelöscht", }, fields: { + relativeDate: { + overdue: "{{count}} T. überfällig", + }, richText: { format: "Format: {{format}}", basicEditorHint: "Rich-Text-Editor (einfach)", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index e6c098f9f..3afc43d02 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -112,6 +112,9 @@ const en = { deleteSuccess: '{{object}} deleted successfully', }, fields: { + relativeDate: { + overdue: 'Overdue {{count}}d', + }, richText: { format: 'Format: {{format}}', basicEditorHint: 'Rich text editor (basic)', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 5757370a6..b6cb24db2 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -105,6 +105,9 @@ const es = { deleteSuccess: "{{object}} eliminado", }, fields: { + relativeDate: { + overdue: "Atrasado {{count}} d", + }, richText: { format: "Formato: {{format}}", basicEditorHint: "Editor de texto enriquecido (básico)", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 8c0c39122..dfbe30428 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -105,6 +105,9 @@ const fr = { deleteSuccess: "{{object}} supprimé", }, fields: { + relativeDate: { + overdue: "En retard de {{count}} j", + }, richText: { format: "Format : {{format}}", basicEditorHint: "Éditeur de texte enrichi (basique)", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 83d892caa..2aa492cad 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -105,6 +105,9 @@ const ja = { deleteSuccess: "{{object}}が削除されました", }, fields: { + relativeDate: { + overdue: "期限超過 {{count}}日", + }, richText: { format: "フォーマット: {{format}}", basicEditorHint: "リッチテキストエディター(基本)", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 917fb3c63..3ad7b54e1 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -105,6 +105,9 @@ const ko = { deleteSuccess: "{{object}} 삭제됨", }, fields: { + relativeDate: { + overdue: "기한 초과 {{count}}일", + }, richText: { format: "형식: {{format}}", basicEditorHint: "서식 있는 텍스트 편집기 (기본)", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index a9f0c0797..193de885b 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -105,6 +105,9 @@ const pt = { deleteSuccess: "{{object}} excluído", }, fields: { + relativeDate: { + overdue: "Atrasado {{count}} d", + }, richText: { format: "Formato: {{format}}", basicEditorHint: "Editor de texto rico (básico)", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index ed291c65c..3bf38cb7d 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -105,6 +105,9 @@ const ru = { deleteSuccess: "{{object}} удалён", }, fields: { + relativeDate: { + overdue: "Просрочено на {{count}} дн.", + }, richText: { format: "Формат: {{format}}", basicEditorHint: "Редактор форматированного текста (базовый)", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 0eca3fe6e..dc5b15be6 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -112,6 +112,9 @@ const zh = { deleteSuccess: '{{object}}删除成功', }, fields: { + relativeDate: { + overdue: '逾期 {{count}} 天', + }, richText: { format: '格式: {{format}}', basicEditorHint: '富文本编辑器(基础)',