Skip to content
Closed
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
18 changes: 2 additions & 16 deletions src/renderer/src/components/GitHubItemDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
17 changes: 2 additions & 15 deletions src/renderer/src/components/JiraIssueWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
18 changes: 2 additions & 16 deletions src/renderer/src/components/LinearItemDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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 = {
Expand Down
18 changes: 2 additions & 16 deletions src/renderer/src/components/PullRequestPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
26 changes: 2 additions & 24 deletions src/renderer/src/components/TaskPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand Down
15 changes: 2 additions & 13 deletions src/renderer/src/components/activity/ActivityPrototypePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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(
Expand Down
19 changes: 2 additions & 17 deletions src/renderer/src/components/linear-issue-workspace-text.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion src/renderer/src/components/settings/AccountsPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ import {
selectCodexProviderAccount,
watchProviderAccounts
} from '@/runtime/runtime-provider-accounts-client'
import { getUiRelativeTimeFormatter } from '@/i18n/relative-time-format'

export { getAccountsPaneSearchEntries }

Expand All @@ -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')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
16 changes: 2 additions & 14 deletions src/renderer/src/components/status-bar/workspace-space-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 {
Expand Down
62 changes: 62 additions & 0 deletions src/renderer/src/i18n/relative-time-format.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
39 changes: 39 additions & 0 deletions src/renderer/src/i18n/relative-time-format.ts
Original file line number Diff line number Diff line change
@@ -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())
}
Loading