diff --git a/src/renderer/src/components/JiraIssueWorkspace.tsx b/src/renderer/src/components/JiraIssueWorkspace.tsx index dd1ccb4f2d7..969765c4dca 100644 --- a/src/renderer/src/components/JiraIssueWorkspace.tsx +++ b/src/renderer/src/components/JiraIssueWorkspace.tsx @@ -48,6 +48,7 @@ import type { } from '../../../shared/types' import type { TaskSourceContext } from '../../../shared/task-source-context' import { translate } from '@/i18n/i18n' +import { formatUiRelativeTimeFromDate } from '@/i18n/relative-time-format' type JiraIssueWorkspaceProps = { issue: JiraIssue | null @@ -56,22 +57,8 @@ type JiraIssueWorkspaceProps = { sourceContext?: TaskSourceContext | null } -const relativeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) - function formatRelativeTime(input: string): string { - const date = new Date(input) - if (Number.isNaN(date.getTime())) { - return 'recently' - } - const diffMinutes = Math.round((date.getTime() - Date.now()) / 60_000) - if (Math.abs(diffMinutes) < 60) { - return relativeFormatter.format(diffMinutes, 'minute') - } - const diffHours = Math.round(diffMinutes / 60) - if (Math.abs(diffHours) < 24) { - return relativeFormatter.format(diffHours, 'hour') - } - return relativeFormatter.format(Math.round(diffHours / 24), 'day') + return formatUiRelativeTimeFromDate(input) } function buildJiraBranchName(issue: JiraIssue): string { diff --git a/src/renderer/src/components/LinearItemDrawer.tsx b/src/renderer/src/components/LinearItemDrawer.tsx index 25ee1ffef16..719aecbf4b1 100644 --- a/src/renderer/src/components/LinearItemDrawer.tsx +++ b/src/renderer/src/components/LinearItemDrawer.tsx @@ -50,6 +50,7 @@ import { linearUpdateIssue } from '@/runtime/runtime-linear-client' import { translate } from '@/i18n/i18n' +import { formatUiRelativeTimeFromDate } from '@/i18n/relative-time-format' function LinearIcon({ className }: { className?: string }): React.JSX.Element { return ( @@ -101,22 +102,7 @@ function LinearEditChipAdornment({ } function formatRelativeTime(input: string): string { - const date = new Date(input) - if (Number.isNaN(date.getTime())) { - return 'recently' - } - const diffMs = date.getTime() - Date.now() - const diffMinutes = Math.round(diffMs / 60_000) - const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) - if (Math.abs(diffMinutes) < 60) { - return formatter.format(diffMinutes, 'minute') - } - const diffHours = Math.round(diffMinutes / 60) - if (Math.abs(diffHours) < 24) { - return formatter.format(diffHours, 'hour') - } - const diffDays = Math.round(diffHours / 24) - return formatter.format(diffDays, 'day') + return formatUiRelativeTimeFromDate(input) } type LinearItemDrawerProps = { diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index b430b0c33a3..0f2fa5c12fb 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -359,6 +359,7 @@ import { resolveVisibleTaskProvider } from '../../../shared/task-providers' import { translate } from '@/i18n/i18n' +import { formatUiRelativeTimeFromDate } from '@/i18n/relative-time-format' import { getGitHubModeButtons, getGitHubTaskKindPresets, @@ -585,29 +586,8 @@ function scopeGitHubTaskSearch(query: string, kind: GitHubTaskKind): string { return `${inferredKind === 'prs' ? 'is:pr' : 'is:issue'} ${trimmed}` } -// Why: Intl.RelativeTimeFormat allocation is non-trivial; hoist to module scope so all rows share one instance instead of allocating per render. -const relativeTimeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) - function formatRelativeTime(input: string): string { - const date = new Date(input) - if (Number.isNaN(date.getTime())) { - return 'recently' - } - - const diffMs = date.getTime() - Date.now() - const diffMinutes = Math.round(diffMs / 60_000) - - if (Math.abs(diffMinutes) < 60) { - return relativeTimeFormatter.format(diffMinutes, 'minute') - } - - const diffHours = Math.round(diffMinutes / 60) - if (Math.abs(diffHours) < 24) { - return relativeTimeFormatter.format(diffHours, 'hour') - } - - const diffDays = Math.round(diffHours / 24) - return relativeTimeFormatter.format(diffDays, 'day') + return formatUiRelativeTimeFromDate(input) } type LinearProjectTab = 'overview' | 'issues' diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.tsx b/src/renderer/src/components/activity/ActivityPrototypePage.tsx index 4ee1e7c8be6..2c3f6b28bb1 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.tsx +++ b/src/renderer/src/components/activity/ActivityPrototypePage.tsx @@ -73,6 +73,7 @@ import { parsePaneKey } from '../../../../shared/stable-pane-id' import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text' import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry' import { translate } from '@/i18n/i18n' +import { formatUiRelativeTime } from '@/i18n/relative-time-format' import { getActivityThreadTaskTitle, getActivityThreadWorkspaceTitle, @@ -168,24 +169,12 @@ const absoluteDateFormatter = new Intl.DateTimeFormat(undefined, { minute: '2-digit' }) -const relativeTimeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) - function formatAbsoluteDate(timestamp: number): string { return absoluteDateFormatter.format(new Date(timestamp)) } function formatRelativeTime(timestamp: number): string { - const diffMs = timestamp - Date.now() - const diffMinutes = Math.round(diffMs / 60_000) - if (Math.abs(diffMinutes) < 60) { - return relativeTimeFormatter.format(diffMinutes, 'minute') - } - const diffHours = Math.round(diffMinutes / 60) - if (Math.abs(diffHours) < 24) { - return relativeTimeFormatter.format(diffHours, 'hour') - } - const diffDays = Math.round(diffHours / 24) - return relativeTimeFormatter.format(diffDays, 'day') + return formatUiRelativeTime(timestamp - Date.now()) } function findActivityTerminalPane( diff --git a/src/renderer/src/components/github/work-item-state-presentation.tsx b/src/renderer/src/components/github/work-item-state-presentation.tsx index 7e14804fbaf..fadc502fb91 100644 --- a/src/renderer/src/components/github/work-item-state-presentation.tsx +++ b/src/renderer/src/components/github/work-item-state-presentation.tsx @@ -1,24 +1,10 @@ import React from 'react' import { GitHubUserAvatar } from '@/components/github/github-user-avatar' import type { GitHubAssignableUser, GitHubWorkItem } from '../../../../shared/types' +import { formatUiRelativeTimeFromDate } from '@/i18n/relative-time-format' export function formatRelativeTime(input: string): string { - const date = new Date(input) - if (Number.isNaN(date.getTime())) { - return 'recently' - } - const diffMs = date.getTime() - Date.now() - const diffMinutes = Math.round(diffMs / 60_000) - const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) - if (Math.abs(diffMinutes) < 60) { - return formatter.format(diffMinutes, 'minute') - } - const diffHours = Math.round(diffMinutes / 60) - if (Math.abs(diffHours) < 24) { - return formatter.format(diffHours, 'hour') - } - const diffDays = Math.round(diffHours / 24) - return formatter.format(diffDays, 'day') + return formatUiRelativeTimeFromDate(input) } export function getStateLabel(item: GitHubWorkItem): string { diff --git a/src/renderer/src/components/linear-issue-workspace-text.ts b/src/renderer/src/components/linear-issue-workspace-text.ts index b4e88121bb8..e047495d40b 100644 --- a/src/renderer/src/components/linear-issue-workspace-text.ts +++ b/src/renderer/src/components/linear-issue-workspace-text.ts @@ -1,25 +1,10 @@ import { getLinearIssueWorkspaceName } from '../../../shared/workspace-name' import type { LinearIssue } from '../../../shared/types' import { getUsableLinearBranchName } from '../../../shared/new-workspace/workspace-source' - -const relativeTimeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) +import { formatUiRelativeTimeFromDate } from '@/i18n/relative-time-format' export function formatLinearIssueRelativeTime(input: string): string { - const date = new Date(input) - if (Number.isNaN(date.getTime())) { - return 'recently' - } - const diffMs = date.getTime() - Date.now() - const diffMinutes = Math.round(diffMs / 60_000) - if (Math.abs(diffMinutes) < 60) { - return relativeTimeFormatter.format(diffMinutes, 'minute') - } - const diffHours = Math.round(diffMinutes / 60) - if (Math.abs(diffHours) < 24) { - return relativeTimeFormatter.format(diffHours, 'hour') - } - const diffDays = Math.round(diffHours / 24) - return relativeTimeFormatter.format(diffDays, 'day') + return formatUiRelativeTimeFromDate(input) } export function buildLinearIssueBranchName(issue: LinearIssue): string { diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx index 7578112895a..d9db52dd9c6 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -81,6 +81,7 @@ import { type ProviderAccountRuntimeView } from './provider-account-visibility' import { translate } from '@/i18n/i18n' +import { formatUiRelativeTime } from '@/i18n/relative-time-format' import { cn } from '@/lib/utils' import { isWebClientLocation } from '@/lib/web-client-location' import { @@ -104,16 +105,7 @@ function formatMiniMaxRelativeRefresh(updatedAt: number, now: number): string { if (diffMs < 60_000) { return translate('auto.components.settings.AccountsPane.3a30aaf526', 'just now') } - const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) - const minutes = Math.round(diffMs / 60_000) - if (minutes < 60) { - return formatter.format(-minutes, 'minute') - } - const hours = Math.round(minutes / 60) - if (hours < 24) { - return formatter.format(-hours, 'hour') - } - return formatter.format(-Math.round(hours / 24), 'day') + return formatUiRelativeTime(-diffMs) } function MiniMaxCookieHelpPopover(): React.JSX.Element { diff --git a/src/renderer/src/components/status-bar/workspace-space-format.test.ts b/src/renderer/src/components/status-bar/workspace-space-format.test.ts index 6c43b22136c..57b089bc2ac 100644 --- a/src/renderer/src/components/status-bar/workspace-space-format.test.ts +++ b/src/renderer/src/components/status-bar/workspace-space-format.test.ts @@ -22,7 +22,8 @@ describe('workspace space format helpers', () => { it('formats scan times as relative age labels', () => { const now = new Date('2026-05-14T22:15:00Z').getTime() - const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) + // Pinned to 'en' so the expectation matches the UI language, not the machine's OS locale. + const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }) expect(getWorkspaceSpaceScanTimeLabel(now - 2 * 60_000, now)).toBe( formatter.format(-2, 'minute') diff --git a/src/renderer/src/components/status-bar/workspace-space-format.ts b/src/renderer/src/components/status-bar/workspace-space-format.ts index ab056ea68e4..e02443fd0d0 100644 --- a/src/renderer/src/components/status-bar/workspace-space-format.ts +++ b/src/renderer/src/components/status-bar/workspace-space-format.ts @@ -3,9 +3,9 @@ import type { WorkspaceSpaceScanStatus, WorkspaceSpaceWorktree } from '../../../../shared/workspace-space-types' +import { formatUiRelativeTime } from '@/i18n/relative-time-format' const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] as const -const relativeTimeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) const fullDateTimeFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' @@ -41,19 +41,7 @@ export function formatCompactCount(count: number): string { } export function getWorkspaceSpaceScanTimeLabel(scannedAt: number, now = Date.now()): string { - const diffMs = scannedAt - now - const diffMinutes = Math.round(diffMs / 60_000) - if (Math.abs(diffMinutes) < 60) { - return relativeTimeFormatter.format(diffMinutes, 'minute') - } - - const diffHours = Math.round(diffMinutes / 60) - if (Math.abs(diffHours) < 24) { - return relativeTimeFormatter.format(diffHours, 'hour') - } - - const diffDays = Math.round(diffHours / 24) - return relativeTimeFormatter.format(diffDays, 'day') + return formatUiRelativeTime(scannedAt - now) } export function getWorkspaceSpaceScanDateTimeLabel(scannedAt: number): string { diff --git a/src/renderer/src/i18n/relative-time-format.test.ts b/src/renderer/src/i18n/relative-time-format.test.ts new file mode 100644 index 00000000000..81cfc08bdaa --- /dev/null +++ b/src/renderer/src/i18n/relative-time-format.test.ts @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockIntlLocale } = vi.hoisted(() => ({ mockIntlLocale: { value: 'en' } })) + +vi.mock('./i18n', () => ({ getIntlLocale: () => mockIntlLocale.value })) + +import { + formatUiRelativeTime, + formatUiRelativeTimeFromDate, + getUiRelativeTimeFormatter +} from './relative-time-format' + +beforeEach(() => { + mockIntlLocale.value = 'en' +}) + +describe('getUiRelativeTimeFormatter', () => { + it('formats with the configured UI language instead of the OS locale', () => { + expect(getUiRelativeTimeFormatter().format(-1, 'day')).toBe('yesterday') + }) + + it('reuses the cached formatter while the language is unchanged', () => { + expect(getUiRelativeTimeFormatter()).toBe(getUiRelativeTimeFormatter()) + }) + + it('rebuilds the formatter after a runtime language switch', () => { + const english = getUiRelativeTimeFormatter() + mockIntlLocale.value = 'ko' + const korean = getUiRelativeTimeFormatter() + expect(korean).not.toBe(english) + expect(korean.format(-1, 'day')).toBe('어제') + }) + + // getIntlLocale() already maps the synthetic plugin resource language to a real BCP-47 tag, + // so the constructor never sees a value Intl would reject. + it('formats in the tag a plugin language pack resolves to', () => { + mockIntlLocale.value = 'ja' + expect(getUiRelativeTimeFormatter().format(-1, 'day')).toBe('昨日') + }) +}) + +describe('formatUiRelativeTime', () => { + it('picks the minute/hour/day unit from the signed millisecond delta', () => { + expect(formatUiRelativeTime(-5 * 60_000)).toBe('5 minutes ago') + expect(formatUiRelativeTime(-3 * 3_600_000)).toBe('3 hours ago') + expect(formatUiRelativeTime(-2 * 86_400_000)).toBe('2 days ago') + }) + + it('formats future deltas', () => { + expect(formatUiRelativeTime(2 * 86_400_000)).toBe('in 2 days') + }) +}) + +describe('formatUiRelativeTimeFromDate', () => { + it('returns the fallback for an invalid date', () => { + expect(formatUiRelativeTimeFromDate('not-a-date')).toBe('recently') + expect(formatUiRelativeTimeFromDate('not-a-date', 'unknown')).toBe('unknown') + }) +}) diff --git a/src/renderer/src/i18n/relative-time-format.ts b/src/renderer/src/i18n/relative-time-format.ts new file mode 100644 index 00000000000..95387b5daf1 --- /dev/null +++ b/src/renderer/src/i18n/relative-time-format.ts @@ -0,0 +1,39 @@ +import { getIntlLocale } from './i18n' + +// Why: relative times are language words ("2 days ago" / "그저께"), so they must follow the +// configured UI language rather than the OS locale — unlike Intl.DateTimeFormat, where numeric +// date shapes following the OS region is conventional. The language can change at runtime, and +// plugin packs register under a synthetic tag Intl rejects, so resolve through getIntlLocale() +// and cache per resolved locale rather than freezing one formatter at module scope. +let cached: { locale: string; formatter: Intl.RelativeTimeFormat } | null = null + +export function getUiRelativeTimeFormatter(): Intl.RelativeTimeFormat { + const locale = getIntlLocale() + if (!cached || cached.locale !== locale) { + cached = { locale, formatter: new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }) } + } + return cached.formatter +} + +/** Format a signed millisecond delta (future positive, past negative) at minute/hour/day granularity. */ +export function formatUiRelativeTime(diffMs: number): string { + const formatter = getUiRelativeTimeFormatter() + const diffMinutes = Math.round(diffMs / 60_000) + if (Math.abs(diffMinutes) < 60) { + return formatter.format(diffMinutes, 'minute') + } + const diffHours = Math.round(diffMinutes / 60) + if (Math.abs(diffHours) < 24) { + return formatter.format(diffHours, 'hour') + } + return formatter.format(Math.round(diffHours / 24), 'day') +} + +/** Parse a date string and format it relative to now; returns `fallback` when the input is invalid. */ +export function formatUiRelativeTimeFromDate(input: string, fallback = 'recently'): string { + const date = new Date(input) + if (Number.isNaN(date.getTime())) { + return fallback + } + return formatUiRelativeTime(date.getTime() - Date.now()) +}