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
31 changes: 27 additions & 4 deletions packages/fields/src/__tests__/DateCellRenderer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) =>
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(<DateCellRenderer value={daysAgo(6)} field={{ type: 'date', name: 'start_date' } as any} />);
expect(screen.getByText('6d ago')).toBeInTheDocument();
expect(screen.getByText('6 days ago')).toBeInTheDocument();
expect(screen.queryByText(/Overdue/)).not.toBeInTheDocument();
});

Expand Down
73 changes: 59 additions & 14 deletions packages/fields/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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, unknown>) => string) | undefined {
try {
const { t } = useObjectTranslation();
return t as (key: string, params?: Record<string, unknown>) => string;
} catch {
return undefined;
}
}

import { TextField } from './widgets/TextField';
import { NumberField } from './widgets/NumberField';
import { BooleanField } from './widgets/BooleanField';
Expand Down Expand Up @@ -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, unknown>) => 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 '—';
Expand All @@ -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 '—';
Expand All @@ -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',
Expand Down Expand Up @@ -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 <EmptyValue />;
const safe = coerceToSafeValue(value);
const dateField = field as any;
Expand All @@ -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());
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ const ar = {
deleteSuccess: "تم حذف {{object}}",
},
fields: {
relativeDate: {
overdue: "متأخر {{count}} يوم",
},
richText: {
format: "التنسيق: {{format}}",
basicEditorHint: "محرر النص الغني (أساسي)",
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)',
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ const ja = {
deleteSuccess: "{{object}}が削除されました",
},
fields: {
relativeDate: {
overdue: "期限超過 {{count}}日",
},
richText: {
format: "フォーマット: {{format}}",
basicEditorHint: "リッチテキストエディター(基本)",
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ const ko = {
deleteSuccess: "{{object}} 삭제됨",
},
fields: {
relativeDate: {
overdue: "기한 초과 {{count}}일",
},
richText: {
format: "형식: {{format}}",
basicEditorHint: "서식 있는 텍스트 편집기 (기본)",
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ const ru = {
deleteSuccess: "{{object}} удалён",
},
fields: {
relativeDate: {
overdue: "Просрочено на {{count}} дн.",
},
richText: {
format: "Формат: {{format}}",
basicEditorHint: "Редактор форматированного текста (базовый)",
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,9 @@ const zh = {
deleteSuccess: '{{object}}删除成功',
},
fields: {
relativeDate: {
overdue: '逾期 {{count}} 天',
},
richText: {
format: '格式: {{format}}',
basicEditorHint: '富文本编辑器(基础)',
Expand Down
Loading