---
phase: 34-i18n-foundation
reviewed: 2026-04-17T00:00:00Z
depth: standard
files_reviewed: 23
files_reviewed_list:
  - src/client/components/ClassificationBadge.tsx
  - src/client/components/ImageUpload.tsx
  - src/client/components/ImpactDeltaBadge.tsx
  - src/client/components/PlanningView.tsx
  - src/client/components/PublicSetupCard.tsx
  - src/client/components/SetupImpactSelector.tsx
  - src/client/components/ThreadCard.tsx
  - src/client/components/ThreadTabs.tsx
  - src/client/components/TotalsBar.tsx
  - src/client/locales/de/collection.json
  - src/client/locales/de/common.json
  - src/client/locales/de/onboarding.json
  - src/client/locales/de/settings.json
  - src/client/locales/de/setups.json
  - src/client/locales/de/threads.json
  - src/client/locales/en/collection.json
  - src/client/locales/en/common.json
  - src/client/locales/en/settings.json
  - src/client/locales/en/setups.json
  - src/client/locales/en/threads.json
  - src/client/routes/index.tsx
  - src/client/routes/profile.tsx
  - src/client/routes/settings.tsx
findings:
  critical: 0
  warning: 7
  info: 4
  total: 11
status: issues_found
---

# Phase 34: Code Review Report

**Reviewed:** 2026-04-17T00:00:00Z
**Depth:** standard
**Files Reviewed:** 23
**Status:** issues_found

## Summary

This review covers the i18n foundation work: wiring components to `react-i18next`, locale JSON files for English and German, and three routes (`index`, `profile`, `settings`). The EN locale is complete and internally consistent. The DE locale has significant gaps — several keys used by components exist only in EN, meaning German users will see raw key strings or English fallback text in multiple places. Additionally, one component hardcodes a locale in a `toLocaleDateString` call (bypassing i18n entirely), one component shadows the `t` translation function with a filter callback variable, and two hardcoded English strings in `ImageUpload` were not extracted to the locale.

---

## Warnings

### WR-01: DE locale missing `tabs.setups`, `totals`, and `classificationBadge` keys

**File:** `src/client/locales/de/collection.json`
**Issue:** The EN `collection.json` contains three top-level sections absent from the DE file: `tabs` (`tabs.setups`), `totals` (`totals.totalWeight`, `totals.totalCost`), and `classificationBadge` (`classificationBadge.base`, `classificationBadge.worn`, `classificationBadge.consumable`). `CollectionTabs` calls `t("tabs.setups")` and `ClassificationBadge` calls `t("classificationBadge.base")` as a `defaultValue`. When the language is German, these keys resolve to the raw key string unless a fallback language is configured in i18next.
**Fix:** Add the missing keys to `de/collection.json`:
```json
"tabs": {
  "setups": "Setups"
},
"totals": {
  "totalWeight": "Gesamtgewicht",
  "totalCost": "Gesamtkosten"
},
"classificationBadge": {
  "base": "Basisgewicht",
  "worn": "Getragen",
  "consumable": "Verbrauchsmaterial"
}
```

### WR-02: DE locale missing `card.by`, `card.anonymous`, `impact.compareWith` in setups

**File:** `src/client/locales/de/setups.json`
**Issue:** The EN `setups.json` contains `card.by`, `card.anonymous`, and `impact.compareWith`. The DE file omits all three. `PublicSetupCard` calls `t("card.by", { name: ... })` and `t("card.anonymous")` unconditionally; `SetupImpactSelector` calls `t("impact.compareWith")` as the blank option label. German users will see raw key strings in these locations.
**Fix:** Add to `de/setups.json`:
```json
"card": {
  "items": "{{count}} Gegenstände",
  "items_one": "{{count}} Gegenstand",
  "weight": "Gewicht",
  "price": "Preis",
  "by": "von {{name}}",
  "anonymous": "Anonym"
},
"impact": {
  "title": "Auswirkungsvorschau",
  "adding": "Hinzufügen",
  "removing": "Entfernen",
  "compareWith": "Mit Setup vergleichen..."
}
```

### WR-03: DE locale missing `card.candidates` and `planning.*` keys in threads

**File:** `src/client/locales/de/threads.json`
**Issue:** The EN `threads.json` contains `card.candidates` (with pluralisation) and the entire `planning.*` subtree (8 keys). `ThreadCard` calls `t("card.candidates", { count: candidateCount })` and `PlanningView` calls `t("threads:planning.title")`, `t("threads:planning.emptyTitle")`, `t("threads:planning.createFirst")`, and three sets of step titles/descriptions. All of these fall back to raw key strings in German.
**Fix:** Add to `de/threads.json`:
```json
"card": {
  "candidates": "{{count}} Kandidaten",
  "candidates_one": "{{count}} Kandidat"
},
"planning": {
  "title": "Planungs-Threads",
  "emptyTitle": "Planen Sie Ihren nächsten Kauf",
  "createFirst": "Ersten Thread erstellen",
  "step1Title": "Thread erstellen",
  "step1Description": "Starten Sie einen Recherche-Thread für Ausrüstung, die Sie in Betracht ziehen",
  "step2Title": "Kandidaten hinzufügen",
  "step2Description": "Fügen Sie Produkte hinzu, die Sie mit Preisen und Gewichten vergleichen",
  "step3Title": "Gewinner wählen",
  "step3Description": "Schließen Sie den Thread ab und der Gewinner wird Ihrer Sammlung hinzugefügt"
}
```

