docs(34): create phase plans for i18n foundation
This commit is contained in:
319
.planning/phases/34-i18n-foundation/34-01-PLAN.md
Normal file
319
.planning/phases/34-i18n-foundation/34-01-PLAN.md
Normal file
@@ -0,0 +1,319 @@
|
||||
---
|
||||
phase: 34-i18n-foundation
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- package.json
|
||||
- src/client/lib/i18n.ts
|
||||
- src/client/main.tsx
|
||||
- src/client/locales/en/common.json
|
||||
- src/client/locales/en/collection.json
|
||||
- src/client/locales/en/threads.json
|
||||
- src/client/locales/en/setups.json
|
||||
- src/client/locales/en/onboarding.json
|
||||
- src/client/locales/en/settings.json
|
||||
autonomous: true
|
||||
requirements: [D-05, D-06, D-07, D-08, D-12]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "i18next, react-i18next, and i18next-browser-languagedetector are installed"
|
||||
- "i18n.ts initializes i18next with LanguageDetector and initReactI18next"
|
||||
- "English locale JSON files exist in src/client/locales/en/ with namespaces: common, collection, threads, setups, onboarding, settings"
|
||||
- "main.tsx imports i18n.ts before rendering the app"
|
||||
- "fallback language is set to en"
|
||||
- "Language detection order is localStorage then navigator"
|
||||
artifacts:
|
||||
- path: "src/client/lib/i18n.ts"
|
||||
provides: "i18next initialization with language detection and all namespaces"
|
||||
contains: "initReactI18next"
|
||||
- path: "src/client/locales/en/common.json"
|
||||
provides: "English common namespace translations"
|
||||
contains: "save"
|
||||
- path: "package.json"
|
||||
provides: "i18n dependencies"
|
||||
contains: "react-i18next"
|
||||
key_links:
|
||||
- from: "src/client/main.tsx"
|
||||
to: "src/client/lib/i18n.ts"
|
||||
via: "import statement"
|
||||
pattern: "import.*i18n"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Install the i18n framework (react-i18next) and create all English locale JSON files with namespace structure.
|
||||
|
||||
Purpose: Foundation — all other plans depend on having i18next initialized and English strings extracted into JSON files.
|
||||
Output: Working i18n setup with all English translation files, app initializes i18next before rendering.
|
||||
</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>
|
||||
From src/client/main.tsx:
|
||||
```typescript
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createRouter, RouterProvider } from "@tanstack/react-router";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { routeTree } from "./routeTree.gen";
|
||||
```
|
||||
|
||||
From src/client/hooks/useFormatters.ts:
|
||||
```typescript
|
||||
export function useFormatters() {
|
||||
const unit = useWeightUnit();
|
||||
const currency = useCurrency();
|
||||
return {
|
||||
weight: (grams: number | null) => formatWeight(grams, unit),
|
||||
price: (cents: number | null) => formatPrice(cents, currency),
|
||||
unit,
|
||||
currency,
|
||||
};
|
||||
}
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Install i18n packages</name>
|
||||
<files>package.json</files>
|
||||
<read_first>package.json</read_first>
|
||||
<behavior>
|
||||
- i18next is in dependencies
|
||||
- react-i18next is in dependencies
|
||||
- i18next-browser-languagedetector is in dependencies
|
||||
</behavior>
|
||||
<action>
|
||||
Run: `bun add i18next react-i18next i18next-browser-languagedetector`
|
||||
|
||||
This adds the three required packages:
|
||||
- `i18next` — core translation engine (~8KB)
|
||||
- `react-i18next` — React hooks and components (`useTranslation`)
|
||||
- `i18next-browser-languagedetector` — auto-detect browser locale from `navigator.language` (D-10)
|
||||
</action>
|
||||
<acceptance_criteria>
|
||||
- package.json contains "i18next" in dependencies
|
||||
- package.json contains "react-i18next" in dependencies
|
||||
- package.json contains "i18next-browser-languagedetector" in dependencies
|
||||
- `bun install` completes without errors
|
||||
</acceptance_criteria>
|
||||
<verify>
|
||||
<automated>cd /home/jlmak/Projects/jlmak/GearBox && grep -c "i18next\|react-i18next\|i18next-browser-languagedetector" package.json</automated>
|
||||
</verify>
|
||||
<done>All three i18n packages installed</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Create English locale JSON files with all translatable strings</name>
|
||||
<files>src/client/locales/en/common.json, src/client/locales/en/collection.json, src/client/locales/en/threads.json, src/client/locales/en/setups.json, src/client/locales/en/onboarding.json, src/client/locales/en/settings.json</files>
|
||||
<read_first>src/client/components/TopNav.tsx, src/client/components/BottomTabBar.tsx, src/client/components/FabMenu.tsx, src/client/components/ConfirmDialog.tsx, src/client/components/AuthPromptModal.tsx, src/client/routes/__root.tsx, src/client/components/CollectionView.tsx, src/client/components/ItemCard.tsx, src/client/components/ItemForm.tsx, src/client/components/CategoryPicker.tsx, src/client/components/CategoryHeader.tsx, src/client/components/WeightSummaryCard.tsx, src/client/components/PlanningView.tsx, src/client/components/ThreadCard.tsx, src/client/components/ThreadTabs.tsx, src/client/components/CandidateCard.tsx, src/client/components/CandidateForm.tsx, src/client/components/ComparisonTable.tsx, src/client/components/CreateThreadModal.tsx, src/client/components/SetupsView.tsx, src/client/components/SetupCard.tsx, src/client/components/SetupImpactSelector.tsx, src/client/components/ShareModal.tsx, src/client/components/onboarding/OnboardingWelcome.tsx, src/client/components/onboarding/OnboardingHobbyPicker.tsx, src/client/components/onboarding/OnboardingItemBrowser.tsx, src/client/components/onboarding/OnboardingReview.tsx, src/client/components/onboarding/OnboardingDone.tsx, src/client/components/onboarding/OnboardingFlow.tsx, src/client/routes/settings.tsx, src/client/components/StatusBadge.tsx, src/client/components/ClassificationBadge.tsx, src/client/components/ExternalLinkDialog.tsx, src/client/components/CatalogSearchOverlay.tsx, src/client/components/AddToCollectionModal.tsx, src/client/components/AddToThreadModal.tsx, src/client/components/GlobalItemCard.tsx, src/client/components/GearImage.tsx, src/client/components/ImageUpload.tsx, src/client/components/DashboardCard.tsx, src/client/components/TotalsBar.tsx, src/client/components/ImpactDeltaBadge.tsx, src/client/routes/index.tsx, src/client/routes/login.tsx, src/client/routes/profile.tsx, src/client/components/UserMenu.tsx, src/client/components/ProfileSection.tsx, src/client/components/PublicSetupCard.tsx, src/client/components/ManualEntryForm.tsx, src/client/components/LinkToGlobalItem.tsx, src/client/components/SlideOutPanel.tsx, src/client/components/ItemPicker.tsx, src/client/components/ImageCropEditor.tsx, src/client/components/CategoryFilterDropdown.tsx</read_first>
|
||||
<behavior>
|
||||
- src/client/locales/en/common.json contains keys for: nav items, action buttons (save, cancel, delete, edit, create, back, close, search, confirm), empty states, error messages, loading states, auth prompts
|
||||
- src/client/locales/en/collection.json contains keys for: collection page, item cards, item forms, category picker, weight summary, planning view, totals bar
|
||||
- src/client/locales/en/threads.json contains keys for: thread list, thread detail, candidate cards, candidate form, comparison table, create thread modal, status badges
|
||||
- src/client/locales/en/setups.json contains keys for: setup list, setup detail, setup cards, impact preview, share modal
|
||||
- src/client/locales/en/onboarding.json contains keys for: welcome, hobby picker, item browser, review, done screens
|
||||
- src/client/locales/en/settings.json contains keys for: settings page, weight unit, currency, API keys, import/export
|
||||
</behavior>
|
||||
<action>
|
||||
Create directory `src/client/locales/en/`.
|
||||
|
||||
Read EVERY component listed in read_first. For each component, extract all hardcoded English strings (button text, headings, labels, descriptions, placeholder text, error messages, empty states, toast messages, modal titles/descriptions, confirmation dialogs) and add them to the appropriate namespace JSON file.
|
||||
|
||||
**String key convention:** Nested objects with dot notation access. Group by component/feature. Use camelCase for keys.
|
||||
|
||||
Example structure for `common.json`:
|
||||
```json
|
||||
{
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
"collection": "Collection",
|
||||
"setups": "Setups",
|
||||
"discover": "Discover",
|
||||
"settings": "Settings",
|
||||
"search": "Search"
|
||||
},
|
||||
"actions": {
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"create": "Create",
|
||||
"close": "Close",
|
||||
"back": "Back",
|
||||
"confirm": "Confirm",
|
||||
"tryAgain": "Try again",
|
||||
"dismiss": "Dismiss",
|
||||
"loading": "Loading...",
|
||||
"saving": "Saving...",
|
||||
"deleting": "Deleting..."
|
||||
},
|
||||
"errors": {
|
||||
"somethingWentWrong": "Something went wrong",
|
||||
"unexpectedError": "An unexpected error occurred"
|
||||
},
|
||||
"auth": {
|
||||
"signIn": "Sign in",
|
||||
"signInRequired": "Sign in to continue",
|
||||
"signInDescription": "Create an account or sign in to start tracking your gear"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**IMPORTANT:** Read every component file thoroughly. Do NOT guess strings — extract the actual English text from the JSX. Every user-visible string in the component should become a translation key.
|
||||
|
||||
For interpolation (dynamic values), use `{{variable}}` syntax. Example: if a component shows "3 items", the key would be `"itemCount": "{{count}} items"` or use pluralization `"itemCount_one": "{{count}} item"`, `"itemCount_other": "{{count}} items"`.
|
||||
|
||||
Do NOT translate: item names, category names created by users, thread titles, candidate names, setup names — these are user-generated content (D-03).
|
||||
</action>
|
||||
<acceptance_criteria>
|
||||
- src/client/locales/en/common.json exists and is valid JSON
|
||||
- src/client/locales/en/collection.json exists and is valid JSON
|
||||
- src/client/locales/en/threads.json exists and is valid JSON
|
||||
- src/client/locales/en/setups.json exists and is valid JSON
|
||||
- src/client/locales/en/onboarding.json exists and is valid JSON
|
||||
- src/client/locales/en/settings.json exists and is valid JSON
|
||||
- common.json contains "nav" key with at least "home", "collection", "setups"
|
||||
- common.json contains "actions" key with at least "save", "cancel", "delete"
|
||||
- settings.json contains keys for "weightUnit", "currency", "apiKeys", "importExport"
|
||||
- onboarding.json contains keys for all 5 onboarding steps (welcome, hobby, items, review, done)
|
||||
</acceptance_criteria>
|
||||
<verify>
|
||||
<automated>cd /home/jlmak/Projects/jlmak/GearBox && for f in common collection threads setups onboarding settings; do node -e "JSON.parse(require('fs').readFileSync('src/client/locales/en/$f.json','utf8')); console.log('$f.json: valid')"; done</automated>
|
||||
</verify>
|
||||
<done>All 6 English namespace JSON files created with strings extracted from every component</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Create i18n initialization module and wire into app entry point</name>
|
||||
<files>src/client/lib/i18n.ts, src/client/main.tsx</files>
|
||||
<read_first>src/client/main.tsx, src/client/locales/en/common.json</read_first>
|
||||
<behavior>
|
||||
- src/client/lib/i18n.ts initializes i18next with LanguageDetector and initReactI18next
|
||||
- Resources include all 6 namespaces for "en" locale
|
||||
- fallbackLng is "en"
|
||||
- defaultNS is "common"
|
||||
- interpolation.escapeValue is false (React handles XSS)
|
||||
- Detection order is ["localStorage", "navigator"]
|
||||
- Detection lookupLocalStorage is "gearbox-language"
|
||||
- Detection caches is ["localStorage"]
|
||||
- main.tsx imports i18n.ts before any React rendering (side-effect import)
|
||||
</behavior>
|
||||
<action>
|
||||
Create `src/client/lib/i18n.ts`:
|
||||
|
||||
```typescript
|
||||
import i18n from "i18next";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
|
||||
import enCommon from "../locales/en/common.json";
|
||||
import enCollection from "../locales/en/collection.json";
|
||||
import enThreads from "../locales/en/threads.json";
|
||||
import enSetups from "../locales/en/setups.json";
|
||||
import enOnboarding from "../locales/en/onboarding.json";
|
||||
import enSettings from "../locales/en/settings.json";
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources: {
|
||||
en: {
|
||||
common: enCommon,
|
||||
collection: enCollection,
|
||||
threads: enThreads,
|
||||
setups: enSetups,
|
||||
onboarding: enOnboarding,
|
||||
settings: enSettings,
|
||||
},
|
||||
},
|
||||
fallbackLng: "en",
|
||||
defaultNS: "common",
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
detection: {
|
||||
order: ["localStorage", "navigator"],
|
||||
lookupLocalStorage: "gearbox-language",
|
||||
caches: ["localStorage"],
|
||||
},
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
```
|
||||
|
||||
Update `src/client/main.tsx` — add `import "./lib/i18n";` as the FIRST import (before React, before QueryClient, before Router). This ensures i18next is initialized before any component tries to use `useTranslation()`. The import is a side-effect import — no named export needed.
|
||||
|
||||
The final import order in main.tsx should be:
|
||||
1. `import "./lib/i18n";` (side-effect — initializes i18next)
|
||||
2. Existing imports (QueryClient, Router, etc.)
|
||||
</action>
|
||||
<acceptance_criteria>
|
||||
- src/client/lib/i18n.ts exists
|
||||
- src/client/lib/i18n.ts contains `import { initReactI18next } from "react-i18next"`
|
||||
- src/client/lib/i18n.ts contains `fallbackLng: "en"`
|
||||
- src/client/lib/i18n.ts contains `defaultNS: "common"`
|
||||
- src/client/lib/i18n.ts contains `lookupLocalStorage: "gearbox-language"`
|
||||
- src/client/lib/i18n.ts imports all 6 en namespace JSON files
|
||||
- src/client/main.tsx first import line is `import "./lib/i18n"`
|
||||
- `bun run build` completes without errors
|
||||
</acceptance_criteria>
|
||||
<verify>
|
||||
<automated>cd /home/jlmak/Projects/jlmak/GearBox && grep -c "initReactI18next\|fallbackLng\|defaultNS\|LanguageDetector" src/client/lib/i18n.ts && head -3 src/client/main.tsx | grep -c "i18n"</automated>
|
||||
</verify>
|
||||
<done>i18n initialized with language detection, all English namespaces loaded, app entry point imports i18n first</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| localStorage→i18n | Language preference read from localStorage — treated as user preference, not security-sensitive |
|
||||
| navigator.language→i18n | Browser locale — untrusted but benign (only matched against known locales) |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-34-01 | Tampering | i18n.ts localStorage | accept | Language preference is non-sensitive. Tampered localStorage key only affects UI language, not data. Validated against known locale list via i18next supportedLngs. |
|
||||
| T-34-02 | Information Disclosure | locale JSON files | accept | Translation files contain only UI strings, no secrets. Bundled in client JS — intentionally public. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `bun install` completes without errors
|
||||
- All 6 en/*.json files are valid JSON
|
||||
- `bun run build` completes without errors
|
||||
- src/client/lib/i18n.ts initializes correctly with all namespaces
|
||||
- src/client/main.tsx imports i18n before rendering
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- i18next and react-i18next installed
|
||||
- All English translation strings extracted into 6 namespace JSON files
|
||||
- i18n initialization module created with language detection
|
||||
- App entry point wires i18n before React rendering
|
||||
- Build passes
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/34-i18n-foundation/34-01-SUMMARY.md`
|
||||
</output>
|
||||
447
.planning/phases/34-i18n-foundation/34-02-PLAN.md
Normal file
447
.planning/phases/34-i18n-foundation/34-02-PLAN.md
Normal file
@@ -0,0 +1,447 @@
|
||||
---
|
||||
phase: 34-i18n-foundation
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: [01]
|
||||
files_modified:
|
||||
- src/client/components/TopNav.tsx
|
||||
- src/client/components/BottomTabBar.tsx
|
||||
- src/client/components/FabMenu.tsx
|
||||
- src/client/components/ConfirmDialog.tsx
|
||||
- src/client/components/AuthPromptModal.tsx
|
||||
- src/client/components/ExternalLinkDialog.tsx
|
||||
- src/client/components/CatalogSearchOverlay.tsx
|
||||
- src/client/components/AddToCollectionModal.tsx
|
||||
- src/client/components/AddToThreadModal.tsx
|
||||
- src/client/components/UserMenu.tsx
|
||||
- src/client/components/CollectionView.tsx
|
||||
- src/client/components/ItemCard.tsx
|
||||
- src/client/components/ItemForm.tsx
|
||||
- src/client/components/CategoryPicker.tsx
|
||||
- src/client/components/CategoryHeader.tsx
|
||||
- src/client/components/WeightSummaryCard.tsx
|
||||
- src/client/components/PlanningView.tsx
|
||||
- src/client/components/TotalsBar.tsx
|
||||
- src/client/components/DashboardCard.tsx
|
||||
- src/client/components/ThreadCard.tsx
|
||||
- src/client/components/ThreadTabs.tsx
|
||||
- src/client/components/CandidateCard.tsx
|
||||
- src/client/components/CandidateForm.tsx
|
||||
- src/client/components/CandidateListItem.tsx
|
||||
- src/client/components/ComparisonTable.tsx
|
||||
- src/client/components/CreateThreadModal.tsx
|
||||
- src/client/components/StatusBadge.tsx
|
||||
- src/client/components/ClassificationBadge.tsx
|
||||
- src/client/components/SetupsView.tsx
|
||||
- src/client/components/SetupCard.tsx
|
||||
- src/client/components/SetupImpactSelector.tsx
|
||||
- src/client/components/ShareModal.tsx
|
||||
- src/client/components/ImpactDeltaBadge.tsx
|
||||
- src/client/components/ItemPicker.tsx
|
||||
- src/client/components/ImageUpload.tsx
|
||||
- src/client/components/GearImage.tsx
|
||||
- src/client/components/GlobalItemCard.tsx
|
||||
- src/client/components/ManualEntryForm.tsx
|
||||
- src/client/components/LinkToGlobalItem.tsx
|
||||
- src/client/components/ProfileSection.tsx
|
||||
- src/client/components/PublicSetupCard.tsx
|
||||
- src/client/routes/__root.tsx
|
||||
- src/client/routes/index.tsx
|
||||
- src/client/routes/login.tsx
|
||||
- src/client/routes/profile.tsx
|
||||
- src/client/routes/settings.tsx
|
||||
- src/client/routes/collection/index.tsx
|
||||
- src/client/routes/collection/gear.tsx
|
||||
- src/client/routes/items/$itemId.tsx
|
||||
- src/client/routes/threads/index.tsx
|
||||
- src/client/routes/threads/$threadId.tsx
|
||||
- src/client/routes/setups/$setupId.tsx
|
||||
- src/client/routes/global-items/index.tsx
|
||||
- src/client/routes/global-items/$itemId.tsx
|
||||
- src/client/routes/users/$userId.tsx
|
||||
autonomous: true
|
||||
requirements: [D-01, D-02, D-03]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Every component with hardcoded English strings uses useTranslation() hook"
|
||||
- "t() function calls reference keys that exist in the English locale JSON files from Plan 01"
|
||||
- "User-generated content (item names, category names, thread titles, setup names) is NOT wrapped in t()"
|
||||
- "All components import useTranslation from react-i18next"
|
||||
- "No hardcoded English strings remain in UI chrome elements (buttons, labels, headings, nav items, empty states, error messages, toasts)"
|
||||
artifacts:
|
||||
- path: "src/client/components/TopNav.tsx"
|
||||
provides: "Translated navigation"
|
||||
contains: "useTranslation"
|
||||
- path: "src/client/components/BottomTabBar.tsx"
|
||||
provides: "Translated tab labels"
|
||||
contains: "useTranslation"
|
||||
- path: "src/client/routes/settings.tsx"
|
||||
provides: "Translated settings page"
|
||||
contains: "useTranslation"
|
||||
key_links:
|
||||
- from: "src/client/components/TopNav.tsx"
|
||||
to: "src/client/locales/en/common.json"
|
||||
via: "useTranslation('common')"
|
||||
pattern: "t\\("
|
||||
---
|
||||
|
||||
<objective>
|
||||
Replace all hardcoded English strings in UI components with i18n t() calls.
|
||||
|
||||
Purpose: Complete string extraction — after this plan, all UI chrome text comes from translation files instead of hardcoded strings.
|
||||
Output: Every component uses useTranslation() hook, all strings reference keys from en/*.json.
|
||||
</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>
|
||||
useTranslation hook pattern:
|
||||
```typescript
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function MyComponent() {
|
||||
const { t } = useTranslation("common"); // or specific namespace
|
||||
return <button>{t("actions.save")}</button>;
|
||||
}
|
||||
```
|
||||
|
||||
For multiple namespaces in one component:
|
||||
```typescript
|
||||
const { t } = useTranslation(["common", "collection"]);
|
||||
// Access: t("common:actions.save"), t("collection:itemCard.title")
|
||||
// Or with default namespace: t("actions.save") uses first in array
|
||||
```
|
||||
|
||||
For interpolation:
|
||||
```typescript
|
||||
t("items.count", { count: 5 }) // "5 items"
|
||||
t("items.count_one", { count: 1 }) // "1 item" (plural)
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Extract strings from navigation and global UI components</name>
|
||||
<files>src/client/components/TopNav.tsx, src/client/components/BottomTabBar.tsx, src/client/components/FabMenu.tsx, src/client/components/UserMenu.tsx, src/client/routes/__root.tsx</files>
|
||||
<read_first>src/client/components/TopNav.tsx, src/client/components/BottomTabBar.tsx, src/client/components/FabMenu.tsx, src/client/components/UserMenu.tsx, src/client/routes/__root.tsx, src/client/locales/en/common.json</read_first>
|
||||
<behavior>
|
||||
- TopNav.tsx uses t() for "Collection", "Setups", "Discover" nav labels and search placeholder
|
||||
- BottomTabBar.tsx uses t() for "Home", "Collection", "Search", "Setups" tab labels
|
||||
- FabMenu.tsx uses t() for all menu item labels
|
||||
- UserMenu.tsx uses t() for menu items like "Settings", "Sign out", profile-related labels
|
||||
- __root.tsx uses t() for "Something went wrong", "Try again", "Delete Candidate", "Pick Winner" dialog text
|
||||
- All components import { useTranslation } from "react-i18next"
|
||||
</behavior>
|
||||
<action>
|
||||
For each component listed in files, add `import { useTranslation } from "react-i18next"` and destructure `const { t } = useTranslation("common")` at the top of the component function body (or use the appropriate namespace).
|
||||
|
||||
**TopNav.tsx:**
|
||||
- Replace "Collection" with `t("nav.collection")`
|
||||
- Replace "Setups" with `t("nav.setups")`
|
||||
- Replace "Discover" with `t("nav.discover")`
|
||||
- Replace search input placeholder with `t("nav.searchPlaceholder")`
|
||||
- Replace "GearBox" brand text — leave as-is (brand name, not translatable)
|
||||
|
||||
**BottomTabBar.tsx:**
|
||||
- Replace "Home" with `t("nav.home")`
|
||||
- Replace "Collection" with `t("nav.collection")`
|
||||
- Replace "Search" with `t("nav.search")`
|
||||
- Replace "Setups" with `t("nav.setups")`
|
||||
|
||||
**FabMenu.tsx:**
|
||||
- Replace all menu item labels with `t("fab.addItem")`, `t("fab.newThread")`, `t("fab.newSetup")` etc. (read the component to find exact labels)
|
||||
|
||||
**UserMenu.tsx:**
|
||||
- Replace "Settings" with `t("nav.settings")`
|
||||
- Replace "Sign out" / "Log out" with `t("auth.signOut")`
|
||||
- Replace other menu text with appropriate t() keys
|
||||
|
||||
**__root.tsx:**
|
||||
- Replace "Something went wrong" with `t("errors.somethingWentWrong")`
|
||||
- Replace "Try again" with `t("actions.tryAgain")`
|
||||
- Replace "Delete Candidate" dialog title/text with `t("common:actions.deleteCandidate")` etc.
|
||||
- Replace "Pick Winner" dialog with `t("threads:resolve.title")` etc.
|
||||
- Replace "Cancel" buttons with `t("actions.cancel")`
|
||||
- Replace "Delete" buttons with `t("actions.delete")`
|
||||
|
||||
**IMPORTANT:** Do NOT wrap user-generated content (candidateName, thread title, etc.) in t() — only UI chrome.
|
||||
|
||||
If any new keys are needed that were not included in Plan 01's locale files, add them to the appropriate en/*.json file as part of this task.
|
||||
</action>
|
||||
<acceptance_criteria>
|
||||
- TopNav.tsx contains `useTranslation` import and `t(` calls
|
||||
- BottomTabBar.tsx contains `useTranslation` import and `t(` calls for all tab labels
|
||||
- FabMenu.tsx contains `useTranslation` import and `t(` calls
|
||||
- UserMenu.tsx contains `useTranslation` import and `t(` calls
|
||||
- __root.tsx contains `useTranslation` import and `t(` calls for dialog text
|
||||
- No hardcoded English nav/tab labels remain in these 5 files
|
||||
- `bun run build` succeeds
|
||||
</acceptance_criteria>
|
||||
<verify>
|
||||
<automated>cd /home/jlmak/Projects/jlmak/GearBox && for f in TopNav BottomTabBar FabMenu UserMenu; do grep -c "useTranslation" src/client/components/$f.tsx; done && grep -c "useTranslation" src/client/routes/__root.tsx</automated>
|
||||
</verify>
|
||||
<done>Navigation and global UI components fully internationalized</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Extract strings from collection and item components</name>
|
||||
<files>src/client/components/CollectionView.tsx, src/client/components/ItemCard.tsx, src/client/components/ItemForm.tsx, src/client/components/CategoryPicker.tsx, src/client/components/CategoryHeader.tsx, src/client/components/WeightSummaryCard.tsx, src/client/components/PlanningView.tsx, src/client/components/TotalsBar.tsx, src/client/components/DashboardCard.tsx, src/client/components/ClassificationBadge.tsx, src/client/components/ImageUpload.tsx, src/client/components/GearImage.tsx, src/client/components/GlobalItemCard.tsx, src/client/components/ManualEntryForm.tsx, src/client/components/LinkToGlobalItem.tsx, src/client/components/CategoryFilterDropdown.tsx, src/client/components/ItemPicker.tsx, src/client/components/ProfileSection.tsx</files>
|
||||
<read_first>src/client/components/CollectionView.tsx, src/client/components/ItemCard.tsx, src/client/components/ItemForm.tsx, src/client/components/CategoryPicker.tsx, src/client/components/CategoryHeader.tsx, src/client/components/WeightSummaryCard.tsx, src/client/components/PlanningView.tsx, src/client/components/TotalsBar.tsx, src/client/components/DashboardCard.tsx, src/client/components/ClassificationBadge.tsx, src/client/components/ImageUpload.tsx, src/client/components/GlobalItemCard.tsx, src/client/components/ManualEntryForm.tsx, src/client/components/LinkToGlobalItem.tsx, src/client/components/CategoryFilterDropdown.tsx, src/client/components/ItemPicker.tsx, src/client/components/ProfileSection.tsx, src/client/locales/en/collection.json, src/client/locales/en/common.json</read_first>
|
||||
<behavior>
|
||||
- All listed components import { useTranslation } from "react-i18next"
|
||||
- Each component uses const { t } = useTranslation("collection") (or "common" for shared strings)
|
||||
- Headings like "Your Collection", "Items", "Weight Summary" use t() calls
|
||||
- Form labels like "Name", "Brand", "Model", "Weight", "Price", "Notes" use t() calls
|
||||
- Empty states like "No items yet" use t() calls
|
||||
- Action buttons already covered by common namespace t() calls
|
||||
- Weight classification labels ("Ultralight", "Light", "Medium", "Heavy") use t() calls
|
||||
- User-generated content (item names, category names) is NOT wrapped in t()
|
||||
</behavior>
|
||||
<action>
|
||||
For each component in the files list:
|
||||
1. Add `import { useTranslation } from "react-i18next"`
|
||||
2. Add `const { t } = useTranslation("collection")` (or `["collection", "common"]` for components that need both namespaces)
|
||||
3. Replace every hardcoded English string with the corresponding `t()` call
|
||||
|
||||
**Key mappings (use namespace-prefixed keys when mixing namespaces):**
|
||||
|
||||
Collection components use `collection` namespace:
|
||||
- Headings: `t("title")`, `t("gear")`, `t("planning")`
|
||||
- Empty states: `t("empty.noItems")`, `t("empty.noCategories")`
|
||||
- Item form labels: `t("form.name")`, `t("form.brand")`, `t("form.model")`, `t("form.weight")`, `t("form.price")`, `t("form.notes")`, `t("form.category")`
|
||||
- Item form placeholders
|
||||
- Weight summary labels
|
||||
- Classification badges: `t("classification.ultralight")`, etc.
|
||||
- Totals: `t("totals.totalWeight")`, `t("totals.totalPrice")`, `t("totals.itemCount")`
|
||||
|
||||
Common-namespace strings (buttons, actions) accessed via `t("common:actions.save")` or by passing array `["collection", "common"]`.
|
||||
|
||||
**For components that only use common strings** (like ImageUpload, GearImage): use `useTranslation("common")`.
|
||||
|
||||
**If a component has no translatable strings** (purely renders user data with no UI chrome), skip it — do NOT add unnecessary imports.
|
||||
|
||||
Add any new keys needed to `src/client/locales/en/collection.json` and `src/client/locales/en/common.json`.
|
||||
</action>
|
||||
<acceptance_criteria>
|
||||
- CollectionView.tsx contains useTranslation import and t() calls
|
||||
- ItemForm.tsx uses t() for all form labels (name, brand, model, weight, price, notes)
|
||||
- CategoryPicker.tsx uses t() for search placeholder and "Create category" text
|
||||
- WeightSummaryCard.tsx uses t() for summary labels
|
||||
- ClassificationBadge.tsx uses t() for classification names
|
||||
- No hardcoded English strings remain in UI chrome of these components
|
||||
- `bun run build` succeeds
|
||||
</acceptance_criteria>
|
||||
<verify>
|
||||
<automated>cd /home/jlmak/Projects/jlmak/GearBox && for f in CollectionView ItemCard ItemForm CategoryPicker WeightSummaryCard; do echo -n "$f: "; grep -c "useTranslation" src/client/components/$f.tsx; done</automated>
|
||||
</verify>
|
||||
<done>Collection and item components fully internationalized</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Extract strings from thread and candidate components</name>
|
||||
<files>src/client/components/ThreadCard.tsx, src/client/components/ThreadTabs.tsx, src/client/components/CandidateCard.tsx, src/client/components/CandidateForm.tsx, src/client/components/CandidateListItem.tsx, src/client/components/ComparisonTable.tsx, src/client/components/CreateThreadModal.tsx, src/client/components/StatusBadge.tsx, src/client/components/AddToThreadModal.tsx</files>
|
||||
<read_first>src/client/components/ThreadCard.tsx, src/client/components/ThreadTabs.tsx, src/client/components/CandidateCard.tsx, src/client/components/CandidateForm.tsx, src/client/components/CandidateListItem.tsx, src/client/components/ComparisonTable.tsx, src/client/components/CreateThreadModal.tsx, src/client/components/StatusBadge.tsx, src/client/components/AddToThreadModal.tsx, src/client/locales/en/threads.json</read_first>
|
||||
<behavior>
|
||||
- All listed components import useTranslation from react-i18next
|
||||
- Thread components use "threads" namespace
|
||||
- Status labels ("Active", "Resolved", "Archived") use t() calls
|
||||
- Thread creation modal labels use t() calls
|
||||
- Candidate form labels use t() calls
|
||||
- Comparison table headers use t() calls
|
||||
- Thread/candidate names are NOT wrapped in t() (user-generated content)
|
||||
</behavior>
|
||||
<action>
|
||||
For each component:
|
||||
1. Add `import { useTranslation } from "react-i18next"`
|
||||
2. Add `const { t } = useTranslation("threads")` (or `["threads", "common"]`)
|
||||
3. Replace all hardcoded English UI chrome strings with t() calls
|
||||
|
||||
**Key mappings for threads namespace:**
|
||||
- Status: `t("status.active")`, `t("status.resolved")`, `t("status.archived")`
|
||||
- Create modal: `t("create.title")`, `t("create.namePlaceholder")`, `t("create.description")`
|
||||
- Candidate form: `t("candidate.name")`, `t("candidate.price")`, `t("candidate.weight")`, `t("candidate.url")`, `t("candidate.pros")`, `t("candidate.cons")`, `t("candidate.notes")`
|
||||
- Comparison headers: `t("comparison.weight")`, `t("comparison.price")`, `t("comparison.pros")`, `t("comparison.cons")`
|
||||
- Actions: use common namespace for buttons
|
||||
|
||||
Add any new keys to `src/client/locales/en/threads.json`.
|
||||
</action>
|
||||
<acceptance_criteria>
|
||||
- ThreadCard.tsx contains useTranslation import and t() calls
|
||||
- CandidateForm.tsx uses t() for all form labels
|
||||
- ComparisonTable.tsx uses t() for column headers
|
||||
- StatusBadge.tsx uses t() for status labels
|
||||
- CreateThreadModal.tsx uses t() for modal title and form labels
|
||||
- No hardcoded English status labels remain
|
||||
- `bun run build` succeeds
|
||||
</acceptance_criteria>
|
||||
<verify>
|
||||
<automated>cd /home/jlmak/Projects/jlmak/GearBox && for f in ThreadCard CandidateForm ComparisonTable StatusBadge CreateThreadModal; do echo -n "$f: "; grep -c "useTranslation" src/client/components/$f.tsx; done</automated>
|
||||
</verify>
|
||||
<done>Thread and candidate components fully internationalized</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: Extract strings from setup, modal, and route components</name>
|
||||
<files>src/client/components/SetupsView.tsx, src/client/components/SetupCard.tsx, src/client/components/SetupImpactSelector.tsx, src/client/components/ShareModal.tsx, src/client/components/PublicSetupCard.tsx, src/client/components/ImpactDeltaBadge.tsx, src/client/components/ConfirmDialog.tsx, src/client/components/ExternalLinkDialog.tsx, src/client/components/AddToCollectionModal.tsx, src/client/components/SlideOutPanel.tsx, src/client/routes/index.tsx, src/client/routes/login.tsx, src/client/routes/profile.tsx, src/client/routes/collection/index.tsx, src/client/routes/collection/gear.tsx, src/client/routes/items/$itemId.tsx, src/client/routes/threads/index.tsx, src/client/routes/threads/$threadId.tsx, src/client/routes/setups/$setupId.tsx, src/client/routes/global-items/index.tsx, src/client/routes/global-items/$itemId.tsx, src/client/routes/users/$userId.tsx</files>
|
||||
<read_first>src/client/components/SetupsView.tsx, src/client/components/SetupCard.tsx, src/client/components/SetupImpactSelector.tsx, src/client/components/ShareModal.tsx, src/client/components/PublicSetupCard.tsx, src/client/components/ConfirmDialog.tsx, src/client/components/ExternalLinkDialog.tsx, src/client/components/AddToCollectionModal.tsx, src/client/routes/index.tsx, src/client/routes/login.tsx, src/client/routes/profile.tsx, src/client/routes/collection/index.tsx, src/client/routes/items/$itemId.tsx, src/client/routes/threads/index.tsx, src/client/routes/threads/$threadId.tsx, src/client/routes/setups/$setupId.tsx, src/client/routes/global-items/index.tsx, src/client/routes/global-items/$itemId.tsx, src/client/routes/users/$userId.tsx, src/client/locales/en/setups.json, src/client/locales/en/common.json</read_first>
|
||||
<behavior>
|
||||
- Setup components use "setups" namespace for setup-specific strings
|
||||
- Modal/dialog components use "common" namespace
|
||||
- Route pages use their respective namespace (collection routes use "collection", thread routes use "threads", etc.)
|
||||
- Landing page (index.tsx) strings use "common" namespace
|
||||
- Login page strings use "common" namespace
|
||||
- All user-generated content (setup names, thread titles, user names) is NOT wrapped in t()
|
||||
</behavior>
|
||||
<action>
|
||||
For each component/route:
|
||||
1. Add `import { useTranslation } from "react-i18next"`
|
||||
2. Add `const { t } = useTranslation(...)` with appropriate namespace
|
||||
3. Replace all hardcoded English UI chrome strings with t() calls
|
||||
|
||||
**Setup namespace keys:**
|
||||
- `t("title")`, `t("create")`, `t("empty")`, `t("card.items")`, `t("card.weight")`, `t("card.price")`
|
||||
- Share: `t("share.title")`, `t("share.copyLink")`, `t("share.copied")`
|
||||
- Impact: `t("impact.title")`, `t("impact.adding")`, `t("impact.removing")`
|
||||
|
||||
**Common namespace for modals/dialogs:**
|
||||
- ConfirmDialog: `t("confirm.title")`, `t("confirm.message")`
|
||||
- ExternalLinkDialog: `t("externalLink.title")`, `t("externalLink.message")`
|
||||
- AddToCollectionModal: `t("addToCollection.title")`
|
||||
|
||||
**Route pages:** Use the matching namespace. Page-level headings and descriptions get t() calls. Links back ("Back") use `t("common:actions.back")`.
|
||||
|
||||
Add any new keys to the appropriate en/*.json files.
|
||||
</action>
|
||||
<acceptance_criteria>
|
||||
- SetupsView.tsx contains useTranslation import
|
||||
- SetupCard.tsx uses t() for card labels
|
||||
- ShareModal.tsx uses t() for share dialog text
|
||||
- ConfirmDialog.tsx uses t() for confirmation dialog text
|
||||
- Login page (routes/login.tsx) uses t() for login page text
|
||||
- Landing page (routes/index.tsx) uses t() for discovery page text
|
||||
- Route pages use appropriate namespaces
|
||||
- `bun run build` succeeds
|
||||
</acceptance_criteria>
|
||||
<verify>
|
||||
<automated>cd /home/jlmak/Projects/jlmak/GearBox && grep -rl "useTranslation" src/client/components/ src/client/routes/ | wc -l</automated>
|
||||
</verify>
|
||||
<done>Setups, modals, dialogs, and all route pages fully internationalized</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 5: Extract strings from onboarding and settings components</name>
|
||||
<files>src/client/components/onboarding/OnboardingWelcome.tsx, src/client/components/onboarding/OnboardingHobbyPicker.tsx, src/client/components/onboarding/OnboardingItemBrowser.tsx, src/client/components/onboarding/OnboardingReview.tsx, src/client/components/onboarding/OnboardingDone.tsx, src/client/components/onboarding/OnboardingFlow.tsx, src/client/components/onboarding/StepIndicator.tsx, src/client/components/onboarding/HobbyCard.tsx, src/client/components/onboarding/SelectableItemCard.tsx, src/client/routes/settings.tsx</files>
|
||||
<read_first>src/client/components/onboarding/OnboardingWelcome.tsx, src/client/components/onboarding/OnboardingHobbyPicker.tsx, src/client/components/onboarding/OnboardingItemBrowser.tsx, src/client/components/onboarding/OnboardingReview.tsx, src/client/components/onboarding/OnboardingDone.tsx, src/client/components/onboarding/OnboardingFlow.tsx, src/client/components/onboarding/StepIndicator.tsx, src/client/components/onboarding/HobbyCard.tsx, src/client/components/onboarding/SelectableItemCard.tsx, src/client/routes/settings.tsx, src/client/locales/en/onboarding.json, src/client/locales/en/settings.json</read_first>
|
||||
<behavior>
|
||||
- Onboarding components use "onboarding" namespace
|
||||
- Welcome screen: title, subtitle, CTA button text use t()
|
||||
- Hobby picker: heading, description use t() (hobby names MAY be translatable if they are system-defined)
|
||||
- Item browser: heading, description, search placeholder use t()
|
||||
- Review: heading, description use t()
|
||||
- Done: heading, description, CTA button use t()
|
||||
- Settings page uses "settings" namespace
|
||||
- Settings labels (Weight Unit, Currency, API Keys, Import/Export) use t()
|
||||
- Settings descriptions use t()
|
||||
</behavior>
|
||||
<action>
|
||||
For each onboarding component:
|
||||
1. Add `import { useTranslation } from "react-i18next"`
|
||||
2. Add `const { t } = useTranslation("onboarding")`
|
||||
3. Replace all hardcoded strings with t() calls
|
||||
|
||||
**Onboarding namespace keys:**
|
||||
- Welcome: `t("welcome.title")`, `t("welcome.subtitle")`, `t("welcome.cta")`
|
||||
- HobbyPicker: `t("hobby.title")`, `t("hobby.subtitle")`, `t("hobby.next")`
|
||||
- ItemBrowser: `t("items.title")`, `t("items.subtitle")`, `t("items.searchPlaceholder")`, `t("items.next")`
|
||||
- Review: `t("review.title")`, `t("review.subtitle")`
|
||||
- Done: `t("done.title")`, `t("done.subtitle")`, `t("done.cta")`
|
||||
- Step indicators: `t("step.of", { current: 1, total: 5 })`
|
||||
|
||||
For settings.tsx:
|
||||
1. Add `import { useTranslation } from "react-i18next"`
|
||||
2. Add `const { t } = useTranslation("settings")`
|
||||
3. Replace settings labels:
|
||||
- "Settings" heading: `t("title")`
|
||||
- "Back": use `t("common:actions.back")`
|
||||
- "Weight Unit" label: `t("weightUnit.title")`
|
||||
- "Choose the unit used to display weights across the app": `t("weightUnit.description")`
|
||||
- "Currency" label: `t("currency.title")`
|
||||
- "Changes the currency symbol displayed. This does not convert values.": `t("currency.description")`
|
||||
- "API Keys" heading: `t("apiKeys.title")`
|
||||
- "API keys allow programmatic access...": `t("apiKeys.description")`
|
||||
- "Copy this key now — it won't be shown again:": `t("apiKeys.copyWarning")`
|
||||
- "Dismiss": `t("common:actions.dismiss")`
|
||||
- "Key name (e.g., claude-desktop)": `t("apiKeys.namePlaceholder")`
|
||||
- "Create": `t("common:actions.create")`
|
||||
- "Revoke": `t("apiKeys.revoke")`
|
||||
- "Import / Export" heading: `t("importExport.title")`
|
||||
- "Export your gear collection as a CSV...": `t("importExport.description")`
|
||||
- "Export CSV": `t("importExport.export")`
|
||||
- "Import CSV": `t("importExport.import")`
|
||||
- "Importing...": `t("importExport.importing")`
|
||||
- Import result messages
|
||||
|
||||
Add any new keys to the appropriate en/*.json files.
|
||||
</action>
|
||||
<acceptance_criteria>
|
||||
- All 9 onboarding component files contain useTranslation import
|
||||
- OnboardingWelcome.tsx uses t() for title, subtitle, and CTA
|
||||
- OnboardingDone.tsx uses t() for done screen text
|
||||
- settings.tsx uses t() for all section headings, labels, descriptions
|
||||
- settings.tsx "Weight Unit" label uses `t("weightUnit.title")`
|
||||
- settings.tsx "API Keys" section uses `t("apiKeys.title")`
|
||||
- No hardcoded English strings remain in onboarding or settings UI chrome
|
||||
- `bun run build` succeeds
|
||||
</acceptance_criteria>
|
||||
<verify>
|
||||
<automated>cd /home/jlmak/Projects/jlmak/GearBox && grep -c "useTranslation" src/client/routes/settings.tsx && for f in OnboardingWelcome OnboardingHobbyPicker OnboardingItemBrowser OnboardingReview OnboardingDone; do echo -n "$f: "; grep -c "useTranslation" src/client/components/onboarding/$f.tsx; done</automated>
|
||||
</verify>
|
||||
<done>Onboarding flow and settings page fully internationalized</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| translation files→DOM | Translation strings rendered in JSX — React escapes by default |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-34-03 | Injection | t() output in JSX | accept | i18next interpolation escapeValue is false BUT React's JSX escaping prevents XSS. Translation strings are bundled static content, not user input. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `bun run build` succeeds
|
||||
- grep -rl "useTranslation" finds matches in all major component and route files
|
||||
- No hardcoded English UI chrome strings remain in extracted components
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- All UI components use useTranslation() hook
|
||||
- All hardcoded English strings replaced with t() calls
|
||||
- User-generated content is NOT wrapped in t()
|
||||
- Build passes
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/34-i18n-foundation/34-02-SUMMARY.md`
|
||||
</output>
|
||||
467
.planning/phases/34-i18n-foundation/34-03-PLAN.md
Normal file
467
.planning/phases/34-i18n-foundation/34-03-PLAN.md
Normal file
@@ -0,0 +1,467 @@
|
||||
---
|
||||
phase: 34-i18n-foundation
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: [01, 02]
|
||||
files_modified:
|
||||
- src/client/lib/formatters.ts
|
||||
- src/client/hooks/useFormatters.ts
|
||||
- src/client/hooks/useLanguage.ts
|
||||
- tests/formatters.test.ts
|
||||
autonomous: true
|
||||
requirements: [D-04, D-09, D-10]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "formatPrice() uses Intl.NumberFormat with locale parameter for locale-aware currency display"
|
||||
- "formatWeight() uses locale parameter for locale-aware number formatting"
|
||||
- "useFormatters() hook returns locale-aware weight and price formatters"
|
||||
- "useLanguage() hook reads language from settings and returns the current locale string"
|
||||
- "German locale formats prices as '1.234,56 EUR' not '$1,234.56'"
|
||||
- "English locale formats prices as '$1,234.56' not '1.234,56 EUR'"
|
||||
artifacts:
|
||||
- path: "src/client/lib/formatters.ts"
|
||||
provides: "Locale-aware formatWeight and formatPrice functions"
|
||||
contains: "Intl.NumberFormat"
|
||||
- path: "src/client/hooks/useLanguage.ts"
|
||||
provides: "Language preference hook"
|
||||
exports: ["useLanguage"]
|
||||
- path: "src/client/hooks/useFormatters.ts"
|
||||
provides: "Extended formatters with locale"
|
||||
contains: "useLanguage"
|
||||
- path: "tests/formatters.test.ts"
|
||||
provides: "Tests for locale-aware formatting"
|
||||
min_lines: 30
|
||||
key_links:
|
||||
- from: "src/client/hooks/useFormatters.ts"
|
||||
to: "src/client/hooks/useLanguage.ts"
|
||||
via: "useLanguage() import"
|
||||
pattern: "useLanguage"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Make weight and price formatting locale-aware and create the useLanguage() hook.
|
||||
|
||||
Purpose: Formatting integration — numbers, currencies, and weights display according to the user's locale (e.g., German: "1.234,56 EUR" vs English: "$1,234.56").
|
||||
Output: Locale-aware formatters, useLanguage hook, formatter tests.
|
||||
</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 formatters.ts:
|
||||
```typescript
|
||||
export type WeightUnit = "g" | "oz" | "lb" | "kg";
|
||||
export function formatWeight(grams: number | null | undefined, unit: WeightUnit = "g"): string { ... }
|
||||
export type Currency = "USD" | "EUR" | "GBP" | "JPY" | "CAD" | "AUD";
|
||||
export function formatPrice(cents: number | null | undefined, currency: Currency = "USD"): string { ... }
|
||||
```
|
||||
|
||||
Current useFormatters.ts:
|
||||
```typescript
|
||||
export function useFormatters() {
|
||||
const unit = useWeightUnit();
|
||||
const currency = useCurrency();
|
||||
return {
|
||||
weight: (grams: number | null) => formatWeight(grams, unit),
|
||||
price: (cents: number | null) => formatPrice(cents, currency),
|
||||
unit,
|
||||
currency,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Current useWeightUnit.ts pattern:
|
||||
```typescript
|
||||
export function useWeightUnit(): WeightUnit {
|
||||
const { data } = useSetting("weightUnit");
|
||||
if (data && VALID_UNITS.includes(data as WeightUnit)) return data as WeightUnit;
|
||||
return "g";
|
||||
}
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Create useLanguage hook</name>
|
||||
<files>src/client/hooks/useLanguage.ts</files>
|
||||
<read_first>src/client/hooks/useWeightUnit.ts, src/client/hooks/useCurrency.ts, src/client/hooks/useSettings.ts</read_first>
|
||||
<behavior>
|
||||
- useLanguage() reads from useSetting("language")
|
||||
- Returns "en" when setting is null, undefined, or invalid
|
||||
- Returns "de" when setting value is "de"
|
||||
- Validates against VALID_LANGUAGES array ["en", "de"]
|
||||
- Exports VALID_LANGUAGES array
|
||||
</behavior>
|
||||
<action>
|
||||
Create `src/client/hooks/useLanguage.ts`:
|
||||
|
||||
```typescript
|
||||
import { useSetting } from "./useSettings";
|
||||
|
||||
export const VALID_LANGUAGES = ["en", "de"] as const;
|
||||
export type Language = (typeof VALID_LANGUAGES)[number];
|
||||
|
||||
export function useLanguage(): Language {
|
||||
const { data } = useSetting("language");
|
||||
if (data && VALID_LANGUAGES.includes(data as Language)) {
|
||||
return data as Language;
|
||||
}
|
||||
return "en";
|
||||
}
|
||||
```
|
||||
|
||||
This follows the exact same pattern as `useWeightUnit()` and `useCurrency()` per established project conventions.
|
||||
</action>
|
||||
<acceptance_criteria>
|
||||
- src/client/hooks/useLanguage.ts exists
|
||||
- File exports useLanguage function
|
||||
- File exports VALID_LANGUAGES array containing "en" and "de"
|
||||
- useLanguage returns "en" as default fallback
|
||||
- Pattern matches useWeightUnit (useSetting, validation, default)
|
||||
</acceptance_criteria>
|
||||
<verify>
|
||||
<automated>cd /home/jlmak/Projects/jlmak/GearBox && grep -c "useLanguage\|VALID_LANGUAGES\|useSetting" src/client/hooks/useLanguage.ts</automated>
|
||||
</verify>
|
||||
<done>useLanguage hook created following established settings hook pattern</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Make formatPrice locale-aware using Intl.NumberFormat</name>
|
||||
<files>src/client/lib/formatters.ts</files>
|
||||
<read_first>src/client/lib/formatters.ts</read_first>
|
||||
<behavior>
|
||||
- formatPrice gains a third parameter: locale (string, defaults to "en")
|
||||
- formatPrice uses new Intl.NumberFormat(locale, { style: "currency", currency }) instead of manual symbol lookup
|
||||
- formatPrice("en", "USD", 123456) returns "$1,234.56"
|
||||
- formatPrice("de", "EUR", 123456) returns "1.234,56 €"
|
||||
- formatPrice("en", "JPY", 10000) returns "¥100" (no decimals)
|
||||
- formatPrice(null) still returns "--"
|
||||
- CURRENCY_SYMBOLS constant can be removed (Intl handles symbols)
|
||||
</behavior>
|
||||
<action>
|
||||
Update `src/client/lib/formatters.ts`:
|
||||
|
||||
Replace the `formatPrice` function with:
|
||||
|
||||
```typescript
|
||||
export function formatPrice(
|
||||
cents: number | null | undefined,
|
||||
currency: Currency = "USD",
|
||||
locale = "en",
|
||||
): string {
|
||||
if (cents == null) return "--";
|
||||
return new Intl.NumberFormat(locale, {
|
||||
style: "currency",
|
||||
currency,
|
||||
minimumFractionDigits: currency === "JPY" ? 0 : 2,
|
||||
maximumFractionDigits: currency === "JPY" ? 0 : 2,
|
||||
}).format(cents / 100);
|
||||
}
|
||||
```
|
||||
|
||||
Remove the `CURRENCY_SYMBOLS` constant and its `Record<Currency, string>` type — they are replaced by `Intl.NumberFormat`.
|
||||
|
||||
Keep the `Currency` type export and the existing values ("USD", "EUR", "GBP", "JPY", "CAD", "AUD").
|
||||
|
||||
**NOTE:** The `locale` parameter defaults to `"en"` so existing callers that don't pass locale continue to work (backward compatible).
|
||||
</action>
|
||||
<acceptance_criteria>
|
||||
- formatPrice function signature has 3 parameters: cents, currency, locale
|
||||
- formatPrice contains `new Intl.NumberFormat(locale`
|
||||
- CURRENCY_SYMBOLS constant is removed from the file
|
||||
- formatPrice(null) returns "--"
|
||||
- formatPrice(12345, "USD", "en") produces "$123.45"
|
||||
- formatPrice(12345, "EUR", "de") produces a string containing "123,45" and "€"
|
||||
</acceptance_criteria>
|
||||
<verify>
|
||||
<automated>cd /home/jlmak/Projects/jlmak/GearBox && grep -c "Intl.NumberFormat" src/client/lib/formatters.ts && grep -c "CURRENCY_SYMBOLS" src/client/lib/formatters.ts</automated>
|
||||
</verify>
|
||||
<done>formatPrice uses Intl.NumberFormat for locale-aware currency formatting</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Make formatWeight locale-aware</name>
|
||||
<files>src/client/lib/formatters.ts</files>
|
||||
<read_first>src/client/lib/formatters.ts</read_first>
|
||||
<behavior>
|
||||
- formatWeight gains a third parameter: locale (string, defaults to "en")
|
||||
- formatWeight uses Intl.NumberFormat for the number part, then appends the unit suffix
|
||||
- formatWeight(1234, "g", "en") returns "1,234g" (with thousands separator)
|
||||
- formatWeight(1234, "g", "de") returns "1.234g" (German thousands separator is period)
|
||||
- formatWeight(null) still returns "--"
|
||||
- Unit suffixes remain as-is (g, oz, lb, kg are universal abbreviations)
|
||||
</behavior>
|
||||
<action>
|
||||
Update `formatWeight` in `src/client/lib/formatters.ts`:
|
||||
|
||||
```typescript
|
||||
export function formatWeight(
|
||||
grams: number | null | undefined,
|
||||
unit: WeightUnit = "g",
|
||||
locale = "en",
|
||||
): string {
|
||||
if (grams == null) return "--";
|
||||
let value: number;
|
||||
let fractionDigits: number;
|
||||
switch (unit) {
|
||||
case "g":
|
||||
value = Math.round(grams);
|
||||
fractionDigits = 0;
|
||||
break;
|
||||
case "oz":
|
||||
value = grams / GRAMS_PER_OZ;
|
||||
fractionDigits = 1;
|
||||
break;
|
||||
case "lb":
|
||||
value = grams / GRAMS_PER_LB;
|
||||
fractionDigits = 2;
|
||||
break;
|
||||
case "kg":
|
||||
value = grams / GRAMS_PER_KG;
|
||||
fractionDigits = 2;
|
||||
break;
|
||||
}
|
||||
const formatted = new Intl.NumberFormat(locale, {
|
||||
minimumFractionDigits: fractionDigits,
|
||||
maximumFractionDigits: fractionDigits,
|
||||
}).format(value);
|
||||
return unit === "g" ? `${formatted}g` : `${formatted} ${unit}`;
|
||||
}
|
||||
```
|
||||
|
||||
This preserves the existing behavior (unit conversion math, decimal places per unit) but adds locale-aware number formatting (thousands separators, decimal separators).
|
||||
</action>
|
||||
<acceptance_criteria>
|
||||
- formatWeight function signature has 3 parameters: grams, unit, locale
|
||||
- formatWeight contains Intl.NumberFormat usage
|
||||
- formatWeight(null) returns "--"
|
||||
- formatWeight(1234, "g", "en") produces a string ending with "g"
|
||||
- formatWeight(1234.5, "kg", "de") uses comma as decimal separator
|
||||
</acceptance_criteria>
|
||||
<verify>
|
||||
<automated>cd /home/jlmak/Projects/jlmak/GearBox && grep -c "Intl.NumberFormat" src/client/lib/formatters.ts</automated>
|
||||
</verify>
|
||||
<done>formatWeight uses Intl.NumberFormat for locale-aware number display</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 4: Update useFormatters hook to pass locale</name>
|
||||
<files>src/client/hooks/useFormatters.ts</files>
|
||||
<read_first>src/client/hooks/useFormatters.ts, src/client/hooks/useLanguage.ts, src/client/lib/formatters.ts</read_first>
|
||||
<behavior>
|
||||
- useFormatters imports useLanguage
|
||||
- useFormatters calls useLanguage() to get current locale
|
||||
- weight formatter passes locale to formatWeight
|
||||
- price formatter passes locale to formatPrice
|
||||
- useFormatters return object includes locale property
|
||||
</behavior>
|
||||
<action>
|
||||
Update `src/client/hooks/useFormatters.ts`:
|
||||
|
||||
```typescript
|
||||
import { formatPrice, formatWeight } from "../lib/formatters";
|
||||
import { useCurrency } from "./useCurrency";
|
||||
import { useLanguage } from "./useLanguage";
|
||||
import { useWeightUnit } from "./useWeightUnit";
|
||||
|
||||
export function useFormatters() {
|
||||
const unit = useWeightUnit();
|
||||
const currency = useCurrency();
|
||||
const locale = useLanguage();
|
||||
return {
|
||||
weight: (grams: number | null) => formatWeight(grams, unit, locale),
|
||||
price: (cents: number | null) => formatPrice(cents, currency, locale),
|
||||
unit,
|
||||
currency,
|
||||
locale,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
This adds `useLanguage` import, passes `locale` to both formatters, and exposes `locale` in the return object for components that need it.
|
||||
</action>
|
||||
<acceptance_criteria>
|
||||
- useFormatters.ts imports useLanguage from "./useLanguage"
|
||||
- useFormatters calls useLanguage()
|
||||
- formatWeight call passes locale as third argument
|
||||
- formatPrice call passes locale as third argument
|
||||
- Return object includes locale property
|
||||
</acceptance_criteria>
|
||||
<verify>
|
||||
<automated>cd /home/jlmak/Projects/jlmak/GearBox && grep -c "useLanguage\|locale" src/client/hooks/useFormatters.ts</automated>
|
||||
</verify>
|
||||
<done>useFormatters hook passes locale to all formatters</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 5: Write tests for locale-aware formatters</name>
|
||||
<files>tests/formatters.test.ts</files>
|
||||
<read_first>src/client/lib/formatters.ts, tests/services/item.service.test.ts</read_first>
|
||||
<behavior>
|
||||
- Tests verify formatPrice with "en" locale produces "$" prefix for USD
|
||||
- Tests verify formatPrice with "de" locale produces "€" suffix for EUR
|
||||
- Tests verify formatPrice handles null input
|
||||
- Tests verify formatPrice handles JPY (no decimals)
|
||||
- Tests verify formatWeight with "en" locale uses comma for thousands
|
||||
- Tests verify formatWeight with "de" locale uses period for thousands
|
||||
- Tests verify formatWeight handles null input
|
||||
- Tests verify formatWeight unit conversions still correct
|
||||
</behavior>
|
||||
<action>
|
||||
Create `tests/formatters.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { formatPrice, formatWeight } from "../src/client/lib/formatters";
|
||||
|
||||
describe("formatPrice", () => {
|
||||
test("returns -- for null", () => {
|
||||
expect(formatPrice(null)).toBe("--");
|
||||
});
|
||||
|
||||
test("returns -- for undefined", () => {
|
||||
expect(formatPrice(undefined)).toBe("--");
|
||||
});
|
||||
|
||||
test("formats USD with en locale", () => {
|
||||
const result = formatPrice(12345, "USD", "en");
|
||||
expect(result).toContain("123.45");
|
||||
expect(result).toContain("$");
|
||||
});
|
||||
|
||||
test("formats EUR with de locale", () => {
|
||||
const result = formatPrice(12345, "EUR", "de");
|
||||
expect(result).toContain("123,45");
|
||||
expect(result).toContain("€");
|
||||
});
|
||||
|
||||
test("formats JPY with no decimals", () => {
|
||||
const result = formatPrice(10000, "JPY", "en");
|
||||
expect(result).toContain("100");
|
||||
expect(result).toContain("¥");
|
||||
expect(result).not.toContain(".");
|
||||
});
|
||||
|
||||
test("formats large amounts with thousands separator en", () => {
|
||||
const result = formatPrice(123456789, "USD", "en");
|
||||
expect(result).toContain("1,234,567.89");
|
||||
});
|
||||
|
||||
test("formats large amounts with thousands separator de", () => {
|
||||
const result = formatPrice(123456789, "EUR", "de");
|
||||
// German uses period for thousands and comma for decimal
|
||||
expect(result).toContain("1.234.567,89");
|
||||
});
|
||||
|
||||
test("defaults to en locale when no locale provided", () => {
|
||||
const result = formatPrice(12345, "USD");
|
||||
expect(result).toContain("$");
|
||||
expect(result).toContain("123.45");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatWeight", () => {
|
||||
test("returns -- for null", () => {
|
||||
expect(formatWeight(null)).toBe("--");
|
||||
});
|
||||
|
||||
test("returns -- for undefined", () => {
|
||||
expect(formatWeight(undefined)).toBe("--");
|
||||
});
|
||||
|
||||
test("formats grams with en locale", () => {
|
||||
expect(formatWeight(1234, "g", "en")).toBe("1,234g");
|
||||
});
|
||||
|
||||
test("formats grams with de locale", () => {
|
||||
expect(formatWeight(1234, "g", "de")).toBe("1.234g");
|
||||
});
|
||||
|
||||
test("formats ounces", () => {
|
||||
const result = formatWeight(100, "oz", "en");
|
||||
expect(result).toContain("oz");
|
||||
expect(result).toContain("3.5");
|
||||
});
|
||||
|
||||
test("formats kilograms", () => {
|
||||
const result = formatWeight(1500, "kg", "en");
|
||||
expect(result).toContain("1.50");
|
||||
expect(result).toContain("kg");
|
||||
});
|
||||
|
||||
test("formats pounds", () => {
|
||||
const result = formatWeight(1000, "lb", "en");
|
||||
expect(result).toContain("lb");
|
||||
expect(result).toContain("2.2");
|
||||
});
|
||||
|
||||
test("defaults to en locale when no locale provided", () => {
|
||||
const result = formatWeight(1234, "g");
|
||||
expect(result).toBe("1,234g");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**NOTE:** Intl.NumberFormat output may vary slightly between JS engines (Bun uses JavaScriptCore). The tests use `toContain` for flexible matching where exact format may vary, and `toBe` only where the format is deterministic.
|
||||
</action>
|
||||
<acceptance_criteria>
|
||||
- tests/formatters.test.ts exists
|
||||
- File contains at least 14 test cases (7 for formatPrice, 7 for formatWeight)
|
||||
- Tests cover null input, en locale, de locale, default locale
|
||||
- `bun test tests/formatters.test.ts` passes
|
||||
</acceptance_criteria>
|
||||
<verify>
|
||||
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bun test tests/formatters.test.ts</automated>
|
||||
</verify>
|
||||
<done>Formatter tests pass for both locales</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| settings DB→useLanguage | Language preference from DB — validated against VALID_LANGUAGES |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-34-04 | Tampering | useLanguage | mitigate | Validates language value against VALID_LANGUAGES array before returning; invalid values fall back to "en" |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `bun test tests/formatters.test.ts` passes
|
||||
- `bun run build` succeeds
|
||||
- formatPrice produces locale-appropriate output for en and de
|
||||
- formatWeight produces locale-appropriate output for en and de
|
||||
- useFormatters hook passes locale to both formatters
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- formatPrice uses Intl.NumberFormat for locale-aware formatting
|
||||
- formatWeight uses Intl.NumberFormat for locale-aware number display
|
||||
- useLanguage hook reads language from settings with "en" fallback
|
||||
- useFormatters hook passes locale to formatters
|
||||
- All formatter tests pass
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/34-i18n-foundation/34-03-SUMMARY.md`
|
||||
</output>
|
||||
291
.planning/phases/34-i18n-foundation/34-04-PLAN.md
Normal file
291
.planning/phases/34-i18n-foundation/34-04-PLAN.md
Normal file
@@ -0,0 +1,291 @@
|
||||
---
|
||||
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>
|
||||
364
.planning/phases/34-i18n-foundation/34-05-PLAN.md
Normal file
364
.planning/phases/34-i18n-foundation/34-05-PLAN.md
Normal file
@@ -0,0 +1,364 @@
|
||||
---
|
||||
phase: 34-i18n-foundation
|
||||
plan: 05
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: [01, 02, 03, 04]
|
||||
files_modified:
|
||||
- src/client/locales/de/common.json
|
||||
- src/client/locales/de/collection.json
|
||||
- src/client/locales/de/threads.json
|
||||
- src/client/locales/de/setups.json
|
||||
- src/client/locales/de/onboarding.json
|
||||
- src/client/locales/de/settings.json
|
||||
- src/client/lib/i18n.ts
|
||||
- tests/i18n/locales.test.ts
|
||||
autonomous: true
|
||||
requirements: [D-13, D-14, D-15]
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "German locale files exist at src/client/locales/de/ for all 6 namespaces"
|
||||
- "Every key in en/*.json has a corresponding key in de/*.json"
|
||||
- "German translations are natural German, not word-for-word translations"
|
||||
- "i18n.ts loads both en and de resources"
|
||||
- "Switching to de locale renders German text throughout the app"
|
||||
- "A test verifies key parity between en and de locales"
|
||||
artifacts:
|
||||
- path: "src/client/locales/de/common.json"
|
||||
provides: "German common namespace translations"
|
||||
contains: "Speichern"
|
||||
- path: "src/client/locales/de/settings.json"
|
||||
provides: "German settings translations"
|
||||
contains: "Gewichtseinheit"
|
||||
- path: "src/client/lib/i18n.ts"
|
||||
provides: "Updated i18n init with de resources"
|
||||
contains: "deCommon"
|
||||
- path: "tests/i18n/locales.test.ts"
|
||||
provides: "Key parity test"
|
||||
min_lines: 20
|
||||
key_links:
|
||||
- from: "src/client/lib/i18n.ts"
|
||||
to: "src/client/locales/de/common.json"
|
||||
via: "import deCommon"
|
||||
pattern: "deCommon"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Create German translations for all namespaces and register them in the i18n configuration.
|
||||
|
||||
Purpose: Ship the first additional language — German (de) alongside English (en), making the app fully bilingual.
|
||||
Output: Complete German translation files, i18n config updated, key parity test.
|
||||
</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>
|
||||
English locale files (source of truth for key structure):
|
||||
- src/client/locales/en/common.json
|
||||
- src/client/locales/en/collection.json
|
||||
- src/client/locales/en/threads.json
|
||||
- src/client/locales/en/setups.json
|
||||
- src/client/locales/en/onboarding.json
|
||||
- src/client/locales/en/settings.json
|
||||
|
||||
i18n.ts resources structure:
|
||||
```typescript
|
||||
resources: {
|
||||
en: {
|
||||
common: enCommon,
|
||||
collection: enCollection,
|
||||
// ...
|
||||
},
|
||||
// de needs to be added here
|
||||
},
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Create German translation files for all namespaces</name>
|
||||
<files>src/client/locales/de/common.json, src/client/locales/de/collection.json, src/client/locales/de/threads.json, src/client/locales/de/setups.json, src/client/locales/de/onboarding.json, src/client/locales/de/settings.json</files>
|
||||
<read_first>src/client/locales/en/common.json, src/client/locales/en/collection.json, src/client/locales/en/threads.json, src/client/locales/en/setups.json, src/client/locales/en/onboarding.json, src/client/locales/en/settings.json</read_first>
|
||||
<behavior>
|
||||
- Each de/*.json has the exact same key structure as its en/*.json counterpart
|
||||
- Values are natural German translations, not literal word-for-word
|
||||
- German translations use formal "Sie" form (standard for apps)
|
||||
- Common action buttons: Save→Speichern, Cancel→Abbrechen, Delete→Loeschen, Edit→Bearbeiten, Create→Erstellen, Close→Schliessen, Back→Zurueck, Search→Suchen
|
||||
- Navigation: Home→Startseite, Collection→Sammlung, Setups→Setups (keep English), Discover→Entdecken, Settings→Einstellungen
|
||||
- Interpolation variables ({{count}}, {{name}}) remain unchanged
|
||||
- Pluralization keys (_one, _other) have German plural forms
|
||||
</behavior>
|
||||
<action>
|
||||
Create directory `src/client/locales/de/`.
|
||||
|
||||
For EACH English locale file (`src/client/locales/en/*.json`):
|
||||
1. Read the file to get the exact key structure
|
||||
2. Create the corresponding `src/client/locales/de/*.json` with the same key structure
|
||||
3. Translate every value to natural German
|
||||
|
||||
**Translation guidelines:**
|
||||
- Use formal "Sie" address form (standard for web apps)
|
||||
- Keep brand names and technical terms in English where German speakers would expect it (e.g., "Setup" stays "Setup", "Thread" can stay "Thread" or become "Recherche")
|
||||
- Weight units (g, oz, lb, kg) are universal — keep as-is
|
||||
- Currency symbols stay as-is
|
||||
- Interpolation placeholders like `{{count}}` or `{{name}}` must remain exactly as-is in the German text
|
||||
- Pluralization: German uses the same _one/_other pattern as English for most cases
|
||||
|
||||
**Key German translations reference:**
|
||||
|
||||
| English | German |
|
||||
|---------|--------|
|
||||
| Save | Speichern |
|
||||
| Cancel | Abbrechen |
|
||||
| Delete | Loeschen |
|
||||
| Edit | Bearbeiten |
|
||||
| Create | Erstellen |
|
||||
| Close | Schliessen |
|
||||
| Back | Zurueck |
|
||||
| Search | Suchen |
|
||||
| Confirm | Bestaetigen |
|
||||
| Loading... | Laden... |
|
||||
| Something went wrong | Etwas ist schiefgelaufen |
|
||||
| Sign in | Anmelden |
|
||||
| Sign out | Abmelden |
|
||||
| Settings | Einstellungen |
|
||||
| Collection | Sammlung |
|
||||
| Items | Gegenstaende |
|
||||
| Weight | Gewicht |
|
||||
| Price | Preis |
|
||||
| Name | Name |
|
||||
| Brand | Marke |
|
||||
| Model | Modell |
|
||||
| Notes | Notizen |
|
||||
| Category | Kategorie |
|
||||
| No items yet | Noch keine Gegenstaende |
|
||||
| Weight Unit | Gewichtseinheit |
|
||||
| Currency | Waehrung |
|
||||
| Language | Sprache |
|
||||
| Import / Export | Import / Export |
|
||||
| API Keys | API-Schluessel |
|
||||
|
||||
**IMPORTANT:** Read each en/*.json file fully before translating. Every single key must have a German value. Do not leave any English strings in the de/*.json files.
|
||||
</action>
|
||||
<acceptance_criteria>
|
||||
- src/client/locales/de/common.json exists and is valid JSON
|
||||
- src/client/locales/de/collection.json exists and is valid JSON
|
||||
- src/client/locales/de/threads.json exists and is valid JSON
|
||||
- src/client/locales/de/setups.json exists and is valid JSON
|
||||
- src/client/locales/de/onboarding.json exists and is valid JSON
|
||||
- src/client/locales/de/settings.json exists and is valid JSON
|
||||
- de/common.json "actions.save" value is "Speichern" (not "Save")
|
||||
- de/common.json "nav.settings" value is "Einstellungen" (not "Settings")
|
||||
- de/settings.json contains "Gewichtseinheit" for weight unit label
|
||||
- All interpolation variables ({{count}}, {{name}}) preserved in German translations
|
||||
</acceptance_criteria>
|
||||
<verify>
|
||||
<automated>cd /home/jlmak/Projects/jlmak/GearBox && for f in common collection threads setups onboarding settings; do node -e "JSON.parse(require('fs').readFileSync('src/client/locales/de/$f.json','utf8')); console.log('de/$f.json: valid')"; done</automated>
|
||||
</verify>
|
||||
<done>All 6 German translation files created with complete translations</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Register German locale in i18n configuration</name>
|
||||
<files>src/client/lib/i18n.ts</files>
|
||||
<read_first>src/client/lib/i18n.ts</read_first>
|
||||
<behavior>
|
||||
- i18n.ts imports all 6 de/*.json files
|
||||
- resources object includes "de" key with all 6 namespaces
|
||||
- supportedLngs is set to ["en", "de"] to prevent loading unsupported locales
|
||||
</behavior>
|
||||
<action>
|
||||
Update `src/client/lib/i18n.ts`:
|
||||
|
||||
1. Add imports for all German locale files (after the English imports):
|
||||
```typescript
|
||||
import deCommon from "../locales/de/common.json";
|
||||
import deCollection from "../locales/de/collection.json";
|
||||
import deThreads from "../locales/de/threads.json";
|
||||
import deSetups from "../locales/de/setups.json";
|
||||
import deOnboarding from "../locales/de/onboarding.json";
|
||||
import deSettings from "../locales/de/settings.json";
|
||||
```
|
||||
|
||||
2. Add `de` entry to the `resources` object:
|
||||
```typescript
|
||||
resources: {
|
||||
en: {
|
||||
common: enCommon,
|
||||
collection: enCollection,
|
||||
threads: enThreads,
|
||||
setups: enSetups,
|
||||
onboarding: enOnboarding,
|
||||
settings: enSettings,
|
||||
},
|
||||
de: {
|
||||
common: deCommon,
|
||||
collection: deCollection,
|
||||
threads: deThreads,
|
||||
setups: deSetups,
|
||||
onboarding: deOnboarding,
|
||||
settings: deSettings,
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
3. Add `supportedLngs: ["en", "de"]` to the init config (after `fallbackLng`). This prevents i18next from trying to load unsupported locales and forces fallback to "en" per D-12.
|
||||
</action>
|
||||
<acceptance_criteria>
|
||||
- i18n.ts imports deCommon, deCollection, deThreads, deSetups, deOnboarding, deSettings
|
||||
- i18n.ts resources object has "de" key with all 6 namespaces
|
||||
- i18n.ts has supportedLngs: ["en", "de"]
|
||||
- `bun run build` succeeds
|
||||
</acceptance_criteria>
|
||||
<verify>
|
||||
<automated>cd /home/jlmak/Projects/jlmak/GearBox && grep -c "deCommon\|deCollection\|deThreads\|deSetups\|deOnboarding\|deSettings\|supportedLngs" src/client/lib/i18n.ts</automated>
|
||||
</verify>
|
||||
<done>i18n config loads both English and German resources</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Write key parity test between en and de locales</name>
|
||||
<files>tests/i18n/locales.test.ts</files>
|
||||
<read_first>src/client/locales/en/common.json, src/client/locales/de/common.json</read_first>
|
||||
<behavior>
|
||||
- Test reads all en/*.json and de/*.json files
|
||||
- For each namespace, flattens keys to dot notation
|
||||
- Asserts every en key exists in de
|
||||
- Asserts every de key exists in en (no orphan keys)
|
||||
- Asserts no de values are empty strings
|
||||
- Test fails if a key is missing from either locale
|
||||
</behavior>
|
||||
<action>
|
||||
Create directory `tests/i18n/` if not exists.
|
||||
|
||||
Create `tests/i18n/locales.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const LOCALES_DIR = join(import.meta.dir, "../../src/client/locales");
|
||||
|
||||
function flattenKeys(obj: Record<string, unknown>, prefix = ""): string[] {
|
||||
const keys: string[] = [];
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const fullKey = prefix ? `${prefix}.${key}` : key;
|
||||
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
||||
keys.push(...flattenKeys(value as Record<string, unknown>, fullKey));
|
||||
} else {
|
||||
keys.push(fullKey);
|
||||
}
|
||||
}
|
||||
return keys.sort();
|
||||
}
|
||||
|
||||
function loadLocale(locale: string): Record<string, Record<string, unknown>> {
|
||||
const dir = join(LOCALES_DIR, locale);
|
||||
const files = readdirSync(dir).filter((f) => f.endsWith(".json"));
|
||||
const result: Record<string, Record<string, unknown>> = {};
|
||||
for (const file of files) {
|
||||
const ns = file.replace(".json", "");
|
||||
result[ns] = JSON.parse(readFileSync(join(dir, file), "utf8"));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
describe("locale key parity", () => {
|
||||
const en = loadLocale("en");
|
||||
const de = loadLocale("de");
|
||||
|
||||
test("en and de have the same namespaces", () => {
|
||||
expect(Object.keys(en).sort()).toEqual(Object.keys(de).sort());
|
||||
});
|
||||
|
||||
for (const ns of Object.keys(en)) {
|
||||
test(`${ns}: every en key exists in de`, () => {
|
||||
const enKeys = flattenKeys(en[ns]);
|
||||
const deKeys = flattenKeys(de[ns]);
|
||||
const missing = enKeys.filter((k) => !deKeys.includes(k));
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
test(`${ns}: every de key exists in en`, () => {
|
||||
const enKeys = flattenKeys(en[ns]);
|
||||
const deKeys = flattenKeys(de[ns]);
|
||||
const orphan = deKeys.filter((k) => !enKeys.includes(k));
|
||||
expect(orphan).toEqual([]);
|
||||
});
|
||||
|
||||
test(`${ns}: no empty de values`, () => {
|
||||
const deFlat = flattenKeys(de[ns]);
|
||||
for (const key of deFlat) {
|
||||
const value = key.split(".").reduce(
|
||||
(obj, k) => (obj as Record<string, unknown>)?.[k],
|
||||
de[ns] as unknown,
|
||||
);
|
||||
expect(typeof value === "string" && value.length > 0).toBe(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
This test automatically discovers all namespace files and checks key parity without hardcoding namespace names. When future languages are added, the test structure can be extended.
|
||||
</action>
|
||||
<acceptance_criteria>
|
||||
- tests/i18n/locales.test.ts exists
|
||||
- Test checks namespace parity between en and de
|
||||
- Test checks key parity for each namespace (both directions)
|
||||
- Test checks no empty strings in de translations
|
||||
- `bun test tests/i18n/locales.test.ts` passes
|
||||
</acceptance_criteria>
|
||||
<verify>
|
||||
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bun test tests/i18n/locales.test.ts</automated>
|
||||
</verify>
|
||||
<done>Key parity test ensures en and de locales stay in sync</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| locale JSON→i18n | Static bundled files — trusted, no runtime injection vector |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-34-06 | Spoofing | de locale files | accept | German translations are AI-generated per D-14. No security implication — worst case is awkward German. Users correct organically. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- All 6 de/*.json files are valid JSON
|
||||
- `bun test tests/i18n/locales.test.ts` passes (key parity)
|
||||
- `bun run build` succeeds
|
||||
- Switching to "de" in settings renders German text
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Complete German translations for all 6 namespaces
|
||||
- i18n config loads both en and de resources
|
||||
- Key parity test prevents translation drift
|
||||
- Build passes with both locales
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/34-i18n-foundation/34-05-SUMMARY.md`
|
||||
</output>
|
||||
Reference in New Issue
Block a user