From 0c0c345e822c99ade3f378e1889cf50b1e085a72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 12 Mar 2026 21:31:30 +0100 Subject: [PATCH] i18n: migrate from German-first to English-first default locale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- AGENTS.md | 7 +- CLAUDE.md | 109 +++++++++++ messages/de.json | 178 +++++++++++++++++- messages/en.json | 178 +++++++++++++++++- package.json | 2 +- src/app/api/admin/data/route.ts | 4 +- .../admin/monitoring/glitchtip-test/route.ts | 8 +- .../api/admin/monitoring/umami-test/route.ts | 10 +- src/app/api/admin/notifications/test/route.ts | 20 +- src/app/api/admin/settings/route.ts | 2 +- .../admin/users/[id]/reset-password/route.ts | 6 +- src/app/api/admin/users/[id]/route.ts | 4 +- src/app/api/auth/login/route.ts | 8 +- .../api/auth/passkey/login-options/route.ts | 2 +- .../api/auth/passkey/login-verify/route.ts | 8 +- .../api/auth/passkey/register-verify/route.ts | 4 +- src/app/api/auth/passkeys/[id]/route.ts | 4 +- src/app/api/auth/password/route.ts | 10 +- src/app/api/auth/profile/route.ts | 2 +- src/app/api/auth/register/route.ts | 8 +- src/app/api/bugreport/route.ts | 22 +-- src/app/api/doctor-report/route.ts | 2 +- src/app/api/export/route.ts | 6 +- src/app/api/ingest/medication/route.ts | 6 +- src/app/api/insights/generate/route.ts | 12 +- src/app/api/insights/settings/route.ts | 6 +- src/app/api/insights/targets/route.ts | 48 ++--- .../api/integrations/moodlog/sync/route.ts | 2 +- src/app/api/measurements/[id]/route.ts | 6 +- src/app/api/measurements/route.ts | 2 +- .../medications/[id]/api-endpoint/route.ts | 10 +- .../api/medications/[id]/compliance/route.ts | 2 +- .../[id]/intake/[eventId]/route.ts | 4 +- .../medications/[id]/intake/import/route.ts | 4 +- .../medications/[id]/intake/purge/route.ts | 2 +- src/app/api/medications/[id]/intake/route.ts | 4 +- .../medications/[id]/phase-config/route.ts | 8 +- src/app/api/medications/[id]/route.ts | 6 +- src/app/api/monitoring/glitchtip/route.ts | 6 +- src/app/api/mood-entries/[id]/route.ts | 6 +- src/app/api/mood-entries/route.ts | 2 +- .../api/notifications/preferences/route.ts | 6 +- src/app/api/notifications/web-push/route.ts | 8 +- src/app/api/onboarding/complete/route.ts | 2 +- src/app/api/settings/account/route.ts | 6 +- src/app/api/settings/data/route.ts | 4 +- src/app/api/settings/moodlog/route.ts | 2 +- src/app/api/settings/ntfy/route.ts | 4 +- src/app/api/settings/ntfy/test/route.ts | 10 +- src/app/api/settings/telegram/route.ts | 18 +- src/app/api/settings/telegram/test/route.ts | 8 +- src/app/api/tokens/[id]/route.ts | 4 +- src/app/api/tokens/route.ts | 6 +- src/app/api/withings/credentials/route.ts | 2 +- src/app/api/withings/disconnect/route.ts | 2 +- src/app/{stimmung => mood}/page.tsx | 2 +- src/app/{zielwerte => targets}/page.tsx | 2 +- src/components/charts/compliance-heatmap.tsx | 38 ++-- src/components/charts/mood-chart.tsx | 20 +- src/components/insights/insights-card.tsx | 23 +-- src/components/layout/bottom-nav.tsx | 4 +- src/components/layout/sidebar-nav.tsx | 4 +- .../medications/intake-history-list.tsx | 57 +++--- .../medications/medication-form.tsx | 36 ++-- src/components/ui/password-strength.tsx | 41 ++-- .../__tests__/classifications.test.ts | 50 ++--- src/lib/analytics/classifications.ts | 106 +++++------ src/lib/analytics/pulse-targets.ts | 12 +- src/lib/api-handler.ts | 4 +- src/lib/i18n/config.ts | 2 +- src/lib/i18n/context.tsx | 6 +- src/lib/notifications/types.ts | 10 +- src/lib/validations/auth.ts | 22 +-- src/proxy.ts | 12 ++ 74 files changed, 871 insertions(+), 402 deletions(-) create mode 100644 CLAUDE.md rename src/app/{stimmung => mood}/page.tsx (98%) rename src/app/{zielwerte => targets}/page.tsx (99%) diff --git a/AGENTS.md b/AGENTS.md index 8a9af31c9..093b2d394 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,7 +77,8 @@ src/ │ ├── notifications/page.tsx # Notification preferences matrix │ ├── onboarding/page.tsx # 4-step guided onboarding │ ├── settings/page.tsx # All settings (13 sections, ~2100 lines) -│ ├── zielwerte/page.tsx # Target values dashboard +│ ├── mood/page.tsx # Mood tracking +│ ├── targets/page.tsx # Target values dashboard │ └── api/ # ~60 API route files (see docs/api.md) │ ├── mood-entries/ # Mood CRUD │ ├── import/ # JSON data import @@ -128,10 +129,10 @@ docs.healthlog.dev # External documentation site ## Key Conventions -- **UI language is German**. All user-facing text in German. Code, comments, docs in English. +- **Default locale is English**. All code-level strings in English. UI text via i18n (`t()` calls) with German + English translations. - **i18n**: Use `useTranslations()` hook → `t("section.key")`. Supports parameter interpolation: `t("key", { count: 5 })`. Messages in `messages/de.json` + `messages/en.json`. - **API response format**: Always `{ data, error, meta }` via `apiSuccess(data)` / `apiError(message, status)` from `src/lib/api-response.ts`. -- **Auth check pattern**: `const session = await getSession(); if (!session) return apiError("Nicht angemeldet", 401);` +- **Auth check pattern**: `const session = await getSession(); if (!session) return apiError("Not authenticated", 401);` - **Timezone**: `Europe/Berlin` for display, UTC in database. - **Encryption**: Sensitive data (Withings tokens, API keys, VAPID private keys) encrypted with AES-256-GCM via `src/lib/crypto.ts`. - **Dracula colors**: Use CSS variables `var(--dracula-purple)`, `var(--dracula-cyan)`, `var(--dracula-green)`, `var(--dracula-orange)`, `var(--dracula-pink)`, `var(--dracula-red)`, `var(--dracula-yellow)`, `var(--dracula-fg)`, `var(--dracula-comment)` for chart/graph elements. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..dcb191679 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,109 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project + +HealthLog — a personal health-tracking PWA (weight, blood pressure, pulse, mood, medication compliance) with Withings integration, moodLog.app sync, Dracula-themed UI, mobile-first design, offline caching, and doctor report PDF export. + +## Commands + +```bash +# Development +pnpm dev # Start dev server (http://localhost:3000) +pnpm build # Production build +pnpm lint # ESLint +pnpm format # Prettier format +pnpm format:check # Prettier check +pnpm typecheck # TypeScript strict check + +# Tests +pnpm test # Run all tests (vitest) +pnpm test:watch # Watch mode + +# Database +pnpm db:generate # Generate Prisma client +pnpm db:migrate # Create & apply migration (dev) +pnpm db:migrate:deploy # Apply migrations (production) +pnpm db:push # Push schema without migration (prototyping) +pnpm db:studio # Prisma Studio GUI + +# Docker +docker compose up -d # Start app + postgres +docker compose logs -f app # Tail app logs +``` + +## Architecture + +- **Next.js 16** App Router with TypeScript strict. Pages are RSC by default; `"use client"` only for interactivity. +- **Prisma 7** ORM with PostgreSQL (22 models). Uses `PrismaPg` adapter from `@prisma/adapter-pg`. Client singleton at `src/lib/db.ts`. Generated client at `src/generated/prisma/client` (note the `/client` suffix). Prisma config in `prisma.config.ts` (not in schema.prisma). +- **shadcn/ui** components (new-york style) in `src/components/ui/`. Add new ones via `pnpm dlx shadcn@latest add `. +- **Dracula theme** via CSS variables in `globals.css`. Dark mode is default. Use `--dracula-*` tokens for chart colors. +- **TanStack Query** for client-side data fetching. Provider in `src/components/providers.tsx`. +- **Zod v4** (`zod/v4`) for all input validation (API routes and forms). +- **API response envelope**: `{ data, error, meta }` via helpers in `src/lib/api-response.ts`. +- **apiHandler** wraps ALL API route handlers (`src/lib/api-handler.ts`) — error handling, Wide Event logging, x-request-id propagation. +- **Structured Logging** (Wide Events) in `src/lib/logging/` — one JSON event per request/operation via `WideEventBuilder` + `AsyncLocalStorage`. Tail sampling, stdout JSON + optional Loki Push. +- **Vitest** for unit testing. Config in `vitest.config.ts`. +- **pg-boss** (PostgreSQL-native) for job queue: medication reminders, insight caching, weekly data backups. +- **jsPDF** + jspdf-autotable for client-side doctor report PDF generation (`src/lib/doctor-report-pdf.ts`). +- **PWA offline** via service worker caching strategies in `public/sw.js` (cache-first for static, network-first for pages, network-only for API). +- **Proxy route protection** — `src/proxy.ts` checks session cookie, enforces auth on all non-public paths. Next.js 16 uses proxy.ts (not middleware.ts). + +## Key Conventions + +- Default locale is **English**. All code-level strings in English. UI text via i18n `t()` with German + English translations. +- Timezone: `Europe/Berlin` for display, UTC in database. +- Sensitive data (Withings tokens, API keys, VAPID private keys) encrypted with AES-256-GCM (`src/lib/crypto.ts`) before DB storage. +- Passkeys are primary auth (SimpleWebAuthn v13). Sessions stored server-side in PostgreSQL. +- All API mutations require authentication. External ingest uses Bearer token (hashed with SHA-256). +- Rate limiting (in-memory sliding window) on auth and external-facing endpoints. + +## File Layout + +- `src/app/` — pages and API routes (App Router) +- `src/components/ui/` — shadcn/ui primitives +- `src/components/layout/` — shell (sidebar-nav, topbar, mobile-nav, bottom-nav) +- `src/components/medications/` — medication form, card, timeline +- `src/components/measurements/` — measurement form, list +- `src/components/mood/` — mood form, mood list +- `src/components/charts/` — Recharts wrappers +- `src/lib/logging/` — Wide Events structured logging (types, config, event-builder, context, sampler, transports, background) +- `src/lib/api-handler.ts` — apiHandler wrapper, requireAuth(), requireAdmin(), HttpError +- `src/lib/` — server utilities (db, crypto, auth, analytics, export, rate-limit, gravatar) +- `src/lib/export.ts` — CSV/JSON export formatting (measurements, medications, mood) +- `src/lib/doctor-report-pdf.ts` — client-side PDF generation (jsPDF, European medical format) +- `src/lib/withings/` — Withings OAuth client and sync service +- `src/lib/notifications/` — dispatcher, types, senders (telegram, ntfy, web-push) +- `src/lib/jobs/` — pg-boss reminder worker (reminders, insights, data backups) +- `src/lib/analytics/` — trend calculations, compliance, correlations +- `src/lib/i18n/` — i18n context, config, locale detection +- `src/lib/validations/` — Zod schemas shared between API + client +- `src/hooks/` — React hooks (`use-auth`) +- `messages/de.json` + `messages/en.json` — i18n translations +- `prisma/schema.prisma` — database schema (21 models) +- `prisma.config.ts` — Prisma config (DB URL here, not in schema) +- `public/sw.js` — Service worker for Web Push notifications + offline caching +- `docs/` — architecture, security, assumptions, ops, API docs, ADRs +- `docs/STATUS.md` — Current project status + open tasks +- `AGENTS.md` — AI agent instructions (Codex, Cursor, etc.) + +## Important Patterns + +- Import Prisma client from `@/generated/prisma/client` (not `@/generated/prisma`) +- Prisma 7 does NOT support `url = env("...")` in schema.prisma — use `prisma.config.ts` +- Next.js 16 uses `proxy.ts` (not `middleware.ts`) — having both causes build error +- SimpleWebAuthn v13 has no `@simplewebauthn/server/script/deps` — define Transport type inline +- `zxcvbn-typescript` uses default export only (no named `zxcvbnAsync`/`zxcvbnOptions`) +- pg-boss v12: `{ PgBoss }` named import, `localConcurrency` instead of `teamSize`, handler receives `Job[]` array +- **i18n**: All UI text uses `useTranslations()` hook with `t("section.key")`. Messages in `messages/de.json` (German, primary) + `messages/en.json` +- **Notifications**: 3 channels (Telegram, ntfy, Web Push). Dispatcher at `src/lib/notifications/dispatcher.ts`. Opt-out model (all events enabled by default) +- **Gravatar**: Server-side SHA-256 hash via `src/lib/gravatar.ts`. URL returned in `/api/auth/me` response as `gravatarUrl` field +- **Sidebar**: Collapsible (localStorage-persisted). Settings mode with section shortcuts. User section at bottom with three-dot dropdown menu +- **ESLint**: Strict `react-hooks/set-state-in-effect` rule — avoid `setState` inside `useEffect`. Use lazy `useState()` initializer for localStorage reads +- **Proxy route protection**: `src/proxy.ts` checks `healthlog_session` cookie; PUBLIC_PATHS array bypasses auth. Security headers (CSP, HSTS, X-Frame-Options) applied to all responses +- **PWA safe area**: Root layout has `viewportFit: "cover"`. Bottom-nav uses `pb-[env(safe-area-inset-bottom)]` for iOS safe area +- **Doctor report PDF**: Client-side via `src/lib/doctor-report-pdf.ts` (jsPDF). Data from `POST /api/doctor-report`. European medical format, German terminology +- **Achievements**: Persistent in `UserAchievement` table. Computed on API call, new unlocks written to DB with stable `unlockedAt` timestamps +- **Data backup**: pg-boss `data-backup` queue runs weekly (Sundays 03:00), stores JSON in `DataBackup` model +- **Wide Events / Structured Logging**: `apiHandler()` wraps all API routes. Use `annotate()` from `@/lib/logging/context` for business-action annotations. Use `requireAuth()` / `requireAdmin()` from `@/lib/api-handler` (auto-annotates auth). Background jobs use `withBackgroundEvent()`. External calls tracked via `getEvent()?.addExternalCall()`. No `console.log` in production code — use event annotations instead. Env vars: `LOG_LEVEL`, `LOG_SAMPLE_RATE`, `LOG_SLOW_THRESHOLD_MS`, `LOG_INCLUDE_STACK`, `LOKI_ENDPOINT`, `LOKI_USERNAME`, `LOKI_PASSWORD` diff --git a/messages/de.json b/messages/de.json index e626a6c50..8ecb0f66b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -385,7 +385,21 @@ "phaseAfterEnd": "nach Ende", "phaseResetDefaults": "Auf Defaults zurücksetzen", "phaseSaved": "Erinnerungsphasen gespeichert", - "phaseReset": "Auf Defaults zurückgesetzt" + "phaseReset": "Auf Defaults zurückgesetzt", + "sourceWeb": "Webseite", + "sourceApi": "Externe API", + "sourceReminder": "Telegram / Erinnerung", + "sourceImport": "CSV-Import", + "unitDrops": "Tropfen", + "unitTablets": "Tablette(n)", + "unitCapsules": "Kapsel(n)", + "unitPieces": "Stück", + "namePlaceholder": "z.B. Ibuprofen", + "dosePlaceholder": "z.B. 400", + "labelPlaceholder": "z.B. Morgens", + "defaultDose": "Standard", + "dayDeactivate": "{day} deaktivieren", + "dayActivate": "{day} aktivieren" }, "charts": { "title": "Verlauf", @@ -429,7 +443,39 @@ "colorRed": "Rot:", "mood": "Stimmung", "moodScore": "Stimmungswert", - "moodMA": "7-Tage-Schnitt" + "moodMA": "7-Tage-Schnitt", + "moodLabel1": "Lausig", + "moodLabel2": "Schlecht", + "moodLabel3": "Okay", + "moodLabel4": "Gut", + "moodLabel5": "Super", + "weekdays": { + "mon": "Mo", + "wed": "Mi", + "fri": "Fr", + "sun": "So" + }, + "months": { + "jan": "Jan", + "feb": "Feb", + "mar": "Mär", + "apr": "Apr", + "may": "Mai", + "jun": "Jun", + "jul": "Jul", + "aug": "Aug", + "sep": "Sep", + "oct": "Okt", + "nov": "Nov", + "dec": "Dez" + }, + "heatmapOnTime": "pünktlich", + "heatmapLate": "spät", + "heatmapVeryLate": "sehr spät", + "legendOnTime": "Pünktlich", + "legendLate": "Spät", + "legendVeryLate": "Sehr spät", + "legendMissed": "Verpasst" }, "insights": { "title": "Insights", @@ -543,7 +589,20 @@ "moodWeightNone": "Kein erkennbarer Zusammenhang zwischen Stimmung und Gewicht.", "moodWeightStrong": "Es besteht ein starker Zusammenhang zwischen Stimmung und Gewicht.", "moodWeightWeak": "Es besteht ein schwacher Zusammenhang zwischen Stimmung und Gewicht.", - "notEnoughMoodCorrelationData": "Zu wenig Stimmungsdaten für Korrelationsanalyse." + "notEnoughMoodCorrelationData": "Zu wenig Stimmungsdaten für Korrelationsanalyse.", + "refreshButton": "Aktualisieren", + "generateButton": "Generieren", + "analyzing": "Analysiere Gesundheitsdaten...", + "confidence": "Konfidenz", + "confidence_niedrig": "niedrig", + "confidence_mittel": "mittel", + "confidence_hoch": "hoch", + "cached": "Gecacht", + "sectionChanged": "Was hat sich verändert", + "sectionStable": "Was ist stabil", + "sectionDrivers": "Mögliche Zusammenhänge", + "sectionNextSteps": "Nächste Schritte", + "generatePrompt": "Klicke auf \"Generieren\" für KI-basierte Insights deiner Gesundheitsdaten." }, "targets": { "title": "Zielwerte", @@ -1208,5 +1267,118 @@ "dateShort": "{day}.{month}.{year}", "timeShort": "{hours}:{minutes}", "dateTime": "{day}.{month}.{year}, {hours}:{minutes}" + }, + "classifications": { + "bmi": { + "Underweight": "Untergewicht", + "Normal": "Normalgewicht", + "Overweight": "Übergewicht", + "ObesityGradeI": "Adipositas Grad I", + "ObesityGradeII": "Adipositas Grad II", + "ObesityGradeIII": "Adipositas Grad III" + }, + "bp": { + "Optimal": "Optimal", + "Normal": "Normal", + "HighNormal": "Hoch-normal", + "HypertensionGrade1": "Hypertonie Grad 1", + "HypertensionGrade2": "Hypertonie Grad 2", + "HypertensionGrade3": "Hypertonie Grad 3" + }, + "pulse": { + "Bradycardia": "Bradykardie", + "Normal": "Normal", + "Elevated": "Erhöht", + "Tachycardia": "Tachykardie" + }, + "pulseTarget": { + "SignificantlyLow": "Deutlich niedrig", + "SlightlyLow": "Leicht niedrig", + "OnTarget": "Zielbereich", + "SlightlyElevated": "Leicht erhöht", + "SignificantlyElevated": "Deutlich erhöht" + }, + "sleep": { + "FarTooShort": "Deutlich zu kurz", + "TooShort": "Zu kurz", + "OnTarget": "Zielbereich", + "SlightlyLong": "Etwas lang", + "FarTooLong": "Deutlich zu lang" + }, + "bodyFat": { + "Essential": "Essenziell", + "Athletic": "Athletisch", + "Fitness": "Fitness", + "Average": "Durchschnitt", + "Obese": "Adipös" + }, + "steps": { + "VeryLow": "Sehr wenig", + "LowActive": "Wenig aktiv", + "ModeratelyActive": "Moderat aktiv", + "Active": "Aktiv", + "VeryActive": "Sehr aktiv" + }, + "general": { + "Good": "Gut", + "Moderate": "Moderat", + "Low": "Niedrig", + "VeryGood": "Sehr gut", + "VeryStable": "Sehr stabil", + "Stable": "Stabil", + "Fluctuating": "Schwankend" + }, + "alerts": { + "bmiBelowNormalTitle": "BMI unter Normalbereich", + "bmiBelowNormalMessage": "Ihr BMI von {bmi} liegt unter dem Normalbereich (18,5–24,9).", + "bmiOverweightTitle": "BMI im Bereich Übergewicht", + "bmiOverweightMessage": "Ihr BMI von {bmi} liegt im Bereich Übergewicht. Regelmäßige Bewegung und ausgewogene Ernährung können helfen.", + "bmiObesityTitle": "BMI im Bereich Adipositas", + "bmiObesityMessage": "Ihr BMI von {bmi} liegt im Bereich Adipositas.", + "bmiNormalTitle": "BMI im Normalbereich", + "bmiNormalMessage": "Ihr BMI von {bmi} liegt im gesunden Bereich.", + "bpDangerTitle": "Blutdruck deutlich erhöht", + "bpDangerMessage": "Ihr durchschnittlicher Blutdruck ({sys}/{dia}) wird als \"{category}\" eingestuft.", + "bpWarningTitle": "Blutdruck leicht erhöht", + "bpWarningMessage": "Ihr durchschnittlicher Blutdruck ({sys}/{dia}) liegt im Bereich \"{category}\".", + "bpTargetGoodTitle": "Gute Blutdruck-Zielbereichstreue", + "bpTargetGoodMessage": "{pct}% Ihrer Messungen liegen im Zielbereich.", + "bpTargetBadTitle": "Blutdruck oft außerhalb des Zielbereichs", + "bpTargetBadMessage": "Nur {pct}% Ihrer Messungen liegen im Zielbereich.", + "weightIncreasingTitle": "Gewicht steigend", + "weightIncreasingMessage": "Ihr Gewicht steigt um ca. {change} kg pro Woche in den letzten 30 Tagen.", + "weightDecreasingTitle": "Gewicht fallend", + "weightDecreasingMessage": "Ihr Gewicht sinkt um ca. {change} kg pro Woche in den letzten 30 Tagen.", + "pulseOutliersTitle": "Puls-Ausreißer erkannt", + "pulseOutliersMessage": "{count} ungewöhnliche Pulsmessungen in den letzten 30 Tagen.", + "complianceLowTitle": "Niedrige Einnahmetreue: {name}", + "complianceLowMessage": "Ihre 7-Tage-Compliance für {name} liegt bei nur {pct}%. Regelmäßige Einnahme ist wichtig.", + "complianceGoodTitle": "Sehr gute Einnahmetreue: {name}", + "complianceGoodMessage": "{pct}% Compliance in den letzten 7 Tagen. Weiter so!" + } + }, + "targets": { + "weight": "Gewicht", + "bloodPressure": "Blutdruck", + "bloodPressureOnTarget": "Blutdruck im Zielbereich", + "restingPulse": "Ruhepuls", + "sleepDuration": "Schlafdauer", + "bmi": "BMI", + "bodyFat": "Körperfett", + "stepsPerDay": "Schritte/Tag", + "medicationCompliance": "Einnahmetreue", + "mood": "Stimmung", + "moodStability": "Stimmungsstabilität", + "steps": "Schritte" + }, + "passwordStrength": { + "veryWeak": "Sehr schwach", + "weak": "Schwach", + "acceptable": "Akzeptabel", + "strong": "Stark", + "veryStrong": "Sehr stark", + "tooShort": "Zu kurz", + "characters": "Zeichen", + "minLength": "Mindestens {count} Zeichen erforderlich." } } diff --git a/messages/en.json b/messages/en.json index 8bc173d6c..21c7d60b0 100644 --- a/messages/en.json +++ b/messages/en.json @@ -385,7 +385,21 @@ "phaseAfterEnd": "after end", "phaseResetDefaults": "Reset to defaults", "phaseSaved": "Reminder phases saved", - "phaseReset": "Reset to defaults" + "phaseReset": "Reset to defaults", + "sourceWeb": "Website", + "sourceApi": "External API", + "sourceReminder": "Telegram / Reminder", + "sourceImport": "CSV Import", + "unitDrops": "Drops", + "unitTablets": "Tablet(s)", + "unitCapsules": "Capsule(s)", + "unitPieces": "Piece(s)", + "namePlaceholder": "e.g. Ibuprofen", + "dosePlaceholder": "e.g. 400", + "labelPlaceholder": "e.g. Morning", + "defaultDose": "Default", + "dayDeactivate": "Deactivate {day}", + "dayActivate": "Activate {day}" }, "charts": { "title": "History", @@ -429,7 +443,39 @@ "colorRed": "Red:", "mood": "Mood", "moodScore": "Mood Score", - "moodMA": "7-Day Average" + "moodMA": "7-Day Average", + "moodLabel1": "Awful", + "moodLabel2": "Bad", + "moodLabel3": "Okay", + "moodLabel4": "Good", + "moodLabel5": "Great", + "weekdays": { + "mon": "Mon", + "wed": "Wed", + "fri": "Fri", + "sun": "Sun" + }, + "months": { + "jan": "Jan", + "feb": "Feb", + "mar": "Mar", + "apr": "Apr", + "may": "May", + "jun": "Jun", + "jul": "Jul", + "aug": "Aug", + "sep": "Sep", + "oct": "Oct", + "nov": "Nov", + "dec": "Dec" + }, + "heatmapOnTime": "on time", + "heatmapLate": "late", + "heatmapVeryLate": "very late", + "legendOnTime": "On time", + "legendLate": "Late", + "legendVeryLate": "Very late", + "legendMissed": "Missed" }, "insights": { "title": "Insights", @@ -543,7 +589,20 @@ "moodWeightNone": "No detectable correlation between mood and weight.", "moodWeightStrong": "There is a strong correlation between mood and weight.", "moodWeightWeak": "There is a weak correlation between mood and weight.", - "notEnoughMoodCorrelationData": "Not enough mood data for correlation analysis." + "notEnoughMoodCorrelationData": "Not enough mood data for correlation analysis.", + "refreshButton": "Refresh", + "generateButton": "Generate", + "analyzing": "Analyzing health data...", + "confidence": "Confidence", + "confidence_niedrig": "low", + "confidence_mittel": "medium", + "confidence_hoch": "high", + "cached": "Cached", + "sectionChanged": "What has changed", + "sectionStable": "What is stable", + "sectionDrivers": "Possible correlations", + "sectionNextSteps": "Next steps", + "generatePrompt": "Click \"Generate\" for AI-powered insights about your health data." }, "targets": { "title": "Targets", @@ -1208,5 +1267,118 @@ "dateShort": "{month}/{day}/{year}", "timeShort": "{hours}:{minutes}", "dateTime": "{month}/{day}/{year}, {hours}:{minutes}" + }, + "classifications": { + "bmi": { + "Underweight": "Underweight", + "Normal": "Normal", + "Overweight": "Overweight", + "ObesityGradeI": "Obesity Grade I", + "ObesityGradeII": "Obesity Grade II", + "ObesityGradeIII": "Obesity Grade III" + }, + "bp": { + "Optimal": "Optimal", + "Normal": "Normal", + "HighNormal": "High-normal", + "HypertensionGrade1": "Hypertension Grade 1", + "HypertensionGrade2": "Hypertension Grade 2", + "HypertensionGrade3": "Hypertension Grade 3" + }, + "pulse": { + "Bradycardia": "Bradycardia", + "Normal": "Normal", + "Elevated": "Elevated", + "Tachycardia": "Tachycardia" + }, + "pulseTarget": { + "SignificantlyLow": "Significantly low", + "SlightlyLow": "Slightly low", + "OnTarget": "On target", + "SlightlyElevated": "Slightly elevated", + "SignificantlyElevated": "Significantly elevated" + }, + "sleep": { + "FarTooShort": "Far too short", + "TooShort": "Too short", + "OnTarget": "On target", + "SlightlyLong": "Slightly long", + "FarTooLong": "Far too long" + }, + "bodyFat": { + "Essential": "Essential", + "Athletic": "Athletic", + "Fitness": "Fitness", + "Average": "Average", + "Obese": "Obese" + }, + "steps": { + "VeryLow": "Very low", + "LowActive": "Low active", + "ModeratelyActive": "Moderately active", + "Active": "Active", + "VeryActive": "Very active" + }, + "general": { + "Good": "Good", + "Moderate": "Moderate", + "Low": "Low", + "VeryGood": "Very good", + "VeryStable": "Very stable", + "Stable": "Stable", + "Fluctuating": "Fluctuating" + }, + "alerts": { + "bmiBelowNormalTitle": "BMI below normal range", + "bmiBelowNormalMessage": "Your BMI of {bmi} is below the normal range (18.5–24.9).", + "bmiOverweightTitle": "BMI in overweight range", + "bmiOverweightMessage": "Your BMI of {bmi} is in the overweight range. Regular exercise and a balanced diet can help.", + "bmiObesityTitle": "BMI in obesity range", + "bmiObesityMessage": "Your BMI of {bmi} is in the obesity range.", + "bmiNormalTitle": "BMI in normal range", + "bmiNormalMessage": "Your BMI of {bmi} is in the healthy range.", + "bpDangerTitle": "Blood pressure significantly elevated", + "bpDangerMessage": "Your average blood pressure ({sys}/{dia}) is classified as \"{category}\".", + "bpWarningTitle": "Blood pressure slightly elevated", + "bpWarningMessage": "Your average blood pressure ({sys}/{dia}) is in the \"{category}\" range.", + "bpTargetGoodTitle": "Good blood pressure target adherence", + "bpTargetGoodMessage": "{pct}% of your readings are within the target range.", + "bpTargetBadTitle": "Blood pressure often outside target range", + "bpTargetBadMessage": "Only {pct}% of your readings are within the target range.", + "weightIncreasingTitle": "Weight increasing", + "weightIncreasingMessage": "Your weight is increasing by approx. {change} kg per week over the last 30 days.", + "weightDecreasingTitle": "Weight decreasing", + "weightDecreasingMessage": "Your weight is decreasing by approx. {change} kg per week over the last 30 days.", + "pulseOutliersTitle": "Pulse outliers detected", + "pulseOutliersMessage": "{count} unusual pulse readings in the last 30 days.", + "complianceLowTitle": "Low compliance: {name}", + "complianceLowMessage": "Your 7-day compliance for {name} is only {pct}%. Regular intake is important.", + "complianceGoodTitle": "Excellent compliance: {name}", + "complianceGoodMessage": "{pct}% compliance over the last 7 days. Keep it up!" + } + }, + "targets": { + "weight": "Weight", + "bloodPressure": "Blood pressure", + "bloodPressureOnTarget": "Blood pressure on target", + "restingPulse": "Resting pulse", + "sleepDuration": "Sleep duration", + "bmi": "BMI", + "bodyFat": "Body fat", + "stepsPerDay": "Steps/day", + "medicationCompliance": "Medication compliance", + "mood": "Mood", + "moodStability": "Mood stability", + "steps": "steps" + }, + "passwordStrength": { + "veryWeak": "Very weak", + "weak": "Weak", + "acceptable": "Acceptable", + "strong": "Strong", + "veryStrong": "Very strong", + "tooShort": "Too short", + "characters": "characters", + "minLength": "At least {count} characters required." } } diff --git a/package.json b/package.json index 8347c206b..c754a1095 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "0.1.0", + "version": "1.0.1", "private": true, "packageManager": "pnpm@10.31.0", "scripts": { diff --git a/src/app/api/admin/data/route.ts b/src/app/api/admin/data/route.ts index 241ccaf07..b8e9e4c2c 100644 --- a/src/app/api/admin/data/route.ts +++ b/src/app/api/admin/data/route.ts @@ -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) => { diff --git a/src/app/api/admin/monitoring/glitchtip-test/route.ts b/src/app/api/admin/monitoring/glitchtip-test/route.ts index 6f5e0218c..3ad6de52f 100644 --- a/src/app/api/admin/monitoring/glitchtip-test/route.ts +++ b/src/app/api/admin/monitoring/glitchtip-test/route.ts @@ -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", }); }); diff --git a/src/app/api/admin/monitoring/umami-test/route.ts b/src/app/api/admin/monitoring/umami-test/route.ts index 578b9660a..57fb79d2f 100644 --- a/src/app/api/admin/monitoring/umami-test/route.ts +++ b/src/app/api/admin/monitoring/umami-test/route.ts @@ -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, ); }); diff --git a/src/app/api/admin/notifications/test/route.ts b/src/app/api/admin/notifications/test/route.ts index a57105286..50ed6b222 100644 --- a/src/app/api/admin/notifications/test/route.ts +++ b/src/app/api/admin/notifications/test/route.ts @@ -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: - "HealthLog Test: Wenn du diese Nachricht siehst, funktionieren deine Benachrichtigungen!", + "HealthLog Test: 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 }); diff --git a/src/app/api/admin/settings/route.ts b/src/app/api/admin/settings/route.ts index 88af83fd8..9ab0671f0 100644 --- a/src/app/api/admin/settings/route.ts +++ b/src/app/api/admin/settings/route.ts @@ -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({ diff --git a/src/app/api/admin/users/[id]/reset-password/route.ts b/src/app/api/admin/users/[id]/reset-password/route.ts index 44dd0c7a9..c096a74cd 100644 --- a/src/app/api/admin/users/[id]/reset-password/route.ts +++ b/src/app/api/admin/users/[id]/reset-password/route.ts @@ -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, ); } diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts index 9499c4694..2529da085 100644 --- a/src/app/api/admin/users/[id]/route.ts +++ b/src/app/api/admin/users/[id]/route.ts @@ -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); } } diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 04acb1ac5..12dcbe19e 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -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"); diff --git a/src/app/api/auth/passkey/login-options/route.ts b/src/app/api/auth/passkey/login-options/route.ts index 273cb5314..c50cd0f2a 100644 --- a/src/app/api/auth/passkey/login-options/route.ts +++ b/src/app/api/auth/passkey/login-options/route.ts @@ -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) }, ); diff --git a/src/app/api/auth/passkey/login-verify/route.ts b/src/app/api/auth/passkey/login-verify/route.ts index 7cf22db0e..47a6912f5 100644 --- a/src/app/api/auth/passkey/login-verify/route.ts +++ b/src/app/api/auth/passkey/login-verify/route.ts @@ -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>(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"); diff --git a/src/app/api/auth/passkey/register-verify/route.ts b/src/app/api/auth/passkey/register-verify/route.ts index 89202fb68..7f4dbda7a 100644 --- a/src/app/api/auth/passkey/register-verify/route.ts +++ b/src/app/api/auth/passkey/register-verify/route.ts @@ -16,13 +16,13 @@ export const POST = apiHandler(async (request: NextRequest) => { const credential = body.credential as Record | 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; diff --git a/src/app/api/auth/passkeys/[id]/route.ts b/src/app/api/auth/passkeys/[id]/route.ts index 444a2756d..a44174494 100644 --- a/src/app/api/auth/passkeys/[id]/route.ts +++ b/src/app/api/auth/passkeys/[id]/route.ts @@ -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, ); } diff --git a/src/app/api/auth/password/route.ts b/src/app/api/auth/password/route.ts index 0a3c6db94..55fb8e9bf 100644 --- a/src/app/api/auth/password/route.ts +++ b/src/app/api/auth/password/route.ts @@ -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, ); } diff --git a/src/app/api/auth/profile/route.ts b/src/app/api/auth/profile/route.ts index ba095efac..56bb228d8 100644 --- a/src/app/api/auth/profile/route.ts +++ b/src/app/api/auth/profile/route.ts @@ -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); } } diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index 67e7a2259..aa15b253c 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -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, ); } diff --git a/src/app/api/bugreport/route.ts b/src/app/api/bugreport/route.ts index b6946191f..57b7add06 100644 --- a/src/app/api/bugreport/route.ts +++ b/src/app/api/bugreport/route.ts @@ -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`, diff --git a/src/app/api/doctor-report/route.ts b/src/app/api/doctor-report/route.ts index 94fe7bff2..39b9a4499 100644 --- a/src/app/api/doctor-report/route.ts +++ b/src/app/api/doctor-report/route.ts @@ -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); diff --git a/src/app/api/export/route.ts b/src/app/api/export/route.ts index bc586c2a3..b9afccf37 100644 --- a/src/app/api/export/route.ts +++ b/src/app/api/export/route.ts @@ -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; diff --git a/src/app/api/ingest/medication/route.ts b/src/app/api/ingest/medication/route.ts index 620ab44e7..f71d61f66 100644 --- a/src/app/api/ingest/medication/route.ts +++ b/src/app/api/ingest/medication/route.ts @@ -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({ diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index 71000c80e..4a57a69b3 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -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 diff --git a/src/app/api/insights/settings/route.ts b/src/app/api/insights/settings/route.ts index 9179a2eb7..f2ceb2682 100644 --- a/src/app/api/insights/settings/route.ts +++ b/src/app/api/insights/settings/route.ts @@ -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({ diff --git a/src/app/api/insights/targets/route.ts b/src/app/api/insights/targets/route.ts index 9af144681..7962e5d23 100644 --- a/src/app/api/insights/targets/route.ts +++ b/src/app/api/insights/targets/route.ts @@ -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, diff --git a/src/app/api/integrations/moodlog/sync/route.ts b/src/app/api/integrations/moodlog/sync/route.ts index 2caa18b10..7a05fd1d7 100644 --- a/src/app/api/integrations/moodlog/sync/route.ts +++ b/src/app/api/integrations/moodlog/sync/route.ts @@ -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; diff --git a/src/app/api/measurements/[id]/route.ts b/src/app/api/measurements/[id]/route.ts index a8c34a550..4474ae34d 100644 --- a/src/app/api/measurements/[id]/route.ts +++ b/src/app/api/measurements/[id]/route.ts @@ -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 } }); diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index bd04cea61..298183081 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -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; } diff --git a/src/app/api/medications/[id]/api-endpoint/route.ts b/src/app/api/medications/[id]/api-endpoint/route.ts index 9ccf52fab..4f36d2700 100644 --- a/src/app/api/medications/[id]/api-endpoint/route.ts +++ b/src/app/api/medications/[id]/api-endpoint/route.ts @@ -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); diff --git a/src/app/api/medications/[id]/compliance/route.ts b/src/app/api/medications/[id]/compliance/route.ts index 3a18fbf19..60d528ad3 100644 --- a/src/app/api/medications/[id]/compliance/route.ts +++ b/src/app/api/medications/[id]/compliance/route.ts @@ -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({ diff --git a/src/app/api/medications/[id]/intake/[eventId]/route.ts b/src/app/api/medications/[id]/intake/[eventId]/route.ts index 64778430b..6555160df 100644 --- a/src/app/api/medications/[id]/intake/[eventId]/route.ts +++ b/src/app/api/medications/[id]/intake/[eventId]/route.ts @@ -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 } }); diff --git a/src/app/api/medications/[id]/intake/import/route.ts b/src/app/api/medications/[id]/intake/import/route.ts index e18d38af1..c14e79b39 100644 --- a/src/app/api/medications/[id]/intake/import/route.ts +++ b/src/app/api/medications/[id]/intake/import/route.ts @@ -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, ); } diff --git a/src/app/api/medications/[id]/intake/purge/route.ts b/src/app/api/medications/[id]/intake/purge/route.ts index 641152733..007a4edf1 100644 --- a/src/app/api/medications/[id]/intake/purge/route.ts +++ b/src/app/api/medications/[id]/intake/purge/route.ts @@ -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({ diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index d3c61bd57..f69424984 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/route.ts @@ -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); diff --git a/src/app/api/medications/[id]/phase-config/route.ts b/src/app/api/medications/[id]/phase-config/route.ts index ad35852b3..24e0580ab 100644 --- a/src/app/api/medications/[id]/phase-config/route.ts +++ b/src/app/api/medications/[id]/phase-config/route.ts @@ -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({ diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index 9ebf344df..a82a1e568 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -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 diff --git a/src/app/api/monitoring/glitchtip/route.ts b/src/app/api/monitoring/glitchtip/route.ts index 26394d29a..02211bd24 100644 --- a/src/app/api/monitoring/glitchtip/route.ts +++ b/src/app/api/monitoring/glitchtip/route.ts @@ -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 }); diff --git a/src/app/api/mood-entries/[id]/route.ts b/src/app/api/mood-entries/[id]/route.ts index 29392c6a6..1fd4e0620 100644 --- a/src/app/api/mood-entries/[id]/route.ts +++ b/src/app/api/mood-entries/[id]/route.ts @@ -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 } }); diff --git a/src/app/api/mood-entries/route.ts b/src/app/api/mood-entries/route.ts index 135b8af8a..4db023f5f 100644 --- a/src/app/api/mood-entries/route.ts +++ b/src/app/api/mood-entries/route.ts @@ -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, ); } diff --git a/src/app/api/notifications/preferences/route.ts b/src/app/api/notifications/preferences/route.ts index 9dba90d2b..fd6936802 100644 --- a/src/app/api/notifications/preferences/route.ts +++ b/src/app/api/notifications/preferences/route.ts @@ -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: { diff --git a/src/app/api/notifications/web-push/route.ts b/src/app/api/notifications/web-push/route.ts index 5e1dda797..76371982b 100644 --- a/src/app/api/notifications/web-push/route.ts +++ b/src/app/api/notifications/web-push/route.ts @@ -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: { diff --git a/src/app/api/onboarding/complete/route.ts b/src/app/api/onboarding/complete/route.ts index 92349eb7e..490db8728 100644 --- a/src/app/api/onboarding/complete/route.ts +++ b/src/app/api/onboarding/complete/route.ts @@ -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 = { diff --git a/src/app/api/settings/account/route.ts b/src/app/api/settings/account/route.ts index 56267ffb7..42652150f 100644 --- a/src/app/api/settings/account/route.ts +++ b/src/app/api/settings/account/route.ts @@ -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, ); } diff --git a/src/app/api/settings/data/route.ts b/src/app/api/settings/data/route.ts index 0f88d334e..7f3c43082 100644 --- a/src/app/api/settings/data/route.ts +++ b/src/app/api/settings/data/route.ts @@ -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; diff --git a/src/app/api/settings/moodlog/route.ts b/src/app/api/settings/moodlog/route.ts index 66a893ead..5c14cb9a5 100644 --- a/src/app/api/settings/moodlog/route.ts +++ b/src/app/api/settings/moodlog/route.ts @@ -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; diff --git a/src/app/api/settings/ntfy/route.ts b/src/app/api/settings/ntfy/route.ts index a28b27570..44f10c6f5 100644 --- a/src/app/api/settings/ntfy/route.ts +++ b/src/app/api/settings/ntfy/route.ts @@ -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, ); } diff --git a/src/app/api/settings/ntfy/test/route.ts b/src/app/api/settings/ntfy/test/route.ts index 34e574e96..faf64a16b 100644 --- a/src/app/api/settings/ntfy/test/route.ts +++ b/src/app/api/settings/ntfy/test/route.ts @@ -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 } }); diff --git a/src/app/api/settings/telegram/route.ts b/src/app/api/settings/telegram/route.ts index 07548394f..f8295ae69 100644 --- a/src/app/api/settings/telegram/route.ts +++ b/src/app/api/settings/telegram/route.ts @@ -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, ); } diff --git a/src/app/api/settings/telegram/test/route.ts b/src/app/api/settings/telegram/test/route.ts index a6fd56214..ed04db345 100644 --- a/src/app/api/settings/telegram/test/route.ts +++ b/src/app/api/settings/telegram/test/route.ts @@ -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, ); } diff --git a/src/app/api/tokens/[id]/route.ts b/src/app/api/tokens/[id]/route.ts index 226167f7a..c94dc40ed 100644 --- a/src/app/api/tokens/[id]/route.ts +++ b/src/app/api/tokens/[id]/route.ts @@ -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({ diff --git a/src/app/api/tokens/route.ts b/src/app/api/tokens/route.ts index 85f7cd65e..f75f4d795 100644 --- a/src/app/api/tokens/route.ts +++ b/src/app/api/tokens/route.ts @@ -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); diff --git a/src/app/api/withings/credentials/route.ts b/src/app/api/withings/credentials/route.ts index 37ce5da1f..f59323a57 100644 --- a/src/app/api/withings/credentials/route.ts +++ b/src/app/api/withings/credentials/route.ts @@ -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({ diff --git a/src/app/api/withings/disconnect/route.ts b/src/app/api/withings/disconnect/route.ts index acd73b32b..f2ea08a6a 100644 --- a/src/app/api/withings/disconnect/route.ts +++ b/src/app/api/withings/disconnect/route.ts @@ -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) diff --git a/src/app/stimmung/page.tsx b/src/app/mood/page.tsx similarity index 98% rename from src/app/stimmung/page.tsx rename to src/app/mood/page.tsx index 5457a0f43..86bb6ccf8 100644 --- a/src/app/stimmung/page.tsx +++ b/src/app/mood/page.tsx @@ -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); diff --git a/src/app/zielwerte/page.tsx b/src/app/targets/page.tsx similarity index 99% rename from src/app/zielwerte/page.tsx rename to src/app/targets/page.tsx index 70003dfcc..b7c4feeb0 100644 --- a/src/app/zielwerte/page.tsx +++ b/src/app/targets/page.tsx @@ -471,7 +471,7 @@ function TargetCard({ ); } -export default function ZielwertePage() { +export default function TargetsPage() { const { t } = useTranslations(); const { isAuthenticated } = useAuth(); diff --git a/src/components/charts/compliance-heatmap.tsx b/src/components/charts/compliance-heatmap.tsx index 3687ac3aa..abd2d5796 100644 --- a/src/components/charts/compliance-heatmap.tsx +++ b/src/components/charts/compliance-heatmap.tsx @@ -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(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 }) => (
= { - 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 = { + 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); }; diff --git a/src/components/insights/insights-card.tsx b/src/components/insights/insights-card.tsx index b101105b6..d90d38dd5 100644 --- a/src/components/insights/insights-card.tsx +++ b/src/components/insights/insights-card.tsx @@ -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 = { export function InsightsCard() { const { isAuthenticated } = useAuth(); const queryClient = useQueryClient(); + const { t } = useTranslations(); const [error, setError] = useState(null); // Check if insights are configured @@ -81,7 +83,7 @@ export function InsightsCard() {
- KI-Insights + {t("insights.aiInsights")}
@@ -111,7 +113,7 @@ export function InsightsCard() {
- Analysiere Gesundheitsdaten... + {t("insights.analyzing")}
)} @@ -120,19 +122,19 @@ export function InsightsCard() {
- Konfidenz: {insights.confidence} + {t("insights.confidence")}: {t(`insights.confidence_${insights.confidence}`)} {generate.data?.cached && ( - Gecacht + {t("insights.cached")} )}
-
-
-
-
+
+
+
+

@@ -145,8 +147,7 @@ export function InsightsCard() { {!insights && !generate.isPending && !error && (

- Klicke auf "Generieren" für KI-basierte Insights deiner - Gesundheitsdaten. + {t("insights.generatePrompt")}

)} diff --git a/src/components/layout/bottom-nav.tsx b/src/components/layout/bottom-nav.tsx index 16f035a94..7ca6e88f5 100644 --- a/src/components/layout/bottom-nav.tsx +++ b/src/components/layout/bottom-nav.tsx @@ -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 }, ]; diff --git a/src/components/layout/sidebar-nav.tsx b/src/components/layout/sidebar-nav.tsx index 2236adc6c..ccfc513ee 100644 --- a/src/components/layout/sidebar-nav.tsx +++ b/src/components/layout/sidebar-nav.tsx @@ -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 }, ]; diff --git a/src/components/medications/intake-history-list.tsx b/src/components/medications/intake-history-list.tsx index 325b45f0c..40bb864df 100644 --- a/src/components/medications/intake-history-list.tsx +++ b/src/components/medications/intake-history-list.tsx @@ -72,12 +72,7 @@ interface IntakeHistoryListProps { onCreateOpenChange?: (open: boolean) => void; } -const SOURCE_LABELS: Record = { - 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 = { + 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("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 (

- Bitte anmelden, um Einnahmen zu sehen. + {t("medications.loginRequiredIntakes")}

); } @@ -387,7 +389,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
) : !data?.events.length ? (
- Noch keine Einnahmen vorhanden + {t("medications.noIntakesYet")}
) : ( <> @@ -420,7 +422,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange className="w-28" /> - Aktionen + {t("medications.actions")} @@ -438,7 +440,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange {getStatusBadge(event)} - {SOURCE_LABELS[event.source] ?? event.source} + {sourceLabels[event.source] ?? event.source} @@ -485,7 +487,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
{getStatusBadge(event)} - {SOURCE_LABELS[event.source] ?? event.source} + {sourceLabels[event.source] ?? event.source}
@@ -516,7 +518,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange {totalPages > 1 && (
- Seite {page} von {totalPages} + {t("medications.pageInfo", { page, total: totalPages })}
@@ -659,7 +661,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange - Abbrechen + {t("common.cancel")} ) : null} - Löschen + {t("common.delete")} @@ -737,13 +739,13 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange onClick={closeCreate} disabled={createMutation.isPending} > - Abbrechen + {t("common.cancel")}
@@ -800,6 +802,7 @@ function DeleteButton({ description: string; onConfirm: () => void; }) { + const { t } = useTranslations(); return ( @@ -807,7 +810,7 @@ function DeleteButton({ variant="ghost" size="icon" className="text-destructive h-8 w-8" - aria-label="Löschen" + aria-label={label} > @@ -818,12 +821,12 @@ function DeleteButton({ {description} - Abbrechen + {t("common.cancel")} - Löschen + {t("common.delete")} diff --git a/src/components/medications/medication-form.tsx b/src/components/medications/medication-form.tsx index 85a685c55..854b0a737 100644 --- a/src/components/medications/medication-form.tsx +++ b/src/components/medications/medication-form.tsx @@ -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({ - {DOSE_UNITS.map((u) => ( + {doseUnits.map((u) => ( @@ -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} /> @@ -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} /> @@ -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} diff --git a/src/components/ui/password-strength.tsx b/src/components/ui/password-strength.tsx index 26db15587..fadc90ec6 100644 --- a/src/components/ui/password-strength.tsx +++ b/src/components/ui/password-strength.tsx @@ -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 */}
- - {tooShort ? "Zu kurz" : config.label} + + {tooShort ? t("passwordStrength.tooShort") : scoreLabels[score]} - {password.length}/{minLength}+ Zeichen + {password.length}/{minLength}+ {t("passwordStrength.characters")}
diff --git a/src/lib/analytics/__tests__/classifications.test.ts b/src/lib/analytics/__tests__/classifications.test.ts index 31ce07872..5b2a359a5 100644 --- a/src/lib/analytics/__tests__/classifications.test.ts +++ b/src/lib/analytics/__tests__/classifications.test.ts @@ -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"); }); diff --git a/src/lib/analytics/classifications.ts b/src/lib/analytics/classifications.ts index 93cbf6aa8..32933512f 100644 --- a/src/lib/analytics/classifications.ts +++ b/src/lib/analytics/classifications.ts @@ -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!`, }); } } diff --git a/src/lib/analytics/pulse-targets.ts b/src/lib/analytics/pulse-targets.ts index 0d8c5fc4e..2e6b6fe28 100644 --- a/src/lib/analytics/pulse-targets.ts +++ b/src/lib/analytics/pulse-targets.ts @@ -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" }; } diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index dff1bbb83..a452b9123 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -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; } diff --git a/src/lib/i18n/config.ts b/src/lib/i18n/config.ts index 581c19d7a..d8b8c6f21 100644 --- a/src/lib/i18n/config.ts +++ b/src/lib/i18n/config.ts @@ -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 = { de: "Deutsch", diff --git a/src/lib/i18n/context.tsx b/src/lib/i18n/context.tsx index 71af417bc..5090a6e06 100644 --- a/src/lib/i18n/context.tsx +++ b/src/lib/i18n/context.tsx @@ -84,9 +84,9 @@ export function I18nProvider({ children }: { children: ReactNode }) { (key: string, params?: Record): 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 diff --git a/src/lib/notifications/types.ts b/src/lib/notifications/types.ts index a89db8385..9849b2710 100644 --- a/src/lib/notifications/types.ts +++ b/src/lib/notifications/types.ts @@ -17,11 +17,11 @@ export const EVENT_TYPES = [ export type EventType = (typeof EVENT_TYPES)[number]; export const EVENT_TYPE_LABELS: Record = { - 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 = { diff --git a/src/lib/validations/auth.ts b/src/lib/validations/auth.ts index f9f194891..3e5b0e956 100644 --- a/src/lib/validations/auth.ts +++ b/src/lib/validations/auth.ts @@ -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"], }); diff --git a/src/proxy.ts b/src/proxy.ts index b3f5e2dea..952b27810 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -36,9 +36,21 @@ const DEMO_MUTATION_ALLOWLIST = [ "/api/auth/passkey/login-verify", ]; +// Legacy route redirects (German → English) +const LEGACY_REDIRECTS: Record = { + "/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();