feat(i18n): add German translations and key parity test

- Create all 6 German namespace JSON files (common, collection, threads, setups, onboarding, settings)
- Register German locale in i18n configuration with supportedLngs
- Add key parity test ensuring en/de have identical key structures
- All 19 locale parity tests pass, all 15 formatter tests pass

Phase 34, Plan 05
This commit is contained in:
2026-04-13 18:23:45 +02:00
parent 46715cc793
commit 5e731b436b
8 changed files with 356 additions and 0 deletions

View File

@@ -9,6 +9,13 @@ import enSettings from "../locales/en/settings.json";
import enSetups from "../locales/en/setups.json";
import enThreads from "../locales/en/threads.json";
import deCollection from "../locales/de/collection.json";
import deCommon from "../locales/de/common.json";
import deOnboarding from "../locales/de/onboarding.json";
import deSettings from "../locales/de/settings.json";
import deSetups from "../locales/de/setups.json";
import deThreads from "../locales/de/threads.json";
i18n
.use(LanguageDetector)
.use(initReactI18next)
@@ -22,6 +29,14 @@ i18n
onboarding: enOnboarding,
settings: enSettings,
},
de: {
common: deCommon,
collection: deCollection,
threads: deThreads,
setups: deSetups,
onboarding: deOnboarding,
settings: deSettings,
},
},
supportedLngs: ["en", "de"],
fallbackLng: "en",

View File

@@ -0,0 +1,31 @@
{
"title": "Sammlung",
"gear": "Ausruestung",
"planning": "Planung",
"empty": {
"title": "Ihre Sammlung ist leer",
"description": "Beginnen Sie mit der Katalogisierung Ihrer Ausruestung, indem Sie Ihren ersten Gegenstand hinzufuegen. Verfolgen Sie Gewicht, Preis und organisieren Sie nach Kategorie.",
"addFirst": "Ersten Gegenstand hinzufuegen"
},
"form": {
"name": "Name",
"nameRequired": "Name *",
"namePlaceholder": "z.B. Osprey Talon 22",
"weight": "Gewicht (g)",
"weightPlaceholder": "z.B. 680",
"price": "Preis ($)",
"pricePlaceholder": "z.B. 129,99",
"quantity": "Menge",
"category": "Kategorie",
"notes": "Notizen",
"notesPlaceholder": "Zusaetzliche Notizen...",
"productLink": "Produktlink",
"urlPlaceholder": "https://..."
},
"classification": {
"ultralight": "Ultraleicht",
"light": "Leicht",
"medium": "Mittel",
"heavy": "Schwer"
}
}

View File

