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([