Files
GearBox/.planning/phases/34-i18n-foundation/34-04-PLAN.md

292 lines
10 KiB
Markdown

---
phase: 34-i18n-foundation
plan: 04
type: execute
wave: 2
depends_on: [01, 03]
files_modified:
- src/client/routes/settings.tsx
- src/client/routes/__root.tsx
- src/client/lib/i18n.ts
autonomous: true
requirements: [D-09, D-10, D-11, D-12]
must_haves:
truths:
- "Language picker appears in settings page with English and Deutsch options"
- "Language picker uses the pill-toggle pattern matching weight unit and currency pickers"
- "Selecting a language persists via updateSetting('language', value)"
- "Selecting a language calls i18n.changeLanguage(value) to update the UI immediately"
- "Language picker is placed above weight unit in settings page"
- "Browser auto-detection works on first visit (navigator.language)"
- "Unknown browser locales fall back to English"
artifacts:
- path: "src/client/routes/settings.tsx"
provides: "Language picker UI"
contains: "language"
- path: "src/client/routes/__root.tsx"
provides: "i18n language sync with settings"
contains: "changeLanguage"
key_links:
- from: "src/client/routes/settings.tsx"
to: "src/client/hooks/useLanguage.ts"
via: "useLanguage() import"
pattern: "useLanguage"
- from: "src/client/routes/__root.tsx"
to: "src/client/lib/i18n.ts"
via: "i18n.changeLanguage"
pattern: "changeLanguage"
---
<objective>
Add language picker to settings page and wire language changes to i18n instance.
Purpose: User controls — users can see their current language, change it, and the UI updates immediately.
Output: Language picker in settings, i18n sync on language change, browser auto-detection on first visit.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/34-i18n-foundation/34-CONTEXT.md
@.planning/phases/34-i18n-foundation/34-RESEARCH.md
<interfaces>
Current settings.tsx pill-toggle pattern (weight unit):
```tsx
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-gray-900">Weight Unit</h3>
<p className="text-xs text-gray-500 mt-0.5">
Choose the unit used to display weights across the app
</p>
</div>
<div className="flex items-center gap-1 bg-gray-100 rounded-full px-1 py-0.5">
{UNITS.map((u) => (
<button key={u} type="button"
onClick={() => updateSetting.mutate({ key: "weightUnit", value: u })}
className={`px-2.5 py-1 text-xs rounded-full transition-colors ${
unit === u
? "bg-white text-gray-700 shadow-sm font-medium"
: "text-gray-400 hover:text-gray-600"
}`}>
{u}
</button>
))}
</div>
</div>
```
useLanguage hook (from Plan 03):
```typescript
export const VALID_LANGUAGES = ["en", "de"] as const;
export type Language = (typeof VALID_LANGUAGES)[number];
export function useLanguage(): Language { ... }
```
i18n instance:
```typescript
import i18n from "../lib/i18n";
i18n.changeLanguage("de"); // switches language
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Add language picker to settings page</name>
<files>src/client/routes/settings.tsx</files>
<read_first>src/client/routes/settings.tsx, src/client/hooks/useLanguage.ts, src/client/locales/en/settings.json</read_first>
<behavior>
- Settings page imports useLanguage from hooks/useLanguage
- Settings page imports i18n from lib/i18n
- Language picker section appears ABOVE the weight unit section (first preference in the list)
- Language picker uses the same pill-toggle pattern as weight unit and currency
- Options: "English" (value: "en") and "Deutsch" (value: "de")
- Clicking an option calls updateSetting.mutate({ key: "language", value }) AND i18n.changeLanguage(value)
- Active language is highlighted with the same styling pattern
- Label and description use t() keys from settings namespace
</behavior>
<action>
Update `src/client/routes/settings.tsx`:
1. Add imports:
```typescript
import i18n from "../lib/i18n";
import { useLanguage } from "../hooks/useLanguage";
```
2. In the `SettingsPage` component, add after `const updateSetting = useUpdateSetting();`:
```typescript
const language = useLanguage();
```
3. Add a `LANGUAGES` constant at the top of the file (near UNITS and CURRENCIES):
```typescript
const LANGUAGES = [
{ value: "en", label: "English" },
{ value: "de", label: "Deutsch" },
];
```
4. Add the language picker section BEFORE the weight unit section (first item in the settings card). It uses the same pill-toggle pattern:
```tsx
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-gray-900">{t("language.title")}</h3>
<p className="text-xs text-gray-500 mt-0.5">
{t("language.description")}
</p>
</div>
<div className="flex items-center gap-1 bg-gray-100 rounded-full px-1 py-0.5">
{LANGUAGES.map((lang) => (
<button
key={lang.value}
type="button"
onClick={() => {
updateSetting.mutate({ key: "language", value: lang.value });
i18n.changeLanguage(lang.value);
}}
className={`px-2.5 py-1 text-xs rounded-full transition-colors ${
language === lang.value
? "bg-white text-gray-700 shadow-sm font-medium"
: "text-gray-400 hover:text-gray-600"
}`}
>
{lang.label}
</button>
))}
</div>
</div>
<div className="border-t border-gray-100" />
```
5. Add these keys to `src/client/locales/en/settings.json` if not already present:
```json
{
"language": {
"title": "Language",
"description": "Change the display language of the app"
}
}
```
**NOTE:** Language labels ("English", "Deutsch") are intentionally NOT translated — they should always appear in their native language so users can identify their language even when the UI is in another language.
</action>
<acceptance_criteria>
- settings.tsx imports useLanguage and i18n
- settings.tsx has LANGUAGES constant with "en"/"English" and "de"/"Deutsch"
- Language picker section appears before weight unit section
- onClick handler calls both updateSetting.mutate and i18n.changeLanguage
- Language labels use native names ("English", "Deutsch"), not translated
- Pill-toggle styling matches weight unit and currency pickers
- settings.json has language.title and language.description keys
- `bun run build` succeeds
</acceptance_criteria>
<verify>
<automated>cd /home/jlmak/Projects/jlmak/GearBox && grep -c "useLanguage\|changeLanguage\|LANGUAGES" src/client/routes/settings.tsx</automated>
</verify>
<done>Language picker added to settings matching existing preference UI pattern</done>
</task>
<task type="auto">
<name>Task 2: Sync i18n language with settings on app load</name>
<files>src/client/routes/__root.tsx</files>
<read_first>src/client/routes/__root.tsx, src/client/hooks/useLanguage.ts, src/client/lib/i18n.ts</read_first>
<behavior>
- RootLayout component syncs i18n language when useLanguage() value changes
- On first load, if user has a saved language preference, i18n switches to it
- If no saved preference, i18n uses the browser-detected language (already configured in i18n.ts detection)
- useEffect watches language value and calls i18n.changeLanguage when it changes
- This handles the case where a user has "de" saved in settings but i18n initially detected "en" from browser
</behavior>
<action>
Update `src/client/routes/__root.tsx`:
1. Add imports:
```typescript
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useLanguage } from "../hooks/useLanguage";
```
Note: `useState` is already imported. Check if `useEffect` is already imported — if not, add it.
2. In the `RootLayout` function, add after existing hooks:
```typescript
const language = useLanguage();
const { i18n } = useTranslation();
useEffect(() => {
if (language && i18n.language !== language) {
i18n.changeLanguage(language);
}
}, [language, i18n]);
```
This syncs the i18n instance with the persisted language setting. On first load:
- i18next's LanguageDetector picks browser locale or localStorage cache
- useSetting("language") resolves from the DB
- If they differ, useEffect syncs i18n to the DB value (DB is source of truth)
On subsequent language changes via settings:
- updateSetting immediately calls i18n.changeLanguage (in settings.tsx)
- useLanguage() updates via React Query invalidation
- useEffect acts as a safety net if the values drift
</action>
<acceptance_criteria>
- __root.tsx imports useLanguage from hooks/useLanguage
- __root.tsx imports useTranslation from react-i18next
- RootLayout has useEffect that calls i18n.changeLanguage(language)
- useEffect depends on [language, i18n]
- `bun run build` succeeds
</acceptance_criteria>
<verify>
<automated>cd /home/jlmak/Projects/jlmak/GearBox && grep -c "useLanguage\|changeLanguage\|useTranslation" src/client/routes/__root.tsx</automated>
</verify>
<done>Language syncs between settings DB and i18n instance on load and change</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| settings API→i18n | Language value from DB flows into i18n.changeLanguage — validated |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-34-05 | Tampering | settings.tsx language picker | accept | Language values limited to LANGUAGES constant array ("en", "de"). Even if tampered, worst case is fallback to "en". |
</threat_model>
<verification>
- `bun run build` succeeds
- Language picker visible in settings page
- Clicking a language option changes the UI language
- Language preference persists across page reloads
</verification>
<success_criteria>
- Language picker in settings with English and Deutsch options
- Pill-toggle pattern matches weight unit and currency pickers
- Language change persists and syncs with i18n
- Browser auto-detection works for first visit
</success_criteria>
<output>
After completion, create `.planning/phases/34-i18n-foundation/34-04-SUMMARY.md`
</output>