### WR-04: DE locale missing `currency.suggestion`, `currency.switch`, and `showConversions` keys in settings

**File:** `src/client/locales/de/settings.json`
**Issue:** The EN `settings.json` contains `currency.suggestion`, `currency.switch`, and the entire `showConversions` block. `SettingsPage` renders a currency suggestion banner using `t("currency.suggestion", { symbol, code })` and `t("currency.switch")`, and the "Show Converted Prices" toggle uses `t("showConversions.title")` and `t("showConversions.description")`. German users see raw key strings for all four of these.
**Fix:** Add to `de/settings.json`:
```json
"currency": {
  "title": "Währung",
  "description": "Ändert das angezeigte Währungssymbol. Werte werden nicht umgerechnet.",
  "suggestion": "Basierend auf Ihrer Region empfehlen wir {{symbol}} ({{code}})",
  "switch": "Wechseln"
},
"showConversions": {
  "title": "Umgerechnete Preise anzeigen",
  "description": "Ungefähre Umrechnungen anzeigen, wenn kein lokaler Preis verfügbar ist"
}
```

### WR-05: DE locale missing `home.*`, `imageUpload.*`, and `profile.*` keys in common

**File:** `src/client/locales/de/common.json`
**Issue:** The EN `common.json` contains three top-level sections absent from DE: `home` (3 keys used by `index.tsx`), `imageUpload` (4 keys used by `ImageUpload.tsx`), and `profile` (22 keys used by `profile.tsx`). These are the most user-facing gaps — the entire profile page renders raw key strings in German.
**Fix:** Add the missing sections to `de/common.json`. The `profile` section is large; key translations include:
```json
"home": {
  "popularSetups": "Beliebte Setups",
  "recentlyAdded": "Kürzlich hinzugefügt",
  "trendingCategories": "Beliebte Kategorien"
},
"imageUpload": {
  "clickToAdd": "Klicken zum Hinzufügen eines Fotos",
  "invalidType": "Bitte wählen Sie ein JPG-, PNG- oder WebP-Bild.",
  "tooLarge": "Das Bild muss kleiner als 5 MB sein.",
  "uploadFailed": "Upload fehlgeschlagen. Bitte versuchen Sie es erneut."
},
"profile": {
  "title": "Profil",
  "account": "Konto",
  "accountInfo": "Ihre Kontoinformationen",
  "email": "E-Mail",
  "noEmail": "Keine E-Mail-Adresse hinterlegt",
  "change": "Ändern",
  "newEmailPlaceholder": "Neue E-Mail-Adresse",
  "updating": "Wird aktualisiert...",
  "updateEmail": "E-Mail aktualisieren",
  "emailUpdated": "E-Mail aktualisiert",
  "memberSince": "Mitglied seit",
  "security": "Sicherheit",
  "managePassword": "Ihr Passwort verwalten",
  "currentPassword": "Aktuelles Passwort",
  "newPassword": "Neues Passwort",
  "password": "Passwort",
  "confirmPassword": "Passwort bestätigen",
  "passwordRequirements": "Das Passwort muss mindestens 8 Zeichen mit Groß-, Kleinbuchstaben und einer Zahl enthalten.",
  "passwordUpdated": "Passwort aktualisiert",
  "changingPassword": "Wird geändert...",
  "changePassword": "Passwort ändern",
  "setPassword": "Passwort festlegen",
  "dangerZone": "Gefahrenzone",
  "dangerZoneDescription": "Löschen Sie Ihr Konto und alle persönlichen Daten. Öffentliche Setups werden \"Gelöschter Benutzer\" zugeordnet.",
  "deleteAccount": "Konto löschen",
  "deleteConfirmMessage": "Diese Aktion ist dauerhaft. Geben Sie LÖSCHEN ein, um zu bestätigen.",
  "deleteConfirmPlaceholder": "LÖSCHEN eingeben, um zu bestätigen"
}
```

### WR-06: `ThreadCard.formatDate` hardcodes `"en-US"` locale

