mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
1 parent
8adeb59396
commit
a6524721da
9 files changed
+862
-573
No files matched your search
@@ -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 <Name> — 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"
|
||||
}
|
||||
}
|
||||
@@ -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 <name> — 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"
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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}`,
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
+583
-538
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,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user