mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 16:03:20 +00:00
fix(i18n): format relative times with the configured UI language (#8662)
Eight renderer call sites built Intl.RelativeTimeFormat(undefined, ...), which resolves to the OS locale, so relative timestamps rendered in Korean on a Korean-locale machine even with the UI language set to English. Unlike DateTimeFormat, RelativeTimeFormat emits language words, so it must follow the UI language. Rebased onto main: the formatter now resolves through getIntlLocale() (#12105) rather than i18n.resolvedLanguage, so the synthetic plugin<hex> resource language cannot reach the constructor and throw. GitHubItemDialog and PullRequestPage no longer own their formatter — main moved it into work-item-state-presentation, which is converted instead, along with the newly added site there. Co-authored-by: moseoh <azqazq195@gmail.com> Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
committed by
Neil
co-authored by
Jinjing
Orca
parent
c39972ba7e
commit
32d3016cd5
@@ -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 {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<hex> 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')
|
||||
})
|
||||
})
|
||||
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user