fix(i18n): localise medication reminders and dashboard streak per user (#124)

Closes the v3 audit "Locale Mixing" CRIT plus three of the cousin
findings the v1.4 discovery audit added:

1. **`getPhaseMessage` and `getPhaseKeyboard` were German-only.** Every
   medication reminder shipped via Telegram, ntfy, and Web Push read
   "Erinnerung", "Bald fällig", etc. regardless of the recipient's
   locale. This is the user-facing surface most affected by the v3
   "Locale Mixing" finding because the strings hit users on a 15-minute
   cron, not just an admin / settings page they might never open. The
   reminder worker now passes `med.user.locale` through; messages and
   button labels resolve from `messages/{de,en}.json` under
   `medicationReminders.*`. `callback_data` keys stay stable English
   identifiers so the bot dispatcher keeps matching them across locale
   changes.

2. **`/api/dashboard/summary` ships a hard-coded "Tage in Folge" label
   and "Hi, X" salutation.** Both now come from
   `dashboard.streakLabel` / `dashboard.greetingSalutation` resolved
   server-side against the user's locale.

3. **Mixed-locale Zod messages.** `validations/measurement.ts:69` had
   "Wert muss zwischen … liegen" while line 86 had "Value out of
   plausible range" — the response envelope leaked German into English
   sessions and vice versa. `validations/admin.ts` had four German
   messages (web-push subject, Umami URL, Glitchtip DSN, GitHub
   bug-report repo). All now resolve to a single English wording (the
   app is English-first; the German UI translates the field labels
   client-side, the server-side validation message is treated as a
   developer-facing string).

Adds 13 new tests in `reminder-phases.test.ts` covering English,
German, and unknown-locale fallbacks for messages and keyboards;
updates `validations/measurement.test.ts` to match the unified English
message. The audit's remaining items
(`classifications.ts` server-returned EN category strings,
`pulse-targets.ts`, `targets/route.ts` labels,
`/api/admin/notifications/reminder-check`) ride a follow-up i18n PR —
those are admin-only or client-renderable surfaces with lower
user-facing priority.

Tests: 358 → 371 (+13). Typecheck clean. Locale-integrity test still
green.

Co-authored-by: Marc-André Bombeck <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandMarc-André Bombeck authored and GitHub committed 2026-05-08 12:11:44 +02:00
1 parent 8adeb59396
commit a6524721da
9 files changed
+862 -573

No files matched your search

+17
View File
@@ -194,6 +194,8 @@
},
"welcomeBack": "{greeting}, willkommen zurück.",
"welcomeBackWithName": "{greeting} {name}, willkommen zurück.",
"greetingSalutation": "Hi, {name}",
"streakLabel": "Tage in Folge",
"weight": "Gewicht",
"bloodPressure": "Blutdruck",
"bloodPressureSys": "Blutdruck (Sys)",
@@ -1629,5 +1631,20 @@
"medicationNotFoundExact": "Medikament nicht gefunden. Bitte Namen exakt wie in HealthLog senden.",
"helpHeader": "<b>Verfügbare Befehle:</b>",
"helpBody": "/help — Diese Hilfe anzeigen\n/add — Einnahme erfassen\n/start — Bot starten\n\n<b>Textbefehle:</b>\ngenommen &lt;Name&gt; — Einnahme bestätigen\n\n<b>Über die Buttons in Erinnerungen:</b>\n• Genommen — Einnahme bestätigen\n• 🕐 1h / 🕐 3h — Erinnerung verschieben\n• ⏭ Überspringen — Einnahme überspringen\n• ✓ Bestätigen — Verpasste Einnahme bestätigen"
},
"medicationReminders": {
"phaseGreenTitle": "🟢 Erinnerung: {medName}",
"phaseGreenMessage": "🟢 Erinnerung:\n<b>{medName}</b> ({doseInfo}, {timeWindow})\nZeitfenster endet in {minutes} Min.",
"phaseYellowTitle": "🟡 Bald fällig: {medName}",
"phaseYellowMessage": "🟡 Bald fällig:\n<b>{medName}</b> ({doseInfo}, {timeWindow})\nNoch {minutes} Min. Zeit.",
"phaseOrangeTitle": "🟠 Überfällig: {medName}",
"phaseOrangeMessage": "🟠 Überfällig:\n<b>{medName}</b> ({doseInfo}, {timeWindow})\nSeit {minutes} Min. überfällig.",
"phaseRedTitle": "🔴 Verpasst: {medName}",
"phaseRedMessage": "🔴 Verpasst:\n<b>{medName}</b> ({doseInfo}, {timeWindow})\nAls verpasst markiert.",
"buttonTaken": "Genommen",
"buttonConfirm": "✓ Bestätigen",
"buttonSnoozeOneHour": "🕐 1h",
"buttonSnoozeThreeHours": "🕐 3h",
"buttonSkip": "⏭ Überspringen"
}
}
+17
View File
@@ -194,6 +194,8 @@
},
"welcomeBack": "{greeting}, welcome back.",
"welcomeBackWithName": "{greeting} {name}, welcome back.",
"greetingSalutation": "Hi, {name}",
"streakLabel": "days in a row",
"weight": "Weight",
"bloodPressure": "Blood Pressure",
"bloodPressureSys": "Blood Pressure (Sys)",
@@ -1629,5 +1631,20 @@
"medicationNotFoundExact": "Medication not found. Please send the name exactly as in HealthLog.",
"helpHeader": "<b>Available commands:</b>",
"helpBody": "/help — show this help\n/add — record an intake\n/start — start the bot\n\n<b>Text commands:</b>\ntaken &lt;name&gt; — confirm an intake\n\n<b>Buttons in reminders:</b>\n• Taken — confirm intake\n• 🕐 1h / 🕐 3h — postpone reminder\n• ⏭ Skip — skip intake\n• ✓ Confirm — confirm a missed intake"
},
"medicationReminders": {
"phaseGreenTitle": "🟢 Reminder: {medName}",
"phaseGreenMessage": "🟢 Reminder:\n<b>{medName}</b> ({doseInfo}, {timeWindow})\nWindow closes in {minutes} min.",
"phaseYellowTitle": "🟡 Due soon: {medName}",
"phaseYellowMessage": "🟡 Due soon:\n<b>{medName}</b> ({doseInfo}, {timeWindow})\n{minutes} min left.",
"phaseOrangeTitle": "🟠 Overdue: {medName}",
"phaseOrangeMessage": "🟠 Overdue:\n<b>{medName}</b> ({doseInfo}, {timeWindow})\nOverdue by {minutes} min.",
"phaseRedTitle": "🔴 Missed: {medName}",
"phaseRedMessage": "🔴 Missed:\n<b>{medName}</b> ({doseInfo}, {timeWindow})\nMarked as missed.",
"buttonTaken": "Taken",
"buttonConfirm": "✓ Confirm",
"buttonSnoozeOneHour": "🕐 1h",
"buttonSnoozeThreeHours": "🕐 3h",
"buttonSkip": "⏭ Skip"
}
}
+13 -6
View File
@@ -15,6 +15,8 @@ import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import type { MeasurementType } from "@/generated/prisma/client";
import { measurementTypeEnum } from "@/lib/validations/measurement";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { defaultLocale, type Locale } from "@/lib/i18n/config";
const SPARK_DAYS = 7;
const STREAK_WINDOW_DAYS = 365;
@@ -159,8 +161,8 @@ export const GET = apiHandler(async () => {
...measurementTypeEnum.options,
] as MeasurementType[];
const [recentMeasurements, todaysIntakes, streakActivity] =
await Promise.all([
const [recentMeasurements, todaysIntakes, streakActivity] = await Promise.all(
[
prisma.measurement.findMany({
where: {
userId: user.id,
@@ -185,10 +187,12 @@ export const GET = apiHandler(async () => {
},
select: { takenAt: true, scheduledFor: true },
}),
]);
],
);
const activityDays = new Set<string>();
for (const m of recentMeasurements) activityDays.add(berlinDayKey(m.measuredAt));
for (const m of recentMeasurements)
activityDays.add(berlinDayKey(m.measuredAt));
// Pull a wider measurement window for the streak so it isn't capped by
// SPARK_DAYS — but only if the user has any data at all.
if (recentMeasurements.length > 0) {
@@ -333,16 +337,19 @@ export const GET = apiHandler(async () => {
).length;
const greetingName = user.displayName ?? user.username;
const locale: Locale =
user.locale === "de" || user.locale === "en" ? user.locale : defaultLocale;
const t = getServerTranslator(locale).t;
return apiSuccess({
greeting: {
salutation: `Hi, ${greetingName}`,
salutation: t("dashboard.greetingSalutation", { name: greetingName }),
date: now.toISOString(),
},
streak: {
currentDays: streak.currentDays,
longest: streak.longest,
label: "Tage in Folge",
label: t("dashboard.streakLabel"),
},
compliance: {
scheduledToday,
@@ -0,0 +1,144 @@
import { describe, it, expect } from "vitest";
import {
determinePhase,
getPhaseKeyboard,
getPhaseMessage,
resolvePhaseThresholds,
DEFAULT_PHASE_CONFIG,
} from "../reminder-phases";
describe("determinePhase", () => {
const thresholds = resolvePhaseThresholds(DEFAULT_PHASE_CONFIG, 60);
it("returns RED past the red threshold", () => {
expect(determinePhase(-300, 360, thresholds)).toBe("RED");
});
it("returns ORANGE between window end and red", () => {
expect(determinePhase(-30, 90, thresholds)).toBe("ORANGE");
});
it("returns YELLOW within yellowMinBefore of window end", () => {
expect(determinePhase(20, 40, thresholds)).toBe("YELLOW");
});
it("returns GREEN inside green window when at/after window start", () => {
expect(determinePhase(50, 10, thresholds)).toBe("GREEN");
});
it("returns null before window start", () => {
expect(determinePhase(80, -20, thresholds)).toBe(null);
});
});
// v1.4 marathon — closes v3 audit "Locale Mixing" CRIT.
// Reminder messages must follow the user's stored locale, not always
// German. callback_data values are stable English identifiers and stay
// untranslated regardless of UI locale.
describe("getPhaseMessage — localised", () => {
it("uses German message + title for de locale", () => {
const result = getPhaseMessage(
"GREEN",
"Ramipril",
"10mg",
"08:00–09:00",
45,
"de",
);
expect(result.title).toBe("🟢 Erinnerung: Ramipril");
expect(result.message).toContain("Zeitfenster endet in 45 Min.");
expect(result.message).toContain("<b>Ramipril</b>");
});
it("uses English message + title for en locale", () => {
const result = getPhaseMessage(
"YELLOW",
"Ramipril",
"10mg",
"08:00–09:00",
20,
"en",
);
expect(result.title).toBe("🟡 Due soon: Ramipril");
expect(result.message).toContain("20 min left.");
expect(result.message).toContain("<b>Ramipril</b>");
});
it("falls back to default locale (en) for unknown / missing locale", () => {
const result = getPhaseMessage(
"ORANGE",
"Aspirin",
"100mg",
"12:00–13:00",
-90,
null,
);
expect(result.title).toBe("🟠 Overdue: Aspirin");
expect(result.message).toContain("Overdue by 90 min.");
});
it("RED phase template suppresses the per-message minutes count", () => {
const result = getPhaseMessage(
"RED",
"Aspirin",
"100mg",
"12:00–13:00",
-300,
"en",
);
expect(result.message).toContain("Marked as missed.");
expect(result.message).not.toContain("300");
});
});
describe("getPhaseKeyboard — localised", () => {
it("returns German labels for de locale", () => {
const kb = getPhaseKeyboard("YELLOW", "med-1", "de");
const flat = kb.inline_keyboard.flat();
expect(flat.find((b) => b.callback_data === "taken:med-1")?.text).toBe(
"Genommen",
);
expect(flat.find((b) => b.callback_data === "snooze:med-1:60")?.text).toBe(
"🕐 1h",
);
expect(flat.find((b) => b.callback_data === "skip:med-1")?.text).toBe(
"⏭ Überspringen",
);
});
it("returns English labels for en locale", () => {
const kb = getPhaseKeyboard("YELLOW", "med-1", "en");
const flat = kb.inline_keyboard.flat();
expect(flat.find((b) => b.callback_data === "taken:med-1")?.text).toBe(
"Taken",
);
expect(flat.find((b) => b.callback_data === "skip:med-1")?.text).toBe(
"⏭ Skip",
);
});
it("RED phase has Confirm button localised", () => {
expect(
getPhaseKeyboard("RED", "med-1", "en").inline_keyboard[0].find(
(b) => b.callback_data === "ack:med-1",
)?.text,
).toBe("✓ Confirm");
expect(
getPhaseKeyboard("RED", "med-1", "de").inline_keyboard[0].find(
(b) => b.callback_data === "ack:med-1",
)?.text,
).toBe("✓ Bestätigen");
});
it("callback_data values are NEVER translated (stable contract)", () => {
for (const locale of ["de", "en", null, "fr"] as const) {
const kb = getPhaseKeyboard("YELLOW", "med-x", locale);
const cb = kb.inline_keyboard.flat().map((b) => b.callback_data);
expect(cb).toContain("taken:med-x");
expect(cb).toContain("snooze:med-x:60");
expect(cb).toContain("snooze:med-x:180");
expect(cb).toContain("skip:med-x");
}
});
});
+76 -17
View File
@@ -4,6 +4,9 @@
* current time relative to the schedule window end.
*/
import { defaultLocale, type Locale } from "@/lib/i18n/config";
import { getServerTranslator } from "@/lib/i18n/server-translator";
export type ReminderPhase = "GREEN" | "YELLOW" | "ORANGE" | "RED";
export type PhaseMode = "MINUTES" | "PERCENT";
@@ -115,8 +118,18 @@ export function determinePhase(
return null; // Not in any phase yet
}
function resolveLocale(locale: Locale | string | null | undefined): Locale {
return locale === "en" || locale === "de" ? locale : defaultLocale;
}
/**
* Get the message template for a phase.
* Get the message template for a phase, localised to the recipient
* user. Strings live in `messages/{de,en}.json` under
* `medicationReminders.phase*`. The reminder worker passes the user's
* stored locale; absent / unknown locales fall back to the app default.
*
* v1.4 marathon — closes the v3 audit "Locale-Mixing" CRIT finding
* (German-only Telegram / Web Push templates regardless of user locale).
*/
export function getPhaseMessage(
phase: ReminderPhase,
@@ -124,47 +137,79 @@ export function getPhaseMessage(
doseInfo: string,
timeWindow: string,
minutesToEnd: number,
locale: Locale | string | null | undefined,
): { title: string; message: string } {
const minutesPastEnd = -minutesToEnd;
const t = getServerTranslator(resolveLocale(locale)).t;
const absMinutes = Math.abs(minutesToEnd);
const minutesPastEnd = Math.max(0, -minutesToEnd);
const params = (extra: Record<string, string | number>) => ({
medName,
doseInfo,
timeWindow,
...extra,
});
switch (phase) {
case "GREEN":
return {
title: `🟢 Erinnerung: ${medName}`,
message: `🟢 Erinnerung:\n<b>${medName}</b> (${doseInfo}, ${timeWindow})\nZeitfenster endet in ${absMinutes} Min.`,
title: t("medicationReminders.phaseGreenTitle", { medName }),
message: t(
"medicationReminders.phaseGreenMessage",
params({ minutes: absMinutes }),
),
};
case "YELLOW":
return {
title: `🟡 Bald fällig: ${medName}`,
message: `🟡 Bald fällig:\n<b>${medName}</b> (${doseInfo}, ${timeWindow})\nNoch ${absMinutes} Min. Zeit.`,
title: t("medicationReminders.phaseYellowTitle", { medName }),
message: t(
"medicationReminders.phaseYellowMessage",
params({ minutes: absMinutes }),
),
};
case "ORANGE":
return {
title: `🟠 Überfällig: ${medName}`,
message: `🟠 Überfällig:\n<b>${medName}</b> (${doseInfo}, ${timeWindow})\nSeit ${minutesPastEnd} Min. überfällig.`,
title: t("medicationReminders.phaseOrangeTitle", { medName }),
message: t(
"medicationReminders.phaseOrangeMessage",
params({ minutes: minutesPastEnd }),
),
};
case "RED":
return {
title: `🔴 Verpasst: ${medName}`,
message: `🔴 Verpasst:\n<b>${medName}</b> (${doseInfo}, ${timeWindow})\nAls verpasst markiert.`,
title: t("medicationReminders.phaseRedTitle", { medName }),
message: t(
"medicationReminders.phaseRedMessage",
params({ minutes: 0 }),
),
};
}
}
/**
* Get the inline keyboard for a phase.
* Get the inline keyboard for a phase. Button labels follow the user's
* locale; callback_data values are stable English identifiers and must
* NOT be translated (the bot dispatcher matches on them).
*/
export function getPhaseKeyboard(
phase: ReminderPhase,
medicationId: string,
locale: Locale | string | null | undefined,
): { inline_keyboard: { text: string; callback_data: string }[][] } {
const t = getServerTranslator(resolveLocale(locale)).t;
if (phase === "RED") {
return {
inline_keyboard: [
[
{ text: "Genommen", callback_data: `taken:${medicationId}` },
{ text: "✓ Bestätigen", callback_data: `ack:${medicationId}` },
{
text: t("medicationReminders.buttonTaken"),
callback_data: `taken:${medicationId}`,
},
{
text: t("medicationReminders.buttonConfirm"),
callback_data: `ack:${medicationId}`,
},
],
],
};
@@ -172,11 +217,25 @@ export function getPhaseKeyboard(
return {
inline_keyboard: [
[{ text: "Genommen", callback_data: `taken:${medicationId}` }],
[
{ text: "🕐 1h", callback_data: `snooze:${medicationId}:60` },
{ text: "🕐 3h", callback_data: `snooze:${medicationId}:180` },
{ text: "⏭ Überspringen", callback_data: `skip:${medicationId}` },
{
text: t("medicationReminders.buttonTaken"),
callback_data: `taken:${medicationId}`,
},
],
[
{
text: t("medicationReminders.buttonSnoozeOneHour"),
callback_data: `snooze:${medicationId}:60`,
},
{
text: t("medicationReminders.buttonSnoozeThreeHours"),
callback_data: `snooze:${medicationId}:180`,
},
{
text: t("medicationReminders.buttonSkip"),
callback_data: `skip:${medicationId}`,
},
],
],
};
File diff suppressed because it is too large. Load diff
@@ -27,18 +27,18 @@ describe("measurement validation", () => {
describe("validateMeasurementRange", () => {
it("rejects values below the plausible minimum", () => {
expect(validateMeasurementRange("WEIGHT", 0.5)).toMatch(/zwischen/);
expect(validateMeasurementRange("BONE_MASS", 0.1)).toMatch(/zwischen/);
expect(validateMeasurementRange("WEIGHT", 0.5)).toMatch(/between/i);
expect(validateMeasurementRange("BONE_MASS", 0.1)).toMatch(/between/i);
expect(validateMeasurementRange("TOTAL_BODY_WATER", 1)).toMatch(
/zwischen/,
/between/i,
);
});
it("rejects values above the plausible maximum", () => {
expect(validateMeasurementRange("WEIGHT", 600)).toMatch(/zwischen/);
expect(validateMeasurementRange("BONE_MASS", 12)).toMatch(/zwischen/);
expect(validateMeasurementRange("WEIGHT", 600)).toMatch(/between/i);
expect(validateMeasurementRange("BONE_MASS", 12)).toMatch(/between/i);
expect(validateMeasurementRange("TOTAL_BODY_WATER", 200)).toMatch(
/zwischen/,
/between/i,
);
});
+4 -4
View File
@@ -16,7 +16,7 @@ export const adminSettingsSchema = z
.string()
.refine((v) => !v.trim() || /^mailto:.+@.+$/.test(v.trim()), {
message:
"Web Push Subject muss im Format mailto:adresse@example.com sein",
"webPushVapidSubject must be in mailto:address@example.com format",
})
.optional(),
webPushVapidPrivateKey: z.string().optional(),
@@ -36,7 +36,7 @@ export const adminSettingsSchema = z
return false;
}
},
{ message: "Umami Script-URL ist ungültig" },
{ message: "Umami script URL is invalid" },
)
.optional(),
umamiWebsiteId: z.string().optional(),
@@ -54,7 +54,7 @@ export const adminSettingsSchema = z
return false;
}
},
{ message: "Glitchtip DSN ist ungültig" },
{ message: "Glitchtip DSN is invalid" },
)
.optional(),
glitchtipEnvironment: z.string().optional(),
@@ -66,7 +66,7 @@ export const adminSettingsSchema = z
if (!trimmed) return true;
return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(trimmed);
},
{ message: "GitHub-Repository muss im Format owner/repo sein" },
{ message: "GitHub repository must be in owner/repo format" },
)
.optional(),
bugReportToken: z.string().optional(),
+2 -2
View File
@@ -66,7 +66,7 @@ export function validateMeasurementRange(
): string | null {
const range = VALUE_RANGES[type];
if (range && (value < range.min || value > range.max)) {
return `Wert muss zwischen ${range.min} und ${range.max} liegen`;
return `Value must be between ${range.min} and ${range.max}`;
}
return null;
}
@@ -104,7 +104,7 @@ export const updateMeasurementSchema = z.object({
.optional(),
notes: z
.string()
.max(25, "Kommentar darf maximal 25 Zeichen haben")
.max(25, "Note cannot exceed 25 characters")
.nullable()
.optional(),
});