mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
i18n: migrate from German-first to English-first default locale
Switch defaultLocale from "de" to "en" and make English the code-level language throughout. All ~145 hardcoded German strings translated: - API error messages (52 routes) now return English - Zod validation messages in English - Classification/analytics functions return English categories - Notification type labels in English - Component strings use i18n t() calls (heatmap, mood chart, medication form, intake history, password strength, insights card) - ~50 new i18n keys in both de.json and en.json Route migration: - /stimmung → /mood, /zielwerte → /targets (new pages) - 301 redirects in proxy.ts for legacy URLs - Sidebar + bottom nav updated Bump version to 1.0.1.
This commit is contained in:
1 parent
38309932f3
commit
0c0c345e82
74 files changed
+871
-402
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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 <component>`.
|
||||
- **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<T>[]` 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`
|
||||
+175
-3
@@ -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."
|
||||
}
|
||||
}
|
||||
+175
-3
@@ -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."
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.1",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.31.0",
|
||||
"scripts": {
|
||||
|
||||
@@ -30,11 +30,11 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
const body = await request.json();
|
||||
confirm = typeof body?.confirm === "string" ? body.confirm : "";
|
||||
} catch {
|
||||
return apiError("Ungültige Anfrage", 422);
|
||||
return apiError("Invalid request", 422);
|
||||
}
|
||||
|
||||
if (confirm !== "DELETE ALL") {
|
||||
return apiError("Bestätigung fehlt", 422);
|
||||
return apiError("Confirmation missing", 422);
|
||||
}
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
|
||||
@@ -12,10 +12,10 @@ export const POST = apiHandler(async () => {
|
||||
|
||||
const settings = await getGlitchtipSettings();
|
||||
if (!settings.glitchtipEnabled) {
|
||||
return apiError("Glitchtip ist deaktiviert", 422);
|
||||
return apiError("Glitchtip is disabled", 422);
|
||||
}
|
||||
if (!settings.glitchtipDsn) {
|
||||
return apiError("Glitchtip DSN fehlt", 422);
|
||||
return apiError("Glitchtip DSN is missing", 422);
|
||||
}
|
||||
|
||||
const delivery = await sendGlitchtipEvent({
|
||||
@@ -32,13 +32,13 @@ export const POST = apiHandler(async () => {
|
||||
if (!delivery.ok) {
|
||||
getEvent()?.addWarning("Glitchtip test event rejected: " + delivery.method + " " + delivery.status + " " + delivery.details);
|
||||
return apiError(
|
||||
`Glitchtip-Testevent wurde abgelehnt (HTTP ${delivery.status ?? 502})`,
|
||||
`Glitchtip test event rejected (HTTP ${delivery.status ?? 502})`,
|
||||
502,
|
||||
);
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
sent: true,
|
||||
message: "Glitchtip-Testevent wurde gesendet",
|
||||
message: "Glitchtip test event sent",
|
||||
});
|
||||
});
|
||||
@@ -51,18 +51,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const settings = await getPublicMonitoringSettings();
|
||||
if (!settings.umamiEnabled) {
|
||||
return apiError("Umami ist deaktiviert", 422);
|
||||
return apiError("Umami is disabled", 422);
|
||||
}
|
||||
if (!settings.umamiScriptUrl || !settings.umamiWebsiteId) {
|
||||
return apiError(
|
||||
"Umami Script-URL und Website-ID müssen konfiguriert sein",
|
||||
"Umami script URL and website ID must be configured",
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
const targetUrls = resolveUmamiSendUrls(settings.umamiScriptUrl);
|
||||
if (targetUrls.length === 0) {
|
||||
return apiError("Umami Script-URL ist ungültig", 422);
|
||||
return apiError("Umami script URL is invalid", 422);
|
||||
}
|
||||
|
||||
const appUrl = resolveAppUrl(request);
|
||||
@@ -100,7 +100,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
if (upstream.ok) {
|
||||
return apiSuccess({
|
||||
sent: true,
|
||||
message: "Umami-Testevent wurde gesendet",
|
||||
message: "Umami test event sent",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
getEvent()?.addWarning("Umami test event rejected: " + lastStatus);
|
||||
return apiError(
|
||||
`Umami-Testevent wurde abgelehnt (HTTP ${lastStatus})`,
|
||||
`Umami test event rejected (HTTP ${lastStatus})`,
|
||||
502,
|
||||
);
|
||||
});
|
||||
@@ -79,7 +79,7 @@ export const POST = apiHandler(async () => {
|
||||
if (channels.length === 0) {
|
||||
return apiSuccess({
|
||||
sent: false,
|
||||
message: "Keine aktivierten Benachrichtigungskanäle gefunden",
|
||||
message: "No enabled notification channels found",
|
||||
results: [],
|
||||
});
|
||||
}
|
||||
@@ -89,7 +89,7 @@ export const POST = apiHandler(async () => {
|
||||
userId: user.id,
|
||||
title: "Test-Notification",
|
||||
message:
|
||||
"<b>HealthLog Test:</b> Wenn du diese Nachricht siehst, funktionieren deine Benachrichtigungen!",
|
||||
"<b>HealthLog Test:</b> If you see this message, your notifications are working!",
|
||||
};
|
||||
|
||||
const results: Array<{
|
||||
@@ -104,7 +104,7 @@ export const POST = apiHandler(async () => {
|
||||
results.push({
|
||||
channel: channel.type,
|
||||
success: false,
|
||||
error: "SYSTEM_ALERT deaktiviert in Einstellungen",
|
||||
error: "SYSTEM_ALERT disabled in settings",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -124,7 +124,7 @@ export const POST = apiHandler(async () => {
|
||||
results.push({
|
||||
channel: "TELEGRAM",
|
||||
success: false,
|
||||
error: `Telegram API hat Fehler gemeldet (chatId: ${config.chatId})`,
|
||||
error: `Telegram API reported error (chatId: ${config.chatId})`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -139,7 +139,7 @@ export const POST = apiHandler(async () => {
|
||||
results.push({
|
||||
channel: "NTFY",
|
||||
success: false,
|
||||
error: `Senden fehlgeschlagen (topic: ${config.topic})`,
|
||||
error: `Send failed (topic: ${config.topic})`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -153,7 +153,7 @@ export const POST = apiHandler(async () => {
|
||||
results.push({
|
||||
channel: type,
|
||||
success: false,
|
||||
error: "Unbekannter Kanaltyp",
|
||||
error: "Unknown channel type",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -163,7 +163,7 @@ export const POST = apiHandler(async () => {
|
||||
results.push({
|
||||
channel: channel.type,
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : "Unbekannter Fehler",
|
||||
error: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -173,11 +173,11 @@ export const POST = apiHandler(async () => {
|
||||
|
||||
let message: string;
|
||||
if (failCount === 0) {
|
||||
message = `Alle ${successCount} Kanäle erfolgreich`;
|
||||
message = `All ${successCount} channels successful`;
|
||||
} else if (successCount === 0) {
|
||||
message = `Alle ${failCount} Kanäle fehlgeschlagen`;
|
||||
message = `All ${failCount} channels failed`;
|
||||
} else {
|
||||
message = `${successCount} erfolgreich, ${failCount} fehlgeschlagen`;
|
||||
message = `${successCount} successful, ${failCount} failed`;
|
||||
}
|
||||
|
||||
return apiSuccess({ sent: successCount > 0, message, results });
|
||||
|
||||
@@ -180,7 +180,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return apiError("Keine gültigen Felder", 422);
|
||||
return apiError("No valid fields", 422);
|
||||
}
|
||||
|
||||
const settings = await prisma.appSettings.upsert({
|
||||
|
||||
@@ -32,16 +32,16 @@ export const POST = apiHandler(async (
|
||||
const { password } = body as { password?: string };
|
||||
|
||||
if (!password || typeof password !== "string") {
|
||||
return apiError("Passwort erforderlich", 422);
|
||||
return apiError("Password required", 422);
|
||||
}
|
||||
|
||||
const target = await prisma.user.findUnique({ where: { id } });
|
||||
if (!target) return apiError("Benutzer nicht gefunden", 404);
|
||||
if (!target) return apiError("User not found", 404);
|
||||
|
||||
const strength = checkPasswordStrength(password, [target.username]);
|
||||
if (!strength.isAcceptable) {
|
||||
return apiError(
|
||||
strength.feedback[0] || "Passwort zu schwach (Score < 3)",
|
||||
strength.feedback[0] || "Password too weak (score < 3)",
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return NextResponse.json(
|
||||
{
|
||||
data: null,
|
||||
error: "Zu viele Anmeldeversuche. Bitte später erneut versuchen.",
|
||||
error: "Too many login attempts. Please try again later.",
|
||||
},
|
||||
{ status: 429, headers: rateLimitHeaders(rl) },
|
||||
);
|
||||
@@ -31,7 +31,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const parsed = loginPasswordSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return apiError("Ungültige Anmeldedaten", 422);
|
||||
return apiError("Invalid credentials", 422);
|
||||
}
|
||||
|
||||
const { email, password } = parsed.data;
|
||||
@@ -51,7 +51,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
ipAddress: ip,
|
||||
details: { identifier, reason: "user_not_found_or_no_password" },
|
||||
});
|
||||
return apiError("Ungültige Anmeldedaten", 401);
|
||||
return apiError("Invalid credentials", 401);
|
||||
}
|
||||
|
||||
const valid = await verifyPassword(user.passwordHash, password);
|
||||
@@ -61,7 +61,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
ipAddress: ip,
|
||||
details: { reason: "invalid_password" },
|
||||
});
|
||||
return apiError("Ungültige Anmeldedaten", 401);
|
||||
return apiError("Invalid credentials", 401);
|
||||
}
|
||||
|
||||
const ua = request.headers.get("user-agent");
|
||||
|
||||
@@ -16,7 +16,7 @@ export const POST = apiHandler(async (request: Request) => {
|
||||
return NextResponse.json(
|
||||
{
|
||||
data: null,
|
||||
error: "Zu viele Passkey-Anfragen. Bitte später erneut versuchen.",
|
||||
error: "Too many passkey requests. Please try again later.",
|
||||
},
|
||||
{ status: 429, headers: rateLimitHeaders(rl) },
|
||||
);
|
||||
|
||||
@@ -15,7 +15,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const ip = getClientIp(request);
|
||||
const rl = await checkRateLimit(`auth:passkey-verify:${ip}`, 10, 15 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Zu viele Versuche. Bitte 15 Minuten warten.", 429);
|
||||
return apiError("Too many attempts. Please wait 15 minutes.", 429);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson<Record<string, unknown>>(request);
|
||||
@@ -25,7 +25,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const credential = body.credential;
|
||||
|
||||
if (!challengeId || !credential) {
|
||||
return apiError("challengeId und credential erforderlich", 422);
|
||||
return apiError("challengeId and credential required", 422);
|
||||
}
|
||||
|
||||
const { verification, passkey } = await verifyAuthentication(
|
||||
@@ -38,7 +38,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
ipAddress: ip,
|
||||
details: { reason: "passkey_verification_failed" },
|
||||
});
|
||||
return apiError("Passkey-Verifizierung fehlgeschlagen", 401);
|
||||
return apiError("Passkey verification failed", 401);
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
@@ -46,7 +46,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return apiError("Benutzer nicht gefunden", 404);
|
||||
return apiError("User not found", 404);
|
||||
}
|
||||
|
||||
const ua = request.headers.get("user-agent");
|
||||
|
||||
@@ -16,13 +16,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const credential = body.credential as Record<string, unknown> | undefined;
|
||||
|
||||
if (!challengeId || !credential) {
|
||||
return apiError("challengeId und credential erforderlich", 422);
|
||||
return apiError("challengeId and credential required", 422);
|
||||
}
|
||||
|
||||
const verification = await verifyRegistration(challengeId, credential);
|
||||
|
||||
if (!verification.verified || !verification.registrationInfo) {
|
||||
return apiError("Passkey-Verifizierung fehlgeschlagen", 400);
|
||||
return apiError("Passkey verification failed", 400);
|
||||
}
|
||||
|
||||
const { registrationInfo } = verification;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}...)\n\n*Hinweis: Der vollständige Screenshot wurde aus Größengründen gekürzt. Bitte den Nutzer direkt kontaktieren.*`;
|
||||
const commentBody = `### Screenshot\n\n}...)\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`,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -161,7 +161,7 @@ export const GET = apiHandler(async () => {
|
||||
}
|
||||
targets.push({
|
||||
type: "WEIGHT",
|
||||
label: "Gewicht",
|
||||
label: "Weight",
|
||||
current: latestByType.WEIGHT ?? null,
|
||||
average30: avg30ByType.WEIGHT ?? null,
|
||||
trend: computeTrend("WEIGHT"),
|
||||
@@ -186,7 +186,7 @@ export const GET = apiHandler(async () => {
|
||||
}
|
||||
targets.push({
|
||||
type: "BLOOD_PRESSURE",
|
||||
label: "Blutdruck",
|
||||
label: "Blood pressure",
|
||||
current: latestByType.BLOOD_PRESSURE_SYS ?? null,
|
||||
average30: avg30ByType.BLOOD_PRESSURE_SYS ?? null,
|
||||
trend: computeTrend("BLOOD_PRESSURE_SYS"),
|
||||
@@ -227,7 +227,7 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
targets.push({
|
||||
type: "BLOOD_PRESSURE_IN_TARGET",
|
||||
label: "Blutdruck im Zielbereich",
|
||||
label: "Blood pressure on target",
|
||||
current: bpInTargetRate,
|
||||
average30: bpInTargetRate,
|
||||
trend: null,
|
||||
@@ -236,10 +236,10 @@ export const GET = apiHandler(async () => {
|
||||
classification:
|
||||
bpInTargetRate != null
|
||||
? bpInTargetRate >= 70
|
||||
? { category: "Gut", color: "#50fa7b" }
|
||||
? { category: "Good", color: "#50fa7b" }
|
||||
: bpInTargetRate >= 40
|
||||
? { category: "Moderat", color: "#f1fa8c" }
|
||||
: { category: "Niedrig", color: "#ff5555" }
|
||||
? { category: "Moderate", color: "#f1fa8c" }
|
||||
: { category: "Low", color: "#ff5555" }
|
||||
: null,
|
||||
source: "ESC/ESH 2018",
|
||||
});
|
||||
@@ -254,7 +254,7 @@ export const GET = apiHandler(async () => {
|
||||
}
|
||||
targets.push({
|
||||
type: "PULSE",
|
||||
label: "Ruhepuls",
|
||||
label: "Resting pulse",
|
||||
current: latestByType.PULSE ?? null,
|
||||
average30: avg30ByType.PULSE ?? null,
|
||||
trend: computeTrend("PULSE"),
|
||||
@@ -273,7 +273,7 @@ export const GET = apiHandler(async () => {
|
||||
}
|
||||
targets.push({
|
||||
type: "SLEEP_DURATION",
|
||||
label: "Schlafdauer",
|
||||
label: "Sleep duration",
|
||||
current: latestByType.SLEEP_DURATION ?? null,
|
||||
average30: avg30ByType.SLEEP_DURATION ?? null,
|
||||
trend: computeTrend("SLEEP_DURATION"),
|
||||
@@ -335,7 +335,7 @@ export const GET = apiHandler(async () => {
|
||||
}
|
||||
targets.push({
|
||||
type: "BODY_FAT",
|
||||
label: "Koerperfett",
|
||||
label: "Body fat",
|
||||
current: latestByType.BODY_FAT ?? null,
|
||||
average30: avg30ByType.BODY_FAT ?? null,
|
||||
trend: computeTrend("BODY_FAT"),
|
||||
@@ -354,11 +354,11 @@ export const GET = apiHandler(async () => {
|
||||
}
|
||||
targets.push({
|
||||
type: "ACTIVITY_STEPS",
|
||||
label: "Schritte/Tag",
|
||||
label: "Steps/day",
|
||||
current: latestByType.ACTIVITY_STEPS ?? null,
|
||||
average30: avg30ByType.ACTIVITY_STEPS ?? null,
|
||||
trend: computeTrend("ACTIVITY_STEPS"),
|
||||
unit: "Schritte",
|
||||
unit: "steps",
|
||||
range: stepsRange,
|
||||
classification: stepsClassification,
|
||||
source: "WHO",
|
||||
@@ -453,7 +453,7 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
targets.push({
|
||||
type: "MEDICATION_COMPLIANCE",
|
||||
label: "Einnahmetreue",
|
||||
label: "Medication compliance",
|
||||
current: complianceRate7,
|
||||
average30: complianceRate30,
|
||||
trend: null,
|
||||
@@ -462,12 +462,12 @@ export const GET = apiHandler(async () => {
|
||||
classification:
|
||||
complianceRate7 != null
|
||||
? complianceRate7 >= 90
|
||||
? { category: "Sehr gut", color: "#50fa7b" }
|
||||
? { category: "Very good", color: "#50fa7b" }
|
||||
: complianceRate7 >= 70
|
||||
? { category: "Gut", color: "#f1fa8c" }
|
||||
: { category: "Niedrig", color: "#ff5555" }
|
||||
? { category: "Good", color: "#f1fa8c" }
|
||||
: { category: "Low", color: "#ff5555" }
|
||||
: null,
|
||||
source: "7-Tage",
|
||||
source: "7-day",
|
||||
details: {
|
||||
medications: medicationStats.map((medication) => ({
|
||||
name: medication.name,
|
||||
@@ -523,15 +523,15 @@ export const GET = apiHandler(async () => {
|
||||
const moodClassification =
|
||||
latestMoodScore != null
|
||||
? latestMoodScore >= 3.5
|
||||
? { category: "Gut", color: "#50fa7b" }
|
||||
? { category: "Good", color: "#50fa7b" }
|
||||
: latestMoodScore >= 2
|
||||
? { category: "Moderat", color: "#f1fa8c" }
|
||||
: { category: "Niedrig", color: "#ff5555" }
|
||||
? { category: "Moderate", color: "#f1fa8c" }
|
||||
: { category: "Low", color: "#ff5555" }
|
||||
: null;
|
||||
|
||||
targets.push({
|
||||
type: "MOOD_SCORE",
|
||||
label: "Stimmung",
|
||||
label: "Mood",
|
||||
current: latestMoodScore,
|
||||
average30: moodAvg30,
|
||||
trend: moodTrend,
|
||||
@@ -551,14 +551,14 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
const stabilityClassification =
|
||||
stdDev <= 0.5
|
||||
? { category: "Sehr stabil", color: "#50fa7b" }
|
||||
? { category: "Very stable", color: "#50fa7b" }
|
||||
: stdDev <= 1.0
|
||||
? { category: "Stabil", color: "#f1fa8c" }
|
||||
: { category: "Schwankend", color: "#ff5555" };
|
||||
? { category: "Stable", color: "#f1fa8c" }
|
||||
: { category: "Fluctuating", color: "#ff5555" };
|
||||
|
||||
targets.push({
|
||||
type: "MOOD_STABILITY",
|
||||
label: "Stimmungsstabilitaet",
|
||||
label: "Mood stability",
|
||||
current: stdDev,
|
||||
average30: stdDev,
|
||||
trend: null,
|
||||
|
||||
@@ -17,7 +17,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
select: { moodLogGlobal: true },
|
||||
});
|
||||
if (appSettings && !appSettings.moodLogGlobal) {
|
||||
return apiError("moodLog-Integration ist deaktiviert", 403);
|
||||
return apiError("moodLog integration is disabled", 403);
|
||||
}
|
||||
|
||||
let fullSync = false;
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -134,7 +134,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") {
|
||||
return apiError("Ein Messwert mit diesen Daten existiert bereits", 409);
|
||||
return apiError("A measurement with this data already exists", 409);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -17,13 +17,13 @@ function medicationScope(medicationId: string): string {
|
||||
export const GET = apiHandler(async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
if (!(await isApiGloballyEnabled())) {
|
||||
return apiError("API ist global deaktiviert", 403);
|
||||
return apiError("API is globally disabled", 403);
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const medication = await prisma.medication.findUnique({ where: { id } });
|
||||
if (!medication || medication.userId !== user.id) {
|
||||
return apiError("Medikament nicht gefunden", 404);
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
|
||||
const scope = medicationScope(id);
|
||||
@@ -56,13 +56,13 @@ export const GET = apiHandler(async (_request: NextRequest, { params }: RoutePar
|
||||
export const PUT = apiHandler(async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
if (!(await isApiGloballyEnabled())) {
|
||||
return apiError("API ist global deaktiviert", 403);
|
||||
return apiError("API is globally disabled", 403);
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const medication = await prisma.medication.findUnique({ where: { id } });
|
||||
if (!medication || medication.userId !== user.id) {
|
||||
return apiError("Medikament nicht gefunden", 404);
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
|
||||
let enabled = false;
|
||||
@@ -70,7 +70,7 @@ export const PUT = apiHandler(async (request: NextRequest, { params }: RoutePara
|
||||
const body = await request.json();
|
||||
enabled = body?.enabled === true;
|
||||
} catch {
|
||||
return apiError("Ungültige Anfrage", 422);
|
||||
return apiError("Invalid request", 422);
|
||||
}
|
||||
|
||||
const scope = medicationScope(id);
|
||||
|
||||
@@ -20,7 +20,7 @@ export const GET = apiHandler(async (_request: Request, { params }: RouteParams)
|
||||
});
|
||||
|
||||
if (!medication || medication.userId !== user.id) {
|
||||
return apiError("Medikament nicht gefunden", 404);
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
|
||||
const events = await prisma.medicationIntakeEvent.findMany({
|
||||
|
||||
@@ -22,7 +22,7 @@ export const PUT = apiHandler(async (request: NextRequest, { params }: RoutePara
|
||||
event.userId !== user.id ||
|
||||
event.medicationId !== id
|
||||
) {
|
||||
return apiError("Einnahme nicht gefunden", 404);
|
||||
return apiError("Intake not found", 404);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
@@ -77,7 +77,7 @@ export const DELETE = apiHandler(async (request: NextRequest, { params }: RouteP
|
||||
event.userId !== user.id ||
|
||||
event.medicationId !== id
|
||||
) {
|
||||
return apiError("Einnahme nicht gefunden", 404);
|
||||
return apiError("Intake not found", 404);
|
||||
}
|
||||
|
||||
await prisma.medicationIntakeEvent.delete({ where: { id: eventId } });
|
||||
|
||||
@@ -22,7 +22,7 @@ export const POST = apiHandler(async (request: NextRequest, { params }: RoutePar
|
||||
const { id } = await params;
|
||||
const medication = await prisma.medication.findUnique({ where: { id } });
|
||||
if (!medication || medication.userId !== user.id) {
|
||||
return apiError("Medikament nicht gefunden", 404);
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
@@ -39,7 +39,7 @@ export const POST = apiHandler(async (request: NextRequest, { params }: RoutePar
|
||||
const parsed = importSchema.safeParse(payload);
|
||||
if (!parsed.success) {
|
||||
return apiError(
|
||||
`Ungültiges Format: ${parsed.error.issues[0].message}`,
|
||||
`Invalid format: ${parsed.error.issues[0].message}`,
|
||||
422,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export const DELETE = apiHandler(async (request: NextRequest, { params }: RouteP
|
||||
const { id } = await params;
|
||||
const medication = await prisma.medication.findUnique({ where: { id } });
|
||||
if (!medication || medication.userId !== user.id) {
|
||||
return apiError("Medikament nicht gefunden", 404);
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
|
||||
const { count } = await prisma.medicationIntakeEvent.deleteMany({
|
||||
|
||||
@@ -17,7 +17,7 @@ export const POST = apiHandler(async (request: NextRequest, { params }: RoutePar
|
||||
const { id } = await params;
|
||||
const medication = await prisma.medication.findUnique({ where: { id } });
|
||||
if (!medication || medication.userId !== user.id) {
|
||||
return apiError("Medikament nicht gefunden", 404);
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
@@ -108,7 +108,7 @@ export const GET = apiHandler(async (request: NextRequest, { params }: RoutePara
|
||||
const { id } = await params;
|
||||
const medication = await prisma.medication.findUnique({ where: { id } });
|
||||
if (!medication || medication.userId !== user.id) {
|
||||
return apiError("Medikament nicht gefunden", 404);
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
|
||||
const searchParams = Object.fromEntries(request.nextUrl.searchParams);
|
||||
|
||||
@@ -16,7 +16,7 @@ export const GET = apiHandler(async (_request: NextRequest, { params }: RoutePar
|
||||
select: { userId: true },
|
||||
});
|
||||
if (!medication || medication.userId !== user.id) {
|
||||
return apiError("Medikament nicht gefunden", 404);
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
|
||||
const config = await prisma.reminderPhaseConfig.findUnique({
|
||||
@@ -55,7 +55,7 @@ export const PUT = apiHandler(async (request: NextRequest, { params }: RoutePara
|
||||
select: { userId: true },
|
||||
});
|
||||
if (!medication || medication.userId !== user.id) {
|
||||
return apiError("Medikament nicht gefunden", 404);
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
@@ -63,7 +63,7 @@ export const PUT = apiHandler(async (request: NextRequest, { params }: RoutePara
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = phaseConfigSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError("Ungültige Eingabe", 400);
|
||||
return apiError("Invalid input", 400);
|
||||
}
|
||||
|
||||
const config = await prisma.reminderPhaseConfig.upsert({
|
||||
@@ -95,7 +95,7 @@ export const DELETE = apiHandler(async (_request: NextRequest, { params }: Route
|
||||
select: { userId: true },
|
||||
});
|
||||
if (!medication || medication.userId !== user.id) {
|
||||
return apiError("Medikament nicht gefunden", 404);
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
|
||||
await prisma.reminderPhaseConfig.deleteMany({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -113,7 +113,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
} catch (err) {
|
||||
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") {
|
||||
return apiError(
|
||||
"Ein Stimmungseintrag mit diesen Daten existiert bereits",
|
||||
"A mood entry with this data already exists",
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -98,18 +98,18 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return apiError("Ungültige JSON-Daten", 422);
|
||||
return apiError("Invalid JSON data", 422);
|
||||
}
|
||||
|
||||
const parsed = notificationPreferenceSchema.safeParse(body);
|
||||
if (!parsed.success) return apiError("Ungültige Daten", 422);
|
||||
if (!parsed.success) return apiError("Invalid data", 422);
|
||||
|
||||
const { channelId, eventType, enabled } = parsed.data;
|
||||
|
||||
const channel = await prisma.notificationChannel.findFirst({
|
||||
where: { id: channelId, userId: user.id },
|
||||
});
|
||||
if (!channel) return apiError("Kanal nicht gefunden", 404);
|
||||
if (!channel) return apiError("Channel not found", 404);
|
||||
|
||||
const preference = await prisma.notificationPreference.upsert({
|
||||
where: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -24,7 +24,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
if (jsonError) return jsonError;
|
||||
const result = z.safeParse(onboardingSchema, body);
|
||||
if (!result.success) {
|
||||
return apiError("Ungültige Eingabe", 422);
|
||||
return apiError("Invalid input", 422);
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -19,7 +19,7 @@ export const POST = apiHandler(async () => {
|
||||
});
|
||||
|
||||
if (!connection) {
|
||||
return apiError("Keine Withings-Verbindung vorhanden", 404);
|
||||
return apiError("No Withings connection", 404);
|
||||
}
|
||||
|
||||
// Try to unsubscribe webhook (best-effort)
|
||||
|
||||
@@ -17,7 +17,7 @@ import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export default function StimmungPage() {
|
||||
export default function MoodPage() {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
@@ -471,7 +471,7 @@ function TargetCard({
|
||||
);
|
||||
}
|
||||
|
||||
export default function ZielwertePage() {
|
||||
export default function TargetsPage() {
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useEffect, useRef } from "react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
interface DailyData {
|
||||
expected: number;
|
||||
@@ -19,21 +20,6 @@ interface ComplianceHeatmapProps {
|
||||
|
||||
const CELL_SIZE = 18;
|
||||
const GAP = 3;
|
||||
const WEEKDAY_LABELS = ["Mo", "", "Mi", "", "Fr", "", "So"];
|
||||
const MONTH_LABELS = [
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mär",
|
||||
"Apr",
|
||||
"Mai",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Okt",
|
||||
"Nov",
|
||||
"Dez",
|
||||
];
|
||||
|
||||
function getColor(data: DailyData): string {
|
||||
if (data.expected === 0) return "var(--secondary)";
|
||||
@@ -80,6 +66,7 @@ export function ComplianceHeatmap({
|
||||
days = 90,
|
||||
stretch = false,
|
||||
}: ComplianceHeatmapProps) {
|
||||
const { t } = useTranslations();
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const [tooltip, setTooltip] = useState<{
|
||||
@@ -88,6 +75,9 @@ export function ComplianceHeatmap({
|
||||
text: string;
|
||||
} | null>(null);
|
||||
|
||||
const WEEKDAY_LABELS = [
|
||||
t("charts.weekdays.mon"), "", t("charts.weekdays.wed"), "", t("charts.weekdays.fri"), "", t("charts.weekdays.sun"),
|
||||
];
|
||||
useEffect(() => {
|
||||
if (!stretch) return;
|
||||
const element = containerRef.current;
|
||||
@@ -107,6 +97,12 @@ export function ComplianceHeatmap({
|
||||
}, [stretch]);
|
||||
|
||||
const { cells, weeks, monthMarkers } = useMemo(() => {
|
||||
const MONTH_LABELS = [
|
||||
t("charts.months.jan"), t("charts.months.feb"), t("charts.months.mar"),
|
||||
t("charts.months.apr"), t("charts.months.may"), t("charts.months.jun"),
|
||||
t("charts.months.jul"), t("charts.months.aug"), t("charts.months.sep"),
|
||||
t("charts.months.oct"), t("charts.months.nov"), t("charts.months.dec"),
|
||||
];
|
||||
const now = new Date();
|
||||
const cellList: Array<{
|
||||
dateKey: string;
|
||||
@@ -162,7 +158,7 @@ export function ComplianceHeatmap({
|
||||
}
|
||||
|
||||
return { cells: cellList, weeks: col + 1, monthMarkers: markers };
|
||||
}, [dailyCompliance, days]);
|
||||
}, [dailyCompliance, days, t]);
|
||||
|
||||
const labelWidth = stretch ? 0 : 76;
|
||||
const headerHeight = 18;
|
||||
@@ -244,7 +240,7 @@ export function ComplianceHeatmap({
|
||||
cell.data.late !== undefined ||
|
||||
cell.data.veryLate !== undefined;
|
||||
const timingInfo = hasTimingData
|
||||
? ` | ${cell.data.onTime ?? 0} pünktlich, ${cell.data.late ?? 0} spät, ${cell.data.veryLate ?? 0} sehr spät`
|
||||
? ` | ${cell.data.onTime ?? 0} ${t("charts.heatmapOnTime")}, ${cell.data.late ?? 0} ${t("charts.heatmapLate")}, ${cell.data.veryLate ?? 0} ${t("charts.heatmapVeryLate")}`
|
||||
: "";
|
||||
setTooltip({
|
||||
x: e.clientX,
|
||||
@@ -274,10 +270,10 @@ export function ComplianceHeatmap({
|
||||
style={{ marginLeft: stretch ? 0 : labelWidth }}
|
||||
>
|
||||
{[
|
||||
{ color: "var(--dracula-green)", label: "Pünktlich" },
|
||||
{ color: "var(--dracula-yellow)", label: "Spät" },
|
||||
{ color: "var(--dracula-orange)", label: "Sehr spät" },
|
||||
{ color: "var(--dracula-red)", label: "Verpasst" },
|
||||
{ color: "var(--dracula-green)", label: t("charts.legendOnTime") },
|
||||
{ color: "var(--dracula-yellow)", label: t("charts.legendLate") },
|
||||
{ color: "var(--dracula-orange)", label: t("charts.legendVeryLate") },
|
||||
{ color: "var(--dracula-red)", label: t("charts.legendMissed") },
|
||||
].map(({ color, label }) => (
|
||||
<span key={label} className="flex items-center gap-1">
|
||||
<div
|
||||
|
||||
@@ -56,13 +56,7 @@ const TIME_RANGES_KEYS = [
|
||||
{ labelKey: "charts.pointsAllLabel", points: 0, titleKey: "charts.pointsAllTitle" },
|
||||
] as const;
|
||||
|
||||
const MOOD_LABELS: Record<number, string> = {
|
||||
1: "Lausig",
|
||||
2: "Schlecht",
|
||||
3: "Okay",
|
||||
4: "Gut",
|
||||
5: "Super",
|
||||
};
|
||||
// MOOD_LABELS built dynamically via t() in the component
|
||||
|
||||
const VALUE_BANDS = [
|
||||
{ min: 1, max: 2, color: "#ff5555", opacity: 0.16 },
|
||||
@@ -231,13 +225,21 @@ export function MoodChart({ title }: MoodChartProps) {
|
||||
|
||||
const maxPointIndex = Math.max(0, (chartData?.length ?? 1) - 1);
|
||||
|
||||
const moodLabels: Record<number, string> = {
|
||||
1: t("charts.moodLabel1"),
|
||||
2: t("charts.moodLabel2"),
|
||||
3: t("charts.moodLabel3"),
|
||||
4: t("charts.moodLabel4"),
|
||||
5: t("charts.moodLabel5"),
|
||||
};
|
||||
|
||||
const formatMoodTick = (value: number): string => {
|
||||
return MOOD_LABELS[value] ?? String(value);
|
||||
return moodLabels[value] ?? String(value);
|
||||
};
|
||||
|
||||
const formatTooltipValue = (value: number): string => {
|
||||
const rounded = Math.round(value * 10) / 10;
|
||||
const label = MOOD_LABELS[Math.round(value)];
|
||||
const label = moodLabels[Math.round(value)];
|
||||
return label ? `${rounded} (${label})` : String(rounded);
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Sparkles, RefreshCw, Loader2, AlertTriangle } from "lucide-react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
interface InsightsOutput {
|
||||
changed: string;
|
||||
@@ -32,6 +33,7 @@ const CONFIDENCE_COLORS: Record<string, string> = {
|
||||
export function InsightsCard() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslations();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Check if insights are configured
|
||||
@@ -81,7 +83,7 @@ export function InsightsCard() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="text-dracula-purple h-5 w-5" />
|
||||
<CardTitle className="text-lg">KI-Insights</CardTitle>
|
||||
<CardTitle className="text-lg">{t("insights.aiInsights")}</CardTitle>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -94,7 +96,7 @@ export function InsightsCard() {
|
||||
) : (
|
||||
<RefreshCw className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
{insights ? "Aktualisieren" : "Generieren"}
|
||||
{insights ? t("insights.refreshButton") : t("insights.generateButton")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -111,7 +113,7 @@ export function InsightsCard() {
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="text-dracula-purple h-6 w-6 animate-spin" />
|
||||
<span className="text-muted-foreground ml-2 text-sm">
|
||||
Analysiere Gesundheitsdaten...
|
||||
{t("insights.analyzing")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -120,19 +122,19 @@ export function InsightsCard() {
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className={CONFIDENCE_COLORS[insights.confidence] ?? ""}>
|
||||
Konfidenz: {insights.confidence}
|
||||
{t("insights.confidence")}: {t(`insights.confidence_${insights.confidence}`)}
|
||||
</Badge>
|
||||
{generate.data?.cached && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Gecacht
|
||||
{t("insights.cached")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Section title="Was hat sich verändert" text={insights.changed} />
|
||||
<Section title="Was ist stabil" text={insights.stable} />
|
||||
<Section title="Mögliche Zusammenhänge" text={insights.drivers} />
|
||||
<Section title="Nächste Schritte" text={insights.nextSteps} />
|
||||
<Section title={t("insights.sectionChanged")} text={insights.changed} />
|
||||
<Section title={t("insights.sectionStable")} text={insights.stable} />
|
||||
<Section title={t("insights.sectionDrivers")} text={insights.drivers} />
|
||||
<Section title={t("insights.sectionNextSteps")} text={insights.nextSteps} />
|
||||
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
@@ -145,8 +147,7 @@ export function InsightsCard() {
|
||||
|
||||
{!insights && !generate.isPending && !error && (
|
||||
<p className="text-muted-foreground py-4 text-center text-sm">
|
||||
Klicke auf "Generieren" für KI-basierte Insights deiner
|
||||
Gesundheitsdaten.
|
||||
{t("insights.generatePrompt")}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
|
||||
@@ -50,10 +50,10 @@ const STORAGE_KEY = "healthlog-sidebar-collapsed";
|
||||
const navItems = [
|
||||
{ href: "/", tKey: "nav.dashboard", icon: Home },
|
||||
{ href: "/measurements", tKey: "nav.measurements", icon: Activity },
|
||||
{ href: "/stimmung", tKey: "nav.mood", icon: Waves },
|
||||
{ href: "/mood", tKey: "nav.mood", icon: Waves },
|
||||
{ href: "/medications", tKey: "nav.medications", icon: Pill },
|
||||
{ href: "/insights", tKey: "nav.insights", icon: Lightbulb },
|
||||
{ href: "/zielwerte", tKey: "nav.targets", icon: Target },
|
||||
{ href: "/targets", tKey: "nav.targets", icon: Target },
|
||||
{ href: "/achievements", tKey: "nav.achievements", icon: Trophy },
|
||||
];
|
||||
|
||||
|
||||
@@ -72,12 +72,7 @@ interface IntakeHistoryListProps {
|
||||
onCreateOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
WEB: "Webseite",
|
||||
API: "Externe API",
|
||||
REMINDER: "Telegram / Erinnerung",
|
||||
IMPORT: "CSV-Import",
|
||||
};
|
||||
// SOURCE_LABELS built dynamically via t() in the component
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
@@ -97,6 +92,13 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslations();
|
||||
|
||||
const sourceLabels: Record<string, string> = {
|
||||
WEB: t("medications.sourceWeb"),
|
||||
API: t("medications.sourceApi"),
|
||||
REMINDER: t("medications.sourceReminder"),
|
||||
IMPORT: t("medications.sourceImport"),
|
||||
};
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const [sortBy, setSortBy] = useState<string>("scheduledFor");
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
|
||||
@@ -219,7 +221,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
|
||||
},
|
||||
onError: (err) => {
|
||||
setEditError(
|
||||
err instanceof Error ? err.message : "Fehler beim Speichern",
|
||||
err instanceof Error ? err.message : t("medications.saveError"),
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -257,7 +259,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
|
||||
},
|
||||
onError: (err) => {
|
||||
setCreateError(
|
||||
err instanceof Error ? err.message : "Fehler beim Speichern",
|
||||
err instanceof Error ? err.message : t("medications.saveError"),
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -293,7 +295,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
|
||||
await deleteMutation.mutateAsync(editing.id);
|
||||
closeEdit();
|
||||
} catch {
|
||||
setEditError("Fehler beim Löschen");
|
||||
setEditError(t("medications.deleteError"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,7 +305,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
|
||||
|
||||
const scheduledDate = new Date(editScheduledFor);
|
||||
if (Number.isNaN(scheduledDate.getTime())) {
|
||||
setEditError("Bitte einen gültigen Zeitpunkt wählen.");
|
||||
setEditError(t("medications.invalidTimestamp"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -323,7 +325,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
|
||||
|
||||
const scheduledDate = new Date(createScheduledFor);
|
||||
if (Number.isNaN(scheduledDate.getTime())) {
|
||||
setCreateError("Bitte einen gültigen Zeitpunkt wählen.");
|
||||
setCreateError(t("medications.invalidTimestamp"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -365,7 +367,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Bitte anmelden, um Einnahmen zu sehen.
|
||||
{t("medications.loginRequiredIntakes")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -387,7 +389,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
|
||||
</div>
|
||||
) : !data?.events.length ? (
|
||||
<div className="text-muted-foreground flex h-32 items-center justify-center rounded-lg border border-dashed">
|
||||
Noch keine Einnahmen vorhanden
|
||||
{t("medications.noIntakesYet")}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -420,7 +422,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
|
||||
className="w-28"
|
||||
/>
|
||||
<TableHead className="w-20 pr-4 text-right">
|
||||
Aktionen
|
||||
{t("medications.actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -438,7 +440,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
|
||||
<TableCell>{getStatusBadge(event)}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{SOURCE_LABELS[event.source] ?? event.source}
|
||||
{sourceLabels[event.source] ?? event.source}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="pr-4 text-right">
|
||||
@@ -485,7 +487,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
|
||||
<div className="flex items-center gap-1.5">
|
||||
{getStatusBadge(event)}
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{SOURCE_LABELS[event.source] ?? event.source}
|
||||
{sourceLabels[event.source] ?? event.source}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
@@ -516,7 +518,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
Seite {page} von {totalPages}
|
||||
{t("medications.pageInfo", { page, total: totalPages })}
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
@@ -614,7 +616,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
|
||||
onClick={() => setEditDeleteDialogOpen(true)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Löschen
|
||||
{t("common.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -628,7 +630,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
|
||||
updateMutation.isPending || deleteMutation.isPending
|
||||
}
|
||||
>
|
||||
Abbrechen
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -639,7 +641,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
|
||||
{updateMutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
Speichern
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -659,7 +661,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteMutation.isPending}>
|
||||
Abbrechen
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
@@ -669,7 +671,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
|
||||
{deleteMutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
Löschen
|
||||
{t("common.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
@@ -737,13 +739,13 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
|
||||
onClick={closeCreate}
|
||||
disabled={createMutation.isPending}
|
||||
>
|
||||
Abbrechen
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
Speichern
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -800,6 +802,7 @@ function DeleteButton({
|
||||
description: string;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
@@ -807,7 +810,7 @@ function DeleteButton({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive h-8 w-8"
|
||||
aria-label="Löschen"
|
||||
aria-label={label}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -818,12 +821,12 @@ function DeleteButton({
|
||||
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
Löschen
|
||||
{t("common.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -51,17 +51,7 @@ import { toast } from "sonner";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { PhaseConfigDialog } from "@/components/medications/phase-config-dialog";
|
||||
|
||||
const DOSE_UNITS = [
|
||||
"mg",
|
||||
"g",
|
||||
"ml",
|
||||
"Tropfen",
|
||||
"Tablette(n)",
|
||||
"Kapsel(n)",
|
||||
"Stück",
|
||||
"IE",
|
||||
"µg",
|
||||
] as const;
|
||||
// DOSE_UNITS built dynamically via t() in the component
|
||||
|
||||
type TranslateFn = (
|
||||
key: string,
|
||||
@@ -245,6 +235,16 @@ export function MedicationForm({
|
||||
}: MedicationFormProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslations();
|
||||
|
||||
const doseUnits = [
|
||||
"mg", "g", "ml",
|
||||
t("medications.unitDrops"),
|
||||
t("medications.unitTablets"),
|
||||
t("medications.unitCapsules"),
|
||||
t("medications.unitPieces"),
|
||||
"IE", "µg",
|
||||
];
|
||||
|
||||
const [name, setName] = useState(initial?.name ?? "");
|
||||
|
||||
const initialDose = parseDose(initial?.dose ?? "");
|
||||
@@ -449,7 +449,7 @@ export function MedicationForm({
|
||||
id="med-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="z.B. Ibuprofen"
|
||||
placeholder={t("medications.namePlaceholder")}
|
||||
required
|
||||
maxLength={100}
|
||||
/>
|
||||
@@ -508,7 +508,7 @@ export function MedicationForm({
|
||||
inputMode="decimal"
|
||||
value={doseAmount}
|
||||
onChange={(e) => setDoseAmount(e.target.value)}
|
||||
placeholder="z.B. 400"
|
||||
placeholder={t("medications.dosePlaceholder")}
|
||||
required
|
||||
maxLength={20}
|
||||
/>
|
||||
@@ -527,7 +527,7 @@ export function MedicationForm({
|
||||
</div>
|
||||
</div>
|
||||
<datalist id="dose-units">
|
||||
{DOSE_UNITS.map((u) => (
|
||||
{doseUnits.map((u) => (
|
||||
<option key={u} value={u} />
|
||||
))}
|
||||
</datalist>
|
||||
@@ -641,7 +641,7 @@ export function MedicationForm({
|
||||
value={s.label}
|
||||
className="h-8 text-xs md:text-xs"
|
||||
onChange={(e) => updateSchedule(i, "label", e.target.value)}
|
||||
placeholder="z.B. Morgens"
|
||||
placeholder={t("medications.labelPlaceholder")}
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
@@ -653,7 +653,7 @@ export function MedicationForm({
|
||||
value={s.dose}
|
||||
className="h-8 text-xs md:text-xs"
|
||||
onChange={(e) => updateSchedule(i, "dose", e.target.value)}
|
||||
placeholder={dose || "Standard"}
|
||||
placeholder={dose || t("medications.defaultDose")}
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
@@ -721,8 +721,8 @@ export function MedicationForm({
|
||||
}`}
|
||||
title={
|
||||
s.daysOfWeek.includes(dayIndex)
|
||||
? `${label} deaktivieren`
|
||||
: `${label} aktivieren`
|
||||
? t("medications.dayDeactivate", { day: label })
|
||||
: t("medications.dayActivate", { day: label })
|
||||
}
|
||||
>
|
||||
{label}
|
||||
|
||||
@@ -3,32 +3,26 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { translateZxcvbn } from "@/lib/zxcvbn-de";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
interface PasswordStrengthProps {
|
||||
password: string;
|
||||
minLength?: number;
|
||||
}
|
||||
|
||||
const SCORE_CONFIG = [
|
||||
{ label: "Sehr schwach", color: "bg-red-500", textColor: "text-red-500" },
|
||||
{ label: "Schwach", color: "bg-orange-500", textColor: "text-orange-500" },
|
||||
{
|
||||
label: "Akzeptabel",
|
||||
color: "bg-yellow-500",
|
||||
textColor: "text-yellow-500",
|
||||
},
|
||||
{ label: "Stark", color: "bg-green-500", textColor: "text-green-500" },
|
||||
{
|
||||
label: "Sehr stark",
|
||||
color: "bg-emerald-600",
|
||||
textColor: "text-emerald-600",
|
||||
},
|
||||
const SCORE_COLORS = [
|
||||
{ color: "bg-red-500", textColor: "text-red-500" },
|
||||
{ color: "bg-orange-500", textColor: "text-orange-500" },
|
||||
{ color: "bg-yellow-500", textColor: "text-yellow-500" },
|
||||
{ color: "bg-green-500", textColor: "text-green-500" },
|
||||
{ color: "bg-emerald-600", textColor: "text-emerald-600" },
|
||||
];
|
||||
|
||||
export function PasswordStrength({
|
||||
password,
|
||||
minLength = 12,
|
||||
}: PasswordStrengthProps) {
|
||||
const { t } = useTranslations();
|
||||
const [result, setResult] = useState<{
|
||||
score: number;
|
||||
feedback: { warning: string; suggestions: string[] };
|
||||
@@ -60,13 +54,20 @@ export function PasswordStrength({
|
||||
if (!password) return null;
|
||||
|
||||
const score = result?.score ?? 0;
|
||||
const config = SCORE_CONFIG[score];
|
||||
const colors = SCORE_COLORS[score];
|
||||
const scoreLabels = [
|
||||
t("passwordStrength.veryWeak"),
|
||||
t("passwordStrength.weak"),
|
||||
t("passwordStrength.acceptable"),
|
||||
t("passwordStrength.strong"),
|
||||
t("passwordStrength.veryStrong"),
|
||||
];
|
||||
const tooShort = password.length < minLength;
|
||||
|
||||
// Collect feedback
|
||||
const feedback: string[] = [];
|
||||
if (tooShort) {
|
||||
feedback.push(`Mindestens ${minLength} Zeichen erforderlich.`);
|
||||
feedback.push(t("passwordStrength.minLength", { count: minLength }));
|
||||
}
|
||||
if (result?.feedback.warning) {
|
||||
feedback.push(translateZxcvbn(result.feedback.warning));
|
||||
@@ -84,7 +85,7 @@ export function PasswordStrength({
|
||||
key={i}
|
||||
className={cn(
|
||||
"h-1.5 flex-1 rounded-full transition-colors",
|
||||
i <= score && !tooShort ? config.color : "bg-muted",
|
||||
i <= score && !tooShort ? colors.color : "bg-muted",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
@@ -92,11 +93,11 @@ export function PasswordStrength({
|
||||
|
||||
{/* Label */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={cn("text-xs font-medium", config.textColor)}>
|
||||
{tooShort ? "Zu kurz" : config.label}
|
||||
<span className={cn("text-xs font-medium", colors.textColor)}>
|
||||
{tooShort ? t("passwordStrength.tooShort") : scoreLabels[score]}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{password.length}/{minLength}+ Zeichen
|
||||
{password.length}/{minLength}+ {t("passwordStrength.characters")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,46 +4,46 @@ import { classifyBMI, classifyBP, generateAlerts } from "../classifications";
|
||||
describe("classifyBMI", () => {
|
||||
it("classifies underweight", () => {
|
||||
const result = classifyBMI(17.5);
|
||||
expect(result.category).toBe("Untergewicht");
|
||||
expect(result.category).toBe("Underweight");
|
||||
expect(result.severity).toBe("warning");
|
||||
});
|
||||
|
||||
it("classifies normal weight", () => {
|
||||
const result = classifyBMI(22);
|
||||
expect(result.category).toBe("Normalgewicht");
|
||||
expect(result.category).toBe("Normal");
|
||||
expect(result.severity).toBe("normal");
|
||||
});
|
||||
|
||||
it("classifies overweight", () => {
|
||||
const result = classifyBMI(27);
|
||||
expect(result.category).toBe("Übergewicht");
|
||||
expect(result.category).toBe("Overweight");
|
||||
expect(result.severity).toBe("warning");
|
||||
});
|
||||
|
||||
it("classifies obesity grade I", () => {
|
||||
const result = classifyBMI(32);
|
||||
expect(result.category).toBe("Adipositas Grad I");
|
||||
expect(result.category).toBe("Obesity Grade I");
|
||||
expect(result.severity).toBe("danger");
|
||||
});
|
||||
|
||||
it("classifies obesity grade II", () => {
|
||||
const result = classifyBMI(37);
|
||||
expect(result.category).toBe("Adipositas Grad II");
|
||||
expect(result.category).toBe("Obesity Grade II");
|
||||
expect(result.severity).toBe("danger");
|
||||
});
|
||||
|
||||
it("classifies obesity grade III", () => {
|
||||
const result = classifyBMI(42);
|
||||
expect(result.category).toBe("Adipositas Grad III");
|
||||
expect(result.category).toBe("Obesity Grade III");
|
||||
expect(result.severity).toBe("danger");
|
||||
});
|
||||
|
||||
it("handles boundary values", () => {
|
||||
expect(classifyBMI(18.5).category).toBe("Normalgewicht");
|
||||
expect(classifyBMI(25).category).toBe("Übergewicht");
|
||||
expect(classifyBMI(30).category).toBe("Adipositas Grad I");
|
||||
expect(classifyBMI(35).category).toBe("Adipositas Grad II");
|
||||
expect(classifyBMI(40).category).toBe("Adipositas Grad III");
|
||||
expect(classifyBMI(18.5).category).toBe("Normal");
|
||||
expect(classifyBMI(25).category).toBe("Overweight");
|
||||
expect(classifyBMI(30).category).toBe("Obesity Grade I");
|
||||
expect(classifyBMI(35).category).toBe("Obesity Grade II");
|
||||
expect(classifyBMI(40).category).toBe("Obesity Grade III");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,32 +62,32 @@ describe("classifyBP", () => {
|
||||
|
||||
it("classifies high-normal", () => {
|
||||
const result = classifyBP(135, 87);
|
||||
expect(result.category).toBe("Hoch-normal");
|
||||
expect(result.category).toBe("High-normal");
|
||||
expect(result.severity).toBe("elevated");
|
||||
});
|
||||
|
||||
it("classifies hypertension grade 1", () => {
|
||||
const result = classifyBP(150, 95);
|
||||
expect(result.category).toBe("Hypertonie Grad 1");
|
||||
expect(result.category).toBe("Hypertension Grade 1");
|
||||
expect(result.severity).toBe("warning");
|
||||
});
|
||||
|
||||
it("classifies hypertension grade 2", () => {
|
||||
const result = classifyBP(170, 105);
|
||||
expect(result.category).toBe("Hypertonie Grad 2");
|
||||
expect(result.category).toBe("Hypertension Grade 2");
|
||||
expect(result.severity).toBe("danger");
|
||||
});
|
||||
|
||||
it("classifies hypertension grade 3", () => {
|
||||
const result = classifyBP(185, 115);
|
||||
expect(result.category).toBe("Hypertonie Grad 3");
|
||||
expect(result.category).toBe("Hypertension Grade 3");
|
||||
expect(result.severity).toBe("danger");
|
||||
});
|
||||
|
||||
it("uses the higher category when sys and dia differ", () => {
|
||||
// Sys is optimal but dia is high
|
||||
const result = classifyBP(115, 95);
|
||||
expect(result.category).toBe("Hypertonie Grad 1");
|
||||
expect(result.category).toBe("Hypertension Grade 1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -100,51 +100,51 @@ describe("generateAlerts", () => {
|
||||
const alerts = generateAlerts({ bmi: 22 });
|
||||
expect(alerts).toHaveLength(1);
|
||||
expect(alerts[0].level).toBe("success");
|
||||
expect(alerts[0].title).toContain("Normalbereich");
|
||||
expect(alerts[0].title).toContain("normal range");
|
||||
});
|
||||
|
||||
it("generates warning for underweight BMI", () => {
|
||||
const alerts = generateAlerts({ bmi: 16 });
|
||||
expect(alerts[0].level).toBe("warning");
|
||||
expect(alerts[0].title).toContain("unter Normalbereich");
|
||||
expect(alerts[0].title).toContain("below normal");
|
||||
});
|
||||
|
||||
it("generates warning for obese BMI", () => {
|
||||
const alerts = generateAlerts({ bmi: 32 });
|
||||
expect(alerts[0].level).toBe("warning");
|
||||
expect(alerts[0].title).toContain("Adipositas");
|
||||
expect(alerts[0].title).toContain("obesity");
|
||||
});
|
||||
|
||||
it("generates danger for very high BP", () => {
|
||||
const alerts = generateAlerts({ bpAvgSys: 175, bpAvgDia: 105 });
|
||||
const bpAlert = alerts.find((a) => a.title.includes("Blutdruck"));
|
||||
const bpAlert = alerts.find((a) => a.title.includes("Blood pressure"));
|
||||
expect(bpAlert?.level).toBe("danger");
|
||||
});
|
||||
|
||||
it("generates success for good BP target adherence", () => {
|
||||
const alerts = generateAlerts({ bpPctInTarget: 85 });
|
||||
const targetAlert = alerts.find((a) =>
|
||||
a.title.includes("Zielbereichstreue"),
|
||||
a.title.includes("target adherence"),
|
||||
);
|
||||
expect(targetAlert?.level).toBe("success");
|
||||
});
|
||||
|
||||
it("generates warning for low BP target adherence", () => {
|
||||
const alerts = generateAlerts({ bpPctInTarget: 40 });
|
||||
const targetAlert = alerts.find((a) => a.title.includes("außerhalb"));
|
||||
const targetAlert = alerts.find((a) => a.title.includes("outside"));
|
||||
expect(targetAlert?.level).toBe("warning");
|
||||
});
|
||||
|
||||
it("generates info for significant weight increase", () => {
|
||||
const alerts = generateAlerts({ weightSlope30: 0.1 }); // 0.7 kg/week
|
||||
const weightAlert = alerts.find((a) => a.title.includes("Gewicht"));
|
||||
const weightAlert = alerts.find((a) => a.title.includes("Weight"));
|
||||
expect(weightAlert?.level).toBe("info");
|
||||
expect(weightAlert?.title).toContain("steigend");
|
||||
expect(weightAlert?.title).toContain("increasing");
|
||||
});
|
||||
|
||||
it("generates warning for pulse anomalies", () => {
|
||||
const alerts = generateAlerts({ pulseAnomalyCount: 5 });
|
||||
const pulseAlert = alerts.find((a) => a.title.includes("Puls"));
|
||||
const pulseAlert = alerts.find((a) => a.title.includes("Pulse"));
|
||||
expect(pulseAlert?.level).toBe("warning");
|
||||
});
|
||||
|
||||
|
||||
@@ -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!`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
@@ -103,7 +103,7 @@ export async function requireAuth(): Promise<{
|
||||
: never;
|
||||
}> {
|
||||
const sessionData = await getSession();
|
||||
if (!sessionData) throw new HttpError(401, "Nicht angemeldet");
|
||||
if (!sessionData) throw new HttpError(401, "Not authenticated");
|
||||
|
||||
const evt = getEvent();
|
||||
if (evt) {
|
||||
@@ -131,7 +131,7 @@ export async function requireAdmin(): Promise<{
|
||||
}> {
|
||||
const sessionData = await requireAuth();
|
||||
if (sessionData.user.role !== "ADMIN") {
|
||||
throw new HttpError(403, "Nur Admins erlaubt");
|
||||
throw new HttpError(403, "Admin access required");
|
||||
}
|
||||
return sessionData;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export const locales = ["de", "en"] as const;
|
||||
export type Locale = (typeof locales)[number];
|
||||
|
||||
export const defaultLocale: Locale = "de";
|
||||
export const defaultLocale: Locale = "en";
|
||||
|
||||
export const localeLabels: Record<Locale, string> = {
|
||||
de: "Deutsch",
|
||||
|
||||
@@ -84,9 +84,9 @@ export function I18nProvider({ children }: { children: ReactNode }) {
|
||||
(key: string, params?: Record<string, string | number>): string => {
|
||||
let value = resolveKey(allMessages[locale], key);
|
||||
|
||||
// Fallback to German if key missing in current locale
|
||||
if (value === undefined && locale !== "de") {
|
||||
value = resolveKey(allMessages.de, key);
|
||||
// Fallback to English if key missing in current locale
|
||||
if (value === undefined && locale !== "en") {
|
||||
value = resolveKey(allMessages.en, key);
|
||||
}
|
||||
|
||||
// Fallback to key itself
|
||||
|
||||
@@ -17,11 +17,11 @@ export const EVENT_TYPES = [
|
||||
export type EventType = (typeof EVENT_TYPES)[number];
|
||||
|
||||
export const EVENT_TYPE_LABELS: Record<EventType, string> = {
|
||||
MEDICATION_REMINDER: "Medikamenten-Erinnerung",
|
||||
MEASUREMENT_ANOMALY: "Anomale Messwerte",
|
||||
COMPLIANCE_LOW: "Niedrige Einnahmetreue",
|
||||
WITHINGS_SYNC_FAILED: "Withings-Sync-Fehler",
|
||||
SYSTEM_ALERT: "System-Hinweise",
|
||||
MEDICATION_REMINDER: "Medication reminder",
|
||||
MEASUREMENT_ANOMALY: "Anomalous measurements",
|
||||
COMPLIANCE_LOW: "Low compliance",
|
||||
WITHINGS_SYNC_FAILED: "Withings sync failed",
|
||||
SYSTEM_ALERT: "System alerts",
|
||||
};
|
||||
|
||||
export const CHANNEL_TYPE_LABELS: Record<ChannelType, string> = {
|
||||
|
||||
+11
-11
@@ -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"],
|
||||
});
|
||||
|
||||
|
||||
@@ -36,9 +36,21 @@ const DEMO_MUTATION_ALLOWLIST = [
|
||||
"/api/auth/passkey/login-verify",
|
||||
];
|
||||
|
||||
// Legacy route redirects (German → English)
|
||||
const LEGACY_REDIRECTS: Record<string, string> = {
|
||||
"/stimmung": "/mood",
|
||||
"/zielwerte": "/targets",
|
||||
};
|
||||
|
||||
export function proxy(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
// 301 redirects for renamed routes
|
||||
const redirect = LEGACY_REDIRECTS[pathname];
|
||||
if (redirect) {
|
||||
return NextResponse.redirect(new URL(redirect, request.url), 301);
|
||||
}
|
||||
|
||||
// Demo mode: block all mutations except login
|
||||
if (process.env.DEMO_MODE === "true") {
|
||||
const method = request.method.toUpperCase();
|
||||
|
||||
Reference in new issue
Block a user