Skip to content
Merged
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
15 changes: 14 additions & 1 deletion desktop/src/i18n/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { act, renderHook } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { useSettingsStore } from '../stores/settingsStore'
import { useTranslation } from '.'
import { translate, useTranslation } from '.'

describe('useTranslation', () => {
afterEach(() => {
Expand All @@ -26,4 +26,17 @@ describe('useTranslation', () => {
})
expect(result.current).not.toBe(initial)
})

it('resolves every registered locale to its own translation', () => {
expect(translate('en', 'common.save')).toBe('Save')
expect(translate('zh', 'common.save')).toBe('保存')
expect(translate('zh-TW', 'common.save')).toBe('儲存')
expect(translate('jp', 'common.save')).toBe('保存')
expect(translate('kr', 'common.save')).toBe('저장')
})

it('interpolates params across the new locales', () => {
expect(translate('jp', 'session.timeMinutes', { n: 5 })).toBe('5 分前')
expect(translate('kr', 'session.timeMinutes', { n: 5 })).toBe('5분 전')
})
})
13 changes: 11 additions & 2 deletions desktop/src/i18n/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,19 @@ import { useCallback } from 'react'
import { useSettingsStore } from '../stores/settingsStore'
import { en, type TranslationKey } from './locales/en'
import { zh } from './locales/zh'
import { zh as zhTW } from './locales/zh-TW'
import { jp } from './locales/jp'
import { kr } from './locales/kr'

export type Locale = 'en' | 'zh'
export type Locale = 'en' | 'zh' | 'zh-TW' | 'jp' | 'kr'

const translations: Record<Locale, Record<string, string>> = { en, zh }
const translations: Record<Locale, Record<string, string>> = {
en,
zh,
'zh-TW': zhTW,
jp,
kr,
}

