diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index 5ecb66aadf..3c6a3ac2c4 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -19,6 +19,7 @@ import type { AuthProvider, ManualOAuthCodeInfo, ModelInfo, BackupListResponse, import { useMemoryBackendStatus } from "../hooks/useMemoryBackendStatus"; import { useOverlayDismiss } from "../hooks/useOverlayDismiss"; import type { ToastType } from "../hooks/useToast"; +import { useTranslation } from "react-i18next"; import { ThemeSelector } from "./ThemeSelector"; import { LanguageSelector } from "./LanguageSelector"; import { useSessionBannersHidden, setSessionBannersHidden } from "../hooks/useSessionBannerPref"; @@ -431,6 +432,7 @@ export function SettingsModal({ onReopenOnboarding, onOpenApprovals, }: SettingsModalProps) { + const { t } = useTranslation("app"); const { confirm } = useConfirm(); const worktrunkInstall = useWorktrunkInstallStatus(projectId); const worktrunkInstallVerified = worktrunkInstall.status === "installed"; @@ -3693,7 +3695,7 @@ export function SettingsModal({ return ( <> {renderScopeBanner()} -

Appearance

+

{t("settings.appearance.title", "Appearance")}

({ lng: "en" })); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + i18n: { resolvedLanguage: state.lng, language: state.lng }, + t: (k: string, d?: string) => d ?? k, + }), +})); + +const { useLocaleFormat } = await import("../format"); + +describe("useLocaleFormat", () => { + it("formats numbers with the active locale's separators", () => { + state.lng = "en"; + const en = renderHook(() => useLocaleFormat()).result.current.formatNumber(1234567); + expect(en).toContain(","); + + state.lng = "fr"; + const fr = renderHook(() => useLocaleFormat()).result.current.formatNumber(1234567); + expect(fr).not.toContain(","); + expect(fr).not.toBe(en); + }); + + it("formats dates per active locale", () => { + const date = new Date(Date.UTC(2026, 0, 15)); + state.lng = "en"; + const en = renderHook(() => useLocaleFormat()).result.current.formatDate(date, { + month: "long", + timeZone: "UTC", + }); + state.lng = "fr"; + const fr = renderHook(() => useLocaleFormat()).result.current.formatDate(date, { + month: "long", + timeZone: "UTC", + }); + expect(en).toMatch(/January/); + expect(fr).toMatch(/janvier/); + }); + + it("exposes the resolved locale", () => { + state.lng = "zh-CN"; + expect(renderHook(() => useLocaleFormat()).result.current.locale).toBe("zh-CN"); + }); +}); diff --git a/packages/dashboard/app/i18n/__tests__/labels.test.ts b/packages/dashboard/app/i18n/__tests__/labels.test.ts new file mode 100644 index 0000000000..04c9837a07 --- /dev/null +++ b/packages/dashboard/app/i18n/__tests__/labels.test.ts @@ -0,0 +1,27 @@ +import { renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +const state = vi.hoisted(() => ({ map: {} as Record })); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, def?: string) => state.map[key] ?? def ?? key, + }), +})); + +const { useColumnLabel } = await import("../labels"); + +describe("useColumnLabel", () => { + it("falls back to the English COLUMN_LABELS when no translation exists", () => { + state.map = {}; + const label = renderHook(() => useColumnLabel()).result.current; + expect(label("done")).toBe("Done"); + expect(label("in-progress")).toBe("In Progress"); + }); + + it("uses the translated value when present", () => { + state.map = { "columns.done": "完成", "columns.in-progress": "进行中" }; + const label = renderHook(() => useColumnLabel()).result.current; + expect(label("done")).toBe("完成"); + expect(label("in-progress")).toBe("进行中"); + }); +}); diff --git a/packages/dashboard/app/i18n/format.ts b/packages/dashboard/app/i18n/format.ts new file mode 100644 index 0000000000..75231cb755 --- /dev/null +++ b/packages/dashboard/app/i18n/format.ts @@ -0,0 +1,25 @@ +import { useTranslation } from "react-i18next"; + +/** + * Locale-aware date/number formatting bound to the active i18n locale. + * + * Replaces the ~45 `toLocale*(undefined, …)` call sites that previously used + * the implicit browser locale. Routing them through this hook threads the + * user's chosen language into all date/number formatting (R8). + */ +export function useLocaleFormat() { + const { i18n } = useTranslation(); + const locale = i18n.resolvedLanguage || i18n.language || "en"; + + return { + locale, + formatDate: (value: number | string | Date, options?: Intl.DateTimeFormatOptions) => + new Date(value).toLocaleDateString(locale, options), + formatTime: (value: number | string | Date, options?: Intl.DateTimeFormatOptions) => + new Date(value).toLocaleTimeString(locale, options), + formatDateTime: (value: number | string | Date, options?: Intl.DateTimeFormatOptions) => + new Date(value).toLocaleString(locale, options), + formatNumber: (value: number, options?: Intl.NumberFormatOptions) => + value.toLocaleString(locale, options), + }; +} diff --git a/packages/dashboard/app/i18n/labels.ts b/packages/dashboard/app/i18n/labels.ts new file mode 100644 index 0000000000..6f9a8981dc --- /dev/null +++ b/packages/dashboard/app/i18n/labels.ts @@ -0,0 +1,13 @@ +import { COLUMN_LABELS, type Column } from "@fusion/core"; +import { useTranslation } from "react-i18next"; + +/** + * Returns a translator for board column labels, backed by the `common:columns.*` + * keys with the English `COLUMN_LABELS` as the fallback. This is the migration + * pattern for the centralized core label constants: import the hook, call it, + * and replace `COLUMN_LABELS[col]` with `columnLabel(col)`. + */ +export function useColumnLabel(): (column: Column) => string { + const { t } = useTranslation("common"); + return (column: Column) => t(`columns.${column}`, COLUMN_LABELS[column]); +}