282 lines
11 KiB
Markdown
282 lines
11 KiB
Markdown
# 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
|