diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index d076f0b325d1..7b8fd7277ec7 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -226,6 +226,7 @@ import { validateTaskPageGitHubDuplicateTarget, type TaskPageGitHubCloseAction } from '@/components/task-page-github-status-actions' +import { formatUiRelativeTimeFromDate } from '@/i18n/relative-time-format' // Why: the GH item dialog can be opened from any work-item list surface and // doesn't have the full owner/repo context the list's cache entry carries. @@ -339,22 +340,7 @@ type GitHubItemDialogProps = { } 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) } function getStateLabel(item: GitHubWorkItem): string { diff --git a/src/renderer/src/components/JiraIssueWorkspace.tsx b/src/renderer/src/components/JiraIssueWorkspace.tsx index 857df73e5994..8eed9c925366 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 25ee1ffef163..719aecbf4b18 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/PullRequestPage.tsx b/src/renderer/src/components/PullRequestPage.tsx index d807dacf89fa..083d91599129 100644 --- a/src/renderer/src/components/PullRequestPage.tsx +++ b/src/renderer/src/components/PullRequestPage.tsx @@ -222,6 +222,7 @@ import { } from '../../../shared/task-source-context' import { translate } from '@/i18n/i18n' import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' +import { formatUiRelativeTimeFromDate } from '@/i18n/relative-time-format' // Why: the GH item dialog can be opened from any work-item list surface and // doesn't have the full owner/repo context the list's cache entry carries. @@ -326,22 +327,7 @@ type PullRequestPageProps = { } 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) } function findMentionQuery(value: string, caret: number): MentionQuery | null { diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 3b538d44335b..0300fa157994 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -364,6 +364,7 @@ import { type LinearOrderBy, type LinearViewMode } from '@/components/task-page-localized-options' +import { formatUiRelativeTimeFromDate } from '@/i18n/relative-time-format' function isGitLabMRFilter(value: GitLabTaskFilter | GitLabIssueFilter): value is GitLabTaskFilter { return value === 'opened' || value === 'merged' || value === 'closed' || value === 'all' @@ -570,31 +571,8 @@ function scopeGitHubTaskSearch(query: string, kind: GitHubTaskKind): string { return `${inferredKind === 'prs' ? 'is:pr' : 'is:issue'} ${trimmed}` } -// Why: Intl.RelativeTimeFormat allocation is non-trivial, and previously we -// built a new formatter per work-item row render. Hoisting to module scope -// means all rows share one instance — zero per-row allocation cost. -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 ccb57a05e807..f86b0139441e 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.tsx +++ b/src/renderer/src/components/activity/ActivityPrototypePage.tsx @@ -73,6 +73,7 @@ import { resolveActivityThreadStatusPreview } from '@/lib/activity-thread-display' import { getAgentRowPrimaryText } from '@/lib/agent-row-primary-text' +import { formatUiRelativeTime } from '@/i18n/relative-time-format' type ThreadReadFilter = 'all' | 'unread' type ActivityGroupBy = 'status' | 'project' | 'worktree' | 'agent' @@ -165,24 +166,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/linear-issue-workspace-text.ts b/src/renderer/src/components/linear-issue-workspace-text.ts index 0d95641d5ec1..ec511d53a377 100644 --- a/src/renderer/src/components/linear-issue-workspace-text.ts +++ b/src/renderer/src/components/linear-issue-workspace-text.ts @@ -1,24 +1,9 @@ import { getLinearIssueWorkspaceName } from '../../../shared/workspace-name' import type { LinearIssue } from '../../../shared/types' - -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 5268a890e36b..cf72b9ed4788 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -82,6 +82,7 @@ import { selectCodexProviderAccount, watchProviderAccounts } from '@/runtime/runtime-provider-accounts-client' +import { getUiRelativeTimeFormatter } from '@/i18n/relative-time-format' export { getAccountsPaneSearchEntries } @@ -93,7 +94,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 formatter = getUiRelativeTimeFormatter() const minutes = Math.round(diffMs / 60_000) if (minutes < 60) { return formatter.format(-minutes, 'minute') 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 6c43b22136c7..87cd749f593d 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,9 @@ 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' }) + // Why: scan-time labels follow the configured UI language (English in + // tests), 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 ab056ea68e4d..e02443fd0d07 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 000000000000..745e06ddb0dc --- /dev/null +++ b/src/renderer/src/i18n/relative-time-format.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from 'vitest' + +const { mockI18n } = vi.hoisted(() => ({ + mockI18n: { language: 'en', resolvedLanguage: 'en' as string | undefined } +})) + +vi.mock('./i18n', () => ({ i18n: mockI18n })) + +import { + formatUiRelativeTime, + formatUiRelativeTimeFromDate, + getUiRelativeTimeFormatter +} from './relative-time-format' + +describe('getUiRelativeTimeFormatter', () => { + it('formats with the configured UI language instead of the OS locale', () => { + mockI18n.resolvedLanguage = 'en' + expect(getUiRelativeTimeFormatter().format(-1, 'day')).toBe('yesterday') + }) + + it('reuses the cached formatter while the language is unchanged', () => { + mockI18n.resolvedLanguage = 'en' + expect(getUiRelativeTimeFormatter()).toBe(getUiRelativeTimeFormatter()) + }) + + it('rebuilds the formatter after a runtime language switch', () => { + mockI18n.resolvedLanguage = 'en' + const english = getUiRelativeTimeFormatter() + mockI18n.resolvedLanguage = 'ko' + const korean = getUiRelativeTimeFormatter() + expect(korean).not.toBe(english) + expect(korean.format(-1, 'day')).toBe('어제') + }) + + it('falls back to the active language when resolvedLanguage is unset', () => { + mockI18n.resolvedLanguage = undefined + mockI18n.language = 'ja' + expect(getUiRelativeTimeFormatter().format(-1, 'day')).toBe('昨日') + }) +}) + +describe('formatUiRelativeTime', () => { + it('picks the minute/hour/day unit from the signed millisecond delta', () => { + mockI18n.resolvedLanguage = 'en' + 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', () => { + mockI18n.resolvedLanguage = 'en' + expect(formatUiRelativeTime(2 * 86_400_000)).toBe('in 2 days') + }) +}) + +describe('formatUiRelativeTimeFromDate', () => { + it('returns the fallback for an invalid date', () => { + mockI18n.resolvedLanguage = 'en' + 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 000000000000..3cbfece6dfdd --- /dev/null +++ b/src/renderer/src/i18n/relative-time-format.ts @@ -0,0 +1,39 @@ +import { i18n } from './i18n' + +// Why: relative times are language words ("2 days ago" / "그저께"), so they must +// follow the configured UI language rather than the OS locale, and the UI +// language can change at runtime — cache per resolved locale, not per module. +let cached: { locale: string | undefined; formatter: Intl.RelativeTimeFormat } | null = null + +export function getUiRelativeTimeFormatter(): Intl.RelativeTimeFormat { + const locale = i18n.resolvedLanguage ?? i18n.language + 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 in the configured UI language. +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` (default +// "recently") when the input isn't a valid date. +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()) +}