**File:** `src/client/components/ThreadCard.tsx:21`
**Issue:** `formatDate` calls `d.toLocaleDateString("en-US", ...)` with a hardcoded locale. Regardless of the user's language setting, thread card dates will always format in English (e.g. "Apr 17" not "17. Apr"). This is a locale bypass that survives even a correct i18next setup.
**Fix:** Pass `undefined` (or the active i18n language) so the browser respects the user's locale:
```ts
function formatDate(iso: string): string {
  const d = new Date(iso);
  return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
```
Alternatively, import `i18n` from `../lib/i18n` and use `i18n.language` as the first argument.

### WR-07: `t` variable shadowed by filter callback parameter in `PlanningView`

**File:** `src/client/components/PlanningView.tsx:32`
**Issue:** The file destructures `{ t }` from `useTranslation` at line 11, then on line 32 uses `t` as the name of the filter callback parameter:
```ts
const filteredThreads = (threads ?? [])
  .filter((t) => t.status === activeTab)
  .filter((t) => (categoryFilter ? t.categoryId === categoryFilter : true));
```
The inner `t` shadows the outer translation function. While JS resolves this correctly inside the arrow functions, it makes the code misleading and will cause a lint warning (or error with strict shadowing rules). A future developer editing the filter body could accidentally call `t("some.key")` expecting a translation, and instead receive an object.
**Fix:** Rename the filter parameter:
```ts
const filteredThreads = (threads ?? [])
  .filter((thread) => thread.status === activeTab)
  .filter((thread) => (categoryFilter ? thread.categoryId === categoryFilter : true));
```

---

## Info

### IN-01: Two hardcoded English strings in `ImageUpload` not extracted to locale

**File:** `src/client/components/ImageUpload.tsx:119,136`
**Issue:** Line 119 has `alt="Item"` and line 136 has `title="Adjust framing"` as hardcoded English strings. The component already imports `useTranslation("common")` and uses it for other strings. These two are not in the EN locale file and will not be translated.
**Fix:** Add keys to `en/common.json` (and `de/common.json`) under `imageUpload`:
```json
"imageUpload": {
  "clickToAdd": "Click to add photo",
  "invalidType": "...",
  "tooLarge": "...",
  "uploadFailed": "...",
  "altText": "Item photo",
  "adjustFraming": "Adjust framing"
}
```
Then use them in the component:
```tsx
// line 119
alt={t("imageUpload.altText")}
// line 136
title={t("imageUpload.adjustFraming")}
```

### IN-02: `CollectionTabs` uses inconsistent key depth for first two tabs

**File:** `src/client/components/ThreadTabs.tsx:13-15`
**Issue:** The tabs array mixes key depths:
```ts
{ key: "gear", label: t("gear") },
{ key: "planning", label: t("planning") },
{ key: "setups", label: t("tabs.setups") },
```
`"gear"` and `"planning"` are looked up at the root of the `collection` namespace, while `"setups"` is nested under `"tabs"`. This asymmetry is accidental — if `"gear"` or `"planning"` ever gain sub-keys, lookups will break silently. Consistent nesting (all under `"tabs"`) is cleaner and mirrors standard i18n patterns.
**Fix:** Move all three into a `tabs` grouping in the locale files and look them up uniformly:
```ts
{ key: "gear", label: t("tabs.gear") },
{ key: "planning", label: t("tabs.planning") },
{ key: "setups", label: t("tabs.setups") },
```

### IN-03: `profile.tsx` silently swallows account-deletion error after logging to console

**File:** `src/client/routes/profile.tsx:327-332`
**Issue:** `handleDelete` catches errors with `console.error` but provides no feedback to the user. If `deleteAccount.mutateAsync()` throws, the user sees nothing — no error message, no state change.
**Fix:** Add error state and display an error message:
```tsx
const [deleteError, setDeleteError] = useState<string | null>(null);

async function handleDelete() {
  setDeleteError(null);
  try {
    await deleteAccount.mutateAsync();
    window.location.href = "/logout";
  } catch (err) {
    setDeleteError((err as Error).message || t("errors.somethingWentWrong"));
  }
}
```
Render `{deleteError && <p className="text-sm text-red-600">{deleteError}</p>}` in the danger zone form.

### IN-04: `de/threads.json` uses "Abgeschlossen" for `status.resolved` but `empty.noThreads` says "noch keine" vs EN "No threads found"

**File:** `src/client/locales/de/threads.json:42`
**Issue:** The DE `empty.noThreads` reads "Noch keine Recherche-Threads" (meaning "No research threads yet") while the EN version reads "No threads found". These are used for two different states: the EN string is shown when a category filter returns no matches (not an "empty collection" state), but the DE string implies the collection is empty. When filtered to a category with no results, German users receive a misleading message.
**Fix:** Align the DE translation with the EN intent:
```json
"empty": {
  "noThreads": "Keine Threads gefunden",
  "noCandidates": "Noch keine Kandidaten"
}
```

---

_Reviewed: 2026-04-17T00:00:00Z_
_Reviewer: Claude (gsd-code-reviewer)_
_Depth: standard_
