docs(34): add research and validation strategy

This commit is contained in:
2026-04-13 18:03:57 +02:00
parent f8ab69684a
commit a531581623
2 changed files with 360 additions and 0 deletions

View File

@@ -0,0 +1,281 @@
# Phase 34: i18n Foundation - Research
**Researched:** 2026-04-13
**Status:** Complete
## Library Evaluation
### react-i18next vs Lingui
| Criterion | react-i18next | Lingui |
|-----------|--------------|--------|
| React 19 support | Yes (v15+) | Yes (v5+) |
| Hook-based API | `useTranslation()` | `useLingui()` |
| Lazy loading | Built-in (`react-i18next/icu`) backend, dynamic imports | Catalog-based lazy loading via `@lingui/loader` |
| TypeScript support | Strong with `i18next` typed resources | Strong with compiled catalogs |
| Bundle size | ~10kb (i18next core + react-i18next) | ~5kb (runtime only) |
| Vite plugin | `i18next-resources-for-ts` or manual | `@lingui/vite-plugin` |
| Extraction tooling | `i18next-parser` (CLI) | `@lingui/cli extract` (built-in) |
| JSON file format | Native JSON key-value | PO files or JSON catalogs |
| Bun/Hono server-side | `i18next` works standalone (no React dependency) | `@lingui/core` works standalone |
| Community/ecosystem | Larger ecosystem, more plugins | Growing, more opinionated |
| Namespace support | Built-in first-class | Via message IDs with prefixes |
| Interpolation | `{{name}}` syntax | `{name}` syntax with ICU |
**Recommendation: react-i18next**
Reasons:
1. **Namespace support is first-class** — CONTEXT.md decision D-08 requires namespaces by feature area. react-i18next has this built-in; Lingui requires manual ID prefixing.
2. **JSON translation files** — Decision D-06 specifies JSON files in `src/client/locales/`. react-i18next uses plain JSON natively. Lingui prefers PO files or its own catalog format.
3. **Server-side reuse**`i18next` core (no React) can be used directly in Hono routes and MCP tool descriptions. Same translation files, same API.
4. **Larger ecosystem** — More documentation, Stack Overflow answers, and community plugins for future needs (Crowdin/Lokalise integration mentioned in D-06).
5. **Lazy loading**`i18next-http-backend` or dynamic imports work cleanly with Vite code splitting.
### Required Packages
```
i18next # Core translation engine
react-i18next # React bindings (useTranslation hook)
i18next-browser-languagedetector # Auto-detect browser locale (D-10)
```
No additional Vite plugins needed — JSON imports work natively.
## Architecture Design
### Client-Side Setup
```
src/client/
├── locales/
│ ├── en/
│ │ ├── common.json # Shared: buttons, labels, navigation
│ │ ├── collection.json # Collection page strings
│ │ ├── threads.json # Research threads strings
│ │ ├── setups.json # Setups strings
│ │ ├── onboarding.json # Onboarding flow strings
│ │ └── settings.json # Settings page strings
│ └── de/
│ ├── common.json
│ ├── collection.json
│ ├── threads.json
│ ├── setups.json
│ ├── onboarding.json
│ └── settings.json
├── lib/
│ └── i18n.ts # i18next initialization
```
**Namespace strategy (D-08):**
- `common` — buttons ("Save", "Cancel", "Delete"), nav items, shared labels, error messages, empty states
- `collection` — collection page, item forms, item cards
- `threads` — thread list, thread detail, candidate forms
- `setups` — setup list, setup detail, impact preview
- `onboarding` — welcome, hobby picker, item browser, review, done screens
- `settings` — settings page labels, API keys section, import/export
### i18n Initialization (`src/client/lib/i18n.ts`)
```typescript
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
// Eager-load both locales (small app, 2 languages)
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";
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";
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
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,
},
},
fallbackLng: "en",
defaultNS: "common",
interpolation: {
escapeValue: false, // React handles XSS
},
detection: {
order: ["localStorage", "navigator"],
lookupLocalStorage: "gearbox-language",
caches: ["localStorage"],
},
});
export default i18n;
```
**Note on lazy loading:** With only 2 languages and ~200 strings, eager loading all namespaces is simpler and avoids loading spinners. Total JSON payload is <20KB gzipped. Lazy loading can be added later when more languages are added.
### Integration with `useFormatters()`
Decision D-04 specifies i18n integrates with the existing formatters hook. The formatters currently use manual string concatenation. With i18n, number and date formatting should use `Intl.NumberFormat` and `Intl.DateTimeFormat` for locale-aware output.
**Approach:** Extend `useFormatters()` to accept locale from i18n context and pass it to `formatWeight()` and `formatPrice()`. The format functions gain a `locale` parameter:
```typescript
// formatPrice now uses Intl.NumberFormat for locale-aware number display
export function formatPrice(cents: number | null, currency: Currency, locale: string): string {
if (cents == null) return "--";
return new Intl.NumberFormat(locale, {
style: "currency",
currency,
minimumFractionDigits: currency === "JPY" ? 0 : 2,
}).format(cents / 100);
}
```
This replaces the manual symbol lookup with `Intl.NumberFormat` which handles symbol placement, decimal separators, and grouping per locale (e.g., German: `1.234,56 €` vs English: `$1,234.56`).
### Language Setting Storage
Following the `useWeightUnit()` and `useCurrency()` pattern:
```typescript
// src/client/hooks/useLanguage.ts
export function useLanguage(): string {
const { data } = useSetting("language");
return data && VALID_LANGUAGES.includes(data) ? data : "en";
}
```
**Key difference from weight/currency:** Language changes need to call `i18n.changeLanguage()` in addition to persisting via `useSetting()`. A `useEffect` in the root layout (or the `useLanguage` hook) syncs the i18n instance when the setting changes.
### Server-Side Translation
MCP tool descriptions and API error messages need server-side translation. Since Hono runs on Bun (not browser), use `i18next` core directly:
```typescript
// src/server/lib/i18n.ts
import i18next from "i18next";
import en from "../../client/locales/en/common.json";
import de from "../../client/locales/de/common.json";
const serverI18n = i18next.createInstance();
serverI18n.init({
lng: "en", // Default server language
resources: { en: { common: en }, de: { common: de } },
defaultNS: "common",
});
export function t(key: string, lng?: string): string {
return serverI18n.t(key, { lng });
}
```
**MCP tool descriptions:** These are registered once at server start and consumed by AI clients. They should remain in English — AI models work best with English tool descriptions. Server-side i18n applies to API error messages returned to the browser, not MCP tool descriptions.
### String Key Convention
**Nested keys with dot notation:**
```json
{
"nav": {
"collection": "Collection",
"setups": "Setups",
"discover": "Discover",
"settings": "Settings"
},
"actions": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"create": "Create"
},
"empty": {
"noItems": "No items yet",
"noThreads": "No research threads yet"
}
}
```
Access pattern: `t("nav.collection")`, `t("actions.save")`.
### Language Picker UX
Reuse the pill-toggle pattern from weight unit and currency in settings:
```tsx
const LANGUAGES = [
{ value: "en", label: "English" },
{ value: "de", label: "Deutsch" },
];
```
Place in the settings page above weight unit, since language is the most fundamental preference.
### String Extraction Strategy
Given ~200 strings across ~50 components, extraction should be done systematically by feature area matching the namespace structure:
1. **Common** — TopNav, BottomTabBar, FabMenu, ConfirmDialog, AuthPromptModal, empty states
2. **Collection** — CollectionView, ItemCard, ItemForm, CategoryPicker, CategoryHeader, WeightSummaryCard
3. **Threads** — ThreadCard, ThreadTabs, CandidateCard, CandidateForm, ComparisonTable, CreateThreadModal
4. **Setups** — SetupsView, SetupCard, SetupImpactSelector, ShareModal
5. **Onboarding** — OnboardingWelcome, OnboardingHobbyPicker, OnboardingItemBrowser, OnboardingReview, OnboardingDone
6. **Settings** — SettingsPage (weight, currency, language, API keys, import/export)
## Validation Architecture
### Test Strategy
1. **Unit tests** — i18n initialization loads both locales without error
2. **Unit tests**`useLanguage()` hook returns correct language from settings
3. **Unit tests**`formatPrice()` with locale produces correct output for en and de
4. **Unit tests**`formatWeight()` with locale produces correct output for en and de
5. **Integration test** — Language change via settings API persists and takes effect
6. **E2E test** — Switch language in settings, verify UI text changes to German
### Completeness Checks
- Every `en/*.json` key has a corresponding `de/*.json` key (no missing translations)
- No hardcoded English strings remain in components that have been extracted
- `i18n.ts` registers all namespaces for both languages
- Language picker appears in settings and persists selection
## Risk Assessment
| Risk | Impact | Mitigation |
|------|--------|------------|
| React 19 compatibility with react-i18next | Medium | react-i18next v15+ supports React 19. Pin compatible version. |
| Bundle size increase | Low | i18next + react-i18next is ~10KB gzipped. JSON files add <10KB per language. |
| String extraction misses some strings | Low | Incremental approach — extract by namespace area, verify each area. |
| German translation quality | Low | AI-generated is acceptable per D-14. User corrects organically. |
| Formatter locale breaking existing tests | Medium | Update test helpers to pass locale. Existing tests keep "en" default. |
## Dependencies
- **Phase 33 (Currency System):** Language detection should follow the same browser auto-detection pattern. The `useCurrency` hook pattern is the model for `useLanguage`. Phase 33 may add market auto-detection; language auto-detection is independent but similar.
- **No schema changes needed:** Language preference stored in existing `settings` table via `useSetting("language")`.
## RESEARCH COMPLETE