From 37202972cf8d9acf6d5ded63973ed2087633f23a Mon Sep 17 00:00:00 2001 From: moseoh Date: Tue, 14 Jul 2026 12:37:23 +0900 Subject: [PATCH 1/3] fix(i18n): format relative times with the configured UI language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight renderer call sites built Intl.RelativeTimeFormat with an undefined locale, which resolves to the Chromium/OS locale. With the UI language set to English on a Korean-locale machine, relative timestamps ('그저께', '5일 전') rendered in Korean while every other label was English — the Tasks UPDATED column, GitHub/Linear/Jira item views, account usage rows, and workspace-space labels were all affected. Relative times are language words, not just date shapes, so they follow the configured UI language now. A shared locale-keyed cached getter replaces the per-module formatter constants: it preserves the zero per-row allocation behavior TaskPage relied on while staying correct across runtime language switches, which the module-scope constants could not. Intentionally unchanged: Intl.DateTimeFormat call sites — numeric date/time shapes following the OS region is conventional. --- .../src/components/GitHubItemDialog.tsx | 3 +- .../src/components/JiraIssueWorkspace.tsx | 9 +++-- .../src/components/LinearItemDrawer.tsx | 3 +- .../src/components/PullRequestPage.tsx | 3 +- src/renderer/src/components/TaskPage.tsx | 12 +++---- .../activity/ActivityPrototypePage.tsx | 9 +++-- .../components/linear-issue-workspace-text.ts | 9 +++-- .../src/components/settings/AccountsPane.tsx | 3 +- .../status-bar/workspace-space-format.test.ts | 4 ++- .../status-bar/workspace-space-format.ts | 8 ++--- .../src/i18n/relative-time-format.test.ts | 34 +++++++++++++++++++ src/renderer/src/i18n/relative-time-format.ts | 14 ++++++++ 12 files changed, 79 insertions(+), 32 deletions(-) create mode 100644 src/renderer/src/i18n/relative-time-format.test.ts create mode 100644 src/renderer/src/i18n/relative-time-format.ts diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index d076f0b325d1..520e9c54294a 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 { getUiRelativeTimeFormatter } 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. @@ -345,7 +346,7 @@ function formatRelativeTime(input: string): string { } const diffMs = date.getTime() - Date.now() const diffMinutes = Math.round(diffMs / 60_000) - const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) + const formatter = getUiRelativeTimeFormatter() if (Math.abs(diffMinutes) < 60) { return formatter.format(diffMinutes, 'minute') } diff --git a/src/renderer/src/components/JiraIssueWorkspace.tsx b/src/renderer/src/components/JiraIssueWorkspace.tsx index 857df73e5994..81ee0d8571c1 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 { getUiRelativeTimeFormatter } from '@/i18n/relative-time-format' type JiraIssueWorkspaceProps = { issue: JiraIssue | null @@ -56,8 +57,6 @@ 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())) { @@ -65,13 +64,13 @@ function formatRelativeTime(input: string): string { } const diffMinutes = Math.round((date.getTime() - Date.now()) / 60_000) if (Math.abs(diffMinutes) < 60) { - return relativeFormatter.format(diffMinutes, 'minute') + return getUiRelativeTimeFormatter().format(diffMinutes, 'minute') } const diffHours = Math.round(diffMinutes / 60) if (Math.abs(diffHours) < 24) { - return relativeFormatter.format(diffHours, 'hour') + return getUiRelativeTimeFormatter().format(diffHours, 'hour') } - return relativeFormatter.format(Math.round(diffHours / 24), 'day') + return getUiRelativeTimeFormatter().format(Math.round(diffHours / 24), 'day') } function buildJiraBranchName(issue: JiraIssue): string { diff --git a/src/renderer/src/components/LinearItemDrawer.tsx b/src/renderer/src/components/LinearItemDrawer.tsx index 25ee1ffef163..c3787423ef30 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 { getUiRelativeTimeFormatter } from '@/i18n/relative-time-format' function LinearIcon({ className }: { className?: string }): React.JSX.Element { return ( @@ -107,7 +108,7 @@ function formatRelativeTime(input: string): string { } const diffMs = date.getTime() - Date.now() const diffMinutes = Math.round(diffMs / 60_000) - const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) + const formatter = getUiRelativeTimeFormatter() if (Math.abs(diffMinutes) < 60) { return formatter.format(diffMinutes, 'minute') } diff --git a/src/renderer/src/components/PullRequestPage.tsx b/src/renderer/src/components/PullRequestPage.tsx index d807dacf89fa..a90b43f47956 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 { getUiRelativeTimeFormatter } 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. @@ -332,7 +333,7 @@ function formatRelativeTime(input: string): string { } const diffMs = date.getTime() - Date.now() const diffMinutes = Math.round(diffMs / 60_000) - const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) + const formatter = getUiRelativeTimeFormatter() if (Math.abs(diffMinutes) < 60) { return formatter.format(diffMinutes, 'minute') } diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 3b538d44335b..e5ac05c69b17 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 { getUiRelativeTimeFormatter } from '@/i18n/relative-time-format' function isGitLabMRFilter(value: GitLabTaskFilter | GitLabIssueFilter): value is GitLabTaskFilter { return value === 'opened' || value === 'merged' || value === 'closed' || value === 'all' @@ -570,11 +571,6 @@ 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())) { @@ -585,16 +581,16 @@ function formatRelativeTime(input: string): string { const diffMinutes = Math.round(diffMs / 60_000) if (Math.abs(diffMinutes) < 60) { - return relativeTimeFormatter.format(diffMinutes, 'minute') + return getUiRelativeTimeFormatter().format(diffMinutes, 'minute') } const diffHours = Math.round(diffMinutes / 60) if (Math.abs(diffHours) < 24) { - return relativeTimeFormatter.format(diffHours, 'hour') + return getUiRelativeTimeFormatter().format(diffHours, 'hour') } const diffDays = Math.round(diffHours / 24) - return relativeTimeFormatter.format(diffDays, 'day') + return getUiRelativeTimeFormatter().format(diffDays, 'day') } type LinearProjectTab = 'overview' | 'issues' diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.tsx b/src/renderer/src/components/activity/ActivityPrototypePage.tsx index ccb57a05e807..768768752947 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 { getUiRelativeTimeFormatter } from '@/i18n/relative-time-format' type ThreadReadFilter = 'all' | 'unread' type ActivityGroupBy = 'status' | 'project' | 'worktree' | 'agent' @@ -165,8 +166,6 @@ 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)) } @@ -175,14 +174,14 @@ 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') + return getUiRelativeTimeFormatter().format(diffMinutes, 'minute') } const diffHours = Math.round(diffMinutes / 60) if (Math.abs(diffHours) < 24) { - return relativeTimeFormatter.format(diffHours, 'hour') + return getUiRelativeTimeFormatter().format(diffHours, 'hour') } const diffDays = Math.round(diffHours / 24) - return relativeTimeFormatter.format(diffDays, 'day') + return getUiRelativeTimeFormatter().format(diffDays, 'day') } 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..9f450e8bf901 100644 --- a/src/renderer/src/components/linear-issue-workspace-text.ts +++ b/src/renderer/src/components/linear-issue-workspace-text.ts @@ -1,7 +1,6 @@ import { getLinearIssueWorkspaceName } from '../../../shared/workspace-name' import type { LinearIssue } from '../../../shared/types' - -const relativeTimeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) +import { getUiRelativeTimeFormatter } from '@/i18n/relative-time-format' export function formatLinearIssueRelativeTime(input: string): string { const date = new Date(input) @@ -11,14 +10,14 @@ export function formatLinearIssueRelativeTime(input: string): string { const diffMs = date.getTime() - Date.now() const diffMinutes = Math.round(diffMs / 60_000) if (Math.abs(diffMinutes) < 60) { - return relativeTimeFormatter.format(diffMinutes, 'minute') + return getUiRelativeTimeFormatter().format(diffMinutes, 'minute') } const diffHours = Math.round(diffMinutes / 60) if (Math.abs(diffHours) < 24) { - return relativeTimeFormatter.format(diffHours, 'hour') + return getUiRelativeTimeFormatter().format(diffHours, 'hour') } const diffDays = Math.round(diffHours / 24) - return relativeTimeFormatter.format(diffDays, 'day') + return getUiRelativeTimeFormatter().format(diffDays, 'day') } 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..7922ad65bc14 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 { getUiRelativeTimeFormatter } 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' @@ -44,16 +44,16 @@ export function getWorkspaceSpaceScanTimeLabel(scannedAt: number, now = Date.now const diffMs = scannedAt - now const diffMinutes = Math.round(diffMs / 60_000) if (Math.abs(diffMinutes) < 60) { - return relativeTimeFormatter.format(diffMinutes, 'minute') + return getUiRelativeTimeFormatter().format(diffMinutes, 'minute') } const diffHours = Math.round(diffMinutes / 60) if (Math.abs(diffHours) < 24) { - return relativeTimeFormatter.format(diffHours, 'hour') + return getUiRelativeTimeFormatter().format(diffHours, 'hour') } const diffDays = Math.round(diffHours / 24) - return relativeTimeFormatter.format(diffDays, 'day') + return getUiRelativeTimeFormatter().format(diffDays, 'day') } 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..e7b9b1cec684 --- /dev/null +++ b/src/renderer/src/i18n/relative-time-format.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from 'vitest' + +const mockI18n = { language: 'en', resolvedLanguage: 'en' as string | undefined } + +vi.mock('./i18n', () => ({ i18n: mockI18n })) + +import { 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('昨日') + }) +}) 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..a528d7130b0b --- /dev/null +++ b/src/renderer/src/i18n/relative-time-format.ts @@ -0,0 +1,14 @@ +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 +} From df51e0e03e7f941e71fdd7f463129f17f2fbf1a3 Mon Sep 17 00:00:00 2001 From: moseoh Date: Tue, 14 Jul 2026 13:32:36 +0900 Subject: [PATCH 2/3] test(i18n): hoist the relative-time mock; reuse the formatter per call Address CodeRabbit review: vi.mock factories are hoisted above const initializers, so the mock object moves into vi.hoisted (matching the repo's existing pattern), and functions that format multiple units now grab the cached formatter once instead of per branch. --- src/renderer/src/components/JiraIssueWorkspace.tsx | 7 ++++--- src/renderer/src/components/TaskPage.tsx | 7 ++++--- .../src/components/activity/ActivityPrototypePage.tsx | 7 ++++--- src/renderer/src/components/linear-issue-workspace-text.ts | 7 ++++--- .../src/components/status-bar/workspace-space-format.ts | 7 ++++--- src/renderer/src/i18n/relative-time-format.test.ts | 4 +++- 6 files changed, 23 insertions(+), 16 deletions(-) diff --git a/src/renderer/src/components/JiraIssueWorkspace.tsx b/src/renderer/src/components/JiraIssueWorkspace.tsx index 81ee0d8571c1..05b3961b0856 100644 --- a/src/renderer/src/components/JiraIssueWorkspace.tsx +++ b/src/renderer/src/components/JiraIssueWorkspace.tsx @@ -62,15 +62,16 @@ function formatRelativeTime(input: string): string { if (Number.isNaN(date.getTime())) { return 'recently' } + const formatter = getUiRelativeTimeFormatter() const diffMinutes = Math.round((date.getTime() - Date.now()) / 60_000) if (Math.abs(diffMinutes) < 60) { - return getUiRelativeTimeFormatter().format(diffMinutes, 'minute') + return formatter.format(diffMinutes, 'minute') } const diffHours = Math.round(diffMinutes / 60) if (Math.abs(diffHours) < 24) { - return getUiRelativeTimeFormatter().format(diffHours, 'hour') + return formatter.format(diffHours, 'hour') } - return getUiRelativeTimeFormatter().format(Math.round(diffHours / 24), 'day') + return formatter.format(Math.round(diffHours / 24), 'day') } function buildJiraBranchName(issue: JiraIssue): string { diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index e5ac05c69b17..c5b1654a9dee 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -577,20 +577,21 @@ function formatRelativeTime(input: string): string { return 'recently' } + const formatter = getUiRelativeTimeFormatter() const diffMs = date.getTime() - Date.now() const diffMinutes = Math.round(diffMs / 60_000) if (Math.abs(diffMinutes) < 60) { - return getUiRelativeTimeFormatter().format(diffMinutes, 'minute') + return formatter.format(diffMinutes, 'minute') } const diffHours = Math.round(diffMinutes / 60) if (Math.abs(diffHours) < 24) { - return getUiRelativeTimeFormatter().format(diffHours, 'hour') + return formatter.format(diffHours, 'hour') } const diffDays = Math.round(diffHours / 24) - return getUiRelativeTimeFormatter().format(diffDays, 'day') + return formatter.format(diffDays, 'day') } type LinearProjectTab = 'overview' | 'issues' diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.tsx b/src/renderer/src/components/activity/ActivityPrototypePage.tsx index 768768752947..359ccaca1132 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.tsx +++ b/src/renderer/src/components/activity/ActivityPrototypePage.tsx @@ -171,17 +171,18 @@ function formatAbsoluteDate(timestamp: number): string { } function formatRelativeTime(timestamp: number): string { + const formatter = getUiRelativeTimeFormatter() const diffMs = timestamp - Date.now() const diffMinutes = Math.round(diffMs / 60_000) if (Math.abs(diffMinutes) < 60) { - return getUiRelativeTimeFormatter().format(diffMinutes, 'minute') + return formatter.format(diffMinutes, 'minute') } const diffHours = Math.round(diffMinutes / 60) if (Math.abs(diffHours) < 24) { - return getUiRelativeTimeFormatter().format(diffHours, 'hour') + return formatter.format(diffHours, 'hour') } const diffDays = Math.round(diffHours / 24) - return getUiRelativeTimeFormatter().format(diffDays, 'day') + return formatter.format(diffDays, 'day') } 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 9f450e8bf901..519fd1688f86 100644 --- a/src/renderer/src/components/linear-issue-workspace-text.ts +++ b/src/renderer/src/components/linear-issue-workspace-text.ts @@ -7,17 +7,18 @@ export function formatLinearIssueRelativeTime(input: string): string { if (Number.isNaN(date.getTime())) { return 'recently' } + const formatter = getUiRelativeTimeFormatter() const diffMs = date.getTime() - Date.now() const diffMinutes = Math.round(diffMs / 60_000) if (Math.abs(diffMinutes) < 60) { - return getUiRelativeTimeFormatter().format(diffMinutes, 'minute') + return formatter.format(diffMinutes, 'minute') } const diffHours = Math.round(diffMinutes / 60) if (Math.abs(diffHours) < 24) { - return getUiRelativeTimeFormatter().format(diffHours, 'hour') + return formatter.format(diffHours, 'hour') } const diffDays = Math.round(diffHours / 24) - return getUiRelativeTimeFormatter().format(diffDays, 'day') + return formatter.format(diffDays, 'day') } export function buildLinearIssueBranchName(issue: LinearIssue): string { 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 7922ad65bc14..68777831cf6e 100644 --- a/src/renderer/src/components/status-bar/workspace-space-format.ts +++ b/src/renderer/src/components/status-bar/workspace-space-format.ts @@ -41,19 +41,20 @@ export function formatCompactCount(count: number): string { } export function getWorkspaceSpaceScanTimeLabel(scannedAt: number, now = Date.now()): string { + const formatter = getUiRelativeTimeFormatter() const diffMs = scannedAt - now const diffMinutes = Math.round(diffMs / 60_000) if (Math.abs(diffMinutes) < 60) { - return getUiRelativeTimeFormatter().format(diffMinutes, 'minute') + return formatter.format(diffMinutes, 'minute') } const diffHours = Math.round(diffMinutes / 60) if (Math.abs(diffHours) < 24) { - return getUiRelativeTimeFormatter().format(diffHours, 'hour') + return formatter.format(diffHours, 'hour') } const diffDays = Math.round(diffHours / 24) - return getUiRelativeTimeFormatter().format(diffDays, 'day') + return formatter.format(diffDays, 'day') } 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 index e7b9b1cec684..332a43bfbb3b 100644 --- a/src/renderer/src/i18n/relative-time-format.test.ts +++ b/src/renderer/src/i18n/relative-time-format.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it, vi } from 'vitest' -const mockI18n = { language: 'en', resolvedLanguage: 'en' as string | undefined } +const { mockI18n } = vi.hoisted(() => ({ + mockI18n: { language: 'en', resolvedLanguage: 'en' as string | undefined } +})) vi.mock('./i18n', () => ({ i18n: mockI18n })) From 9266eb8639231baa83035a7f7a56388d81d5fcec Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 13 Jul 2026 23:19:30 -0700 Subject: [PATCH 3/3] refactor(i18n): collapse duplicated relative-time formatters into shared helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight call sites each carried an identical minute/hour/day branching copy around getUiRelativeTimeFormatter. Centralize that into formatUiRelativeTime (signed ms delta) and formatUiRelativeTimeFromDate (string input + "recently" fallback) so the locale-aware branching lives in one place. AccountsPane's formatMiniMaxRelativeRefresh stays bespoke — it has a translated "just now" branch and a past-clamped, asymmetric rounding convention that the symmetric helper would subtly shift at half-unit boundaries. Net -65 lines; behavior unchanged for the eight consolidated sites. --- .../src/components/GitHubItemDialog.tsx | 19 ++----------- .../src/components/JiraIssueWorkspace.tsx | 17 ++--------- .../src/components/LinearItemDrawer.tsx | 19 ++----------- .../src/components/PullRequestPage.tsx | 19 ++----------- src/renderer/src/components/TaskPage.tsx | 23 ++------------- .../activity/ActivityPrototypePage.tsx | 15 ++-------- .../components/linear-issue-workspace-text.ts | 19 ++----------- .../status-bar/workspace-space-format.ts | 17 ++--------- .../src/i18n/relative-time-format.test.ts | 28 ++++++++++++++++++- src/renderer/src/i18n/relative-time-format.ts | 25 +++++++++++++++++ 10 files changed, 68 insertions(+), 133 deletions(-) diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index 520e9c54294a..7b8fd7277ec7 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -226,7 +226,7 @@ import { validateTaskPageGitHubDuplicateTarget, type TaskPageGitHubCloseAction } from '@/components/task-page-github-status-actions' -import { getUiRelativeTimeFormatter } from '@/i18n/relative-time-format' +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. @@ -340,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 = getUiRelativeTimeFormatter() - 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 05b3961b0856..8eed9c925366 100644 --- a/src/renderer/src/components/JiraIssueWorkspace.tsx +++ b/src/renderer/src/components/JiraIssueWorkspace.tsx @@ -48,7 +48,7 @@ import type { } from '../../../shared/types' import type { TaskSourceContext } from '../../../shared/task-source-context' import { translate } from '@/i18n/i18n' -import { getUiRelativeTimeFormatter } from '@/i18n/relative-time-format' +import { formatUiRelativeTimeFromDate } from '@/i18n/relative-time-format' type JiraIssueWorkspaceProps = { issue: JiraIssue | null @@ -58,20 +58,7 @@ type JiraIssueWorkspaceProps = { } function formatRelativeTime(input: string): string { - const date = new Date(input) - if (Number.isNaN(date.getTime())) { - return 'recently' - } - const formatter = getUiRelativeTimeFormatter() - const diffMinutes = Math.round((date.getTime() - Date.now()) / 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') + 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 c3787423ef30..719aecbf4b18 100644 --- a/src/renderer/src/components/LinearItemDrawer.tsx +++ b/src/renderer/src/components/LinearItemDrawer.tsx @@ -50,7 +50,7 @@ import { linearUpdateIssue } from '@/runtime/runtime-linear-client' import { translate } from '@/i18n/i18n' -import { getUiRelativeTimeFormatter } from '@/i18n/relative-time-format' +import { formatUiRelativeTimeFromDate } from '@/i18n/relative-time-format' function LinearIcon({ className }: { className?: string }): React.JSX.Element { return ( @@ -102,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 = getUiRelativeTimeFormatter() - 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 a90b43f47956..083d91599129 100644 --- a/src/renderer/src/components/PullRequestPage.tsx +++ b/src/renderer/src/components/PullRequestPage.tsx @@ -222,7 +222,7 @@ import { } from '../../../shared/task-source-context' import { translate } from '@/i18n/i18n' import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' -import { getUiRelativeTimeFormatter } from '@/i18n/relative-time-format' +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. @@ -327,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 = getUiRelativeTimeFormatter() - 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 c5b1654a9dee..0300fa157994 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -364,7 +364,7 @@ import { type LinearOrderBy, type LinearViewMode } from '@/components/task-page-localized-options' -import { getUiRelativeTimeFormatter } from '@/i18n/relative-time-format' +import { formatUiRelativeTimeFromDate } from '@/i18n/relative-time-format' function isGitLabMRFilter(value: GitLabTaskFilter | GitLabIssueFilter): value is GitLabTaskFilter { return value === 'opened' || value === 'merged' || value === 'closed' || value === 'all' @@ -572,26 +572,7 @@ function scopeGitHubTaskSearch(query: string, kind: GitHubTaskKind): string { } function formatRelativeTime(input: string): string { - const date = new Date(input) - if (Number.isNaN(date.getTime())) { - return 'recently' - } - - const formatter = getUiRelativeTimeFormatter() - const diffMs = date.getTime() - Date.now() - 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') - } - - const diffDays = Math.round(diffHours / 24) - return formatter.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 359ccaca1132..f86b0139441e 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.tsx +++ b/src/renderer/src/components/activity/ActivityPrototypePage.tsx @@ -73,7 +73,7 @@ import { resolveActivityThreadStatusPreview } from '@/lib/activity-thread-display' import { getAgentRowPrimaryText } from '@/lib/agent-row-primary-text' -import { getUiRelativeTimeFormatter } from '@/i18n/relative-time-format' +import { formatUiRelativeTime } from '@/i18n/relative-time-format' type ThreadReadFilter = 'all' | 'unread' type ActivityGroupBy = 'status' | 'project' | 'worktree' | 'agent' @@ -171,18 +171,7 @@ function formatAbsoluteDate(timestamp: number): string { } function formatRelativeTime(timestamp: number): string { - const formatter = getUiRelativeTimeFormatter() - const diffMs = timestamp - Date.now() - 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') - } - const diffDays = Math.round(diffHours / 24) - return formatter.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 519fd1688f86..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' -import { getUiRelativeTimeFormatter } from '@/i18n/relative-time-format' +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 formatter = getUiRelativeTimeFormatter() - const diffMs = date.getTime() - Date.now() - 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') - } - const diffDays = Math.round(diffHours / 24) - return formatter.format(diffDays, 'day') + return formatUiRelativeTimeFromDate(input) } export function buildLinearIssueBranchName(issue: LinearIssue): string { 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 68777831cf6e..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,7 +3,7 @@ import type { WorkspaceSpaceScanStatus, WorkspaceSpaceWorktree } from '../../../../shared/workspace-space-types' -import { getUiRelativeTimeFormatter } from '@/i18n/relative-time-format' +import { formatUiRelativeTime } from '@/i18n/relative-time-format' const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] as const const fullDateTimeFormatter = new Intl.DateTimeFormat(undefined, { @@ -41,20 +41,7 @@ export function formatCompactCount(count: number): string { } export function getWorkspaceSpaceScanTimeLabel(scannedAt: number, now = Date.now()): string { - const formatter = getUiRelativeTimeFormatter() - const diffMs = scannedAt - now - 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') - } - - const diffDays = Math.round(diffHours / 24) - return formatter.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 index 332a43bfbb3b..745e06ddb0dc 100644 --- a/src/renderer/src/i18n/relative-time-format.test.ts +++ b/src/renderer/src/i18n/relative-time-format.test.ts @@ -6,7 +6,11 @@ const { mockI18n } = vi.hoisted(() => ({ vi.mock('./i18n', () => ({ i18n: mockI18n })) -import { getUiRelativeTimeFormatter } from './relative-time-format' +import { + formatUiRelativeTime, + formatUiRelativeTimeFromDate, + getUiRelativeTimeFormatter +} from './relative-time-format' describe('getUiRelativeTimeFormatter', () => { it('formats with the configured UI language instead of the OS locale', () => { @@ -34,3 +38,25 @@ describe('getUiRelativeTimeFormatter', () => { 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 index a528d7130b0b..3cbfece6dfdd 100644 --- a/src/renderer/src/i18n/relative-time-format.ts +++ b/src/renderer/src/i18n/relative-time-format.ts @@ -12,3 +12,28 @@ export function getUiRelativeTimeFormatter(): Intl.RelativeTimeFormat { } 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()) +}