@@ -0,0 +1,80 @@
{
"nav": {
"home": "Startseite",
"collection": "Sammlung",
"setups": "Setups",
"discover": "Entdecken",
"settings": "Einstellungen",
"search": "Suchen",
"searchPlaceholder": "Katalog durchsuchen...",
"profile": "Profil"
},
"actions": {
"save": "Speichern",
"cancel": "Abbrechen",
"delete": "Loeschen",
"edit": "Bearbeiten",
"create": "Erstellen",
"close": "Schliessen",
"back": "Zurueck",
"confirm": "Bestaetigen",
"continue": "Weiter",
"tryAgain": "Erneut versuchen",
"dismiss": "Schliessen",
"saving": "Wird gespeichert...",
"deleting": "Wird geloescht...",
"creating": "Wird erstellt...",
"loading": "Laden...",
"addItem": "Gegenstand hinzufuegen",
"saveChanges": "Aenderungen speichern",
"revoke": "Widerrufen",
"skipStep": "Diesen Schritt ueberspringen"
},
"errors": {
"somethingWentWrong": "Etwas ist schiefgelaufen",
"unexpectedError": "Ein unerwarteter Fehler ist aufgetreten",
"nameRequired": "Name ist erforderlich",
"positiveNumber": "Muss eine positive Zahl sein",
"validUrl": "Muss eine gueltige URL sein (https://...)"
},
"auth": {
"signIn": "Anmelden",
"signOut": "Abmelden",
"joinGearBox": "GearBox beitreten",
"signInToGearBox": "Bei GearBox anmelden",
"signInDescription": "Melden Sie sich an oder erstellen Sie ein Konto, um Ihre Sammlung zu verwalten.",
"createAccount": "Konto erstellen",
"redirectDescription": "Sie werden zur Anmeldung weitergeleitet."
},
"confirm": {
"deleteItem": "Gegenstand loeschen",
"deleteItemMessage": "Sind Sie sicher, dass Sie <bold>{{name}}</bold> loeschen moechten? Diese Aktion kann nicht rueckgaengig gemacht werden.",
"deleteCandidate": "Kandidat loeschen",
"deleteCandidateMessage": "Sind Sie sicher, dass Sie <bold>{{name}}</bold> loeschen moechten? Diese Aktion kann nicht rueckgaengig gemacht werden.",
"pickWinner": "Gewinner waehlen",
"pickWinnerMessage": "<bold>{{name}}</bold> als Gewinner waehlen? Der Gegenstand wird Ihrer Sammlung hinzugefuegt und der Thread archiviert."
},
"externalLink": {
"title": "Sie verlassen GearBox",
"redirectMessage": "Sie werden weitergeleitet zu:"
},
"fab": {
"addToCollection": "Zur Sammlung hinzufuegen",
"startNewThread": "Neuen Thread starten",
"newSetup": "Neues Setup"
},
"empty": {
"noResults": "Keine Ergebnisse gefunden",
"noItems": "Keine Gegenstaende gefunden"
},
"stats": {
"items": "Gegenstaende",
"totalWeight": "Gesamtgewicht",
"totalSpent": "Gesamtausgaben"
},
"filter": {
"showing": "{{filtered}} von {{total}} Gegenstaenden",
"searchItems": "Gegenstaende suchen...",
"allCategories": "Alle Kategorien"
}
}

View File

@@ -0,0 +1,34 @@
{
"welcome": {
"title": "Willkommen bei GearBox",
"subtitle": "Sagen Sie uns, was Sie interessiert, und wir helfen Ihnen, Ihre Sammlung mit Ausruestung einzurichten, die wirklich genutzt wird.",
"cta": "Los geht's"
},
"hobby": {
"title": "Was interessiert Sie?",
"subtitle": "Waehlen Sie eins oder mehrere — wir zeigen Ihnen beliebte Ausruestung fuer jedes.",
"continue": "Weiter"
},
"items": {
"title": "Beliebte Ausruestung fuer {{hobby}}",
"titleMultiple": "Beliebte Ausruestung fuer Ihre Hobbys",
"subtitle": "Tippen Sie auf Gegenstaende, die Sie bereits besitzen. Wir fuegen sie Ihrer Sammlung hinzu.",
"noCatalog": "Noch keine Ausruestung katalogisiert",
"noCatalogDescription": "Wir bauen unseren Katalog fuer dieses Hobby noch auf. Sie koennen diesen Schritt ueberspringen und spaeter manuell Ausruestung hinzufuegen.",
"reviewCount": "{{count}} Gegenstaende pruefen",
"reviewCount_one": "{{count}} Gegenstand pruefen"
},
"review": {
"title": "Ihre Startsammlung",
"itemsReady": "{{count}} Gegenstaende bereit zum Hinzufuegen",
"itemsReady_one": "{{count}} Gegenstand bereit zum Hinzufuegen",
"noItemsSelected": "Keine Gegenstaende ausgewaehlt — Sie koennen jederzeit spaeter Ausruestung aus dem Katalog hinzufuegen.",
"addToCollection": "Zu meiner Sammlung hinzufuegen",
"adding": "Wird hinzugefuegt..."
},
"done": {
"title": "Alles bereit!",
"subtitle": "Ihre Sammlung ist fertig. Durchstoebern Sie jederzeit den Katalog, um mehr Ausruestung zu entdecken.",
"cta": "Jetzt entdecken"
}
}

View File

