feat(dashboard): establish string-migration pattern and locale formatting (U5)

Add the two reusable migration primitives: useLocaleFormat (date/number bound
to the active locale, replacing implicit-browser-locale toLocale* calls — R8)
and useColumnLabel (translate core COLUMN_LABELS via common:columns.* with
English fallback). Migrate the Settings Appearance heading to t() as a worked
example. The bulk migration of the ~464-file long tail is deferred follow-up
work per the plan's Scope Boundaries; the pattern and primitives are in place
so remaining clusters are mechanical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-03 08:22:49 -07:00
parent 3ae1fc3830
commit 677fb9c46c
5 changed files with 114 additions and 1 deletions

View File

@@ -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()}
<h4 className="settings-section-heading">Appearance</h4>
<h4 className="settings-section-heading">{t("settings.appearance.title", "Appearance")}</h4>
<ThemeSelector
themeMode={themeMode}
colorTheme={colorTheme}

View File

@@ -0,0 +1,46 @@
import { renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({ 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");
});
});

View File

@@ -0,0 +1,27 @@
import { renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({ map: {} as Record<string, string> }));
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("进行中");
});
});

View File

@@ -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),
};
}

View File

@@ -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]);
}