mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
* fix(i18n,ux): package A — missing keys, chart labels, query invalidation, admin error UX Addresses bugs surfaced in issues #73 and #74 plus drift found during audit. - i18n: add 21 missing translation keys (targets.* namespace was only partially populated after the English-first migration; common.reset and settings.configured were referenced but absent), plus common helpers (copy, copyDetails, retry, reportIssue) and admin.settingsSaveError. - charts: rename points7Label/30Label/90Label from cryptic "7P/30P/90P" to "7 pts / 30 pts / 90 pts" in EN and "7 Pkt / 30 Pkt / 90 Pkt" in DE. - queries: add src/lib/query-keys.ts as a single source of truth with measurement/mood/medication-dependent key sets. Wire them into measurement-form/list, mood-form/list, medication-form/card, intake-history-list, intake-timeline, medications page, and Withings sync in settings. Fixes dashboard not auto-refreshing after adding a measurement (old code only invalidated ["measurements"], not ["analytics"] / ["insights"] / ["insights","targets"]). - admin: useUpdateSettings + updateUser mutations now surface real error messages via sonner toasts and invoke getApiErrorMessage() so saves fail loudly instead of silently. Success also gets a toast. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(i18n): package B — locale-aware formatters throughout Removes the hard-coded de-DE locale that lingered from the German-first era and makes every number, date, and time locale-aware. English stays the default per user preference; German remains selectable. New: - src/lib/format-locale.ts exposes makeFormatters(locale) + a cached Europe/Berlin timezone. Covers number, integer, percent, date, dateShort, dateTime, time, dateWithWeekday, monthShort. Also exports parseLocaleFromAcceptLanguage() for server code. - useFormatters() hook in @/lib/i18n/context resolves from the active locale, memoized per render. Migrated (all Intl.NumberFormat("de-DE") / toLocaleString("de-DE") / Intl.DateTimeFormat("de-DE") call sites): - src/app/page.tsx (dashboard) - src/app/insights/page.tsx - src/app/admin/page.tsx - src/app/settings/page.tsx (Withings last-sync, doctor-report caller) - src/components/charts/trend-card.tsx (+ i18n "7d"/"30d" short labels) - src/components/charts/health-chart.tsx - src/components/medications/medication-card.tsx - src/components/medications/medication-form.tsx - src/components/measurements/measurement-list.tsx - src/components/mood/mood-list.tsx - src/components/insights/insight-status-card.tsx (also removed 5 German hard-coded strings with TODO: i18n markers — now translated) - src/components/insights/insight-advisor-card.tsx (same — 10+ strings) Server: reminder-check / reminder-worker / umami-test switched from "de-DE" to neutral en-GB/en-US where the string is machine-compared rather than user-facing. Bug-report GitHub issue titles use ISO-8601 UTC now — the repo is English-speaking. Next.js metadata: - OpenGraph locale from "de_DE" → "en_US" with "de_DE" as alternateLocale. - Root layout now reads healthlog-locale cookie (falls back to Accept-Language) and sets <html lang> server-side, avoiding an EN-flashes-then-DE hydration gap and fixing SEO/a11y. - I18nProvider mirrors locale to a 1-year SameSite=Lax cookie so the next SSR pass is correct. Doctor-report PDF: - generateDoctorReportPDF(data, { t, locale }) now accepts injected translator + locale. All section titles, column headers, BMI / BP classifications, mood labels, and the footer disclaimer are i18n-driven. File name slug switches between gesundheitsbericht / health-report. - 40+ new doctorReport.* keys in EN + DE. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(ux): package C — typed passkey errors, bug-report gating, error boundary Addresses issue #73 points 1, 5, 6 and lays groundwork for copy-paste error reporting requested during review. Passkey registration / login (#73.1): - src/lib/passkey-errors.ts maps DOMException.name + SimpleWebAuthn code to specific i18n keys instead of swallowing every failure as "cancelled". Distinguishes NotAllowed/InvalidState/NotSupported/ Security/Abort errors and falls back to a diagnostic message. - settings passkey + login passkey flows both use describePasskeyError now, so origin mismatches and previous-registration collisions show actionable copy. - 5 new passkey.* i18n keys in EN + DE. Bug report (#73.5): - New GET /api/bugreport/status reports {configured, hasToken, hasRepo, isAdmin}. - /bugreport page now queries status first. When not configured it shows a notice (different copy for admins vs. regular users) with a direct link to the admin panel. Form is hidden, not silently broken. - When POST /api/bugreport can't create issues it returns 503 instead of 500 with the same user-facing message. - 4 new bugreport.* i18n keys. Global error UX: - src/components/error-details.tsx — reusable panel with retry + copy diagnostics (message, digest, stack (trimmed), URL, user-agent, request-id, locale, timestamp) + report-issue link. Clipboard fallback prompt covers insecure-context cases. - src/app/error.tsx uses it. - src/app/global-error.tsx is provider-free (no i18n dependency) since it replaces the root layout on catastrophic failures — hardcoded English + Dracula palette so it still looks like HealthLog. - 2 new errorBoundary.* keys and 4 common.* keys (copy, copyDetails, retry, reportIssue). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(query-keys): collapse repeated invalidation blocks into invalidateKeys helper Eleven mutation handlers repeated `Promise.all(keys.map(k => invalidate({queryKey: k})))` verbatim. Adds `invalidateKeys(qc, keys)` to the existing query-keys factory and adopts it everywhere, including replacing the local `invalidateMedicationRelated` helper that medication-form.tsx had grown. Also fixes the import-sandwich that helper introduced. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor: small simplifications in i18n/UX changes - passkey-errors: drop the typeof-name dance now that error.name is already string after the instanceof Error narrow - doctor-report-pdf: replace nested gender ternary with a small lookup map; clarify unitFor's null-as-translation-marker convention - dashboard page: route the analytics queries through the new queryKeys factory so future invalidation stays in one place - health-chart: add missing fmt dependency to trendInfo useMemo (introduced when formatSigned was switched to the locale-aware formatter) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ai): capture upstream response body in OpenAI errors A production insights burst at 12:19 UTC failed with "OpenAI request failed (500)". The 500 was the upstream OpenAI HTTP status (confirmed via logs), but we had no way to tell from logs whether it was an OpenAI incident, a model-not-found, or a response_format rejection because the response body was thrown away. Now the error message includes the first 500 chars of the response body and structured fields (httpStatus, upstream, model) that Wide Event logging can attach. No behavior change for the happy path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(review): address code-review + security findings Review round produced five real issues — fix them all, plus three nits that were cheap enough to bundle. Must-fix: - security: error-details.tsx + global-error.tsx were copying window.location.href into the clipboard, which on OAuth callback routes (/api/withings/*, password reset) would leak code/state tokens into GitHub issues. Strip to pathname + searchParamCount. - fe: query-keys.invalidateKeys now uses Promise.allSettled, so a transient invalidate failure doesn't abort the chain and no unhandled rejection leaks from void invalidateKeys(...) call sites. - fe: resolveInitialLocale in app/layout.tsx wraps cookies()/headers() in try/catch — a dynamic-server error in the root layout now degrades to the "en" default instead of crashing into global-error. - be: reminder-worker + reminder-check parseTimeToMinutes normalize "24:00" → "00:00" (some ICU builds emit that for midnight under en-GB). Prevents a 1440-minute comparison against schedule windows. - security: openai-client error body excerpt redacts sk-* / Bearer tokens before logging, and moves the excerpt off Error.message into a structured field (bodyExcerpt) so log transports can filter it. - i18n: /api/withings/connect German error message → English. - i18n: /insights/page.tsx hardcoded "Fehler beim Laden" → t(...) Hardening: - /api/bugreport/status collapses to {configured, isAdmin} and adds a 30/min rate limit per user. Client interface updated. - i18n/context.tsx: I18nProvider's initializer now reads the cookie (matches SSR) before falling back to localStorage or browser detection — eliminates the locale-flash hydration class. - format.ts legacy helpers also read cookie first, matching SSR. Tests: - new src/lib/__tests__/format-locale.test.ts — 9 cases covering makeFormatters, parseLocaleFromAcceptLanguage, resolveIntlLocale - new passkey-errors.test.ts — 8 cases covering every DOMException name and SimpleWebAuthn code we map - new query-keys.test.ts — bundles + invalidateKeys allSettled semantics (incl. partial-failure test) 118 → 142 tests. All pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Marc-André Bombeck <marc@bombeck.io> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
46 files changed
+2570
-370
No files matched your search
@@ -0,0 +1,207 @@
|
||||
# Blood Glucose Tracking — Design Spec
|
||||
|
||||
**Datum:** 2026-04-18
|
||||
**Status:** Draft
|
||||
**Scope:** Add `BLOOD_GLUCOSE` as a first-class measurement type with context-aware classification (fasting / postprandial / random / bedtime), user-selectable display unit (mg/dL ↔ mmol/L), reference ranges from ADA 2024 and DGIM 2023, and full integration across form, charts, targets, doctor PDF, and insights.
|
||||
|
||||
## 1. Problem Statement
|
||||
|
||||
Users repeatedly request blood-sugar logging — most recently in **GitHub issue #73, point 8**. Diabetics, pre-diabetics, and people on metabolic-impacting medication (steroids, atypical antipsychotics) need a single place to log glucose alongside weight, BP, and pulse so the doctor PDF reflects the complete metabolic picture. Today they fall back to spreadsheets or vendor-locked apps. Adding glucose to HealthLog closes the largest remaining gap in the "vital sign" set and unlocks correlation insights (e.g. weight ↔ fasting glucose, sleep ↔ glucose variability).
|
||||
|
||||
## 2. Scope
|
||||
|
||||
In v1 we ship:
|
||||
|
||||
- A new `BLOOD_GLUCOSE` measurement type stored canonically in **mg/dL**.
|
||||
- A required **context tag** per reading: `FASTING`, `POSTPRANDIAL`, `RANDOM`, `BEDTIME`.
|
||||
- A user preference `glucoseUnit` (`MG_DL` | `MMOL_L`, default `MG_DL` for `de` users, can flip in settings) — display only; storage is canonical.
|
||||
- Context-aware classification (see §4) and a target card on `/targets` for fasting glucose; secondary line for postprandial when data exists.
|
||||
- Health chart with diabetes/pre-diabetes band overlays.
|
||||
- Doctor PDF row + classification line.
|
||||
- Insights descriptor "glucose control" (fasting avg + variability).
|
||||
|
||||
**Stretch (v1.1, not in this spec):** HbA1c as a separate measurement type with its own classification (<5.7% / 5.7–6.4% / ≥6.5%, ADA 2024). Schema is forward-compatible (just add another enum value).
|
||||
|
||||
## 3. Data Model
|
||||
|
||||
```prisma
|
||||
enum MeasurementType {
|
||||
WEIGHT
|
||||
BLOOD_PRESSURE_SYS
|
||||
BLOOD_PRESSURE_DIA
|
||||
PULSE
|
||||
BODY_FAT
|
||||
SLEEP_DURATION
|
||||
ACTIVITY_STEPS
|
||||
BLOOD_GLUCOSE // NEW
|
||||
}
|
||||
|
||||
enum GlucoseContext { // NEW
|
||||
FASTING
|
||||
POSTPRANDIAL
|
||||
RANDOM
|
||||
BEDTIME
|
||||
|
||||
@@map("glucose_context")
|
||||
}
|
||||
|
||||
model Measurement {
|
||||
// ...existing fields
|
||||
glucoseContext GlucoseContext? @map("glucose_context") // NEW, only set when type = BLOOD_GLUCOSE
|
||||
}
|
||||
|
||||
model User {
|
||||
// ...existing fields
|
||||
glucoseUnit String @default("MG_DL") @map("glucose_unit") // "MG_DL" | "MMOL_L"
|
||||
}
|
||||
```
|
||||
|
||||
Migration `20260418_add_blood_glucose`:
|
||||
1. `ALTER TYPE measurement_type ADD VALUE 'BLOOD_GLUCOSE';`
|
||||
2. `CREATE TYPE glucose_context AS ENUM (...);`
|
||||
3. `ALTER TABLE measurements ADD COLUMN glucose_context glucose_context;`
|
||||
4. `ALTER TABLE users ADD COLUMN glucose_unit text NOT NULL DEFAULT 'MG_DL';`
|
||||
5. Add `CHECK ((type = 'BLOOD_GLUCOSE') = (glucose_context IS NOT NULL))` to enforce coupling.
|
||||
|
||||
The unique constraint `(userId, type, measuredAt, source)` already prevents duplicates; context is intentionally **not** part of the key — the same timestamp can't legally carry two contexts.
|
||||
|
||||
Storage unit is always **mg/dL** (`unit = "mg/dL"` in the row) so downstream analytics, exports, and Withings ingest don't need to branch.
|
||||
|
||||
## 4. Classifications (`src/lib/analytics/classifications.ts`)
|
||||
|
||||
New helpers, ranges from **ADA Standards of Care 2024** (Diabetes Care, Vol. 47, Suppl. 1) and **DGIM/DDG S3-Leitlinie 2023**:
|
||||
|
||||
```ts
|
||||
export type GlucoseContext = "FASTING" | "POSTPRANDIAL" | "RANDOM" | "BEDTIME";
|
||||
|
||||
export interface GlucoseClassification {
|
||||
category: string;
|
||||
color: string;
|
||||
severity: "info" | "normal" | "warning" | "danger";
|
||||
}
|
||||
|
||||
export function classifyGlucose(
|
||||
mgdl: number,
|
||||
context: GlucoseContext,
|
||||
): GlucoseClassification;
|
||||
```
|
||||
|
||||
Boundaries (mg/dL — mmol/L conversion shown for traceability):
|
||||
|
||||
| Context | Hypo (<70 / <3.9) | Normal | Pre-diabetes | Diabetes |
|
||||
|---------|-------------------|--------|--------------|----------|
|
||||
| FASTING | danger | 70–99 (3.9–5.5) normal | 100–125 (5.6–6.9) warning | ≥126 (≥7.0) danger |
|
||||
| POSTPRANDIAL (2h) | danger | <140 (<7.8) normal | 140–199 (7.8–11.0) warning | ≥200 (≥11.1) danger |
|
||||
| RANDOM | danger | <140 normal | 140–199 warning | ≥200 danger |
|
||||
| BEDTIME | danger | 90–150 normal (info if outside) | — | ≥250 danger |
|
||||
|
||||
Color tokens reuse the existing Dracula palette (`#50fa7b` normal, `#f1fa8c` info, `#ffb86c` / `#ff79c6` warning, `#ff5555` danger). Add `getGlucoseFastingRange()` returning `{ min: 70, max: 99 }` for the target card range bar, and `getGlucosePostprandialRange()` for the optional secondary bar.
|
||||
|
||||
## 5. UI
|
||||
|
||||
**Measurement form (`src/components/measurements/measurement-form.tsx`):**
|
||||
- New entry in `MEASUREMENT_TYPES`: `{ value: "BLOOD_GLUCOSE", labelKey: "measurements.typeGlucose", unitKey: "measurements.unitGlucose", placeholder: "95" }`.
|
||||
- When `type === "BLOOD_GLUCOSE"`, render an additional `<Select>` for context (4 options). Default = `FASTING` between 04:00–10:00 local time, else `RANDOM`.
|
||||
- Unit suffix is resolved via `useGlucoseUnit()` hook reading `user.glucoseUnit`; the input value is converted to mg/dL before POST.
|
||||
- Min 20, max 600 mg/dL (or equivalent mmol/L); inputs accept 1 decimal in mmol/L mode, integers in mg/dL mode.
|
||||
|
||||
**Targets page (`src/app/targets/page.tsx`):**
|
||||
- New `TYPE_ICONS["BLOOD_GLUCOSE"]` = `Droplet` (Lucide), color token `text-dracula-yellow`.
|
||||
- Card title "Fasting glucose" with `RangeBar` 70–99, orangeMin 100, orangeMax 125 (puts pre-diabetes in yellow zone, diabetes in red).
|
||||
- If postprandial data exists in last 30 days, render a second labelled `RangeBar` below using `getGlucosePostprandialRange()`, mirroring the BP-diastolic pattern already in `TargetCard`.
|
||||
- Source link → `https://diabetesjournals.org/care/issue/47/Supplement_1` (ADA 2024).
|
||||
|
||||
**Health chart (`src/components/charts/health-chart.tsx`):**
|
||||
- New chart preset on `/charts` page: `types: ["BLOOD_GLUCOSE"]`, color `var(--dracula-yellow)`, `valueBands` for pre-diabetes (100–125) and diabetes (≥126) overlays, plus a `ReferenceLine` at 70 (hypo).
|
||||
- Filter chips for context (All / Fasting / Postprandial / Random / Bedtime) — implemented as a client-side filter on the dataset, not a new API call.
|
||||
- Y-axis unit follows user pref (mg/dL or mmol/L); points are converted on render.
|
||||
|
||||
**Insights:** Add `glucose-control.ts` insight returning `{ avgFasting, fastingInTargetPct, postprandialPeak, variability, classification }`. AI-friendly descriptor injected into the general-status prompt: `"glucose control: avg fasting {avg} mg/dL ({class}), {pct}% of fasting readings in target."`
|
||||
|
||||
**Doctor PDF (`src/lib/doctor-report-pdf.ts`):**
|
||||
- `TYPE_LABEL_KEYS["BLOOD_GLUCOSE"] = "doctorReport.typeGlucose"`.
|
||||
- `TYPE_UNIT_KEYS["BLOOD_GLUCOSE"]` resolves to user pref via a new `unitFor(type, ctx)` extension.
|
||||
- Add `BLOOD_GLUCOSE` to `vitalTypes` array — table row appears automatically.
|
||||
- New optional section "Glukose-Klassifikation" below the BP block: shows fasting average + classification key, plus postprandial average if `count > 0`, with i18n keys `doctorReport.glucoseClassificationTitle`, `doctorReport.glucoseFastingRow`, `doctorReport.glucosePostprandialRow`.
|
||||
- Distribution table per context (n per FASTING/POSTPRANDIAL/RANDOM/BEDTIME) so the GP sees coverage.
|
||||
|
||||
## 6. API
|
||||
|
||||
No new endpoints. `POST /api/measurements` already accepts `type`, `value`, `measuredAt`, `notes` — extend the Zod schema in `src/lib/validations/measurement.ts` with optional `glucoseContext: z.enum([...])` and a refinement: `BLOOD_GLUCOSE` requires the context, other types reject it. The form sends mg/dL; conversion happens client-side.
|
||||
|
||||
`/api/analytics` and `/api/insights/targets` add `BLOOD_GLUCOSE` to their type whitelists and emit a `glucose` summary object with per-context aggregates when ≥1 reading exists.
|
||||
|
||||
`PATCH /api/auth/me` (already exists for profile updates) gains an optional `glucoseUnit` field.
|
||||
|
||||
## 7. Conversion Helpers
|
||||
|
||||
New module `src/lib/glucose.ts`:
|
||||
|
||||
```ts
|
||||
export const GLUCOSE_FACTOR = 18.0182; // mg/dL per mmol/L (molar mass of glucose)
|
||||
|
||||
export function mgdlToMmol(mgdl: number): number {
|
||||
return Math.round((mgdl / GLUCOSE_FACTOR) * 10) / 10; // 1 decimal
|
||||
}
|
||||
|
||||
export function mmolToMgdl(mmol: number): number {
|
||||
return Math.round(mmol * GLUCOSE_FACTOR); // integer
|
||||
}
|
||||
|
||||
export function formatGlucose(mgdl: number, unit: GlucoseUnit, t: T): string;
|
||||
```
|
||||
|
||||
Round-trip rule: `mmolToMgdl(mgdlToMmol(x))` must be within ±1 mg/dL of `x` for x ∈ [40, 400]. Tested in §9.
|
||||
|
||||
## 8. i18n Keys (`messages/de.json` + `messages/en.json`)
|
||||
|
||||
| Key | DE | EN |
|
||||
|---|---|---|
|
||||
| `measurements.typeGlucose` | Blutzucker | Blood glucose |
|
||||
| `measurements.unitGlucose.mgdl` | mg/dL | mg/dL |
|
||||
| `measurements.unitGlucose.mmol` | mmol/L | mmol/L |
|
||||
| `measurements.glucoseContextLabel` | Kontext | Context |
|
||||
| `measurements.glucoseContextFasting` | Nüchtern | Fasting |
|
||||
| `measurements.glucoseContextPostprandial` | Nach dem Essen (2 h) | Post-meal (2h) |
|
||||
| `measurements.glucoseContextRandom` | Zufällig | Random |
|
||||
| `measurements.glucoseContextBedtime` | Vor dem Schlafen | Bedtime |
|
||||
| `targets.glucoseFastingTitle` | Nüchtern-Blutzucker | Fasting glucose |
|
||||
| `targets.glucosePostprandialTitle` | 2-h-Postprandial | 2h post-meal |
|
||||
| `targets.glucoseSourceAda` | ADA 2024 / DGIM 2023 | ADA 2024 / DGIM 2023 |
|
||||
| `classifications.glucoseHypo` | Hypoglykämie | Hypoglycemia |
|
||||
| `classifications.glucoseNormal` | Normal | Normal |
|
||||
| `classifications.glucosePrediabetes` | Prädiabetes | Pre-diabetes |
|
||||
| `classifications.glucoseDiabetes` | Diabetes-Bereich | Diabetes range |
|
||||
| `doctorReport.typeGlucose` | Blutzucker | Blood glucose |
|
||||
| `doctorReport.glucoseClassificationTitle` | Glukose-Klassifikation | Glucose classification |
|
||||
| `doctorReport.glucoseFastingRow` | Ø Nüchtern: {avg} {unit} — {class} | Avg fasting: {avg} {unit} — {class} |
|
||||
| `doctorReport.glucosePostprandialRow` | Ø Postprandial: {avg} {unit} — {class} | Avg postprandial: {avg} {unit} — {class} |
|
||||
| `settings.glucoseUnitLabel` | Blutzucker-Einheit | Blood glucose unit |
|
||||
| `insights.glucoseControlTitle` | Glukose-Kontrolle | Glucose control |
|
||||
|
||||
## 9. Nyquist Validation
|
||||
|
||||
- **Unit conversion**: property test — for every integer mg/dL in [40, 400], `mmolToMgdl(mgdlToMmol(x))` differs from `x` by ≤1.
|
||||
- **Classification boundaries**: assert `classifyGlucose(99, "FASTING").severity === "normal"`, `classifyGlucose(100, "FASTING").severity === "warning"`, `classifyGlucose(125, ...) === "warning"`, `classifyGlucose(126, ...) === "danger"`, `classifyGlucose(69, "FASTING").severity === "danger"` (hypo). Same for postprandial at 139/140/199/200.
|
||||
- **Schema refinement**: `POST /api/measurements` with `BLOOD_GLUCOSE` and missing `glucoseContext` returns 400; with non-glucose type and a `glucoseContext` returns 400.
|
||||
- **End-to-end smoke**: form submit → DB row → chart render → PDF generation; assert mg/dL stored regardless of UI unit, assert PDF row appears in `vitalRows`.
|
||||
- **Doctor PDF snapshot test**: render with mixed contexts, assert classification line and per-context distribution rows.
|
||||
|
||||
## 10. Risks
|
||||
|
||||
- **Hypoglycemia notifications**: showing "severe low — seek help" on every reading <54 mg/dL could panic users with sensor noise or delayed entries. Notifications are **opt-in** via NotificationPreference (`MEASUREMENT_ANOMALY` event), default off, and the message text always ends with "HealthLog ist kein Medizinprodukt — bei akuten Symptomen 112 anrufen."
|
||||
- **Unit confusion**: a user flipping the unit pref must not reinterpret historical mg/dL values as mmol/L. Migration sets a default; UI shows the unit suffix on every value.
|
||||
- **Withings overlap**: Withings supports glucose via some BGM integrations. Document that Withings sync stays disabled for glucose in v1 (out of scope) to avoid double-entry races.
|
||||
- **Context defaulting**: auto-selecting `FASTING` based on time of day is a heuristic; the user must always be able to override before save.
|
||||
|
||||
## 11. Out of Scope
|
||||
|
||||
- CGM (Dexcom / Libre) live integration.
|
||||
- Ketone (β-hydroxybutyrate) tracking.
|
||||
- Insulin dose log and bolus calculator.
|
||||
- Carb counting / meal logging.
|
||||
- HbA1c (deferred to v1.1, schema is ready).
|
||||
|
||||
## 12. Effort
|
||||
|
||||
**M (medium).** Roughly: schema + migration (S), classifications + conversion lib + tests (S), form/context selector + unit pref (M), targets card + chart preset (S), insight + PDF row + i18n (M), Nyquist tests (S). Estimate 1.5–2 dev-days end-to-end including review. No external dependencies, no new infra.
|
||||
@@ -0,0 +1,162 @@
|
||||
# Built-in Feedback System — Design Spec
|
||||
|
||||
**Status:** Proposed (v1.2+)
|
||||
**Date:** 2026-04-18
|
||||
**Owner:** TBD
|
||||
**Tracking:** GitHub issue #73 (point 6)
|
||||
**Effort:** M
|
||||
|
||||
## 1. Problem Statement
|
||||
|
||||
The current bug-report flow (`src/app/bugreport/page.tsx` + `src/app/api/bugreport/route.ts`) is gated on a GitHub token and repository configured by an admin. The status route returns `configured: false` when those are missing, and the UI degrades to an "ask your admin to configure GitHub" screen — so a non-technical owner of a self-hosted HealthLog instance cannot collect feedback at all without first procuring a GitHub PAT and dedicating a repo.
|
||||
|
||||
Verbatim from issue #73, point 6: *"Provide a simple built-in feedback system where users can submit reports without GitHub accounts, repositories, or tokens. The app should handle issue creation automatically in the background. Alternatively, provide a pre-configured default repository maintained by the developer."*
|
||||
|
||||
We need a feedback path that **works with zero external configuration**, while keeping the existing GitHub integration available as an **optional escalation** for instance owners who do want public issues.
|
||||
|
||||
## 2. Approach
|
||||
|
||||
- **Primary store: Postgres.** Every submission lands in a new `Feedback` table managed by Prisma. No external dependency required.
|
||||
- **Admin inbox UI** at `/admin#feedback` lists, filters and triages submissions.
|
||||
- **GitHub stays optional and per-item.** The admin can press "Publish to GitHub issue" on any feedback row; the existing route logic in `src/app/api/bugreport/route.ts` is refactored into a reusable helper and called from the new admin endpoint. If GitHub isn't configured, the button is hidden — the rest of the flow is unaffected.
|
||||
- **v1 admin notification: email** via the existing `src/lib/notifications/dispatcher.ts` (Telegram / ntfy / web-push reuse the same dispatcher). Optional; silent if no admin channel is configured.
|
||||
- **Existing `/api/bugreport` is deprecated but kept** for two release cycles so any external automation still works. Internally it forwards to the new feedback pipeline.
|
||||
|
||||
## 3. Data Model
|
||||
|
||||
New Prisma model in `prisma/schema.prisma`, additive only:
|
||||
|
||||
```prisma
|
||||
enum FeedbackCategory { BUG FEATURE_REQUEST QUESTION OTHER }
|
||||
enum FeedbackStatus { OPEN ACK RESOLVED ARCHIVED }
|
||||
|
||||
model Feedback {
|
||||
id String @id @default(cuid())
|
||||
userId String? // nullable — see auth note below
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
email String? // snapshot of submitter email at time of submission
|
||||
category FeedbackCategory @default(BUG)
|
||||
subject String @db.VarChar(200)
|
||||
description String @db.Text // ≤ 5000 chars enforced in Zod
|
||||
screenshotBase64 String? @db.Text // ≤ 5 MB; later moved to object storage
|
||||
metadata Json? // { url, userAgent, locale, appVersion, viewport, timestamp }
|
||||
status FeedbackStatus @default(OPEN)
|
||||
adminNote String? @db.Text
|
||||
gitHubIssueUrl String?
|
||||
notifiedEmail Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([status, createdAt])
|
||||
@@index([userId])
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `userId` is nullable to keep the door open for anonymous public-instance submissions in a later iteration; **v1 still requires auth** (`requireAuth()`), so the column will always be populated. The `email` snapshot survives user deletion for triage continuity.
|
||||
- `metadata` is a free-form JSON envelope mirroring the same context we'd otherwise paste into a GitHub issue body — we capture it once at submission time so the admin doesn't have to ask "what page were you on?".
|
||||
- An `AuditLog` row (`feedback.submit`, `feedback.status_change`, `feedback.publish_github`) follows the existing pattern in `src/lib/auth/audit.ts`.
|
||||
|
||||
## 4. API
|
||||
|
||||
All routes use `apiHandler` (Wide Events + x-request-id) and return the standard `{ data, error, meta }` envelope.
|
||||
|
||||
| Method | Path | Auth | Purpose |
|
||||
|---|---|---|---|
|
||||
| `POST` | `/api/feedback` | user | Submit feedback. Rate-limited 5/h per `user.id`. Zod-validated. Returns `{ id, confirmationNumber }`. |
|
||||
| `GET` | `/api/admin/feedback` | admin | Paginated list. Query params: `status`, `category`, `q`, `page`, `pageSize` (default 20, cap 100). |
|
||||
| `GET` | `/api/admin/feedback/:id` | admin | Single item with raw `metadata` and screenshot. |
|
||||
| `PATCH`| `/api/admin/feedback/:id` | admin | Mutate `status` and/or `adminNote`. Writes audit log. |
|
||||
| `POST` | `/api/admin/feedback/:id/github` | admin | Publish to GitHub. 404 if GitHub not configured. Stores `gitHubIssueUrl` on success. |
|
||||
| `GET` | `/api/feedback/status` | user | Public capability probe: `{ available: true, githubAvailable: boolean }`. Replaces `/api/bugreport/status`'s "configured: false" wall. |
|
||||
|
||||
`apiSuccess` payloads use `confirmationNumber = id.slice(-6).toUpperCase()` so users can quote a short code without leaking the full cuid.
|
||||
|
||||
## 5. UI
|
||||
|
||||
### `/bugreport` (renamed conceptually to "Feedback", URL kept)
|
||||
- **Category** dropdown (Bug / Feature request / Question / Other).
|
||||
- **Subject** input (max 120 chars, required).
|
||||
- **Description** textarea (10–5000 chars, existing constraint).
|
||||
- **Screenshot** optional, same upload control as today, ≤ 5 MB, EXIF stripped client-side before base64 encoding.
|
||||
- **Submit** — always enabled when authenticated. No more "not configured" wall.
|
||||
- On success: toast "Thanks for your feedback — reference #ABC123".
|
||||
|
||||
### Admin section `/admin#feedback`
|
||||
- Table: created, category badge, subject, status badge, submitter, actions.
|
||||
- Filters: status (OPEN/ACK/RESOLVED/ARCHIVED), category, free-text search over `subject`/`description`.
|
||||
- Detail dialog: full description, screenshot preview, raw `metadata` JSON, admin note textarea, action buttons **Ack** / **Resolve** / **Archive** / **Publish to GitHub** (last one only when `githubAvailable`).
|
||||
- Empty states (no feedback yet, no results for filter) with translated copy.
|
||||
|
||||
## 6. Screenshot Handling
|
||||
|
||||
- **Storage:** base64 in the `screenshotBase64` column. Hard cap **5 MB per item** (matching the current `bugReportSchema.screenshot` limit) and a soft per-user **20 MB total quota** rejected at submit with a 413.
|
||||
- **EXIF stripping** in the browser via canvas re-encode before upload — prevents inadvertent GPS leak.
|
||||
- **PII warning** in the form copy: "Your screenshot may contain personal data. Crop it before uploading if unsure."
|
||||
- **Risk acknowledged in §13** — DB bloat is the obvious downside; v2 moves blobs to object storage.
|
||||
|
||||
## 7. Abuse & Safety
|
||||
|
||||
- Rate-limit via `src/lib/rate-limit.ts`: `feedback:submit:${user.id}` → 5 per rolling hour. Admin endpoints rate-limited too (`feedback:admin:${user.id}`, 60/min).
|
||||
- Description and subject are **plain text** — no HTML, no Markdown rendering on display in the admin inbox (use `<pre>` or a plain `<div>` with `whitespace-pre-wrap`). When publishing to GitHub we apply the same backtick-escape sanitization as the current bugreport route.
|
||||
- Length caps enforced in Zod and at the DB level (`@db.VarChar(200)` for subject).
|
||||
- **Auto-archive job** in `src/lib/jobs/`: daily pg-boss job marks `RESOLVED` items older than 180 days as `ARCHIVED`, and deletes `ARCHIVED` items older than 365 days. Configurable via `AppSettings`.
|
||||
- Inbox-size soft cap (e.g., 10 000 OPEN rows) surfaces a banner in the admin UI prompting cleanup.
|
||||
|
||||
## 8. Migration
|
||||
|
||||
- Additive Prisma migration only (new enum, new table). No existing data touched.
|
||||
- `/api/bugreport` route stays for **two release cycles**, internally calls the new feedback pipeline, and emits a `Deprecation` response header pointing to `/api/feedback`.
|
||||
- `/api/bugreport/status` keeps responding for legacy clients but its `configured` field now means "feedback works" (always `true` for authenticated users), with `hasGithub` exposed separately.
|
||||
- No data backfill required.
|
||||
|
||||
## 9. Notifications
|
||||
|
||||
- On a successful `POST /api/feedback`, dispatch through `src/lib/notifications/dispatcher.ts` to whichever admin channels are configured (email-first for v1; Telegram/ntfy/web-push fall out for free since they share the dispatcher).
|
||||
- Payload: category, subject, submitter username, deep link `/admin#feedback?id=...`. **No description body** in the notification — keeps PII out of email/push transports.
|
||||
- `notifiedEmail = true` after a successful send so retries don't double-fire.
|
||||
- If no channel is configured, dispatch is a no-op (logged as an info annotation, not a warning).
|
||||
|
||||
## 10. i18n
|
||||
|
||||
New translation namespace `feedback.*` in `messages/en.json` and `messages/de.json`:
|
||||
- Category names (`feedback.category.bug` etc.), status names, form labels, admin-inbox column headers, action button labels, empty states, toast/confirmation copy, PII warning text, deprecation banner on legacy bugreport endpoint.
|
||||
- Existing `bugreport.*` keys stay until the route is removed; new UI does not depend on them.
|
||||
|
||||
## 11. GitHub Publish
|
||||
|
||||
- Refactor the GitHub-issue creation in `src/app/api/bugreport/route.ts` into `src/lib/feedback/publish-github.ts` — pure function `publishToGithub(feedback): Promise<{ issueNumber, issueUrl }>`. Reused by both the deprecated bugreport route (transparently) and the new `POST /api/admin/feedback/:id/github`.
|
||||
- Idempotency: if `gitHubIssueUrl` is already set on the feedback row, return 409 with the existing URL.
|
||||
- Admin can append optional context via a textarea in the publish dialog; this is prepended to the issue body and **not** stored on the feedback row.
|
||||
|
||||
## 12. Nyquist Validation
|
||||
|
||||
Acceptance scenarios that must pass before sign-off:
|
||||
- Submit while logged out → 401.
|
||||
- Submit 6 times in an hour → 6th returns 429 with rate-limit headers.
|
||||
- Screenshot > 5 MB → 413, no DB row created.
|
||||
- Cumulative user quota > 20 MB → 413 with quota-specific error code.
|
||||
- Non-admin hits `/api/admin/feedback*` → 403.
|
||||
- Publish-to-GitHub round-trip: feedback row gains `gitHubIssueUrl`, repeat call returns 409.
|
||||
- Auto-archive job marks correct rows after fast-forwarding clock in test.
|
||||
- Notification dispatch is silent when no channel configured (info event, not warning).
|
||||
- Plain-text rendering verified — submitting `<script>alert(1)</script>` displays escaped in admin UI.
|
||||
- Wide Event payload includes `action.name=feedback.submit`, `meta.category`, `meta.has_screenshot`.
|
||||
|
||||
## 13. Risks
|
||||
|
||||
- **DB bloat from base64 screenshots.** Mitigation: per-item + per-user quotas now, object storage migration (S3/MinIO/Coolify volume) tracked as a follow-up. Schema is forward-compatible — `screenshotBase64` becomes nullable alongside a future `screenshotKey` column.
|
||||
- **Email transport coupling.** If the dispatcher fails, submission must still succeed; notifications are fire-and-forget after the DB row commits.
|
||||
- **Admin-inbox UX scaling** past a few hundred items needs server-side filtering (already in the API spec).
|
||||
|
||||
## 14. Out of Scope
|
||||
|
||||
- User-to-user comment threads on feedback items.
|
||||
- Voting / upvoting feedback.
|
||||
- Public read-only feedback board.
|
||||
- Anonymous (logged-out) submissions — schema-ready, intentionally not enabled in v1.
|
||||
- Importing existing GitHub issues back into the inbox.
|
||||
|
||||
## 15. Effort
|
||||
|
||||
**M** — one Prisma migration, ~6 new API routes (most thin), one form refactor, one new admin section, refactor of existing GitHub call into a helper, dispatcher hookup, one pg-boss job, ~30 i18n keys × 2 locales.
|
||||
@@ -0,0 +1,227 @@
|
||||
# User-Configurable Health Thresholds — Design Spec
|
||||
|
||||
**Datum:** 2026-04-18
|
||||
**Status:** Draft
|
||||
**Scope:** Per-user override layer for the metric target ranges currently rendered on `/targets` and used across analytics.
|
||||
|
||||
## 1. Problem
|
||||
|
||||
HealthLog computes target ranges from established medical guidelines:
|
||||
|
||||
- Weight from BMI 18.5–24.9 (WHO) via `getWeightRange` / `buildWeightBandsFromHeight`
|
||||
- BP targets age-personalized per ESC/ESH 2018 (`bp-targets.ts`, `getBpTargetsByAge`)
|
||||
- Pulse from CDC/NCHS percentiles or AHA fallback (`pulse-targets.ts`)
|
||||
- Body fat from ACE thresholds (`classifyBodyFat`)
|
||||
- Sleep duration from AASM/SRS (`getSleepDurationRange`)
|
||||
- Steps from WHO (`getStepsRange`)
|
||||
|
||||
These are sane defaults, but real patients receive **doctor-recommended individual targets**. A diabetic with autonomic neuropathy may have a BP target of 110/65–135/80; a marathoner may have a personal "green" pulse band of 38–55 that the AHA bands flag as bradycardia; a recovering anorexia patient may have a clinically supervised weight floor above the BMI-18.5 line. Today the UI exposes computed ranges with **no override mechanism**, and analytics, alerts, and the doctor-report PDF all silently use the guideline values.
|
||||
|
||||
## 2. Why (User Intent)
|
||||
|
||||
- Users may have doctor-recommended personalized targets that differ from the default ESC/ESH / WHO / AHA guidelines.
|
||||
- Current UI shows computed defaults with no override mechanism.
|
||||
- User wants a dedicated settings page where thresholds can be overridden per metric, with the default still visible for comparison and one-click reset.
|
||||
|
||||
## 3. Requirements
|
||||
|
||||
1. **Per-metric override** for the full set: `WEIGHT` (min/max), `BLOOD_PRESSURE_SYS` (low/high), `BLOOD_PRESSURE_DIA` (low/high), `PULSE` (min/max), `BODY_FAT` (min/max), `SLEEP_DURATION` (min/max in hours), `ACTIVITY_STEPS` (min/max). Reserve slots for `BLOOD_GLUCOSE_FASTING` and `BLOOD_GLUCOSE_POSTPRANDIAL` even though they are not yet measured — the schema should not need a migration when the metric ships.
|
||||
2. **Optional yellow zone**: each metric may carry a separate `warnMin/warnMax` (orange band) in addition to `min/max` (green band). Falls back to the default's orange computation (currently `±30%` span via `buildTrafficRange`) if absent.
|
||||
3. **Comparable to default**: every UI surface that renders a range must be able to show "Default: X / Your override: Y" — the API therefore returns `defaults`, `overrides`, and `effective` together.
|
||||
4. **Reset to default per metric** — single-click, no confirm needed unless overrides existed (see §11).
|
||||
5. **Source-of-truth precedence**: `effective = override ?? default(profile)`. Default still recomputes when profile (height, age, gender) changes, so users who never override see no change in behavior.
|
||||
6. **Server-side validation** — overrides must satisfy `min < max`, sit within hard sanity bounds, and pass plausibility checks (out-of-normal-range warns, see §11).
|
||||
7. **All consumers must use one helper** — no analytics module should call the raw default-computer directly anymore (see §6).
|
||||
8. **Audit trail** for every change (medical data, see §8).
|
||||
|
||||
## 4. Data Model
|
||||
|
||||
### Decision: JSON column on `User`, not a new table
|
||||
|
||||
Two options were considered:
|
||||
|
||||
**Option A — `UserThreshold` table** (one row per user/metric):
|
||||
- Pro: query-friendly, easy partial indexes, future reporting per metric.
|
||||
- Con: 9+ rows per user for a feature that is read together every page load; joins on every targets fetch; migration friction when adding metrics.
|
||||
|
||||
**Option B — JSON column on `User`** (`thresholdsJson Json?`):
|
||||
- Pro: read-with-user is one row, atomic write, schema-less for new metrics, mirrors Prisma 7 / Postgres `jsonb` strengths, validated by Zod at the application layer.
|
||||
- Con: cannot index individual thresholds in the DB (acceptable — there is no use case for "find all users whose pulse cap > 110").
|
||||
|
||||
**Choose Option B.** A `UserSetting` model does not currently exist, so we add the column directly to `User`:
|
||||
|
||||
```
|
||||
// prisma/schema.prisma — additive
|
||||
model User {
|
||||
...
|
||||
thresholdsJson Json? @map("thresholds_json")
|
||||
}
|
||||
```
|
||||
|
||||
### JSON shape (TypeScript, source of truth in `src/lib/validations/thresholds.ts`)
|
||||
|
||||
```ts
|
||||
type ThresholdOverride = {
|
||||
min?: number;
|
||||
max?: number;
|
||||
warnMin?: number; // optional yellow zone lower bound
|
||||
warnMax?: number; // optional yellow zone upper bound
|
||||
updatedAt: string; // ISO-8601, set server-side on every PUT
|
||||
};
|
||||
|
||||
type ThresholdsJson = {
|
||||
WEIGHT?: ThresholdOverride;
|
||||
BLOOD_PRESSURE_SYS?: ThresholdOverride;
|
||||
BLOOD_PRESSURE_DIA?: ThresholdOverride;
|
||||
PULSE?: ThresholdOverride;
|
||||
BODY_FAT?: ThresholdOverride;
|
||||
SLEEP_DURATION?: ThresholdOverride;
|
||||
ACTIVITY_STEPS?: ThresholdOverride;
|
||||
BLOOD_GLUCOSE_FASTING?: ThresholdOverride; // reserved
|
||||
BLOOD_GLUCOSE_POSTPRANDIAL?: ThresholdOverride; // reserved
|
||||
};
|
||||
```
|
||||
|
||||
An override is "active" when `min` and `max` are both numbers; partial-only objects are rejected by Zod. Absence of a key === use default.
|
||||
|
||||
## 5. API
|
||||
|
||||
### `GET /api/user/thresholds`
|
||||
|
||||
Response (envelope `{ data, error, meta }`):
|
||||
```
|
||||
data: {
|
||||
defaults: { WEIGHT: { min, max, warnMin, warnMax, source }, ... },
|
||||
overrides: { PULSE: { min, max, warnMin?, warnMax?, updatedAt }, ... },
|
||||
effective: { WEIGHT: { min, max, warnMin, warnMax, isOverride: false }, ... },
|
||||
profile: { heightCm, age, gender }
|
||||
}
|
||||
```
|
||||
`defaults` is always populated (even for metrics the user has not measured, so the UI can render an empty slot with the default visible). `overrides` only contains keys the user has actually customized. `effective` is the merged result every analytics consumer should think in.
|
||||
|
||||
### `PUT /api/user/thresholds`
|
||||
|
||||
Body: partial map of metric → override (Zod-validated). Replaces only the listed metrics; unspecified keys are untouched.
|
||||
```
|
||||
{ "PULSE": { "min": 50, "max": 95 },
|
||||
"BLOOD_PRESSURE_SYS": { "min": 110, "max": 130, "warnMin": 105, "warnMax": 140 } }
|
||||
```
|
||||
Per-metric Zod rules (`src/lib/validations/thresholds.ts`):
|
||||
- `min < max`
|
||||
- if `warnMin` set: `warnMin <= min`; if `warnMax` set: `warnMax >= max`
|
||||
- hard sanity bounds (reject obviously broken input):
|
||||
- `WEIGHT`: 30–300 kg
|
||||
- `BLOOD_PRESSURE_SYS`: 70–220 mmHg
|
||||
- `BLOOD_PRESSURE_DIA`: 40–140 mmHg
|
||||
- `PULSE`: 30–220 bpm
|
||||
- `BODY_FAT`: 3–60 %
|
||||
- `SLEEP_DURATION`: 3–14 h
|
||||
- `ACTIVITY_STEPS`: 0–50000
|
||||
- `BLOOD_GLUCOSE_FASTING`: 50–250 mg/dL
|
||||
- `BLOOD_GLUCOSE_POSTPRANDIAL`: 70–350 mg/dL
|
||||
- soft "out-of-normal-range" check returns a Zod warning (not error) — surfaced to the UI to trigger the confirm dialog (see §11).
|
||||
|
||||
### `DELETE /api/user/thresholds/:metric`
|
||||
|
||||
Removes one key from `thresholdsJson`. Returns the new `effective` block so the client can refresh without a second roundtrip.
|
||||
|
||||
All three routes are wrapped by `apiHandler` and `requireAuth()`, log Wide Events, and emit AuditLog entries (§8).
|
||||
|
||||
## 6. Integration Points
|
||||
|
||||
A single helper becomes the only public entry for "what is the target range":
|
||||
|
||||
```
|
||||
src/lib/analytics/effective-range.ts
|
||||
getEffectiveRange(metric, user, overrides?) → { min, max, warnMin, warnMax, source, isOverride }
|
||||
```
|
||||
|
||||
It internally calls the existing default computers (`getWeightRange`, `getBpTargets`, `getPersonalizedPulseTarget`, `classifyBodyFat`'s table, `getSleepDurationRange`, `getStepsRange`) and applies any matching override. `source` becomes `"User override"` when `isOverride === true`, otherwise the existing guideline string.
|
||||
|
||||
Refactor in scope:
|
||||
- `src/lib/analytics/classifications.ts` — `classifyBP`, `classifyPulse`, `classifyBodyFat`, `classifySleepDuration`, `classifySteps`, `getWeightRange`, `getPulseRange`, `getSleepDurationRange`, `getBpTargetsByAge`: keep as pure default computers, but stop being called from feature code. Add thin `classifyXWithRange(value, range)` variants that take the resolved range.
|
||||
- `src/lib/analytics/value-bands.ts` — `buildTrafficRange` and `buildWeightBandsFromHeight` accept an optional `override` argument and prefer it over the BMI-derived band.
|
||||
- `src/lib/analytics/pulse-targets.ts` — `getPersonalizedPulseTarget` stays as the default; new `resolvePulseTarget(user, overrides)` lives in `effective-range.ts`.
|
||||
- `src/lib/analytics/bp-targets.ts` — `getBpTargets` likewise stays; `effective-range.ts` wraps it.
|
||||
- `src/app/api/insights/targets/route.ts` (the API behind `/targets`) reads `thresholdsJson` once and passes it to `getEffectiveRange` per metric, then returns the `effective` block to the client. The `source` field already shown on `TargetCard` flips to "User override" automatically.
|
||||
- Doctor-report PDF (`src/lib/doctor-report-pdf.ts`) and Wide-Event annotations include `isOverride` so reports clearly mark customized targets.
|
||||
|
||||
## 7. Migration
|
||||
|
||||
Additive only — single Prisma migration adds `users.thresholds_json jsonb`. Existing users get `NULL`, which `getEffectiveRange` treats as "no overrides → behave exactly as before". No data backfill, no flag rollout.
|
||||
|
||||
## 8. Auditing
|
||||
|
||||
Every `PUT` and `DELETE` writes one `AuditLog` row (model already exists at `prisma/schema.prisma:391`):
|
||||
|
||||
- `action`: `"thresholds.update"` or `"thresholds.reset"`
|
||||
- `details` (JSON string): `{ metric, before: {min,max,warnMin,warnMax} | null, after: {...} | null, reason?: "user" | "out_of_range_confirmed" }`
|
||||
- `userId`, `ipAddress`, `location` from session context (already populated by `apiHandler`).
|
||||
|
||||
The `before` value is read from the current `thresholdsJson` *or* the computed default (so the audit trail captures the implicit baseline a user customized away from). Reset writes `after: null`.
|
||||
|
||||
## 9. i18n Keys
|
||||
|
||||
New section under `messages/en.json` and `messages/de.json`:
|
||||
|
||||
| Key | EN | DE |
|
||||
|---|---|---|
|
||||
| `thresholds.title` | "Custom thresholds" | "Eigene Zielwerte" |
|
||||
| `thresholds.intro` | "Override the medical default ranges with values your doctor recommended. Defaults stay visible for comparison and you can reset any metric to the guideline at any time." | "Ersetze die medizinischen Standardbereiche durch arztempfohlene Werte. Die Standardwerte bleiben sichtbar und du kannst jeden Wert jederzeit auf die Leitlinie zurücksetzen." |
|
||||
| `thresholds.defaultLabel` | "Default" | "Standard" |
|
||||
| `thresholds.overrideLabel` | "Your override" | "Eigener Wert" |
|
||||
| `thresholds.enableToggle` | "Use custom value" | "Eigenen Wert verwenden" |
|
||||
| `thresholds.warnZoneLabel` | "Yellow zone (optional)" | "Gelber Bereich (optional)" |
|
||||
| `thresholds.minLabel` | "Min" | "Min" |
|
||||
| `thresholds.maxLabel` | "Max" | "Max" |
|
||||
| `thresholds.resetButton` | "Reset to default" | "Auf Standard zurücksetzen" |
|
||||
| `thresholds.resetPrompt` | "Reset {metric} to the medical default?" | "{metric} auf den medizinischen Standardwert zurücksetzen?" |
|
||||
| `thresholds.saved` | "Thresholds saved" | "Zielwerte gespeichert" |
|
||||
| `thresholds.outOfRangeWarn` | "This value lies outside the typical medical range. Please confirm only if your doctor recommended it." | "Dieser Wert liegt außerhalb des üblichen medizinischen Bereichs. Bitte nur bestätigen, wenn dein Arzt das empfohlen hat." |
|
||||
| `thresholds.notMedicalAdvice` | "HealthLog does not provide medical advice. Custom thresholds are stored as-is." | "HealthLog ersetzt keine ärztliche Beratung. Eigene Zielwerte werden unverändert gespeichert." |
|
||||
| `thresholds.validation.minLessMax` | "Min must be less than max" | "Min muss kleiner als Max sein" |
|
||||
| `thresholds.validation.warnEnvelope` | "Yellow zone must surround the green zone" | "Der gelbe Bereich muss den grünen Bereich umschließen" |
|
||||
| `thresholds.validation.outOfBounds` | "Value outside allowed range ({min}–{max} {unit})" | "Wert außerhalb des erlaubten Bereichs ({min}–{max} {unit})" |
|
||||
|
||||
Existing `targets.*` keys stay; the targets page gains a small "Customize" link routing to `/settings#thresholds`.
|
||||
|
||||
## 10. UI
|
||||
|
||||
New section in the existing settings shell at `/settings#thresholds`:
|
||||
|
||||
- One card per metric. Header shows the metric label + icon (reuses `TYPE_ICONS` from `targets/page.tsx`).
|
||||
- Body left: read-only "Default: 60 – 100 bpm" with the source citation (same external link logic as `getTargetSourceLink`).
|
||||
- Body right: an "Use custom value" toggle. When on, two number inputs (min, max) plus an optional collapsible "Yellow zone" with `warnMin`/`warnMax`.
|
||||
- Inline validation via React Hook Form + Zod (the same schema the server uses).
|
||||
- Footer: "Reset to default" button (visible only when an override exists).
|
||||
- Out-of-range soft warnings render as an inline orange callout with a "I confirm my doctor recommended this" checkbox required to enable Save.
|
||||
- Persistent disclaimer banner at the top: `thresholds.notMedicalAdvice`.
|
||||
|
||||
## 11. Nyquist Validation (tests in `src/lib/analytics/__tests__/effective-range.test.ts` and route tests)
|
||||
|
||||
- `getEffectiveRange` returns the override when present and the default when absent (one test per metric).
|
||||
- Boundary tests: `min === max` rejected; `warnMin === min` accepted; off-by-one above/below the hard sanity bounds rejected/accepted.
|
||||
- Profile-change test: removing `heightCm` collapses the WEIGHT default to "no range", but a present override survives.
|
||||
- API contract: `GET` returns `defaults`, `overrides`, `effective`, `profile`. `PUT` with partial body leaves untouched metrics intact. `DELETE :metric` removes only that key.
|
||||
- AuditLog firing: spy that one row is written per `PUT`/`DELETE`, with correct `before`/`after`. Confirm `details` JSON parses.
|
||||
- Out-of-range confirmation: PUT with a value flagged "out_of_normal_range" requires `?confirm=1` query param (or a body flag) — without it the route returns `400 confirmation_required`.
|
||||
- i18n parity test: every new EN key has a DE counterpart (re-uses the existing parity test if present, else add).
|
||||
|
||||
## 12. Risks & Mitigations
|
||||
|
||||
- **Users set unhealthy targets.** Hard sanity bounds reject obvious nonsense; soft "out-of-normal-range" warns and demands explicit confirmation. Persistent banner ("not medical advice"). Audit trail enables forensic review.
|
||||
- **Insights/AI prompts may quote a user-customized range as if it were the medical guideline.** The provider prompts must receive the `source` field and explicitly distinguish "User override" from a guideline citation. Update `src/lib/ai/prompts/*` accordingly.
|
||||
- **Doctor-report PDF.** Mark each customized range with a small "(custom)" tag so a treating physician sees that the report's coloring reflects the patient's own targets, not the guideline.
|
||||
- **Default drift over time.** If we update guidelines (e.g. ESC/ESH 2024), users with overrides are unaffected — but users without overrides will see their colored bands shift. Document in the changelog.
|
||||
|
||||
## 13. Out of Scope
|
||||
|
||||
- Sharing thresholds between users / household.
|
||||
- Doctor export of overrides (a treating physician downloading the override JSON to reuse) — could come later as part of the doctor report.
|
||||
- Time-bounded thresholds ("until 2026-12-31"), seasonal thresholds, schedule-based thresholds.
|
||||
- Per-medication or per-condition threshold packs.
|
||||
- Threshold suggestions from AI insights.
|
||||
|
||||
## 14. Effort
|
||||
|
||||
**M (medium).** One Prisma migration, one new helper module, three new API routes, refactoring of ~5 analytics consumers to go through `getEffectiveRange`, one new settings card, ~14 i18n keys per locale, and one new test file. No external integrations, no UI primitive work (existing shadcn `Card`/`Input`/`Switch`/`Button` cover everything). Estimated 1.5–2 focused days for a senior dev including tests and audit-log wiring.
|
||||
@@ -0,0 +1,117 @@
|
||||
# Dashboard Customization — Design Spec
|
||||
|
||||
**Status:** Proposed (v1.2+)
|
||||
**Date:** 2026-04-18
|
||||
**Owner:** TBD
|
||||
|
||||
## Why
|
||||
|
||||
- Users have different health focus areas. Someone not tracking blood pressure shouldn't see an empty BP card.
|
||||
- Current "hide if no data" rule (`showBodyFatCard = (bf?.count ?? 0) > 0`, etc. in `src/app/page.tsx`) is implicit — users can't pin the cards they care about, and a card vanishes the moment they delete the last entry.
|
||||
- This is for a v1.2+ release, not part of the current bugfix push.
|
||||
|
||||
## 1. Problem Statement
|
||||
|
||||
The dashboard at `src/app/page.tsx` renders a fixed set of trend cards and charts (weight, BP sys/dia, pulse, body fat, mood, BMI) in a hard-coded order. Visibility is implicit (derived from data presence), ordering is non-negotiable. Narrowly-focused users carry visual noise; users with strong preferences cannot reorder. We need explicit, server-persisted, per-user control over which widgets appear and in what order.
|
||||
|
||||
## 2. Requirements
|
||||
|
||||
- **Per-user widget visibility.** Each chart and trend card is a "widget" that the user can show or hide independently.
|
||||
- **Widget ordering — list-based reorder with up/down buttons + numeric position.** Defended below.
|
||||
- **"Reset to defaults" action** that restores the v1.0 behavior (everything visible, default order).
|
||||
- **Server-persisted** so settings follow the user across devices (web + PWA on phone). localStorage only acts as an optimistic cache.
|
||||
- **i18n-friendly.** Widget IDs are stable English keys; display labels resolve through existing `dashboard.*` translation keys (`messages/en.json`, `messages/de.json`).
|
||||
|
||||
**Reorder UX choice:** list-based (up/down arrows) over drag-and-drop. Reasons: (a) mobile-first PWA — drag-on-touch with scroll containers is fragile; (b) keyboard/screen-reader accessible by default; (c) no extra dep (`@dnd-kit` ~30 KB). DnD can be added later behind a flag.
|
||||
|
||||
## 3. Data Model
|
||||
|
||||
**Decision: extend `User` with a `dashboardWidgets Json?` column.** No new table.
|
||||
|
||||
```prisma
|
||||
model User {
|
||||
// ...
|
||||
dashboardWidgets Json? @map("dashboard_widgets")
|
||||
}
|
||||
```
|
||||
|
||||
Shape (validated by Zod on read/write):
|
||||
```ts
|
||||
type DashboardWidgetState = {
|
||||
version: 1;
|
||||
widgets: Array<{ id: WidgetId; visible: boolean; order: number }>;
|
||||
};
|
||||
```
|
||||
|
||||
`WidgetId` is a string-literal union seeded from a server-side registry (`src/lib/dashboard/widgets.ts`):
|
||||
`"weight" | "bp" | "pulse" | "body_fat" | "mood" | "bmi"`.
|
||||
|
||||
**Tradeoffs vs. dedicated `DashboardWidget` model:** JSON wins because the widget set is small (<20 forecast), state is read once per dashboard load, and we never need to query "which users hide widget X." A table would cost a migration + FK + N writes per reorder for ~6 rows, with no queryability payoff. Cost: schema drift risk — mitigated by the Zod gate and the `version` field, which lets us evolve the shape without a SQL migration.
|
||||
|
||||
## 4. API
|
||||
|
||||
Two routes under `src/app/api/dashboard/widgets/route.ts`, both wrapped in `apiHandler` and `requireAuth()`.
|
||||
|
||||
**`GET /api/dashboard/widgets`** — returns merged state (stored values overlaid on the registry; new widgets appear at the end with `visible: true`):
|
||||
```json
|
||||
{ "data": { "version": 1, "widgets": [
|
||||
{ "id": "weight", "visible": true, "order": 0 },
|
||||
{ "id": "mood", "visible": false, "order": 5 }
|
||||
]}, "error": null, "meta": {...} }
|
||||
```
|
||||
|
||||
**`PUT /api/dashboard/widgets`** — replaces full state. Zod schema in `src/lib/validations/dashboard.ts`:
|
||||
```ts
|
||||
const dashboardWidgetsSchema = z.object({
|
||||
version: z.literal(1),
|
||||
widgets: z.array(z.object({
|
||||
id: z.enum(WIDGET_REGISTRY.map(w => w.id)),
|
||||
visible: z.boolean(),
|
||||
order: z.int().min(0),
|
||||
})).min(1),
|
||||
}).refine(s => new Set(s.widgets.map(w => w.id)).size === s.widgets.length);
|
||||
```
|
||||
Unknown IDs and duplicates are rejected. Mutations invalidate `queryKeys.dashboardLayout()`.
|
||||
|
||||
**`DELETE /api/dashboard/widgets`** — clears the column → user gets defaults again ("Reset to defaults").
|
||||
|
||||
## 5. UI
|
||||
|
||||
**Location:** new section in `/settings` named **"Dashboard"** (matches existing settings architecture: Profile, Notifications, Integrations, etc.). Plus a small **"Customize"** icon button on the dashboard header next to the "Add" dropdown that deep-links to `/settings/dashboard`.
|
||||
|
||||
**Flow** (settings page):
|
||||
1. Header "Dashboard widgets" + subhead "Choose which cards and charts appear on your dashboard."
|
||||
2. Ordered list of shadcn `Card` rows — translated label (`t("dashboard.weight")`), short description, `Switch` for visibility, `ChevronUp`/`ChevronDown` reorder buttons (disabled at list ends).
|
||||
3. Sticky footer: outline "Reset to defaults" (confirm via `AlertDialog`) + primary "Save" (disabled until dirty).
|
||||
4. Optimistic update via TanStack Query mutation; on error, rollback + toast.
|
||||
|
||||
**Dashboard read path:** `src/app/page.tsx` becomes a thin orchestrator — fetch layout via `useQuery(queryKeys.dashboardLayout())`, sort by `order`, filter `visible`, render through a `<DashboardWidget id="…" />` switch. Existing data-presence guards stay as a safety net.
|
||||
|
||||
## 6. Migration
|
||||
|
||||
- Prisma migration: `ALTER TABLE users ADD COLUMN dashboard_widgets jsonb`.
|
||||
- No backfill. Null = "use defaults" = the v1.0 behavior frozen as `DEFAULT_LAYOUT` constant in `src/lib/dashboard/widgets.ts`. The `GET` endpoint synthesizes this on the fly.
|
||||
- First save writes the full normalized blob.
|
||||
- Existing users see no change until they open settings.
|
||||
|
||||
## 7. Nyquist Validation Criteria
|
||||
|
||||
- **Vitest:** Zod rejects duplicate IDs, unknown IDs, negative order, missing version.
|
||||
- **Vitest:** layout merger appends new registry widgets with `visible: true` when user state predates them.
|
||||
- **Vitest:** `DELETE` then `GET` returns `DEFAULT_LAYOUT`.
|
||||
- **API integration:** unauth `PUT` → 401; valid `PUT` → 200 + persisted; payload >50 KB → 413.
|
||||
- **Manual UAT:** hide BP on desktop → reload on phone PWA → BP gone. Reorder weight to last → reload → still last. Reset → defaults restored.
|
||||
- **A11y:** keyboard nav on reorder buttons, `aria-label` on Up/Down, screen-reader announces position changes.
|
||||
- **Perf:** dashboard initial render does not regress (React Profiler check).
|
||||
|
||||
## 8. Out of Scope
|
||||
|
||||
- Custom user-defined widgets / per-widget chart config (color, range, time window).
|
||||
- Widgets for future measurement types (blood glucose, SpO2, sleep stages, steps) — added to registry when those features ship.
|
||||
- Multiple dashboards or tabs; sharing layouts between users.
|
||||
- Drag-and-drop reorder (revisit if list UX gets complaints).
|
||||
- Per-widget grid size (1x/2x spans).
|
||||
|
||||
## 9. Estimated Effort
|
||||
|
||||
**M** — roughly 1–2 days. Schema + Zod + registry ~2h, API + tests ~3h, Settings UI ~4h, dashboard refactor ~3h, i18n + polish + UAT ~2h.
|
||||
@@ -0,0 +1,216 @@
|
||||
# Multi-provider AI Insights — Design Spec
|
||||
|
||||
**Date:** 2026-04-18
|
||||
**Status:** Draft
|
||||
**Scope:** Add Anthropic Claude and self-hosted (OpenAI-compatible) LLM endpoints alongside the existing OpenAI / ChatGPT Codex providers. Per-user provider choice, admin-level fallback, encrypted credential storage.
|
||||
|
||||
## 1. Problem
|
||||
|
||||
Today HealthLog can only talk to OpenAI — either the user's ChatGPT subscription via Codex OAuth, or an admin-managed OpenAI API key. That ties insight generation to a single vendor and to the public internet.
|
||||
|
||||
> "I'd like to pick my provider per user. OpenAI for some accounts, Claude for me, and on my home server I want to point HealthLog at a local Ollama instance — none of my BP readings should leave the LAN."
|
||||
> — User intent
|
||||
|
||||
Consequences of the single-vendor model:
|
||||
- No way to use Anthropic Claude despite better medical-reasoning answers.
|
||||
- Privacy-sensitive users (BP, mood, medication) have no on-prem option.
|
||||
- Vendor outages or quota exhaustion bring all insights down (already observed with OpenAI quota).
|
||||
|
||||
## 2. Current state
|
||||
|
||||
`src/lib/ai/provider.ts` exposes `resolveProvider(userId)` returning an `AIProvider`. The contract in `src/lib/ai/types.ts` is small and provider-agnostic:
|
||||
|
||||
```ts
|
||||
export type ProviderType = "codex" | "admin-key" | "none";
|
||||
export interface AIProvider {
|
||||
type: ProviderType;
|
||||
generateCompletion(params: CompletionParams): Promise<CompletionResult>;
|
||||
}
|
||||
export interface CompletionParams {
|
||||
systemPrompt: string;
|
||||
userPrompt: string;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
}
|
||||
export interface CompletionResult {
|
||||
content: string;
|
||||
tokensUsed: number | null;
|
||||
model: string;
|
||||
providerType: ProviderType;
|
||||
}
|
||||
```
|
||||
|
||||
Implementations: `OpenAIClient` (Chat Completions, `response_format: json_object`, captures upstream body excerpt for diagnostics), `CodexClient` (chatgpt.com Responses API with refresh-on-401). Selection is hard-coded: Codex if connected, else admin OpenAI key, else `NoProvider`.
|
||||
|
||||
What needs to change:
|
||||
- Widen `ProviderType` to include `anthropic` and `local`.
|
||||
- Add per-user provider choice (currently only Codex is per-user).
|
||||
- Add per-user model + base-URL + API key fields, mirrored on `AppSettings`.
|
||||
- Two new clients (`AnthropicClient`, `LocalOpenAICompatibleClient`).
|
||||
- New selection function that honors user preference before admin defaults.
|
||||
|
||||
## 3. Target abstraction
|
||||
|
||||
The `AIProvider` interface stays exactly as-is. New concrete clients implement it without touching call-sites in `src/app/api/insights/*`.
|
||||
|
||||
```
|
||||
ProviderType = "codex" | "openai" | "anthropic" | "local" | "admin-key" | "none"
|
||||
```
|
||||
(`"admin-key"` retained for back-compat with already-cached `InsightResult.providerType` strings.)
|
||||
|
||||
Selection order in `resolveProvider(userId)`:
|
||||
1. **User explicit choice** (`User.aiProvider`) with valid credentials.
|
||||
2. **Codex OAuth** if user picked `CHATGPT_OAUTH` and tokens are present (existing path).
|
||||
3. **Admin default** (`AppSettings.adminAiProvider` + admin key) for users with no personal config.
|
||||
4. **`NoProvider`** — surface a clear error to the route handler.
|
||||
|
||||
The function is the single boundary: every call that needs an LLM goes through it. No client is constructed elsewhere.
|
||||
|
||||
## 4. Data model
|
||||
|
||||
`User` model additions (all nullable; user keeps current behaviour until they opt in):
|
||||
|
||||
```prisma
|
||||
aiProvider String? @map("ai_provider") // OPENAI | ANTHROPIC | LOCAL | CHATGPT_OAUTH
|
||||
aiModel String? @map("ai_model") // e.g. claude-sonnet-4-6
|
||||
aiBaseUrl String? @map("ai_base_url") // local endpoint
|
||||
aiOpenAiKeyEncrypted String? @map("ai_openai_key_encrypted")
|
||||
aiAnthropicKeyEncrypted String? @map("ai_anthropic_key_encrypted")
|
||||
aiLocalKeyEncrypted String? @map("ai_local_key_encrypted") // optional bearer for local
|
||||
```
|
||||
|
||||
`AppSettings` mirrors:
|
||||
|
||||
```prisma
|
||||
adminAiProvider String @default("OPENAI") @map("admin_ai_provider")
|
||||
adminAiAnthropicKeyEncrypted String? @map("admin_ai_anthropic_key_encrypted")
|
||||
adminAiAnthropicModel String? @default("claude-sonnet-4-6") @map("admin_ai_anthropic_model")
|
||||
adminAiLocalBaseUrl String? @map("admin_ai_local_base_url")
|
||||
adminAiLocalKeyEncrypted String? @map("admin_ai_local_key_encrypted")
|
||||
adminAiLocalModel String? @map("admin_ai_local_model")
|
||||
```
|
||||
|
||||
The existing `adminAiKeyEncrypted` / `adminAiModel` / `adminAiBaseUrl` fields stay and represent the OpenAI configuration. One Prisma migration adds the columns; existing users have `aiProvider = NULL` and route through admin default exactly like today.
|
||||
|
||||
## 5. New clients
|
||||
|
||||
### AnthropicClient
|
||||
- `POST {baseUrl}/v1/messages` with `x-api-key` and `anthropic-version: 2023-06-01`.
|
||||
- `system` parameter for `params.systemPrompt`; `messages: [{ role: "user", content: params.userPrompt }]`.
|
||||
- Structured JSON via **tool use**: declare a single `record_insight` tool whose `input_schema` mirrors `insightResultSchema`. Set `tool_choice: { type: "tool", name: "record_insight" }`. Read JSON from the `tool_use` block's `input` and serialize to a string for `CompletionResult.content`.
|
||||
- Prompt caching: send the system prompt with `cache_control: { type: "ephemeral" }`. Insights re-run the same multi-paragraph medical preamble per metric — empirically ~90% cost reduction on cached reads.
|
||||
- Tokens: sum `usage.input_tokens + usage.output_tokens` (incl. `cache_read_input_tokens`).
|
||||
- Errors: capture body excerpt like `OpenAIClient` already does, scrub any `sk-ant-...` substrings before logging.
|
||||
|
||||
Default models (selectable in UI):
|
||||
- `claude-sonnet-4-6` (default — quality / cost balance)
|
||||
- `claude-opus-4-7` (highest quality)
|
||||
- `claude-haiku-4-5` (cheap, fast)
|
||||
|
||||
### LocalOpenAICompatibleClient
|
||||
- Same wire format as `OpenAIClient` (`/chat/completions`, optional `Authorization: Bearer ...` only if a key is set).
|
||||
- `baseUrl` is required and user-configurable (e.g. `http://localhost:11434/v1` for Ollama, `http://lm-studio.lan:1234/v1`).
|
||||
- Some local backends reject `response_format: { type: "json_object" }`. Strategy: feature-detect on first failure (HTTP 400 with body containing `response_format`), persist `User.aiLocalSupportsJsonMode = false`, and re-issue without the field. Append `"Return JSON only, no prose."` to the user prompt and run output through `jsonrepair` before `JSON.parse`.
|
||||
- No-telemetry mode: never set `User-Agent` beyond `HealthLog/local`, never include user IDs in headers, do not send the request body to Wide Event annotation when provider is `local` (only `model`, `latencyMs`, `tokensUsed`).
|
||||
|
||||
## 6. UI — `/settings#ai`
|
||||
|
||||
Single dropdown `aiProvider` with options OpenAI / Anthropic Claude / Local LLM / ChatGPT (OAuth). Conditional fields:
|
||||
|
||||
| Provider | Visible fields |
|
||||
| ------------ | --------------------------------------------------------------- |
|
||||
| OpenAI | API key, model (default `gpt-4o-mini`) |
|
||||
| Anthropic | API key, model dropdown (sonnet/opus/haiku) |
|
||||
| Local | Base URL, model name, optional API key, "Disable JSON mode" hint |
|
||||
| ChatGPT OAuth| Existing Codex connect button |
|
||||
|
||||
Each provider section has a **Test connection** button that POSTs to `/api/insights/test` with a tiny prompt (`"Reply with {\"ok\":true}"`), surfacing the upstream status + body excerpt on failure.
|
||||
|
||||
Admin settings page gains the same dropdown for the global default plus an explanatory note: *"Used only for users who have not configured their own provider."*
|
||||
|
||||
## 7. Backward compatibility
|
||||
|
||||
- Migration is additive — no column drops, no data rewrites.
|
||||
- `User.aiProvider IS NULL` ⇒ behave exactly as today (Codex if connected, else admin OpenAI).
|
||||
- `CompletionResult.providerType = "admin-key"` continues to be emitted by `OpenAIClient` so cached insights and analytics dashboards keep working. New clients use new strings.
|
||||
- Existing tests in `src/lib/ai/__tests__` keep passing; new tests are additive.
|
||||
|
||||
## 8. Error handling
|
||||
|
||||
Each client follows the `OpenAIClient` pattern: capture up to 500 chars of the upstream body, attach `{ httpStatus, upstream, model }` to the thrown `Error`, let `apiHandler` log it as a Wide Event. Per-provider mapping in `src/lib/ai/errors.ts`:
|
||||
|
||||
| Upstream | User-facing message (i18n key) |
|
||||
| --------------------------------------- | ----------------------------------------- |
|
||||
| Anthropic 401 / `invalid_api_key` | `insights.error.anthropicInvalidKey` |
|
||||
| Anthropic 429 / `rate_limit_error` | `insights.error.anthropicRateLimited` |
|
||||
| Anthropic 529 / `overloaded_error` | `insights.error.anthropicOverloaded` |
|
||||
| Local fetch ECONNREFUSED | `insights.error.localUnreachable` |
|
||||
| Local 400 mentioning `response_format` | auto-retry once, then `insights.error.localNoJsonMode` |
|
||||
| OpenAI 429 `insufficient_quota` | existing key |
|
||||
|
||||
## 9. Internationalisation
|
||||
|
||||
New keys (EN + DE) under `settings.ai.*` and `insights.error.*`:
|
||||
|
||||
```
|
||||
settings.ai.providerLabel "AI Provider" / "KI-Anbieter"
|
||||
settings.ai.providerOpenAI "OpenAI"
|
||||
settings.ai.providerAnthropic "Anthropic Claude"
|
||||
settings.ai.providerLocal "Local LLM"
|
||||
settings.ai.providerChatGptOAuth "ChatGPT (OAuth)"
|
||||
settings.ai.baseUrlLabel "Endpoint URL" / "Endpunkt-URL"
|
||||
settings.ai.baseUrlHint.local "z.B. http://localhost:11434/v1"
|
||||
settings.ai.modelLabel "Model" / "Modell"
|
||||
settings.ai.testConnection "Test connection" / "Verbindung testen"
|
||||
settings.ai.testSuccess "Connection OK ({model}, {ms} ms)"
|
||||
settings.ai.testFailure "Failed: {detail}"
|
||||
settings.ai.localPrivacyNote "Requests stay on your network. Bodies are not logged."
|
||||
insights.error.anthropicInvalidKey "Anthropic key rejected. Check Settings → AI."
|
||||
insights.error.anthropicRateLimited "Anthropic rate-limited. Try again shortly."
|
||||
insights.error.anthropicOverloaded "Anthropic is overloaded. Try again in a minute."
|
||||
insights.error.localUnreachable "Local LLM endpoint not reachable: {url}"
|
||||
insights.error.localNoJsonMode "Local model rejected JSON mode — falling back to text parsing."
|
||||
```
|
||||
|
||||
## 10. Security
|
||||
|
||||
- All keys stored via existing AES-256-GCM helpers in `src/lib/crypto.ts`. Never persist plaintext.
|
||||
- Anthropic keys begin with `sk-ant-`. The Wide Event redactor (`src/lib/logging/event-builder.ts`) gets a regex `/sk-ant-[A-Za-z0-9_\-]{20,}/g` → `"sk-ant-***"` applied to all `externalCalls[].errorBody`.
|
||||
- Local provider runs with `next/server` `fetch` against arbitrary URLs — validate that `aiBaseUrl` parses as `http`/`https`, reject `file://`, `data://`, and (in production) bare IPs in private ranges *unless* `LOCAL_LLM_ALLOW_PRIVATE=true` (default true on self-hosted, false on `apps-01` build).
|
||||
- Test-connection endpoint is `requireAuth` and rate-limited (5 req/min/user) to prevent SSRF probing.
|
||||
- Admin keys remain admin-only via `requireAdmin()`.
|
||||
|
||||
## 11. Nyquist validation
|
||||
|
||||
- **Unit (Vitest)** — one `*.test.ts` per new client, mocking `fetch`:
|
||||
- `anthropic-client.test.ts`: success path returns parsed JSON from tool_use; 401 maps to error; cache_control header present.
|
||||
- `local-client.test.ts`: success path; auto-retry on `response_format` 400; ECONNREFUSED handled; bearer header omitted when no key.
|
||||
- **Integration** — extend `provider.test.ts`:
|
||||
- User with `aiProvider = ANTHROPIC` and key → returns `AnthropicClient`.
|
||||
- User with no preference falls back to admin default of each kind.
|
||||
- Codex still wins when user picks `CHATGPT_OAUTH` and is connected.
|
||||
- **End-to-end smoke** — manual: switch provider in `/settings#ai`, hit `/api/insights/general-status`, verify `providerType` in cached `InsightResult`.
|
||||
|
||||
## 12. Risks & mitigations
|
||||
|
||||
- **Admin changes default provider, breaks users.** Mitigated by precedence rule: any user with `aiProvider != NULL` is unaffected. Migration backfills nothing — admin only impacts opted-out users.
|
||||
- **Local model returns malformed JSON.** Two-layer defence: tool-use where supported, `jsonrepair` fallback otherwise; on persistent failure surface `dataQuality.confidence = "gering"` and the raw text in a debug field.
|
||||
- **Cached insight from old provider.** Cache key already includes `providerType`; switching provider invalidates naturally on next generate.
|
||||
- **Anthropic prompt-caching cost regression** if system prompt is rebuilt per request without cache_control. Mitigation: emit a Wide Event metric `ai.cache_read_tokens` and add a Grafana panel.
|
||||
|
||||
## 13. Out of scope
|
||||
|
||||
- Vendor-specific features beyond JSON output (Anthropic citations, OpenAI function-calling tool registries, Google Gemini).
|
||||
- Streaming responses to the UI (insights are batch-generated).
|
||||
- Multi-region routing or load-balancing across providers.
|
||||
- Per-metric provider override (one provider per user, applied to all metrics).
|
||||
- Cost dashboards beyond the existing `tokensUsed` field.
|
||||
|
||||
## 14. Effort
|
||||
|
||||
**M–L.** ~2 days dev:
|
||||
- 0.5d schema migration + `resolveProvider` rewrite + types.
|
||||
- 0.5d `AnthropicClient` (tool-use, caching, errors).
|
||||
- 0.5d `LocalOpenAICompatibleClient` + JSON-mode feature detection + jsonrepair.
|
||||
- 0.25d settings UI + test-connection endpoint.
|
||||
- 0.25d i18n + tests + docs.
|
||||
+132
-9
@@ -36,7 +36,81 @@
|
||||
"replace": "Ersetzen",
|
||||
"add": "Hinzufügen",
|
||||
"import": "Importieren",
|
||||
"test": "Test"
|
||||
"test": "Test",
|
||||
"reset": "Zurücksetzen",
|
||||
"copy": "Kopieren",
|
||||
"copyDetails": "Details kopieren",
|
||||
"retry": "Erneut versuchen",
|
||||
"reportIssue": "Fehler melden"
|
||||
},
|
||||
"doctorReport": {
|
||||
"title": "Gesundheitsbericht",
|
||||
"subtitle": "HealthLog — Persönlicher Gesundheitsbericht",
|
||||
"patient": "Patient",
|
||||
"dateOfBirth": "Geburtsdatum",
|
||||
"gender": "Geschlecht",
|
||||
"genderMale": "Männlich",
|
||||
"genderFemale": "Weiblich",
|
||||
"genderOther": "Divers",
|
||||
"height": "Körpergröße",
|
||||
"period": "Berichtszeitraum",
|
||||
"createdOn": "Erstellt am",
|
||||
"vitalsTitle": "Vitalparameter — Übersicht",
|
||||
"colParameter": "Parameter",
|
||||
"colCurrent": "Aktuell",
|
||||
"colAvgPeriod": "Ø Zeitraum",
|
||||
"colMin": "Min",
|
||||
"colMax": "Max",
|
||||
"colN": "n",
|
||||
"bpClassificationTitle": "Blutdruck — ESC/ESH-Klassifikation (2018)",
|
||||
"avgBp": "Durchschnittlicher Blutdruck",
|
||||
"classification": "Klassifikation",
|
||||
"bmiTitle": "Body-Mass-Index (BMI)",
|
||||
"bmiRow": "BMI: {bmi} kg/m² — Klassifikation: {class} (WHO)",
|
||||
"complianceTitle": "Medikamenten-Compliance",
|
||||
"colMedication": "Medikament",
|
||||
"colTaken": "Eingenommen",
|
||||
"colSkipped": "Übersprungen",
|
||||
"colMissed": "Verpasst",
|
||||
"colTotal": "Gesamt",
|
||||
"colComplianceRate": "Compliance-Rate",
|
||||
"moodTitle": "Stimmungsverlauf",
|
||||
"moodSummary": "Durchschnitt: {avg} / 5,0 · Einträge: {count} · Bereich: {min}–{max}",
|
||||
"colMood": "Stimmung",
|
||||
"colCount": "Anzahl",
|
||||
"colShare": "Anteil",
|
||||
"footerDisclaimer1": "Dieser Bericht wurde automatisch aus selbst erfassten Daten generiert und dient ausschließlich zur Unterstützung",
|
||||
"footerDisclaimer2": "des Arzt-Patienten-Gesprächs. Er ersetzt keine ärztliche Diagnose. Korrelationen implizieren keine Kausalität.",
|
||||
"footerSource": "Quelle: HealthLog | Zeitzone: Europe/Berlin | Erstellt: {timestamp}",
|
||||
"typeWeight": "Körpergewicht",
|
||||
"typeBpSys": "Systolischer Blutdruck",
|
||||
"typeBpDia": "Diastolischer Blutdruck",
|
||||
"typePulse": "Ruhepuls",
|
||||
"typeBodyFat": "Körperfettanteil",
|
||||
"typeSleep": "Schlafdauer",
|
||||
"typeSteps": "Aktivität (Schritte)",
|
||||
"unitSteps": "Schritte",
|
||||
"bmiUnderweight": "Untergewicht",
|
||||
"bmiNormal": "Normalgewicht",
|
||||
"bmiOverweight": "Übergewicht (Präadipositas)",
|
||||
"bmiObeseGrade1": "Adipositas Grad I",
|
||||
"bmiObeseGrade2": "Adipositas Grad II",
|
||||
"bmiObeseGrade3": "Adipositas Grad III",
|
||||
"bpOptimal": "Optimal",
|
||||
"bpNormal": "Normal",
|
||||
"bpHighNormal": "Hochnormal",
|
||||
"bpHypertensionGrade1": "Hypertonie Grad 1",
|
||||
"bpHypertensionGrade2": "Hypertonie Grad 2",
|
||||
"bpHypertensionGrade3": "Hypertonie Grad 3",
|
||||
"moodAwful": "Sehr schlecht",
|
||||
"moodBad": "Schlecht",
|
||||
"moodNeutral": "Neutral",
|
||||
"moodGood": "Gut",
|
||||
"moodGreat": "Sehr gut"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Da ist etwas schiefgelaufen",
|
||||
"description": "Ein unerwarteter Fehler ist aufgetreten. Du kannst es erneut versuchen, die technischen Details kopieren oder einen Bug-Report öffnen."
|
||||
},
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
@@ -420,17 +494,19 @@
|
||||
"noData": "Keine Daten im gewählten Zeitraum",
|
||||
"movingAverage": "Gleitender Durchschnitt",
|
||||
"target": "Zielbereich",
|
||||
"points7Label": "7M",
|
||||
"points7Title": "Die sieben letzten Messpunkte",
|
||||
"points30Label": "30M",
|
||||
"points30Title": "Die dreißig letzten Messpunkte",
|
||||
"points90Label": "90M",
|
||||
"points90Title": "Die neunzig letzten Messpunkte",
|
||||
"points7Label": "7 Pkt",
|
||||
"points7Title": "Letzte 7 Messpunkte",
|
||||
"points30Label": "30 Pkt",
|
||||
"points30Title": "Letzte 30 Messpunkte",
|
||||
"points90Label": "90 Pkt",
|
||||
"points90Title": "Letzte 90 Messpunkte",
|
||||
"pointsAllLabel": "Alle",
|
||||
"pointsAllTitle": "Alle verfügbaren Messpunkte",
|
||||
"systolic": "Systolisch",
|
||||
"diastolic": "Diastolisch",
|
||||
"movingAverage7d": "7T-Schnitt",
|
||||
"avg7dShort": "7T",
|
||||
"avg30dShort": "30T",
|
||||
"trend": "Trend",
|
||||
"targetRanges": "Zielbereiche",
|
||||
"perWeek": "/Woche",
|
||||
@@ -478,6 +554,23 @@
|
||||
"legendMissed": "Verpasst"
|
||||
},
|
||||
"insights": {
|
||||
"aiAnalysisTitle": "KI-Gesundheitsanalyse",
|
||||
"noAnalysisYet": "Noch keine Analyse vorhanden.",
|
||||
"noProviderConfigured": "KI-Provider nicht konfiguriert.",
|
||||
"startAnalysis": "Analyse starten",
|
||||
"refreshAnalysis": "Analyse aktualisieren",
|
||||
"keyTakeaway": "Das Wichtigste",
|
||||
"findingsTitle": "Befunde",
|
||||
"correlationsTitle": "Zusammenhänge",
|
||||
"recommendationsTitle": "Empfehlungen",
|
||||
"dataFoundation": "Datengrundlage",
|
||||
"dataGaps": "Datenlücken:",
|
||||
"lastUpdated": "Zuletzt aktualisiert",
|
||||
"confidenceHoch": "Hoch",
|
||||
"confidenceMittel": "Mittel",
|
||||
"confidenceGering": "Gering",
|
||||
"loadError": "Auswertungen konnten nicht geladen werden",
|
||||
"generationFailed": "Die Analyse konnte nicht erstellt werden. Bitte erneut versuchen.",
|
||||
"title": "Insights",
|
||||
"subtitle": "KI-basierte Analyse deiner Gesundheitsdaten",
|
||||
"overviewSubtitle": "Trends, Risiken, Fortschritt auf einen Blick",
|
||||
@@ -710,6 +803,11 @@
|
||||
"passkeyOptionsError": "Konnte Passkey-Optionen nicht laden",
|
||||
"passkeyRegistrationFailed": "Fehler bei Passkey-Registrierung",
|
||||
"passkeyRegistrationCancelled": "Passkey-Registrierung abgebrochen",
|
||||
"passkeyNotSupported": "Dein Browser oder Gerät unterstützt Passkeys noch nicht. Bitte einen anderen Browser oder ein anderes Gerät verwenden.",
|
||||
"passkeyAlreadyRegistered": "Dieses Gerät ist bereits als Passkey für dein Konto registriert.",
|
||||
"passkeySecurityBlocked": "Dein Browser hat die Passkey-Anfrage aus Sicherheitsgründen blockiert. Bitte auf einer vertrauenswürdigen HTTPS-URL erneut versuchen.",
|
||||
"passkeyTimeout": "Die Passkey-Aufforderung ist abgelaufen. Bitte erneut versuchen.",
|
||||
"passkeyUnknownError": "Passkey-Registrierung fehlgeschlagen: {message}",
|
||||
"deletePasskey": "Passkey löschen?",
|
||||
"deletePasskeyDescription": "Der Passkey wird unwiderruflich gelöscht.",
|
||||
"singleDevice": "Einzelgerät",
|
||||
@@ -777,6 +875,7 @@
|
||||
"withingsCredentialsSavedPlaceholderSecret": "Gespeichert — neues eingeben zum Ersetzen",
|
||||
"withingsSaveCredentials": "Zugangsdaten speichern",
|
||||
"withingsConnected": "Verbunden",
|
||||
"configured": "Konfiguriert",
|
||||
"withingsTokenExpired": "Token abgelaufen",
|
||||
"withingsLastSync": "Letzte Sync",
|
||||
"withingsSync": "Jetzt synchronisieren",
|
||||
@@ -905,6 +1004,8 @@
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
"subtitle": "Systemstatus, Benutzer und Einstellungen",
|
||||
"settingsSaveError": "Einstellungen konnten nicht gespeichert werden",
|
||||
"settingsSaveValidationError": "Bitte markierte Felder prüfen",
|
||||
"sectionsTitle": "Admin",
|
||||
"systemStatus": "Systemstatus",
|
||||
"version": "Version",
|
||||
@@ -1247,7 +1348,11 @@
|
||||
"submit": "Senden",
|
||||
"success": "Vielen Dank! Dein Bug Report wurde erfolgreich übermittelt.",
|
||||
"viewOnGithub": "Auf GitHub ansehen",
|
||||
"errorCreating": "Fehler beim Erstellen"
|
||||
"errorCreating": "Fehler beim Erstellen",
|
||||
"notConfiguredTitle": "Bug-Reports noch nicht aktiviert",
|
||||
"notConfiguredUser": "Bug-Reports sind auf diesem Server nicht aktiv. Bitte wende dich an den Administrator.",
|
||||
"notConfiguredAdmin": "Für Bug-Reports wird eine GitHub-Integration benötigt (Issue-Token + Repository). Im Admin-Panel unter Einstellungen → Bug-Reports einrichten.",
|
||||
"openAdminSettings": "Admin-Einstellungen öffnen"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Benachrichtigungen",
|
||||
@@ -1361,6 +1466,8 @@
|
||||
}
|
||||
},
|
||||
"targets": {
|
||||
"title": "Zielwerte",
|
||||
"introText": "Deine aktuellen Werte im Vergleich zu empfohlenen Zielbereichen. Quellen der Leitlinien sind auf jeder Karte verlinkt.",
|
||||
"weight": "Gewicht",
|
||||
"bloodPressure": "Blutdruck",
|
||||
"bloodPressureOnTarget": "Blutdruck im Zielbereich",
|
||||
@@ -1372,7 +1479,23 @@
|
||||
"medicationCompliance": "Einnahmetreue",
|
||||
"mood": "Stimmung",
|
||||
"moodStability": "Stimmungsstabilität",
|
||||
"steps": "Schritte"
|
||||
"steps": "Schritte",
|
||||
"average30d": "30-Tage-Schnitt:",
|
||||
"currentValue": "Aktuell: {value} {unit}",
|
||||
"targetRangeValue": "Zielbereich: {min}–{max} {unit}",
|
||||
"sourceLabel": "Quelle: {source}",
|
||||
"inTarget": "Im Zielbereich",
|
||||
"aboveTarget": "{delta} {unit} über Zielbereich",
|
||||
"belowTarget": "{delta} {unit} unter Zielbereich",
|
||||
"diastolic": "Diastolisch",
|
||||
"noMeasurementYet": "Noch keine Messungen",
|
||||
"noMeasurementData": "Keine Messdaten verfügbar. Füge Messwerte hinzu, um deine Ziele zu sehen.",
|
||||
"profileIncomplete": "Profil unvollständig",
|
||||
"profileIncompleteHeight": "Trage deine Körpergröße im Profil ein, um BMI-Ziele zu aktivieren.",
|
||||
"profileIncompleteAge": "Trage dein Geburtsdatum im Profil ein, um altersspezifische Ziele zu aktivieren.",
|
||||
"profileIncompleteHeightAge": "Trage Körpergröße und Geburtsdatum im Profil ein, um personalisierte Ziele zu sehen.",
|
||||
"compliancePerMedication": "Einnahmetreue pro Medikament",
|
||||
"loadError": "Zielwerte konnten nicht geladen werden"
|
||||
},
|
||||
"passwordStrength": {
|
||||
"veryWeak": "Sehr schwach",
|
||||
|
||||
+132
-9
@@ -36,7 +36,81 @@
|
||||
"replace": "Replace",
|
||||
"add": "Add",
|
||||
"import": "Import",
|
||||
"test": "Test"
|
||||
"test": "Test",
|
||||
"reset": "Reset",
|
||||
"copy": "Copy",
|
||||
"copyDetails": "Copy details",
|
||||
"retry": "Retry",
|
||||
"reportIssue": "Report issue"
|
||||
},
|
||||
"doctorReport": {
|
||||
"title": "Health Report",
|
||||
"subtitle": "HealthLog — Personal Health Report",
|
||||
"patient": "Patient",
|
||||
"dateOfBirth": "Date of birth",
|
||||
"gender": "Gender",
|
||||
"genderMale": "Male",
|
||||
"genderFemale": "Female",
|
||||
"genderOther": "Non-binary",
|
||||
"height": "Height",
|
||||
"period": "Reporting period",
|
||||
"createdOn": "Created on",
|
||||
"vitalsTitle": "Vital signs — Summary",
|
||||
"colParameter": "Parameter",
|
||||
"colCurrent": "Current",
|
||||
"colAvgPeriod": "Avg. period",
|
||||
"colMin": "Min",
|
||||
"colMax": "Max",
|
||||
"colN": "n",
|
||||
"bpClassificationTitle": "Blood pressure — ESC/ESH classification (2018)",
|
||||
"avgBp": "Average blood pressure",
|
||||
"classification": "Classification",
|
||||
"bmiTitle": "Body Mass Index (BMI)",
|
||||
"bmiRow": "BMI: {bmi} kg/m² — Classification: {class} (WHO)",
|
||||
"complianceTitle": "Medication compliance",
|
||||
"colMedication": "Medication",
|
||||
"colTaken": "Taken",
|
||||
"colSkipped": "Skipped",
|
||||
"colMissed": "Missed",
|
||||
"colTotal": "Total",
|
||||
"colComplianceRate": "Compliance rate",
|
||||
"moodTitle": "Mood trajectory",
|
||||
"moodSummary": "Average: {avg} / 5.0 · Entries: {count} · Range: {min}–{max}",
|
||||
"colMood": "Mood",
|
||||
"colCount": "Count",
|
||||
"colShare": "Share",
|
||||
"footerDisclaimer1": "This report was generated automatically from self-tracked data and is intended to support",
|
||||
"footerDisclaimer2": "the doctor–patient conversation. It does not replace a medical diagnosis. Correlations do not imply causation.",
|
||||
"footerSource": "Source: HealthLog | Timezone: Europe/Berlin | Created: {timestamp}",
|
||||
"typeWeight": "Body weight",
|
||||
"typeBpSys": "Systolic blood pressure",
|
||||
"typeBpDia": "Diastolic blood pressure",
|
||||
"typePulse": "Resting pulse",
|
||||
"typeBodyFat": "Body fat",
|
||||
"typeSleep": "Sleep duration",
|
||||
"typeSteps": "Activity (steps)",
|
||||
"unitSteps": "steps",
|
||||
"bmiUnderweight": "Underweight",
|
||||
"bmiNormal": "Normal weight",
|
||||
"bmiOverweight": "Overweight (pre-obese)",
|
||||
"bmiObeseGrade1": "Obesity grade I",
|
||||
"bmiObeseGrade2": "Obesity grade II",
|
||||
"bmiObeseGrade3": "Obesity grade III",
|
||||
"bpOptimal": "Optimal",
|
||||
"bpNormal": "Normal",
|
||||
"bpHighNormal": "High-normal",
|
||||
"bpHypertensionGrade1": "Hypertension grade 1",
|
||||
"bpHypertensionGrade2": "Hypertension grade 2",
|
||||
"bpHypertensionGrade3": "Hypertension grade 3",
|
||||
"moodAwful": "Very bad",
|
||||
"moodBad": "Bad",
|
||||
"moodNeutral": "Neutral",
|
||||
"moodGood": "Good",
|
||||
"moodGreat": "Very good"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Something went wrong",
|
||||
"description": "An unexpected error occurred. You can retry, copy the technical details, or open a bug report."
|
||||
},
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
@@ -420,17 +494,19 @@
|
||||
"noData": "No data in selected time range",
|
||||
"movingAverage": "Moving average",
|
||||
"target": "Target range",
|
||||
"points7Label": "7P",
|
||||
"points7Title": "Last seven data points",
|
||||
"points30Label": "30P",
|
||||
"points30Title": "Last thirty data points",
|
||||
"points90Label": "90P",
|
||||
"points90Title": "Last ninety data points",
|
||||
"points7Label": "7 pts",
|
||||
"points7Title": "Last 7 data points",
|
||||
"points30Label": "30 pts",
|
||||
"points30Title": "Last 30 data points",
|
||||
"points90Label": "90 pts",
|
||||
"points90Title": "Last 90 data points",
|
||||
"pointsAllLabel": "All",
|
||||
"pointsAllTitle": "All available data points",
|
||||
"systolic": "Systolic",
|
||||
"diastolic": "Diastolic",
|
||||
"movingAverage7d": "7d avg",
|
||||
"avg7dShort": "7d",
|
||||
"avg30dShort": "30d",
|
||||
"trend": "Trend",
|
||||
"targetRanges": "Target ranges",
|
||||
"perWeek": "/week",
|
||||
@@ -478,6 +554,23 @@
|
||||
"legendMissed": "Missed"
|
||||
},
|
||||
"insights": {
|
||||
"aiAnalysisTitle": "AI Health Analysis",
|
||||
"noAnalysisYet": "No analysis yet.",
|
||||
"noProviderConfigured": "AI provider not configured.",
|
||||
"startAnalysis": "Start analysis",
|
||||
"refreshAnalysis": "Refresh analysis",
|
||||
"keyTakeaway": "Key Takeaway",
|
||||
"findingsTitle": "Findings",
|
||||
"correlationsTitle": "Correlations",
|
||||
"recommendationsTitle": "Recommendations",
|
||||
"dataFoundation": "Data coverage",
|
||||
"dataGaps": "Data gaps:",
|
||||
"lastUpdated": "Last updated",
|
||||
"confidenceHoch": "High",
|
||||
"confidenceMittel": "Medium",
|
||||
"confidenceGering": "Low",
|
||||
"loadError": "Failed to load insights",
|
||||
"generationFailed": "The analysis couldn't be generated. Please try again.",
|
||||
"title": "Insights",
|
||||
"subtitle": "AI-powered analysis of your health data",
|
||||
"overviewSubtitle": "Trends, risks, progress at a glance",
|
||||
@@ -710,6 +803,11 @@
|
||||
"passkeyOptionsError": "Could not load passkey options",
|
||||
"passkeyRegistrationFailed": "Passkey registration failed",
|
||||
"passkeyRegistrationCancelled": "Passkey registration cancelled",
|
||||
"passkeyNotSupported": "Your browser or device doesn't support passkeys yet. Try a different browser or device.",
|
||||
"passkeyAlreadyRegistered": "This device is already registered as a passkey for your account.",
|
||||
"passkeySecurityBlocked": "Your browser blocked the passkey request for security reasons. Make sure you're on a trusted HTTPS origin and try again.",
|
||||
"passkeyTimeout": "The passkey prompt timed out. Please try again.",
|
||||
"passkeyUnknownError": "Passkey registration failed: {message}",
|
||||
"deletePasskey": "Delete passkey?",
|
||||
"deletePasskeyDescription": "The passkey will be permanently deleted.",
|
||||
"singleDevice": "Single device",
|
||||
@@ -777,6 +875,7 @@
|
||||
"withingsCredentialsSavedPlaceholderSecret": "Saved — enter new to replace",
|
||||
"withingsSaveCredentials": "Save credentials",
|
||||
"withingsConnected": "Connected",
|
||||
"configured": "Configured",
|
||||
"withingsTokenExpired": "Token expired",
|
||||
"withingsLastSync": "Last sync",
|
||||
"withingsSync": "Sync now",
|
||||
@@ -905,6 +1004,8 @@
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
"subtitle": "System status, users, and settings",
|
||||
"settingsSaveError": "Failed to save settings",
|
||||
"settingsSaveValidationError": "Please check the highlighted fields",
|
||||
"sectionsTitle": "Admin",
|
||||
"systemStatus": "System Status",
|
||||
"version": "Version",
|
||||
@@ -1247,7 +1348,11 @@
|
||||
"submit": "Send",
|
||||
"success": "Thank you! Your bug report has been submitted successfully.",
|
||||
"viewOnGithub": "View on GitHub",
|
||||
"errorCreating": "Error creating report"
|
||||
"errorCreating": "Error creating report",
|
||||
"notConfiguredTitle": "Bug reports not available yet",
|
||||
"notConfiguredUser": "Bug reporting is disabled on this server. Please contact your administrator.",
|
||||
"notConfiguredAdmin": "Bug reporting requires a GitHub integration (issue token + repository). Configure it in the admin panel under Settings → Bug reports.",
|
||||
"openAdminSettings": "Open admin settings"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
@@ -1361,6 +1466,8 @@
|
||||
}
|
||||
},
|
||||
"targets": {
|
||||
"title": "Targets",
|
||||
"introText": "Your current values compared to recommended target ranges. Guideline sources are linked on each card.",
|
||||
"weight": "Weight",
|
||||
"bloodPressure": "Blood pressure",
|
||||
"bloodPressureOnTarget": "Blood pressure on target",
|
||||
@@ -1372,7 +1479,23 @@
|
||||
"medicationCompliance": "Medication compliance",
|
||||
"mood": "Mood",
|
||||
"moodStability": "Mood stability",
|
||||
"steps": "steps"
|
||||
"steps": "steps",
|
||||
"average30d": "30-day average:",
|
||||
"currentValue": "Current: {value} {unit}",
|
||||
"targetRangeValue": "Target: {min}–{max} {unit}",
|
||||
"sourceLabel": "Source: {source}",
|
||||
"inTarget": "Within target range",
|
||||
"aboveTarget": "{delta} {unit} above target",
|
||||
"belowTarget": "{delta} {unit} below target",
|
||||
"diastolic": "Diastolic",
|
||||
"noMeasurementYet": "No measurements yet",
|
||||
"noMeasurementData": "No measurement data available. Add some measurements to see your targets.",
|
||||
"profileIncomplete": "Profile incomplete",
|
||||
"profileIncompleteHeight": "Add your height in profile settings to enable BMI targets.",
|
||||
"profileIncompleteAge": "Add your date of birth in profile settings for age-specific targets.",
|
||||
"profileIncompleteHeightAge": "Add your height and date of birth in profile settings for personalized targets.",
|
||||
"compliancePerMedication": "Compliance per medication",
|
||||
"loadError": "Failed to load targets"
|
||||
},
|
||||
"passwordStrength": {
|
||||
"veryWeak": "Very weak",
|
||||
|
||||
+23
-3
@@ -48,7 +48,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { PasswordStrength } from "@/components/ui/password-strength";
|
||||
import { formatDate, formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { toast } from "sonner";
|
||||
|
||||
function PasswordInput(props: React.ComponentProps<typeof Input>) {
|
||||
@@ -203,6 +203,7 @@ function useAdminSettings() {
|
||||
|
||||
function useUpdateSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslations();
|
||||
return useMutation({
|
||||
mutationFn: async (data: Record<string, unknown>) => {
|
||||
const res = await fetch("/api/admin/settings", {
|
||||
@@ -210,10 +211,20 @@ function useUpdateSettings() {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
if (!res.ok) {
|
||||
throw new Error(await getApiErrorMessage(res));
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "settings"] });
|
||||
toast.success(t("common.saved"));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t("admin.settingsSaveError"),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -235,6 +246,7 @@ async function getApiErrorMessage(response: Response): Promise<string> {
|
||||
|
||||
function SystemStatusSection({ id }: { id: string }) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
|
||||
const { data: status } = useQuery({
|
||||
queryKey: ["admin", "status"],
|
||||
@@ -283,7 +295,7 @@ function SystemStatusSection({ id }: { id: string }) {
|
||||
<StatusItem
|
||||
icon={Activity}
|
||||
label={t("admin.measurementsCount")}
|
||||
value={status.counts.measurements.toLocaleString("de-DE")}
|
||||
value={fmt.integer(status.counts.measurements)}
|
||||
/>
|
||||
<StatusItem
|
||||
icon={Key}
|
||||
@@ -1875,6 +1887,14 @@ function UserManagementSection({
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "users"] });
|
||||
setEditingUser(null);
|
||||
toast.success(t("common.saved"));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t("admin.settingsSaveError"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
website: settings.umamiWebsiteId,
|
||||
hostname: appUrl.hostname,
|
||||
screen: "1920x1080",
|
||||
language: "de-DE",
|
||||
language: "en-US",
|
||||
title: "HealthLog Monitoring Test",
|
||||
url: "/admin",
|
||||
referrer: "",
|
||||
|
||||
@@ -11,7 +11,9 @@ export const dynamic = "force-dynamic";
|
||||
function parseTimeToMinutes(value: string): number {
|
||||
const [h, m] = value.split(":").map(Number);
|
||||
if (!Number.isFinite(h) || !Number.isFinite(m)) return 0;
|
||||
return h * 60 + m;
|
||||
// Normalize "24:00" → "00:00" (some ICU builds emit that for midnight).
|
||||
const hours = h === 24 ? 0 : h;
|
||||
return hours * 60 + m;
|
||||
}
|
||||
|
||||
const dayLabels = ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"];
|
||||
@@ -71,7 +73,7 @@ export const POST = apiHandler(async () => {
|
||||
now,
|
||||
userTz,
|
||||
);
|
||||
const currentTime = now.toLocaleTimeString("de-DE", {
|
||||
const currentTime = now.toLocaleTimeString("en-GB", {
|
||||
timeZone: userTz,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
|
||||
@@ -47,9 +47,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const ghRepo = appSettings?.githubIssueRepo || process.env.GITHUB_ISSUE_REPO; // e.g. "owner/repo"
|
||||
|
||||
if (!ghToken || !ghRepo) {
|
||||
// 503 instead of 500: the client keys off status to render the "not
|
||||
// configured" screen. The user also sees a clear message rather than
|
||||
// "Internal server error".
|
||||
return apiError(
|
||||
"Bug report not configured (GitHub issue token/repository missing in admin settings)",
|
||||
500,
|
||||
503,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -63,9 +66,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const { description, screenshot } = parsed.data;
|
||||
|
||||
const dateStr = new Date().toLocaleString("de-DE", {
|
||||
timeZone: "Europe/Berlin",
|
||||
});
|
||||
// GitHub issues are maintainer-facing and the repo's primary language is English.
|
||||
// Use an ISO-8601 timestamp so titles are unambiguous and sortable.
|
||||
const now = new Date();
|
||||
const dateStr = now.toISOString().replace("T", " ").slice(0, 16) + " UTC";
|
||||
const title = `Bug Report – ${dateStr}`;
|
||||
|
||||
// Sanitize user-provided content for GitHub markdown
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Returns whether bug report submission is configured (GitHub token + repo).
|
||||
*
|
||||
* The bug report page uses this to gate its UI so users don't hit a silent
|
||||
* 500 when the admin hasn't set up the GitHub integration. Only two booleans
|
||||
* escape: a single `configured` flag and `isAdmin` so the page can render
|
||||
* the admin-specific copy + admin link. Token/repo granularity is kept
|
||||
* server-side (log annotation only) so we don't hand regular users a probe.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// Light rate limit — the endpoint is cheap, but no reason to let a logged-in
|
||||
// client hammer Postgres in a loop.
|
||||
const rl = await checkRateLimit(
|
||||
`bugreport-status:${user.id}`,
|
||||
30,
|
||||
60 * 1000,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Rate limit exceeded", 429);
|
||||
}
|
||||
|
||||
const appSettings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: { githubIssueTokenEncrypted: true, githubIssueRepo: true },
|
||||
});
|
||||
|
||||
const hasToken = Boolean(
|
||||
appSettings?.githubIssueTokenEncrypted || process.env.GITHUB_ISSUE_TOKEN,
|
||||
);
|
||||
const hasRepo = Boolean(
|
||||
appSettings?.githubIssueRepo || process.env.GITHUB_ISSUE_REPO,
|
||||
);
|
||||
const configured = hasToken && hasRepo;
|
||||
|
||||
annotate({
|
||||
action: { name: "bugreport.status" },
|
||||
meta: { configured, has_token: hasToken, has_repo: hasRepo },
|
||||
});
|
||||
|
||||
return apiSuccess({ configured, isAdmin: user.role === "ADMIN" });
|
||||
});
|
||||
@@ -17,7 +17,7 @@ export const GET = apiHandler(async () => {
|
||||
const creds = await getUserWithingsCredentials(user.id);
|
||||
if (!creds) {
|
||||
return apiError(
|
||||
"Bitte zuerst Withings Client-ID und Client-Secret in den Einstellungen hinterlegen.",
|
||||
"Please configure your Withings Client ID and Client Secret in Settings first.",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { describePasskeyError } from "@/lib/passkey-errors";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export default function LoginPage() {
|
||||
@@ -81,8 +82,9 @@ export default function LoginPage() {
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ["auth"] });
|
||||
router.push(getRedirectTarget());
|
||||
} catch {
|
||||
setError(t("auth.passkeyFailed"));
|
||||
} catch (err) {
|
||||
const { key, params } = describePasskeyError(err);
|
||||
setError(t(key, params));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
+81
-11
@@ -1,14 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Loader2, Upload, X, CheckCircle2, ImageIcon, Bug } from "lucide-react";
|
||||
import {
|
||||
Loader2,
|
||||
Upload,
|
||||
X,
|
||||
CheckCircle2,
|
||||
ImageIcon,
|
||||
Bug,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
interface BugReportStatus {
|
||||
configured: boolean;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
export default function BugReportPage() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
const [description, setDescription] = useState("");
|
||||
const [screenshot, setScreenshot] = useState<string | null>(null);
|
||||
const [screenshotName, setScreenshotName] = useState<string | null>(null);
|
||||
@@ -18,7 +35,17 @@ export default function BugReportPage() {
|
||||
message: string;
|
||||
} | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const { t } = useTranslations();
|
||||
|
||||
const { data: status, isLoading: statusLoading } = useQuery({
|
||||
queryKey: queryKeys.bugreportStatus(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/bugreport/status");
|
||||
if (!res.ok) throw new Error("Failed to load status");
|
||||
const json = await res.json();
|
||||
return json.data as BugReportStatus;
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
async function handleScreenshot(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
@@ -30,10 +57,7 @@ export default function BugReportPage() {
|
||||
}
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
setResult({
|
||||
type: "error",
|
||||
message: t("bugreport.tooLarge"),
|
||||
});
|
||||
setResult({ type: "error", message: t("bugreport.tooLarge") });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -68,10 +92,7 @@ export default function BugReportPage() {
|
||||
|
||||
const json = await res.json();
|
||||
if (res.ok) {
|
||||
setResult({
|
||||
type: "success",
|
||||
message: t("bugreport.success"),
|
||||
});
|
||||
setResult({ type: "success", message: t("bugreport.success") });
|
||||
setDescription("");
|
||||
removeScreenshot();
|
||||
} else {
|
||||
@@ -100,6 +121,56 @@ export default function BugReportPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (statusLoading) {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-6xl space-y-6">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("bugreport.title")}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-sm">{t("common.loading")}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status && !status.configured) {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-6xl space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("bugreport.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("bugreport.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-card border-border flex gap-3 rounded-xl border-l-4 border-l-orange-500 p-5">
|
||||
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-orange-500" />
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">
|
||||
{t("bugreport.notConfiguredTitle")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{status.isAdmin
|
||||
? t("bugreport.notConfiguredAdmin")
|
||||
: t("bugreport.notConfiguredUser")}
|
||||
</p>
|
||||
{status.isAdmin && (
|
||||
<Button asChild size="sm" variant="outline" className="mt-2">
|
||||
<Link href="/admin#bug-reports">
|
||||
{t("bugreport.openAdminSettings")}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-6xl space-y-6">
|
||||
<div>
|
||||
@@ -119,7 +190,6 @@ export default function BugReportPage() {
|
||||
id="bug-desc"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder=""
|
||||
required
|
||||
minLength={10}
|
||||
maxLength={5000}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { ErrorDetails } from "@/components/error-details";
|
||||
|
||||
export default function AppError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
// Surface to any client-side error tracker that's already hooked up.
|
||||
if (typeof window !== "undefined") {
|
||||
const g = window as typeof window & {
|
||||
__healthlog_onError?: (err: Error & { digest?: string }) => void;
|
||||
};
|
||||
g.__healthlog_onError?.(error);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
return <ErrorDetails error={error} reset={reset} />;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Root-level error boundary. Renders when even the root layout itself fails,
|
||||
* so it cannot rely on any providers (i18n, query, auth). Keep it static,
|
||||
* in English, and self-contained.
|
||||
*/
|
||||
export default function GlobalError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
async function handleCopy() {
|
||||
// Strip query string — OAuth callback routes carry sensitive tokens.
|
||||
const loc =
|
||||
typeof window !== "undefined" ? window.location : null;
|
||||
const payload = {
|
||||
message: error.message,
|
||||
digest: error.digest,
|
||||
name: error.name,
|
||||
stack: error.stack?.split("\n").slice(0, 10).join("\n"),
|
||||
urlPath: loc?.pathname ?? null,
|
||||
userAgent:
|
||||
typeof navigator !== "undefined" ? navigator.userAgent : null,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
const text = JSON.stringify(payload, null, 2);
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
window.prompt("Copy details", text);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
style={{
|
||||
fontFamily: "system-ui, sans-serif",
|
||||
backgroundColor: "#1e1f29",
|
||||
color: "#f8f8f2",
|
||||
margin: 0,
|
||||
minHeight: "100vh",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "24px",
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: 560 }}>
|
||||
<h1 style={{ fontSize: 20, marginBottom: 12 }}>
|
||||
Something went wrong
|
||||
</h1>
|
||||
<p style={{ color: "#d0d0d0", marginBottom: 16, fontSize: 14 }}>
|
||||
A critical error occurred. You can retry or copy the details for
|
||||
support.
|
||||
</p>
|
||||
<pre
|
||||
style={{
|
||||
background: "#282a36",
|
||||
padding: 12,
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
overflow: "auto",
|
||||
marginBottom: 16,
|
||||
border: "1px solid #44475a",
|
||||
}}
|
||||
>
|
||||
{error.message || "Unknown error"}
|
||||
</pre>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
<button
|
||||
onClick={reset}
|
||||
style={{
|
||||
background: "#bd93f9",
|
||||
color: "#282a36",
|
||||
border: "none",
|
||||
borderRadius: 6,
|
||||
padding: "8px 14px",
|
||||
cursor: "pointer",
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
style={{
|
||||
background: "transparent",
|
||||
color: "#f8f8f2",
|
||||
border: "1px solid #44475a",
|
||||
borderRadius: 6,
|
||||
padding: "8px 14px",
|
||||
cursor: "pointer",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
Copy details
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
+13
-15
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -222,15 +222,12 @@ function getRangeHint(
|
||||
unit: string,
|
||||
config: RangeDisplayConfig,
|
||||
t: (key: string) => string,
|
||||
formatNumber: (value: number, fractionDigits?: number) => string,
|
||||
): React.ReactNode | undefined {
|
||||
const range = config.range;
|
||||
if (!range) return undefined;
|
||||
|
||||
const format = (value: number) =>
|
||||
new Intl.NumberFormat("de-DE", {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
}).format(value);
|
||||
const format = (value: number) => formatNumber(value, 1);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -496,6 +493,7 @@ function getMedicationComplianceSectionStatus(input: {
|
||||
export default function InsightsPage() {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const { t, locale } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
|
||||
const STRENGTH_LABELS: Record<string, string> = {
|
||||
stark: t("insights.strengthStrong"),
|
||||
@@ -508,7 +506,7 @@ export default function InsightsPage() {
|
||||
queryKey: ["insights", "comprehensive"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/insights/comprehensive");
|
||||
if (!res.ok) throw new Error("Fehler beim Laden");
|
||||
if (!res.ok) throw new Error(t("insights.loadError"));
|
||||
const json = await res.json();
|
||||
return json.data as ComprehensiveData;
|
||||
},
|
||||
@@ -838,8 +836,8 @@ export default function InsightsPage() {
|
||||
avg30={w?.avg30 ?? null}
|
||||
avg7ColorClass={getRangeColorClass(w?.avg7, { range: weightRange })}
|
||||
avg30ColorClass={getRangeColorClass(w?.avg30, { range: weightRange })}
|
||||
avg7Hint={getRangeHint("kg", { range: weightRange }, t)}
|
||||
avg30Hint={getRangeHint("kg", { range: weightRange }, t)}
|
||||
avg7Hint={getRangeHint("kg", { range: weightRange }, t, fmt.number)}
|
||||
avg30Hint={getRangeHint("kg", { range: weightRange }, t, fmt.number)}
|
||||
slope30={w?.slope30 ?? null}
|
||||
icon={Activity}
|
||||
/>
|
||||
@@ -853,8 +851,8 @@ export default function InsightsPage() {
|
||||
avg30ColorClass={getRangeColorClass(sys?.avg30, {
|
||||
range: bpSysRange,
|
||||
})}
|
||||
avg7Hint={getRangeHint("mmHg", { range: bpSysRange }, t)}
|
||||
avg30Hint={getRangeHint("mmHg", { range: bpSysRange }, t)}
|
||||
avg7Hint={getRangeHint("mmHg", { range: bpSysRange }, t, fmt.number)}
|
||||
avg30Hint={getRangeHint("mmHg", { range: bpSysRange }, t, fmt.number)}
|
||||
slope30={sys?.slope30 ?? null}
|
||||
icon={Heart}
|
||||
/>
|
||||
@@ -868,8 +866,8 @@ export default function InsightsPage() {
|
||||
avg30ColorClass={getRangeColorClass(dia?.avg30, {
|
||||
range: bpDiaRange,
|
||||
})}
|
||||
avg7Hint={getRangeHint("mmHg", { range: bpDiaRange }, t)}
|
||||
avg30Hint={getRangeHint("mmHg", { range: bpDiaRange }, t)}
|
||||
avg7Hint={getRangeHint("mmHg", { range: bpDiaRange }, t, fmt.number)}
|
||||
avg30Hint={getRangeHint("mmHg", { range: bpDiaRange }, t, fmt.number)}
|
||||
slope30={dia?.slope30 ?? null}
|
||||
icon={Heart}
|
||||
/>
|
||||
@@ -885,8 +883,8 @@ export default function InsightsPage() {
|
||||
avg30ColorClass={getRangeColorClass(p?.avg30, {
|
||||
range: pulseDisplayRange,
|
||||
})}
|
||||
avg7Hint={getRangeHint("bpm", { range: pulseDisplayRange }, t)}
|
||||
avg30Hint={getRangeHint("bpm", { range: pulseDisplayRange }, t)}
|
||||
avg7Hint={getRangeHint("bpm", { range: pulseDisplayRange }, t, fmt.number)}
|
||||
avg30Hint={getRangeHint("bpm", { range: pulseDisplayRange }, t, fmt.number)}
|
||||
slope30={p?.slope30 ?? null}
|
||||
icon={TrendingUp}
|
||||
/>
|
||||
|
||||
+25
-3
@@ -1,10 +1,29 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import { headers } from "next/headers";
|
||||
import { headers, cookies } from "next/headers";
|
||||
import "./globals.css";
|
||||
import { Providers } from "@/components/providers";
|
||||
import { AuthShell } from "@/components/layout/auth-shell";
|
||||
import { MonitoringBootstrap } from "@/components/monitoring/bootstrap";
|
||||
import { parseLocaleFromAcceptLanguage } from "@/lib/format-locale";
|
||||
import { locales, type Locale } from "@/lib/i18n/config";
|
||||
|
||||
async function resolveInitialLocale(): Promise<Locale> {
|
||||
// Both cookies() and headers() can throw (DynamicServerError, etc.) —
|
||||
// fall back to the default so a locale hiccup never crashes the root
|
||||
// layout into global-error.tsx.
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const cookieLocale = cookieStore.get("healthlog-locale")?.value;
|
||||
if (cookieLocale && (locales as readonly string[]).includes(cookieLocale)) {
|
||||
return cookieLocale as Locale;
|
||||
}
|
||||
const headerList = await headers();
|
||||
return parseLocaleFromAcceptLanguage(headerList.get("accept-language"));
|
||||
} catch {
|
||||
return "en";
|
||||
}
|
||||
}
|
||||
|
||||
const inter = Inter({
|
||||
variable: "--font-sans",
|
||||
@@ -31,7 +50,8 @@ export const metadata: Metadata = {
|
||||
description:
|
||||
"Personal health tracking — weight, blood pressure, pulse, mood, medication compliance",
|
||||
type: "website",
|
||||
locale: "de_DE",
|
||||
locale: "en_US",
|
||||
alternateLocale: ["de_DE"],
|
||||
siteName: "HealthLog",
|
||||
},
|
||||
twitter: {
|
||||
@@ -62,8 +82,10 @@ export default async function RootLayout({
|
||||
? ((await headers()).get("x-nonce") ?? undefined)
|
||||
: undefined;
|
||||
|
||||
const initialLocale = await resolveInitialLocale();
|
||||
|
||||
return (
|
||||
<html lang="de" suppressHydrationWarning>
|
||||
<html lang={initialLocale} suppressHydrationWarning>
|
||||
<head>
|
||||
<script
|
||||
suppressHydrationWarning
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
|
||||
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
|
||||
import { MedicationForm } from "@/components/medications/medication-form";
|
||||
import { MedicationCard } from "@/components/medications/medication-card";
|
||||
@@ -366,7 +367,7 @@ function IntakeImportDialog({
|
||||
: ""),
|
||||
);
|
||||
setResultType("success");
|
||||
queryClient.invalidateQueries({ queryKey: ["medications"] });
|
||||
void invalidateKeys(queryClient, medicationDependentKeys);
|
||||
} else {
|
||||
setResult(json.error || t("medications.importFailed"));
|
||||
setResultType("error");
|
||||
|
||||
+16
-17
@@ -36,7 +36,8 @@ const MoodChart = dynamic(
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
import { getBpTargets } from "@/lib/analytics/bp-targets";
|
||||
import {
|
||||
@@ -66,7 +67,7 @@ function getHourForTimeZone(timeZone?: string): number {
|
||||
if (!timeZone) return now.getHours();
|
||||
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat("de-DE", {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
hour: "2-digit",
|
||||
hour12: false,
|
||||
timeZone,
|
||||
@@ -98,15 +99,12 @@ function getRangeHint(
|
||||
unit: string,
|
||||
config: RangeDisplayConfig,
|
||||
t: (key: string) => string,
|
||||
formatNumber: (value: number, fractionDigits?: number) => string,
|
||||
): React.ReactNode | undefined {
|
||||
const range = config.range;
|
||||
if (!range) return undefined;
|
||||
|
||||
const format = (value: number) =>
|
||||
new Intl.NumberFormat("de-DE", {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
}).format(value);
|
||||
const format = (value: number) => formatNumber(value, 1);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -136,10 +134,11 @@ function getRangeHint(
|
||||
export default function DashboardPage() {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const [quickEntryDialog, setQuickEntryDialog] = useState<"measurement" | "mood" | null>(null);
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ["analytics"],
|
||||
queryKey: queryKeys.analytics(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/analytics");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
@@ -150,7 +149,7 @@ export default function DashboardPage() {
|
||||
});
|
||||
|
||||
const { data: moodData } = useQuery({
|
||||
queryKey: ["mood-analytics"],
|
||||
queryKey: queryKeys.moodAnalytics(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/mood/analytics");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
@@ -334,8 +333,8 @@ export default function DashboardPage() {
|
||||
avg30={w?.avg30 ?? null}
|
||||
avg7ColorClass={getRangeColorClass(w?.avg7, { range: weightRange })}
|
||||
avg30ColorClass={getRangeColorClass(w?.avg30, { range: weightRange })}
|
||||
avg7Hint={getRangeHint("kg", { range: weightRange }, t)}
|
||||
avg30Hint={getRangeHint("kg", { range: weightRange }, t)}
|
||||
avg7Hint={getRangeHint("kg", { range: weightRange }, t, fmt.number)}
|
||||
avg30Hint={getRangeHint("kg", { range: weightRange }, t, fmt.number)}
|
||||
slope30={w?.slope30 ?? null}
|
||||
icon={Activity}
|
||||
/>
|
||||
@@ -349,8 +348,8 @@ export default function DashboardPage() {
|
||||
avg30ColorClass={getRangeColorClass(sys?.avg30, {
|
||||
range: bpSysRange,
|
||||
})}
|
||||
avg7Hint={getRangeHint("mmHg", { range: bpSysRange }, t)}
|
||||
avg30Hint={getRangeHint("mmHg", { range: bpSysRange }, t)}
|
||||
avg7Hint={getRangeHint("mmHg", { range: bpSysRange }, t, fmt.number)}
|
||||
avg30Hint={getRangeHint("mmHg", { range: bpSysRange }, t, fmt.number)}
|
||||
slope30={sys?.slope30 ?? null}
|
||||
icon={Heart}
|
||||
/>
|
||||
@@ -364,8 +363,8 @@ export default function DashboardPage() {
|
||||
avg30ColorClass={getRangeColorClass(dia?.avg30, {
|
||||
range: bpDiaRange,
|
||||
})}
|
||||
avg7Hint={getRangeHint("mmHg", { range: bpDiaRange }, t)}
|
||||
avg30Hint={getRangeHint("mmHg", { range: bpDiaRange }, t)}
|
||||
avg7Hint={getRangeHint("mmHg", { range: bpDiaRange }, t, fmt.number)}
|
||||
avg30Hint={getRangeHint("mmHg", { range: bpDiaRange }, t, fmt.number)}
|
||||
slope30={dia?.slope30 ?? null}
|
||||
icon={Heart}
|
||||
/>
|
||||
@@ -381,8 +380,8 @@ export default function DashboardPage() {
|
||||
avg30ColorClass={getRangeColorClass(p?.avg30, {
|
||||
range: pulseDisplayRange,
|
||||
})}
|
||||
avg7Hint={getRangeHint("bpm", { range: pulseDisplayRange }, t)}
|
||||
avg30Hint={getRangeHint("bpm", { range: pulseDisplayRange }, t)}
|
||||
avg7Hint={getRangeHint("bpm", { range: pulseDisplayRange }, t, fmt.number)}
|
||||
avg30Hint={getRangeHint("bpm", { range: pulseDisplayRange }, t, fmt.number)}
|
||||
slope30={p?.slope30 ?? null}
|
||||
icon={TrendingUp}
|
||||
/>
|
||||
|
||||
@@ -54,6 +54,8 @@ import {
|
||||
import { formatDate, formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { locales, localeLabels, type Locale } from "@/lib/i18n/config";
|
||||
import { invalidateKeys, measurementDependentKeys } from "@/lib/query-keys";
|
||||
import { describePasskeyError } from "@/lib/passkey-errors";
|
||||
|
||||
function PasswordInput(props: React.ComponentProps<typeof Input>) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
@@ -208,8 +210,9 @@ export default function SettingsPage() {
|
||||
);
|
||||
setPasskeyMsgType("error");
|
||||
}
|
||||
} catch {
|
||||
setPasskeyMsg(t("settings.passkeyRegistrationCancelled"));
|
||||
} catch (err) {
|
||||
const { key, params } = describePasskeyError(err);
|
||||
setPasskeyMsg(t(key, params));
|
||||
setPasskeyMsgType("error");
|
||||
} finally {
|
||||
setPasskeyLoading(false);
|
||||
@@ -891,7 +894,7 @@ function WithingsSection({
|
||||
: t("settings.withingsSyncResult", { count: json.data.imported }),
|
||||
);
|
||||
setSyncMsgType("success");
|
||||
queryClient.invalidateQueries({ queryKey: ["measurements"] });
|
||||
void invalidateKeys(queryClient, measurementDependentKeys);
|
||||
} else {
|
||||
setSyncMsg(json.error || t("settings.withingsSyncFailed"));
|
||||
setSyncMsgType("error");
|
||||
@@ -1535,7 +1538,7 @@ function ApiTokensSection({
|
||||
/* ─────────────────────── Export Section ─────────────────────── */
|
||||
|
||||
function ExportSection({ id }: { id: string }) {
|
||||
const { t } = useTranslations();
|
||||
const { t, locale } = useTranslations();
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [generatingReport, setGeneratingReport] = useState(false);
|
||||
|
||||
@@ -1573,10 +1576,9 @@ function ExportSection({ id }: { id: string }) {
|
||||
const { generateDoctorReportPDF } = await import(
|
||||
"@/lib/doctor-report-pdf"
|
||||
);
|
||||
const doc = generateDoctorReportPDF(json.data);
|
||||
doc.save(
|
||||
`gesundheitsbericht-${new Date().toISOString().slice(0, 10)}.pdf`,
|
||||
);
|
||||
const doc = generateDoctorReportPDF(json.data, { t, locale });
|
||||
const fileSlug = locale === "de" ? "gesundheitsbericht" : "health-report";
|
||||
doc.save(`${fileSlug}-${new Date().toISOString().slice(0, 10)}.pdf`);
|
||||
} finally {
|
||||
setGeneratingReport(false);
|
||||
}
|
||||
@@ -2536,7 +2538,7 @@ function MoodLogSection({ t }: { t: (key: string) => string }) {
|
||||
{status.lastSyncedAt && (
|
||||
<span>
|
||||
{t("settings.moodLogLastSync")}:{" "}
|
||||
{new Date(status.lastSyncedAt).toLocaleString("de-DE")}
|
||||
{formatDateTime(status.lastSyncedAt)}
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
|
||||
@@ -20,7 +20,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { formatDateShort } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
|
||||
const TIME_RANGES_KEYS = [
|
||||
{
|
||||
@@ -216,6 +216,7 @@ export function HealthChart({
|
||||
}: HealthChartProps) {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const [rangePoints, setRangePoints] = useState(30);
|
||||
const [showMA, setShowMA] = useState(false);
|
||||
const [showTrend, setShowTrend] = useState(false);
|
||||
@@ -490,25 +491,15 @@ export function HealthChart({
|
||||
return zones;
|
||||
}, [showBands, targetZones, yDomain]);
|
||||
|
||||
const formatAxisValue = (value: number) =>
|
||||
new Intl.NumberFormat("de-DE", {
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
const formatAxisValue = (value: number) => fmt.integer(value);
|
||||
|
||||
const formatTooltipValue = (value: number) =>
|
||||
new Intl.NumberFormat("de-DE", {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
}).format(value);
|
||||
const formatTooltipValue = (value: number) => fmt.number(value, 1);
|
||||
|
||||
const trendInfo = useMemo(() => {
|
||||
if (!showTrend || !chartData?.length) return [];
|
||||
|
||||
const formatSigned = (value: number) => {
|
||||
const formatted = new Intl.NumberFormat("de-DE", {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
}).format(Math.abs(value));
|
||||
const formatted = fmt.number(Math.abs(value), 1);
|
||||
return `${value > 0 ? "+" : value < 0 ? "-" : ""}${formatted}`;
|
||||
};
|
||||
|
||||
@@ -545,7 +536,7 @@ export function HealthChart({
|
||||
}`;
|
||||
})
|
||||
.filter((entry): entry is string => entry !== null);
|
||||
}, [chartData, showTrend, types, unit, valueMode, t]);
|
||||
}, [chartData, showTrend, types, unit, valueMode, t, fmt]);
|
||||
|
||||
if (!isLoading && !data?.length) return null;
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
|
||||
interface TrendCardProps {
|
||||
label: string;
|
||||
@@ -37,6 +38,8 @@ export function TrendCard({
|
||||
slope30,
|
||||
icon: Icon,
|
||||
}: TrendCardProps) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const TrendIcon =
|
||||
slope30?.direction === "up"
|
||||
? ArrowUp
|
||||
@@ -53,10 +56,7 @@ export function TrendCard({
|
||||
? "text-dracula-cyan"
|
||||
: "text-muted-foreground";
|
||||
|
||||
const formatValue = (value: number) =>
|
||||
new Intl.NumberFormat("de-DE", {
|
||||
maximumFractionDigits: 1,
|
||||
}).format(value);
|
||||
const formatValue = (value: number) => fmt.number(value, 1);
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-3">
|
||||
@@ -77,7 +77,7 @@ export function TrendCard({
|
||||
<div className="text-muted-foreground mt-1 flex gap-3 text-xs">
|
||||
{avg7 !== null && (
|
||||
<span>
|
||||
7T:{" "}
|
||||
{t("charts.avg7dShort")}:{" "}
|
||||
{avg7Hint ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -98,7 +98,7 @@ export function TrendCard({
|
||||
)}
|
||||
{avg30 !== null && (
|
||||
<span>
|
||||
30T:{" "}
|
||||
{t("charts.avg30dShort")}:{" "}
|
||||
{avg30Hint ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Copy, CheckCircle2, RotateCcw, Bug } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export interface ErrorDetailsProps {
|
||||
/** The error object, whether it's a runtime Error or a typed API error. */
|
||||
error: Error & { digest?: string };
|
||||
/** Pass Next.js' `reset()` callback if available. */
|
||||
reset?: () => void;
|
||||
/** Request ID propagated from the server (x-request-id), if known. */
|
||||
requestId?: string | null;
|
||||
/** Extra fields to include in the diagnostic payload. */
|
||||
context?: Record<string, unknown>;
|
||||
/** Override the "Report bug" link target. */
|
||||
reportHref?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured error panel with a copy-to-clipboard payload that includes
|
||||
* everything we'd need to reproduce or file an issue: message, stack digest,
|
||||
* URL, user agent, locale, timestamp.
|
||||
*/
|
||||
export function ErrorDetails({
|
||||
error,
|
||||
reset,
|
||||
requestId,
|
||||
context,
|
||||
reportHref = "/bugreport",
|
||||
}: ErrorDetailsProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function handleCopy() {
|
||||
// Only capture the pathname — some flows carry OAuth codes / state tokens
|
||||
// in the query string (Withings connect, password reset) and we don't
|
||||
// want users pasting those into public GitHub issues. Preserve the count
|
||||
// for context without exposing values.
|
||||
const loc =
|
||||
typeof window !== "undefined" ? window.location : null;
|
||||
const urlPath = loc?.pathname ?? null;
|
||||
const searchParamCount = loc?.search
|
||||
? new URLSearchParams(loc.search).toString().split("&").filter(Boolean)
|
||||
.length
|
||||
: 0;
|
||||
|
||||
const payload = {
|
||||
message: error.message,
|
||||
digest: error.digest,
|
||||
name: error.name,
|
||||
stack: error.stack?.split("\n").slice(0, 10).join("\n"),
|
||||
requestId: requestId ?? null,
|
||||
urlPath,
|
||||
searchParamCount,
|
||||
userAgent:
|
||||
typeof navigator !== "undefined" ? navigator.userAgent : null,
|
||||
locale,
|
||||
timestamp: new Date().toISOString(),
|
||||
context,
|
||||
};
|
||||
|
||||
const text = JSON.stringify(payload, null, 2);
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Fallback: open a prompt the user can copy from manually.
|
||||
window.prompt(t("common.copyDetails"), text);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-xl space-y-4 p-6">
|
||||
<h1 className="text-xl font-semibold">{t("errorBoundary.title")}</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("errorBoundary.description")}
|
||||
</p>
|
||||
<div className="bg-muted/50 rounded-md border p-3 font-mono text-xs text-muted-foreground break-all">
|
||||
{error.message || t("common.unknownError")}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{reset && (
|
||||
<Button onClick={reset} variant="outline" size="sm">
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
{t("common.retry")}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleCopy} variant="outline" size="sm">
|
||||
{copied ? (
|
||||
<CheckCircle2 className="mr-2 h-4 w-4 text-dracula-green" />
|
||||
) : (
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{copied ? t("common.copied") : t("common.copyDetails")}
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={reportHref}>
|
||||
<Bug className="mr-2 h-4 w-4" />
|
||||
{t("common.reportIssue")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
ChevronUp,
|
||||
Info,
|
||||
} from "lucide-react";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────
|
||||
|
||||
@@ -91,10 +92,10 @@ const CONFIDENCE_STYLES: Record<string, string> = {
|
||||
gering: "text-dracula-orange",
|
||||
};
|
||||
|
||||
const CONFIDENCE_LABELS: Record<string, string> = {
|
||||
hoch: "Hoch",
|
||||
mittel: "Mittel",
|
||||
gering: "Gering",
|
||||
const CONFIDENCE_LABEL_KEYS: Record<string, string> = {
|
||||
hoch: "insights.confidenceHoch",
|
||||
mittel: "insights.confidenceMittel",
|
||||
gering: "insights.confidenceGering",
|
||||
};
|
||||
|
||||
// ─── Section Separator ────────────────────────────────────
|
||||
@@ -120,6 +121,8 @@ export function InsightAdvisorCard({
|
||||
regenerating = false,
|
||||
cachedAt,
|
||||
}: InsightAdvisorCardProps) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const [dataQualityOpen, setDataQualityOpen] = useState(false);
|
||||
|
||||
const classStyle = insight
|
||||
@@ -134,8 +137,7 @@ export function InsightAdvisorCard({
|
||||
<CardContent className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-dracula-purple" />
|
||||
<span className="ml-2 text-sm text-muted-foreground">
|
||||
{/* TODO: i18n */}
|
||||
Analyse wird erstellt...
|
||||
{t("insights.generating")}
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -152,8 +154,9 @@ export function InsightAdvisorCard({
|
||||
{icon ?? (
|
||||
<Sparkles className="h-5 w-5 text-dracula-purple" />
|
||||
)}
|
||||
{/* TODO: i18n */}
|
||||
<CardTitle className="text-lg">KI-Gesundheitsanalyse</CardTitle>
|
||||
<CardTitle className="text-lg">
|
||||
{t("insights.aiAnalysisTitle")}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{title}</p>
|
||||
@@ -168,8 +171,7 @@ export function InsightAdvisorCard({
|
||||
{!error && (
|
||||
<div className="flex flex-col items-center gap-3 py-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{/* TODO: i18n */}
|
||||
Noch keine Analyse vorhanden.
|
||||
{t("insights.noAnalysisYet")}
|
||||
</p>
|
||||
{onRegenerate && (
|
||||
<Button
|
||||
@@ -183,8 +185,7 @@ export function InsightAdvisorCard({
|
||||
) : (
|
||||
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
{/* TODO: i18n */}
|
||||
Analyse starten
|
||||
{t("insights.startAnalysis")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -204,8 +205,9 @@ export function InsightAdvisorCard({
|
||||
{icon ?? (
|
||||
<Sparkles className="h-5 w-5 text-dracula-purple" />
|
||||
)}
|
||||
{/* TODO: i18n */}
|
||||
<CardTitle className="text-lg">KI-Gesundheitsanalyse</CardTitle>
|
||||
<CardTitle className="text-lg">
|
||||
{t("insights.aiAnalysisTitle")}
|
||||
</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className={classStyle.badge}>
|
||||
@@ -218,7 +220,7 @@ export function InsightAdvisorCard({
|
||||
className="h-7 w-7"
|
||||
onClick={onRegenerate}
|
||||
disabled={regenerating}
|
||||
title="Analyse aktualisieren" // TODO: i18n
|
||||
title={t("insights.refreshAnalysis")}
|
||||
>
|
||||
{regenerating ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
@@ -245,8 +247,7 @@ export function InsightAdvisorCard({
|
||||
{insight.primaryRecommendation && (
|
||||
<div className="rounded-md border-l-2 border-dracula-purple bg-dracula-purple/5 px-4 py-3">
|
||||
<p className="mb-1 text-xs font-medium uppercase tracking-widest text-dracula-purple">
|
||||
{/* TODO: i18n */}
|
||||
Das Wichtigste
|
||||
{t("insights.keyTakeaway")}
|
||||
</p>
|
||||
<p className="text-sm">{insight.primaryRecommendation}</p>
|
||||
</div>
|
||||
@@ -260,8 +261,7 @@ export function InsightAdvisorCard({
|
||||
{/* Findings */}
|
||||
{insight.findings.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{/* TODO: i18n */}
|
||||
<SectionSeparator label="Befunde" />
|
||||
<SectionSeparator label={t("insights.findingsTitle")} />
|
||||
<div className="space-y-2">
|
||||
{insight.findings.map((finding, i) => (
|
||||
<div key={i}>
|
||||
@@ -288,8 +288,7 @@ export function InsightAdvisorCard({
|
||||
{/* Correlations */}
|
||||
{insight.correlations.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{/* TODO: i18n */}
|
||||
<SectionSeparator label="Zusammenhänge" />
|
||||
<SectionSeparator label={t("insights.correlationsTitle")} />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{insight.correlations.map((corr, i) => (
|
||||
<div
|
||||
@@ -313,8 +312,7 @@ export function InsightAdvisorCard({
|
||||
{/* Recommendations */}
|
||||
{insight.recommendations.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{/* TODO: i18n */}
|
||||
<SectionSeparator label="Empfehlungen" />
|
||||
<SectionSeparator label={t("insights.recommendationsTitle")} />
|
||||
<ol className="space-y-1.5 pl-0">
|
||||
{insight.recommendations.map((rec, i) => (
|
||||
<li key={i} className="flex gap-2 text-sm">
|
||||
@@ -341,15 +339,17 @@ export function InsightAdvisorCard({
|
||||
) : (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{/* TODO: i18n */}
|
||||
<span>Datengrundlage</span>
|
||||
<span>{t("insights.dataFoundation")}</span>
|
||||
<span className="mx-1">·</span>
|
||||
<span>
|
||||
Konfidenz:{" "}
|
||||
{t("insights.confidence")}:{" "}
|
||||
<span
|
||||
className={`font-medium ${CONFIDENCE_STYLES[insight.dataQuality.confidence] ?? ""}`}
|
||||
>
|
||||
{CONFIDENCE_LABELS[insight.dataQuality.confidence]}
|
||||
{t(
|
||||
CONFIDENCE_LABEL_KEYS[insight.dataQuality.confidence] ??
|
||||
"insights.confidenceMittel",
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
@@ -359,8 +359,7 @@ export function InsightAdvisorCard({
|
||||
<p>{insight.dataQuality.coverage}</p>
|
||||
{insight.dataQuality.gaps.length > 0 && (
|
||||
<div>
|
||||
{/* TODO: i18n */}
|
||||
<p className="font-medium">Datenlücken:</p>
|
||||
<p className="font-medium">{t("insights.dataGaps")}</p>
|
||||
<ul className="mt-1 list-inside list-disc space-y-0.5">
|
||||
{insight.dataQuality.gaps.map((gap, i) => (
|
||||
<li key={i}>{gap}</li>
|
||||
@@ -373,18 +372,9 @@ export function InsightAdvisorCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cached timestamp */}
|
||||
{cachedAt && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{/* TODO: i18n */}
|
||||
Zuletzt aktualisiert:{" "}
|
||||
{new Date(cachedAt).toLocaleString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
{t("insights.lastUpdated")}: {fmt.dateTime(cachedAt)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────
|
||||
|
||||
@@ -26,22 +27,21 @@ export function InsightStatusCard({
|
||||
updatedAt,
|
||||
loading = false,
|
||||
}: InsightStatusCardProps) {
|
||||
// ── Loading State ─────────────────────────────────────
|
||||
const { t } = useTranslations();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-dracula-purple" />
|
||||
<span className="ml-2 text-sm text-muted-foreground">
|
||||
{/* TODO: i18n */}
|
||||
Wird geladen...
|
||||
{t("common.loading")}
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── No Provider State ─────────────────────────────────
|
||||
if (!hasProvider) {
|
||||
return (
|
||||
<Card className="opacity-60">
|
||||
@@ -53,15 +53,13 @@ export function InsightStatusCard({
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{/* TODO: i18n */}
|
||||
KI-Provider nicht konfiguriert.
|
||||
{t("insights.noProviderConfigured")}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── No Data State ─────────────────────────────────────
|
||||
if (!text) {
|
||||
return (
|
||||
<Card>
|
||||
@@ -73,15 +71,13 @@ export function InsightStatusCard({
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{/* TODO: i18n */}
|
||||
Noch keine Analyse vorhanden.
|
||||
{t("insights.noAnalysisYet")}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Populated Card ────────────────────────────────────
|
||||
return (
|
||||
<Card className="animate-insight-in border-l-2 border-l-dracula-purple">
|
||||
<CardHeader className="pb-2">
|
||||
@@ -92,28 +88,26 @@ export function InsightStatusCard({
|
||||
</div>
|
||||
{cached && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{/* TODO: i18n */}
|
||||
Zwischengespeichert
|
||||
{t("insights.cached")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">{text}</p>
|
||||
{updatedAt && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{/* TODO: i18n */}
|
||||
Zuletzt aktualisiert:{" "}
|
||||
{new Date(updatedAt).toLocaleString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<LastUpdatedFooter updatedAt={updatedAt} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function LastUpdatedFooter({ updatedAt }: { updatedAt: string | null }) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
if (!updatedAt) return null;
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("insights.lastUpdated")}: {fmt.dateTime(updatedAt)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { Loader2, MoreHorizontal, Plus, RotateCcw } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, measurementDependentKeys } from "@/lib/query-keys";
|
||||
|
||||
const MAX_COMMENT_LENGTH = 25;
|
||||
|
||||
@@ -191,7 +192,7 @@ export function MeasurementForm({
|
||||
setDiaBp("");
|
||||
setPulse("");
|
||||
setNotes("");
|
||||
await queryClient.invalidateQueries({ queryKey: ["measurements"] });
|
||||
await invalidateKeys(queryClient, measurementDependentKeys);
|
||||
toast.success(t("common.saved"));
|
||||
onSuccess?.();
|
||||
} catch {
|
||||
|
||||
@@ -63,7 +63,8 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, measurementDependentKeys } from "@/lib/query-keys";
|
||||
|
||||
const TYPE_LABEL_KEYS: Record<string, string> = {
|
||||
WEIGHT: "measurements.typeWeight",
|
||||
@@ -129,6 +130,7 @@ function toDateTimeLocalValue(isoString: string): string {
|
||||
|
||||
export function MeasurementList({ onEdit }: MeasurementListProps) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const [typeFilter, setTypeFilterRaw] = useState<string>("ALL");
|
||||
@@ -192,7 +194,7 @@ export function MeasurementList({ onEdit }: MeasurementListProps) {
|
||||
if (!res.ok) throw new Error("Delete failed");
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["measurements"] });
|
||||
void invalidateKeys(queryClient, measurementDependentKeys);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -224,7 +226,7 @@ export function MeasurementList({ onEdit }: MeasurementListProps) {
|
||||
}
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["measurements"] });
|
||||
await invalidateKeys(queryClient, measurementDependentKeys);
|
||||
setEditing(null);
|
||||
setEditError(null);
|
||||
},
|
||||
@@ -322,7 +324,7 @@ export function MeasurementList({ onEdit }: MeasurementListProps) {
|
||||
{data?.meta.total !== undefined && (
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("measurements.measurementCount", {
|
||||
count: data.meta.total.toLocaleString("de-DE"),
|
||||
count: fmt.integer(data.meta.total),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -177,12 +178,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
|
||||
if (!res.ok) throw new Error("Delete failed");
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["medications", medicationId, "intake"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["medications", medicationId, "compliance"],
|
||||
});
|
||||
void invalidateKeys(queryClient, medicationDependentKeys);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -210,12 +206,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
|
||||
if (!res.ok) throw new Error(json.error ?? "Update failed");
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["medications", medicationId, "intake"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["medications", medicationId, "compliance"],
|
||||
});
|
||||
await invalidateKeys(queryClient, medicationDependentKeys);
|
||||
setEditing(null);
|
||||
setEditError(null);
|
||||
},
|
||||
@@ -248,12 +239,7 @@ export function IntakeHistoryList({ medicationId, createOpen, onCreateOpenChange
|
||||
if (!res.ok) throw new Error(json.error ?? "Create failed");
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["medications", medicationId, "intake"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["medications", medicationId, "compliance"],
|
||||
});
|
||||
await invalidateKeys(queryClient, medicationDependentKeys);
|
||||
setCreating(false);
|
||||
setCreateError(null);
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -60,12 +61,7 @@ export function IntakeTimeline({
|
||||
if (!res.ok) throw new Error("Delete failed");
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["medications", medicationId, "intake"],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["medications", medicationId, "compliance"],
|
||||
});
|
||||
void invalidateKeys(queryClient, medicationDependentKeys);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -18,7 +18,8 @@ import {
|
||||
History,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
|
||||
|
||||
interface Schedule {
|
||||
id: string;
|
||||
@@ -189,6 +190,7 @@ function isLastIntakeInCurrentWindow(
|
||||
export function MedicationCard({ medication, onEdit }: MedicationCardProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const [intakeLoading, setIntakeLoading] = useState<string | null>(null);
|
||||
|
||||
const { data: compliance } = useQuery({
|
||||
@@ -230,12 +232,7 @@ export function MedicationCard({ medication, onEdit }: MedicationCardProps) {
|
||||
body: JSON.stringify({ skipped }),
|
||||
});
|
||||
if (res.ok) {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["medications"],
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["gamification", "achievements"],
|
||||
});
|
||||
await invalidateKeys(queryClient, medicationDependentKeys);
|
||||
}
|
||||
} finally {
|
||||
setIntakeLoading(null);
|
||||
@@ -462,11 +459,7 @@ export function MedicationCard({ medication, onEdit }: MedicationCardProps) {
|
||||
];
|
||||
dayLabel = weekdayLabels[nextDate.getDay()];
|
||||
} else {
|
||||
dayLabel = nextDate.toLocaleDateString("de-DE", {
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
});
|
||||
dayLabel = fmt.dateWithWeekday(nextDate);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,8 @@ import {
|
||||
} from "lucide-react";
|
||||
import { formatTimeWindowRange } from "@/lib/time-window-format";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
|
||||
import { PhaseConfigDialog } from "@/components/medications/phase-config-dialog";
|
||||
|
||||
// DOSE_UNITS built dynamically via t() in the component
|
||||
@@ -126,7 +127,11 @@ function formatRecurrenceSummary(schedule: Schedule, t: TranslateFn): string {
|
||||
return `${t("medications.scheduleIntervalPrefix")} ${dayText} - ${intervalText}`;
|
||||
}
|
||||
|
||||
function formatNextWindowSummary(schedule: Schedule, t: TranslateFn): string {
|
||||
function formatNextWindowSummary(
|
||||
schedule: Schedule,
|
||||
t: TranslateFn,
|
||||
formatShortDate: (date: Date) => string,
|
||||
): string {
|
||||
const isSimpleDaily =
|
||||
schedule.daysOfWeek.length === 0 && schedule.intervalWeeks === 1;
|
||||
if (isSimpleDaily) {
|
||||
@@ -135,12 +140,7 @@ function formatNextWindowSummary(schedule: Schedule, t: TranslateFn): string {
|
||||
|
||||
const nextDate = getNextRecurrenceDate(schedule);
|
||||
const nextText = nextDate
|
||||
? `${new Intl.DateTimeFormat("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
}).format(
|
||||
nextDate,
|
||||
)}, ${formatTimeWindowRange(schedule.windowStart, schedule.windowEnd)}`
|
||||
? `${formatShortDate(nextDate)}, ${formatTimeWindowRange(schedule.windowStart, schedule.windowEnd)}`
|
||||
: "—";
|
||||
return `${t("medications.nextSchedulePrefix")} ${nextText}`;
|
||||
}
|
||||
@@ -235,6 +235,7 @@ export function MedicationForm({
|
||||
}: MedicationFormProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
|
||||
const doseUnits = [
|
||||
"mg", "g", "ml",
|
||||
@@ -385,7 +386,7 @@ export function MedicationForm({
|
||||
return;
|
||||
}
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ["medications"] });
|
||||
await invalidateKeys(queryClient, medicationDependentKeys);
|
||||
toast.success(t("common.saved"));
|
||||
onSuccess?.();
|
||||
} catch {
|
||||
@@ -409,7 +410,7 @@ export function MedicationForm({
|
||||
return;
|
||||
}
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ["medications"] });
|
||||
await invalidateKeys(queryClient, medicationDependentKeys);
|
||||
onSuccess?.();
|
||||
} catch {
|
||||
setError(t("medications.deleteError"));
|
||||
@@ -432,7 +433,7 @@ export function MedicationForm({
|
||||
return;
|
||||
}
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ["medications"] });
|
||||
await invalidateKeys(queryClient, medicationDependentKeys);
|
||||
setPurgeDialogOpen(false);
|
||||
} catch {
|
||||
setError(t("medications.purgeError"));
|
||||
@@ -561,7 +562,7 @@ export function MedicationForm({
|
||||
`${t("medications.formSchedule")} ${i + 1}`}
|
||||
</span>{" "}
|
||||
<span className="text-foreground/70 font-normal">
|
||||
({formatNextWindowSummary(s, t)})
|
||||
({formatNextWindowSummary(s, t, fmt.dateShort)})
|
||||
</span>
|
||||
</p>
|
||||
<DropdownMenu>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { Loader2, MoreHorizontal, Plus, RotateCcw } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, moodDependentKeys } from "@/lib/query-keys";
|
||||
|
||||
const MOOD_LEVELS = [
|
||||
{ value: "SUPER_GUT", score: 5, labelKey: "mood.levelSuperGut" },
|
||||
@@ -83,7 +84,7 @@ export function MoodForm({ onSuccess, onCancel }: MoodFormProps) {
|
||||
}
|
||||
|
||||
resetForm();
|
||||
await queryClient.invalidateQueries({ queryKey: ["mood-entries"] });
|
||||
await invalidateKeys(queryClient, moodDependentKeys);
|
||||
toast.success(t("common.saved"));
|
||||
onSuccess?.();
|
||||
} catch {
|
||||
|
||||
@@ -57,7 +57,8 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, moodDependentKeys } from "@/lib/query-keys";
|
||||
|
||||
const MOOD_SCORES: Record<string, number> = {
|
||||
SUPER_GUT: 5,
|
||||
@@ -105,6 +106,7 @@ function toDateTimeLocalValue(isoString: string): string {
|
||||
|
||||
export function MoodList() {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const [moodFilter, setMoodFilterRaw] = useState<string>("ALL");
|
||||
@@ -168,7 +170,7 @@ export function MoodList() {
|
||||
if (!res.ok) throw new Error("Delete failed");
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["mood-entries"] });
|
||||
void invalidateKeys(queryClient, moodDependentKeys);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -196,7 +198,7 @@ export function MoodList() {
|
||||
}
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["mood-entries"] });
|
||||
await invalidateKeys(queryClient, moodDependentKeys);
|
||||
setEditing(null);
|
||||
setEditError(null);
|
||||
},
|
||||
@@ -284,7 +286,7 @@ export function MoodList() {
|
||||
{data?.meta.total !== undefined && (
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("mood.entryCount", {
|
||||
count: data.meta.total.toLocaleString("de-DE"),
|
||||
count: fmt.integer(data.meta.total),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
makeFormatters,
|
||||
parseLocaleFromAcceptLanguage,
|
||||
resolveIntlLocale,
|
||||
} from "../format-locale";
|
||||
|
||||
describe("resolveIntlLocale", () => {
|
||||
it("maps short locales to BCP-47 tags", () => {
|
||||
expect(resolveIntlLocale("de")).toBe("de-DE");
|
||||
expect(resolveIntlLocale("en")).toBe("en-US");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseLocaleFromAcceptLanguage", () => {
|
||||
it("returns en for null / empty", () => {
|
||||
expect(parseLocaleFromAcceptLanguage(null)).toBe("en");
|
||||
expect(parseLocaleFromAcceptLanguage("")).toBe("en");
|
||||
});
|
||||
|
||||
it("returns de for primary German tag", () => {
|
||||
expect(parseLocaleFromAcceptLanguage("de-DE,en;q=0.9")).toBe("de");
|
||||
expect(parseLocaleFromAcceptLanguage("de")).toBe("de");
|
||||
expect(parseLocaleFromAcceptLanguage("de-CH")).toBe("de");
|
||||
});
|
||||
|
||||
it("returns en for English or unknown locales", () => {
|
||||
expect(parseLocaleFromAcceptLanguage("en-US,en;q=0.9")).toBe("en");
|
||||
expect(parseLocaleFromAcceptLanguage("fr-FR,fr;q=0.9")).toBe("en");
|
||||
expect(parseLocaleFromAcceptLanguage("*")).toBe("en");
|
||||
});
|
||||
});
|
||||
|
||||
describe("makeFormatters", () => {
|
||||
const deFmt = makeFormatters("de");
|
||||
const enFmt = makeFormatters("en");
|
||||
const sample = new Date("2026-04-18T14:30:00Z"); // 16:30 Europe/Berlin (CEST)
|
||||
|
||||
it("formats numbers with regional decimal separators", () => {
|
||||
expect(deFmt.number(70.5, 1)).toMatch(/70,5/);
|
||||
expect(enFmt.number(70.5, 1)).toMatch(/70\.5/);
|
||||
});
|
||||
|
||||
it("formats integers with thousands separators", () => {
|
||||
expect(deFmt.integer(1234567)).toMatch(/1[.\u202f]234[.\u202f]567/);
|
||||
expect(enFmt.integer(1234567)).toBe("1,234,567");
|
||||
});
|
||||
|
||||
it("formats percent", () => {
|
||||
expect(deFmt.percent(0.835, 1)).toMatch(/83,5\s*%/);
|
||||
expect(enFmt.percent(0.835, 1)).toMatch(/83\.5%/);
|
||||
});
|
||||
|
||||
it("formats date in Europe/Berlin", () => {
|
||||
// Both should include day/month/year; locale-specific order differs.
|
||||
expect(deFmt.date(sample)).toContain("2026");
|
||||
expect(enFmt.date(sample)).toContain("2026");
|
||||
});
|
||||
|
||||
it("formats 24h time in Europe/Berlin regardless of locale", () => {
|
||||
expect(deFmt.time(sample)).toBe("16:30");
|
||||
expect(enFmt.time(sample)).toBe("16:30");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describePasskeyError } from "../passkey-errors";
|
||||
|
||||
function domException(name: string, message = ""): Error {
|
||||
const err = new Error(message);
|
||||
err.name = name;
|
||||
return err;
|
||||
}
|
||||
|
||||
describe("describePasskeyError", () => {
|
||||
it("returns generic failure for non-Error inputs", () => {
|
||||
expect(describePasskeyError(undefined)).toEqual({
|
||||
key: "settings.passkeyRegistrationFailed",
|
||||
});
|
||||
expect(describePasskeyError("just a string")).toEqual({
|
||||
key: "settings.passkeyRegistrationFailed",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps NotAllowedError to cancelled (the common cancel/timeout case)", () => {
|
||||
expect(describePasskeyError(domException("NotAllowedError"))).toEqual({
|
||||
key: "settings.passkeyRegistrationCancelled",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps InvalidStateError to already-registered", () => {
|
||||
expect(describePasskeyError(domException("InvalidStateError"))).toEqual({
|
||||
key: "settings.passkeyAlreadyRegistered",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps NotSupportedError to not-supported", () => {
|
||||
expect(describePasskeyError(domException("NotSupportedError"))).toEqual({
|
||||
key: "settings.passkeyNotSupported",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps SecurityError to security-blocked", () => {
|
||||
expect(describePasskeyError(domException("SecurityError"))).toEqual({
|
||||
key: "settings.passkeySecurityBlocked",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps AbortError to timeout", () => {
|
||||
expect(describePasskeyError(domException("AbortError"))).toEqual({
|
||||
key: "settings.passkeyTimeout",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps SimpleWebAuthn ERROR_INVALID_RP_ID to security-blocked", () => {
|
||||
const err = new Error("bad rp");
|
||||
(err as Error & { code?: string }).code = "ERROR_INVALID_RP_ID";
|
||||
expect(describePasskeyError(err)).toEqual({
|
||||
key: "settings.passkeySecurityBlocked",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to unknown with a message param", () => {
|
||||
const result = describePasskeyError(new Error("mystery failure"));
|
||||
expect(result.key).toBe("settings.passkeyUnknownError");
|
||||
expect(result.params).toEqual({ message: "mystery failure" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
queryKeys,
|
||||
measurementDependentKeys,
|
||||
moodDependentKeys,
|
||||
medicationDependentKeys,
|
||||
invalidateKeys,
|
||||
} from "../query-keys";
|
||||
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
||||
|
||||
describe("queryKeys factory", () => {
|
||||
it("returns stable tuples for simple keys", () => {
|
||||
expect(queryKeys.measurements()).toEqual(["measurements"]);
|
||||
expect(queryKeys.analytics()).toEqual(["analytics"]);
|
||||
expect(queryKeys.moodEntries()).toEqual(["mood-entries"]);
|
||||
});
|
||||
|
||||
it("includes locale in insights status keys", () => {
|
||||
expect(queryKeys.insightsGeneralStatus("en")).toEqual([
|
||||
"insights",
|
||||
"general-status",
|
||||
"en",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dependent-key bundles", () => {
|
||||
it("measurementDependentKeys invalidates analytics/insights/targets", () => {
|
||||
const keyStrings = measurementDependentKeys.map((k) =>
|
||||
JSON.stringify(k),
|
||||
);
|
||||
expect(keyStrings).toContain(JSON.stringify(["measurements"]));
|
||||
expect(keyStrings).toContain(JSON.stringify(["analytics"]));
|
||||
expect(keyStrings).toContain(JSON.stringify(["insights"]));
|
||||
expect(keyStrings).toContain(JSON.stringify(["insights", "targets"]));
|
||||
expect(keyStrings).toContain(
|
||||
JSON.stringify(["gamification", "achievements"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("moodDependentKeys bundle covers mood + analytics + targets", () => {
|
||||
const keyStrings = moodDependentKeys.map((k) => JSON.stringify(k));
|
||||
expect(keyStrings).toContain(JSON.stringify(["mood-entries"]));
|
||||
expect(keyStrings).toContain(JSON.stringify(["mood-analytics"]));
|
||||
expect(keyStrings).toContain(JSON.stringify(["insights"]));
|
||||
});
|
||||
|
||||
it("medicationDependentKeys bundle covers medications + analytics + achievements", () => {
|
||||
const keyStrings = medicationDependentKeys.map((k) => JSON.stringify(k));
|
||||
expect(keyStrings).toContain(JSON.stringify(["medications"]));
|
||||
expect(keyStrings).toContain(JSON.stringify(["analytics"]));
|
||||
expect(keyStrings).toContain(
|
||||
JSON.stringify(["gamification", "achievements"]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalidateKeys", () => {
|
||||
it("calls invalidateQueries for every key in the bundle", async () => {
|
||||
const invalidate = vi.fn().mockResolvedValue(undefined);
|
||||
const client = { invalidateQueries: invalidate } as unknown as QueryClient;
|
||||
const keys: QueryKey[] = [["a"], ["b", "c"], ["d"]];
|
||||
|
||||
await invalidateKeys(client, keys);
|
||||
|
||||
expect(invalidate).toHaveBeenCalledTimes(3);
|
||||
expect(invalidate).toHaveBeenNthCalledWith(1, { queryKey: ["a"] });
|
||||
expect(invalidate).toHaveBeenNthCalledWith(2, { queryKey: ["b", "c"] });
|
||||
expect(invalidate).toHaveBeenNthCalledWith(3, { queryKey: ["d"] });
|
||||
});
|
||||
|
||||
it("continues on partial failure (allSettled semantics)", async () => {
|
||||
const invalidate = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(new Error("boom"))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
const client = { invalidateQueries: invalidate } as unknown as QueryClient;
|
||||
const keys: QueryKey[] = [["a"], ["b"], ["c"]];
|
||||
|
||||
const results = await invalidateKeys(client, keys);
|
||||
|
||||
expect(invalidate).toHaveBeenCalledTimes(3);
|
||||
expect(results[0].status).toBe("fulfilled");
|
||||
expect(results[1].status).toBe("rejected");
|
||||
expect(results[2].status).toBe("fulfilled");
|
||||
});
|
||||
});
|
||||
@@ -36,7 +36,26 @@ export class OpenAIClient implements AIProvider {
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`OpenAI request failed (${res.status})`);
|
||||
// Pull as much of the body as we can so upstream incidents (OpenAI 5xx,
|
||||
// model-not-found, quota exceeded) are diagnosable from logs instead of
|
||||
// surfacing as an opaque "OpenAI request failed (500)". Strip anything
|
||||
// that looks like an API key from the excerpt before logging.
|
||||
const rawBody = await res.text().catch(() => "");
|
||||
const bodyExcerpt = rawBody
|
||||
.slice(0, 500)
|
||||
.replace(/sk-[A-Za-z0-9_-]{8,}/g, "sk-***redacted***")
|
||||
.replace(/Bearer\s+[A-Za-z0-9_.-]+/gi, "Bearer ***redacted***");
|
||||
const err = new Error(`OpenAI request failed (${res.status})`);
|
||||
// Keep the body in a structured field rather than the message so
|
||||
// Error.message stays short and the excerpt lands in dedicated log
|
||||
// fields (bodyExcerpt) that can be filtered/truncated centrally.
|
||||
Object.assign(err, {
|
||||
httpStatus: res.status,
|
||||
upstream: "openai",
|
||||
model: this.config.model,
|
||||
bodyExcerpt,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
+160
-94
@@ -1,9 +1,15 @@
|
||||
/**
|
||||
* Client-side PDF generation for doctor reports.
|
||||
* Uses jspdf + jspdf-autotable for professional medical-style reports.
|
||||
*
|
||||
* Every string and every number/date is driven by an injected `t()` and
|
||||
* locale-aware formatters so the exported PDF matches the user's UI language.
|
||||
* Call sites must pass `{ t, locale }` (use the ones from `useTranslations()`
|
||||
* / `useFormatters()`).
|
||||
*/
|
||||
import { jsPDF } from "jspdf";
|
||||
import autoTable from "jspdf-autotable";
|
||||
import { makeFormatters } from "./format-locale";
|
||||
import type { Locale } from "./i18n/config";
|
||||
|
||||
interface ReportData {
|
||||
period: { days: number; since: string };
|
||||
@@ -40,109 +46,130 @@ interface ReportData {
|
||||
} | null;
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
WEIGHT: "Körpergewicht",
|
||||
BLOOD_PRESSURE_SYS: "Systolischer Blutdruck",
|
||||
BLOOD_PRESSURE_DIA: "Diastolischer Blutdruck",
|
||||
PULSE: "Ruhepuls",
|
||||
BODY_FAT: "Körperfettanteil",
|
||||
SLEEP_DURATION: "Schlafdauer",
|
||||
ACTIVITY_STEPS: "Aktivität (Schritte)",
|
||||
type T = (key: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
export interface DoctorReportOptions {
|
||||
t: T;
|
||||
locale: Locale;
|
||||
}
|
||||
|
||||
const TYPE_LABEL_KEYS: Record<string, string> = {
|
||||
WEIGHT: "doctorReport.typeWeight",
|
||||
BLOOD_PRESSURE_SYS: "doctorReport.typeBpSys",
|
||||
BLOOD_PRESSURE_DIA: "doctorReport.typeBpDia",
|
||||
PULSE: "doctorReport.typePulse",
|
||||
BODY_FAT: "doctorReport.typeBodyFat",
|
||||
SLEEP_DURATION: "doctorReport.typeSleep",
|
||||
ACTIVITY_STEPS: "doctorReport.typeSteps",
|
||||
};
|
||||
|
||||
const TYPE_UNITS: Record<string, string> = {
|
||||
const TYPE_UNIT_KEYS: Record<string, string | null> = {
|
||||
WEIGHT: "kg",
|
||||
BLOOD_PRESSURE_SYS: "mmHg",
|
||||
BLOOD_PRESSURE_DIA: "mmHg",
|
||||
PULSE: "bpm",
|
||||
BODY_FAT: "%",
|
||||
SLEEP_DURATION: "h",
|
||||
ACTIVITY_STEPS: "Schritte",
|
||||
ACTIVITY_STEPS: null, // translated unit
|
||||
};
|
||||
|
||||
const MOOD_LABELS: Record<number, string> = {
|
||||
1: "Sehr schlecht",
|
||||
2: "Schlecht",
|
||||
3: "Neutral",
|
||||
4: "Gut",
|
||||
5: "Sehr gut",
|
||||
const MOOD_LABEL_KEYS: Record<number, string> = {
|
||||
1: "doctorReport.moodAwful",
|
||||
2: "doctorReport.moodBad",
|
||||
3: "doctorReport.moodNeutral",
|
||||
4: "doctorReport.moodGood",
|
||||
5: "doctorReport.moodGreat",
|
||||
};
|
||||
|
||||
function fmt(value: number, decimals = 1): string {
|
||||
return value.toFixed(decimals).replace(".", ",");
|
||||
function getBmiClassificationKey(bmi: number): string {
|
||||
if (bmi < 18.5) return "doctorReport.bmiUnderweight";
|
||||
if (bmi < 25) return "doctorReport.bmiNormal";
|
||||
if (bmi < 30) return "doctorReport.bmiOverweight";
|
||||
if (bmi < 35) return "doctorReport.bmiObeseGrade1";
|
||||
if (bmi < 40) return "doctorReport.bmiObeseGrade2";
|
||||
return "doctorReport.bmiObeseGrade3";
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
function getBpClassificationKey(sys: number, dia: number): string {
|
||||
if (sys < 120 && dia < 80) return "doctorReport.bpOptimal";
|
||||
if (sys < 130 && dia < 85) return "doctorReport.bpNormal";
|
||||
if (sys < 140 && dia < 90) return "doctorReport.bpHighNormal";
|
||||
if (sys < 160 && dia < 100) return "doctorReport.bpHypertensionGrade1";
|
||||
if (sys < 180 && dia < 110) return "doctorReport.bpHypertensionGrade2";
|
||||
return "doctorReport.bpHypertensionGrade3";
|
||||
}
|
||||
|
||||
function getBmiClassification(bmi: number): string {
|
||||
if (bmi < 18.5) return "Untergewicht";
|
||||
if (bmi < 25) return "Normalgewicht";
|
||||
if (bmi < 30) return "Übergewicht (Präadipositas)";
|
||||
if (bmi < 35) return "Adipositas Grad I";
|
||||
if (bmi < 40) return "Adipositas Grad II";
|
||||
return "Adipositas Grad III";
|
||||
}
|
||||
export function generateDoctorReportPDF(
|
||||
data: ReportData,
|
||||
options: DoctorReportOptions,
|
||||
): jsPDF {
|
||||
const { t, locale } = options;
|
||||
const formatters = makeFormatters(locale);
|
||||
const num = (value: number, decimals = 1) =>
|
||||
formatters.number(value, decimals);
|
||||
const fmtDate = (iso: string) => formatters.date(iso);
|
||||
|
||||
function getBpClassification(sys: number, dia: number): string {
|
||||
if (sys < 120 && dia < 80) return "Optimal";
|
||||
if (sys < 130 && dia < 85) return "Normal";
|
||||
if (sys < 140 && dia < 90) return "Hochnormal";
|
||||
if (sys < 160 && dia < 100) return "Hypertonie Grad 1";
|
||||
if (sys < 180 && dia < 110) return "Hypertonie Grad 2";
|
||||
return "Hypertonie Grad 3";
|
||||
}
|
||||
const unitFor = (type: string): string => {
|
||||
// Map entry === null means the unit needs translation (e.g. ACTIVITY_STEPS).
|
||||
const staticUnit = TYPE_UNIT_KEYS[type];
|
||||
if (staticUnit === null && type === "ACTIVITY_STEPS") {
|
||||
return t("doctorReport.unitSteps");
|
||||
}
|
||||
return staticUnit ?? "";
|
||||
};
|
||||
|
||||
export function generateDoctorReportPDF(data: ReportData): jsPDF {
|
||||
const doc = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" });
|
||||
const pageWidth = doc.internal.pageSize.getWidth();
|
||||
const margin = 20;
|
||||
let y = margin;
|
||||
|
||||
// ── Header ──────────────────────────────────────────────────────────────
|
||||
doc.setFontSize(22);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(30, 30, 30);
|
||||
doc.text("Gesundheitsbericht", margin, y);
|
||||
doc.text(t("doctorReport.title"), margin, y);
|
||||
y += 8;
|
||||
|
||||
doc.setFontSize(10);
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.setTextColor(100, 100, 100);
|
||||
doc.text("HealthLog — Persönlicher Gesundheitsbericht", margin, y);
|
||||
doc.text(t("doctorReport.subtitle"), margin, y);
|
||||
y += 6;
|
||||
|
||||
// Divider
|
||||
doc.setDrawColor(200, 200, 200);
|
||||
doc.setLineWidth(0.5);
|
||||
doc.line(margin, y, pageWidth - margin, y);
|
||||
y += 8;
|
||||
|
||||
// ── Patient info ────────────────────────────────────────────────────────
|
||||
doc.setFontSize(9);
|
||||
doc.setTextColor(80, 80, 80);
|
||||
const patientInfo: string[] = [];
|
||||
if (data.patient.username) patientInfo.push(`Patient: ${data.patient.username}`);
|
||||
if (data.patient.dateOfBirth) {
|
||||
patientInfo.push(`Geburtsdatum: ${formatDate(data.patient.dateOfBirth)}`);
|
||||
if (data.patient.username) {
|
||||
patientInfo.push(`${t("doctorReport.patient")}: ${data.patient.username}`);
|
||||
}
|
||||
if (data.patient.gender) {
|
||||
if (data.patient.dateOfBirth) {
|
||||
patientInfo.push(
|
||||
`Geschlecht: ${data.patient.gender === "MALE" ? "Männlich" : data.patient.gender === "FEMALE" ? "Weiblich" : "Divers"}`,
|
||||
`${t("doctorReport.dateOfBirth")}: ${fmtDate(data.patient.dateOfBirth)}`,
|
||||
);
|
||||
}
|
||||
if (data.patient.gender) {
|
||||
const genderKeys: Record<string, string> = {
|
||||
MALE: "doctorReport.genderMale",
|
||||
FEMALE: "doctorReport.genderFemale",
|
||||
};
|
||||
const genderKey = genderKeys[data.patient.gender] ?? "doctorReport.genderOther";
|
||||
patientInfo.push(`${t("doctorReport.gender")}: ${t(genderKey)}`);
|
||||
}
|
||||
if (data.patient.heightCm) {
|
||||
patientInfo.push(`Körpergröße: ${data.patient.heightCm} cm`);
|
||||
patientInfo.push(
|
||||
`${t("doctorReport.height")}: ${data.patient.heightCm} cm`,
|
||||
);
|
||||
}
|
||||
patientInfo.push(
|
||||
`Berichtszeitraum: ${formatDate(data.period.since)} — ${formatDate(new Date().toISOString())}`,
|
||||
`${t("doctorReport.period")}: ${fmtDate(data.period.since)} — ${fmtDate(new Date().toISOString())}`,
|
||||
);
|
||||
patientInfo.push(
|
||||
`${t("doctorReport.createdOn")}: ${fmtDate(new Date().toISOString())}`,
|
||||
);
|
||||
patientInfo.push(`Erstellt am: ${formatDate(new Date().toISOString())}`);
|
||||
|
||||
for (const line of patientInfo) {
|
||||
doc.text(line, margin, y);
|
||||
@@ -150,11 +177,10 @@ export function generateDoctorReportPDF(data: ReportData): jsPDF {
|
||||
}
|
||||
y += 4;
|
||||
|
||||
// ── Vital Signs Summary ─────────────────────────────────────────────────
|
||||
doc.setFontSize(14);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.setTextColor(30, 30, 30);
|
||||
doc.text("Vitalparameter — Übersicht", margin, y);
|
||||
doc.text(t("doctorReport.vitalsTitle"), margin, y);
|
||||
y += 6;
|
||||
|
||||
const vitalRows: string[][] = [];
|
||||
@@ -169,20 +195,30 @@ export function generateDoctorReportPDF(data: ReportData): jsPDF {
|
||||
for (const type of vitalTypes) {
|
||||
const s = data.stats[type];
|
||||
if (!s) continue;
|
||||
const unit = unitFor(type);
|
||||
vitalRows.push([
|
||||
TYPE_LABELS[type] || type,
|
||||
`${fmt(s.latest)} ${TYPE_UNITS[type] || ""}`,
|
||||
`${fmt(s.avg)} ${TYPE_UNITS[type] || ""}`,
|
||||
`${fmt(s.min)}`,
|
||||
`${fmt(s.max)}`,
|
||||
`${s.count}`,
|
||||
t(TYPE_LABEL_KEYS[type] ?? ""),
|
||||
`${num(s.latest)} ${unit}`.trim(),
|
||||
`${num(s.avg)} ${unit}`.trim(),
|
||||
num(s.min),
|
||||
num(s.max),
|
||||
String(s.count),
|
||||
]);
|
||||
}
|
||||
|
||||
if (vitalRows.length > 0) {
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
head: [["Parameter", "Aktuell", "Ø Zeitraum", "Min", "Max", "n"]],
|
||||
head: [
|
||||
[
|
||||
t("doctorReport.colParameter"),
|
||||
t("doctorReport.colCurrent"),
|
||||
t("doctorReport.colAvgPeriod"),
|
||||
t("doctorReport.colMin"),
|
||||
t("doctorReport.colMax"),
|
||||
t("doctorReport.colN"),
|
||||
],
|
||||
],
|
||||
body: vitalRows,
|
||||
theme: "grid",
|
||||
styles: {
|
||||
@@ -200,48 +236,49 @@ export function generateDoctorReportPDF(data: ReportData): jsPDF {
|
||||
alternateRowStyles: { fillColor: [252, 252, 252] },
|
||||
margin: { left: margin, right: margin },
|
||||
});
|
||||
y = (doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
|
||||
.finalY + 8;
|
||||
y =
|
||||
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
|
||||
.finalY + 8;
|
||||
}
|
||||
|
||||
// ── Blood Pressure Classification ────────────────────────────────────────
|
||||
const sysStat = data.stats.BLOOD_PRESSURE_SYS;
|
||||
const diaStat = data.stats.BLOOD_PRESSURE_DIA;
|
||||
if (sysStat && diaStat) {
|
||||
doc.setFontSize(12);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.text("Blutdruck — ESC/ESH-Klassifikation (2018)", margin, y);
|
||||
doc.text(t("doctorReport.bpClassificationTitle"), margin, y);
|
||||
y += 5;
|
||||
|
||||
doc.setFontSize(9);
|
||||
doc.setFont("helvetica", "normal");
|
||||
const bpClass = getBpClassification(sysStat.avg, diaStat.avg);
|
||||
const bpClass = t(getBpClassificationKey(sysStat.avg, diaStat.avg));
|
||||
doc.text(
|
||||
`Durchschnittlicher Blutdruck: ${fmt(sysStat.avg, 0)}/${fmt(diaStat.avg, 0)} mmHg — Klassifikation: ${bpClass}`,
|
||||
`${t("doctorReport.avgBp")}: ${num(sysStat.avg, 0)}/${num(diaStat.avg, 0)} mmHg — ${t("doctorReport.classification")}: ${bpClass}`,
|
||||
margin,
|
||||
y,
|
||||
);
|
||||
y += 8;
|
||||
}
|
||||
|
||||
// ── BMI ──────────────────────────────────────────────────────────────────
|
||||
if (data.bmi) {
|
||||
doc.setFontSize(12);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.text("Body-Mass-Index (BMI)", margin, y);
|
||||
doc.text(t("doctorReport.bmiTitle"), margin, y);
|
||||
y += 5;
|
||||
|
||||
doc.setFontSize(9);
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.text(
|
||||
`BMI: ${fmt(data.bmi)} kg/m² — Klassifikation: ${getBmiClassification(data.bmi)} (WHO)`,
|
||||
t("doctorReport.bmiRow", {
|
||||
bmi: num(data.bmi, 1),
|
||||
class: t(getBmiClassificationKey(data.bmi)),
|
||||
}),
|
||||
margin,
|
||||
y,
|
||||
);
|
||||
y += 8;
|
||||
}
|
||||
|
||||
// ── Medication Compliance ────────────────────────────────────────────────
|
||||
const complianceEntries = Object.entries(data.compliance);
|
||||
if (complianceEntries.length > 0) {
|
||||
if (y > 240) {
|
||||
@@ -251,18 +288,33 @@ export function generateDoctorReportPDF(data: ReportData): jsPDF {
|
||||
|
||||
doc.setFontSize(14);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.text("Medikamenten-Compliance", margin, y);
|
||||
doc.text(t("doctorReport.complianceTitle"), margin, y);
|
||||
y += 6;
|
||||
|
||||
const compRows = complianceEntries.map(([name, c]) => {
|
||||
const rate = c.total > 0 ? ((c.taken / c.total) * 100).toFixed(1) : "—";
|
||||
return [name, `${c.taken}`, `${c.skipped}`, `${c.missed}`, `${c.total}`, `${rate}%`];
|
||||
const rate =
|
||||
c.total > 0 ? `${num((c.taken / c.total) * 100, 1)}%` : "—";
|
||||
return [
|
||||
name,
|
||||
String(c.taken),
|
||||
String(c.skipped),
|
||||
String(c.missed),
|
||||
String(c.total),
|
||||
rate,
|
||||
];
|
||||
});
|
||||
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
head: [
|
||||
["Medikament", "Eingenommen", "Übersprungen", "Verpasst", "Gesamt", "Compliance-Rate"],
|
||||
[
|
||||
t("doctorReport.colMedication"),
|
||||
t("doctorReport.colTaken"),
|
||||
t("doctorReport.colSkipped"),
|
||||
t("doctorReport.colMissed"),
|
||||
t("doctorReport.colTotal"),
|
||||
t("doctorReport.colComplianceRate"),
|
||||
],
|
||||
],
|
||||
body: compRows,
|
||||
theme: "grid",
|
||||
@@ -281,11 +333,11 @@ export function generateDoctorReportPDF(data: ReportData): jsPDF {
|
||||
alternateRowStyles: { fillColor: [252, 252, 252] },
|
||||
margin: { left: margin, right: margin },
|
||||
});
|
||||
y = (doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
|
||||
.finalY + 8;
|
||||
y =
|
||||
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
|
||||
.finalY + 8;
|
||||
}
|
||||
|
||||
// ── Mood ─────────────────────────────────────────────────────────────────
|
||||
if (data.mood) {
|
||||
if (y > 240) {
|
||||
doc.addPage();
|
||||
@@ -294,13 +346,18 @@ export function generateDoctorReportPDF(data: ReportData): jsPDF {
|
||||
|
||||
doc.setFontSize(14);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.text("Stimmungsverlauf", margin, y);
|
||||
doc.text(t("doctorReport.moodTitle"), margin, y);
|
||||
y += 6;
|
||||
|
||||
doc.setFontSize(9);
|
||||
doc.setFont("helvetica", "normal");
|
||||
doc.text(
|
||||
`Durchschnitt: ${fmt(data.mood.avg)} / 5,0 | Einträge: ${data.mood.count} | Bereich: ${data.mood.min} – ${data.mood.max}`,
|
||||
t("doctorReport.moodSummary", {
|
||||
avg: num(data.mood.avg, 1),
|
||||
count: data.mood.count,
|
||||
min: num(data.mood.min, 0),
|
||||
max: num(data.mood.max, 0),
|
||||
}),
|
||||
margin,
|
||||
y,
|
||||
);
|
||||
@@ -308,15 +365,23 @@ export function generateDoctorReportPDF(data: ReportData): jsPDF {
|
||||
|
||||
const distRows = Object.entries(data.mood.distribution).map(
|
||||
([score, count]) => [
|
||||
MOOD_LABELS[Number(score)] || score,
|
||||
`${count}`,
|
||||
`${data.mood!.count > 0 ? ((count / data.mood!.count) * 100).toFixed(1) : 0}%`,
|
||||
t(MOOD_LABEL_KEYS[Number(score)] ?? "doctorReport.moodNeutral"),
|
||||
String(count),
|
||||
data.mood!.count > 0
|
||||
? `${num((count / data.mood!.count) * 100, 1)}%`
|
||||
: "0%",
|
||||
],
|
||||
);
|
||||
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
head: [["Stimmung", "Anzahl", "Anteil"]],
|
||||
head: [
|
||||
[
|
||||
t("doctorReport.colMood"),
|
||||
t("doctorReport.colCount"),
|
||||
t("doctorReport.colShare"),
|
||||
],
|
||||
],
|
||||
body: distRows,
|
||||
theme: "grid",
|
||||
styles: {
|
||||
@@ -333,34 +398,35 @@ export function generateDoctorReportPDF(data: ReportData): jsPDF {
|
||||
},
|
||||
margin: { left: margin, right: margin },
|
||||
});
|
||||
y = (doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
|
||||
.finalY + 8;
|
||||
y =
|
||||
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
|
||||
.finalY + 8;
|
||||
}
|
||||
|
||||
// ── Footer / Disclaimer ──────────────────────────────────────────────────
|
||||
const addFooter = (pageDoc: jsPDF) => {
|
||||
const pageHeight = pageDoc.internal.pageSize.getHeight();
|
||||
pageDoc.setFontSize(7);
|
||||
pageDoc.setFont("helvetica", "italic");
|
||||
pageDoc.setTextColor(140, 140, 140);
|
||||
pageDoc.text(
|
||||
"Dieser Bericht wurde automatisch aus selbst erfassten Daten generiert und dient ausschließlich zur Unterstützung",
|
||||
t("doctorReport.footerDisclaimer1"),
|
||||
margin,
|
||||
pageHeight - 14,
|
||||
);
|
||||
pageDoc.text(
|
||||
"des Arzt-Patienten-Gesprächs. Er ersetzt keine ärztliche Diagnose. Korrelationen implizieren keine Kausalität.",
|
||||
t("doctorReport.footerDisclaimer2"),
|
||||
margin,
|
||||
pageHeight - 10,
|
||||
);
|
||||
pageDoc.text(
|
||||
`Quelle: HealthLog | Zeitzone: Europe/Berlin | Erstellt: ${new Date().toLocaleString("de-DE", { timeZone: "Europe/Berlin" })}`,
|
||||
t("doctorReport.footerSource", {
|
||||
timestamp: formatters.dateTime(new Date()),
|
||||
}),
|
||||
margin,
|
||||
pageHeight - 6,
|
||||
);
|
||||
};
|
||||
|
||||
// Add footer to all pages
|
||||
const totalPages = doc.getNumberOfPages();
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
doc.setPage(i);
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Locale-aware formatters.
|
||||
*
|
||||
* HealthLog's UI language is user-configurable (DE/EN). Numbers, dates, and
|
||||
* times must render in the active locale — not in the hard-coded de-DE that
|
||||
* lingered from the German-first era.
|
||||
*
|
||||
* Call via the `useFormatters()` client hook. For server-only code paths
|
||||
* (jobs, API routes, PDF generation) pass the locale explicitly through
|
||||
* `makeFormatters(locale)`.
|
||||
*
|
||||
* Timezone is fixed to Europe/Berlin for display (user expectation). UTC is
|
||||
* preserved in the database.
|
||||
*/
|
||||
|
||||
import type { Locale } from "./i18n/config";
|
||||
|
||||
export const DISPLAY_TIMEZONE = "Europe/Berlin";
|
||||
|
||||
/**
|
||||
* Map our short locale ("de" / "en") to a full BCP-47 tag suitable for
|
||||
* `Intl.*`. When we add locales, extend this map — do not rely on the short
|
||||
* tag directly since browsers differ in how they handle "en" vs "en-US".
|
||||
*/
|
||||
export const INTL_LOCALE_MAP: Record<Locale, string> = {
|
||||
de: "de-DE",
|
||||
en: "en-US",
|
||||
};
|
||||
|
||||
export function resolveIntlLocale(locale: Locale): string {
|
||||
return INTL_LOCALE_MAP[locale] ?? "en-US";
|
||||
}
|
||||
|
||||
type DateInput = Date | string | number;
|
||||
|
||||
function asDate(value: DateInput): Date {
|
||||
return value instanceof Date ? value : new Date(value);
|
||||
}
|
||||
|
||||
export interface Formatters {
|
||||
/** Integer or decimal with the active locale's decimal separator. */
|
||||
number: (value: number, fractionDigits?: number) => string;
|
||||
/** Same as `number` but thousands-grouped. */
|
||||
integer: (value: number) => string;
|
||||
/** Percentage, e.g. 0.835 → "83,5 %" in DE / "83.5%" in EN. */
|
||||
percent: (value: number, fractionDigits?: number) => string;
|
||||
/** Short date, e.g. "19.02.2026" / "02/19/2026". */
|
||||
date: (value: DateInput) => string;
|
||||
/** Short date without year, e.g. "19.02." / "Feb 19". */
|
||||
dateShort: (value: DateInput) => string;
|
||||
/** Full date + time in 24h. */
|
||||
dateTime: (value: DateInput) => string;
|
||||
/** Time only in 24h, e.g. "14:30". */
|
||||
time: (value: DateInput) => string;
|
||||
/** Short weekday + date, e.g. "Do., 19.02." / "Thu, Feb 19". */
|
||||
dateWithWeekday: (value: DateInput) => string;
|
||||
/** For axis labels: 3-letter month abbreviation. */
|
||||
monthShort: (value: DateInput) => string;
|
||||
}
|
||||
|
||||
export function makeFormatters(locale: Locale): Formatters {
|
||||
const intlLocale = resolveIntlLocale(locale);
|
||||
const tz = DISPLAY_TIMEZONE;
|
||||
|
||||
return {
|
||||
number: (value, fractionDigits) =>
|
||||
new Intl.NumberFormat(intlLocale, {
|
||||
minimumFractionDigits: fractionDigits,
|
||||
maximumFractionDigits: fractionDigits,
|
||||
}).format(value),
|
||||
|
||||
integer: (value) =>
|
||||
new Intl.NumberFormat(intlLocale, { maximumFractionDigits: 0 }).format(
|
||||
value,
|
||||
),
|
||||
|
||||
percent: (value, fractionDigits = 0) =>
|
||||
new Intl.NumberFormat(intlLocale, {
|
||||
style: "percent",
|
||||
minimumFractionDigits: fractionDigits,
|
||||
maximumFractionDigits: fractionDigits,
|
||||
}).format(value),
|
||||
|
||||
date: (value) =>
|
||||
asDate(value).toLocaleDateString(intlLocale, {
|
||||
timeZone: tz,
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
}),
|
||||
|
||||
dateShort: (value) =>
|
||||
asDate(value).toLocaleDateString(intlLocale, {
|
||||
timeZone: tz,
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
}),
|
||||
|
||||
dateTime: (value) =>
|
||||
asDate(value).toLocaleString(intlLocale, {
|
||||
timeZone: tz,
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}),
|
||||
|
||||
time: (value) =>
|
||||
asDate(value).toLocaleTimeString(intlLocale, {
|
||||
timeZone: tz,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}),
|
||||
|
||||
dateWithWeekday: (value) =>
|
||||
asDate(value).toLocaleDateString(intlLocale, {
|
||||
timeZone: tz,
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
}),
|
||||
|
||||
monthShort: (value) =>
|
||||
asDate(value).toLocaleDateString(intlLocale, {
|
||||
timeZone: tz,
|
||||
month: "short",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse locale from a `Accept-Language` header or `healthlog-locale` cookie.
|
||||
* Server-side helper for jobs, API routes, and PDF generation where there is
|
||||
* no React context.
|
||||
*/
|
||||
export function parseLocaleFromAcceptLanguage(header: string | null): Locale {
|
||||
if (!header) return "en";
|
||||
const primary = header.split(",")[0]?.trim().toLowerCase() ?? "";
|
||||
if (primary.startsWith("de")) return "de";
|
||||
return "en";
|
||||
}
|
||||
+46
-42
@@ -1,63 +1,67 @@
|
||||
/**
|
||||
* Consistent date/time formatting for the HealthLog UI.
|
||||
* All functions use de-DE locale, Europe/Berlin timezone, 24h format.
|
||||
* Legacy locale-aware date/time helpers.
|
||||
*
|
||||
* These originally hard-coded `de-DE`. They now defer to `makeFormatters`
|
||||
* using a best-effort read of `healthlog-locale` from cookie → localStorage,
|
||||
* falling back to `en`. New code should prefer `useFormatters()` from
|
||||
* `@/lib/i18n/context` (client) or `makeFormatters(locale)` from
|
||||
* `@/lib/format-locale` (server).
|
||||
*
|
||||
* SSR caveat: on the server `activeLocale()` always returns `en` because
|
||||
* `document` is undefined. All current call sites render their formatted
|
||||
* strings only after a `useQuery` fetch — i.e. post-hydration — so there is
|
||||
* no hydration-mismatch path today. If you add a new caller that renders
|
||||
* pre-fetch data (e.g. a static prop), migrate it to `useFormatters()` to
|
||||
* stay consistent with the server-rendered locale.
|
||||
*/
|
||||
|
||||
const TIMEZONE = "Europe/Berlin";
|
||||
const LOCALE = "de-DE";
|
||||
import { makeFormatters, DISPLAY_TIMEZONE } from "./format-locale";
|
||||
import type { Locale } from "./i18n/config";
|
||||
|
||||
/** "19.02.2026, 14:30" */
|
||||
export { DISPLAY_TIMEZONE };
|
||||
|
||||
function activeLocale(): Locale {
|
||||
if (typeof document === "undefined") return "en";
|
||||
// Prefer the cookie (what SSR used) over localStorage to stay in sync
|
||||
// across hydration. Falls back to localStorage for backwards compat.
|
||||
const cookieMatch = document.cookie.match(
|
||||
/(?:^|;\s*)healthlog-locale=([^;]+)/,
|
||||
);
|
||||
const fromCookie = cookieMatch?.[1];
|
||||
if (fromCookie === "de" || fromCookie === "en") return fromCookie;
|
||||
const fromStorage = window.localStorage?.getItem("healthlog-locale");
|
||||
return fromStorage === "de" ? "de" : "en";
|
||||
}
|
||||
|
||||
function formatters() {
|
||||
return makeFormatters(activeLocale());
|
||||
}
|
||||
|
||||
/** Locale-aware "19.02.2026, 14:30" or "02/19/2026, 2:30 PM". */
|
||||
export function formatDateTime(date: Date | string): string {
|
||||
return new Date(date).toLocaleString(LOCALE, {
|
||||
timeZone: TIMEZONE,
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
return formatters().dateTime(date);
|
||||
}
|
||||
|
||||
/** "19.02.2026" */
|
||||
/** Locale-aware "19.02.2026" or "02/19/2026". */
|
||||
export function formatDate(date: Date | string): string {
|
||||
return new Date(date).toLocaleDateString(LOCALE, {
|
||||
timeZone: TIMEZONE,
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
return formatters().date(date);
|
||||
}
|
||||
|
||||
/** "19.02." or "19.02.2026" if includeYear */
|
||||
/** Locale-aware short date without year unless `includeYear`. */
|
||||
export function formatDateShort(
|
||||
date: Date | string,
|
||||
includeYear = false,
|
||||
): string {
|
||||
return new Date(date).toLocaleDateString(LOCALE, {
|
||||
timeZone: TIMEZONE,
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
...(includeYear ? { year: "numeric" } : {}),
|
||||
});
|
||||
const f = formatters();
|
||||
return includeYear ? f.date(date) : f.dateShort(date);
|
||||
}
|
||||
|
||||
/** "14:30" */
|
||||
/** Locale-aware "14:30". */
|
||||
export function formatTime(date: Date | string): string {
|
||||
return new Date(date).toLocaleTimeString(LOCALE, {
|
||||
timeZone: TIMEZONE,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
return formatters().time(date);
|
||||
}
|
||||
|
||||
/** "Do., 19.02." */
|
||||
/** Locale-aware short weekday + date. */
|
||||
export function formatDateWithWeekday(date: Date | string): string {
|
||||
return new Date(date).toLocaleDateString(LOCALE, {
|
||||
timeZone: TIMEZONE,
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
});
|
||||
return formatters().dateWithWeekday(date);
|
||||
}
|
||||
@@ -4,11 +4,13 @@ import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
useCallback,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { locales, defaultLocale, type Locale } from "./config";
|
||||
import { makeFormatters, type Formatters } from "../format-locale";
|
||||
|
||||
import deMessages from "../../../messages/de.json";
|
||||
import enMessages from "../../../messages/en.json";
|
||||
@@ -53,8 +55,23 @@ function detectSystemLocale(): Locale {
|
||||
: defaultLocale;
|
||||
}
|
||||
|
||||
function readLocaleCookie(): Locale | null {
|
||||
if (typeof document === "undefined") return null;
|
||||
const match = document.cookie.match(/(?:^|;\s*)healthlog-locale=([^;]+)/);
|
||||
const value = match?.[1];
|
||||
if (value && (locales as readonly string[]).includes(value)) {
|
||||
return value as Locale;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getSavedLocale(): Locale | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
// Cookie wins over localStorage: the root layout already rendered the
|
||||
// server HTML using the cookie, so matching the cookie on first client
|
||||
// render avoids a hydration flash of the wrong language.
|
||||
const fromCookie = readLocaleCookie();
|
||||
if (fromCookie) return fromCookie;
|
||||
const saved = localStorage.getItem("healthlog-locale");
|
||||
if (saved && (locales as readonly string[]).includes(saved)) {
|
||||
return saved as Locale;
|
||||
@@ -71,13 +88,19 @@ export function I18nProvider({ children }: { children: ReactNode }) {
|
||||
if ((locales as readonly string[]).includes(newLocale)) {
|
||||
setLocaleState(newLocale);
|
||||
localStorage.setItem("healthlog-locale", newLocale);
|
||||
// Also mirror to cookie so SSR (layout, metadata) renders in the
|
||||
// user's language. 1-year expiry, Lax samesite, not HttpOnly so the
|
||||
// client continues to own it.
|
||||
document.cookie = `healthlog-locale=${newLocale}; path=/; max-age=${60 * 60 * 24 * 365}; SameSite=Lax`;
|
||||
document.documentElement.lang = newLocale;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Set HTML lang on mount
|
||||
// Keep the HTML lang and the cookie in sync with the active locale on
|
||||
// mount. The cookie acts as the SSR handoff for the next request.
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = locale;
|
||||
document.cookie = `healthlog-locale=${locale}; path=/; max-age=${60 * 60 * 24 * 365}; SameSite=Lax`;
|
||||
}, [locale]);
|
||||
|
||||
const t = useCallback(
|
||||
@@ -120,3 +143,13 @@ export function useTranslations() {
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locale-aware formatters tied to the active UI locale. Use for every number,
|
||||
* date, and time rendered in the UI so regional conventions (70,5 vs 70.5,
|
||||
* 19.02.2026 vs Feb 19, 2026) follow the user's language choice.
|
||||
*/
|
||||
export function useFormatters(): Formatters {
|
||||
const { locale } = useTranslations();
|
||||
return useMemo(() => makeFormatters(locale), [locale]);
|
||||
}
|
||||
@@ -46,7 +46,11 @@ import {
|
||||
function parseTimeToMinutes(value: string): number {
|
||||
const [h, m] = value.split(":").map(Number);
|
||||
if (!Number.isFinite(h) || !Number.isFinite(m)) return 0;
|
||||
return h * 60 + m;
|
||||
// Some Node ICU builds render midnight as "24:00" via toLocaleTimeString.
|
||||
// Normalize so comparisons against schedule windows that wrap midnight
|
||||
// don't produce a 1440-minute value.
|
||||
const hours = h === 24 ? 0 : h;
|
||||
return hours * 60 + m;
|
||||
}
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL!;
|
||||
@@ -259,7 +263,7 @@ async function handleReminderCheck(jobs: Job<ReminderCheckPayload>[]) {
|
||||
userTz,
|
||||
);
|
||||
|
||||
const currentTime = now.toLocaleTimeString("de-DE", {
|
||||
const currentTime = now.toLocaleTimeString("en-GB", {
|
||||
timeZone: userTz,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Map WebAuthn / SimpleWebAuthn errors to actionable i18n keys.
|
||||
*
|
||||
* The browser rejects passkey prompts for several reasons that feel
|
||||
* indistinguishable to the user. We preserve the real reason so
|
||||
* "registration cancelled" no longer masks "your origin isn't HTTPS" or
|
||||
* "this device is already registered".
|
||||
*/
|
||||
|
||||
export interface PasskeyErrorMessage {
|
||||
/** i18n key for a user-facing explanation. */
|
||||
key: string;
|
||||
/** Params for interpolation (e.g. {message} for the unknown-error case). */
|
||||
params?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function describePasskeyError(error: unknown): PasskeyErrorMessage {
|
||||
if (!(error instanceof Error)) {
|
||||
return { key: "settings.passkeyRegistrationFailed" };
|
||||
}
|
||||
|
||||
// SimpleWebAuthn wraps DOMException into WebAuthnError with a `code`
|
||||
// property, but the underlying DOMException name is the stable signal.
|
||||
switch (error.name) {
|
||||
case "NotAllowedError":
|
||||
// The user cancelled, or the OS-level prompt timed out. Prefer the
|
||||
// cancelled copy — it matches what the user did in the common case.
|
||||
return { key: "settings.passkeyRegistrationCancelled" };
|
||||
case "InvalidStateError":
|
||||
return { key: "settings.passkeyAlreadyRegistered" };
|
||||
case "NotSupportedError":
|
||||
return { key: "settings.passkeyNotSupported" };
|
||||
case "SecurityError":
|
||||
return { key: "settings.passkeySecurityBlocked" };
|
||||
case "AbortError":
|
||||
return { key: "settings.passkeyTimeout" };
|
||||
}
|
||||
|
||||
// SimpleWebAuthn WebAuthnError surfaces a `code`, e.g. "ERROR_INVALID_RP_ID"
|
||||
const code = (error as { code?: string }).code;
|
||||
if (code === "ERROR_INVALID_RP_ID" || code === "ERROR_INVALID_DOMAIN") {
|
||||
return { key: "settings.passkeySecurityBlocked" };
|
||||
}
|
||||
|
||||
return {
|
||||
key: "settings.passkeyUnknownError",
|
||||
params: { message: error.message || "unknown" },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Centralized TanStack Query key factory.
|
||||
*
|
||||
* Every useQuery/invalidateQueries call should go through this factory so that
|
||||
* mutations invalidate the right consumers. Hard-coded string arrays drifted in
|
||||
* the past (e.g. ["measurements"] didn't invalidate ["analytics"] on the
|
||||
* dashboard), so treat this file as the single source of truth.
|
||||
*/
|
||||
|
||||
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
||||
|
||||
export const queryKeys = {
|
||||
auth: () => ["auth"] as const,
|
||||
authRegistrationStatus: () => ["auth", "registration-status"] as const,
|
||||
|
||||
measurements: () => ["measurements"] as const,
|
||||
moodEntries: () => ["mood-entries"] as const,
|
||||
|
||||
analytics: () => ["analytics"] as const,
|
||||
moodAnalytics: () => ["mood-analytics"] as const,
|
||||
|
||||
insightsRoot: () => ["insights"] as const,
|
||||
insightsComprehensive: () => ["insights", "comprehensive"] as const,
|
||||
insightsTargets: () => ["insights", "targets"] as const,
|
||||
insightsGeneralStatus: (locale: string) =>
|
||||
["insights", "general-status", locale] as const,
|
||||
insightsBpStatus: (locale: string) =>
|
||||
["insights", "blood-pressure-status", locale] as const,
|
||||
insightsWeightStatus: (locale: string) =>
|
||||
["insights", "weight-status", locale] as const,
|
||||
insightsPulseStatus: (locale: string) =>
|
||||
["insights", "pulse-status", locale] as const,
|
||||
insightsBmiStatus: (locale: string) =>
|
||||
["insights", "bmi-status", locale] as const,
|
||||
insightsMoodStatus: (locale: string) =>
|
||||
["insights", "mood-status", locale] as const,
|
||||
insightsMedicationComplianceStatus: (locale: string) =>
|
||||
["insights", "medication-compliance-status", locale] as const,
|
||||
|
||||
medications: () => ["medications"] as const,
|
||||
medicationDetail: (id: string) => ["medications", id] as const,
|
||||
medicationComplianceChart: (medicationId: string) =>
|
||||
["compliance-chart-inline", medicationId] as const,
|
||||
medicationPhaseConfig: (medicationId: string) =>
|
||||
["phase-config", medicationId] as const,
|
||||
medicationIntakeSummary: () => ["medications", "intake-summary"] as const,
|
||||
|
||||
gamificationAchievements: () => ["gamification", "achievements"] as const,
|
||||
|
||||
passkeys: () => ["passkeys"] as const,
|
||||
|
||||
notificationsPreferences: () => ["notifications", "preferences"] as const,
|
||||
|
||||
settingsGlobalServices: () => ["settings", "global-services"] as const,
|
||||
settingsNtfy: () => ["settings", "ntfy"] as const,
|
||||
|
||||
tokens: () => ["tokens"] as const,
|
||||
telegram: () => ["telegram"] as const,
|
||||
telegramSettings: () => ["telegram", "settings"] as const,
|
||||
withings: () => ["withings"] as const,
|
||||
|
||||
adminSettings: () => ["admin", "settings"] as const,
|
||||
adminStatus: () => ["admin", "status"] as const,
|
||||
adminUsers: () => ["admin", "users"] as const,
|
||||
adminTokens: () => ["admin", "tokens"] as const,
|
||||
adminAuditLog: (filter: unknown) => ["admin", "audit-log", filter] as const,
|
||||
|
||||
bugreportStatus: () => ["bugreport", "status"] as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* Keys that should be invalidated when a measurement is created, updated or
|
||||
* deleted. Kept here so dashboards, insights, and targets always stay in sync.
|
||||
*/
|
||||
export const measurementDependentKeys = [
|
||||
queryKeys.measurements(),
|
||||
queryKeys.analytics(),
|
||||
queryKeys.insightsRoot(),
|
||||
queryKeys.insightsTargets(),
|
||||
queryKeys.gamificationAchievements(),
|
||||
];
|
||||
|
||||
/**
|
||||
* Keys that should be invalidated when a mood entry is created, updated or
|
||||
* deleted.
|
||||
*/
|
||||
export const moodDependentKeys = [
|
||||
queryKeys.moodEntries(),
|
||||
queryKeys.moodAnalytics(),
|
||||
queryKeys.insightsRoot(),
|
||||
queryKeys.insightsTargets(),
|
||||
queryKeys.gamificationAchievements(),
|
||||
];
|
||||
|
||||
/**
|
||||
* Keys invalidated when medications change (CRUD or intake).
|
||||
*/
|
||||
export const medicationDependentKeys = [
|
||||
queryKeys.medications(),
|
||||
queryKeys.analytics(),
|
||||
queryKeys.insightsRoot(),
|
||||
queryKeys.insightsTargets(),
|
||||
queryKeys.medicationIntakeSummary(),
|
||||
queryKeys.gamificationAchievements(),
|
||||
];
|
||||
|
||||
/**
|
||||
* Invalidate every key in the bundle in parallel. Use this from mutation
|
||||
* `onSuccess` handlers so the call site stays a one-liner instead of repeating
|
||||
* `Promise.all(keys.map(...))` everywhere.
|
||||
*
|
||||
* Uses `allSettled` so one transient network failure doesn't abort subsequent
|
||||
* invalidations (cache would otherwise be left half-stale) and so the `void
|
||||
* invalidateKeys(...)` fire-and-forget pattern in delete handlers never
|
||||
* surfaces an unhandled rejection.
|
||||
*/
|
||||
export function invalidateKeys(
|
||||
queryClient: QueryClient,
|
||||
keys: readonly QueryKey[],
|
||||
): Promise<PromiseSettledResult<unknown>[]> {
|
||||
return Promise.allSettled(
|
||||
keys.map((queryKey) => queryClient.invalidateQueries({ queryKey })),
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user