@@ -0,0 +1,32 @@
{
"title": "Einstellungen",
"language": {
"title": "Sprache",
"description": "Aendern Sie die Anzeigesprache der App"
},
"weightUnit": {
"title": "Gewichtseinheit",
"description": "Waehlen Sie die Einheit fuer die Gewichtsanzeige in der App"
},
"currency": {
"title": "Waehrung",
"description": "Aendert das angezeigte Waehrungssymbol. Werte werden nicht umgerechnet."
},
"apiKeys": {
"title": "API-Schluessel",
"description": "API-Schluessel ermoeglichen programmatischen Zugriff auf GearBox (z.B. von Claude Desktop oder Skripten).",
"copyWarning": "Kopieren Sie diesen Schluessel jetzt — er wird nicht erneut angezeigt:",
"namePlaceholder": "Schluesselname (z.B. claude-desktop)"
},
"importExport": {
"title": "Import / Export",
"description": "Exportieren Sie Ihre Ausruestungssammlung als CSV-Datei oder importieren Sie Gegenstaende aus einer CSV.",
"export": "CSV exportieren",
"import": "CSV importieren",
"importing": "Wird importiert...",
"imported": "{{count}} Gegenstaende importiert.",
"imported_one": "{{count}} Gegenstand importiert.",
"newCategories": "Neue Kategorien: {{categories}}",
"noItemsFound": "Keine Gegenstaende in der CSV gefunden."
}
}

View File

@@ -0,0 +1,43 @@
{
"title": "Setups",
"create": "Neues Setup",
"empty": {
"title": "Noch keine Setups",
"description": "Erstellen Sie ein Setup, um Ausruestung fuer bestimmte Reisen oder Aktivitaeten zu organisieren."
},
"card": {
"items": "{{count}} Gegenstaende",
"items_one": "{{count}} Gegenstand",
"weight": "Gewicht",
"price": "Preis"
},
"share": {
"title": "Setup teilen",
"shareLinks": "Freigabelinks",
"createLink": "Link erstellen",
"noLinks": "Noch keine Freigabelinks",
"copyLink": "Link kopieren",
"revokeLink": "Link widerrufen",
"copied": "Kopiert!",
"noExpiration": "Kein Ablaufdatum",
"expired": "Abgelaufen",
"expiresToday": "Laeuft heute ab",
"expiresTomorrow": "Laeuft morgen ab",
"expiresInDays": "Laeuft in {{days}} Tagen ab",
"daysOption": "{{days}} Tage",
"deactivateWarning": "Bei Umstellung auf Privat werden alle Freigabelinks deaktiviert. Sie koennen durch Zurueckschalten reaktiviert werden."
},
"visibility": {
"private": "Privat",
"privateDescription": "Nur Sie haben Zugriff",
"link": "Link-Freigabe",
"linkDescription": "Jeder mit dem Link",
"public": "Oeffentlich",
"publicDescription": "Sichtbar auf Ihrem Profil"
},
"impact": {
"title": "Auswirkungsvorschau",
"adding": "Hinzufuegen",
"removing": "Entfernen"
}
}

View File

@@ -0,0 +1,45 @@
{
"title": "Recherche-Threads",
"create": {
"title": "Neuer Thread",
"threadName": "Thread-Name",
"namePlaceholder": "z.B. Leichter Schlafsack",
"category": "Kategorie",
"nameRequired": "Thread-Name ist erforderlich",
"selectCategory": "Bitte waehlen Sie eine Kategorie",
"createFailed": "Thread konnte nicht erstellt werden",
"createThread": "Thread erstellen"
},
"status": {
"active": "Aktiv",
"researching": "Recherche",
"ordered": "Bestellt",
"arrived": "Angekommen",
"resolved": "Abgeschlossen",
"archived": "Archiviert"
},
"candidate": {
"name": "Name",
"price": "Preis",
"weight": "Gewicht",
"url": "URL",
"pros": "Vorteile",
"cons": "Nachteile",
"notes": "Notizen",
"addCandidate": "Kandidat hinzufuegen"
},
"comparison": {
"weight": "Gewicht",
"price": "Preis",
"pros": "Vorteile",
"cons": "Nachteile"
},
"resolve": {
"title": "Gewinner waehlen",
"message": "<bold>{{name}}</bold> als Gewinner waehlen? Der Gegenstand wird Ihrer Sammlung hinzugefuegt und der Thread archiviert."
},
"empty": {
"noThreads": "Noch keine Recherche-Threads",
"noCandidates": "Noch keine Kandidaten"
}
}

View File

@@ -0,0 +1,76 @@
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, test } from "bun:test";
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 deKeys = flattenKeys(de[ns]);
for (const key of deKeys) {
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);
}
});
}
});