feat(i18n): add comprehensive Vietnamese dashboard locale

This commit is contained in:
Tam Nhu Tran
2026-03-03 02:09:46 +07:00
parent 593e7fd031
commit 7893436038
8 changed files with 1346 additions and 34 deletions
+1 -1
View File
@@ -67,7 +67,7 @@ The dashboard provides visual management for all account types:
- **Factory Droid**: Track Droid install location and BYOK settings health
- **Updates Center**: Track support rollouts (Droid target, CLIProxy provider changes, WebSearch integrations)
- **Health Monitor**: Real-time status across all profiles
- **Language Switcher**: Toggle dashboard locale between English and Simplified Chinese
- **Language Switcher**: Toggle dashboard locale between English, Simplified Chinese, and Vietnamese
**Analytics Dashboard**
+4 -2
View File
@@ -13,6 +13,7 @@ Dashboard i18n currently covers UI text rendered by React components.
- Supported locales today:
- `en` (English)
- `zh-CN` (Simplified Chinese)
- `vi` (Vietnamese)
- Locale state is persisted in browser localStorage using `ccs-ui-locale`.
- Fallback language is `en`.
@@ -36,6 +37,7 @@ Out of scope:
- Contains `resources` object with locale blocks:
- `en.translation`
- `zh-CN.translation`
- `vi.translation`
- Uses `initReactI18next` for React integration.
### Locale utilities
@@ -85,7 +87,7 @@ Examples exist in sync/account counters.
## Adding a New Locale
When adding a locale such as Vietnamese (`vi`):
When adding a locale such as Thai (`th`):
1. Add locale id to the supported locale list in `ui/src/lib/locales.ts`.
2. Add locale display label under `translation.locale` in `ui/src/lib/i18n.ts`.
@@ -96,7 +98,7 @@ When adding a locale such as Vietnamese (`vi`):
- `cd ui && bun run test:run tests/unit/ui/i18n/language-switcher.test.tsx`
6. Add or update key-parity tests to catch locale drift.
Tracking issue for comprehensive Vietnamese locale:
Current issue driving the Vietnamese rollout:
- https://github.com/kaitranntt/ccs/issues/659
+1
View File
@@ -59,4 +59,5 @@ For full architecture, conventions, and locale onboarding, see:
- UI locale persistence uses browser localStorage key `ccs-ui-locale`.
- Current supported locales are managed in `ui/src/lib/locales.ts`.
- Current locales: `en`, `zh-CN`, `vi`.
- Fallback locale is English (`en`).
+71 -27
View File
@@ -399,6 +399,7 @@ function getStatusText(code: number): string {
export function formatRelativeTime(modifiedSeconds: number, locale?: string): string {
const formatLocale = getFormattingLocale(locale);
const isZh = formatLocale === 'zh-CN';
const isVi = formatLocale === 'vi';
const now = Date.now();
const modified = modifiedSeconds * 1000; // Convert to milliseconds
const diff = now - modified;
@@ -408,10 +409,26 @@ export function formatRelativeTime(modifiedSeconds: number, locale?: string): st
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (seconds < 60) return isZh ? '刚刚' : 'just now';
if (minutes < 60) return isZh ? `${minutes} 分钟前` : `${minutes}m ago`;
if (hours < 24) return isZh ? `${hours} 小时前` : `${hours}h ago`;
if (days < 7) return isZh ? `${days} 天前` : `${days}d ago`;
if (seconds < 60) {
if (isZh) return '刚刚';
if (isVi) return 'vừa xong';
return 'just now';
}
if (minutes < 60) {
if (isZh) return `${minutes} 分钟前`;
if (isVi) return `${minutes} phút trước`;
return `${minutes}m ago`;
}
if (hours < 24) {
if (isZh) return `${hours} 小时前`;
if (isVi) return `${hours} giờ trước`;
return `${hours}h ago`;
}
if (days < 7) {
if (isZh) return `${days} 天前`;
if (isVi) return `${days} ngày trước`;
return `${days}d ago`;
}
// Format as date for older logs
const date = new Date(modified);
@@ -441,25 +458,39 @@ export function getStatusColor(code: number): string {
* Get error type label
*/
export function getErrorTypeLabel(type: ParsedErrorLog['errorType'], locale?: string): string {
const isZh = getFormattingLocale(locale) === 'zh-CN';
const labels: Record<string, string> = isZh
? {
rate_limit: '限流',
auth: '认证错误',
not_found: '未找到',
server: '服务错误',
timeout: '超时',
unknown: '错误',
}
: {
rate_limit: 'Rate Limited',
auth: 'Auth Error',
not_found: 'Not Found',
server: 'Server Error',
timeout: 'Timeout',
unknown: 'Error',
};
return labels[type] || (isZh ? '错误' : 'Error');
const formatLocale = getFormattingLocale(locale);
if (formatLocale === 'zh-CN') {
const labels: Record<string, string> = {
rate_limit: '限流',
auth: '认证错误',
not_found: '未找到',
server: '服务错误',
timeout: '超时',
unknown: '错误',
};
return labels[type] || '错误';
}
if (formatLocale === 'vi') {
const labels: Record<string, string> = {
rate_limit: 'Quá giới hạn',
auth: 'Lỗi xác thực',
not_found: 'Không tìm thấy',
server: 'Lỗi máy chủ',
timeout: 'Hết thời gian chờ',
unknown: 'Lỗi',
};
return labels[type] || 'Lỗi';
}
const labels: Record<string, string> = {
rate_limit: 'Rate Limited',
auth: 'Auth Error',
not_found: 'Not Found',
server: 'Server Error',
timeout: 'Timeout',
unknown: 'Error',
};
return labels[type] || 'Error';
}
/**
@@ -486,22 +517,35 @@ export function formatQuotaResetTimestamp(
locale?: string
): string | null {
if (!timestamp) return null;
const isZh = getFormattingLocale(locale) === 'zh-CN';
const formatLocale = getFormattingLocale(locale);
const isZh = formatLocale === 'zh-CN';
const isVi = formatLocale === 'vi';
try {
const resetDate = new Date(timestamp);
const now = new Date();
const diff = resetDate.getTime() - now.getTime();
if (diff <= 0) return isZh ? '现在' : 'now';
if (diff <= 0) {
if (isZh) return '现在';
if (isVi) return 'bây giờ';
return 'now';
}
const seconds = Math.floor(diff / 1000);
if (seconds < 60) return isZh ? `${seconds}` : `${seconds}s`;
if (seconds < 60) {
if (isZh) return `${seconds}`;
if (isVi) return `${seconds} giây`;
return `${seconds}s`;
}
if (seconds < 3600) {
const mins = Math.floor(seconds / 60);
return isZh ? `${mins}分钟` : `${mins}m`;
if (isZh) return `${mins}分钟`;
if (isVi) return `${mins} phút`;
return `${mins}m`;
}
const hours = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
if (isZh) return mins > 0 ? `${hours}小时 ${mins}分钟` : `${hours}小时`;
if (isVi) return mins > 0 ? `${hours} giờ ${mins} phút` : `${hours} giờ`;
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
} catch {
return null;
+1159
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,6 +1,6 @@
export const LOCALE_STORAGE_KEY = 'ccs-ui-locale';
export const SUPPORTED_LOCALES = ['en', 'zh-CN'] as const;
export const SUPPORTED_LOCALES = ['en', 'zh-CN', 'vi'] as const;
export type AppLocale = (typeof SUPPORTED_LOCALES)[number];
@@ -11,6 +11,7 @@ export function isSupportedLocale(locale: string): locale is AppLocale {
export function normalizeLocale(locale: string | null | undefined): AppLocale {
if (!locale) return 'en';
if (locale.toLowerCase().startsWith('zh')) return 'zh-CN';
if (locale.toLowerCase().startsWith('vi')) return 'vi';
return 'en';
}
@@ -3,7 +3,31 @@ import { render, screen, userEvent, waitFor } from '@tests/setup/test-utils';
import i18n from '@/lib/i18n';
import { LanguageSwitcher } from '@/components/layout/language-switcher';
import { TabNavigation } from '@/pages/settings/components/tab-navigation';
import { getInitialLocale, LOCALE_STORAGE_KEY, persistLocale } from '@/lib/locales';
import {
getInitialLocale,
LOCALE_STORAGE_KEY,
normalizeLocale,
persistLocale,
} from '@/lib/locales';
function flattenKeys(node: unknown, prefix = '', out = new Set<string>()): Set<string> {
if (typeof node !== 'object' || node === null) return out;
for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
const nextKey = prefix ? `${prefix}.${key}` : key;
if (typeof value === 'string') {
out.add(nextKey);
continue;
}
if (value && typeof value === 'object') {
flattenKeys(value, nextKey, out);
}
}
return out;
}
function collectPlaceholders(text: string): string[] {
return Array.from(text.matchAll(/\{\{[^{}]+\}\}/g), (match) => match[0]).sort();
}
describe('Dashboard i18n', () => {
const storage = new Map<string, string>();
@@ -56,6 +80,7 @@ describe('Dashboard i18n', () => {
expect(screen.getByRole('combobox')).toBeInTheDocument();
expect(screen.getByText('English')).toBeInTheDocument();
expect(screen.queryByText('Vietnamese')).not.toBeInTheDocument();
await userEvent.click(screen.getByRole('combobox'));
await userEvent.click(await screen.findByText('Simplified Chinese'));
@@ -66,9 +91,22 @@ describe('Dashboard i18n', () => {
expect(localStorageMock.setItem).toHaveBeenCalledWith(LOCALE_STORAGE_KEY, 'zh-CN');
}, 10000);
it('supports Vietnamese locale in switcher and persistence', async () => {
render(<LanguageSwitcher />);
await userEvent.click(screen.getByRole('combobox'));
await userEvent.click(await screen.findByText('Vietnamese'));
await waitFor(() => {
expect(i18n.language).toBe('vi');
});
expect(localStorageMock.setItem).toHaveBeenCalledWith(LOCALE_STORAGE_KEY, 'vi');
expect(normalizeLocale('vi-VN')).toBe('vi');
});
it('restores locale from persisted storage', () => {
persistLocale('zh-CN');
expect(getInitialLocale()).toBe('zh-CN');
persistLocale('vi');
expect(getInitialLocale()).toBe('vi');
});
it('shows Chinese labels on translated settings tabs', async () => {
@@ -80,4 +118,33 @@ describe('Dashboard i18n', () => {
expect(screen.getByText('环境')).toBeInTheDocument();
expect(screen.getByText('认证')).toBeInTheDocument();
});
it('keeps vi translation keys in parity with en and preserves placeholders', () => {
const resources = i18n.options.resources as
| Record<string, { translation: Record<string, unknown> }>
| undefined;
const enTranslation = resources?.en?.translation;
const viTranslation = resources?.vi?.translation;
expect(enTranslation).toBeDefined();
expect(viTranslation).toBeDefined();
const enKeys = flattenKeys(enTranslation);
const viKeys = flattenKeys(viTranslation);
expect([...viKeys].sort()).toEqual([...enKeys].sort());
for (const key of enKeys) {
const enValue = key
.split('.')
.reduce<unknown>((acc, part) => (acc as Record<string, unknown>)?.[part], enTranslation);
const viValue = key
.split('.')
.reduce<unknown>((acc, part) => (acc as Record<string, unknown>)?.[part], viTranslation);
if (typeof enValue !== 'string' || typeof viValue !== 'string') continue;
expect(collectPlaceholders(viValue)).toEqual(collectPlaceholders(enValue));
}
});
});
@@ -0,0 +1,38 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
formatQuotaResetTimestamp,
formatRelativeTime,
getErrorTypeLabel,
} from '@/lib/error-log-parser';
describe('error-log-parser locale formatting', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
});
afterEach(() => {
vi.useRealTimers();
});
it('formats relative time in Vietnamese', () => {
const nowSeconds = Math.floor(Date.now() / 1000);
expect(formatRelativeTime(nowSeconds - 10, 'vi')).toBe('vừa xong');
expect(formatRelativeTime(nowSeconds - 120, 'vi')).toBe('2 phút trước');
expect(formatRelativeTime(nowSeconds - 7200, 'vi')).toBe('2 giờ trước');
});
it('returns localized error labels for Vietnamese', () => {
expect(getErrorTypeLabel('rate_limit', 'vi')).toBe('Quá giới hạn');
expect(getErrorTypeLabel('auth', 'vi')).toBe('Lỗi xác thực');
expect(getErrorTypeLabel('unknown', 'vi')).toBe('Lỗi');
});
it('formats quota reset timestamp in Vietnamese', () => {
expect(formatQuotaResetTimestamp('2026-01-01T00:00:45.000Z', 'vi')).toBe('45 giây');
expect(formatQuotaResetTimestamp('2026-01-01T00:05:00.000Z', 'vi')).toBe('5 phút');
expect(formatQuotaResetTimestamp('2026-01-01T02:05:00.000Z', 'vi')).toBe('2 giờ 5 phút');
expect(formatQuotaResetTimestamp('2025-12-31T23:59:58.000Z', 'vi')).toBe('bây giờ');
});
});