i18n(admin): translate Zielwerte status labels (Low/On Target/Stable/Moderate to DE)

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.<TYPE>` — one entry per measurement type, used by
  `<TargetCard>` 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.<key>` — 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 `<TargetsPage>` 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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 13:21:36 +02:00
1 parent 6507646a90
commit 90a109d0b4
5 files changed
+387 -4

No files matched your search

+58 -1
View File
@@ -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",
+58 -1
View File
@@ -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",
+158
View File
@@ -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(
<I18nProvider initialLocale={locale}>
<TargetsPage />
</I18nProvider>,
);
}
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(/>Low</);
expect(html).not.toMatch(/>On target</);
expect(html).not.toMatch(/>Stable</);
expect(html).not.toMatch(/>Moderate</);
// Their German renderings must show up instead.
expect(html).toContain("Niedrig"); // Low
expect(html).toContain("Im Zielbereich"); // On target
expect(html).toContain("Stabil"); // Stable
expect(html).toContain("Moderat"); // Moderate
// "Normal" is identical in EN and DE — the badge should still
// render the localised key (`targets.status.normal` → "Normal"),
// not the raw server string.
expect(html).toContain("Normal");
});
it("English locale still surfaces the original English status labels (no breakage)", () => {
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");
});
});
+102 -2
View File
@@ -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<string, string> = {
// 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>) => 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.<TYPE>` here so the card
* title respects the user's locale.
*/
function translateTargetLabel(
target: TargetData,
t: (key: string, vars?: Record<string, string>) => 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 (
<Card>
<CardHeader className="pb-2">
@@ -331,7 +431,7 @@ function TargetCard({
<div className="flex items-center gap-2">
<Icon className={`h-4 w-4 ${iconColor}`} />
<CardTitle className="text-sm font-medium">
{target.label}
{localisedLabel}
</CardTitle>
</div>
<TrendIcon trend={target.trend} />
@@ -442,7 +542,7 @@ function TargetCard({
color: target.classification.color,
}}
>
{target.classification.category}
{translateStatus(target.classification.category, t)}
</Badge>
</TooltipTrigger>
<TooltipContent>
@@ -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([