i18n: migrate from German-first to English-first default locale

Switch defaultLocale from "de" to "en" and make English the code-level
language throughout. All ~145 hardcoded German strings translated:

- API error messages (52 routes) now return English
- Zod validation messages in English
- Classification/analytics functions return English categories
- Notification type labels in English
- Component strings use i18n t() calls (heatmap, mood chart,
  medication form, intake history, password strength, insights card)
- ~50 new i18n keys in both de.json and en.json

Route migration:
- /stimmung → /mood, /zielwerte → /targets (new pages)
- 301 redirects in proxy.ts for legacy URLs
- Sidebar + bottom nav updated

Bump version to 1.0.1.
This commit is contained in:
Marc-André Bombeck committed 2026-03-12 21:31:48 +01:00
1 parent 38309932f3
commit 0c0c345e82
74 files changed
+871 -402

No files matched your search

+2 -2
View File
@@ -30,11 +30,11 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
const body = await request.json();
confirm = typeof body?.confirm === "string" ? body.confirm : "";
} catch {
return apiError("Ungültige Anfrage", 422);
return apiError("Invalid request", 422);
}
if (confirm !== "DELETE ALL") {
return apiError("Bestätigung fehlt", 422);
return apiError("Confirmation missing", 422);
}
const result = await prisma.$transaction(async (tx) => {
@@ -12,10 +12,10 @@ export const POST = apiHandler(async () => {
const settings = await getGlitchtipSettings();
if (!settings.glitchtipEnabled) {
return apiError("Glitchtip ist deaktiviert", 422);
return apiError("Glitchtip is disabled", 422);
}
if (!settings.glitchtipDsn) {
return apiError("Glitchtip DSN fehlt", 422);
return apiError("Glitchtip DSN is missing", 422);
}
const delivery = await sendGlitchtipEvent({
@@ -32,13 +32,13 @@ export const POST = apiHandler(async () => {
if (!delivery.ok) {
getEvent()?.addWarning("Glitchtip test event rejected: " + delivery.method + " " + delivery.status + " " + delivery.details);
return apiError(
`Glitchtip-Testevent wurde abgelehnt (HTTP ${delivery.status ?? 502})`,
`Glitchtip test event rejected (HTTP ${delivery.status ?? 502})`,
502,
);
}
return apiSuccess({
sent: true,
message: "Glitchtip-Testevent wurde gesendet",
message: "Glitchtip test event sent",
});
});
@@ -51,18 +51,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
const settings = await getPublicMonitoringSettings();
if (!settings.umamiEnabled) {
return apiError("Umami ist deaktiviert", 422);
return apiError("Umami is disabled", 422);
}
if (!settings.umamiScriptUrl || !settings.umamiWebsiteId) {
return apiError(
"Umami Script-URL und Website-ID müssen konfiguriert sein",
"Umami script URL and website ID must be configured",
422,
);
}
const targetUrls = resolveUmamiSendUrls(settings.umamiScriptUrl);
if (targetUrls.length === 0) {
return apiError("Umami Script-URL ist ungültig", 422);
return apiError("Umami script URL is invalid", 422);
}
const appUrl = resolveAppUrl(request);
@@ -100,7 +100,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (upstream.ok) {
return apiSuccess({
sent: true,
message: "Umami-Testevent wurde gesendet",
message: "Umami test event sent",
});
}
@@ -117,7 +117,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
getEvent()?.addWarning("Umami test event rejected: " + lastStatus);
return apiError(
`Umami-Testevent wurde abgelehnt (HTTP ${lastStatus})`,
`Umami test event rejected (HTTP ${lastStatus})`,
502,
);
});
+10 -10
View File
@@ -79,7 +79,7 @@ export const POST = apiHandler(async () => {
if (channels.length === 0) {
return apiSuccess({
sent: false,
message: "Keine aktivierten Benachrichtigungskanäle gefunden",
message: "No enabled notification channels found",
results: [],
});
}
@@ -89,7 +89,7 @@ export const POST = apiHandler(async () => {
userId: user.id,
title: "Test-Notification",
message:
"<b>HealthLog Test:</b> Wenn du diese Nachricht siehst, funktionieren deine Benachrichtigungen!",
"<b>HealthLog Test:</b> If you see this message, your notifications are working!",
};
const results: Array<{
@@ -104,7 +104,7 @@ export const POST = apiHandler(async () => {
results.push({
channel: channel.type,
success: false,
error: "SYSTEM_ALERT deaktiviert in Einstellungen",
error: "SYSTEM_ALERT disabled in settings",
});
continue;
}
@@ -124,7 +124,7 @@ export const POST = apiHandler(async () => {
results.push({
channel: "TELEGRAM",
success: false,
error: `Telegram API hat Fehler gemeldet (chatId: ${config.chatId})`,
error: `Telegram API reported error (chatId: ${config.chatId})`,
});
continue;
}
@@ -139,7 +139,7 @@ export const POST = apiHandler(async () => {
results.push({
channel: "NTFY",
success: false,
error: `Senden fehlgeschlagen (topic: ${config.topic})`,
error: `Send failed (topic: ${config.topic})`,
});
continue;
}
@@ -153,7 +153,7 @@ export const POST = apiHandler(async () => {
results.push({
channel: type,
success: false,
error: "Unbekannter Kanaltyp",
error: "Unknown channel type",
});
continue;
}
@@ -163,7 +163,7 @@ export const POST = apiHandler(async () => {
results.push({
channel: channel.type,
success: false,
error: err instanceof Error ? err.message : "Unbekannter Fehler",
error: err instanceof Error ? err.message : "Unknown error",
});
}
}
@@ -173,11 +173,11 @@ export const POST = apiHandler(async () => {
let message: string;
if (failCount === 0) {
message = `Alle ${successCount} Kanäle erfolgreich`;
message = `All ${successCount} channels successful`;
} else if (successCount === 0) {
message = `Alle ${failCount} Kanäle fehlgeschlagen`;
message = `All ${failCount} channels failed`;
} else {
message = `${successCount} erfolgreich, ${failCount} fehlgeschlagen`;
message = `${successCount} successful, ${failCount} failed`;
}
return apiSuccess({ sent: successCount > 0, message, results });
+1 -1
View File
@@ -180,7 +180,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
}
if (Object.keys(updates).length === 0) {
return apiError("Keine gültigen Felder", 422);
return apiError("No valid fields", 422);
}
const settings = await prisma.appSettings.upsert({
@@ -32,16 +32,16 @@ export const POST = apiHandler(async (
const { password } = body as { password?: string };
if (!password || typeof password !== "string") {
return apiError("Passwort erforderlich", 422);
return apiError("Password required", 422);
}
const target = await prisma.user.findUnique({ where: { id } });
if (!target) return apiError("Benutzer nicht gefunden", 404);
if (!target) return apiError("User not found", 404);
const strength = checkPasswordStrength(password, [target.username]);
if (!strength.isAcceptable) {
return apiError(
strength.feedback[0] || "Passwort zu schwach (Score < 3)",
strength.feedback[0] || "Password too weak (score < 3)",
422,
);
}
+2 -2
View File
@@ -30,13 +30,13 @@ export const PUT = apiHandler(async (
}
const target = await prisma.user.findUnique({ where: { id } });
if (!target) return apiError("Benutzer nicht gefunden", 404);
if (!target) return apiError("User not found", 404);
// Prevent removing the last admin
if (parsed.data.role === "USER" && target.role === "ADMIN") {
const adminCount = await prisma.user.count({ where: { role: "ADMIN" } });
if (adminCount <= 1) {
return apiError("Der letzte Admin kann nicht degradiert werden", 400);
return apiError("The last admin cannot be demoted", 400);
}
}
+4 -4
View File
@@ -17,7 +17,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
return NextResponse.json(
{
data: null,
error: "Zu viele Anmeldeversuche. Bitte später erneut versuchen.",
error: "Too many login attempts. Please try again later.",
},
{ status: 429, headers: rateLimitHeaders(rl) },
);
@@ -31,7 +31,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
const parsed = loginPasswordSchema.safeParse(body);
if (!parsed.success) {
return apiError("Ungültige Anmeldedaten", 422);
return apiError("Invalid credentials", 422);
}
const { email, password } = parsed.data;
@@ -51,7 +51,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
ipAddress: ip,
details: { identifier, reason: "user_not_found_or_no_password" },
});
return apiError("Ungültige Anmeldedaten", 401);
return apiError("Invalid credentials", 401);
}
const valid = await verifyPassword(user.passwordHash, password);
@@ -61,7 +61,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
ipAddress: ip,
details: { reason: "invalid_password" },
});
return apiError("Ungültige Anmeldedaten", 401);
return apiError("Invalid credentials", 401);
}
const ua = request.headers.get("user-agent");
@@ -16,7 +16,7 @@ export const POST = apiHandler(async (request: Request) => {
return NextResponse.json(
{
data: null,
error: "Zu viele Passkey-Anfragen. Bitte später erneut versuchen.",
error: "Too many passkey requests. Please try again later.",
},
{ status: 429, headers: rateLimitHeaders(rl) },
);
@@ -15,7 +15,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
const ip = getClientIp(request);
const rl = await checkRateLimit(`auth:passkey-verify:${ip}`, 10, 15 * 60 * 1000);
if (!rl.allowed) {
return apiError("Zu viele Versuche. Bitte 15 Minuten warten.", 429);
return apiError("Too many attempts. Please wait 15 minutes.", 429);
}
const { data: body, error: jsonError } = await safeJson<Record<string, unknown>>(request);
@@ -25,7 +25,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
const credential = body.credential;
if (!challengeId || !credential) {
return apiError("challengeId und credential erforderlich", 422);
return apiError("challengeId and credential required", 422);
}
const { verification, passkey } = await verifyAuthentication(
@@ -38,7 +38,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
ipAddress: ip,
details: { reason: "passkey_verification_failed" },
});
return apiError("Passkey-Verifizierung fehlgeschlagen", 401);
return apiError("Passkey verification failed", 401);
}
const user = await prisma.user.findUnique({
@@ -46,7 +46,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
if (!user) {
return apiError("Benutzer nicht gefunden", 404);
return apiError("User not found", 404);
}
const ua = request.headers.get("user-agent");
@@ -16,13 +16,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
const credential = body.credential as Record<string, unknown> | undefined;
if (!challengeId || !credential) {
return apiError("challengeId und credential erforderlich", 422);
return apiError("challengeId and credential required", 422);
}
const verification = await verifyRegistration(challengeId, credential);
if (!verification.verified || !verification.registrationInfo) {
return apiError("Passkey-Verifizierung fehlgeschlagen", 400);
return apiError("Passkey verification failed", 400);
}
const { registrationInfo } = verification;
+2 -2
View File
@@ -18,7 +18,7 @@ export const DELETE = apiHandler(async (
});
if (!passkey || passkey.userId !== user.id) {
return apiError("Passkey nicht gefunden", 404);
return apiError("Passkey not found", 404);
}
// Check: at least 1 auth method must remain
@@ -29,7 +29,7 @@ export const DELETE = apiHandler(async (
if (passkeyCount <= 1 && !hasPassword) {
return apiError(
"Kann nicht gelöscht werden — es muss mindestens eine Anmeldemethode bestehen bleiben",
"Cannot delete — at least one authentication method must remain",
400,
);
}
+5 -5
View File
@@ -23,7 +23,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
);
if (!rl.allowed) {
return apiError(
"Zu viele Versuche. Bitte 15 Minuten warten.",
"Too many attempts. Please wait 15 minutes.",
429,
);
}
@@ -38,7 +38,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
const { currentPassword, newPassword } = parsed.data;
if (!user.passwordHash) {
return apiError("Für dieses Konto ist kein Passwort gesetzt", 400);
return apiError("No password set for this account", 400);
}
const currentValid = await verifyPassword(
@@ -46,12 +46,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
currentPassword,
);
if (!currentValid) {
return apiError("Aktuelles Passwort ist falsch", 401);
return apiError("Current password is incorrect", 401);
}
if (currentPassword === newPassword) {
return apiError(
"Neues Passwort muss sich vom aktuellen unterscheiden",
"New password must differ from current password",
422,
);
}
@@ -62,7 +62,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
]);
if (!strength.isAcceptable) {
return apiError(
strength.feedback[0] || "Passwort zu schwach (Score < 3)",
strength.feedback[0] || "Password too weak (score < 3)",
422,
);
}
+1 -1
View File
@@ -28,7 +28,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
});
if (existingUser && existingUser.id !== user.id) {
return apiError("E-Mail-Adresse wird bereits verwendet", 409);
return apiError("Email already in use", 409);
}
}
+4 -4
View File
@@ -18,7 +18,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
{
data: null,
error:
"Zu viele Registrierungsversuche. Bitte später erneut versuchen.",
"Too many registration attempts. Please try again later.",
},
{ status: 429, headers: rateLimitHeaders(rl) },
);
@@ -42,7 +42,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
// Table may not exist yet; allow registration
}
if (!registrationEnabled) {
return apiError("Registrierung ist deaktiviert", 403);
return apiError("Registration is disabled", 403);
}
const { data: body, error: jsonError } = await safeJson(request);
@@ -62,14 +62,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
prisma.user.findUnique({ where: { username } }),
]);
if (existingEmail || existingUsername) {
return apiError("Benutzername oder E-Mail-Adresse bereits vergeben", 409);
return apiError("Username or email already taken", 409);
}
// Validate password strength
const strength = checkPasswordStrength(password, [username, email]);
if (!strength.isAcceptable) {
return apiError(
strength.feedback[0] || "Passwort zu schwach (Score < 3)",
strength.feedback[0] || "Password too weak (score < 3)",
422,
);
}
+11 -11
View File
@@ -9,10 +9,10 @@ import { NextRequest } from "next/server";
import { z } from "zod/v4";
const bugReportSchema = z.object({
description: z.string().min(10, "Beschreibung zu kurz").max(5000),
description: z.string().min(10, "Description too short").max(5000),
screenshot: z
.string()
.max(7_000_000, "Screenshot zu groß (max. 5 MB)")
.max(7_000_000, "Screenshot too large (max 5 MB)")
.optional(),
});
@@ -21,7 +21,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
const rl = await checkRateLimit(`bugreport:${user.id}`, 3, 60 * 60 * 1000);
if (!rl.allowed) {
return apiError("Maximal 3 Bugreports pro Stunde", 429);
return apiError("Maximum 3 bug reports per hour", 429);
}
annotate({ action: { name: "bugreport.submit" } });
@@ -48,7 +48,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (!ghToken || !ghRepo) {
return apiError(
"Bugreport ist nicht konfiguriert (GitHub-Issue-Token/Repository in Admin-Einstellungen fehlt)",
"Bug report not configured (GitHub issue token/repository missing in admin settings)",
500,
);
}
@@ -75,15 +75,15 @@ export const POST = apiHandler(async (request: NextRequest) => {
.slice(0, 5000);
// Build issue body
let issueBody = `**Gemeldet von:** ${safeUsername}\n`;
issueBody += `**Datum:** ${dateStr}\n\n`;
issueBody += `## Beschreibung\n\n${safeDescription}\n`;
issueBody += `\n---\n*Erstellt über die HealthLog Bugreport-Funktion*`;
let issueBody = `**Reported by:** ${safeUsername}\n`;
issueBody += `**Date:** ${dateStr}\n\n`;
issueBody += `## Description\n\n${safeDescription}\n`;
issueBody += `\n---\n*Created via HealthLog bug report*`;
// If screenshot provided, upload to GitHub as a gist or include as base64
// For simplicity, we'll note there's a screenshot but GitHub issues don't support inline base64
if (screenshot) {
issueBody += `\n\n**Screenshot:** Ein Screenshot wurde angehängt (siehe Kommentar).`;
issueBody += `\n\n**Screenshot:** A screenshot was attached (see comment).`;
}
// Create the issue
@@ -107,7 +107,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (!issueRes.ok) {
const errBody = await issueRes.text();
getEvent()?.addWarning("GitHub issue creation failed: " + errBody);
return apiError("Issue konnte nicht erstellt werden", 500);
return apiError("Failed to create issue", 500);
}
const issue = (await issueRes.json()) as {
@@ -123,7 +123,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
// Upload screenshot as issue comment with embedded image
// GitHub doesn't support base64 in comments, so we note the limitation
const commentBody = `### Screenshot\n\n![Screenshot](data:image/${ext};base64,${base64Data.slice(0, 100)}...)\n\n*Hinweis: Der vollständige Screenshot wurde aus Größengründen gekürzt. Bitte den Nutzer direkt kontaktieren.*`;
const commentBody = `### Screenshot\n\n![Screenshot](data:image/${ext};base64,${base64Data.slice(0, 100)}...)\n\n*Note: The full screenshot was truncated due to size limits. Please contact the user directly.*`;
await fetch(
`https://api.github.com/repos/${ghRepo}/issues/${issue.number}/comments`,
+1 -1
View File
@@ -16,7 +16,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
const rl = await checkRateLimit(`doctor-report:${user.id}`, 10, 60 * 60 * 1000);
if (!rl.allowed) {
return apiError("Maximal 10 Reports pro Stunde", 429);
return apiError("Maximum 10 reports per hour", 429);
}
const { data: body, error: jsonError } = await safeJson(request);
+3 -3
View File
@@ -26,7 +26,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
const rl = await checkRateLimit(`export:${user.id}`, 10, 60 * 60 * 1000);
if (!rl.allowed) {
return apiError("Maximal 10 Exports pro Stunde", 429);
return apiError("Maximum 10 exports per hour", 429);
}
annotate({ action: { name: "export.download" } });
@@ -36,10 +36,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
const type = searchParams.get("type") ?? "all";
if (!["csv", "json"].includes(format)) {
return apiError("Format muss csv oder json sein", 422);
return apiError("Format must be csv or json", 422);
}
if (!["measurements", "medications", "intake", "mood", "all"].includes(type)) {
return apiError("Typ muss measurements, medications, intake, mood oder all sein", 422);
return apiError("Type must be measurements, medications, intake, mood, or all", 422);
}
const userId = user.id;
+3 -3
View File
@@ -19,7 +19,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "ingest.medication" } });
if (!(await isApiGloballyEnabled())) {
return apiError("API ist global deaktiviert", 403);
return apiError("API is globally disabled", 403);
}
const ip = getClientIp(request);
@@ -109,12 +109,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
if (!medication) {
return apiError("Medikament nicht gefunden", 404);
return apiError("Medication not found", 404);
}
const medicationScope = `medication:${medication.id}:ingest`;
if (!apiToken.permissions.includes(medicationScope)) {
return apiError("API-Endpoint fuer dieses Medikament ist deaktiviert", 403);
return apiError("API endpoint for this medication is disabled", 403);
}
const event = await prisma.medicationIntakeEvent.create({
+6 -6
View File
@@ -27,7 +27,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
const rl = await checkRateLimit(`insights:${userId}`, 2, 60 * 60 * 1000);
if (!rl.allowed) {
return apiError(
"Maximal 2 Insights-Generierungen pro Stunde. Bitte spaeter erneut versuchen.",
"Maximum 2 insight generations per hour. Please try again later.",
429,
);
}
@@ -44,7 +44,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (!dbUser?.openaiKeyEncrypted) {
return apiError(
"Kein OpenAI API-Key hinterlegt. Bitte in den Einstellungen konfigurieren.",
"No OpenAI API key configured. Please set it in settings.",
422,
);
}
@@ -105,23 +105,23 @@ export const POST = apiHandler(async (request: NextRequest) => {
const errBody = await openaiRes.text();
annotate({ meta: { openai_status: openaiRes.status, openai_error: errBody } });
if (openaiRes.status === 401) {
return apiError("Ungueltiger OpenAI API-Key", 422);
return apiError("Invalid OpenAI API key", 422);
}
return apiError("OpenAI-Anfrage fehlgeschlagen", 502);
return apiError("OpenAI request failed", 502);
}
const openaiJson = await openaiRes.json();
const content = openaiJson.choices?.[0]?.message?.content;
if (!content) {
return apiError("Keine Antwort von OpenAI erhalten", 502);
return apiError("No response from OpenAI", 502);
}
let insights: InsightsOutput;
try {
insights = JSON.parse(content);
} catch {
return apiError("OpenAI-Antwort konnte nicht verarbeitet werden", 502);
return apiError("Failed to parse OpenAI response", 502);
}
// Cache the result
+3 -3
View File
@@ -51,7 +51,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
} else {
if (!key.startsWith("sk-")) {
return apiError(
"Ungueltiges API-Key-Format (muss mit sk- beginnen)",
"Invalid API key format (must start with sk-)",
422,
);
}
@@ -63,7 +63,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
if (typeof body.privacyMode === "string") {
const mode = body.privacyMode as string;
if (!["aggregated", "raw"].includes(mode)) {
return apiError("Ungueltiger Privacy-Modus", 422);
return apiError("Invalid privacy mode", 422);
}
data.insightsPrivacyMode = mode;
// Clear cache when privacy mode changes
@@ -72,7 +72,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
}
if (Object.keys(data).length === 0) {
return apiError("Keine Aenderungen", 422);
return apiError("No changes", 422);
}
await prisma.user.update({
+24 -24
View File
@@ -161,7 +161,7 @@ export const GET = apiHandler(async () => {
}
targets.push({
type: "WEIGHT",
label: "Gewicht",
label: "Weight",
current: latestByType.WEIGHT ?? null,
average30: avg30ByType.WEIGHT ?? null,
trend: computeTrend("WEIGHT"),
@@ -186,7 +186,7 @@ export const GET = apiHandler(async () => {
}
targets.push({
type: "BLOOD_PRESSURE",
label: "Blutdruck",
label: "Blood pressure",
current: latestByType.BLOOD_PRESSURE_SYS ?? null,
average30: avg30ByType.BLOOD_PRESSURE_SYS ?? null,
trend: computeTrend("BLOOD_PRESSURE_SYS"),
@@ -227,7 +227,7 @@ export const GET = apiHandler(async () => {
targets.push({
type: "BLOOD_PRESSURE_IN_TARGET",
label: "Blutdruck im Zielbereich",
label: "Blood pressure on target",
current: bpInTargetRate,
average30: bpInTargetRate,
trend: null,
@@ -236,10 +236,10 @@ export const GET = apiHandler(async () => {
classification:
bpInTargetRate != null
? bpInTargetRate >= 70
? { category: "Gut", color: "#50fa7b" }
? { category: "Good", color: "#50fa7b" }
: bpInTargetRate >= 40
? { category: "Moderat", color: "#f1fa8c" }
: { category: "Niedrig", color: "#ff5555" }
? { category: "Moderate", color: "#f1fa8c" }
: { category: "Low", color: "#ff5555" }
: null,
source: "ESC/ESH 2018",
});
@@ -254,7 +254,7 @@ export const GET = apiHandler(async () => {
}
targets.push({
type: "PULSE",
label: "Ruhepuls",
label: "Resting pulse",
current: latestByType.PULSE ?? null,
average30: avg30ByType.PULSE ?? null,
trend: computeTrend("PULSE"),
@@ -273,7 +273,7 @@ export const GET = apiHandler(async () => {
}
targets.push({
type: "SLEEP_DURATION",
label: "Schlafdauer",
label: "Sleep duration",
current: latestByType.SLEEP_DURATION ?? null,
average30: avg30ByType.SLEEP_DURATION ?? null,
trend: computeTrend("SLEEP_DURATION"),
@@ -335,7 +335,7 @@ export const GET = apiHandler(async () => {
}
targets.push({
type: "BODY_FAT",
label: "Koerperfett",
label: "Body fat",
current: latestByType.BODY_FAT ?? null,
average30: avg30ByType.BODY_FAT ?? null,
trend: computeTrend("BODY_FAT"),
@@ -354,11 +354,11 @@ export const GET = apiHandler(async () => {
}
targets.push({
type: "ACTIVITY_STEPS",
label: "Schritte/Tag",
label: "Steps/day",
current: latestByType.ACTIVITY_STEPS ?? null,
average30: avg30ByType.ACTIVITY_STEPS ?? null,
trend: computeTrend("ACTIVITY_STEPS"),
unit: "Schritte",
unit: "steps",
range: stepsRange,
classification: stepsClassification,
source: "WHO",
@@ -453,7 +453,7 @@ export const GET = apiHandler(async () => {
targets.push({
type: "MEDICATION_COMPLIANCE",
label: "Einnahmetreue",
label: "Medication compliance",
current: complianceRate7,
average30: complianceRate30,
trend: null,
@@ -462,12 +462,12 @@ export const GET = apiHandler(async () => {
classification:
complianceRate7 != null
? complianceRate7 >= 90
? { category: "Sehr gut", color: "#50fa7b" }
? { category: "Very good", color: "#50fa7b" }
: complianceRate7 >= 70
? { category: "Gut", color: "#f1fa8c" }
: { category: "Niedrig", color: "#ff5555" }
? { category: "Good", color: "#f1fa8c" }
: { category: "Low", color: "#ff5555" }
: null,
source: "7-Tage",
source: "7-day",
details: {
medications: medicationStats.map((medication) => ({
name: medication.name,
@@ -523,15 +523,15 @@ export const GET = apiHandler(async () => {
const moodClassification =
latestMoodScore != null
? latestMoodScore >= 3.5
? { category: "Gut", color: "#50fa7b" }
? { category: "Good", color: "#50fa7b" }
: latestMoodScore >= 2
? { category: "Moderat", color: "#f1fa8c" }
: { category: "Niedrig", color: "#ff5555" }
? { category: "Moderate", color: "#f1fa8c" }
: { category: "Low", color: "#ff5555" }
: null;
targets.push({
type: "MOOD_SCORE",
label: "Stimmung",
label: "Mood",
current: latestMoodScore,
average30: moodAvg30,
trend: moodTrend,
@@ -551,14 +551,14 @@ export const GET = apiHandler(async () => {
const stabilityClassification =
stdDev <= 0.5
? { category: "Sehr stabil", color: "#50fa7b" }
? { category: "Very stable", color: "#50fa7b" }
: stdDev <= 1.0
? { category: "Stabil", color: "#f1fa8c" }
: { category: "Schwankend", color: "#ff5555" };
? { category: "Stable", color: "#f1fa8c" }
: { category: "Fluctuating", color: "#ff5555" };
targets.push({
type: "MOOD_STABILITY",
label: "Stimmungsstabilitaet",
label: "Mood stability",
current: stdDev,
average30: stdDev,
trend: null,
@@ -17,7 +17,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
select: { moodLogGlobal: true },
});
if (appSettings && !appSettings.moodLogGlobal) {
return apiError("moodLog-Integration ist deaktiviert", 403);
return apiError("moodLog integration is disabled", 403);
}
let fullSync = false;
+3 -3
View File
@@ -18,7 +18,7 @@ export const GET = apiHandler(async (_request: NextRequest, { params }: RoutePar
});
if (!measurement || measurement.userId !== user.id) {
return apiError("Messwert nicht gefunden", 404);
return apiError("Measurement not found", 404);
}
annotate({
@@ -42,7 +42,7 @@ export const PUT = apiHandler(async (request: NextRequest, { params }: RoutePara
});
if (!existing || existing.userId !== user.id) {
return apiError("Messwert nicht gefunden", 404);
return apiError("Measurement not found", 404);
}
const { data: body, error: jsonError } = await safeJson(request);
@@ -90,7 +90,7 @@ export const DELETE = apiHandler(async (request: NextRequest, { params }: RouteP
});
if (!existing || existing.userId !== user.id) {
return apiError("Messwert nicht gefunden", 404);
return apiError("Measurement not found", 404);
}
await prisma.measurement.delete({ where: { id } });
+1 -1
View File
@@ -134,7 +134,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") {
return apiError("Ein Messwert mit diesen Daten existiert bereits", 409);
return apiError("A measurement with this data already exists", 409);
}
throw err;
}
@@ -17,13 +17,13 @@ function medicationScope(medicationId: string): string {
export const GET = apiHandler(async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
if (!(await isApiGloballyEnabled())) {
return apiError("API ist global deaktiviert", 403);
return apiError("API is globally disabled", 403);
}
const { id } = await params;
const medication = await prisma.medication.findUnique({ where: { id } });
if (!medication || medication.userId !== user.id) {
return apiError("Medikament nicht gefunden", 404);
return apiError("Medication not found", 404);
}
const scope = medicationScope(id);
@@ -56,13 +56,13 @@ export const GET = apiHandler(async (_request: NextRequest, { params }: RoutePar
export const PUT = apiHandler(async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
if (!(await isApiGloballyEnabled())) {
return apiError("API ist global deaktiviert", 403);
return apiError("API is globally disabled", 403);
}
const { id } = await params;
const medication = await prisma.medication.findUnique({ where: { id } });
if (!medication || medication.userId !== user.id) {
return apiError("Medikament nicht gefunden", 404);
return apiError("Medication not found", 404);
}
let enabled = false;
@@ -70,7 +70,7 @@ export const PUT = apiHandler(async (request: NextRequest, { params }: RoutePara
const body = await request.json();
enabled = body?.enabled === true;
} catch {
return apiError("Ungültige Anfrage", 422);
return apiError("Invalid request", 422);
}
const scope = medicationScope(id);
@@ -20,7 +20,7 @@ export const GET = apiHandler(async (_request: Request, { params }: RouteParams)
});
if (!medication || medication.userId !== user.id) {
return apiError("Medikament nicht gefunden", 404);
return apiError("Medication not found", 404);
}
const events = await prisma.medicationIntakeEvent.findMany({
@@ -22,7 +22,7 @@ export const PUT = apiHandler(async (request: NextRequest, { params }: RoutePara
event.userId !== user.id ||
event.medicationId !== id
) {
return apiError("Einnahme nicht gefunden", 404);
return apiError("Intake not found", 404);
}
const { data: body, error: jsonError } = await safeJson(request);
@@ -77,7 +77,7 @@ export const DELETE = apiHandler(async (request: NextRequest, { params }: RouteP
event.userId !== user.id ||
event.medicationId !== id
) {
return apiError("Einnahme nicht gefunden", 404);
return apiError("Intake not found", 404);
}
await prisma.medicationIntakeEvent.delete({ where: { id: eventId } });
@@ -22,7 +22,7 @@ export const POST = apiHandler(async (request: NextRequest, { params }: RoutePar
const { id } = await params;
const medication = await prisma.medication.findUnique({ where: { id } });
if (!medication || medication.userId !== user.id) {
return apiError("Medikament nicht gefunden", 404);
return apiError("Medication not found", 404);
}
const { data: body, error: jsonError } = await safeJson(request);
@@ -39,7 +39,7 @@ export const POST = apiHandler(async (request: NextRequest, { params }: RoutePar
const parsed = importSchema.safeParse(payload);
if (!parsed.success) {
return apiError(
`Ungültiges Format: ${parsed.error.issues[0].message}`,
`Invalid format: ${parsed.error.issues[0].message}`,
422,
);
}
@@ -13,7 +13,7 @@ export const DELETE = apiHandler(async (request: NextRequest, { params }: RouteP
const { id } = await params;
const medication = await prisma.medication.findUnique({ where: { id } });
if (!medication || medication.userId !== user.id) {
return apiError("Medikament nicht gefunden", 404);
return apiError("Medication not found", 404);
}
const { count } = await prisma.medicationIntakeEvent.deleteMany({
+2 -2
View File
@@ -17,7 +17,7 @@ export const POST = apiHandler(async (request: NextRequest, { params }: RoutePar
const { id } = await params;
const medication = await prisma.medication.findUnique({ where: { id } });
if (!medication || medication.userId !== user.id) {
return apiError("Medikament nicht gefunden", 404);
return apiError("Medication not found", 404);
}
const { data: body, error: jsonError } = await safeJson(request);
@@ -108,7 +108,7 @@ export const GET = apiHandler(async (request: NextRequest, { params }: RoutePara
const { id } = await params;
const medication = await prisma.medication.findUnique({ where: { id } });
if (!medication || medication.userId !== user.id) {
return apiError("Medikament nicht gefunden", 404);
return apiError("Medication not found", 404);
}
const searchParams = Object.fromEntries(request.nextUrl.searchParams);
@@ -16,7 +16,7 @@ export const GET = apiHandler(async (_request: NextRequest, { params }: RoutePar
select: { userId: true },
});
if (!medication || medication.userId !== user.id) {
return apiError("Medikament nicht gefunden", 404);
return apiError("Medication not found", 404);
}
const config = await prisma.reminderPhaseConfig.findUnique({
@@ -55,7 +55,7 @@ export const PUT = apiHandler(async (request: NextRequest, { params }: RoutePara
select: { userId: true },
});
if (!medication || medication.userId !== user.id) {
return apiError("Medikament nicht gefunden", 404);
return apiError("Medication not found", 404);
}
const { data: body, error: jsonError } = await safeJson(request);
@@ -63,7 +63,7 @@ export const PUT = apiHandler(async (request: NextRequest, { params }: RoutePara
if (jsonError) return jsonError;
const parsed = phaseConfigSchema.safeParse(body);
if (!parsed.success) {
return apiError("Ungültige Eingabe", 400);
return apiError("Invalid input", 400);
}
const config = await prisma.reminderPhaseConfig.upsert({
@@ -95,7 +95,7 @@ export const DELETE = apiHandler(async (_request: NextRequest, { params }: Route
select: { userId: true },
});
if (!medication || medication.userId !== user.id) {
return apiError("Medikament nicht gefunden", 404);
return apiError("Medication not found", 404);
}
await prisma.reminderPhaseConfig.deleteMany({
+3 -3
View File
@@ -24,7 +24,7 @@ export const GET = apiHandler(async (_request: NextRequest, { params }: RoutePar
});
if (!medication || medication.userId !== user.id) {
return apiError("Medikament nicht gefunden", 404);
return apiError("Medication not found", 404);
}
let category = "OTHER";
@@ -52,7 +52,7 @@ export const PUT = apiHandler(async (request: NextRequest, { params }: RoutePara
const { id } = await params;
const existing = await prisma.medication.findUnique({ where: { id } });
if (!existing || existing.userId !== user.id) {
return apiError("Medikament nicht gefunden", 404);
return apiError("Medication not found", 404);
}
const { data: body, error: jsonError } = await safeJson(request);
@@ -171,7 +171,7 @@ export const DELETE = apiHandler(async (request: NextRequest, { params }: RouteP
const { id } = await params;
const existing = await prisma.medication.findUnique({ where: { id } });
if (!existing || existing.userId !== user.id) {
return apiError("Medikament nicht gefunden", 404);
return apiError("Medication not found", 404);
}
// Revoke API tokens scoped to this medication
+3 -3
View File
@@ -32,11 +32,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
try {
body = await request.json();
} catch {
return apiError("Ungültige JSON-Daten", 422);
return apiError("Invalid JSON data", 422);
}
const parsed = clientErrorSchema.safeParse(body);
if (!parsed.success) return apiError("Ungültige Fehlerdaten", 422);
if (!parsed.success) return apiError("Invalid error data", 422);
const delivery = await sendGlitchtipEvent({
dsn: settings.glitchtipDsn,
@@ -54,7 +54,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (!delivery.ok) {
getEvent()?.addWarning("Glitchtip event rejected: " + delivery.method + " " + delivery.status + " " + delivery.details);
return apiError("Glitchtip konnte Fehler nicht annehmen", 502);
return apiError("Glitchtip could not accept error", 502);
}
return apiSuccess({ sent: true });
+3 -3
View File
@@ -34,7 +34,7 @@ export const GET = apiHandler(async (_request: NextRequest, { params }: RoutePar
const entry = await prisma.moodEntry.findUnique({ where: { id } });
if (!entry || entry.userId !== user.id) {
return apiError("Stimmungseintrag nicht gefunden", 404);
return apiError("Mood entry not found", 404);
}
annotate({ action: { name: "mood-entries.get" }, meta: { moodEntryId: id } });
@@ -50,7 +50,7 @@ export const PUT = apiHandler(async (request: NextRequest, { params }: RoutePara
const existing = await prisma.moodEntry.findUnique({ where: { id } });
if (!existing || existing.userId !== user.id) {
return apiError("Stimmungseintrag nicht gefunden", 404);
return apiError("Mood entry not found", 404);
}
const { data: body, error: jsonError } = await safeJson(request);
@@ -100,7 +100,7 @@ export const DELETE = apiHandler(async (request: NextRequest, { params }: RouteP
const existing = await prisma.moodEntry.findUnique({ where: { id } });
if (!existing || existing.userId !== user.id) {
return apiError("Stimmungseintrag nicht gefunden", 404);
return apiError("Mood entry not found", 404);
}
await prisma.moodEntry.delete({ where: { id } });
+1 -1
View File
@@ -113,7 +113,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") {
return apiError(
"Ein Stimmungseintrag mit diesen Daten existiert bereits",
"A mood entry with this data already exists",
409,
);
}
@@ -98,18 +98,18 @@ export const PUT = apiHandler(async (request: NextRequest) => {
try {
body = await request.json();
} catch {
return apiError("Ungültige JSON-Daten", 422);
return apiError("Invalid JSON data", 422);
}
const parsed = notificationPreferenceSchema.safeParse(body);
if (!parsed.success) return apiError("Ungültige Daten", 422);
if (!parsed.success) return apiError("Invalid data", 422);
const { channelId, eventType, enabled } = parsed.data;
const channel = await prisma.notificationChannel.findFirst({
where: { id: channelId, userId: user.id },
});
if (!channel) return apiError("Kanal nicht gefunden", 404);
if (!channel) return apiError("Channel not found", 404);
const preference = await prisma.notificationPreference.upsert({
where: {
+4 -4
View File
@@ -30,11 +30,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
try {
body = await request.json();
} catch {
return apiError("Ungültige JSON-Daten", 422);
return apiError("Invalid JSON data", 422);
}
const parsed = subscribeSchema.safeParse(body);
if (!parsed.success) return apiError("Ungültige Daten", 422);
if (!parsed.success) return apiError("Invalid data", 422);
const { endpoint, keys } = parsed.data;
const userAgent = request.headers.get("user-agent") ?? undefined;
@@ -92,11 +92,11 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
try {
body = await request.json();
} catch {
return apiError("Ungültige JSON-Daten", 422);
return apiError("Invalid JSON data", 422);
}
const parsed = unsubscribeSchema.safeParse(body);
if (!parsed.success) return apiError("Ungültige Daten", 422);
if (!parsed.success) return apiError("Invalid data", 422);
await prisma.pushSubscription.deleteMany({
where: {
+1 -1
View File
@@ -24,7 +24,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (jsonError) return jsonError;
const result = z.safeParse(onboardingSchema, body);
if (!result.success) {
return apiError("Ungültige Eingabe", 422);
return apiError("Invalid input", 422);
}
const data: Record<string, unknown> = {
+3 -3
View File
@@ -20,12 +20,12 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
const body = await request.json();
confirm = typeof body?.confirm === "string" ? body.confirm : "";
} catch {
return apiError("Ungueltige Anfrage", 422);
return apiError("Invalid request", 422);
}
if (confirm !== "DELETE_ACCOUNT") {
return apiError(
"Bestaetigung fehlt. Sende { confirm: 'DELETE_ACCOUNT' }",
"Confirmation missing. Send { confirm: 'DELETE_ACCOUNT' }",
422,
);
}
@@ -35,7 +35,7 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
const adminCount = await prisma.user.count({ where: { role: "ADMIN" } });
if (adminCount <= 1) {
return apiError(
"Der letzte Admin-Account kann nicht geloescht werden",
"The last admin account cannot be deleted",
400,
);
}
+2 -2
View File
@@ -18,11 +18,11 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
const body = await request.json();
confirm = typeof body?.confirm === "string" ? body.confirm : "";
} catch {
return apiError("Ungueltige Anfrage", 422);
return apiError("Invalid request", 422);
}
if (confirm !== "DELETE") {
return apiError("Bestaetigung fehlt", 422);
return apiError("Confirmation missing", 422);
}
const userId = user.id;
+1 -1
View File
@@ -21,7 +21,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const parsed = moodLogCredentialsSchema.safeParse(body);
if (!parsed.success) {
return apiError("Ungueltige Eingabe", 422);
return apiError("Invalid input", 422);
}
const { url, apiKey } = parsed.data;
+2 -2
View File
@@ -51,13 +51,13 @@ export const PUT = apiHandler(async (request: NextRequest) => {
if (jsonError) return jsonError;
const parsed = ntfySettingsSchema.safeParse(body);
if (!parsed.success) return apiError("Ungueltige Daten", 422);
if (!parsed.success) return apiError("Invalid data", 422);
const { serverUrl, topic, authToken, enabled } = parsed.data;
if (enabled && (!serverUrl || !topic)) {
return apiError(
"Server-URL und Topic sind erforderlich, wenn ntfy aktiviert ist",
"Server URL and topic are required when ntfy is enabled",
422,
);
}
+5 -5
View File
@@ -15,7 +15,7 @@ export const POST = apiHandler(async () => {
const rl = await checkRateLimit(`ntfy-test:${user.id}`, 5, 5 * 60 * 1000);
if (!rl.allowed) {
return apiError("Maximal 5 Tests in 5 Minuten", 429);
return apiError("Maximum 5 tests in 5 minutes", 429);
}
const channel = await prisma.notificationChannel.findUnique({
@@ -25,13 +25,13 @@ export const POST = apiHandler(async () => {
});
if (!channel) {
return apiError("ntfy ist nicht konfiguriert", 400);
return apiError("ntfy is not configured", 400);
}
const config = JSON.parse(decrypt(channel.config)) as NtfyChannelConfig;
if (!config.serverUrl || !config.topic) {
return apiError("Server-URL und Topic sind erforderlich", 400);
return apiError("Server URL and topic are required", 400);
}
const success = await sendViaNtfy(config, {
@@ -39,11 +39,11 @@ export const POST = apiHandler(async () => {
userId: user.id,
title: "HealthLog Test",
message:
"HealthLog: Verbindung erfolgreich! ntfy-Benachrichtigungen sind aktiv.",
"HealthLog: Connection successful! ntfy notifications are active.",
});
if (!success) {
return apiError("Testnachricht konnte nicht gesendet werden", 500);
return apiError("Failed to send test message", 500);
}
annotate({ action: { name: "settings.ntfy.test" }, meta: { success: true } });
+9 -9
View File
@@ -47,14 +47,14 @@ export const PUT = apiHandler(async (request: NextRequest) => {
telegramEnabled: true,
},
});
if (!current) return apiError("Benutzer nicht gefunden", 404);
if (!current) return apiError("User not found", 404);
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const result = z.safeParse(telegramSettingsSchema, body);
if (!result.success) {
return apiError("Ungueltige Eingabe", 422);
return apiError("Invalid input", 422);
}
const { botToken, chatId, enabled } = result.data;
@@ -68,7 +68,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
if (enabled && (!hasTokenAfter || !hasChatIdAfter)) {
return apiError(
"Fuer aktiviertes Telegram werden Bot-Token und Chat-ID benoetigt",
"Bot token and chat ID are required when Telegram is enabled",
422,
);
}
@@ -82,7 +82,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const appUrl = process.env.APP_URL ?? process.env.NEXT_PUBLIC_APP_URL;
if (!appUrl) {
return apiError(
"Server-Konfiguration fehlerhaft: APP_URL (oder NEXT_PUBLIC_APP_URL) fehlt.",
"Server configuration error: APP_URL (or NEXT_PUBLIC_APP_URL) is missing.",
500,
);
}
@@ -92,14 +92,14 @@ export const PUT = apiHandler(async (request: NextRequest) => {
appBaseUrl = new URL(appUrl);
} catch {
return apiError(
"Server-Konfiguration fehlerhaft: APP_URL ist ungueltig.",
"Server configuration error: APP_URL is invalid.",
500,
);
}
if (appBaseUrl.protocol !== "https:") {
return apiError(
"Telegram-Webhook benoetigt eine oeffentliche HTTPS-URL (kein http/localhost).",
"Telegram webhook requires a public HTTPS URL (no http/localhost).",
422,
);
}
@@ -107,7 +107,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const localHosts = new Set(["localhost", "127.0.0.1", "::1"]);
if (localHosts.has(appBaseUrl.hostname.toLowerCase())) {
return apiError(
"Telegram-Webhook benoetigt eine oeffentlich erreichbare Domain (localhost ist nicht erlaubt).",
"Telegram webhook requires a publicly reachable domain (localhost is not allowed).",
422,
);
}
@@ -117,7 +117,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
!["80", "88", "443", "8443"].includes(appBaseUrl.port)
) {
return apiError(
"Telegram-Webhook erlaubt nur die Ports 80, 88, 443 oder 8443.",
"Telegram webhook only allows ports 80, 88, 443, or 8443.",
422,
);
}
@@ -131,7 +131,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
);
if (!ok) {
return apiError(
"Telegram-Webhook konnte nicht gesetzt werden. Pruefe Bot-Token und Erreichbarkeit.",
"Failed to set Telegram webhook. Check bot token and reachability.",
422,
);
}
+4 -4
View File
@@ -14,7 +14,7 @@ export const POST = apiHandler(async () => {
const rl = await checkRateLimit(`telegram-test:${user.id}`, 5, 5 * 60 * 1000);
if (!rl.allowed) {
return apiError("Maximal 5 Tests in 5 Minuten", 429);
return apiError("Maximum 5 tests in 5 minutes", 429);
}
const dbUser = await prisma.user.findUnique({
@@ -24,7 +24,7 @@ export const POST = apiHandler(async () => {
if (!dbUser?.telegramBotToken || !dbUser?.telegramChatId) {
return apiError(
"Bot-Token und Chat-ID muessen zuerst gespeichert werden",
"Bot token and chat ID must be saved first",
422,
);
}
@@ -33,12 +33,12 @@ export const POST = apiHandler(async () => {
const ok = await sendTelegramMessage(
botToken,
dbUser.telegramChatId,
"HealthLog: Verbindung erfolgreich! Telegram-Benachrichtigungen sind aktiv.",
"HealthLog: Connection successful! Telegram notifications are active.",
);
if (!ok) {
return apiError(
"Nachricht konnte nicht gesendet werden. Pruefe Bot-Token und Chat-ID.",
"Failed to send message. Check bot token and chat ID.",
422,
);
}
+2 -2
View File
@@ -14,14 +14,14 @@ export const DELETE = apiHandler(async (_request: Request, { params }: RoutePara
annotate({ action: { name: "tokens.revoke" } });
if (!(await isApiGloballyEnabled())) {
return apiError("API ist global deaktiviert", 403);
return apiError("API is globally disabled", 403);
}
const { id } = await params;
const token = await prisma.apiToken.findUnique({ where: { id } });
if (!token || token.userId !== user.id) {
return apiError("Token nicht gefunden", 404);
return apiError("Token not found", 404);
}
await prisma.apiToken.update({
+3 -3
View File
@@ -9,7 +9,7 @@ import { NextRequest } from "next/server";
import { z } from "zod/v4";
const createTokenSchema = z.object({
name: z.string().min(1, "Name erforderlich").max(100),
name: z.string().min(1, "Name required").max(100),
expiresInDays: z.number().int().min(1).max(365).optional(),
});
@@ -18,7 +18,7 @@ export const GET = apiHandler(async () => {
annotate({ action: { name: "tokens.list" } });
if (!(await isApiGloballyEnabled())) {
return apiError("API ist global deaktiviert", 403);
return apiError("API is globally disabled", 403);
}
const tokens = await prisma.apiToken.findMany({
@@ -43,7 +43,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "tokens.create" } });
if (!(await isApiGloballyEnabled())) {
return apiError("API ist global deaktiviert", 403);
return apiError("API is globally disabled", 403);
}
const { data: body, error: jsonError } = await safeJson(request);
+1 -1
View File
@@ -41,7 +41,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
if (jsonError) return jsonError;
const result = z.safeParse(withingsCredentialsSchema, body);
if (!result.success) {
return apiError("Client-ID und Client-Secret sind erforderlich", 422);
return apiError("Client ID and Client Secret are required", 422);
}
await prisma.user.update({
+1 -1
View File
@@ -19,7 +19,7 @@ export const POST = apiHandler(async () => {
});
if (!connection) {
return apiError("Keine Withings-Verbindung vorhanden", 404);
return apiError("No Withings connection", 404);
}
// Try to unsubscribe webhook (best-effort)
@@ -17,7 +17,7 @@ import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useTranslations } from "@/lib/i18n/context";
export default function StimmungPage() {
export default function MoodPage() {
const { isAuthenticated, isLoading } = useAuth();
const router = useRouter();
const [dialogOpen, setDialogOpen] = useState(false);
@@ -471,7 +471,7 @@ function TargetCard({
);
}
export default function ZielwertePage() {
export default function TargetsPage() {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
+17 -21
View File
@@ -1,6 +1,7 @@
"use client";
import { useState, useMemo, useEffect, useRef } from "react";
import { useTranslations } from "@/lib/i18n/context";
interface DailyData {
expected: number;
@@ -19,21 +20,6 @@ interface ComplianceHeatmapProps {
const CELL_SIZE = 18;
const GAP = 3;
const WEEKDAY_LABELS = ["Mo", "", "Mi", "", "Fr", "", "So"];
const MONTH_LABELS = [
"Jan",
"Feb",
"Mär",
"Apr",
"Mai",
"Jun",
"Jul",
"Aug",
"Sep",
"Okt",
"Nov",
"Dez",
];
function getColor(data: DailyData): string {
if (data.expected === 0) return "var(--secondary)";
@@ -80,6 +66,7 @@ export function ComplianceHeatmap({
days = 90,
stretch = false,
}: ComplianceHeatmapProps) {
const { t } = useTranslations();
const containerRef = useRef<HTMLDivElement | null>(null);
const [containerWidth, setContainerWidth] = useState(0);
const [tooltip, setTooltip] = useState<{
@@ -88,6 +75,9 @@ export function ComplianceHeatmap({
text: string;
} | null>(null);
const WEEKDAY_LABELS = [
t("charts.weekdays.mon"), "", t("charts.weekdays.wed"), "", t("charts.weekdays.fri"), "", t("charts.weekdays.sun"),
];
useEffect(() => {
if (!stretch) return;
const element = containerRef.current;
@@ -107,6 +97,12 @@ export function ComplianceHeatmap({
}, [stretch]);
const { cells, weeks, monthMarkers } = useMemo(() => {
const MONTH_LABELS = [
t("charts.months.jan"), t("charts.months.feb"), t("charts.months.mar"),
t("charts.months.apr"), t("charts.months.may"), t("charts.months.jun"),
t("charts.months.jul"), t("charts.months.aug"), t("charts.months.sep"),
t("charts.months.oct"), t("charts.months.nov"), t("charts.months.dec"),
];
const now = new Date();
const cellList: Array<{
dateKey: string;
@@ -162,7 +158,7 @@ export function ComplianceHeatmap({
}
return { cells: cellList, weeks: col + 1, monthMarkers: markers };
}, [dailyCompliance, days]);
}, [dailyCompliance, days, t]);
const labelWidth = stretch ? 0 : 76;
const headerHeight = 18;
@@ -244,7 +240,7 @@ export function ComplianceHeatmap({
cell.data.late !== undefined ||
cell.data.veryLate !== undefined;
const timingInfo = hasTimingData
? ` | ${cell.data.onTime ?? 0} pünktlich, ${cell.data.late ?? 0} spät, ${cell.data.veryLate ?? 0} sehr spät`
? ` | ${cell.data.onTime ?? 0} ${t("charts.heatmapOnTime")}, ${cell.data.late ?? 0} ${t("charts.heatmapLate")}, ${cell.data.veryLate ?? 0} ${t("charts.heatmapVeryLate")}`
: "";
setTooltip({
x: e.clientX,
@@ -274,10 +270,10 @@ export function ComplianceHeatmap({
style={{ marginLeft: stretch ? 0 : labelWidth }}
>
{[
{ color: "var(--dracula-green)", label: "Pünktlich" },
{ color: "var(--dracula-yellow)", label: "Spät" },
{ color: "var(--dracula-orange)", label: "Sehr spät" },
{ color: "var(--dracula-red)", label: "Verpasst" },
{ color: "var(--dracula-green)", label: t("charts.legendOnTime") },
{ color: "var(--dracula-yellow)", label: t("charts.legendLate") },
{ color: "var(--dracula-orange)", label: t("charts.legendVeryLate") },
{ color: "var(--dracula-red)", label: t("charts.legendMissed") },
].map(({ color, label }) => (
<span key={label} className="flex items-center gap-1">
<div
+11 -9
View File
@@ -56,13 +56,7 @@ const TIME_RANGES_KEYS = [
{ labelKey: "charts.pointsAllLabel", points: 0, titleKey: "charts.pointsAllTitle" },
] as const;
const MOOD_LABELS: Record<number, string> = {
1: "Lausig",
2: "Schlecht",
3: "Okay",
4: "Gut",
5: "Super",
};
// MOOD_LABELS built dynamically via t() in the component
const VALUE_BANDS = [
{ min: 1, max: 2, color: "#ff5555", opacity: 0.16 },
@@ -231,13 +225,21 @@ export function MoodChart({ title }: MoodChartProps) {
const maxPointIndex = Math.max(0, (chartData?.length ?? 1) - 1);
const moodLabels: Record<number, string> = {
1: t("charts.moodLabel1"),
2: t("charts.moodLabel2"),
3: t("charts.moodLabel3"),
4: t("charts.moodLabel4"),
5: t("charts.moodLabel5"),
};
const formatMoodTick = (value: number): string => {
return MOOD_LABELS[value] ?? String(value);
return moodLabels[value] ?? String(value);
};
const formatTooltipValue = (value: number): string => {
const rounded = Math.round(value * 10) / 10;
const label = MOOD_LABELS[Math.round(value)];
const label = moodLabels[Math.round(value)];
return label ? `${rounded} (${label})` : String(rounded);
};
+12 -11
View File
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Sparkles, RefreshCw, Loader2, AlertTriangle } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
interface InsightsOutput {
changed: string;
@@ -32,6 +33,7 @@ const CONFIDENCE_COLORS: Record<string, string> = {
export function InsightsCard() {
const { isAuthenticated } = useAuth();
const queryClient = useQueryClient();
const { t } = useTranslations();
const [error, setError] = useState<string | null>(null);
// Check if insights are configured
@@ -81,7 +83,7 @@ export function InsightsCard() {
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Sparkles className="text-dracula-purple h-5 w-5" />
<CardTitle className="text-lg">KI-Insights</CardTitle>
<CardTitle className="text-lg">{t("insights.aiInsights")}</CardTitle>
</div>
<Button
variant="ghost"
@@ -94,7 +96,7 @@ export function InsightsCard() {
) : (
<RefreshCw className="mr-1 h-3.5 w-3.5" />
)}
{insights ? "Aktualisieren" : "Generieren"}
{insights ? t("insights.refreshButton") : t("insights.generateButton")}
</Button>
</div>
</CardHeader>
@@ -111,7 +113,7 @@ export function InsightsCard() {
<div className="flex items-center justify-center py-8">
<Loader2 className="text-dracula-purple h-6 w-6 animate-spin" />
<span className="text-muted-foreground ml-2 text-sm">
Analysiere Gesundheitsdaten...
{t("insights.analyzing")}
</span>
</div>
)}
@@ -120,19 +122,19 @@ export function InsightsCard() {
<div className="space-y-4">
<div className="flex items-center gap-2">
<Badge className={CONFIDENCE_COLORS[insights.confidence] ?? ""}>
Konfidenz: {insights.confidence}
{t("insights.confidence")}: {t(`insights.confidence_${insights.confidence}`)}
</Badge>
{generate.data?.cached && (
<Badge variant="outline" className="text-xs">
Gecacht
{t("insights.cached")}
</Badge>
)}
</div>
<Section title="Was hat sich verändert" text={insights.changed} />
<Section title="Was ist stabil" text={insights.stable} />
<Section title="Mögliche Zusammenhänge" text={insights.drivers} />
<Section title="Nächste Schritte" text={insights.nextSteps} />
<Section title={t("insights.sectionChanged")} text={insights.changed} />
<Section title={t("insights.sectionStable")} text={insights.stable} />
<Section title={t("insights.sectionDrivers")} text={insights.drivers} />
<Section title={t("insights.sectionNextSteps")} text={insights.nextSteps} />
<div className="bg-muted/50 rounded-lg p-3">
<p className="text-muted-foreground text-xs">
@@ -145,8 +147,7 @@ export function InsightsCard() {
{!insights && !generate.isPending && !error && (
<p className="text-muted-foreground py-4 text-center text-sm">
Klicke auf &quot;Generieren&quot; für KI-basierte Insights deiner
Gesundheitsdaten.
{t("insights.generatePrompt")}
</p>
)}
</CardContent>
+2 -2
View File
@@ -17,10 +17,10 @@ import { useTranslations } from "@/lib/i18n/context";
const navItems = [
{ href: "/", tKey: "nav.dashboard", icon: Home },
{ href: "/measurements", tKey: "nav.measurements", icon: Activity },
{ href: "/stimmung", tKey: "nav.mood", icon: Waves },
{ href: "/mood", tKey: "nav.mood", icon: Waves },
{ href: "/medications", tKey: "nav.medications", icon: Pill },
{ href: "/insights", tKey: "nav.insights", icon: Lightbulb },
{ href: "/zielwerte", tKey: "nav.targets", icon: Target },
{ href: "/targets", tKey: "nav.targets", icon: Target },
{ href: "/achievements", tKey: "nav.achievements", icon: Trophy },
];
+2 -2
View File
@@ -50,10 +50,10 @@ const STORAGE_KEY = "healthlog-sidebar-collapsed";
const navItems = [
{ href: "/", tKey: "nav.dashboard", icon: Home },
{ href: "/measurements", tKey: "nav.measurements", icon: Activity },
{ href: "/stimmung", tKey: "nav.mood", icon: Waves },
{ href: "/mood", tKey: "nav.mood", icon: Waves },
{ href: "/medications", tKey: "nav.medications", icon: Pill },
{ href: "/insights", tKey: "nav.insights", icon: Lightbulb },
{ href: "/zielwerte", tKey: "nav.targets", icon: Target },
{ href: "/targets", tKey: "nav.targets", icon: Target },
{ href: "/achievements", tKey: "nav.achievements", icon: Trophy },
];
@@ -72,12 +72,7 @@ interface IntakeHistoryListProps {
onCreateOpenChange?: (open: boolean) => void;
}
const SOURCE_LABELS: Record<string, string> = {
WEB: "Webseite",
API: "Externe API",
REMINDER: "Telegram / Erinnerung",
IMPORT: "CSV-Import",
};
// SOURCE_LABELS built dynamically via t() in the component
const PAGE_SIZE = 25;
@@ -97,6 +92,13 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
const queryClient = useQueryClient();
const { t } = useTranslations();
const sourceLabels: Record<string, string> = {
WEB: t("medications.sourceWeb"),
API: t("medications.sourceApi"),
REMINDER: t("medications.sourceReminder"),
IMPORT: t("medications.sourceImport"),
};
const [page, setPage] = useState(1);
const [sortBy, setSortBy] = useState<string>("scheduledFor");
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
@@ -219,7 +221,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
},
onError: (err) => {
setEditError(
err instanceof Error ? err.message : "Fehler beim Speichern",
err instanceof Error ? err.message : t("medications.saveError"),
);
},
});
@@ -257,7 +259,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
},
onError: (err) => {
setCreateError(
err instanceof Error ? err.message : "Fehler beim Speichern",
err instanceof Error ? err.message : t("medications.saveError"),
);
},
});
@@ -293,7 +295,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
await deleteMutation.mutateAsync(editing.id);
closeEdit();
} catch {
setEditError("Fehler beim Löschen");
setEditError(t("medications.deleteError"));
}
}
@@ -303,7 +305,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
const scheduledDate = new Date(editScheduledFor);
if (Number.isNaN(scheduledDate.getTime())) {
setEditError("Bitte einen gültigen Zeitpunkt wählen.");
setEditError(t("medications.invalidTimestamp"));
return;
}
@@ -323,7 +325,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
const scheduledDate = new Date(createScheduledFor);
if (Number.isNaN(scheduledDate.getTime())) {
setCreateError("Bitte einen gültigen Zeitpunkt wählen.");
setCreateError(t("medications.invalidTimestamp"));
return;
}
@@ -365,7 +367,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
if (!isAuthenticated) {
return (
<p className="text-muted-foreground text-sm">
Bitte anmelden, um Einnahmen zu sehen.
{t("medications.loginRequiredIntakes")}
</p>
);
}
@@ -387,7 +389,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
</div>
) : !data?.events.length ? (
<div className="text-muted-foreground flex h-32 items-center justify-center rounded-lg border border-dashed">
Noch keine Einnahmen vorhanden
{t("medications.noIntakesYet")}
</div>
) : (
<>
@@ -420,7 +422,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
className="w-28"
/>
<TableHead className="w-20 pr-4 text-right">
Aktionen
{t("medications.actions")}
</TableHead>
</TableRow>
</TableHeader>
@@ -438,7 +440,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
<TableCell>{getStatusBadge(event)}</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs">
{SOURCE_LABELS[event.source] ?? event.source}
{sourceLabels[event.source] ?? event.source}
</Badge>
</TableCell>
<TableCell className="pr-4 text-right">
@@ -485,7 +487,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
<div className="flex items-center gap-1.5">
{getStatusBadge(event)}
<Badge variant="outline" className="text-xs">
{SOURCE_LABELS[event.source] ?? event.source}
{sourceLabels[event.source] ?? event.source}
</Badge>
</div>
</div>
@@ -516,7 +518,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
{totalPages > 1 && (
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-sm">
Seite {page} von {totalPages}
{t("medications.pageInfo", { page, total: totalPages })}
</span>
<div className="flex gap-1">
<Button
@@ -614,7 +616,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
onClick={() => setEditDeleteDialogOpen(true)}
>
<Trash2 className="mr-2 h-4 w-4" />
Löschen
{t("common.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -628,7 +630,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
updateMutation.isPending || deleteMutation.isPending
}
>
Abbrechen
{t("common.cancel")}
</Button>
<Button
type="submit"
@@ -639,7 +641,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
{updateMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
Speichern
{t("common.save")}
</Button>
</div>
</div>
@@ -659,7 +661,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>
Abbrechen
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
@@ -669,7 +671,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
{deleteMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
Löschen
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -737,13 +739,13 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
onClick={closeCreate}
disabled={createMutation.isPending}
>
Abbrechen
{t("common.cancel")}
</Button>
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
Speichern
{t("common.save")}
</Button>
</div>
</form>
@@ -800,6 +802,7 @@ function DeleteButton({
description: string;
onConfirm: () => void;
}) {
const { t } = useTranslations();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
@@ -807,7 +810,7 @@ function DeleteButton({
variant="ghost"
size="icon"
className="text-destructive h-8 w-8"
aria-label="Löschen"
aria-label={label}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
@@ -818,12 +821,12 @@ function DeleteButton({
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={onConfirm}
>
Löschen
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
+18 -18
View File
@@ -51,17 +51,7 @@ import { toast } from "sonner";
import { useTranslations } from "@/lib/i18n/context";
import { PhaseConfigDialog } from "@/components/medications/phase-config-dialog";
const DOSE_UNITS = [
"mg",
"g",
"ml",
"Tropfen",
"Tablette(n)",
"Kapsel(n)",
"Stück",
"IE",
"µg",
] as const;
// DOSE_UNITS built dynamically via t() in the component
type TranslateFn = (
key: string,
@@ -245,6 +235,16 @@ export function MedicationForm({
}: MedicationFormProps) {
const queryClient = useQueryClient();
const { t } = useTranslations();
const doseUnits = [
"mg", "g", "ml",
t("medications.unitDrops"),
t("medications.unitTablets"),
t("medications.unitCapsules"),
t("medications.unitPieces"),
"IE", "µg",
];
const [name, setName] = useState(initial?.name ?? "");
const initialDose = parseDose(initial?.dose ?? "");
@@ -449,7 +449,7 @@ export function MedicationForm({
id="med-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="z.B. Ibuprofen"
placeholder={t("medications.namePlaceholder")}
required
maxLength={100}
/>
@@ -508,7 +508,7 @@ export function MedicationForm({
inputMode="decimal"
value={doseAmount}
onChange={(e) => setDoseAmount(e.target.value)}
placeholder="z.B. 400"
placeholder={t("medications.dosePlaceholder")}
required
maxLength={20}
/>
@@ -527,7 +527,7 @@ export function MedicationForm({
</div>
</div>
<datalist id="dose-units">
{DOSE_UNITS.map((u) => (
{doseUnits.map((u) => (
<option key={u} value={u} />
))}
</datalist>
@@ -641,7 +641,7 @@ export function MedicationForm({
value={s.label}
className="h-8 text-xs md:text-xs"
onChange={(e) => updateSchedule(i, "label", e.target.value)}
placeholder="z.B. Morgens"
placeholder={t("medications.labelPlaceholder")}
maxLength={50}
/>
</div>
@@ -653,7 +653,7 @@ export function MedicationForm({
value={s.dose}
className="h-8 text-xs md:text-xs"
onChange={(e) => updateSchedule(i, "dose", e.target.value)}
placeholder={dose || "Standard"}
placeholder={dose || t("medications.defaultDose")}
maxLength={50}
/>
</div>
@@ -721,8 +721,8 @@ export function MedicationForm({
}`}
title={
s.daysOfWeek.includes(dayIndex)
? `${label} deaktivieren`
: `${label} aktivieren`
? t("medications.dayDeactivate", { day: label })
: t("medications.dayActivate", { day: label })
}
>
{label}
+21 -20
View File
@@ -3,32 +3,26 @@
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { translateZxcvbn } from "@/lib/zxcvbn-de";
import { useTranslations } from "@/lib/i18n/context";
interface PasswordStrengthProps {
password: string;
minLength?: number;
}
const SCORE_CONFIG = [
{ label: "Sehr schwach", color: "bg-red-500", textColor: "text-red-500" },
{ label: "Schwach", color: "bg-orange-500", textColor: "text-orange-500" },
{
label: "Akzeptabel",
color: "bg-yellow-500",
textColor: "text-yellow-500",
},
{ label: "Stark", color: "bg-green-500", textColor: "text-green-500" },
{
label: "Sehr stark",
color: "bg-emerald-600",
textColor: "text-emerald-600",
},
const SCORE_COLORS = [
{ color: "bg-red-500", textColor: "text-red-500" },
{ color: "bg-orange-500", textColor: "text-orange-500" },
{ color: "bg-yellow-500", textColor: "text-yellow-500" },
{ color: "bg-green-500", textColor: "text-green-500" },
{ color: "bg-emerald-600", textColor: "text-emerald-600" },
];
export function PasswordStrength({
password,
minLength = 12,
}: PasswordStrengthProps) {
const { t } = useTranslations();
const [result, setResult] = useState<{
score: number;
feedback: { warning: string; suggestions: string[] };
@@ -60,13 +54,20 @@ export function PasswordStrength({
if (!password) return null;
const score = result?.score ?? 0;
const config = SCORE_CONFIG[score];
const colors = SCORE_COLORS[score];
const scoreLabels = [
t("passwordStrength.veryWeak"),
t("passwordStrength.weak"),
t("passwordStrength.acceptable"),
t("passwordStrength.strong"),
t("passwordStrength.veryStrong"),
];
const tooShort = password.length < minLength;
// Collect feedback
const feedback: string[] = [];
if (tooShort) {
feedback.push(`Mindestens ${minLength} Zeichen erforderlich.`);
feedback.push(t("passwordStrength.minLength", { count: minLength }));
}
if (result?.feedback.warning) {
feedback.push(translateZxcvbn(result.feedback.warning));
@@ -84,7 +85,7 @@ export function PasswordStrength({
key={i}
className={cn(
"h-1.5 flex-1 rounded-full transition-colors",
i <= score && !tooShort ? config.color : "bg-muted",
i <= score && !tooShort ? colors.color : "bg-muted",
)}
/>
))}
@@ -92,11 +93,11 @@ export function PasswordStrength({
{/* Label */}
<div className="flex items-center justify-between">
<span className={cn("text-xs font-medium", config.textColor)}>
{tooShort ? "Zu kurz" : config.label}
<span className={cn("text-xs font-medium", colors.textColor)}>
{tooShort ? t("passwordStrength.tooShort") : scoreLabels[score]}
</span>
<span className="text-muted-foreground text-xs">
{password.length}/{minLength}+ Zeichen
{password.length}/{minLength}+ {t("passwordStrength.characters")}
</span>
</div>
@@ -4,46 +4,46 @@ import { classifyBMI, classifyBP, generateAlerts } from "../classifications";
describe("classifyBMI", () => {
it("classifies underweight", () => {
const result = classifyBMI(17.5);
expect(result.category).toBe("Untergewicht");
expect(result.category).toBe("Underweight");
expect(result.severity).toBe("warning");
});
it("classifies normal weight", () => {
const result = classifyBMI(22);
expect(result.category).toBe("Normalgewicht");
expect(result.category).toBe("Normal");
expect(result.severity).toBe("normal");
});
it("classifies overweight", () => {
const result = classifyBMI(27);
expect(result.category).toBe("Übergewicht");
expect(result.category).toBe("Overweight");
expect(result.severity).toBe("warning");
});
it("classifies obesity grade I", () => {
const result = classifyBMI(32);
expect(result.category).toBe("Adipositas Grad I");
expect(result.category).toBe("Obesity Grade I");
expect(result.severity).toBe("danger");
});
it("classifies obesity grade II", () => {
const result = classifyBMI(37);
expect(result.category).toBe("Adipositas Grad II");
expect(result.category).toBe("Obesity Grade II");
expect(result.severity).toBe("danger");
});
it("classifies obesity grade III", () => {
const result = classifyBMI(42);
expect(result.category).toBe("Adipositas Grad III");
expect(result.category).toBe("Obesity Grade III");
expect(result.severity).toBe("danger");
});
it("handles boundary values", () => {
expect(classifyBMI(18.5).category).toBe("Normalgewicht");
expect(classifyBMI(25).category).toBe("Übergewicht");
expect(classifyBMI(30).category).toBe("Adipositas Grad I");
expect(classifyBMI(35).category).toBe("Adipositas Grad II");
expect(classifyBMI(40).category).toBe("Adipositas Grad III");
expect(classifyBMI(18.5).category).toBe("Normal");
expect(classifyBMI(25).category).toBe("Overweight");
expect(classifyBMI(30).category).toBe("Obesity Grade I");
expect(classifyBMI(35).category).toBe("Obesity Grade II");
expect(classifyBMI(40).category).toBe("Obesity Grade III");
});
});
@@ -62,32 +62,32 @@ describe("classifyBP", () => {
it("classifies high-normal", () => {
const result = classifyBP(135, 87);
expect(result.category).toBe("Hoch-normal");
expect(result.category).toBe("High-normal");
expect(result.severity).toBe("elevated");
});
it("classifies hypertension grade 1", () => {
const result = classifyBP(150, 95);
expect(result.category).toBe("Hypertonie Grad 1");
expect(result.category).toBe("Hypertension Grade 1");
expect(result.severity).toBe("warning");
});
it("classifies hypertension grade 2", () => {
const result = classifyBP(170, 105);
expect(result.category).toBe("Hypertonie Grad 2");
expect(result.category).toBe("Hypertension Grade 2");
expect(result.severity).toBe("danger");
});
it("classifies hypertension grade 3", () => {
const result = classifyBP(185, 115);
expect(result.category).toBe("Hypertonie Grad 3");
expect(result.category).toBe("Hypertension Grade 3");
expect(result.severity).toBe("danger");
});
it("uses the higher category when sys and dia differ", () => {
// Sys is optimal but dia is high
const result = classifyBP(115, 95);
expect(result.category).toBe("Hypertonie Grad 1");
expect(result.category).toBe("Hypertension Grade 1");
});
});
@@ -100,51 +100,51 @@ describe("generateAlerts", () => {
const alerts = generateAlerts({ bmi: 22 });
expect(alerts).toHaveLength(1);
expect(alerts[0].level).toBe("success");
expect(alerts[0].title).toContain("Normalbereich");
expect(alerts[0].title).toContain("normal range");
});
it("generates warning for underweight BMI", () => {
const alerts = generateAlerts({ bmi: 16 });
expect(alerts[0].level).toBe("warning");
expect(alerts[0].title).toContain("unter Normalbereich");
expect(alerts[0].title).toContain("below normal");
});
it("generates warning for obese BMI", () => {
const alerts = generateAlerts({ bmi: 32 });
expect(alerts[0].level).toBe("warning");
expect(alerts[0].title).toContain("Adipositas");
expect(alerts[0].title).toContain("obesity");
});
it("generates danger for very high BP", () => {
const alerts = generateAlerts({ bpAvgSys: 175, bpAvgDia: 105 });
const bpAlert = alerts.find((a) => a.title.includes("Blutdruck"));
const bpAlert = alerts.find((a) => a.title.includes("Blood pressure"));
expect(bpAlert?.level).toBe("danger");
});
it("generates success for good BP target adherence", () => {
const alerts = generateAlerts({ bpPctInTarget: 85 });
const targetAlert = alerts.find((a) =>
a.title.includes("Zielbereichstreue"),
a.title.includes("target adherence"),
);
expect(targetAlert?.level).toBe("success");
});
it("generates warning for low BP target adherence", () => {
const alerts = generateAlerts({ bpPctInTarget: 40 });
const targetAlert = alerts.find((a) => a.title.includes("außerhalb"));
const targetAlert = alerts.find((a) => a.title.includes("outside"));
expect(targetAlert?.level).toBe("warning");
});
it("generates info for significant weight increase", () => {
const alerts = generateAlerts({ weightSlope30: 0.1 }); // 0.7 kg/week
const weightAlert = alerts.find((a) => a.title.includes("Gewicht"));
const weightAlert = alerts.find((a) => a.title.includes("Weight"));
expect(weightAlert?.level).toBe("info");
expect(weightAlert?.title).toContain("steigend");
expect(weightAlert?.title).toContain("increasing");
});
it("generates warning for pulse anomalies", () => {
const alerts = generateAlerts({ pulseAnomalyCount: 5 });
const pulseAlert = alerts.find((a) => a.title.includes("Puls"));
const pulseAlert = alerts.find((a) => a.title.includes("Pulse"));
expect(pulseAlert?.level).toBe("warning");
});
+53 -53
View File
@@ -13,34 +13,34 @@ export interface BmiClassification {
export function classifyBMI(bmi: number): BmiClassification {
if (bmi < 18.5) {
return { category: "Untergewicht", color: "#f1fa8c", severity: "warning" };
return { category: "Underweight", color: "#f1fa8c", severity: "warning" };
}
if (bmi < 25) {
return { category: "Normalgewicht", color: "#50fa7b", severity: "normal" };
return { category: "Normal", color: "#50fa7b", severity: "normal" };
}
if (bmi < 30) {
return {
category: "Übergewicht",
category: "Overweight",
color: "#ffb86c",
severity: "warning",
};
}
if (bmi < 35) {
return {
category: "Adipositas Grad I",
category: "Obesity Grade I",
color: "#ff79c6",
severity: "danger",
};
}
if (bmi < 40) {
return {
category: "Adipositas Grad II",
category: "Obesity Grade II",
color: "#ff5555",
severity: "danger",
};
}
return {
category: "Adipositas Grad III",
category: "Obesity Grade III",
color: "#ff5555",
severity: "danger",
};
@@ -63,27 +63,27 @@ export function classifyBP(sys: number, dia: number): BpClassification {
}
if (sys < 140 && dia < 90) {
return {
category: "Hoch-normal",
category: "High-normal",
color: "#f1fa8c",
severity: "elevated",
};
}
if (sys < 160 && dia < 100) {
return {
category: "Hypertonie Grad 1",
category: "Hypertension Grade 1",
color: "#ffb86c",
severity: "warning",
};
}
if (sys < 180 && dia < 110) {
return {
category: "Hypertonie Grad 2",
category: "Hypertension Grade 2",
color: "#ff79c6",
severity: "danger",
};
}
return {
category: "Hypertonie Grad 3",
category: "Hypertension Grade 3",
color: "#ff5555",
severity: "danger",
};
@@ -99,15 +99,15 @@ export interface PulseClassification {
export function classifyPulse(bpm: number): PulseClassification {
if (bpm < 60) {
return { category: "Bradykardie", color: "#8be9fd", severity: "info" };
return { category: "Bradycardia", color: "#8be9fd", severity: "info" };
}
if (bpm <= 100) {
return { category: "Normal", color: "#50fa7b", severity: "normal" };
}
if (bpm <= 120) {
return { category: "Erhöht", color: "#ffb86c", severity: "warning" };
return { category: "Elevated", color: "#ffb86c", severity: "warning" };
}
return { category: "Tachykardie", color: "#ff5555", severity: "danger" };
return { category: "Tachycardia", color: "#ff5555", severity: "danger" };
}
// ── Sleep Duration Classification (AASM/SRS) ───────────
@@ -123,22 +123,22 @@ export function classifySleepDuration(
): SleepDurationClassification {
if (hours < 6) {
return {
category: "Deutlich zu kurz",
category: "Far too short",
color: "#ff5555",
severity: "warning",
};
}
if (hours < 7) {
return { category: "Zu kurz", color: "#ffb86c", severity: "warning" };
return { category: "Too short", color: "#ffb86c", severity: "warning" };
}
if (hours <= 9) {
return { category: "Zielbereich", color: "#50fa7b", severity: "normal" };
return { category: "On target", color: "#50fa7b", severity: "normal" };
}
if (hours <= 10) {
return { category: "Etwas lang", color: "#8be9fd", severity: "info" };
return { category: "Slightly long", color: "#8be9fd", severity: "info" };
}
return {
category: "Deutlich zu lang",
category: "Far too long",
color: "#ff79c6",
severity: "warning",
};
@@ -184,25 +184,25 @@ export function classifyBodyFat(
if (pct < essential) {
return {
category: "Essenziell",
category: "Essential",
color: "#f1fa8c",
severity: "warning",
};
}
if (pct <= athleteMax) {
return { category: "Athletisch", color: "#50fa7b", severity: "normal" };
return { category: "Athletic", color: "#50fa7b", severity: "normal" };
}
if (pct <= fitnessMax) {
return { category: "Fitness", color: "#50fa7b", severity: "normal" };
}
if (pct <= averageMax) {
return {
category: "Durchschnitt",
category: "Average",
color: "#ffb86c",
severity: "warning",
};
}
return { category: "Adipös", color: "#ff5555", severity: "danger" };
return { category: "Obese", color: "#ff5555", severity: "danger" };
}
// ── Activity Steps Classification (WHO) ─────────────────
@@ -216,25 +216,25 @@ export interface StepsClassification {
export function classifySteps(steps: number): StepsClassification {
if (steps < 3000) {
return {
category: "Sehr wenig",
category: "Very low",
color: "#ff5555",
severity: "warning",
};
}
if (steps < 5000) {
return { category: "Wenig aktiv", color: "#ffb86c", severity: "warning" };
return { category: "Low active", color: "#ffb86c", severity: "warning" };
}
if (steps < 7500) {
return {
category: "Moderat aktiv",
category: "Moderately active",
color: "#f1fa8c",
severity: "info",
};
}
if (steps < 10000) {
return { category: "Aktiv", color: "#50fa7b", severity: "normal" };
return { category: "Active", color: "#50fa7b", severity: "normal" };
}
return { category: "Sehr aktiv", color: "#50fa7b", severity: "normal" };
return { category: "Very active", color: "#50fa7b", severity: "normal" };
}
export function getStepsRange(): { min: number; max: number } {
@@ -315,26 +315,26 @@ export function generateAlerts(input: AlertInput): HealthAlert[] {
if (input.bmi < 18.5) {
alerts.push({
level: "warning",
title: "BMI unter Normalbereich",
message: `Ihr BMI von ${input.bmi} liegt unter dem Normalbereich (18,5–24,9).`,
title: "BMI below normal range",
message: `Your BMI of ${input.bmi} is below the normal range (18.5–24.9).`,
});
} else if (input.bmi >= 25 && input.bmi < 30) {
alerts.push({
level: "info",
title: "BMI im Bereich Übergewicht",
message: `Ihr BMI von ${input.bmi} liegt im Bereich Übergewicht. Regelmäßige Bewegung und ausgewogene Ernährung können helfen.`,
title: "BMI in overweight range",
message: `Your BMI of ${input.bmi} is in the overweight range. Regular exercise and a balanced diet can help.`,
});
} else if (input.bmi >= 30) {
alerts.push({
level: "warning",
title: "BMI im Bereich Adipositas",
message: `Ihr BMI von ${input.bmi} liegt im Bereich Adipositas.`,
title: "BMI in obesity range",
message: `Your BMI of ${input.bmi} is in the obesity range.`,
});
} else {
alerts.push({
level: "success",
title: "BMI im Normalbereich",
message: `Ihr BMI von ${input.bmi} liegt im gesunden Bereich.`,
title: "BMI in normal range",
message: `Your BMI of ${input.bmi} is in the healthy range.`,
});
}
}
@@ -345,14 +345,14 @@ export function generateAlerts(input: AlertInput): HealthAlert[] {
if (bpClass.severity === "danger") {
alerts.push({
level: "danger",
title: "Blutdruck deutlich erhöht",
message: `Ihr durchschnittlicher Blutdruck (${input.bpAvgSys}/${input.bpAvgDia}) wird als "${bpClass.category}" eingestuft.`,
title: "Blood pressure significantly elevated",
message: `Your average blood pressure (${input.bpAvgSys}/${input.bpAvgDia}) is classified as "${bpClass.category}".`,
});
} else if (bpClass.severity === "warning") {
alerts.push({
level: "warning",
title: "Blutdruck leicht erhöht",
message: `Ihr durchschnittlicher Blutdruck (${input.bpAvgSys}/${input.bpAvgDia}) liegt im Bereich "${bpClass.category}".`,
title: "Blood pressure slightly elevated",
message: `Your average blood pressure (${input.bpAvgSys}/${input.bpAvgDia}) is in the "${bpClass.category}" range.`,
});
}
}
@@ -362,14 +362,14 @@ export function generateAlerts(input: AlertInput): HealthAlert[] {
if (input.bpPctInTarget >= 80) {
alerts.push({
level: "success",
title: "Gute Blutdruck-Zielbereichstreue",
message: `${input.bpPctInTarget}% Ihrer Messungen liegen im Zielbereich.`,
title: "Good blood pressure target adherence",
message: `${input.bpPctInTarget}% of your readings are within the target range.`,
});
} else if (input.bpPctInTarget < 50) {
alerts.push({
level: "warning",
title: "Blutdruck oft außerhalb des Zielbereichs",
message: `Nur ${input.bpPctInTarget}% Ihrer Messungen liegen im Zielbereich.`,
title: "Blood pressure often outside target range",
message: `Only ${input.bpPctInTarget}% of your readings are within the target range.`,
});
}
}
@@ -380,14 +380,14 @@ export function generateAlerts(input: AlertInput): HealthAlert[] {
if (weeklyChange > 0.5) {
alerts.push({
level: "info",
title: "Gewicht steigend",
message: `Ihr Gewicht steigt um ca. ${weeklyChange.toFixed(1)} kg pro Woche in den letzten 30 Tagen.`,
title: "Weight increasing",
message: `Your weight is increasing by approx. ${weeklyChange.toFixed(1)} kg per week over the last 30 days.`,
});
} else if (weeklyChange < -0.5) {
alerts.push({
level: "info",
title: "Gewicht fallend",
message: `Ihr Gewicht sinkt um ca. ${Math.abs(weeklyChange).toFixed(1)} kg pro Woche in den letzten 30 Tagen.`,
title: "Weight decreasing",
message: `Your weight is decreasing by approx. ${Math.abs(weeklyChange).toFixed(1)} kg per week over the last 30 days.`,
});
}
}
@@ -396,8 +396,8 @@ export function generateAlerts(input: AlertInput): HealthAlert[] {
if (input.pulseAnomalyCount != null && input.pulseAnomalyCount > 2) {
alerts.push({
level: "warning",
title: "Puls-Ausreißer erkannt",
message: `${input.pulseAnomalyCount} ungewöhnliche Pulsmessungen in den letzten 30 Tagen.`,
title: "Pulse outliers detected",
message: `${input.pulseAnomalyCount} unusual pulse readings in the last 30 days.`,
});
}
@@ -407,14 +407,14 @@ export function generateAlerts(input: AlertInput): HealthAlert[] {
if (med.compliance7 < 80) {
alerts.push({
level: "warning",
title: `Niedrige Einnahmetreue: ${med.name}`,
message: `Ihre 7-Tage-Compliance für ${med.name} liegt bei nur ${med.compliance7}%. Regelmäßige Einnahme ist wichtig.`,
title: `Low compliance: ${med.name}`,
message: `Your 7-day compliance for ${med.name} is only ${med.compliance7}%. Regular intake is important.`,
});
} else if (med.compliance7 >= 95) {
alerts.push({
level: "success",
title: `Sehr gute Einnahmetreue: ${med.name}`,
message: `${med.compliance7}% Compliance in den letzten 7 Tagen. Weiter so!`,
title: `Excellent compliance: ${med.name}`,
message: `${med.compliance7}% compliance over the last 7 days. Keep it up!`,
});
}
}
+6 -6
View File
@@ -75,7 +75,7 @@ export function getPersonalizedPulseTarget(
greenMax: 100,
orangeMin: 55,
orangeMax: 105,
source: "AHA (Erwachsene, Ruhepuls 60-100 bpm)",
source: "AHA (adults, resting pulse 60-100 bpm)",
};
}
@@ -108,20 +108,20 @@ export function classifyPulseByTarget(
target: PersonalizedPulseTarget,
): PulseTargetClassification {
if (bpm < target.orangeMin) {
return { category: "Deutlich niedrig", color: "#8be9fd", severity: "info" };
return { category: "Significantly low", color: "#8be9fd", severity: "info" };
}
if (bpm < target.greenMin) {
return {
category: "Leicht niedrig",
category: "Slightly low",
color: "#ffb86c",
severity: "warning",
};
}
if (bpm <= target.greenMax) {
return { category: "Zielbereich", color: "#50fa7b", severity: "normal" };
return { category: "On target", color: "#50fa7b", severity: "normal" };
}
if (bpm <= target.orangeMax) {
return { category: "Leicht erhöht", color: "#ffb86c", severity: "warning" };
return { category: "Slightly elevated", color: "#ffb86c", severity: "warning" };
}
return { category: "Deutlich erhöht", color: "#ff5555", severity: "danger" };
return { category: "Significantly elevated", color: "#ff5555", severity: "danger" };
}
+2 -2
View File
@@ -103,7 +103,7 @@ export async function requireAuth(): Promise<{
: never;
}> {
const sessionData = await getSession();
if (!sessionData) throw new HttpError(401, "Nicht angemeldet");
if (!sessionData) throw new HttpError(401, "Not authenticated");
const evt = getEvent();
if (evt) {
@@ -131,7 +131,7 @@ export async function requireAdmin(): Promise<{
}> {
const sessionData = await requireAuth();
if (sessionData.user.role !== "ADMIN") {
throw new HttpError(403, "Nur Admins erlaubt");
throw new HttpError(403, "Admin access required");
}
return sessionData;
}
+1 -1
View File
@@ -1,7 +1,7 @@
export const locales = ["de", "en"] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = "de";
export const defaultLocale: Locale = "en";
export const localeLabels: Record<Locale, string> = {
de: "Deutsch",
+3 -3
View File
@@ -84,9 +84,9 @@ export function I18nProvider({ children }: { children: ReactNode }) {
(key: string, params?: Record<string, string | number>): string => {
let value = resolveKey(allMessages[locale], key);
// Fallback to German if key missing in current locale
if (value === undefined && locale !== "de") {
value = resolveKey(allMessages.de, key);
// Fallback to English if key missing in current locale
if (value === undefined && locale !== "en") {
value = resolveKey(allMessages.en, key);
}
// Fallback to key itself
+5 -5
View File
@@ -17,11 +17,11 @@ export const EVENT_TYPES = [
export type EventType = (typeof EVENT_TYPES)[number];
export const EVENT_TYPE_LABELS: Record<EventType, string> = {
MEDICATION_REMINDER: "Medikamenten-Erinnerung",
MEASUREMENT_ANOMALY: "Anomale Messwerte",
COMPLIANCE_LOW: "Niedrige Einnahmetreue",
WITHINGS_SYNC_FAILED: "Withings-Sync-Fehler",
SYSTEM_ALERT: "System-Hinweise",
MEDICATION_REMINDER: "Medication reminder",
MEASUREMENT_ANOMALY: "Anomalous measurements",
COMPLIANCE_LOW: "Low compliance",
WITHINGS_SYNC_FAILED: "Withings sync failed",
SYSTEM_ALERT: "System alerts",
};
export const CHANNEL_TYPE_LABELS: Record<ChannelType, string> = {
+11 -11
View File
@@ -4,24 +4,24 @@ import { z } from "zod/v4";
const PASSWORD_MIN_LENGTH = 12;
export const registerSchema = z.object({
email: z.email("Ungültige E-Mail-Adresse"),
email: z.email("Invalid email address"),
username: z
.string()
.min(3, "Mindestens 3 Zeichen")
.max(30, "Maximal 30 Zeichen")
.regex(/^[a-zA-Z0-9_-]+$/, "Nur Buchstaben, Zahlen, _ und -"),
.min(3, "At least 3 characters")
.max(30, "Maximum 30 characters")
.regex(/^[a-zA-Z0-9_-]+$/, "Only letters, numbers, _ and -"),
password: z
.string()
.min(PASSWORD_MIN_LENGTH, `Passwort muss mindestens ${PASSWORD_MIN_LENGTH} Zeichen lang sein`),
.min(PASSWORD_MIN_LENGTH, `Password must be at least ${PASSWORD_MIN_LENGTH} characters`),
});
export const loginPasswordSchema = z.object({
email: z.string().trim().min(1, "E-Mail oder Benutzername erforderlich"),
email: z.string().trim().min(1, "Email or username required"),
password: z.string().min(1),
});
export const profileSchema = z.object({
email: z.email("Ungültige E-Mail-Adresse").nullable().optional(),
email: z.email("Invalid email address").nullable().optional(),
heightCm: z.number().min(50).max(300).nullable().optional(),
dateOfBirth: z.string().nullable().optional(), // ISO date string
gender: z.enum(["MALE", "FEMALE"]).nullable().optional(),
@@ -29,14 +29,14 @@ export const profileSchema = z.object({
export const changePasswordSchema = z
.object({
currentPassword: z.string().min(1, "Aktuelles Passwort ist erforderlich"),
currentPassword: z.string().min(1, "Current password is required"),
newPassword: z
.string()
.min(PASSWORD_MIN_LENGTH, `Neues Passwort muss mindestens ${PASSWORD_MIN_LENGTH} Zeichen lang sein`),
confirmPassword: z.string().min(1, "Passwort-Bestätigung ist erforderlich"),
.min(PASSWORD_MIN_LENGTH, `New password must be at least ${PASSWORD_MIN_LENGTH} characters`),
confirmPassword: z.string().min(1, "Password confirmation is required"),
})
.refine((data) => data.newPassword === data.confirmPassword, {
message: "Neue Passwörter stimmen nicht überein",
message: "Passwords do not match",
path: ["confirmPassword"],
});
+12
View File
@@ -36,9 +36,21 @@ const DEMO_MUTATION_ALLOWLIST = [
"/api/auth/passkey/login-verify",
];
// Legacy route redirects (German → English)
const LEGACY_REDIRECTS: Record<string, string> = {
"/stimmung": "/mood",
"/zielwerte": "/targets",
};
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
// 301 redirects for renamed routes
const redirect = LEGACY_REDIRECTS[pathname];
if (redirect) {
return NextResponse.redirect(new URL(redirect, request.url), 301);
}
// Demo mode: block all mutations except login
if (process.env.DEMO_MODE === "true") {
const method = request.method.toUpperCase();