From 90a109d0b4b17630a49c50c3168d8c689c6a32f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 10 May 2026 13:21:03 +0200 Subject: [PATCH] i18n(admin): translate Zielwerte status labels (Low/On Target/Stable/Moderate to DE) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marc reported the Zielwerte page (`/targets`) still surfaces English status pills even when the rest of the UI runs in German: "Low / On Target / Stable / Moderate / Normal" stayed in source language because the server emits stable English category strings and the page rendered them verbatim. Card titles ("Resting pulse", "Mood stability", "Medication compliance" …) had the same issue. Add two i18n key blocks under `targets.*`: - `targets.label.` — one entry per measurement type, used by `` to localise the card title. The server keeps emitting English labels (so logs / external consumers don't churn); the page swaps them in via `t("targets.label." + target.type)`. - `targets.status.` — every classification category from `lib/analytics/classifications.ts`, `pulse-targets.ts`, and the inline maps in `targets/route.ts`. A small `STATUS_CATEGORY_KEY` map on the page normalises the human-readable English category to the i18n key. Glucose targets keep their existing key path (the server already emits `targets.glucoseFasting` etc. as the label, resolved at the top of `` before cards mount). Fall-through is graceful: if a future server adds a category we have not translated yet, the verbatim English string still renders — the page never blows up. Also adds `targets.bmi`, `targets.label.BMI`, `targets.status.normal`, `targets.status.optimal`, `targets.status.fitness` to the i18n integrity test's PLACEHOLDER_ALLOWLIST: those terms are identical in EN and DE (BMI is a global acronym; "Normal" / "Optimal" / "Fitness" are technical terms German clinics use verbatim). Co-Authored-By: Claude Opus 4.7 (1M context) --- messages/de.json | 59 ++++++- messages/en.json | 59 ++++++- src/app/__tests__/targets-i18n.test.tsx | 158 ++++++++++++++++++ src/app/targets/page.tsx | 104 +++++++++++- .../__tests__/i18n-locale-integrity.test.ts | 11 ++ 5 files changed, 387 insertions(+), 4 deletions(-) create mode 100644 src/app/__tests__/targets-i18n.test.tsx diff --git a/messages/de.json b/messages/de.json index 2e1a6ac17..21da79b1a 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2432,7 +2432,64 @@ "glucosePostprandial": "Glukose — postprandial", "glucoseRandom": "Glukose — beliebig", "glucoseBedtime": "Glukose — vor dem Schlafen", - "loadError": "Zielwerte konnten nicht geladen werden" + "loadError": "Zielwerte konnten nicht geladen werden", + "label": { + "WEIGHT": "Gewicht", + "BLOOD_PRESSURE": "Blutdruck", + "BLOOD_PRESSURE_IN_TARGET": "Blutdruck im Zielbereich", + "PULSE": "Ruhepuls", + "SLEEP_DURATION": "Schlafdauer", + "BMI": "BMI", + "BODY_FAT": "Körperfett", + "ACTIVITY_STEPS": "Schritte/Tag", + "MEDICATION_COMPLIANCE": "Einnahmetreue", + "MOOD_SCORE": "Stimmung", + "MOOD_STABILITY": "Stimmungsstabilität" + }, + "status": { + "underweight": "Untergewicht", + "normal": "Normal", + "overweight": "Übergewicht", + "obesityGrade1": "Adipositas Grad I", + "obesityGrade2": "Adipositas Grad II", + "obesityGrade3": "Adipositas Grad III", + "optimal": "Optimal", + "highNormal": "Hochnormal", + "hypertensionGrade1": "Hypertonie Grad 1", + "hypertensionGrade2": "Hypertonie Grad 2", + "hypertensionGrade3": "Hypertonie Grad 3", + "bradycardia": "Bradykardie", + "elevated": "Erhöht", + "tachycardia": "Tachykardie", + "significantlyLow": "Deutlich niedrig", + "slightlyLow": "Leicht niedrig", + "onTarget": "Im Zielbereich", + "slightlyElevated": "Leicht erhöht", + "significantlyElevated": "Deutlich erhöht", + "farTooShort": "Viel zu kurz", + "tooShort": "Zu kurz", + "slightlyLong": "Leicht zu lang", + "farTooLong": "Viel zu lang", + "belowEssential": "Unter essenziell", + "essential": "Essenziell", + "athletic": "Athletisch", + "fitness": "Fitness", + "acceptable": "Akzeptabel", + "obese": "Adipös", + "veryLow": "Sehr niedrig", + "lowActive": "Wenig aktiv", + "moderatelyActive": "Mäßig aktiv", + "active": "Aktiv", + "veryActive": "Sehr aktiv", + "good": "Gut", + "moderate": "Moderat", + "low": "Niedrig", + "high": "Hoch", + "veryGood": "Sehr gut", + "veryStable": "Sehr stabil", + "stable": "Stabil", + "fluctuating": "Schwankend" + } }, "passwordStrength": { "veryWeak": "Sehr schwach", diff --git a/messages/en.json b/messages/en.json index 87ac58415..593256a07 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2432,7 +2432,64 @@ "glucosePostprandial": "Glucose — post-meal", "glucoseRandom": "Glucose — random", "glucoseBedtime": "Glucose — bedtime", - "loadError": "Failed to load targets" + "loadError": "Failed to load targets", + "label": { + "WEIGHT": "Weight", + "BLOOD_PRESSURE": "Blood pressure", + "BLOOD_PRESSURE_IN_TARGET": "Blood pressure on target", + "PULSE": "Resting pulse", + "SLEEP_DURATION": "Sleep duration", + "BMI": "BMI", + "BODY_FAT": "Body fat", + "ACTIVITY_STEPS": "Steps/day", + "MEDICATION_COMPLIANCE": "Medication compliance", + "MOOD_SCORE": "Mood", + "MOOD_STABILITY": "Mood stability" + }, + "status": { + "underweight": "Underweight", + "normal": "Normal", + "overweight": "Overweight", + "obesityGrade1": "Obesity Grade I", + "obesityGrade2": "Obesity Grade II", + "obesityGrade3": "Obesity Grade III", + "optimal": "Optimal", + "highNormal": "High-normal", + "hypertensionGrade1": "Hypertension Grade 1", + "hypertensionGrade2": "Hypertension Grade 2", + "hypertensionGrade3": "Hypertension Grade 3", + "bradycardia": "Bradycardia", + "elevated": "Elevated", + "tachycardia": "Tachycardia", + "significantlyLow": "Significantly low", + "slightlyLow": "Slightly low", + "onTarget": "On target", + "slightlyElevated": "Slightly elevated", + "significantlyElevated": "Significantly elevated", + "farTooShort": "Far too short", + "tooShort": "Too short", + "slightlyLong": "Slightly long", + "farTooLong": "Far too long", + "belowEssential": "Below essential", + "essential": "Essential", + "athletic": "Athletic", + "fitness": "Fitness", + "acceptable": "Acceptable", + "obese": "Obese", + "veryLow": "Very low", + "lowActive": "Low active", + "moderatelyActive": "Moderately active", + "active": "Active", + "veryActive": "Very active", + "good": "Good", + "moderate": "Moderate", + "low": "Low", + "high": "High", + "veryGood": "Very good", + "veryStable": "Very stable", + "stable": "Stable", + "fluctuating": "Fluctuating" + } }, "passwordStrength": { "veryWeak": "Very weak", diff --git a/src/app/__tests__/targets-i18n.test.tsx b/src/app/__tests__/targets-i18n.test.tsx new file mode 100644 index 000000000..752aa3d75 --- /dev/null +++ b/src/app/__tests__/targets-i18n.test.tsx @@ -0,0 +1,158 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +/** + * v1.4.19 phase A7 — Marc reported the `/targets` (Zielwerte) page + * still surfaces English status labels even when the rest of the UI + * renders in German: "Low / On Target / Stable / Moderate" stayed in + * the source language. The card titles ("Weight", "Blood pressure", + * "Body fat" …) had the same issue — server returns English + * strings, the page renders them verbatim. + * + * Translate every classification category and card label on the + * client. The server keeps emitting stable English keys (so external + * consumers / logs don't churn); the page resolves them to i18n + * strings via `useTranslations()`. + */ + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/targets", +})); + +const sampleData = { + targets: [ + { + type: "WEIGHT", + label: "Weight", + current: 88.2, + average30: 88.6, + trend: "stable", + unit: "kg", + range: { min: 60, max: 80 }, + classification: { category: "Normal", color: "#50fa7b" }, + source: "WHO BMI", + }, + { + type: "BLOOD_PRESSURE_IN_TARGET", + label: "Blood pressure on target", + current: 65, + average30: 65, + trend: null, + unit: "%", + range: { min: 70, max: 100 }, + classification: { category: "Moderate", color: "#f1fa8c" }, + source: "ESH 2023", + }, + { + type: "PULSE", + label: "Resting pulse", + current: 72, + average30: 72, + trend: "stable", + unit: "bpm", + range: { min: 60, max: 100 }, + classification: { category: "On target", color: "#50fa7b" }, + source: "AHA", + }, + { + type: "MOOD_STABILITY", + label: "Mood stability", + current: 0.6, + average30: 0.6, + trend: null, + unit: "σ", + range: { min: 0, max: 0.5 }, + classification: { category: "Stable", color: "#f1fa8c" }, + source: "moodLog", + }, + { + type: "MEDICATION_COMPLIANCE", + label: "Medication compliance", + current: 60, + average30: 60, + trend: null, + unit: "%", + range: { min: 90, max: 100 }, + classification: { category: "Low", color: "#ff5555" }, + source: "7-day", + }, + ], + bpDiastolic: { current: null, average30: null, range: null }, + profile: { heightCm: 180, age: 35, gender: "MALE", glucoseUnit: null }, +}; + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ + data: sampleData, + isLoading: false, + isError: false, + refetch: vi.fn(), + }), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { + id: "u1", + username: "marc", + email: "marc@example.com", + role: "USER", + }, + isAuthenticated: true, + isLoading: false, + refetch: vi.fn(), + }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import TargetsPage from "../targets/page"; + +function render(locale: "en" | "de" = "en") { + return renderToStaticMarkup( + + + , + ); +} + +describe("/targets page — i18n status labels and titles", () => { + it("translates card titles to German when the locale is `de`", () => { + const html = render("de"); + expect(html).toContain("Gewicht"); // Weight + expect(html).toContain("Ruhepuls"); // Resting pulse + expect(html).toContain("Stimmungsstabilität"); // Mood stability + expect(html).toContain("Einnahmetreue"); // Medication compliance + // Server-emitted English labels must not leak. + expect(html).not.toContain("Resting pulse"); + expect(html).not.toContain("Mood stability"); + expect(html).not.toContain("Medication compliance"); + }); + + it("translates every status badge category to German", () => { + const html = render("de"); + // Marc explicitly named these in his bug report. + expect(html).not.toMatch(/>LowOn targetStableModerate { + const html = render("en"); + expect(html).toContain("Normal"); + expect(html).toContain("Moderate"); + expect(html).toContain("On target"); + expect(html).toContain("Stable"); + expect(html).toContain("Low"); + }); +}); diff --git a/src/app/targets/page.tsx b/src/app/targets/page.tsx index 924595a8c..5a9228d2c 100644 --- a/src/app/targets/page.tsx +++ b/src/app/targets/page.tsx @@ -103,6 +103,104 @@ const GLUCOSE_TYPES = new Set([ "BLOOD_GLUCOSE_BEDTIME", ]); +/** + * Map server-emitted classification.category strings to the i18n + * key under `targets.status.*`. The server is the source of truth + * for the category set (lib/analytics/classifications.ts + + * pulse-targets.ts + targets/route.ts inline maps); v1.4.19 phase + * A7 wires this map so the German locale stops surfacing English + * status pills like "Low / On Target / Stable / Moderate" — the + * exact regression Marc reported. + */ +const STATUS_CATEGORY_KEY: Record = { + // BMI + Underweight: "underweight", + Normal: "normal", + Overweight: "overweight", + "Obesity Grade I": "obesityGrade1", + "Obesity Grade II": "obesityGrade2", + "Obesity Grade III": "obesityGrade3", + // BP + Optimal: "optimal", + "High-normal": "highNormal", + "Hypertension Grade 1": "hypertensionGrade1", + "Hypertension Grade 2": "hypertensionGrade2", + "Hypertension Grade 3": "hypertensionGrade3", + // Pulse + Bradycardia: "bradycardia", + Elevated: "elevated", + Tachycardia: "tachycardia", + // Pulse target / sleep target + "Significantly low": "significantlyLow", + "Slightly low": "slightlyLow", + "On target": "onTarget", + "Slightly elevated": "slightlyElevated", + "Significantly elevated": "significantlyElevated", + // Sleep + "Far too short": "farTooShort", + "Too short": "tooShort", + "Slightly long": "slightlyLong", + "Far too long": "farTooLong", + // Body fat + "Below essential": "belowEssential", + Essential: "essential", + Athletic: "athletic", + Fitness: "fitness", + Acceptable: "acceptable", + Obese: "obese", + // Steps + "Very low": "veryLow", + "Low active": "lowActive", + "Moderately active": "moderatelyActive", + Active: "active", + "Very active": "veryActive", + // Generic / shared (BP-on-target, mood, medication compliance, glucose) + Good: "good", + Moderate: "moderate", + Low: "low", + High: "high", + "Very good": "veryGood", + "Very stable": "veryStable", + Stable: "stable", + Fluctuating: "fluctuating", +}; + +/** Resolve a server-emitted classification.category to its translated label. */ +function translateStatus( + category: string, + t: (key: string, vars?: Record) => string, +): string { + const key = STATUS_CATEGORY_KEY[category]; + if (!key) return category; // unmapped — fall back to verbatim string + return t(`targets.status.${key}`); +} + +/** Resolve a target's display label. + * + * Glucose contexts are special-cased: the server emits an i18n key + * (e.g. `targets.glucoseFasting`) as its `label`, and the page's + * top-level `visibleTargets` mapping resolves that to a localised + * string before the card mounts. So when this helper runs, glucose + * targets already carry the localised label and the i18n lookup + * below misses (no `targets.label.BLOOD_GLUCOSE_FASTING` key) — the + * fallback returns the verbatim resolved label, which is correct. + * + * Every other target type carries a stable English label from the + * server. Look it up under `targets.label.` here so the card + * title respects the user's locale. + */ +function translateTargetLabel( + target: TargetData, + t: (key: string, vars?: Record) => string, +): string { + const i18nKey = `targets.label.${target.type}`; + const localised = t(i18nKey); + if (localised && localised !== i18nKey) { + return localised; + } + return target.label; +} + function getTargetSourceLink(target: TargetData): string | null { if (target.type === "WEIGHT" || target.type === "BMI") { return "https://www.who.int/news-room/fact-sheets/detail/obesity-and-overweight"; @@ -324,6 +422,8 @@ function TargetCard({ const medicationBreakdown = target.details?.medications ?? []; const sourceLink = getTargetSourceLink(target); + const localisedLabel = translateTargetLabel(target, t); + return ( @@ -331,7 +431,7 @@ function TargetCard({
- {target.label} + {localisedLabel}
@@ -442,7 +542,7 @@ function TargetCard({ color: target.classification.color, }} > - {target.classification.category} + {translateStatus(target.classification.category, t)} diff --git a/src/lib/__tests__/i18n-locale-integrity.test.ts b/src/lib/__tests__/i18n-locale-integrity.test.ts index f96f12488..2b9cd4f54 100644 --- a/src/lib/__tests__/i18n-locale-integrity.test.ts +++ b/src/lib/__tests__/i18n-locale-integrity.test.ts @@ -236,6 +236,17 @@ describe("i18n locale file integrity", () => { "classifications.bp.Normal", "classifications.pulse.Normal", "classifications.bodyFat.Fitness", + // v1.4.19 phase A7 — "BMI" is the same acronym in EN and DE, + // and the medical / fitness "Optimal", "Fitness", "Normal" + // categories are technical terms that German clinics also use + // verbatim. The actual translations live in `targets.status.*` + // and `targets.label.*`; the entries here just acknowledge the + // legitimate same-token cases. + "targets.bmi", + "targets.label.BMI", + "targets.status.normal", + "targets.status.optimal", + "targets.status.fitness", ]); it.each([