diff --git a/messages/de.json b/messages/de.json index 8fd92c719..4c3c69efd 100644 --- a/messages/de.json +++ b/messages/de.json @@ -199,6 +199,8 @@ "bloodPressureDia": "Blutdruck (Dia)", "pulse": "Puls", "bodyFat": "Körperfett", + "sleep": "Schlaf", + "steps": "Schritte", "bpInTarget": "BD im Zielbereich", "days30Short": "30T", "medications": "Medikamente", @@ -761,50 +763,6 @@ "unsetExplanation": "Kein eigener Wert — berechneter Default aktiv.", "loadError": "Zielwerte konnten nicht geladen werden" }, - "targets": { - "title": "Zielwerte", - "subtitle": "Persönliche Referenzbereiche basierend auf deinem Profil", - "introText": "Diese Zielwerte sind persönliche Referenzbereiche, die aus deinem Profil und anerkannten medizinischen Richtwerten abgeleitet werden. Sie helfen dir, deine wichtigsten Gesundheitswerte schnell einzuordnen und Entwicklungen früh zu erkennen. Die Bereiche werden für dich individuell berechnet, zum Beispiel anhand von Alter, Geschlecht und Körpergröße. Zu jedem Wert siehst du direkt, ob du im Zielbereich liegst oder darüber beziehungsweise darunter. So bekommst du eine kompakte Übersicht darüber, welche Zielwerte du aktuell gut einhältst und wo noch Potenzial besteht.", - "current": "Aktuell", - "average30d": "30-Tage-Durchschnitt:", - "targetRange": "Zielbereich", - "classification": "Bewertung", - "source": "Quelle", - "sourceLabel": "Quelle: {source}", - "trend": "Trend", - "weight": "Gewicht", - "bloodPressure": "Blutdruck", - "pulse": "Ruhepuls", - "sleep": "Schlafdauer", - "bmi": "BMI", - "bodyFat": "Körperfett", - "steps": "Schritte/Tag", - "compliance": "Einnahmetreue", - "compliancePerMedication": "Pro Medikament", - "diastolic": "Diastolisch", - "noProfile": "Bitte fülle dein Profil aus, um personalisierte Zielwerte zu erhalten.", - "profileIncomplete": "Profil unvollständig", - "profileIncompleteHeightAge": "Hinterlege deine Größe und dein Geburtsdatum im Profil, um personalisierte Zielbereiche zu erhalten.", - "profileIncompleteHeight": "Hinterlege deine Größe im Profil, um den Gewichts-Zielbereich zu berechnen.", - "profileIncompleteAge": "Hinterlege dein Geburtsdatum im Profil, um altersbasierte Zielbereiche zu erhalten.", - "noMeasurementData": "Noch keine Messdaten vorhanden.", - "noMeasurementYet": "Noch keine Messung vorhanden", - "currentValue": "Aktuell: {value} {unit}", - "targetRangeValue": "Zielbereich: {min}–{max} {unit}", - "inTarget": "Im Zielbereich", - "belowTarget": "{delta} {unit} unter Ziel", - "aboveTarget": "{delta} {unit} über Ziel", - "loadError": "Fehler beim Laden", - "classificationOptimal": "Optimal", - "classificationNormal": "Normal", - "classificationElevated": "Erhöht", - "classificationHigh": "Hoch", - "classificationLow": "Niedrig", - "glucoseFasting": "Glukose — nüchtern", - "glucosePostprandial": "Glukose — postprandial", - "glucoseRandom": "Glukose — beliebig", - "glucoseBedtime": "Glukose — vor dem Schlafen" - }, "onboarding": { "welcome": "Willkommen bei HealthLog!", "setupDescription": "Richte dein Profil ein, um loszulegen. Du kannst das auch später in den Einstellungen ändern.", @@ -1461,7 +1419,6 @@ "subtitle": "Sende eine Fehlermeldung, einen Feature-Wunsch oder eine Frage. Der Server-Administrator sieht deine Einreichung.", "loginRequired": "Bitte anmelden, um einen Bug zu melden.", "bugTitle": "Titel", - "bugTitlePlaceholder": "Kurze Beschreibung des Problems", "description": "Beschreibung", "descriptionPlaceholder": "Beschreibe den Fehler möglichst genau. Was hast du getan? Was ist passiert? Was hättest du erwartet?", "screenshot": "Screenshot (optional)", @@ -1627,6 +1584,10 @@ "profileIncompleteAge": "Trage dein Geburtsdatum im Profil ein, um altersspezifische Ziele zu aktivieren.", "profileIncompleteHeightAge": "Trage Körpergröße und Geburtsdatum im Profil ein, um personalisierte Ziele zu sehen.", "compliancePerMedication": "Einnahmetreue pro Medikament", + "glucoseFasting": "Glukose — nüchtern", + "glucosePostprandial": "Glukose — postprandial", + "glucoseRandom": "Glukose — beliebig", + "glucoseBedtime": "Glukose — vor dem Schlafen", "loadError": "Zielwerte konnten nicht geladen werden" }, "passwordStrength": { diff --git a/messages/en.json b/messages/en.json index ef71781c5..296138035 100644 --- a/messages/en.json +++ b/messages/en.json @@ -199,6 +199,8 @@ "bloodPressureDia": "Blood Pressure (Dia)", "pulse": "Pulse", "bodyFat": "Body Fat", + "sleep": "Sleep", + "steps": "Steps", "bpInTarget": "BP in Target", "days30Short": "30d", "medications": "Medications", @@ -761,50 +763,6 @@ "unsetExplanation": "No override — using computed default.", "loadError": "Could not load thresholds" }, - "targets": { - "title": "Targets", - "subtitle": "Personal reference ranges based on your profile", - "introText": "These targets are personal reference ranges derived from your profile and established medical guidelines. They help you quickly classify your key health values and detect changes early. The ranges are calculated individually for you, for example based on age, sex, and height. For each value, you can immediately see whether you are within the target range or above or below it. This gives you a compact overview of which targets you are currently meeting well and where improvement is still possible.", - "current": "Current", - "average30d": "30-day average:", - "targetRange": "Target range", - "classification": "Rating", - "source": "Source", - "sourceLabel": "Source: {source}", - "trend": "Trend", - "weight": "Weight", - "bloodPressure": "Blood Pressure", - "pulse": "Resting Pulse", - "sleep": "Sleep Duration", - "bmi": "BMI", - "bodyFat": "Body Fat", - "steps": "Steps/Day", - "compliance": "Compliance", - "compliancePerMedication": "Per medication", - "diastolic": "Diastolic", - "noProfile": "Please complete your profile to get personalized targets.", - "profileIncomplete": "Profile incomplete", - "profileIncompleteHeightAge": "Add your height and date of birth in your profile to get personalized target ranges.", - "profileIncompleteHeight": "Add your height in your profile to calculate weight target range.", - "profileIncompleteAge": "Add your date of birth in your profile to get age-based target ranges.", - "noMeasurementData": "No measurement data available yet.", - "noMeasurementYet": "No measurement available yet", - "currentValue": "Current: {value} {unit}", - "targetRangeValue": "Target range: {min}–{max} {unit}", - "inTarget": "In target range", - "belowTarget": "{delta} {unit} below target", - "aboveTarget": "{delta} {unit} above target", - "loadError": "Error loading data", - "classificationOptimal": "Optimal", - "classificationNormal": "Normal", - "classificationElevated": "Elevated", - "classificationHigh": "High", - "classificationLow": "Low", - "glucoseFasting": "Glucose — fasting", - "glucosePostprandial": "Glucose — post-meal", - "glucoseRandom": "Glucose — random", - "glucoseBedtime": "Glucose — bedtime" - }, "onboarding": { "welcome": "Welcome to HealthLog!", "setupDescription": "Set up your profile to get started. You can change this later in Settings.", @@ -1461,7 +1419,6 @@ "subtitle": "Send a bug report, feature request, or question. The server administrator will see your submission.", "loginRequired": "Please sign in to report a bug.", "bugTitle": "Title", - "bugTitlePlaceholder": "Brief description of the problem", "description": "Description", "descriptionPlaceholder": "Describe the bug in detail. What did you do? What happened? What did you expect?", "screenshot": "Screenshot (optional)", @@ -1627,6 +1584,10 @@ "profileIncompleteAge": "Add your date of birth in profile settings for age-specific targets.", "profileIncompleteHeightAge": "Add your height and date of birth in profile settings for personalized targets.", "compliancePerMedication": "Compliance per medication", + "glucoseFasting": "Glucose — fasting", + "glucosePostprandial": "Glucose — post-meal", + "glucoseRandom": "Glucose — random", + "glucoseBedtime": "Glucose — bedtime", "loadError": "Failed to load targets" }, "passwordStrength": { diff --git a/src/lib/__tests__/i18n-locale-integrity.test.ts b/src/lib/__tests__/i18n-locale-integrity.test.ts new file mode 100644 index 000000000..60393c73e --- /dev/null +++ b/src/lib/__tests__/i18n-locale-integrity.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const ROOT = join(__dirname, "../../.."); +const EN_PATH = join(ROOT, "messages/en.json"); +const DE_PATH = join(ROOT, "messages/de.json"); + +// JSON.parse silently keeps the last value when an object has duplicate keys, +// which lets shadowed keys hide bugs. Walk the source manually to surface them. +function findDuplicateKeys(src: string): { path: string; line: number; firstLine: number }[] { + const dups: { path: string; line: number; firstLine: number }[] = []; + const pathStack: string[] = []; + let i = 0; + let line = 1; + + function err(msg: string): never { + throw new Error(`${msg} at line ${line}`); + } + + function skipWs() { + while (i < src.length) { + const c = src[i]; + if (c === " " || c === "\t" || c === "\r") i++; + else if (c === "\n") { i++; line++; } + else break; + } + } + + function readString(): { value: string; line: number } { + if (src[i] !== '"') err("expected string"); + const startLine = line; + i++; + let raw = ""; + while (i < src.length) { + const c = src[i]; + if (c === "\\") { + raw += c + src[i + 1]; + i += 2; + continue; + } + if (c === '"') { + i++; + return { value: JSON.parse(`"${raw}"`), line: startLine }; + } + if (c === "\n") line++; + raw += c; + i++; + } + err("unterminated string"); + } + + function parseValue() { + skipWs(); + const c = src[i]; + if (c === "{") return parseObject(); + if (c === "[") return parseArray(); + if (c === '"') { readString(); return; } + while (i < src.length) { + const ch = src[i]; + if (ch === "," || ch === "}" || ch === "]" || ch === " " || ch === "\n" || ch === "\t" || ch === "\r") break; + if (ch === "\n") line++; + i++; + } + } + + function parseArray() { + i++; skipWs(); + if (src[i] === "]") { i++; return; } + while (true) { + parseValue(); + skipWs(); + if (src[i] === ",") { i++; skipWs(); continue; } + if (src[i] === "]") { i++; return; } + err("expected , or ] in array"); + } + } + + function parseObject() { + i++; + const seen = new Map(); + skipWs(); + if (src[i] === "}") { i++; return; } + while (true) { + skipWs(); + const { value: key, line: keyLine } = readString(); + const seenAt = seen.get(key); + if (seenAt != null) { + dups.push({ path: [...pathStack, key].join("."), line: keyLine, firstLine: seenAt }); + } else { + seen.set(key, keyLine); + } + skipWs(); + if (src[i] !== ":") err("expected : after key"); + i++; + pathStack.push(key); + parseValue(); + pathStack.pop(); + skipWs(); + if (src[i] === ",") { i++; continue; } + if (src[i] === "}") { i++; return; } + err("expected , or } in object"); + } + } + + parseValue(); + return dups; +} + +describe("i18n locale file integrity", () => { + it("messages/en.json has no duplicate keys", () => { + const dups = findDuplicateKeys(readFileSync(EN_PATH, "utf8")); + expect( + dups, + `Duplicate keys silently shadow earlier definitions:\n` + + dups + .map((d) => ` ${d.path} at line ${d.line} (first defined at line ${d.firstLine})`) + .join("\n"), + ).toEqual([]); + }); + + it("messages/de.json has no duplicate keys", () => { + const dups = findDuplicateKeys(readFileSync(DE_PATH, "utf8")); + expect( + dups, + `Duplicate keys silently shadow earlier definitions:\n` + + dups + .map((d) => ` ${d.path} at line ${d.line} (first defined at line ${d.firstLine})`) + .join("\n"), + ).toEqual([]); + }); + + it("en and de share the same key shape (locale parity)", () => { + const en = JSON.parse(readFileSync(EN_PATH, "utf8")) as Record; + const de = JSON.parse(readFileSync(DE_PATH, "utf8")) as Record; + + function flatten(obj: unknown, prefix: string, out: string[]) { + if (obj == null || typeof obj !== "object") return; + for (const [k, v] of Object.entries(obj)) { + const key = prefix ? `${prefix}.${k}` : k; + if (typeof v === "string") out.push(key); + else if (typeof v === "object") flatten(v, key, out); + } + } + + const enKeys = new Set(); + const deKeys = new Set(); + const enArr: string[] = []; + const deArr: string[] = []; + flatten(en, "", enArr); + flatten(de, "", deArr); + enArr.forEach((k) => enKeys.add(k)); + deArr.forEach((k) => deKeys.add(k)); + + const onlyInEn = [...enKeys].filter((k) => !deKeys.has(k)).sort(); + const onlyInDe = [...deKeys].filter((k) => !enKeys.has(k)).sort(); + + expect( + { onlyInEn, onlyInDe }, + "Locale files drifted apart — every key must exist in both locales.", + ).toEqual({ onlyInEn: [], onlyInDe: [] }); + }); +});