/**
* Translate a key with optional interpolation params.
Expand Down
1,702 changes: 1,702 additions & 0 deletions desktop/src/i18n/locales/jp.ts

Large diffs are not rendered by default.

1,702 changes: 1,702 additions & 0 deletions desktop/src/i18n/locales/kr.ts

Large diffs are not rendered by default.

1,702 changes: 1,702 additions & 0 deletions desktop/src/i18n/locales/zh-TW.ts

Large diffs are not rendered by default.

30 changes: 30 additions & 0 deletions desktop/src/lib/formatMessageTimestamp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,34 @@ describe('formatMessageTimestamp', () => {

expect(formatMessageTimestamp(value, t('zh'), 'zh', now)).toBe('4月20日 09:30')
})

it('formats Japanese history with Han year/month/day characters', () => {
const weekday = new Date(2026, 4, 24, 15, 50).getTime()
expect(formatMessageTimestamp(weekday, t('jp'), 'jp', now)).toContain('15:50')

const monthDay = new Date(2026, 3, 20, 9, 30).getTime()
expect(formatMessageTimestamp(monthDay, t('jp'), 'jp', now)).toBe('4月20日 09:30')

const yearMonthDay = new Date(2025, 11, 15, 9, 30).getTime()
expect(formatMessageTimestamp(yearMonthDay, t('jp'), 'jp', now)).toBe('2025年12月15日 09:30')

expect(formatMessageTimestamp(now - 5 * 60_000, t('jp'), 'jp', now)).toBe('5 分前')
})

it('formats Traditional Chinese history with Han year/month/day characters', () => {
const monthDay = new Date(2026, 3, 20, 9, 30).getTime()
expect(formatMessageTimestamp(monthDay, t('zh-TW'), 'zh-TW', now)).toBe('4月20日 09:30')

const yearMonthDay = new Date(2025, 11, 15, 9, 30).getTime()
expect(formatMessageTimestamp(yearMonthDay, t('zh-TW'), 'zh-TW', now)).toBe('2025年12月15日 09:30')
})

it('formats Korean history via Intl (ko-KR) without Han characters', () => {
const monthDay = new Date(2026, 3, 20, 9, 30).getTime()
const out = formatMessageTimestamp(monthDay, t('kr'), 'kr', now)
expect(out).toContain('09:30')
expect(out).not.toContain('月') // Korean uses 월, never the Han 月

expect(formatMessageTimestamp(now - 5 * 60_000, t('kr'), 'kr', now)).toBe('5분 전')
})
})
23 changes: 18 additions & 5 deletions desktop/src/lib/formatMessageTimestamp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,19 +52,32 @@ function coerceDate(value: number | string | Date): Date | null {
}

function localeToIntl(locale: Locale): string {
return locale === 'zh' ? 'zh-CN' : 'en-US'
switch (locale) {
case 'zh': return 'zh-CN'
case 'zh-TW': return 'zh-TW'
case 'jp': return 'ja-JP'
case 'kr': return 'ko-KR'
default: return 'en-US'
}
}

// Locales whose dates use the 年/月/日 Han characters: Chinese (Simplified/Traditional)
// and Japanese share identical glyphs. Korean uses 년/월/일, so it goes through Intl instead.
function usesHanYmd(locale: Locale): boolean {
return locale === 'zh' || locale === 'zh-TW' || locale === 'jp'
}

function formatWeekdayTime(date: Date, locale: Locale): string {
const intlLocale = localeToIntl(locale)
const weekday = new Intl.DateTimeFormat(intlLocale, { weekday: locale === 'zh' ? 'long' : 'short' }).format(date)
const han = usesHanYmd(locale)
const weekday = new Intl.DateTimeFormat(intlLocale, { weekday: han ? 'long' : 'short' }).format(date)
const time = formatClockTime(date, intlLocale)
return locale === 'zh' ? `${weekday}${time}` : `${weekday} ${time}`
return han ? `${weekday}${time}` : `${weekday} ${time}`
}

function formatMonthDayTime(date: Date, locale: Locale): string {
const intlLocale = localeToIntl(locale)
if (locale === 'zh') {
if (usesHanYmd(locale)) {
return `${date.getMonth() + 1}月${date.getDate()}日 ${formatClockTime(date, intlLocale)}`
}
const day = new Intl.DateTimeFormat(intlLocale, {
Expand All @@ -76,7 +89,7 @@ function formatMonthDayTime(date: Date, locale: Locale): string {

function formatYearMonthDayTime(date: Date, locale: Locale): string {
const intlLocale = localeToIntl(locale)
if (locale === 'zh') {
if (usesHanYmd(locale)) {
return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日 ${formatClockTime(date, intlLocale)}`
}
const day = new Intl.DateTimeFormat(intlLocale, {
Expand Down
3 changes: 3 additions & 0 deletions desktop/src/pages/ActivitySettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ const HEAT_COLORS = [
const DATE_LOCALES: Record<Locale, string> = {
en: 'en-US',
zh: 'zh-CN',
'zh-TW': 'zh-TW',
jp: 'ja-JP',
kr: 'ko-KR',
}
const DEFAULT_PROFILE: DesktopProfilePreferences = {
displayName: 'cc-haha',
Expand Down
6 changes: 5 additions & 1 deletion desktop/src/pages/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1520,9 +1520,13 @@ function GeneralSettings() {

const LANGUAGES: Array<{ value: Locale; label: string }> = [
{ value: 'en', label: 'English' },
{ value: 'zh', label: '中文' },
{ value: 'zh', label: '简体中文' },
{ value: 'zh-TW', label: '繁體中文' },
{ value: 'jp', label: '日本語' },
{ value: 'kr', label: '한국어' },
]


const RESPONSE_LANGUAGES: Array<{ value: string; label: string }> = [
{ value: '', label: t('settings.general.responseLangDefault') },
{ value: 'english', label: 'English' },
Expand Down
4 changes: 3 additions & 1 deletion desktop/src/stores/settingsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,12 @@ export const UI_ZOOM_STEP = APP_ZOOM_CONTROL_STEP
export const UI_ZOOM_DEFAULT = DEFAULT_APP_ZOOM
let desktopNotificationsSaveQueue: Promise<void> = Promise.resolve()

const VALID_LOCALES: readonly Locale[] = ['en', 'zh', 'zh-TW', 'jp', 'kr']

function getStoredLocale(): Locale {
try {
const stored = localStorage.getItem(LOCALE_STORAGE_KEY)
if (stored === 'en' || stored === 'zh') return stored
if (stored && (VALID_LOCALES as readonly string[]).includes(stored)) return stored as Locale
} catch { /* localStorage unavailable */ }
return 'zh'
}
Expand Down
Loading