mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 20:32:02 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c584ead9c8 | ||
|
|
492748f105 | ||
|
|
0a77558fd0 | ||
|
|
79764d3467 | ||
|
|
0e510944ce | ||
|
|
05142841fa | ||
|
|
53235a0fb7 | ||
|
|
846eda80fa | ||
|
|
ef3e37b7da | ||
|
|
3de2e37ed5 | ||
|
|
908532be73 | ||
|
|
11ed6faedd | ||
|
|
59448d473c | ||
|
|
75d142df25 | ||
|
|
5e721e0791 | ||
|
|
be7df4bc17 | ||
|
|
85f9a07daf | ||
|
|
9d0983dc22 | ||
|
|
068c40270f | ||
|
|
8258f33d02 | ||
|
|
37af3e9e50 | ||
|
|
0081134cd7 | ||
|
|
50e23a38a1 | ||
|
|
6fab09041a | ||
|
|
bfb346db33 | ||
|
|
46aa01bb8a | ||
|
|
922648d375 | ||
|
|
4655a1b030 | ||
|
|
3cfde9fa7a | ||
|
|
d7cd2bc3e6 | ||
|
|
38bfb0520d | ||
|
|
588b7c66c1 | ||
|
|
6a159e7ac3 | ||
|
|
1c13a16d49 | ||
|
|
f4c983c8ba | ||
|
|
784b62b656 | ||
|
|
adcacfd1a4 | ||
|
|
fd6170618a | ||
|
|
2e8279a2fd | ||
|
|
8533755d8d | ||
|
|
9b59390224 | ||
|
|
cc273216be | ||
|
|
b15438db99 | ||
|
|
26697c20c3 | ||
|
|
448c37d232 | ||
|
|
a8478990f8 | ||
|
|
02e8553fb9 | ||
|
|
15e2862434 | ||
|
|
0e521b6fb9 | ||
|
|
533486d44e | ||
|
|
7b4ad10905 | ||
|
|
b3d5f43a1f | ||
|
|
7555fe5b35 | ||
|
|
75e3d686b5 |
No files matched your search
+92
-23
@@ -1,37 +1,106 @@
|
||||
# Database
|
||||
DATABASE_URL="postgresql://healthlog:healthlog@localhost:5432/healthlog?schema=public"
|
||||
# =============================================================================
|
||||
# HealthLog — Environment Configuration
|
||||
# =============================================================================
|
||||
# Copy this file to .env and fill in the values.
|
||||
# $ cp .env.example .env
|
||||
#
|
||||
# Required variables: DATABASE_URL or POSTGRES_PASSWORD, SESSION_SECRET,
|
||||
# ENCRYPTION_KEY, API_TOKEN_HMAC_KEY
|
||||
#
|
||||
# Generate the three secrets with:
|
||||
# $ openssl rand -hex 32 # any UNIX shell
|
||||
# $ node -e 'console.log(require("crypto").randomBytes(32).toString("hex"))'
|
||||
#
|
||||
# All secrets must be 64-character hex strings (= 32 bytes).
|
||||
# =============================================================================
|
||||
|
||||
# App
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Database
|
||||
# -----------------------------------------------------------------------------
|
||||
# When using docker-compose.yml: the app container reaches Postgres via the
|
||||
# service name "db", so the hostname is "db" — NOT "localhost".
|
||||
# When developing without Docker (`pnpm dev` against a host-installed Postgres):
|
||||
# change the hostname to "localhost" and adjust the credentials.
|
||||
DATABASE_URL="postgresql://healthlog:CHANGE-ME@db:5432/healthlog?schema=public"
|
||||
|
||||
# Postgres password — used both by the db service AND by DATABASE_URL above.
|
||||
# Pick a strong random password, e.g. `openssl rand -base64 24`.
|
||||
POSTGRES_PASSWORD="CHANGE-ME"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# App URL
|
||||
# -----------------------------------------------------------------------------
|
||||
# The public URL where users reach the app. Behind a reverse proxy with TLS,
|
||||
# this looks like https://healthlog.example.com. For local dev, leave default.
|
||||
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
||||
APP_URL="http://localhost:3000"
|
||||
|
||||
# Node environment: "development" for `pnpm dev`, "production" for Docker.
|
||||
NODE_ENV="development"
|
||||
|
||||
# Session
|
||||
SESSION_SECRET="change-me-to-a-random-64-char-hex-string"
|
||||
|
||||
# Encryption (for tokens, sensitive notes)
|
||||
ENCRYPTION_KEY="change-me-to-a-random-64-char-hex-string"
|
||||
# -----------------------------------------------------------------------------
|
||||
# Required Secrets — generate with `openssl rand -hex 32`
|
||||
# -----------------------------------------------------------------------------
|
||||
# Signs session cookies. Rotating it logs out everyone.
|
||||
SESSION_SECRET="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
|
||||
|
||||
# HMAC key for API token hashing
|
||||
API_TOKEN_HMAC_KEY="change-me-to-a-random-64-char-hex-string"
|
||||
# AES-256-GCM key for encrypting Withings tokens, GitHub PATs, AI provider
|
||||
# keys, and VAPID private keys at rest. Rotate carefully — old ciphertexts
|
||||
# need re-encryption.
|
||||
ENCRYPTION_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
|
||||
|
||||
# Withings OAuth (M5)
|
||||
# HMAC-SHA256 key for hashing external-API Bearer tokens before storing them
|
||||
# in the database. Rotating it invalidates all issued tokens.
|
||||
API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Withings OAuth (optional — for automatic scale/BP/activity sync)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Register an app at https://developer.withings.com/ and paste the credentials
|
||||
# here. Per-user OAuth credentials can also be configured in Settings.
|
||||
# WITHINGS_CLIENT_ID=""
|
||||
# WITHINGS_CLIENT_SECRET=""
|
||||
# WITHINGS_REDIRECT_URI=""
|
||||
# WITHINGS_WEBHOOK_SECRET="" # random secret for webhook callback URL hardening
|
||||
# WITHINGS_REDIRECT_URI="https://healthlog.example.com/api/withings/callback"
|
||||
# WITHINGS_WEBHOOK_SECRET="" # any random string — used to harden the webhook URL
|
||||
|
||||
# Telegram webhook hardening (optional but recommended)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Telegram (optional — webhook hardening)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Set to verify incoming Telegram updates. The bot token itself is configured
|
||||
# per-user in Settings (encrypted at rest).
|
||||
# TELEGRAM_WEBHOOK_SECRET=""
|
||||
|
||||
# OpenAI (optional, user-provided keys stored encrypted)
|
||||
# No server-side key needed - users bring their own
|
||||
|
||||
# Structured Logging (Wide Events)
|
||||
# LOG_LEVEL="info" # debug | info | warn | error
|
||||
# LOG_SAMPLE_RATE="1.0" # 0.0–1.0, fraction of successful requests to log
|
||||
# LOG_SLOW_THRESHOLD_MS="3000" # Requests slower than this are always logged
|
||||
# LOG_INCLUDE_STACK="true" # Include stack traces in error events
|
||||
# LOKI_ENDPOINT="" # Optional: Loki Push API (e.g. http://loki:3100)
|
||||
# LOKI_USERNAME="" # Optional: Loki basic auth username
|
||||
# LOKI_PASSWORD="" # Optional: Loki basic auth password
|
||||
# -----------------------------------------------------------------------------
|
||||
# AI Provider (optional)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Users bring their own API keys via Settings. No server-side key needed by
|
||||
# default. The admin can configure a fallback key in the Admin Panel.
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Structured Logging — Wide Events (optional, sensible defaults)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Levels: debug | info | warn | error
|
||||
# LOG_LEVEL="info"
|
||||
|
||||
# Sample rate for successful requests (0.0 = sample nothing, 1.0 = log all).
|
||||
# Errors and slow requests are ALWAYS logged regardless of this setting.
|
||||
# LOG_SAMPLE_RATE="1.0"
|
||||
|
||||
# Slow-request threshold in milliseconds — anything slower is always kept.
|
||||
# LOG_SLOW_THRESHOLD_MS="3000"
|
||||
|
||||
# Include stack traces on error events. Disable in environments where logs
|
||||
# leave the host unencrypted.
|
||||
# LOG_INCLUDE_STACK="true"
|
||||
|
||||
# Optional: ship Wide Events to Loki. Leave blank for stdout-only logging.
|
||||
# LOKI_ENDPOINT="http://loki:3100"
|
||||
# LOKI_USERNAME=""
|
||||
# LOKI_PASSWORD=""
|
||||
@@ -0,0 +1,78 @@
|
||||
name: Build & Publish Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
# Cancel in-progress runs when a new commit is pushed to the same ref.
|
||||
concurrency:
|
||||
group: docker-publish-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build & push multi-arch image
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU (multi-arch)
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# Skip login on PRs from forks — they don't have package:write.
|
||||
# Image is built but not pushed in that case.
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Compute image tags & labels
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=sha,prefix=sha-,format=short
|
||||
labels: |
|
||||
org.opencontainers.image.title=HealthLog
|
||||
org.opencontainers.image.description=Self-hosted health-tracking PWA
|
||||
org.opencontainers.image.licenses=AGPL-3.0-only
|
||||
org.opencontainers.image.source=https://github.com/${{ github.repository }}
|
||||
|
||||
- name: Build & push image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
provenance: true
|
||||
sbom: true
|
||||
@@ -30,11 +30,13 @@ jobs:
|
||||
|
||||
- name: ESLint
|
||||
run: pnpm lint
|
||||
# Non-blocking on the existing settings page monolith — its set-state-
|
||||
# in-effect violations need the 1.4.0 settings-split refactor to fix
|
||||
# cleanly. Will flip to blocking once settings/page.tsx is broken up.
|
||||
continue-on-error: true
|
||||
|
||||
- name: TypeScript strict check
|
||||
run: pnpm typecheck
|
||||
continue-on-error: true
|
||||
|
||||
- name: Unit tests
|
||||
run: pnpm test
|
||||
|
||||
@@ -6,7 +6,7 @@ Instructions for AI coding agents (OpenAI Codex, Claude Code, Cursor, etc.) work
|
||||
|
||||
**HealthLog** — a personal health-tracking web app (weight, blood pressure, pulse, mood, medication compliance) with Withings integration, moodLog.app sync, Dracula-themed UI, mobile-first PWA design.
|
||||
|
||||
**Status**: Feature-complete (v1.0.0). See GitHub Issues for open tasks.
|
||||
**Status**: v1.2.0 (Custom Thresholds, Blood Glucose, Dashboard Customization, Built-in Feedback, Multi-Provider AI). See GitHub Issues for open tasks.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
@@ -117,7 +117,7 @@ messages/
|
||||
├── de.json # German translations (primary UI language)
|
||||
└── en.json # English translations
|
||||
prisma/
|
||||
├── schema.prisma # Database schema (22 models)
|
||||
├── schema.prisma # Database schema (23 models)
|
||||
└── migrations/ # Migration files (0001–0018)
|
||||
prisma.config.ts # Prisma config (DB URL lives here, NOT in schema)
|
||||
public/
|
||||
@@ -207,7 +207,7 @@ These are hard-won lessons. Ignoring them will cause errors:
|
||||
|
||||
## Database Models (Prisma)
|
||||
|
||||
22 models: `User`, `Passkey`, `Session`, `AuthChallenge`, `Measurement`, `Medication`, `MedicationSchedule`, `MedicationIntakeEvent`, `ReminderPhaseConfig`, `TelegramReminderMessage`, `TelegramScheduledDeletion`, `ApiToken`, `WithingsConnection`, `MoodEntry`, `AppSettings`, `AuditLog`, `NotificationChannel`, `NotificationPreference`, `PushSubscription`, `DataBackup`, `UserAchievement`, `RateLimit`.
|
||||
23 models: `User`, `Passkey`, `Session`, `AuthChallenge`, `Measurement`, `Medication`, `MedicationSchedule`, `MedicationIntakeEvent`, `ReminderPhaseConfig`, `TelegramReminderMessage`, `TelegramScheduledDeletion`, `ApiToken`, `WithingsConnection`, `MoodEntry`, `AppSettings`, `Feedback`, `AuditLog`, `NotificationChannel`, `NotificationPreference`, `PushSubscription`, `DataBackup`, `UserAchievement`, `RateLimit`.
|
||||
|
||||
## When Making Changes
|
||||
|
||||
|
||||
+195
@@ -1,5 +1,200 @@
|
||||
# Changelog
|
||||
|
||||
## [1.3.2] — 2026-04-28
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Glucose tiles on the dashboard rendered the raw i18n key
|
||||
`targets.glucoseFasting` instead of the translated label** (closes
|
||||
#108). Both `messages/en.json` and `messages/de.json` had two
|
||||
top-level `targets` blocks; `JSON.parse` silently keeps the last
|
||||
occurrence, and that block was missing the four glucose labels. The
|
||||
duplicate is now collapsed into a single block. A duplicate
|
||||
`bugreport.bugTitlePlaceholder` shadowed inside `bugreport` was
|
||||
cleaned up too. Two further keys (`dashboard.sleep`,
|
||||
`dashboard.steps`) were missing from both locales and were falling
|
||||
back to hard-coded English; both are now translated.
|
||||
- **New i18n locale-integrity test**
|
||||
(`src/lib/__tests__/i18n-locale-integrity.test.ts`) fails the build
|
||||
on duplicate keys at any nesting depth and on key drift between
|
||||
`en` and `de` — closes the structural gap that let the duplicate
|
||||
`targets` block ship in the first place.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Screenshot upload removed from the bug-report form** (also part
|
||||
of #108). The form previously accepted an image attachment that
|
||||
was stored in the local DB but never reached the published GitHub
|
||||
issue — GitHub does not accept inline base64 data URIs in issue
|
||||
bodies and offers no public API to attach images to an issue
|
||||
programmatically. Rather than ship misleading
|
||||
"a screenshot was attached" placeholder text in the resulting
|
||||
issue, the upload UI is now gone and the placeholder note is no
|
||||
longer added when promoting feedback. The `screenshotBase64`
|
||||
column and the admin-side preview of previously-submitted
|
||||
screenshots are unchanged — existing reports keep their
|
||||
attachments locally. We plan to revisit a real screenshot pipeline
|
||||
in a future release.
|
||||
|
||||
## [1.3.1] — 2026-04-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Compose env-var validation no longer breaks Coolify-style deploys.**
|
||||
`docker-compose.yml` previously used `${VAR:?required}` shell-parameter
|
||||
syntax for the four secrets and `POSTGRES_PASSWORD`. Some hosting
|
||||
platforms (Coolify in particular) parse compose files eagerly and
|
||||
store the _fallback error string_ (`"POSTGRES_PASSWORD is required"`)
|
||||
as the literal env-var value when `POSTGRES_PASSWORD` was unset,
|
||||
which then collided with `DATABASE_URL` and broke the running app
|
||||
with `P1000: Authentication failed`. Compose now uses plain `${VAR}`
|
||||
interpolation; validation moved into `docker-entrypoint.sh`, which
|
||||
fails fast with a clear stderr message listing the unset variables.
|
||||
|
||||
### Notes
|
||||
|
||||
If you upgraded an existing Compose stack from 1.2.x → 1.3.0 and hit
|
||||
the `POSTGRES_PASSWORD is required` literal-as-value bug, set
|
||||
`POSTGRES_PASSWORD` in your environment to whatever your existing
|
||||
Postgres data volume was originally initialised with (likely
|
||||
`healthlog` if you started from a pre-1.2.1 release), then redeploy.
|
||||
Postgres only honours `POSTGRES_PASSWORD` on first volume init — the
|
||||
existing user keeps the original password regardless of env changes.
|
||||
|
||||
## [1.3.0] — 2026-04-27
|
||||
|
||||
### Added — Body composition + targeted hardening
|
||||
|
||||
- **Total body water and bone mass as measurement types** (closes #89). New
|
||||
enum values `TOTAL_BODY_WATER` and `BONE_MASS`, both stored canonically
|
||||
in kilograms (matches Withings hydration/bone-mass measures and Health
|
||||
Connect's `TotalBodyWaterRecord` / `BoneMassRecord`). Migration is
|
||||
purely additive (`ALTER TYPE ... ADD VALUE`) — safe to apply against
|
||||
any 1.2.x database without downtime.
|
||||
- **Withings sync picks both up automatically.** The Withings client now
|
||||
maps measure type `77` (hydration / water mass) and `88` (bone mass).
|
||||
Anyone with a Withings Body+ scale and an active connection will see
|
||||
the new metrics flowing in on the next sync without any extra config.
|
||||
- **Doctor-report PDF includes both new types** in the vital-signs table
|
||||
when data exists, with locale-aware labels in English and German.
|
||||
- **Dashboard widgets registered for both** (default-invisible — opt in
|
||||
via Settings → Dashboard layout).
|
||||
|
||||
### Security
|
||||
|
||||
- **SSRF guard hardened** (`isPublicUrl`). The previous implementation
|
||||
used `parseInt` with permissive prefix checks like `h.startsWith("10.")`
|
||||
which let `010.0.0.1` slip through — and worse, the WHATWG URL parser
|
||||
silently normalises `010.0.0.1` to `8.0.0.1` (octal interpretation), a
|
||||
real bypass on naive checks. The new guard adds a pre-URL leading-zero
|
||||
check on the raw input, a strict IPv4 parser, and proper IPv6
|
||||
bracket / loopback / link-local handling. Now blocks `127.0.0.0/8`,
|
||||
`0.0.0.0/8`, `10.0.0.0/8`, `169.254.0.0/16`, `172.16.0.0/12`,
|
||||
`192.168.0.0/16`, `100.64.0.0/10` (CGNAT), `::1`, `fe80::/10`,
|
||||
`fc00::/7`. Comprehensive regression tests included.
|
||||
- **GitHub PAT redacted from logged error bodies.** When a feedback
|
||||
escalation to GitHub fails, the response body was being passed
|
||||
verbatim into `getEvent()?.addWarning(...)` — flowing to Loki. The
|
||||
body is now stripped of the configured token before logging.
|
||||
- **Per-user threshold writes are rate-limited** (30 writes / 5 min) to
|
||||
make audit-log enumeration unattractive. Audit-logging itself was
|
||||
already there.
|
||||
|
||||
### Performance
|
||||
|
||||
- **N+1 in `/api/insights/comprehensive` fixed.** The medication-compliance
|
||||
loop hit Postgres once per active medication. It now batches into a
|
||||
single `medicationId IN (...)` query and groups in memory. Latency
|
||||
improvement scales linearly with the user's active medication count.
|
||||
|
||||
### Reliability
|
||||
|
||||
- **pg-boss draining on SIGTERM/SIGINT.** `docker stop`, Coolify redeploys,
|
||||
and Kubernetes pod terminations now trigger `boss.stop({ graceful: true,
|
||||
timeout: 30s })` so in-flight handlers finish instead of being killed.
|
||||
Previously, pending handlers could be lost or replayed on restart.
|
||||
- **CI now blocks on TypeScript strict checks.** `continue-on-error: true`
|
||||
removed for typecheck. Tests already were blocking. ESLint stays
|
||||
non-blocking for now — the existing `settings/page.tsx` monolith has
|
||||
long-standing `react-hooks/set-state-in-effect` violations whose proper
|
||||
fix lives in the 1.4.0 settings-split refactor; a blocking lint here
|
||||
would just gate every PR until that refactor lands. Required cleaning
|
||||
up two `any` types in `api-handler.ts` (justified inline — Next.js
|
||||
variadic handler signature constrained by `Promise<Response>` return).
|
||||
|
||||
### Polish
|
||||
|
||||
- **Bottom-nav touch targets** sized to WCAG 2.5.5 minimum (44×44 CSS px).
|
||||
Visual icon stays at 20 px so the design doesn't shift.
|
||||
- **Phase-config dialog** marks the decorative coloured dot `aria-hidden`
|
||||
because the redundant text label already conveys the phase to screen
|
||||
readers. No more meaningless `image` node announcements.
|
||||
- **Admin-status labels** for "Web Push" and "Bug Report" now go through
|
||||
`t()` (new `admin.integrationWebPush` / `admin.integrationBugReport`
|
||||
keys in en + de). They were the last hard-coded English strings on
|
||||
the admin page.
|
||||
|
||||
### What's _not_ in this release (tracked for later)
|
||||
|
||||
- **Onboarding redesign** (dashboard-first empty-state flow + persistent
|
||||
Getting Started checklist) and the **typed `apiClient` wrapper** that
|
||||
underpins it are tracked for a focused 1.4.0 cycle. The 1.2.1 patch
|
||||
already closed the acute symptoms of #87 (silent-failure toast +
|
||||
default schedule), so the redesign is now a proper UI investment
|
||||
rather than a bug-fix.
|
||||
- **Withings sync is mapping both new measures**, but **a dedicated
|
||||
Bearer-auth ingest endpoint for external pipelines** (n8n + Health
|
||||
Connect, requested in #89) ships in 1.4.0 alongside the API-token
|
||||
flow.
|
||||
|
||||
## [1.2.1] — 2026-04-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Onboarding**: Medications added during onboarding are now actually persisted (closes #87). The wizard previously sent an empty `schedules: []` array, the server-side validation rejected it with a 422, the client never checked `response.ok`, and the user was redirected to the dashboard as if everything had worked. Onboarding now wraps each step in `try/catch`, surfaces failures via toast, and attaches a default reminder window (`08:00–09:00 daily`) so the medication actually persists. A hint under the medication list explains the default.
|
||||
- **Docker setup** (closes #88):
|
||||
- `docker-compose.yml` now uses `ports: "3000:3000"` (was `expose: "3000"`, which made the app unreachable from the host).
|
||||
- `POSTGRES_PASSWORD` is a single env var that both the Postgres service and `DATABASE_URL` interpolate, so they cannot drift apart.
|
||||
- `.env.example` now points at the in-container hostname `db:5432` (was `localhost:5432`, which never resolves inside the app container).
|
||||
- **Documentation**:
|
||||
- `package.json` synced to 1.2.0 (was lagging on 1.1.0).
|
||||
- `CLAUDE.md` and `AGENTS.md` corrected to 23 models (the `Feedback` model added in v1.2 was missing from the count).
|
||||
- `README.md` Quick Start gives a realistic time estimate, generates the four secrets in one block straight into `.env`, and points reverse-proxy users at the docs.
|
||||
|
||||
### Added — Tooling & Supply Chain
|
||||
|
||||
- **Pre-built multi-arch images on GHCR**: `.github/workflows/docker-publish.yml` now builds `linux/amd64` + `linux/arm64` images on every push to `main` and on every `v*` tag, publishing to `ghcr.io/mbombeck/healthlog`. Self-hosters no longer need a build toolchain — `docker compose pull && docker compose up -d` is enough. The bundled `docker-compose.yml` references the published image with a `build:` block as fallback for contributors.
|
||||
- **Supply-chain attestations**: each published image carries a SLSA build provenance statement and a Software Bill of Materials. `SECURITY.md` documents how to verify them and how to pin a specific version.
|
||||
- **Documentation single source of truth**: `getting-started/installation.mdx` is now the canonical setup guide (mirrors the bundled `docker-compose.yml`); `self-hosting/docker.mdx` slimmed to image internals + ops notes only. The landing page's Quick Start terminal block now includes the secrets-generation step (was missing).
|
||||
|
||||
### Notes
|
||||
|
||||
This is a patch release that closes the install/onboarding friction reported in #87 and #88. The bigger user-facing changes (additional measurement types like total body water and bone mass per #89, full onboarding redesign, typed API client) are tracked for `1.3.0`.
|
||||
|
||||
## [1.2.0] — 2026-04-18
|
||||
|
||||
### Added — Personalization, Glucose & Multi-Provider AI
|
||||
|
||||
- **Per-user custom thresholds**: Override the computed default ranges (BP, BMI, glucose, pulse) with values from your clinician. Audit-logged with previous/new values and timestamps. Doctor Report PDF flags custom ranges and prints both your target and the standard guideline value.
|
||||
- **Blood glucose tracking**: New metric with `fasting`, `postprandial`, `random`, and `bedtime` contexts. Display unit switch between mg/dL and mmol/L (lossless conversion). Context-aware classification per ADA 2024 / DGIM. Per-context charting on dashboard and Doctor Report PDF.
|
||||
- **Dashboard customization**: Show/hide and drag-to-reorder every dashboard widget. Per-user preference, reset-to-defaults button. Layout persists across the same user on the same device.
|
||||
- **Built-in feedback system**: New in-app Send Feedback flow (Bug / Feature / Question / Other) with anonymized system info attachment. Stored in HealthLog's own database — no GitHub config required. Optional `Escalate to GitHub` button for admins who configure a PAT.
|
||||
- **Multi-provider AI insights**: Provider abstraction extended with **Anthropic Claude** and **local OpenAI-compatible endpoints** (Ollama, LM Studio, vLLM, LiteLLM) alongside OpenAI. Per-user provider selection. Local endpoints keep all health data on your network.
|
||||
- **Locale-aware UI polish (English-first)**: Numbers, dates, glucose units, BP, weight, and BMI all formatted via `useFormatters()` from the active locale. Doctor Report PDF and AI insight prompts now respect locale end-to-end (no hand-rolled `Intl.*` with fixed locales).
|
||||
|
||||
### Changed
|
||||
|
||||
- Reference range computation extracted into a dedicated `src/lib/health/thresholds.ts` module with computed defaults and override resolution.
|
||||
- AI provider routing reworked to dispatch by `provider` field on the user record; OpenAI remains the default for legacy users.
|
||||
- Dashboard route renders widgets from `UserDashboardLayout` model when present, otherwise falls back to the default order.
|
||||
- Doctor Report PDF: locale-aware headers, glucose section, custom-range badges.
|
||||
|
||||
### Security
|
||||
|
||||
- GitHub PAT for feedback escalation stored AES-256-GCM encrypted in the database (never as env var).
|
||||
- Local AI endpoint URLs validated against SSRF (no localhost/RFC1918 unless explicitly allowed by admin).
|
||||
- Custom threshold writes rate-limited and audit-logged with IP.
|
||||
|
||||
## [1.1.0] — 2026-04-06
|
||||
|
||||
### Added — AI Insights Overhaul
|
||||
|
||||
@@ -36,7 +36,7 @@ docker compose logs -f app # Tail app logs
|
||||
## Architecture
|
||||
|
||||
- **Next.js 16** App Router with TypeScript strict. Pages are RSC by default; `"use client"` only for interactivity.
|
||||
- **Prisma 7** ORM with PostgreSQL (22 models). Uses `PrismaPg` adapter from `@prisma/adapter-pg`. Client singleton at `src/lib/db.ts`. Generated client at `src/generated/prisma/client` (note the `/client` suffix). Prisma config in `prisma.config.ts` (not in schema.prisma).
|
||||
- **Prisma 7** ORM with PostgreSQL (23 models). Uses `PrismaPg` adapter from `@prisma/adapter-pg`. Client singleton at `src/lib/db.ts`. Generated client at `src/generated/prisma/client` (note the `/client` suffix). Prisma config in `prisma.config.ts` (not in schema.prisma).
|
||||
- **shadcn/ui** components (new-york style) in `src/components/ui/`. Add new ones via `pnpm dlx shadcn@latest add <component>`.
|
||||
- **Dracula theme** via CSS variables in `globals.css`. Dark mode is default. Use `--dracula-*` tokens for chart colors.
|
||||
- **TanStack Query** for client-side data fetching. Provider in `src/components/providers.tsx`.
|
||||
@@ -81,7 +81,7 @@ docker compose logs -f app # Tail app logs
|
||||
- `src/lib/validations/` — Zod schemas shared between API + client
|
||||
- `src/hooks/` — React hooks (`use-auth`)
|
||||
- `messages/de.json` + `messages/en.json` — i18n translations
|
||||
- `prisma/schema.prisma` — database schema (21 models)
|
||||
- `prisma/schema.prisma` — database schema (23 models)
|
||||
- `prisma.config.ts` — Prisma config (DB URL here, not in schema)
|
||||
- `public/sw.js` — Service worker for Web Push notifications + offline caching
|
||||
- `docs/` — architecture, security, assumptions, ops, API docs, ADRs
|
||||
@@ -96,7 +96,7 @@ docker compose logs -f app # Tail app logs
|
||||
- SimpleWebAuthn v13 has no `@simplewebauthn/server/script/deps` — define Transport type inline
|
||||
- `zxcvbn-typescript` uses default export only (no named `zxcvbnAsync`/`zxcvbnOptions`)
|
||||
- pg-boss v12: `{ PgBoss }` named import, `localConcurrency` instead of `teamSize`, handler receives `Job<T>[]` array
|
||||
- **i18n**: All UI text uses `useTranslations()` hook with `t("section.key")`. Messages in `messages/de.json` (German, primary) + `messages/en.json`
|
||||
- **i18n**: All UI text uses `useTranslations()` hook with `t("section.key")`. Messages in `messages/en.json` (English, default) + `messages/de.json` (German, user-selectable). Keys missing in the active locale fall back to English; missing from both surfaces the raw key — the `src/lib/__tests__/i18n*.test.ts` suite guards against that. Numbers/dates go through `useFormatters()` — never hand-roll `Intl.*` with a fixed locale.
|
||||
- **Notifications**: 3 channels (Telegram, ntfy, Web Push). Dispatcher at `src/lib/notifications/dispatcher.ts`. Opt-out model (all events enabled by default)
|
||||
- **Gravatar**: Server-side SHA-256 hash via `src/lib/gravatar.ts`. URL returned in `/api/auth/me` response as `gravatarUrl` field
|
||||
- **Sidebar**: Collapsible (localStorage-persisted). Settings mode with section shortcuts. User section at bottom with three-dot dropdown menu
|
||||
|
||||
@@ -37,7 +37,11 @@ Most health apps lock your data behind proprietary clouds, push subscriptions, a
|
||||
|
||||
## Key Features
|
||||
|
||||
**Health Metrics** -- Track weight, blood pressure, pulse, body fat, sleep, and steps with interactive trend charts, moving averages, and traffic-light ranges based on ESC/ESH 2018 guidelines.
|
||||
**Health Metrics** -- Track weight, blood pressure, pulse, body fat, sleep, steps, blood glucose (fasting/postprandial/random/bedtime, mg/dL ↔ mmol/L), total body water, and bone mass with interactive trend charts, moving averages, and traffic-light ranges based on ESC/ESH 2018 and ADA 2024 guidelines. Body-composition metrics sync automatically from Withings Body+ scales.
|
||||
|
||||
**Custom Thresholds** -- Override the computed default ranges per metric with the targets your clinician set. Audit-logged. Doctor Report PDF prints both your target and the standard reference.
|
||||
|
||||
**Customizable Dashboard** -- Show, hide, and drag-to-reorder every widget. Per-user layout with reset-to-defaults.
|
||||
|
||||
**Mood Logging** -- 5-point scale with tags, notes, and trend analytics. Syncs automatically from moodLog.app via webhook.
|
||||
|
||||
@@ -45,9 +49,11 @@ Most health apps lock your data behind proprietary clouds, push subscriptions, a
|
||||
|
||||
**Withings Integration** -- OAuth2 device sync for scales, blood pressure monitors, and activity trackers with automatic deduplication.
|
||||
|
||||
**AI-Powered Insights** -- Bring your own OpenAI API key. Personalized analysis across blood pressure, weight, pulse, mood, BMI, and medication adherence. Cached daily to minimize API usage.
|
||||
**Multi-Provider AI Insights** -- Pick OpenAI, Anthropic Claude, or any local OpenAI-compatible endpoint (Ollama, LM Studio, vLLM). BYOK or admin-shared key. Cached daily. Local endpoints keep all data on your network.
|
||||
|
||||
**Doctor Report PDF Export** -- Generate professional medical reports client-side. European format with vital sign summaries, BP/BMI classification, compliance rates, and optional AI analysis.
|
||||
**Doctor Report PDF Export** -- Generate professional medical reports client-side. Locale-aware (English/German), with vital sign summaries, BP/BMI/glucose classification, compliance rates, custom-threshold badges, and optional AI analysis.
|
||||
|
||||
**Built-in Feedback** -- Send bug reports and feature requests from inside the app. Stored in your HealthLog database — no GitHub config required. Optional GitHub escalation for admins.
|
||||
|
||||
**PWA with Offline Support** -- Installable on iOS and Android. Service worker with intelligent caching strategies for reliable offline access.
|
||||
|
||||
@@ -55,27 +61,30 @@ Most health apps lock your data behind proprietary clouds, push subscriptions, a
|
||||
|
||||
**Gamification** -- 30+ persistent achievements across intake streaks, compliance milestones, and healthy metric streaks.
|
||||
|
||||
**Internationalization** -- Full German and English UI with 1000+ translation keys. Browser-based locale detection with per-user override.
|
||||
**Internationalization** -- English (default) and German UI with 1000+ translation keys. Numbers, dates, units, and AI prompts all locale-aware via `useFormatters()`. Browser-based detection with per-user override.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
Plan ~5 minutes for a working install. The bundled `docker-compose.yml` pulls a pre-built multi-arch image (`linux/amd64` + `linux/arm64`) from [GitHub Container Registry](https://github.com/MBombeck/HealthLog/pkgs/container/healthlog); no build step required for self-hosters. Contributors who want to test local changes can `docker compose up --build`.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/MBombeck/HealthLog.git
|
||||
cd HealthLog
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Generate the required secrets:
|
||||
Generate the four required secrets and paste them into `.env`:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32 # SESSION_SECRET
|
||||
openssl rand -hex 32 # ENCRYPTION_KEY
|
||||
openssl rand -hex 32 # API_TOKEN_HMAC_KEY
|
||||
echo "POSTGRES_PASSWORD=$(openssl rand -base64 24)" >> .env
|
||||
echo "SESSION_SECRET=$(openssl rand -hex 32)" >> .env
|
||||
echo "ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env
|
||||
echo "API_TOKEN_HMAC_KEY=$(openssl rand -hex 32)" >> .env
|
||||
```
|
||||
|
||||
Add them to `.env`, then:
|
||||
Then bring the stack up:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
@@ -83,25 +92,27 @@ docker compose up -d
|
||||
|
||||
Open **http://localhost:3000**. The first registered user becomes admin.
|
||||
|
||||
> Behind a reverse proxy (Caddy / Traefik / Nginx) for TLS, set `NEXT_PUBLIC_APP_URL` and `APP_URL` to your public URL in `.env` before starting. See [Self-Hosting → Reverse Proxy](https://docs.healthlog.dev/self-hosting/reverse-proxy/) for examples.
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|-----------|
|
||||
| Framework | Next.js 16 (App Router, React Server Components) |
|
||||
| Language | TypeScript (strict mode) |
|
||||
| Database | PostgreSQL 16 + Prisma 7 (22 models) |
|
||||
| Job Queue | pg-boss 12 (reminders, insights, backups) |
|
||||
| UI | shadcn/ui, Tailwind CSS 4, Radix UI, Lucide Icons |
|
||||
| Charts | Recharts 3 |
|
||||
| Data Fetching | TanStack Query 5 |
|
||||
| Forms | React Hook Form 7 + Zod 4 |
|
||||
| Auth | SimpleWebAuthn 13, Argon2id |
|
||||
| Notifications | Telegram Bot API, ntfy, Web Push (VAPID) |
|
||||
| PDF | jsPDF (client-side generation) |
|
||||
| Testing | Vitest 4 |
|
||||
| Deployment | Docker (multi-stage Alpine) |
|
||||
| Layer | Technology |
|
||||
| ------------- | ------------------------------------------------- |
|
||||
| Framework | Next.js 16 (App Router, React Server Components) |
|
||||
| Language | TypeScript (strict mode) |
|
||||
| Database | PostgreSQL 16 + Prisma 7 (23 models) |
|
||||
| Job Queue | pg-boss 12 (reminders, insights, backups) |
|
||||
| UI | shadcn/ui, Tailwind CSS 4, Radix UI, Lucide Icons |
|
||||
| Charts | Recharts 3 |
|
||||
| Data Fetching | TanStack Query 5 |
|
||||
| Forms | React Hook Form 7 + Zod 4 |
|
||||
| Auth | SimpleWebAuthn 13, Argon2id |
|
||||
| Notifications | Telegram Bot API, ntfy, Web Push (VAPID) |
|
||||
| PDF | jsPDF (client-side generation) |
|
||||
| Testing | Vitest 4 |
|
||||
| Deployment | Docker (multi-stage Alpine) |
|
||||
|
||||
---
|
||||
|
||||
@@ -124,23 +135,24 @@ HealthLog is designed for people who take data ownership seriously.
|
||||
|
||||
### Required
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `DATABASE_URL` | PostgreSQL connection string |
|
||||
| `SESSION_SECRET` | 64-char hex string for session signing |
|
||||
| `ENCRYPTION_KEY` | 64-char hex string for AES-256-GCM |
|
||||
| `API_TOKEN_HMAC_KEY` | 64-char hex string for API token hashing |
|
||||
| Variable | Description |
|
||||
| -------------------- | ------------------------------------------------------------- |
|
||||
| `POSTGRES_PASSWORD` | Password for the bundled Postgres service (Docker Compose) |
|
||||
| `DATABASE_URL` | PostgreSQL connection string (uses `POSTGRES_PASSWORD` above) |
|
||||
| `SESSION_SECRET` | 64-char hex string for session signing |
|
||||
| `ENCRYPTION_KEY` | 64-char hex string for AES-256-GCM |
|
||||
| `API_TOKEN_HMAC_KEY` | 64-char hex string for API token hashing |
|
||||
|
||||
### Optional
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `NEXT_PUBLIC_APP_URL` | Public-facing URL (default: `http://localhost:3000`) |
|
||||
| `WITHINGS_CLIENT_ID` | Withings OAuth2 client ID |
|
||||
| `WITHINGS_CLIENT_SECRET` | Withings OAuth2 client secret |
|
||||
| `WITHINGS_REDIRECT_URI` | OAuth callback URL |
|
||||
| `WITHINGS_WEBHOOK_SECRET` | Webhook URL hardening secret |
|
||||
| `TELEGRAM_WEBHOOK_SECRET` | Telegram bot webhook secret |
|
||||
| Variable | Description |
|
||||
| ------------------------- | ---------------------------------------------------- |
|
||||
| `NEXT_PUBLIC_APP_URL` | Public-facing URL (default: `http://localhost:3000`) |
|
||||
| `WITHINGS_CLIENT_ID` | Withings OAuth2 client ID |
|
||||
| `WITHINGS_CLIENT_SECRET` | Withings OAuth2 client secret |
|
||||
| `WITHINGS_REDIRECT_URI` | OAuth callback URL |
|
||||
| `WITHINGS_WEBHOOK_SECRET` | Webhook URL hardening secret |
|
||||
| `TELEGRAM_WEBHOOK_SECRET` | Telegram bot webhook secret |
|
||||
|
||||
Telegram bot token, ntfy settings, Web Push VAPID keys, Umami, and GlitchTip URLs are configured in the **Admin Panel** and stored encrypted in the database.
|
||||
|
||||
@@ -199,60 +211,60 @@ All mutations require authentication via session cookie. External ingest uses Be
|
||||
<details>
|
||||
<summary><strong>Health Data</strong></summary>
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/measurements` | List measurements (paginated, filterable) |
|
||||
| `POST` | `/api/measurements` | Create measurement |
|
||||
| `DELETE` | `/api/measurements/:id` | Delete measurement |
|
||||
| `GET` | `/api/analytics` | Trend summaries (7d/30d) |
|
||||
| `GET` | `/api/export` | Export as CSV or JSON |
|
||||
| `POST` | `/api/import` | Import from JSON |
|
||||
| `POST` | `/api/doctor-report` | Aggregated data for PDF |
|
||||
| Method | Endpoint | Description |
|
||||
| -------- | ----------------------- | ----------------------------------------- |
|
||||
| `GET` | `/api/measurements` | List measurements (paginated, filterable) |
|
||||
| `POST` | `/api/measurements` | Create measurement |
|
||||
| `DELETE` | `/api/measurements/:id` | Delete measurement |
|
||||
| `GET` | `/api/analytics` | Trend summaries (7d/30d) |
|
||||
| `GET` | `/api/export` | Export as CSV or JSON |
|
||||
| `POST` | `/api/import` | Import from JSON |
|
||||
| `POST` | `/api/doctor-report` | Aggregated data for PDF |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Mood</strong></summary>
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/mood-entries` | List mood entries |
|
||||
| `POST` | `/api/mood-entries` | Create mood entry |
|
||||
| `DELETE` | `/api/mood-entries/:id` | Delete mood entry |
|
||||
| `GET` | `/api/mood/analytics` | Mood trend analytics |
|
||||
| `POST` | `/api/integrations/moodlog/webhook` | moodLog.app webhook |
|
||||
| Method | Endpoint | Description |
|
||||
| -------- | ----------------------------------- | -------------------- |
|
||||
| `GET` | `/api/mood-entries` | List mood entries |
|
||||
| `POST` | `/api/mood-entries` | Create mood entry |
|
||||
| `DELETE` | `/api/mood-entries/:id` | Delete mood entry |
|
||||
| `GET` | `/api/mood/analytics` | Mood trend analytics |
|
||||
| `POST` | `/api/integrations/moodlog/webhook` | moodLog.app webhook |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Medications</strong></summary>
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/medications` | List all medications |
|
||||
| `POST` | `/api/medications` | Create medication |
|
||||
| `PUT` | `/api/medications/:id` | Update medication |
|
||||
| `DELETE` | `/api/medications/:id` | Delete medication |
|
||||
| `POST` | `/api/medications/:id/intake` | Log intake event |
|
||||
| `GET` | `/api/medications/:id/compliance` | Compliance stats |
|
||||
| `POST` | `/api/ingest/medication` | External intake (Bearer) |
|
||||
| Method | Endpoint | Description |
|
||||
| -------- | --------------------------------- | ------------------------ |
|
||||
| `GET` | `/api/medications` | List all medications |
|
||||
| `POST` | `/api/medications` | Create medication |
|
||||
| `PUT` | `/api/medications/:id` | Update medication |
|
||||
| `DELETE` | `/api/medications/:id` | Delete medication |
|
||||
| `POST` | `/api/medications/:id/intake` | Log intake event |
|
||||
| `GET` | `/api/medications/:id/compliance` | Compliance stats |
|
||||
| `POST` | `/api/ingest/medication` | External intake (Bearer) |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Auth and Integrations</strong></summary>
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `POST` | `/api/auth/register` | Create account |
|
||||
| `POST` | `/api/auth/login` | Password login |
|
||||
| `POST` | `/api/auth/logout` | Destroy session |
|
||||
| `GET` | `/api/auth/me` | Current user profile |
|
||||
| `POST` | `/api/auth/passkey/*` | WebAuthn flows |
|
||||
| `GET` | `/api/withings/connect` | Initiate Withings OAuth |
|
||||
| `POST` | `/api/insights/generate` | Regenerate AI insights |
|
||||
| `GET` | `/api/gamification/achievements` | Achievement progress |
|
||||
| `GET` | `/api/health` | Docker health check |
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | -------------------------------- | ----------------------- |
|
||||
| `POST` | `/api/auth/register` | Create account |
|
||||
| `POST` | `/api/auth/login` | Password login |
|
||||
| `POST` | `/api/auth/logout` | Destroy session |
|
||||
| `GET` | `/api/auth/me` | Current user profile |
|
||||
| `POST` | `/api/auth/passkey/*` | WebAuthn flows |
|
||||
| `GET` | `/api/withings/connect` | Initiate Withings OAuth |
|
||||
| `POST` | `/api/insights/generate` | Regenerate AI insights |
|
||||
| `GET` | `/api/gamification/achievements` | Achievement progress |
|
||||
| `GET` | `/api/health` | Docker health check |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -260,16 +272,16 @@ All mutations require authentication via session cookie. External ingest uses Be
|
||||
|
||||
## Integrations
|
||||
|
||||
| Integration | Setup | Purpose |
|
||||
|-------------|-------|---------|
|
||||
| **Withings** | Env vars | Auto-sync weight, BP, and activity |
|
||||
| **Telegram** | Admin Panel | Medication reminders with inline buttons |
|
||||
| **ntfy** | User Settings | Self-hosted push notifications |
|
||||
| **Web Push** | Admin Panel | Browser-native VAPID notifications |
|
||||
| **OpenAI** | User Settings | AI health insights (BYOK) |
|
||||
| **moodLog.app** | User Settings | Mood tracking sync |
|
||||
| **Umami** | Admin Panel | Privacy-friendly analytics |
|
||||
| **GlitchTip** | Admin Panel | Sentry-compatible error tracking |
|
||||
| Integration | Setup | Purpose |
|
||||
| --------------- | ------------- | ---------------------------------------- |
|
||||
| **Withings** | Env vars | Auto-sync weight, BP, and activity |
|
||||
| **Telegram** | Admin Panel | Medication reminders with inline buttons |
|
||||
| **ntfy** | User Settings | Self-hosted push notifications |
|
||||
| **Web Push** | Admin Panel | Browser-native VAPID notifications |
|
||||
| **OpenAI** | User Settings | AI health insights (BYOK) |
|
||||
| **moodLog.app** | User Settings | Mood tracking sync |
|
||||
| **Umami** | Admin Panel | Privacy-friendly analytics |
|
||||
| **GlitchTip** | Admin Panel | Sentry-compatible error tracking |
|
||||
|
||||
---
|
||||
|
||||
@@ -318,7 +330,7 @@ Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines
|
||||
- Code style: `pnpm format && pnpm lint`
|
||||
- Type safety: `pnpm typecheck` must pass
|
||||
- Tests: `pnpm test`
|
||||
- UI language: German. Code, comments, and commits: English.
|
||||
- UI language: English by default, German selectable per user. Code, comments, and commits: English.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+15
-1
@@ -5,7 +5,7 @@
|
||||
Only the latest release on the `main` branch is actively supported with security updates.
|
||||
|
||||
| Version | Supported |
|
||||
|---------|-----------|
|
||||
| ------- | --------- |
|
||||
| Latest | Yes |
|
||||
| Older | No |
|
||||
|
||||
@@ -59,3 +59,17 @@ HealthLog is designed with security as a core principle:
|
||||
- **Proxy-level route protection** via `proxy.ts` (session cookie validation on all non-public paths)
|
||||
|
||||
For more details, see the [security documentation](https://docs.healthlog.dev/security/overview/).
|
||||
|
||||
## Supply Chain
|
||||
|
||||
HealthLog Docker images are built and published from this repository's CI:
|
||||
|
||||
- **Source**: [`.github/workflows/docker-publish.yml`](.github/workflows/docker-publish.yml) — runs on every push to `main` and every `v*` tag
|
||||
- **Registry**: [`ghcr.io/mbombeck/healthlog`](https://github.com/MBombeck/HealthLog/pkgs/container/healthlog)
|
||||
- **Architectures**: `linux/amd64` and `linux/arm64`
|
||||
- **Provenance attestation**: each image carries a [SLSA build provenance](https://slsa.dev/spec/v1.0/provenance) statement linking it back to the GitHub Actions run, the commit SHA, and the workflow definition
|
||||
- **SBOM**: each image includes a [Software Bill of Materials](https://docs.docker.com/build/metadata/attestations/sbom/) you can inspect with `docker buildx imagetools inspect ghcr.io/mbombeck/healthlog:latest --format '{{ json .SBOM }}'`
|
||||
|
||||
To pin to a specific version in production, replace `:latest` with the released tag in your `docker-compose.yml`, e.g. `ghcr.io/mbombeck/healthlog:1.2.0`. Pinning is recommended for self-hosters who want explicit control over upgrades.
|
||||
|
||||
If you discover a tampered or unexpected image, please report it as a vulnerability via the email above.
|
||||
+19
-7
@@ -1,17 +1,29 @@
|
||||
services:
|
||||
app:
|
||||
# Pre-built multi-arch images are published to GHCR by
|
||||
# .github/workflows/docker-publish.yml on every push to main and on
|
||||
# tagged releases. Pin to a specific version in production:
|
||||
# e.g. ghcr.io/mbombeck/healthlog:1.2.0
|
||||
image: ghcr.io/mbombeck/healthlog:latest
|
||||
# Fallback build context: if the image is not yet in the registry,
|
||||
# or you want to test local changes, run `docker compose up --build`.
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: healthlog-app
|
||||
expose:
|
||||
- "3000"
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
DATABASE_URL: "postgresql://healthlog:healthlog@db:5432/healthlog?schema=public"
|
||||
# Plain ${VAR} interpolation only — no ${VAR:?error} validation
|
||||
# syntax. Some hosting platforms (e.g. Coolify) parse compose files
|
||||
# eagerly and store the *fallback error string* as the literal value
|
||||
# when the env var is unset, which then breaks the deployment.
|
||||
# Validation is enforced by docker-entrypoint.sh before the app starts.
|
||||
DATABASE_URL: "postgresql://healthlog:${POSTGRES_PASSWORD}@db:5432/healthlog?schema=public"
|
||||
NODE_ENV: production
|
||||
SESSION_SECRET: "${SESSION_SECRET:?SESSION_SECRET is required}"
|
||||
ENCRYPTION_KEY: "${ENCRYPTION_KEY:?ENCRYPTION_KEY is required}"
|
||||
API_TOKEN_HMAC_KEY: "${API_TOKEN_HMAC_KEY:?API_TOKEN_HMAC_KEY is required}"
|
||||
SESSION_SECRET: "${SESSION_SECRET}"
|
||||
ENCRYPTION_KEY: "${ENCRYPTION_KEY}"
|
||||
API_TOKEN_HMAC_KEY: "${API_TOKEN_HMAC_KEY}"
|
||||
NEXT_PUBLIC_APP_URL: "${NEXT_PUBLIC_APP_URL:-http://localhost:3000}"
|
||||
APP_URL: "${APP_URL:-http://localhost:3000}"
|
||||
depends_on:
|
||||
@@ -24,7 +36,7 @@ services:
|
||||
container_name: healthlog-db
|
||||
environment:
|
||||
POSTGRES_USER: healthlog
|
||||
POSTGRES_PASSWORD: healthlog
|
||||
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
|
||||
POSTGRES_DB: healthlog
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
|
||||
+21
-1
@@ -1,6 +1,26 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# ── Validate required env vars ───────────────────────────────
|
||||
# We do this in the entrypoint instead of in docker-compose.yml's
|
||||
# environment block (which used to use the ${VAR:?error} syntax)
|
||||
# because some hosting platforms parse compose files eagerly and
|
||||
# end up storing the fallback error string as the literal value.
|
||||
# Validating here guarantees a clear, deploy-platform-agnostic
|
||||
# failure message before we touch the database.
|
||||
missing=""
|
||||
for var in DATABASE_URL SESSION_SECRET ENCRYPTION_KEY API_TOKEN_HMAC_KEY; do
|
||||
eval "value=\$$var"
|
||||
if [ -z "$value" ]; then
|
||||
missing="$missing $var"
|
||||
fi
|
||||
done
|
||||
if [ -n "$missing" ]; then
|
||||
echo "HealthLog: ERROR — required env vars not set:$missing" >&2
|
||||
echo "HealthLog: see .env.example for the full list and how to generate each value." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Wait for PostgreSQL ──────────────────────────────────────
|
||||
MAX_RETRIES=30
|
||||
RETRY=0
|
||||
@@ -15,7 +35,7 @@ until node -e "
|
||||
" 2>/dev/null; do
|
||||
RETRY=$((RETRY + 1))
|
||||
if [ "$RETRY" -ge "$MAX_RETRIES" ]; then
|
||||
echo "HealthLog: ERROR — Database not reachable after ${MAX_RETRIES}s, aborting."
|
||||
echo "HealthLog: ERROR — Database not reachable after ${MAX_RETRIES}s, aborting." >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
# HealthLog Audit-Plan — Phasen-Übersicht
|
||||
**Datum:** 2026-04-26
|
||||
**Audit-Bericht:** `~/infra/reports/healthlog-audit-2026-04-26.md`
|
||||
**Status:** Geplant, noch nicht in Execution
|
||||
|
||||
## Strategische Decisions
|
||||
|
||||
1. **Onboarding-Redesign:** 4-Step-Wizard → 2-Step + Empty-State-Dashboard + Getting-Started-Card.
|
||||
2. **Custom-Measurement-Types:** Pragmatic Hardcoding (jetzt #89), Cronometer-Pattern als v1.5+-Roadmap.
|
||||
3. **`apiClient`-Refactor vor Onboarding-Redesign**, damit neues Onboarding nicht denselben Silent-Failure-Bug reproduziert.
|
||||
|
||||
---
|
||||
|
||||
## Phasen-Sequenz (10 Phasen)
|
||||
|
||||
| # | Phase | Skill | Effort | Issue-Bezug |
|
||||
|---|---|---|---|---|
|
||||
| **1** | Setup-Notfall + Versions-Sync | `gsd:quick` | ≤4 h | #87 (Pflaster), #88 |
|
||||
| **2** | Docker-Image-Pipeline (GHCR-Push + Doc-Konsolidierung) | `gsd:plan-phase` | ~1 d | #88 |
|
||||
| **3** | Issue #89 — TBW + Bone Mass hardcoded | `gsd:plan-phase` | ~1 d | #89 |
|
||||
| **4** | `apiClient`-Wrapper + geteilte Zod-Schemas | `gsd:research-phase` → `gsd:plan-phase` | 2-3 d | #87 (strukturell) |
|
||||
| **3.5** | **Onboarding-Redesign** (2 Steps, TTFV <60s) | `gsd:research-phase` → `gsd:plan-phase` | ~2 d | #87 (strukturell) |
|
||||
| **4.5** | Empty-State-Dashboard-Tiles (eine pro Type) | `gsd:plan-phase` | ~1 d | #87, #89 |
|
||||
| **4.6** | „Getting Started"-Checklist auf Dashboard | `gsd:plan-phase` | ~4 h | #87 |
|
||||
| **5** | Security + Reliability Hardening | `gsd:plan-phase` | 1-2 d | — |
|
||||
| **6** | Test-Coverage-Boost (Auth/Passkey/Withings/pg-boss) | `gsd:plan-phase` | 1-2 d | — |
|
||||
| **7** | UX/i18n/a11y Polish (Residuals aus #73) | `gsd:quick` | ≤4 h | #73 (Residuals) |
|
||||
| **8** | Settings-Page-Splitting (3143 → Sub-Components) | `gsd:research-phase` → `gsd:plan-phase` | 2-3 d | — |
|
||||
|
||||
**Reihenfolge:** 1 → 2 → 3 → **4 (apiClient)** → **3.5 (Onboarding nutzt apiClient)** → 4.5 → 4.6 → 5 → 6 → 7 → 8.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Setup-Notfall (≤4 h)
|
||||
|
||||
**Goal:** Self-Hoster kann mit `git clone && docker compose up -d` tatsächlich starten + Issue #87 stoppt zu blocken.
|
||||
|
||||
- `docker-compose.yml`: `expose:3000` → `ports: "3000:3000"` (DX-002)
|
||||
- `docker-compose.yml`: `POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:?required}"` (SEC-003)
|
||||
- `.env.example`: `localhost:5432` → `db:5432` (DX-003)
|
||||
- `package.json`: `"version": "1.2.0"` (DOC-007)
|
||||
- `CLAUDE.md` + `AGENTS.md`: 23 Models statt 21/22 (DOC-001)
|
||||
- `src/app/onboarding/page.tsx`: `await fetch(...)` mit `.ok`-Check + `toast.error()` (DX-005 Pflaster)
|
||||
- README „minutes"-Claim entschärfen (DX-006)
|
||||
|
||||
**Verification:** Frischer Clone + neue VM → `cp .env.example .env && set secrets && docker compose up -d` → App auf `http://localhost:3000` erreichbar in <5 min.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Docker-Image-Pipeline (~1 d)
|
||||
|
||||
**Goal:** Issue #88 vollständig schließen. Pre-built Image auf GHCR. Docs konsolidiert.
|
||||
|
||||
- `.github/workflows/docker-publish.yml`: Multi-Arch (amd64+arm64), Trigger auf Tag-Push + main, GHCR-Push mit GITHUB_TOKEN
|
||||
- `healthlog-docs/getting-started/installation.mdx` → Verweis auf `self-hosting/docker.mdx` statt Duplikat (DX-004)
|
||||
- Repo-`docker-compose.yml` vs Docs-`docker-compose.yml`: eine Variante als Single-Source-of-Truth (Compose verwendet `image:` aus GHCR; Doc-Snippet wird daraus generiert)
|
||||
- `SECURITY.md` + Image-Pull-Instructions
|
||||
- `healthlog-landing` prüfen ob „Quick Start" aktualisiert werden muss
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Issue #89: TBW + Bone Mass (~1 d)
|
||||
|
||||
**Goal:** User-Anfrage von Manschk3rl beantworten. Blueprint = v1.2-Glucose-Migration.
|
||||
|
||||
**Touchpoints:**
|
||||
1. `prisma/schema.prisma`: `MeasurementType` Enum + 2 neue Werte
|
||||
2. `prisma/migrations/0019_*` (additive `ALTER TYPE ... ADD VALUE`)
|
||||
3. `src/lib/validations/measurement.ts`: `unitMap`, `VALUE_RANGES`
|
||||
4. `src/lib/format.ts` + i18n-Keys (4-6 neue Keys)
|
||||
5. `src/lib/doctor-report-pdf.ts`: `TYPE_LABEL_KEYS`, `TYPE_UNIT_KEYS`, `vitalTypes`
|
||||
6. `src/components/measurements/measurement-form.tsx`: `MEASUREMENT_TYPES` Array
|
||||
7. `src/lib/withings/client.ts` + `sync.ts`: Mapping aus Withings `body_composition`
|
||||
8. `src/app/api/dashboard/widgets/route.ts` + `DEFAULT_DASHBOARD_LAYOUT`: 2 neue Widget-IDs
|
||||
9. healthlog-docs `health-metrics.mdx` aktualisieren
|
||||
10. Optional: `/api/ingest/measurements` Bearer-Endpoint für n8n (analog `/api/ingest/medication`)
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — `apiClient`-Wrapper (2-3 d)
|
||||
|
||||
**Goal:** Silent-Failure-Pattern strukturell eliminieren. Voraussetzung für 3.5.
|
||||
|
||||
- `src/lib/api-client.ts`: `apiClient.{get,post,put,delete}<T>(path, body?, schema?) → Result<T, ApiError>`
|
||||
- Auto-`.ok`-Check + Toast bei Fehler (mit i18n-Mapping)
|
||||
- Geteilte Zod-Schemas: Validations-Module in `src/lib/validations/` werden client-importable
|
||||
- Migration: alle ~50 `fetch(...)`-Aufrufe in `src/app/` und `src/components/` → `apiClient.*`
|
||||
- Tests für `apiClient` (Mock-Fetch, Error-Mapping, Schema-Validation)
|
||||
|
||||
---
|
||||
|
||||
## Phase 3.5 — Onboarding-Redesign (~2 d)
|
||||
|
||||
**Goal:** TTFV <60s, 90% Completion (statt aktuell ~8% global).
|
||||
|
||||
**Neuer Flow:**
|
||||
- Step 1: Welcome + Anzeigename (optional) + Einheiten + Größe (optional). Sekundärlink „Skip everything → Dashboard".
|
||||
- Step 2: Multi-Select-Chips „Was willst du tracken?" (Weight/BP/Pulse/Mood/Glucose/Sleep/Steps/BodyFat/TBW/BoneMass/Medications). Pre-checked Defaults. Speichert in `User.dashboardWidgetsJson`.
|
||||
- Routing nach Onboarding: direkt zum Dashboard mit „Getting Started"-Card (s. Phase 4.6).
|
||||
|
||||
**Entfällt ersatzlos:** Targets-Step, Notifications-Teaser-Step, Front-loaded-Med-Wizard.
|
||||
|
||||
**Permissions deferred:** Notifications nur bei „Add medication → Reminder ein", PWA-Install nur bei Day-2-Return-Visit oder nach 3. Eintrag.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4.5 — Empty-State-Dashboard-Tiles (~1 d)
|
||||
|
||||
**Goal:** Mealie/Tandoor/Linear-Pattern. Jede Metric-Tile hat Empty-State mit „Add your first X"-CTA.
|
||||
|
||||
- `src/components/dashboard/EmptyStateTile.tsx` als wiederverwendbare Komponente
|
||||
- Pro `MeasurementType`: eigene Empty-State-Card (Icon + Headline + CTA-Button → opens MeasurementForm prefilled)
|
||||
- `Medications` analog: Empty-State „Add your first medication" → opens MedicationForm mit Default-Schedule (08:00–09:00 daily)
|
||||
- Dashboard-Routing: User mit `dashboardWidgetsJson` ohne Daten sieht Empty-States, nicht leeres Grid
|
||||
|
||||
---
|
||||
|
||||
## Phase 4.6 — „Getting Started"-Checklist (~4 h)
|
||||
|
||||
**Goal:** Linear-Pattern. Persistent dismissible Card auf Dashboard.
|
||||
|
||||
- `User.gettingStartedDismissedAt: DateTime?` (nullable, NULL = noch sichtbar)
|
||||
- 4 Items mit Live-Status:
|
||||
- ☐ Log your first measurement (✓ wenn ≥1 Measurement existiert)
|
||||
- ☐ Add a medication (✓ wenn ≥1 Medication existiert)
|
||||
- ☐ Set a target value (✓ wenn `thresholdsJson` non-empty)
|
||||
- ☐ Install HealthLog as App (✓ wenn `beforeinstallprompt` accepted, sonst nur sichtbar bei PWA-fähigen Browsern)
|
||||
- „Dismiss"-Button → setzt Timestamp, Card verschwindet
|
||||
- Vollständig durchgeklickt → Card verschwindet automatisch
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Security + Reliability Hardening (1-2 d)
|
||||
|
||||
- SEC-002: SSRF-IPv4-Parser-Härtung (führende Nullen)
|
||||
- SEC-004: GitHub-PAT-Logging-Sanitization (Sensitive-Field-Filter)
|
||||
- SEC-005: AES-256-GCM Key-Rotation-Doc + optional `keyVersion`
|
||||
- SEC-006: Custom-Thresholds-Rate-Limit
|
||||
- PERF-001: N+1 in `insights/comprehensive/route.ts:219` per `groupBy` auflösen
|
||||
- REL-002: Onboarding-Error-Handling über Pflaster hinaus (via apiClient aus Phase 4)
|
||||
- REL-003: pg-boss `process.on('SIGTERM', ...)` für graceful shutdown
|
||||
- LOG-001: 6× `console.log()` in `src/lib/jobs/reminder-worker.ts` → Wide-Event-Annotations
|
||||
- CI: ESLint+Typecheck `continue-on-error: true` → blocking
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Test-Coverage-Boost (1-2 d)
|
||||
|
||||
Coverage 35% → 60% auf kritischen Pfaden.
|
||||
|
||||
**Top-5-Test-Suites:**
|
||||
1. Session-Validation Roundtrip (`src/lib/auth/session.ts`)
|
||||
2. Passkey-Register/Verify-Integration (Attestation-Flow)
|
||||
3. Notification-Dispatcher Multi-Channel (TELEGRAM/NTFY/WEB_PUSH)
|
||||
4. pg-boss Reminder-Worker (Queue-Init, Job-Execution, Cron, Retry)
|
||||
5. Withings-OAuth + Sync (`exchangeCode` → `refresh` → `fetchMeasurements`)
|
||||
|
||||
**Plus:** TEST-006 Custom-Thresholds-Resolution, TEST-007 AI-Provider-SSRF.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — UX/i18n/a11y Polish (≤4 h)
|
||||
|
||||
**Residuals aus Issue #73 + neue Findings:**
|
||||
- I18N-002: Admin-Page Hardcoded „Web Push" / „Bug Report" → `t(...)`
|
||||
- I18N-003: `medication-card.tsx` `Intl.DateTimeFormat("en-CA")` und `toLocaleString("en-US")` → `useFormatters()`
|
||||
- A11Y-001: `phase-config-dialog.tsx` Text-Indicator neben Farb-Punkt (WCAG 1.4.1)
|
||||
- A11Y-002: Admin StatusItem `aria-label` verifizieren
|
||||
- UX-002: Admin-Save-Error-UX (Field-Highlight bei Fehler)
|
||||
- UX-003: Bottom-Nav-Touch-Targets `p-2.5` → `p-3` (≥44×44 px)
|
||||
|
||||
---
|
||||
|
||||
## Phase 8 — Settings-Page-Splitting (2-3 d)
|
||||
|
||||
**Goal:** 3143 Zeilen → Sub-Components mit klarer Boundary.
|
||||
|
||||
- Research-Phase: Sub-Routes (`/settings/profile`, `/settings/notifications`, …) vs Sub-Components in einer Page?
|
||||
- Plan-Phase: Migrationsstrategie ohne URL-Bruch
|
||||
- Insights-Page (1750 Zeilen) folgt analogem Pattern
|
||||
|
||||
---
|
||||
|
||||
## Issue-Vorschlags-Abdeckung
|
||||
|
||||
### Issue #87 (Onboarding-Meds verschwinden)
|
||||
- **Pflaster:** Phase 1 (`.ok`-Check + Toast)
|
||||
- **Strukturelle Lösung:** Phase 4 (`apiClient`) + Phase 3.5 (neuer Flow ohne Med-Wizard) + Phase 4.5 (Empty-State-Tiles, Meds direkt sichtbar) + Phase 4.6 (Checklist)
|
||||
|
||||
### Issue #88 (Setup holprig — 4 Hauptpunkte + 3 Verbesserungsvorschläge)
|
||||
| #88 Punkt | Phase | Status |
|
||||
|---|---|---|
|
||||
| (a) Bundled compose ≠ docs version | Phase 2 (DOC-005) | abgedeckt |
|
||||
| (b) ghcr.io 404 (kein Image gepusht) | Phase 2 (DX-001 GHA-Workflow) | abgedeckt |
|
||||
| (c) `.env.example` `localhost:5432` vs Compose `db:5432` | Phase 1 (DX-003) | abgedeckt |
|
||||
| (d) `expose:3000` statt `ports:3000:3000` | Phase 1 (DX-002) | abgedeckt |
|
||||
| Vorschlag: GHA für Image-Push (GHCR/Docker Hub) | Phase 2 | abgedeckt |
|
||||
| Vorschlag: Merge „Getting started > Installation" + „Self-Hosting > Docker" | Phase 2 (DX-004) | abgedeckt |
|
||||
| Vorschlag: Same presets across compose+docs+.env.example | Phase 1 + Phase 2 | abgedeckt |
|
||||
|
||||
### Issue #89 (TBW + Bone Mass)
|
||||
- Phase 3 hardcoded mit Withings-Mapping. Optional `/api/ingest/measurements` für n8n in Phase 3.
|
||||
|
||||
### Issue #73 (UX-Liste mit 8 Punkten — CLOSED in v1.2)
|
||||
| #73 Punkt | v1.2-Status | Residual? |
|
||||
|---|---|---|
|
||||
| 1. Passkey-Errors unklar | Resolved (Package C, typed errors) | TEST-002 (keine Tests) → Phase 6 |
|
||||
| 2. Number-Formatting (DE 70,5 vs US 70.5) | Resolved (Package B, locale-aware formatters) | I18N-003 in `medication-card.tsx` → Phase 7 |
|
||||
| 3. Chart-Labels „7P/30P/90P/All" | Resolved (Package A, „7 Pkt"/„7 pts") | — |
|
||||
| 4. Dashboard nicht real-time | Resolved (`invalidateKeys` durchgängig) | — |
|
||||
| 5. GitHub-Setup-Confusion | Resolved (Built-in Feedback in v1.2) | — |
|
||||
| 6. Built-in Feedback | Done in v1.2 | — |
|
||||
| 7. Admin-Save ohne Error-UX | Partial (Success-Msg ja, Error-Highlight schwach) | UX-002 → Phase 7 |
|
||||
| 8. Blood-Sugar | Done in v1.2 | — |
|
||||
|
||||
### Issue #74 (raw locale keys in UI)
|
||||
Resolved in v1.2.
|
||||
|
||||
---
|
||||
|
||||
## Time-to-First-Value (TTFV) Ziel
|
||||
|
||||
Nach Onboarding-Redesign + Empty-State-Pattern:
|
||||
- Login → Dashboard mit Empty-States: 2 Klicks
|
||||
- Klick auf „Add your first weight" → Form prefilled „now" → Submit: 30 Sek
|
||||
- **TTFV-Ziel: <60 Sekunden** (Branchenvergleich: führende Health-Apps 30-90s)
|
||||
|
||||
## Anti-Patterns explizit vermieden
|
||||
|
||||
- Permissions-Cold-Prompt (Notifications/PWA) auf Day 1
|
||||
- Front-loaded Targets vor erster Messung
|
||||
- Multi-Step-Wizard mit identischem Skip-Button
|
||||
- `fetch()` ohne `.ok`-Check (apiClient löst strukturell)
|
||||
- Mega-Page-Pattern (Phase 8 löst Settings 3143 Z.)
|
||||
- Custom-Measurement-Types vorzeitig (40-60h Wartung/Jahr für 50% Feature-Loss)
|
||||
@@ -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.
|
||||
+291
-53
@@ -36,7 +36,92 @@
|
||||
"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)",
|
||||
"typeTotalBodyWater": "Gesamtkörperwasser",
|
||||
"typeBoneMass": "Knochenmasse",
|
||||
"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",
|
||||
"typeGlucoseFasting": "Glukose (nüchtern)",
|
||||
"typeGlucosePostprandial": "Glukose (postprandial)",
|
||||
"typeGlucoseRandom": "Glukose (beliebig)",
|
||||
"typeGlucoseBedtime": "Glukose (vor dem Schlafen)",
|
||||
"glucoseClassificationTitle": "Blutzucker — Referenzbereiche (ADA 2024 / DDG)",
|
||||
"glucoseRow": "{label}: Ø {avg} {unit} (Ziel {rangeMin}–{rangeMax}) — {class}",
|
||||
"glucoseInTarget": "im Ziel",
|
||||
"glucoseBelowTarget": "unter Ziel",
|
||||
"glucoseAboveTarget": "über Ziel"
|
||||
},
|
||||
"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",
|
||||
@@ -90,6 +175,7 @@
|
||||
"backToPasskey": "Zurück zu Passkey",
|
||||
"passkeySetupLater": "Passkeys kannst du jederzeit in den Einstellungen einrichten.",
|
||||
"passwordMinLength": "Min. 12 Zeichen",
|
||||
"passwordTooShort": "Passwort muss mindestens {minLength} Zeichen lang sein.",
|
||||
"passwordStrength": "Passwortstärke",
|
||||
"strengthWeak": "Sehr schwach",
|
||||
"strengthFair": "Schwach",
|
||||
@@ -113,6 +199,8 @@
|
||||
"bloodPressureDia": "Blutdruck (Dia)",
|
||||
"pulse": "Puls",
|
||||
"bodyFat": "Körperfett",
|
||||
"sleep": "Schlaf",
|
||||
"steps": "Schritte",
|
||||
"bpInTarget": "BD im Zielbereich",
|
||||
"days30Short": "30T",
|
||||
"medications": "Medikamente",
|
||||
@@ -127,7 +215,17 @@
|
||||
"compliance7d": "Einnahmetreue (7 Tage)",
|
||||
"complianceGood": "Gut",
|
||||
"complianceLow": "Niedrig",
|
||||
"mood": "Stimmung"
|
||||
"mood": "Stimmung",
|
||||
"customizeTitle": "Dashboard-Layout",
|
||||
"customizeSubtitle": "Wähle, welche Karten auf dem Dashboard angezeigt werden und in welcher Reihenfolge. Defaults funktionieren sofort.",
|
||||
"layoutReset": "Auf Defaults zurücksetzen",
|
||||
"layoutSaveSuccess": "Dashboard-Layout gespeichert",
|
||||
"layoutSaveError": "Layout konnte nicht gespeichert werden",
|
||||
"layoutResetSuccess": "Auf Standard-Layout zurückgesetzt",
|
||||
"layoutUsingDefaults": "Standard-Layout aktiv",
|
||||
"layoutCustomized": "Eigenes Layout aktiv",
|
||||
"moveUp": "Nach oben",
|
||||
"moveDown": "Nach unten"
|
||||
},
|
||||
"measurements": {
|
||||
"title": "Messungen",
|
||||
@@ -148,6 +246,16 @@
|
||||
"typeBodyFat": "Körperfett",
|
||||
"typeSleep": "Schlaf",
|
||||
"typeSteps": "Schritte",
|
||||
"typeBloodGlucose": "Blutzucker",
|
||||
"typeTotalBodyWater": "Gesamtkörperwasser",
|
||||
"typeBoneMass": "Knochenmasse",
|
||||
"glucoseContext": "Kontext",
|
||||
"glucoseContextFasting": "Nüchtern (≥8 h ohne Nahrung)",
|
||||
"glucoseContextPostprandial": "Postprandial (2 h nach dem Essen)",
|
||||
"glucoseContextRandom": "Beliebig / ohne Kontext",
|
||||
"glucoseContextBedtime": "Vor dem Schlafen",
|
||||
"glucoseUnit": "Blutzucker-Einheit",
|
||||
"glucoseUnitHint": "Gespeichert in mg/dL; Anzeige-Einheit ist deine Präferenz.",
|
||||
"unitHours": "Stunden",
|
||||
"unitSteps": "Schritte",
|
||||
"sourceManual": "Manuell",
|
||||
@@ -420,17 +528,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 +588,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",
|
||||
@@ -604,45 +731,37 @@
|
||||
"sectionNextSteps": "Nächste Schritte",
|
||||
"generatePrompt": "Klicke auf \"Generieren\" für KI-basierte Insights deiner Gesundheitsdaten."
|
||||
},
|
||||
"targets": {
|
||||
"title": "Zielwerte",
|
||||
"subtitle": "Persönliche Referenzbereiche basierend auf deinem Profil",
|
||||
"introText": "Diese Zielwerte sind persönliche Referenzbereiche, die aus deinem Profil und anerkannten medizinischen Richtwerten abgeleitet werden. Sie helfen dir, deine wichtigsten Gesundheitswerte schnell einzuordnen und Entwicklungen früh zu erkennen. Die Bereiche werden für dich individuell berechnet, zum Beispiel anhand von Alter, Geschlecht und Körpergröße. Zu jedem Wert siehst du direkt, ob du im Zielbereich liegst oder darüber beziehungsweise darunter. So bekommst du eine kompakte Übersicht darüber, welche Zielwerte du aktuell gut einhältst und wo noch Potenzial besteht.",
|
||||
"current": "Aktuell",
|
||||
"average30d": "30-Tage-Durchschnitt:",
|
||||
"targetRange": "Zielbereich",
|
||||
"classification": "Bewertung",
|
||||
"source": "Quelle",
|
||||
"sourceLabel": "Quelle: {source}",
|
||||
"trend": "Trend",
|
||||
"weight": "Gewicht",
|
||||
"bloodPressure": "Blutdruck",
|
||||
"pulse": "Ruhepuls",
|
||||
"sleep": "Schlafdauer",
|
||||
"bmi": "BMI",
|
||||
"bodyFat": "Körperfett",
|
||||
"steps": "Schritte/Tag",
|
||||
"compliance": "Einnahmetreue",
|
||||
"compliancePerMedication": "Pro Medikament",
|
||||
"diastolic": "Diastolisch",
|
||||
"noProfile": "Bitte fülle dein Profil aus, um personalisierte Zielwerte zu erhalten.",
|
||||
"profileIncomplete": "Profil unvollständig",
|
||||
"profileIncompleteHeightAge": "Hinterlege deine Größe und dein Geburtsdatum im Profil, um personalisierte Zielbereiche zu erhalten.",
|
||||
"profileIncompleteHeight": "Hinterlege deine Größe im Profil, um den Gewichts-Zielbereich zu berechnen.",
|
||||
"profileIncompleteAge": "Hinterlege dein Geburtsdatum im Profil, um altersbasierte Zielbereiche zu erhalten.",
|
||||
"noMeasurementData": "Noch keine Messdaten vorhanden.",
|
||||
"noMeasurementYet": "Noch keine Messung vorhanden",
|
||||
"currentValue": "Aktuell: {value} {unit}",
|
||||
"targetRangeValue": "Zielbereich: {min}–{max} {unit}",
|
||||
"inTarget": "Im Zielbereich",
|
||||
"belowTarget": "{delta} {unit} unter Ziel",
|
||||
"aboveTarget": "{delta} {unit} über Ziel",
|
||||
"loadError": "Fehler beim Laden",
|
||||
"classificationOptimal": "Optimal",
|
||||
"classificationNormal": "Normal",
|
||||
"classificationElevated": "Erhöht",
|
||||
"classificationHigh": "Hoch",
|
||||
"classificationLow": "Niedrig"
|
||||
"thresholds": {
|
||||
"title": "Persönliche Zielwerte",
|
||||
"subtitle": "Überschreibe den berechneten Zielbereich für jede Metrik. Ohne Eintrag gilt der Default.",
|
||||
"defaultLabel": "Default",
|
||||
"overrideLabel": "Dein Wert",
|
||||
"autoModeLabel": "Auto",
|
||||
"overrideModeLabel": "Überschrieben",
|
||||
"resetAction": "Auf Default zurücksetzen",
|
||||
"resetAllAction": "Alle auf Default zurücksetzen",
|
||||
"minLabel": "Min",
|
||||
"maxLabel": "Max",
|
||||
"unitSuffix": "({unit})",
|
||||
"outOfBoundsHint": "Erlaubter Bereich: {min}–{max} {unit}",
|
||||
"overrideWarning": "Werte außerhalb der Leitlinien können zu ungesunden Zielen führen. Nur nach ärztlicher Rücksprache überschreiben.",
|
||||
"saveSuccess": "Zielwerte aktualisiert",
|
||||
"saveError": "Zielwerte konnten nicht gespeichert werden",
|
||||
"resetSuccess": "Auf Default zurückgesetzt",
|
||||
"sourceOverride": "Eigener Wert",
|
||||
"metricWeight": "Gewicht",
|
||||
"metricBpSys": "Blutdruck (systolisch)",
|
||||
"metricBpDia": "Blutdruck (diastolisch)",
|
||||
"metricPulse": "Ruhepuls",
|
||||
"metricBodyFat": "Körperfett",
|
||||
"metricSleep": "Schlafdauer",
|
||||
"metricSteps": "Schritte/Tag",
|
||||
"metricGlucoseFasting": "Glukose — nüchtern",
|
||||
"metricGlucosePostprandial": "Glukose — postprandial",
|
||||
"metricGlucoseRandom": "Glukose — beliebig",
|
||||
"metricGlucoseBedtime": "Glukose — vor dem Schlafen",
|
||||
"unsetExplanation": "Kein eigener Wert — berechneter Default aktiv.",
|
||||
"loadError": "Zielwerte konnten nicht geladen werden"
|
||||
},
|
||||
"onboarding": {
|
||||
"welcome": "Willkommen bei HealthLog!",
|
||||
@@ -662,7 +781,12 @@
|
||||
"targetsDescription": "Setze optionale Zielwerte für Blutdruck und Gewicht.",
|
||||
"targetSys": "Systolisch (mmHg)",
|
||||
"targetDia": "Diastolisch (mmHg)",
|
||||
"targetWeight": "Zielgewicht (kg)"
|
||||
"targetWeight": "Zielgewicht (kg)",
|
||||
"errorProfile": "Dein Profil konnte nicht gespeichert werden. Bitte erneut versuchen.",
|
||||
"errorMedication": "Das Medikament „{name}\" konnte nicht angelegt werden. Bitte erneut versuchen oder später unter „Medikamente\" hinzufügen.",
|
||||
"errorTargets": "Deine Zielwerte konnten nicht gespeichert werden. Bitte erneut versuchen.",
|
||||
"errorGeneric": "Beim Abschluss des Setups ist etwas schiefgelaufen. Bitte erneut versuchen.",
|
||||
"medScheduleHint": "Wir legen ein Standard-Erinnerungsfenster an (08:00–09:00 täglich). Du kannst es jederzeit unter „Medikamente\" anpassen."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Einstellungen",
|
||||
@@ -671,6 +795,7 @@
|
||||
"categoryGeneral": "Allgemein",
|
||||
"categoryNotifications": "Benachrichtigungen",
|
||||
"categoryAi": "KI",
|
||||
"categoryPersonalization": "Personalisierung",
|
||||
"categoryIntegration": "Integration",
|
||||
"categoryApi": "API",
|
||||
"profile": "Profil",
|
||||
@@ -710,6 +835,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 +907,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 +1036,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",
|
||||
@@ -932,6 +1065,8 @@
|
||||
"telegramGlobal": "Telegram-Benachrichtigungen systemweit erlauben.",
|
||||
"ntfyGlobal": "ntfy-Benachrichtigungen systemweit erlauben.",
|
||||
"webPushGlobal": "Browser-Push-Benachrichtigungen systemweit erlauben.",
|
||||
"integrationWebPush": "Web Push",
|
||||
"integrationBugReport": "Bug-Reports",
|
||||
"apiGlobal": "API-Endpunkte und API-Tokens systemweit erlauben.",
|
||||
"moodLogGlobal": "moodLog-Integration systemweit erlauben.",
|
||||
"sectionGeneral": "Allgemein",
|
||||
@@ -1053,7 +1188,55 @@
|
||||
"reminderCheckNotifOn": "Benachrichtigungen an",
|
||||
"reminderCheckNotifOff": "Benachrichtigungen aus",
|
||||
"reminderCheckEventsToday": "Heutige Events",
|
||||
"reminderCheckNotifSent": "Gesendet"
|
||||
"reminderCheckNotifSent": "Gesendet",
|
||||
"feedback": {
|
||||
"title": "Feedback-Postfach",
|
||||
"description": "Eingegangene Rückmeldungen, Fehlerberichte und Feature-Wünsche.",
|
||||
"tabOpen": "Offen",
|
||||
"tabAcknowledged": "Bestätigt",
|
||||
"tabResolved": "Erledigt",
|
||||
"tabArchived": "Archiviert",
|
||||
"subject": "Betreff",
|
||||
"category": "Kategorie",
|
||||
"user": "Benutzer",
|
||||
"createdAt": "Eingegangen",
|
||||
"actions": "Aktionen",
|
||||
"details": "Details",
|
||||
"noEntries": "Keine Feedback-Einträge in dieser Kategorie.",
|
||||
"loading": "Lade Feedback...",
|
||||
"anonymous": "anonym",
|
||||
"categoryBug": "Bug",
|
||||
"categoryFeature": "Feature",
|
||||
"categoryQuestion": "Frage",
|
||||
"categoryOther": "Sonstiges",
|
||||
"statusOpen": "Offen",
|
||||
"statusAcknowledged": "Bestätigt",
|
||||
"statusResolved": "Erledigt",
|
||||
"statusArchived": "Archiviert",
|
||||
"dialogTitle": "Feedback-Details",
|
||||
"metadataHeading": "Metadaten",
|
||||
"metaUrl": "URL",
|
||||
"metaLocale": "Sprache",
|
||||
"metaUserAgent": "User-Agent",
|
||||
"metaAppVersion": "App-Version",
|
||||
"screenshotHeading": "Screenshot",
|
||||
"adminNote": "Admin-Notiz",
|
||||
"adminNotePlaceholder": "Interne Notiz (nicht für Benutzer sichtbar)",
|
||||
"saveNote": "Notiz speichern",
|
||||
"noteSaved": "Notiz gespeichert",
|
||||
"actionAcknowledge": "Bestätigen",
|
||||
"actionResolve": "Erledigen",
|
||||
"actionArchive": "Archivieren",
|
||||
"actionPublishGithub": "Auf GitHub veröffentlichen",
|
||||
"publishedToGithub": "Auf GitHub veröffentlicht",
|
||||
"viewIssue": "Issue ansehen",
|
||||
"publishSuccess": "Issue erstellt",
|
||||
"publishFailed": "GitHub-Veröffentlichung fehlgeschlagen",
|
||||
"updateFailed": "Aktualisierung fehlgeschlagen",
|
||||
"archiveConfirm": "Diesen Feedback-Eintrag archivieren?",
|
||||
"submittedBy": "Eingereicht von",
|
||||
"sectionTitle": "Feedback"
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
"title": "Erfolge",
|
||||
@@ -1233,10 +1416,9 @@
|
||||
},
|
||||
"bugreport": {
|
||||
"title": "Bugreport",
|
||||
"subtitle": "Melde einen Fehler oder ein Problem. Es wird automatisch ein GitHub Issue erstellt.",
|
||||
"subtitle": "Sende eine Fehlermeldung, einen Feature-Wunsch oder eine Frage. Der Server-Administrator sieht deine Einreichung.",
|
||||
"loginRequired": "Bitte anmelden, um einen Bug zu melden.",
|
||||
"bugTitle": "Titel",
|
||||
"bugTitlePlaceholder": "Kurze Beschreibung des Problems",
|
||||
"description": "Beschreibung",
|
||||
"descriptionPlaceholder": "Beschreibe den Fehler möglichst genau. Was hast du getan? Was ist passiert? Was hättest du erwartet?",
|
||||
"screenshot": "Screenshot (optional)",
|
||||
@@ -1247,7 +1429,19 @@
|
||||
"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",
|
||||
"category": "Art",
|
||||
"categoryBug": "Fehlermeldung",
|
||||
"categoryFeature": "Feature-Wunsch",
|
||||
"categoryQuestion": "Frage",
|
||||
"categoryOther": "Sonstiges Feedback",
|
||||
"bugTitlePlaceholder": "Kurze Zusammenfassung (optional)",
|
||||
"githubEscalationNote": "Dein Feedback wird lokal gespeichert. Wenn der Administrator die GitHub-Integration aktiviert, kann es auf Wunsch als öffentliches Issue veröffentlicht werden.",
|
||||
"internalOnlyNote": "Dein Feedback wird lokal gespeichert und ist nur für den Administrator dieses Servers sichtbar."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Benachrichtigungen",
|
||||
@@ -1361,6 +1555,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 +1568,27 @@
|
||||
"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",
|
||||
"glucoseFasting": "Glukose — nüchtern",
|
||||
"glucosePostprandial": "Glukose — postprandial",
|
||||
"glucoseRandom": "Glukose — beliebig",
|
||||
"glucoseBedtime": "Glukose — vor dem Schlafen",
|
||||
"loadError": "Zielwerte konnten nicht geladen werden"
|
||||
},
|
||||
"passwordStrength": {
|
||||
"veryWeak": "Sehr schwach",
|
||||
@@ -1383,5 +1599,27 @@
|
||||
"tooShort": "Zu kurz",
|
||||
"characters": "Zeichen",
|
||||
"minLength": "Mindestens {count} Zeichen erforderlich."
|
||||
},
|
||||
"telegram": {
|
||||
"errorInvalidAction": "Ungültige Aktion.",
|
||||
"errorMedicationNotFound": "Medikament nicht gefunden.",
|
||||
"errorMedicationInactive": "Medikament nicht gefunden oder inaktiv.",
|
||||
"errorUnknownAction": "Unbekannte Aktion.",
|
||||
"snoozeOneHour": "1 Stunde",
|
||||
"snoozeThreeHours": "3 Stunden",
|
||||
"snoozedFor": "{name} für {duration} zurückgestellt.",
|
||||
"skipped": "{name} übersprungen.",
|
||||
"alreadyRecorded": "{name} war bereits erfasst.",
|
||||
"recordedAsTaken": "{name} als eingenommen erfasst.",
|
||||
"confirmed": "{name} bestätigt.",
|
||||
"genericConfirmed": "Bestätigt.",
|
||||
"cancelled": "Abgebrochen.",
|
||||
"noActiveMedications": "Keine aktiven Medikamente gefunden.",
|
||||
"whichMedication": "<b>Welches Medikament eingenommen?</b>",
|
||||
"cancelButton": "Abbrechen",
|
||||
"askMedicationName": "Bitte Medikament angeben, z.B. <b>genommen Ramipril</b>.",
|
||||
"medicationNotFoundExact": "Medikament nicht gefunden. Bitte Namen exakt wie in HealthLog senden.",
|
||||
"helpHeader": "<b>Verfügbare Befehle:</b>",
|
||||
"helpBody": "/help — Diese Hilfe anzeigen\n/add — Einnahme erfassen\n/start — Bot starten\n\n<b>Textbefehle:</b>\ngenommen <Name> — Einnahme bestätigen\n\n<b>Über die Buttons in Erinnerungen:</b>\n• Genommen — Einnahme bestätigen\n• 🕐 1h / 🕐 3h — Erinnerung verschieben\n• ⏭ Überspringen — Einnahme überspringen\n• ✓ Bestätigen — Verpasste Einnahme bestätigen"
|
||||
}
|
||||
}
|
||||
+291
-53
@@ -36,7 +36,92 @@
|
||||
"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)",
|
||||
"typeTotalBodyWater": "Total body water",
|
||||
"typeBoneMass": "Bone mass",
|
||||
"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",
|
||||
"typeGlucoseFasting": "Glucose (fasting)",
|
||||
"typeGlucosePostprandial": "Glucose (postprandial)",
|
||||
"typeGlucoseRandom": "Glucose (random)",
|
||||
"typeGlucoseBedtime": "Glucose (bedtime)",
|
||||
"glucoseClassificationTitle": "Blood glucose — reference ranges (ADA 2024 / DDG)",
|
||||
"glucoseRow": "{label}: avg {avg} {unit} (target {rangeMin}–{rangeMax}) — {class}",
|
||||
"glucoseInTarget": "in target",
|
||||
"glucoseBelowTarget": "below target",
|
||||
"glucoseAboveTarget": "above target"
|
||||
},
|
||||
"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",
|
||||
@@ -90,6 +175,7 @@
|
||||
"backToPasskey": "Back to Passkey",
|
||||
"passkeySetupLater": "You can set up passkeys at any time in Settings.",
|
||||
"passwordMinLength": "Min. 12 characters",
|
||||
"passwordTooShort": "Password must be at least {minLength} characters long.",
|
||||
"passwordStrength": "Password strength",
|
||||
"strengthWeak": "Very weak",
|
||||
"strengthFair": "Weak",
|
||||
@@ -113,6 +199,8 @@
|
||||
"bloodPressureDia": "Blood Pressure (Dia)",
|
||||
"pulse": "Pulse",
|
||||
"bodyFat": "Body Fat",
|
||||
"sleep": "Sleep",
|
||||
"steps": "Steps",
|
||||
"bpInTarget": "BP in Target",
|
||||
"days30Short": "30d",
|
||||
"medications": "Medications",
|
||||
@@ -127,7 +215,17 @@
|
||||
"compliance7d": "Compliance (7 days)",
|
||||
"complianceGood": "Good",
|
||||
"complianceLow": "Low",
|
||||
"mood": "Mood"
|
||||
"mood": "Mood",
|
||||
"customizeTitle": "Dashboard layout",
|
||||
"customizeSubtitle": "Choose which cards appear on your dashboard and in what order. Defaults work out of the box.",
|
||||
"layoutReset": "Reset to defaults",
|
||||
"layoutSaveSuccess": "Dashboard layout saved",
|
||||
"layoutSaveError": "Could not save dashboard layout",
|
||||
"layoutResetSuccess": "Reset to default layout",
|
||||
"layoutUsingDefaults": "Using default layout",
|
||||
"layoutCustomized": "Custom layout active",
|
||||
"moveUp": "Move up",
|
||||
"moveDown": "Move down"
|
||||
},
|
||||
"measurements": {
|
||||
"title": "Measurements",
|
||||
@@ -148,6 +246,16 @@
|
||||
"typeBodyFat": "Body Fat",
|
||||
"typeSleep": "Sleep",
|
||||
"typeSteps": "Steps",
|
||||
"typeBloodGlucose": "Blood glucose",
|
||||
"typeTotalBodyWater": "Total body water",
|
||||
"typeBoneMass": "Bone mass",
|
||||
"glucoseContext": "Context",
|
||||
"glucoseContextFasting": "Fasting (≥8h without food)",
|
||||
"glucoseContextPostprandial": "Post-meal (2h after eating)",
|
||||
"glucoseContextRandom": "Random / casual",
|
||||
"glucoseContextBedtime": "Before bed",
|
||||
"glucoseUnit": "Glucose display unit",
|
||||
"glucoseUnitHint": "Stored canonically in mg/dL; display unit is your preference.",
|
||||
"unitHours": "hours",
|
||||
"unitSteps": "steps",
|
||||
"sourceManual": "Manual",
|
||||
@@ -420,17 +528,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 +588,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",
|
||||
@@ -604,45 +731,37 @@
|
||||
"sectionNextSteps": "Next steps",
|
||||
"generatePrompt": "Click \"Generate\" for AI-powered insights about your health data."
|
||||
},
|
||||
"targets": {
|
||||
"title": "Targets",
|
||||
"subtitle": "Personal reference ranges based on your profile",
|
||||
"introText": "These targets are personal reference ranges derived from your profile and established medical guidelines. They help you quickly classify your key health values and detect changes early. The ranges are calculated individually for you, for example based on age, sex, and height. For each value, you can immediately see whether you are within the target range or above or below it. This gives you a compact overview of which targets you are currently meeting well and where improvement is still possible.",
|
||||
"current": "Current",
|
||||
"average30d": "30-day average:",
|
||||
"targetRange": "Target range",
|
||||
"classification": "Rating",
|
||||
"source": "Source",
|
||||
"sourceLabel": "Source: {source}",
|
||||
"trend": "Trend",
|
||||
"weight": "Weight",
|
||||
"bloodPressure": "Blood Pressure",
|
||||
"pulse": "Resting Pulse",
|
||||
"sleep": "Sleep Duration",
|
||||
"bmi": "BMI",
|
||||
"bodyFat": "Body Fat",
|
||||
"steps": "Steps/Day",
|
||||
"compliance": "Compliance",
|
||||
"compliancePerMedication": "Per medication",
|
||||
"diastolic": "Diastolic",
|
||||
"noProfile": "Please complete your profile to get personalized targets.",
|
||||
"profileIncomplete": "Profile incomplete",
|
||||
"profileIncompleteHeightAge": "Add your height and date of birth in your profile to get personalized target ranges.",
|
||||
"profileIncompleteHeight": "Add your height in your profile to calculate weight target range.",
|
||||
"profileIncompleteAge": "Add your date of birth in your profile to get age-based target ranges.",
|
||||
"noMeasurementData": "No measurement data available yet.",
|
||||
"noMeasurementYet": "No measurement available yet",
|
||||
"currentValue": "Current: {value} {unit}",
|
||||
"targetRangeValue": "Target range: {min}–{max} {unit}",
|
||||
"inTarget": "In target range",
|
||||
"belowTarget": "{delta} {unit} below target",
|
||||
"aboveTarget": "{delta} {unit} above target",
|
||||
"loadError": "Error loading data",
|
||||
"classificationOptimal": "Optimal",
|
||||
"classificationNormal": "Normal",
|
||||
"classificationElevated": "Elevated",
|
||||
"classificationHigh": "High",
|
||||
"classificationLow": "Low"
|
||||
"thresholds": {
|
||||
"title": "Personal thresholds",
|
||||
"subtitle": "Override the computed target range for any metric. Defaults stay in effect until you set a value here.",
|
||||
"defaultLabel": "Default",
|
||||
"overrideLabel": "Your override",
|
||||
"autoModeLabel": "Auto",
|
||||
"overrideModeLabel": "Override",
|
||||
"resetAction": "Reset to default",
|
||||
"resetAllAction": "Reset all to default",
|
||||
"minLabel": "Min",
|
||||
"maxLabel": "Max",
|
||||
"unitSuffix": "({unit})",
|
||||
"outOfBoundsHint": "Allowed range: {min}–{max} {unit}",
|
||||
"overrideWarning": "Overriding with values outside guideline ranges may produce unhealthy targets. Only do this on doctor's advice.",
|
||||
"saveSuccess": "Thresholds updated",
|
||||
"saveError": "Could not save thresholds",
|
||||
"resetSuccess": "Reset to default",
|
||||
"sourceOverride": "User override",
|
||||
"metricWeight": "Weight",
|
||||
"metricBpSys": "Blood pressure (systolic)",
|
||||
"metricBpDia": "Blood pressure (diastolic)",
|
||||
"metricPulse": "Resting pulse",
|
||||
"metricBodyFat": "Body fat",
|
||||
"metricSleep": "Sleep duration",
|
||||
"metricSteps": "Daily steps",
|
||||
"metricGlucoseFasting": "Glucose — fasting",
|
||||
"metricGlucosePostprandial": "Glucose — post-meal",
|
||||
"metricGlucoseRandom": "Glucose — random",
|
||||
"metricGlucoseBedtime": "Glucose — bedtime",
|
||||
"unsetExplanation": "No override — using computed default.",
|
||||
"loadError": "Could not load thresholds"
|
||||
},
|
||||
"onboarding": {
|
||||
"welcome": "Welcome to HealthLog!",
|
||||
@@ -662,7 +781,12 @@
|
||||
"targetsDescription": "Set optional target values for blood pressure and weight.",
|
||||
"targetSys": "Systolic (mmHg)",
|
||||
"targetDia": "Diastolic (mmHg)",
|
||||
"targetWeight": "Target weight (kg)"
|
||||
"targetWeight": "Target weight (kg)",
|
||||
"errorProfile": "Could not save your profile. Please try again.",
|
||||
"errorMedication": "Could not add the medication \"{name}\". Please try again or add it later in Medications.",
|
||||
"errorTargets": "Could not save your target values. Please try again.",
|
||||
"errorGeneric": "Something went wrong while finishing setup. Please try again.",
|
||||
"medScheduleHint": "We'll add a default reminder window (08:00–09:00 daily). You can adjust it anytime in Medications."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
@@ -671,6 +795,7 @@
|
||||
"categoryGeneral": "General",
|
||||
"categoryNotifications": "Notifications",
|
||||
"categoryAi": "AI",
|
||||
"categoryPersonalization": "Personalization",
|
||||
"categoryIntegration": "Integration",
|
||||
"categoryApi": "API",
|
||||
"profile": "Profile",
|
||||
@@ -710,6 +835,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 +907,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 +1036,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",
|
||||
@@ -932,6 +1065,8 @@
|
||||
"telegramGlobal": "Allow Telegram notifications system-wide.",
|
||||
"ntfyGlobal": "Allow ntfy notifications system-wide.",
|
||||
"webPushGlobal": "Allow browser push notifications system-wide.",
|
||||
"integrationWebPush": "Web Push",
|
||||
"integrationBugReport": "Bug Report",
|
||||
"apiGlobal": "Allow API endpoints and API tokens system-wide.",
|
||||
"moodLogGlobal": "Allow moodLog integration system-wide.",
|
||||
"sectionGeneral": "General",
|
||||
@@ -1053,7 +1188,55 @@
|
||||
"reminderCheckNotifOn": "Notifications on",
|
||||
"reminderCheckNotifOff": "Notifications off",
|
||||
"reminderCheckEventsToday": "Events today",
|
||||
"reminderCheckNotifSent": "Sent"
|
||||
"reminderCheckNotifSent": "Sent",
|
||||
"feedback": {
|
||||
"title": "Feedback Inbox",
|
||||
"description": "User-submitted feedback, bug reports, and feature requests.",
|
||||
"tabOpen": "Open",
|
||||
"tabAcknowledged": "Acknowledged",
|
||||
"tabResolved": "Resolved",
|
||||
"tabArchived": "Archived",
|
||||
"subject": "Subject",
|
||||
"category": "Category",
|
||||
"user": "User",
|
||||
"createdAt": "Submitted",
|
||||
"actions": "Actions",
|
||||
"details": "Details",
|
||||
"noEntries": "No feedback in this category.",
|
||||
"loading": "Loading feedback...",
|
||||
"anonymous": "anonymous",
|
||||
"categoryBug": "Bug",
|
||||
"categoryFeature": "Feature",
|
||||
"categoryQuestion": "Question",
|
||||
"categoryOther": "Other",
|
||||
"statusOpen": "Open",
|
||||
"statusAcknowledged": "Acknowledged",
|
||||
"statusResolved": "Resolved",
|
||||
"statusArchived": "Archived",
|
||||
"dialogTitle": "Feedback details",
|
||||
"metadataHeading": "Metadata",
|
||||
"metaUrl": "URL",
|
||||
"metaLocale": "Locale",
|
||||
"metaUserAgent": "User agent",
|
||||
"metaAppVersion": "App version",
|
||||
"screenshotHeading": "Screenshot",
|
||||
"adminNote": "Admin note",
|
||||
"adminNotePlaceholder": "Internal note (not visible to user)",
|
||||
"saveNote": "Save note",
|
||||
"noteSaved": "Note saved",
|
||||
"actionAcknowledge": "Acknowledge",
|
||||
"actionResolve": "Resolve",
|
||||
"actionArchive": "Archive",
|
||||
"actionPublishGithub": "Publish to GitHub",
|
||||
"publishedToGithub": "Published to GitHub",
|
||||
"viewIssue": "View issue",
|
||||
"publishSuccess": "Issue created",
|
||||
"publishFailed": "GitHub publish failed",
|
||||
"updateFailed": "Update failed",
|
||||
"archiveConfirm": "Archive this feedback entry?",
|
||||
"submittedBy": "Submitted by",
|
||||
"sectionTitle": "Feedback"
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
"title": "Achievements",
|
||||
@@ -1233,10 +1416,9 @@
|
||||
},
|
||||
"bugreport": {
|
||||
"title": "Bug Report",
|
||||
"subtitle": "Report a bug or issue. A GitHub issue will be created automatically.",
|
||||
"subtitle": "Send a bug report, feature request, or question. The server administrator will see your submission.",
|
||||
"loginRequired": "Please sign in to report a bug.",
|
||||
"bugTitle": "Title",
|
||||
"bugTitlePlaceholder": "Brief description of the problem",
|
||||
"description": "Description",
|
||||
"descriptionPlaceholder": "Describe the bug in detail. What did you do? What happened? What did you expect?",
|
||||
"screenshot": "Screenshot (optional)",
|
||||
@@ -1247,7 +1429,19 @@
|
||||
"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",
|
||||
"category": "Type",
|
||||
"categoryBug": "Bug report",
|
||||
"categoryFeature": "Feature request",
|
||||
"categoryQuestion": "Question",
|
||||
"categoryOther": "Other feedback",
|
||||
"bugTitlePlaceholder": "Short summary (optional)",
|
||||
"githubEscalationNote": "Your feedback is stored locally. If the admin enables GitHub integration, it can be promoted to a public issue on request.",
|
||||
"internalOnlyNote": "Your feedback is stored locally and only visible to the administrator of this server."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
@@ -1361,6 +1555,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 +1568,27 @@
|
||||
"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",
|
||||
"glucoseFasting": "Glucose — fasting",
|
||||
"glucosePostprandial": "Glucose — post-meal",
|
||||
"glucoseRandom": "Glucose — random",
|
||||
"glucoseBedtime": "Glucose — bedtime",
|
||||
"loadError": "Failed to load targets"
|
||||
},
|
||||
"passwordStrength": {
|
||||
"veryWeak": "Very weak",
|
||||
@@ -1383,5 +1599,27 @@
|
||||
"tooShort": "Too short",
|
||||
"characters": "characters",
|
||||
"minLength": "At least {count} characters required."
|
||||
},
|
||||
"telegram": {
|
||||
"errorInvalidAction": "Invalid action.",
|
||||
"errorMedicationNotFound": "Medication not found.",
|
||||
"errorMedicationInactive": "Medication not found or inactive.",
|
||||
"errorUnknownAction": "Unknown action.",
|
||||
"snoozeOneHour": "1 hour",
|
||||
"snoozeThreeHours": "3 hours",
|
||||
"snoozedFor": "{name} snoozed for {duration}.",
|
||||
"skipped": "{name} skipped.",
|
||||
"alreadyRecorded": "{name} was already recorded.",
|
||||
"recordedAsTaken": "{name} recorded as taken.",
|
||||
"confirmed": "{name} confirmed.",
|
||||
"genericConfirmed": "Confirmed.",
|
||||
"cancelled": "Cancelled.",
|
||||
"noActiveMedications": "No active medications found.",
|
||||
"whichMedication": "<b>Which medication did you take?</b>",
|
||||
"cancelButton": "Cancel",
|
||||
"askMedicationName": "Please specify a medication, e.g. <b>taken Ramipril</b>.",
|
||||
"medicationNotFoundExact": "Medication not found. Please send the name exactly as in HealthLog.",
|
||||
"helpHeader": "<b>Available commands:</b>",
|
||||
"helpBody": "/help — show this help\n/add — record an intake\n/start — start the bot\n\n<b>Text commands:</b>\ntaken <name> — confirm an intake\n\n<b>Buttons in reminders:</b>\n• Taken — confirm intake\n• 🕐 1h / 🕐 3h — postpone reminder\n• ⏭ Skip — skip intake\n• ✓ Confirm — confirm a missed intake"
|
||||
}
|
||||
}
|
||||
+18
-18
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.1.0",
|
||||
"version": "1.3.2",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.31.0",
|
||||
"scripts": {
|
||||
@@ -22,25 +22,25 @@
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@prisma/adapter-pg": "^7.6.0",
|
||||
"@prisma/client": "^7.6.0",
|
||||
"@prisma/adapter-pg": "^7.8.0",
|
||||
"@prisma/client": "^7.8.0",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@simplewebauthn/server": "^13.3.0",
|
||||
"@tanstack/react-query": "^5.96.2",
|
||||
"@tanstack/react-query": "^5.100.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^17.4.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"jspdf": "^4.2.1",
|
||||
"jspdf-autotable": "^5.0.7",
|
||||
"lucide-react": "^1.7.0",
|
||||
"next": "16.2.2",
|
||||
"lucide-react": "^1.11.0",
|
||||
"next": "16.2.4",
|
||||
"next-themes": "^0.4.6",
|
||||
"pg-boss": "^12.15.0",
|
||||
"prisma": "^7.6.0",
|
||||
"pg-boss": "^12.18.1",
|
||||
"prisma": "^7.8.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-hook-form": "^7.72.1",
|
||||
"react": "19.2.5",
|
||||
"react-dom": "19.2.5",
|
||||
"react-hook-form": "^7.74.0",
|
||||
"recharts": "^3.8.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
@@ -56,14 +56,14 @@
|
||||
"@types/web-push": "^3.6.4",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.2",
|
||||
"prettier": "^3.5.0",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"shadcn": "^4.1.2",
|
||||
"eslint-config-next": "16.2.4",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-tailwindcss": "^0.7.3",
|
||||
"shadcn": "^4.5.0",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.1.2"
|
||||
"typescript": "^6",
|
||||
"vitest": "^4.1.5"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
|
||||
Generated
+1391
-1416
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,73 @@
|
||||
-- v1.2: Personalization + Blood Glucose + Built-in Feedback
|
||||
--
|
||||
-- This migration is purely additive.
|
||||
-- - New enums: measurement_type.BLOOD_GLUCOSE, glucose_context, feedback_category, feedback_status.
|
||||
-- - New columns on users: thresholds_json, dashboard_widgets_json, glucose_unit,
|
||||
-- ai_provider, ai_model, ai_base_url, ai_anthropic_key_encrypted, ai_local_key_encrypted.
|
||||
-- - New column on measurements: glucose_context (only set for BLOOD_GLUCOSE rows).
|
||||
-- - New table: feedback.
|
||||
|
||||
-- ── Enums ────────────────────────────────────────────────────────────────
|
||||
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'BLOOD_GLUCOSE';
|
||||
|
||||
CREATE TYPE "glucose_context" AS ENUM ('FASTING', 'POSTPRANDIAL', 'RANDOM', 'BEDTIME');
|
||||
|
||||
CREATE TYPE "feedback_category" AS ENUM ('BUG', 'FEATURE_REQUEST', 'QUESTION', 'OTHER');
|
||||
|
||||
CREATE TYPE "feedback_status" AS ENUM ('OPEN', 'ACKNOWLEDGED', 'RESOLVED', 'ARCHIVED');
|
||||
|
||||
-- ── users personalization columns ───────────────────────────────────────
|
||||
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN "thresholds_json" JSONB,
|
||||
ADD COLUMN "dashboard_widgets_json" JSONB,
|
||||
ADD COLUMN "glucose_unit" TEXT,
|
||||
ADD COLUMN "ai_provider" TEXT,
|
||||
ADD COLUMN "ai_model" TEXT,
|
||||
ADD COLUMN "ai_base_url" TEXT,
|
||||
ADD COLUMN "ai_anthropic_key_encrypted" TEXT,
|
||||
ADD COLUMN "ai_local_key_encrypted" TEXT;
|
||||
|
||||
-- ── measurements.glucose_context ────────────────────────────────────────
|
||||
|
||||
ALTER TABLE "measurements"
|
||||
ADD COLUMN "glucose_context" "glucose_context";
|
||||
|
||||
-- Enforce: glucose_context is only set for BLOOD_GLUCOSE rows.
|
||||
ALTER TABLE "measurements"
|
||||
ADD CONSTRAINT "measurements_glucose_context_requires_type"
|
||||
CHECK (
|
||||
(type = 'BLOOD_GLUCOSE' AND glucose_context IS NOT NULL)
|
||||
OR
|
||||
(type <> 'BLOOD_GLUCOSE' AND glucose_context IS NULL)
|
||||
);
|
||||
|
||||
-- ── feedback table ──────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE "feedback" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT,
|
||||
"email" TEXT,
|
||||
"category" "feedback_category" NOT NULL DEFAULT 'BUG',
|
||||
"subject" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
"screenshot_base64" TEXT,
|
||||
"metadata" JSONB,
|
||||
"status" "feedback_status" NOT NULL DEFAULT 'OPEN',
|
||||
"admin_note" TEXT,
|
||||
"github_issue_url" TEXT,
|
||||
"notified_email" BOOLEAN NOT NULL DEFAULT false,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "feedback_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "feedback_status_created_at_idx" ON "feedback" ("status", "created_at");
|
||||
CREATE INDEX "feedback_user_id_idx" ON "feedback" ("user_id");
|
||||
|
||||
ALTER TABLE "feedback"
|
||||
ADD CONSTRAINT "feedback_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id")
|
||||
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- v1.3: Add body-composition measurement types
|
||||
--
|
||||
-- Closes #89: total body water and bone mass as first-class measurement types.
|
||||
-- Both are reported by Withings body-composition scales and can also be ingested
|
||||
-- via the API by external pipelines (e.g. n8n + Health Connect).
|
||||
--
|
||||
-- Storage units (canonical):
|
||||
-- TOTAL_BODY_WATER: percent of body weight (%)
|
||||
-- BONE_MASS: kilograms (kg)
|
||||
--
|
||||
-- Both are unconditional value types — no companion enum (unlike BLOOD_GLUCOSE,
|
||||
-- which carries a glucose_context). The migration is purely additive.
|
||||
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'TOTAL_BODY_WATER';
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'BONE_MASS';
|
||||
+82
-1
@@ -53,6 +53,24 @@ model User {
|
||||
// Locale (null = use system/admin default)
|
||||
locale String? @map("locale") // "de" | "en" | null
|
||||
|
||||
// Personalization
|
||||
// Per-user overrides for computed default thresholds. Shape:
|
||||
// { "WEIGHT": { "min": 70, "max": 85 },
|
||||
// "BLOOD_PRESSURE_SYS": { "min": 110, "max": 130 }, ... }
|
||||
// Null or missing metric → use computed default.
|
||||
thresholdsJson Json? @map("thresholds_json")
|
||||
// Per-user dashboard layout — widget visibility + ordering.
|
||||
// Shape: { "widgets": [{ "id": "weight", "visible": true, "order": 0 }, ...], "version": 1 }
|
||||
dashboardWidgetsJson Json? @map("dashboard_widgets_json")
|
||||
// Blood-glucose display unit preference. Canonical storage is mg/dL.
|
||||
glucoseUnit String? @map("glucose_unit") // "mg/dL" | "mmol/L" | null = default "mg/dL"
|
||||
// AI provider selection for insights. Null = OPENAI fallback.
|
||||
aiProvider String? @map("ai_provider") // "OPENAI" | "ANTHROPIC" | "LOCAL" | "CHATGPT_OAUTH"
|
||||
aiModel String? @map("ai_model")
|
||||
aiBaseUrl String? @map("ai_base_url")
|
||||
aiAnthropicKeyEncrypted String? @map("ai_anthropic_key_encrypted")
|
||||
aiLocalKeyEncrypted String? @map("ai_local_key_encrypted")
|
||||
|
||||
// Onboarding
|
||||
onboardingCompletedAt DateTime? @map("onboarding_completed_at")
|
||||
|
||||
@@ -71,6 +89,7 @@ model User {
|
||||
pushSubscriptions PushSubscription[]
|
||||
dataBackups DataBackup[]
|
||||
achievements UserAchievement[]
|
||||
feedback Feedback[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -130,10 +149,24 @@ enum MeasurementType {
|
||||
BODY_FAT
|
||||
SLEEP_DURATION
|
||||
ACTIVITY_STEPS
|
||||
BLOOD_GLUCOSE // Canonical storage: mg/dL. Display unit (mg/dL or mmol/L) is user preference.
|
||||
TOTAL_BODY_WATER // Canonical storage: kilograms (kg of water). Matches Withings' hydration measure (type 77) and Health Connect's TotalBodyWaterRecord. Display can compute % from same-day weight if desired.
|
||||
BONE_MASS // Canonical storage: kilograms (kg). Matches Withings' bone mass measure (type 88).
|
||||
|
||||
@@map("measurement_type")
|
||||
}
|
||||
|
||||
/// Context for glucose measurements — affects which target range applies.
|
||||
/// NULL for non-glucose measurements.
|
||||
enum GlucoseContext {
|
||||
FASTING // ≥8h without food, typically morning
|
||||
POSTPRANDIAL // 2h after meal
|
||||
RANDOM // any time, non-fasting, non-post-meal
|
||||
BEDTIME // before sleep
|
||||
|
||||
@@map("glucose_context")
|
||||
}
|
||||
|
||||
enum MeasurementSource {
|
||||
MANUAL
|
||||
WITHINGS
|
||||
@@ -147,11 +180,13 @@ model Measurement {
|
||||
userId String @map("user_id")
|
||||
type MeasurementType
|
||||
value Float
|
||||
unit String // kg, mmHg, bpm, %, hours, steps
|
||||
unit String // kg, mmHg, bpm, %, hours, steps, mg/dL
|
||||
source MeasurementSource @default(MANUAL)
|
||||
measuredAt DateTime @map("measured_at")
|
||||
notes String? // encrypted at app level if sensitive
|
||||
externalId String? @map("external_id") // Withings grpid for dedup
|
||||
// Only set for BLOOD_GLUCOSE measurements. NULL for all others.
|
||||
glucoseContext GlucoseContext? @map("glucose_context")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@ -386,6 +421,52 @@ model AppSettings {
|
||||
@@map("app_settings")
|
||||
}
|
||||
|
||||
// ─── Built-in Feedback ───────────────────────────────────────
|
||||
|
||||
enum FeedbackCategory {
|
||||
BUG
|
||||
FEATURE_REQUEST
|
||||
QUESTION
|
||||
OTHER
|
||||
|
||||
@@map("feedback_category")
|
||||
}
|
||||
|
||||
enum FeedbackStatus {
|
||||
OPEN
|
||||
ACKNOWLEDGED
|
||||
RESOLVED
|
||||
ARCHIVED
|
||||
|
||||
@@map("feedback_status")
|
||||
}
|
||||
|
||||
/// Stores user-submitted feedback without requiring external GitHub config.
|
||||
/// Admin inbox lives at /admin#feedback; optional escalation publishes to a
|
||||
/// GitHub issue reusing the existing integration when configured.
|
||||
model Feedback {
|
||||
id String @id @default(cuid())
|
||||
userId String? @map("user_id") // Null after user deletion; email snapshot remains.
|
||||
email String? // Snapshot at submission time for triage continuity.
|
||||
category FeedbackCategory @default(BUG)
|
||||
subject String
|
||||
description String
|
||||
screenshotBase64 String? @map("screenshot_base64") // Optional, <5MB, PNG/JPG.
|
||||
metadata Json? // URL, userAgent, locale, appVersion, …
|
||||
status FeedbackStatus @default(OPEN)
|
||||
adminNote String? @map("admin_note")
|
||||
gitHubIssueUrl String? @map("github_issue_url")
|
||||
notifiedEmail Boolean @default(false) @map("notified_email")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([status, createdAt])
|
||||
@@index([userId])
|
||||
@@map("feedback")
|
||||
}
|
||||
|
||||
// ─── Audit Log ─────────────────────────────────────────────
|
||||
|
||||
model AuditLog {
|
||||
|
||||
+554
-6
@@ -19,6 +19,14 @@ import {
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import {
|
||||
Shield,
|
||||
Users,
|
||||
@@ -45,10 +53,14 @@ import {
|
||||
Key,
|
||||
Bug,
|
||||
Cog,
|
||||
MessageSquare,
|
||||
Inbox,
|
||||
ExternalLink,
|
||||
GitPullRequest,
|
||||
} 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>) {
|
||||
@@ -167,6 +179,11 @@ export default function AdminPage() {
|
||||
<WebPushVapidSection id="section-admin-webpush" />
|
||||
<BugReportSection id="section-admin-bugreport" />
|
||||
|
||||
<h2 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">
|
||||
{t("admin.feedback.sectionTitle")}
|
||||
</h2>
|
||||
<FeedbackInboxSection id="section-admin-feedback" />
|
||||
|
||||
<h2 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">
|
||||
{t("admin.sectionMedication")}
|
||||
</h2>
|
||||
@@ -203,6 +220,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 +228,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 +263,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 +312,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}
|
||||
@@ -349,7 +378,7 @@ function SystemStatusSection({ id }: { id: string }) {
|
||||
{status.integrations.webPush && (
|
||||
<StatusItem
|
||||
icon={BellRing}
|
||||
label="Web Push"
|
||||
label={t("admin.integrationWebPush")}
|
||||
value={t("admin.configured")}
|
||||
className="text-dracula-green"
|
||||
/>
|
||||
@@ -357,7 +386,7 @@ function SystemStatusSection({ id }: { id: string }) {
|
||||
{status.integrations.bugReport && (
|
||||
<StatusItem
|
||||
icon={Bug}
|
||||
label="Bug Report"
|
||||
label={t("admin.integrationBugReport")}
|
||||
value={t("admin.configured")}
|
||||
className="text-dracula-green"
|
||||
/>
|
||||
@@ -489,7 +518,7 @@ function ServicesSection({ id }: { id: string }) {
|
||||
disabled={updateSettings.isPending}
|
||||
/>
|
||||
<SettingsToggle
|
||||
label="Web Push"
|
||||
label={t("admin.integrationWebPush")}
|
||||
description={t("admin.webPushGlobal")}
|
||||
icon={Globe}
|
||||
checked={settings?.webPushGlobal ?? true}
|
||||
@@ -1875,6 +1904,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"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2149,3 +2186,514 @@ function UserManagementSection({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─────────────────────── Feedback Inbox ─────────────────────── */
|
||||
|
||||
type FeedbackStatusType = "OPEN" | "ACKNOWLEDGED" | "RESOLVED" | "ARCHIVED";
|
||||
type FeedbackCategoryType = "BUG" | "FEATURE_REQUEST" | "QUESTION" | "OTHER";
|
||||
|
||||
interface FeedbackItem {
|
||||
id: string;
|
||||
userId: string | null;
|
||||
email: string | null;
|
||||
category: FeedbackCategoryType;
|
||||
subject: string;
|
||||
description: string;
|
||||
status: FeedbackStatusType;
|
||||
adminNote: string | null;
|
||||
gitHubIssueUrl: string | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
screenshotBase64: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
user: { username: string } | null;
|
||||
}
|
||||
|
||||
interface FeedbackListResponse {
|
||||
items: FeedbackItem[];
|
||||
meta: {
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
countsByStatus: Partial<Record<FeedbackStatusType, number>>;
|
||||
};
|
||||
}
|
||||
|
||||
const STATUS_TABS: FeedbackStatusType[] = [
|
||||
"OPEN",
|
||||
"ACKNOWLEDGED",
|
||||
"RESOLVED",
|
||||
"ARCHIVED",
|
||||
];
|
||||
|
||||
function FeedbackInboxSection({ id }: { id: string }) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: status } = useQuery({
|
||||
queryKey: ["admin", "status"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/admin/status");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
return (await res.json()).data as SystemStatus;
|
||||
},
|
||||
});
|
||||
const githubConfigured = Boolean(status?.integrations.bugReport?.configured);
|
||||
|
||||
const [activeStatus, setActiveStatus] = useState<FeedbackStatusType>("OPEN");
|
||||
const [selected, setSelected] = useState<FeedbackItem | null>(null);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["admin", "feedback", activeStatus],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(
|
||||
`/api/admin/feedback?status=${activeStatus}&limit=100`,
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
return (await res.json()).data as FeedbackListResponse;
|
||||
},
|
||||
});
|
||||
|
||||
const counts = data?.meta.countsByStatus ?? {};
|
||||
|
||||
function refresh() {
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "feedback"] });
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className="bg-card border-border scroll-mt-28 rounded-xl border p-6"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Inbox className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">{t("admin.feedback.title")}</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("admin.feedback.description")}
|
||||
</p>
|
||||
|
||||
<Tabs
|
||||
value={activeStatus}
|
||||
onValueChange={(v) => setActiveStatus(v as FeedbackStatusType)}
|
||||
className="mt-4"
|
||||
>
|
||||
<TabsList>
|
||||
{STATUS_TABS.map((s) => (
|
||||
<TabsTrigger key={s} value={s}>
|
||||
<span>
|
||||
{t(
|
||||
`admin.feedback.tab${s.charAt(0) + s.slice(1).toLowerCase()}`,
|
||||
)}
|
||||
</span>
|
||||
<Badge variant="secondary" className="ml-1.5 text-xs">
|
||||
{counts[s] ?? 0}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{STATUS_TABS.map((s) => (
|
||||
<TabsContent key={s} value={s} className="mt-4">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("admin.feedback.loading")}
|
||||
</span>
|
||||
</div>
|
||||
) : !data?.items.length ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("admin.feedback.noEntries")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-b text-xs">
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.feedback.createdAt")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.feedback.category")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.feedback.subject")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.feedback.user")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("admin.feedback.actions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
{data.items.map((item, i) => (
|
||||
<tr
|
||||
key={item.id}
|
||||
className={`hover:bg-muted/40 cursor-pointer ${i % 2 === 0 ? "bg-muted/30" : ""}`}
|
||||
onClick={() => setSelected(item)}
|
||||
>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
|
||||
{formatDateTime(item.createdAt)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<FeedbackCategoryBadge category={item.category} />
|
||||
</td>
|
||||
<td className="max-w-[28ch] truncate px-3 py-2 font-medium">
|
||||
{item.subject}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs">
|
||||
{item.user?.username ?? t("admin.feedback.anonymous")}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelected(item);
|
||||
}}
|
||||
>
|
||||
{t("admin.feedback.details")}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
{selected && (
|
||||
<FeedbackDetailDialog
|
||||
item={selected}
|
||||
open={!!selected}
|
||||
onOpenChange={(open) => !open && setSelected(null)}
|
||||
githubConfigured={githubConfigured}
|
||||
onMutated={refresh}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FeedbackCategoryBadge({
|
||||
category,
|
||||
}: {
|
||||
category: FeedbackCategoryType;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const map: Record<
|
||||
FeedbackCategoryType,
|
||||
{ label: string; className: string }
|
||||
> = {
|
||||
BUG: {
|
||||
label: t("admin.feedback.categoryBug"),
|
||||
className: "bg-red-500/15 text-red-400 border-red-500/30",
|
||||
},
|
||||
FEATURE_REQUEST: {
|
||||
label: t("admin.feedback.categoryFeature"),
|
||||
className: "bg-purple-500/15 text-purple-400 border-purple-500/30",
|
||||
},
|
||||
QUESTION: {
|
||||
label: t("admin.feedback.categoryQuestion"),
|
||||
className: "bg-blue-500/15 text-blue-400 border-blue-500/30",
|
||||
},
|
||||
OTHER: {
|
||||
label: t("admin.feedback.categoryOther"),
|
||||
className: "bg-muted text-muted-foreground border-border",
|
||||
},
|
||||
};
|
||||
const cfg = map[category];
|
||||
return (
|
||||
<Badge className={`border text-xs ${cfg.className}`}>{cfg.label}</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function FeedbackDetailDialog({
|
||||
item,
|
||||
open,
|
||||
onOpenChange,
|
||||
githubConfigured,
|
||||
onMutated,
|
||||
}: {
|
||||
item: FeedbackItem;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
githubConfigured: boolean;
|
||||
onMutated: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const [note, setNote] = useState(item.adminNote ?? "");
|
||||
const [issueUrl, setIssueUrl] = useState(item.gitHubIssueUrl);
|
||||
|
||||
const update = useMutation({
|
||||
mutationFn: async (payload: {
|
||||
status?: FeedbackStatusType;
|
||||
adminNote?: string | null;
|
||||
}) => {
|
||||
const res = await fetch(`/api/admin/feedback/${item.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) throw new Error(await getApiErrorMessage(res));
|
||||
},
|
||||
onSuccess: (_, vars) => {
|
||||
onMutated();
|
||||
if (vars.adminNote !== undefined) {
|
||||
toast.success(t("admin.feedback.noteSaved"));
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : t("admin.feedback.updateFailed"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const archive = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch(`/api/admin/feedback/${item.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) throw new Error(await getApiErrorMessage(res));
|
||||
},
|
||||
onSuccess: () => {
|
||||
onMutated();
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : t("admin.feedback.updateFailed"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const publish = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch(`/api/admin/feedback/${item.id}/github`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) throw new Error(await getApiErrorMessage(res));
|
||||
return (await res.json()).data as { issueUrl: string };
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setIssueUrl(data.issueUrl);
|
||||
toast.success(t("admin.feedback.publishSuccess"));
|
||||
onMutated();
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : t("admin.feedback.publishFailed"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const meta = item.metadata ?? {};
|
||||
const url = typeof meta.url === "string" ? meta.url : null;
|
||||
const locale = typeof meta.locale === "string" ? meta.locale : null;
|
||||
const userAgent = typeof meta.userAgent === "string" ? meta.userAgent : null;
|
||||
const appVersion =
|
||||
typeof meta.appVersion === "string" ? meta.appVersion : null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
{item.subject}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<span className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<FeedbackCategoryBadge category={item.category} />
|
||||
<span>
|
||||
{t("admin.feedback.submittedBy")}:{" "}
|
||||
{item.user?.username ?? t("admin.feedback.anonymous")}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span>{formatDateTime(item.createdAt)}</span>
|
||||
{issueUrl && (
|
||||
<a
|
||||
href={issueUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary inline-flex items-center gap-1"
|
||||
>
|
||||
<GitPullRequest className="h-3 w-3" />
|
||||
{t("admin.feedback.viewIssue")}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 text-sm">
|
||||
<div className="bg-muted/40 rounded-md p-3 whitespace-pre-wrap">
|
||||
{item.description}
|
||||
</div>
|
||||
|
||||
{(url || locale || userAgent || appVersion) && (
|
||||
<div>
|
||||
<h4 className="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
|
||||
{t("admin.feedback.metadataHeading")}
|
||||
</h4>
|
||||
<dl className="mt-2 grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1 text-xs">
|
||||
{url && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("admin.feedback.metaUrl")}
|
||||
</dt>
|
||||
<dd className="font-mono break-all">{url}</dd>
|
||||
</>
|
||||
)}
|
||||
{locale && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("admin.feedback.metaLocale")}
|
||||
</dt>
|
||||
<dd className="font-mono">{locale}</dd>
|
||||
</>
|
||||
)}
|
||||
{appVersion && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("admin.feedback.metaAppVersion")}
|
||||
</dt>
|
||||
<dd className="font-mono">{appVersion}</dd>
|
||||
</>
|
||||
)}
|
||||
{userAgent && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("admin.feedback.metaUserAgent")}
|
||||
</dt>
|
||||
<dd className="font-mono break-all">{userAgent}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.screenshotBase64 && (
|
||||
<div>
|
||||
<h4 className="text-muted-foreground mb-2 text-xs font-semibold tracking-wider uppercase">
|
||||
{t("admin.feedback.screenshotHeading")}
|
||||
</h4>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={item.screenshotBase64}
|
||||
alt="Screenshot"
|
||||
className="border-border max-h-72 rounded-md border"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-feedback-note" className="text-xs">
|
||||
{t("admin.feedback.adminNote")}
|
||||
</Label>
|
||||
<textarea
|
||||
id="admin-feedback-note"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
rows={3}
|
||||
maxLength={5000}
|
||||
placeholder={t("admin.feedback.adminNotePlaceholder")}
|
||||
className="border-input bg-background text-foreground placeholder:text-muted-foreground focus-visible:ring-ring w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={update.isPending || note === (item.adminNote ?? "")}
|
||||
onClick={() => update.mutate({ adminNote: note || null })}
|
||||
>
|
||||
{update.isPending && (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
)}
|
||||
{t("admin.feedback.saveNote")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-border flex flex-wrap items-center gap-2 border-t pt-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={update.isPending || item.status === "ACKNOWLEDGED"}
|
||||
onClick={() => update.mutate({ status: "ACKNOWLEDGED" })}
|
||||
>
|
||||
<CheckCircle2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t("admin.feedback.actionAcknowledge")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={update.isPending || item.status === "RESOLVED"}
|
||||
onClick={() => update.mutate({ status: "RESOLVED" })}
|
||||
>
|
||||
<CheckCircle2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t("admin.feedback.actionResolve")}
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={archive.isPending || item.status === "ARCHIVED"}
|
||||
>
|
||||
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t("admin.feedback.actionArchive")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("admin.feedback.archiveConfirm")}
|
||||
</AlertDialogTitle>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => archive.mutate()}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{t("admin.feedback.actionArchive")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
{githubConfigured && !issueUrl && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={publish.isPending}
|
||||
onClick={() => publish.mutate()}
|
||||
className="ml-auto"
|
||||
>
|
||||
{publish.isPending ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<GitPullRequest className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("admin.feedback.actionPublishGithub")}
|
||||
</Button>
|
||||
)}
|
||||
{issueUrl && (
|
||||
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green ml-auto">
|
||||
{t("admin.feedback.publishedToGithub")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -71,8 +71,39 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
if (baseUrl !== undefined) {
|
||||
const trimmed = baseUrl.trim();
|
||||
if (!trimmed) throw new HttpError(422, "Base URL cannot be empty");
|
||||
if (!trimmed.startsWith("https://")) {
|
||||
throw new HttpError(422, "Base URL must start with https://");
|
||||
|
||||
// The admin AI base URL has full egress of the bearer key on every
|
||||
// insight call. Lock it to HTTPS + a hostname allowlist so a
|
||||
// compromised admin (or stolen cookie) cannot point it at
|
||||
// attacker.example and exfiltrate the key on the next AI call.
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(trimmed);
|
||||
} catch {
|
||||
throw new HttpError(422, "Base URL is not a valid URL");
|
||||
}
|
||||
if (parsed.protocol !== "https:") {
|
||||
throw new HttpError(422, "Base URL must use https://");
|
||||
}
|
||||
const allowedHosts = new Set([
|
||||
"api.openai.com",
|
||||
"api.anthropic.com",
|
||||
"generativelanguage.googleapis.com",
|
||||
"api.mistral.ai",
|
||||
"api.groq.com",
|
||||
"openrouter.ai",
|
||||
]);
|
||||
for (const h of (process.env.ADMIN_AI_BASE_URL_ALLOWLIST ?? "")
|
||||
.split(",")
|
||||
.map((h) => h.trim())
|
||||
.filter(Boolean)) {
|
||||
allowedHosts.add(h);
|
||||
}
|
||||
if (!allowedHosts.has(parsed.hostname)) {
|
||||
throw new HttpError(
|
||||
422,
|
||||
`Base URL host '${parsed.hostname}' not in allowlist. Set ADMIN_AI_BASE_URL_ALLOWLIST if you need a custom provider.`,
|
||||
);
|
||||
}
|
||||
updates.adminAiBaseUrl = trimmed;
|
||||
auditDetails.baseUrl = trimmed;
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* POST /api/admin/feedback/[id]/github — promote feedback to a GitHub Issue.
|
||||
*
|
||||
* Reuses publishFeedbackToGithub() so the issue body matches the legacy
|
||||
* /api/bugreport flow. Idempotent: if gitHubIssueUrl is already set we return
|
||||
* the existing URL instead of creating a duplicate.
|
||||
*/
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
publishFeedbackToGithub,
|
||||
GithubPublishError,
|
||||
} from "@/lib/feedback/publish-github";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
export const POST = apiHandler(
|
||||
async (request: NextRequest, ctx: { params: Promise<{ id: string }> }) => {
|
||||
const { user: admin } = await requireAdmin();
|
||||
const { id } = await ctx.params;
|
||||
|
||||
const feedback = await prisma.feedback.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
category: true,
|
||||
subject: true,
|
||||
description: true,
|
||||
gitHubIssueUrl: true,
|
||||
screenshotBase64: true,
|
||||
metadata: true,
|
||||
user: { select: { username: true } },
|
||||
},
|
||||
});
|
||||
if (!feedback) return apiError("Feedback not found", 404);
|
||||
|
||||
if (feedback.gitHubIssueUrl) {
|
||||
return apiError(
|
||||
`Already published: ${feedback.gitHubIssueUrl}`,
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
// Atomic claim to prevent concurrent admin clicks from creating two
|
||||
// duplicate GitHub issues. Only the first caller whose updateMany
|
||||
// matches (gitHubIssueUrl still null) proceeds. We write a PENDING
|
||||
// sentinel now and replace it with the real URL after the API call.
|
||||
const PENDING = "pending://creating";
|
||||
const claim = await prisma.feedback.updateMany({
|
||||
where: { id, gitHubIssueUrl: null },
|
||||
data: { gitHubIssueUrl: PENDING },
|
||||
});
|
||||
if (claim.count !== 1) {
|
||||
return apiError("Already being published by another request", 409);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await publishFeedbackToGithub({
|
||||
category: feedback.category,
|
||||
subject: feedback.subject,
|
||||
description: feedback.description,
|
||||
username: feedback.user?.username ?? "anonymous",
|
||||
metadata: feedback.metadata,
|
||||
});
|
||||
|
||||
await prisma.feedback.update({
|
||||
where: { id },
|
||||
data: { gitHubIssueUrl: result.issueUrl },
|
||||
});
|
||||
|
||||
await auditLog("admin.feedback.github_publish", {
|
||||
userId: admin.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { feedbackId: id, issueNumber: result.issueNumber },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "admin.feedback.github_publish" },
|
||||
meta: { feedback_id: id, issue_number: result.issueNumber },
|
||||
});
|
||||
|
||||
return apiSuccess({ issueUrl: result.issueUrl });
|
||||
} catch (err) {
|
||||
// Release the PENDING claim so a retry can succeed.
|
||||
await prisma.feedback
|
||||
.updateMany({
|
||||
where: { id, gitHubIssueUrl: PENDING },
|
||||
data: { gitHubIssueUrl: null },
|
||||
})
|
||||
.catch(() => undefined);
|
||||
if (err instanceof GithubPublishError) {
|
||||
return apiError(err.message, err.status);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* PATCH /api/admin/feedback/[id] — change status or admin note.
|
||||
* DELETE /api/admin/feedback/[id] — archive (soft: sets status to ARCHIVED).
|
||||
*/
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, safeJson, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { updateFeedbackSchema } from "@/lib/validations/feedback";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
export const PATCH = apiHandler(
|
||||
async (request: NextRequest, ctx: { params: Promise<{ id: string }> }) => {
|
||||
const { user } = await requireAdmin();
|
||||
const { id } = await ctx.params;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = updateFeedbackSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
}
|
||||
|
||||
const before = await prisma.feedback.findUnique({ where: { id } });
|
||||
if (!before) return apiError("Feedback not found", 404);
|
||||
|
||||
const updated = await prisma.feedback.update({
|
||||
where: { id },
|
||||
data: parsed.data,
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
adminNote: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("admin.feedback.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
feedbackId: id,
|
||||
before: { status: before.status, adminNote: before.adminNote },
|
||||
after: parsed.data,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "admin.feedback.update" },
|
||||
meta: { feedback_id: id },
|
||||
});
|
||||
|
||||
return apiSuccess(updated);
|
||||
},
|
||||
);
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, ctx: { params: Promise<{ id: string }> }) => {
|
||||
const { user } = await requireAdmin();
|
||||
const { id } = await ctx.params;
|
||||
|
||||
const updated = await prisma.feedback.update({
|
||||
where: { id },
|
||||
data: { status: "ARCHIVED" },
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
|
||||
await auditLog("admin.feedback.archive", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { feedbackId: id },
|
||||
});
|
||||
|
||||
return apiSuccess(updated);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Admin feedback inbox. Lists all feedback with status filter.
|
||||
*/
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { NextRequest } from "next/server";
|
||||
import type { FeedbackStatus } from "@/generated/prisma/client";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
await requireAdmin();
|
||||
|
||||
const url = new URL(request.url);
|
||||
const status = url.searchParams.get("status") as FeedbackStatus | null;
|
||||
const limit = Math.min(
|
||||
100,
|
||||
Math.max(1, Number(url.searchParams.get("limit") ?? 50)),
|
||||
);
|
||||
const offset = Math.max(0, Number(url.searchParams.get("offset") ?? 0));
|
||||
|
||||
const where = status ? { status } : {};
|
||||
|
||||
const [items, total, counts] = await Promise.all([
|
||||
prisma.feedback.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
email: true,
|
||||
category: true,
|
||||
subject: true,
|
||||
description: true,
|
||||
status: true,
|
||||
adminNote: true,
|
||||
gitHubIssueUrl: true,
|
||||
metadata: true,
|
||||
screenshotBase64: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
user: { select: { username: true } },
|
||||
},
|
||||
}),
|
||||
prisma.feedback.count({ where }),
|
||||
prisma.feedback.groupBy({
|
||||
by: ["status"],
|
||||
_count: { _all: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
annotate({
|
||||
action: { name: "admin.feedback.list" },
|
||||
meta: { total, filtered_status: status },
|
||||
});
|
||||
|
||||
const countsByStatus = Object.fromEntries(
|
||||
counts.map((c) => [c.status, c._count._all]),
|
||||
) as Record<FeedbackStatus, number>;
|
||||
|
||||
return apiSuccess({
|
||||
items,
|
||||
meta: { total, limit, offset, countsByStatus },
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -7,6 +7,7 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { destroyAllSessions } from "@/lib/auth/session";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
|
||||
export const POST = apiHandler(async (
|
||||
request: NextRequest,
|
||||
@@ -38,7 +39,11 @@ export const POST = apiHandler(async (
|
||||
const target = await prisma.user.findUnique({ where: { id } });
|
||||
if (!target) return apiError("User not found", 404);
|
||||
|
||||
const strength = checkPasswordStrength(password, [target.username]);
|
||||
const locale = await resolveServerLocale({
|
||||
request,
|
||||
userLocale: target.locale ?? null,
|
||||
});
|
||||
const strength = checkPasswordStrength(password, [target.username], locale);
|
||||
if (!strength.isAcceptable) {
|
||||
return apiError(
|
||||
strength.feedback[0] || "Password too weak (score < 3)",
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { resolveProvider } from "@/lib/ai/provider";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "ai.test" } });
|
||||
|
||||
const rl = await checkRateLimit(`ai-test:${user.id}`, 5, 60_000);
|
||||
if (!rl.allowed) return apiError("Too many test requests", 429);
|
||||
|
||||
const provider = await resolveProvider(user.id);
|
||||
if (provider.type === "none") {
|
||||
return apiError("No AI provider configured", 422);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await provider.generateCompletion({
|
||||
systemPrompt: "You are a connection-test responder.",
|
||||
userPrompt: 'Reply with the JSON object {"ok": true} and nothing else.',
|
||||
temperature: 0,
|
||||
maxTokens: 32,
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
ok: true,
|
||||
providerType: result.providerType,
|
||||
model: result.model,
|
||||
tokensUsed: result.tokensUsed,
|
||||
sample: result.content.slice(0, 200),
|
||||
});
|
||||
} catch (e) {
|
||||
const err = e as Error & { httpStatus?: number; bodyExcerpt?: string };
|
||||
return apiError(
|
||||
`${err.message}${err.bodyExcerpt ? ` — ${err.bodyExcerpt.slice(0, 200)}` : ""}`,
|
||||
502,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -20,6 +20,7 @@ export const GET = apiHandler(async () => {
|
||||
"BODY_FAT",
|
||||
"SLEEP_DURATION",
|
||||
"ACTIVITY_STEPS",
|
||||
"BLOOD_GLUCOSE",
|
||||
];
|
||||
|
||||
const measurementsByType = await Promise.all(
|
||||
@@ -110,9 +111,28 @@ export const GET = apiHandler(async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Per-context glucose summaries (canonical mg/dL).
|
||||
const glucoseRows = await prisma.measurement.findMany({
|
||||
where: { userId: user.id, type: "BLOOD_GLUCOSE" },
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { value: true, measuredAt: true, glucoseContext: true },
|
||||
});
|
||||
const glucoseByContext: Record<string, ReturnType<typeof summarize>> = {};
|
||||
if (glucoseRows.length > 0) {
|
||||
const contexts = ["FASTING", "POSTPRANDIAL", "RANDOM", "BEDTIME"] as const;
|
||||
for (const ctx of contexts) {
|
||||
const ctxRows = glucoseRows.filter((r) => r.glucoseContext === ctx);
|
||||
if (ctxRows.length === 0) continue;
|
||||
glucoseByContext[ctx] = summarize(
|
||||
ctxRows.map((r): DataPoint => ({ date: r.measuredAt, value: r.value })),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
summaries: results,
|
||||
bmi,
|
||||
bpInTargetPct,
|
||||
glucoseByContext,
|
||||
});
|
||||
});
|
||||
@@ -21,5 +21,6 @@ export const GET = apiHandler(async () => {
|
||||
timezone: user.timezone,
|
||||
onboardingCompletedAt: user.onboardingCompletedAt,
|
||||
gravatarUrl: user.email ? getGravatarUrl(user.email) : null,
|
||||
glucoseUnit: user.glucoseUnit ?? null,
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { destroyAllSessions, createSession } from "@/lib/auth/session";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
@@ -56,10 +57,15 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
);
|
||||
}
|
||||
|
||||
const strength = checkPasswordStrength(newPassword, [
|
||||
user.username,
|
||||
user.email ?? "",
|
||||
]);
|
||||
const locale = await resolveServerLocale({
|
||||
request,
|
||||
userLocale: user.locale ?? null,
|
||||
});
|
||||
const strength = checkPasswordStrength(
|
||||
newPassword,
|
||||
[user.username, user.email ?? ""],
|
||||
locale,
|
||||
);
|
||||
if (!strength.isAcceptable) {
|
||||
return apiError(
|
||||
strength.feedback[0] || "Password too weak (score < 3)",
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ensureDbCompatibility } from "@/lib/db-compat";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const ip = getClientIp(request) ?? "unknown";
|
||||
@@ -65,8 +66,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return apiError("Username or email already taken", 409);
|
||||
}
|
||||
|
||||
// Validate password strength
|
||||
const strength = checkPasswordStrength(password, [username, email]);
|
||||
// Validate password strength (locale-aware feedback)
|
||||
const locale = await resolveServerLocale({ request });
|
||||
const strength = checkPasswordStrength(password, [username, email], locale);
|
||||
if (!strength.isAcceptable) {
|
||||
return apiError(
|
||||
strength.feedback[0] || "Password too weak (score < 3)",
|
||||
|
||||
@@ -8,11 +8,16 @@ import { decrypt } from "@/lib/crypto";
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
// Strict image/* data URL (no SVG — it can carry inline JS).
|
||||
const SCREENSHOT_DATA_URL =
|
||||
/^data:image\/(png|jpeg|webp|gif);base64,[A-Za-z0-9+/=]+$/;
|
||||
|
||||
const bugReportSchema = z.object({
|
||||
description: z.string().min(10, "Description too short").max(5000),
|
||||
screenshot: z
|
||||
.string()
|
||||
.max(7_000_000, "Screenshot too large (max 5 MB)")
|
||||
.regex(SCREENSHOT_DATA_URL, "Screenshot must be a base64-encoded PNG/JPEG/WEBP/GIF data URL")
|
||||
.optional(),
|
||||
});
|
||||
|
||||
@@ -47,9 +52,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,21 +71,30 @@ 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
|
||||
// Sanitize user-provided content for GitHub markdown. Strip HTML tags
|
||||
// entirely (prevents `<img onerror=>` etc.) and neutralise markdown link
|
||||
// targets that start with dangerous schemes like javascript: or data:.
|
||||
const safeUsername = user.username.replace(/[`*_~<>\[\]|]/g, "");
|
||||
const safeDescription = description
|
||||
.replace(/<[^>]*>/g, "") // strip HTML tags
|
||||
.replace(
|
||||
/\]\(\s*(javascript|data|vbscript|file):/gi,
|
||||
"](about:blank#",
|
||||
) // neutralise dangerous markdown link schemes
|
||||
.replace(/```/g, "\\`\\`\\`")
|
||||
.slice(0, 5000);
|
||||
|
||||
// Build issue body
|
||||
// Wrap the user's description in a fenced code block so any residual
|
||||
// markdown renders literally rather than as executable markup.
|
||||
let issueBody = `**Reported by:** ${safeUsername}\n`;
|
||||
issueBody += `**Date:** ${dateStr}\n\n`;
|
||||
issueBody += `## Description\n\n${safeDescription}\n`;
|
||||
issueBody += `## Description\n\n\`\`\`\n${safeDescription}\n\`\`\`\n`;
|
||||
issueBody += `\n---\n*Created via HealthLog bug report*`;
|
||||
|
||||
// If screenshot provided, upload to GitHub as a gist or include as base64
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* GET / PUT / DELETE dashboard widget layout.
|
||||
*
|
||||
* GET returns the resolved effective layout (defaults merged in if the user
|
||||
* hasn't customized yet). PUT replaces the layout atomically. DELETE resets
|
||||
* to default.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
resolveDashboardLayout,
|
||||
serializeDashboardLayout,
|
||||
DEFAULT_DASHBOARD_LAYOUT,
|
||||
type DashboardLayout,
|
||||
} from "@/lib/dashboard-layout";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { z } from "zod/v4";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
const widgetIdEnum = z.enum([
|
||||
"weight",
|
||||
"bp",
|
||||
"pulse",
|
||||
"bodyFat",
|
||||
"mood",
|
||||
"medications",
|
||||
"sleep",
|
||||
"steps",
|
||||
"glucose",
|
||||
"totalBodyWater",
|
||||
"boneMass",
|
||||
"bpInTarget",
|
||||
]);
|
||||
|
||||
const layoutSchema = z.object({
|
||||
version: z.literal(1),
|
||||
widgets: z
|
||||
.array(
|
||||
z.object({
|
||||
id: widgetIdEnum,
|
||||
visible: z.boolean(),
|
||||
order: z.number().int().min(0).max(99),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(20),
|
||||
});
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { dashboardWidgetsJson: true },
|
||||
});
|
||||
|
||||
const layout = resolveDashboardLayout(row?.dashboardWidgetsJson);
|
||||
return apiSuccess(layout);
|
||||
});
|
||||
|
||||
export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = layoutSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
}
|
||||
|
||||
const normalized = serializeDashboardLayout(parsed.data as DashboardLayout);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
dashboardWidgetsJson: normalized as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "dashboard.widgets.update" },
|
||||
meta: { visible_count: normalized.widgets.filter((w) => w.visible).length },
|
||||
});
|
||||
|
||||
return apiSuccess(normalized);
|
||||
});
|
||||
|
||||
export const DELETE = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { dashboardWidgetsJson: Prisma.JsonNull },
|
||||
});
|
||||
|
||||
annotate({ action: { name: "dashboard.widgets.reset" } });
|
||||
|
||||
return apiSuccess(DEFAULT_DASHBOARD_LAYOUT);
|
||||
});
|
||||
@@ -5,6 +5,12 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { NextRequest } from "next/server";
|
||||
import {
|
||||
getEffectiveRange,
|
||||
type ThresholdOverridesJson,
|
||||
} from "@/lib/analytics/effective-range";
|
||||
import { resolveGlucoseUnit, thresholdMetricForContext } from "@/lib/glucose";
|
||||
import type { GlucoseContext } from "@/generated/prisma/client";
|
||||
|
||||
/**
|
||||
* Collect data for doctor report PDF generation (client-side).
|
||||
@@ -58,6 +64,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
dateOfBirth: true,
|
||||
gender: true,
|
||||
heightCm: true,
|
||||
glucoseUnit: true,
|
||||
thresholdsJson: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
@@ -145,6 +153,46 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
annotate({ meta: { report_days: days } });
|
||||
|
||||
// Per-context glucose stats (canonical mg/dL) and effective ranges.
|
||||
const glucoseContexts: GlucoseContext[] = [
|
||||
"FASTING",
|
||||
"POSTPRANDIAL",
|
||||
"RANDOM",
|
||||
"BEDTIME",
|
||||
];
|
||||
const glucoseStats: Record<
|
||||
string,
|
||||
{ avg: number; min: number; max: number; count: number; latest: number }
|
||||
> = {};
|
||||
const glucoseRanges: Record<string, { min: number; max: number }> = {};
|
||||
const glucoseRows = measurements.filter((m) => m.type === "BLOOD_GLUCOSE");
|
||||
const overrides = (userProfile?.thresholdsJson ?? null) as ThresholdOverridesJson | null;
|
||||
const profileForRange = {
|
||||
heightCm: userProfile?.heightCm ?? null,
|
||||
dateOfBirth: userProfile?.dateOfBirth ?? null,
|
||||
gender: userProfile?.gender ?? null,
|
||||
};
|
||||
for (const ctx of glucoseContexts) {
|
||||
const rows = glucoseRows.filter((m) => m.glucoseContext === ctx);
|
||||
if (rows.length === 0) continue;
|
||||
const values = rows.map((r) => r.value);
|
||||
glucoseStats[ctx] = {
|
||||
avg: values.reduce((a, b) => a + b, 0) / values.length,
|
||||
min: Math.min(...values),
|
||||
max: Math.max(...values),
|
||||
count: values.length,
|
||||
latest: values[values.length - 1],
|
||||
};
|
||||
const eff = getEffectiveRange(
|
||||
thresholdMetricForContext(ctx),
|
||||
profileForRange,
|
||||
overrides,
|
||||
);
|
||||
if (eff.range) {
|
||||
glucoseRanges[ctx] = { min: eff.range.greenMin, max: eff.range.greenMax };
|
||||
}
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
period: { days, since: since.toISOString() },
|
||||
patient: {
|
||||
@@ -155,6 +203,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
},
|
||||
measurements: byType,
|
||||
stats,
|
||||
glucoseStats,
|
||||
glucoseRanges,
|
||||
glucoseUnit: resolveGlucoseUnit(userProfile?.glucoseUnit ?? null),
|
||||
bmi: bmi ? Math.round(bmi * 10) / 10 : null,
|
||||
compliance: complianceByMed,
|
||||
medications: medications.map((m) => ({
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* POST /api/feedback — user submits feedback. Always works, no GitHub needed.
|
||||
* GET /api/feedback — user's own feedback history (paginated).
|
||||
*
|
||||
* Rate-limited at 5 per hour per user. Screenshots are stored as base64 in
|
||||
* the database (≤5 MB each).
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, safeJson, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { createFeedbackSchema } from "@/lib/validations/feedback";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkRateLimit(`feedback:${user.id}`, 5, 60 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many submissions — please try again later", 429);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = createFeedbackSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
}
|
||||
|
||||
const feedback = await prisma.feedback.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
email: user.email ?? null,
|
||||
category: parsed.data.category,
|
||||
subject: parsed.data.subject,
|
||||
description: parsed.data.description,
|
||||
screenshotBase64: parsed.data.screenshot ?? null,
|
||||
metadata:
|
||||
parsed.data.metadata !== undefined
|
||||
? (parsed.data.metadata as Prisma.InputJsonValue)
|
||||
: Prisma.JsonNull,
|
||||
},
|
||||
select: { id: true, createdAt: true, category: true, status: true },
|
||||
});
|
||||
|
||||
await auditLog("feedback.submit", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
feedbackId: feedback.id,
|
||||
category: feedback.category,
|
||||
hasScreenshot: Boolean(parsed.data.screenshot),
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "feedback.submit" },
|
||||
meta: {
|
||||
feedback_id: feedback.id,
|
||||
category: feedback.category,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(feedback, 201);
|
||||
});
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const url = new URL(request.url);
|
||||
const limit = Math.min(
|
||||
50,
|
||||
Math.max(1, Number(url.searchParams.get("limit") ?? 25)),
|
||||
);
|
||||
const offset = Math.max(0, Number(url.searchParams.get("offset") ?? 0));
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
prisma.feedback.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
select: {
|
||||
id: true,
|
||||
category: true,
|
||||
subject: true,
|
||||
status: true,
|
||||
adminNote: true,
|
||||
gitHubIssueUrl: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
}),
|
||||
prisma.feedback.count({ where: { userId: user.id } }),
|
||||
]);
|
||||
|
||||
return apiSuccess({ items, meta: { total, limit, offset } });
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "@/lib/insights/blood-pressure-status";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -13,9 +14,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const localeParam = request.nextUrl.searchParams.get("locale");
|
||||
const locale = resolveBloodPressureStatusLocale(
|
||||
localeParam ?? user.locale ?? "de",
|
||||
);
|
||||
const resolved = await resolveServerLocale({
|
||||
request,
|
||||
userLocale: user.locale ?? null,
|
||||
override: localeParam,
|
||||
});
|
||||
const locale = resolveBloodPressureStatusLocale(resolved);
|
||||
|
||||
const result = await generateBloodPressureStatusForUser(
|
||||
user.id,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "@/lib/insights/bmi-status";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -13,9 +14,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const localeParam = request.nextUrl.searchParams.get("locale");
|
||||
const locale = resolveBmiStatusLocale(
|
||||
localeParam ?? user.locale ?? "de",
|
||||
);
|
||||
const resolved = await resolveServerLocale({
|
||||
request,
|
||||
userLocale: user.locale ?? null,
|
||||
override: localeParam,
|
||||
});
|
||||
const locale = resolveBmiStatusLocale(resolved);
|
||||
|
||||
const result = await generateBmiStatusForUser(user.id, {
|
||||
locale,
|
||||
|
||||
@@ -216,15 +216,30 @@ export const GET = apiHandler(async () => {
|
||||
const bpMedications = medications.filter(
|
||||
(med) => (categoryMap[med.id] ?? "OTHER") === "BLOOD_PRESSURE",
|
||||
);
|
||||
// Single round-trip for all medications instead of N+1: one query keyed
|
||||
// on `medicationId IN (...)`, then group in memory. The previous loop
|
||||
// hit Postgres once per medication, which scales poorly for users with
|
||||
// many active meds.
|
||||
const allEvents = medications.length
|
||||
? await prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
medicationId: { in: medications.map((m) => m.id) },
|
||||
userId,
|
||||
scheduledFor: { gte: ninetyDaysAgo },
|
||||
},
|
||||
orderBy: { scheduledFor: "desc" },
|
||||
})
|
||||
: [];
|
||||
|
||||
const eventsByMed = new Map<string, typeof allEvents>();
|
||||
for (const ev of allEvents) {
|
||||
const list = eventsByMed.get(ev.medicationId);
|
||||
if (list) list.push(ev);
|
||||
else eventsByMed.set(ev.medicationId, [ev]);
|
||||
}
|
||||
|
||||
for (const med of medications) {
|
||||
const events = await prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
medicationId: med.id,
|
||||
userId,
|
||||
scheduledFor: { gte: ninetyDaysAgo },
|
||||
},
|
||||
orderBy: { scheduledFor: "desc" },
|
||||
});
|
||||
const events = eventsByMed.get(med.id) ?? [];
|
||||
const mapped = events.map((e) => ({
|
||||
takenAt: e.takenAt,
|
||||
skipped: e.skipped,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "@/lib/insights/general-status";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -13,9 +14,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const localeParam = request.nextUrl.searchParams.get("locale");
|
||||
const locale = resolveGeneralStatusLocale(
|
||||
localeParam ?? user.locale ?? "de",
|
||||
);
|
||||
const resolved = await resolveServerLocale({
|
||||
request,
|
||||
userLocale: user.locale ?? null,
|
||||
override: localeParam,
|
||||
});
|
||||
const locale = resolveGeneralStatusLocale(resolved);
|
||||
|
||||
const result = await generateGeneralStatusForUser(user.id, {
|
||||
locale,
|
||||
|
||||
@@ -2,13 +2,14 @@ import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
|
||||
import { extractFeatures } from "@/lib/insights/features";
|
||||
import { INSIGHTS_SYSTEM_PROMPT, buildUserPrompt } from "@/lib/insights/prompt";
|
||||
import { getInsightsSystemPrompt, buildUserPrompt } from "@/lib/insights/prompt";
|
||||
import { insightResultSchema, type InsightResult } from "@/lib/ai/types";
|
||||
import { resolveProvider } from "@/lib/ai/provider";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
@@ -25,9 +26,15 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
insightsPrivacyMode: true,
|
||||
insightsCachedAt: true,
|
||||
insightsCachedText: true,
|
||||
locale: true,
|
||||
},
|
||||
});
|
||||
|
||||
const locale = await resolveServerLocale({
|
||||
request,
|
||||
userLocale: dbUser?.locale ?? user.locale ?? null,
|
||||
});
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const forceRefresh = body.force === true;
|
||||
|
||||
@@ -55,10 +62,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const includeRaw = dbUser?.insightsPrivacyMode === "raw";
|
||||
const features = await extractFeatures(userId, includeRaw);
|
||||
const featuresJson = JSON.stringify(features, null, 2);
|
||||
const userPrompt = buildUserPrompt(featuresJson, dbUser?.insightsPrivacyMode ?? "aggregated");
|
||||
const userPrompt = buildUserPrompt(
|
||||
featuresJson,
|
||||
dbUser?.insightsPrivacyMode ?? "aggregated",
|
||||
locale,
|
||||
);
|
||||
|
||||
const result = await provider.generateCompletion({
|
||||
systemPrompt: INSIGHTS_SYSTEM_PROMPT,
|
||||
systemPrompt: getInsightsSystemPrompt(locale),
|
||||
userPrompt,
|
||||
temperature: 0.3,
|
||||
maxTokens: 1500,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "@/lib/insights/medication-compliance-status";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -13,9 +14,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const localeParam = request.nextUrl.searchParams.get("locale");
|
||||
const locale = resolveMedicationComplianceStatusLocale(
|
||||
localeParam ?? user.locale ?? "de",
|
||||
);
|
||||
const resolved = await resolveServerLocale({
|
||||
request,
|
||||
userLocale: user.locale ?? null,
|
||||
override: localeParam,
|
||||
});
|
||||
const locale = resolveMedicationComplianceStatusLocale(resolved);
|
||||
|
||||
const result = await generateMedicationComplianceStatusForUser(
|
||||
user.id,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "@/lib/insights/mood-status";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -13,9 +14,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const localeParam = request.nextUrl.searchParams.get("locale");
|
||||
const locale = resolveMoodStatusLocale(
|
||||
localeParam ?? user.locale ?? "de",
|
||||
);
|
||||
const resolved = await resolveServerLocale({
|
||||
request,
|
||||
userLocale: user.locale ?? null,
|
||||
override: localeParam,
|
||||
});
|
||||
const locale = resolveMoodStatusLocale(resolved);
|
||||
|
||||
const result = await generateMoodStatusForUser(user.id, {
|
||||
locale,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "@/lib/insights/pulse-status";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -13,9 +14,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const localeParam = request.nextUrl.searchParams.get("locale");
|
||||
const locale = resolvePulseStatusLocale(
|
||||
localeParam ?? user.locale ?? "de",
|
||||
);
|
||||
const resolved = await resolveServerLocale({
|
||||
request,
|
||||
userLocale: user.locale ?? null,
|
||||
override: localeParam,
|
||||
});
|
||||
const locale = resolvePulseStatusLocale(resolved);
|
||||
|
||||
const result = await generatePulseStatusForUser(user.id, {
|
||||
locale,
|
||||
|
||||
@@ -17,9 +17,14 @@ import {
|
||||
classifyPulseByTarget,
|
||||
getPersonalizedPulseTarget,
|
||||
} from "@/lib/analytics/pulse-targets";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import type { MeasurementType, GlucoseContext } from "@/generated/prisma/client";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
getEffectiveRange,
|
||||
type ThresholdOverridesJson,
|
||||
} from "@/lib/analytics/effective-range";
|
||||
import { thresholdMetricForContext, resolveGlucoseUnit } from "@/lib/glucose";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -67,6 +72,8 @@ export const GET = apiHandler(async () => {
|
||||
heightCm: true,
|
||||
dateOfBirth: true,
|
||||
gender: true,
|
||||
glucoseUnit: true,
|
||||
thresholdsJson: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -570,6 +577,80 @@ export const GET = apiHandler(async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 10. Blood glucose — one card per logged context.
|
||||
const glucoseContexts: GlucoseContext[] = [
|
||||
"FASTING",
|
||||
"POSTPRANDIAL",
|
||||
"RANDOM",
|
||||
"BEDTIME",
|
||||
];
|
||||
const glucoseUnit = resolveGlucoseUnit(dbUser?.glucoseUnit ?? null);
|
||||
const overrides = (dbUser?.thresholdsJson ?? null) as ThresholdOverridesJson | null;
|
||||
const profileForRange = {
|
||||
heightCm,
|
||||
dateOfBirth: dbUser?.dateOfBirth ?? null,
|
||||
gender: dbUser?.gender ?? null,
|
||||
};
|
||||
const thirtyDaysAgoGlucose = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
const glucoseRows = await prisma.measurement.findMany({
|
||||
where: { userId, type: "BLOOD_GLUCOSE" },
|
||||
orderBy: { measuredAt: "desc" },
|
||||
select: { value: true, measuredAt: true, glucoseContext: true },
|
||||
});
|
||||
|
||||
const labelKeyByContext: Record<GlucoseContext, string> = {
|
||||
FASTING: "targets.glucoseFasting",
|
||||
POSTPRANDIAL: "targets.glucosePostprandial",
|
||||
RANDOM: "targets.glucoseRandom",
|
||||
BEDTIME: "targets.glucoseBedtime",
|
||||
};
|
||||
|
||||
for (const ctx of glucoseContexts) {
|
||||
const ctxRows = glucoseRows.filter((r) => r.glucoseContext === ctx);
|
||||
if (ctxRows.length === 0) continue;
|
||||
const latest = ctxRows[0].value;
|
||||
const recent = ctxRows.filter((r) => r.measuredAt >= thirtyDaysAgoGlucose);
|
||||
const avg30 =
|
||||
recent.length > 0
|
||||
? Math.round(
|
||||
(recent.reduce((s, r) => s + r.value, 0) / recent.length) * 10,
|
||||
) / 10
|
||||
: null;
|
||||
|
||||
const metric = thresholdMetricForContext(ctx);
|
||||
const eff = getEffectiveRange(metric, profileForRange, overrides);
|
||||
const range = eff.range
|
||||
? { min: eff.range.greenMin, max: eff.range.greenMax }
|
||||
: null;
|
||||
|
||||
let classification: { category: string; color: string } | null = null;
|
||||
if (range) {
|
||||
if (latest >= range.min && latest <= range.max) {
|
||||
classification = { category: "Optimal", color: "#50fa7b" };
|
||||
} else if (
|
||||
eff.range &&
|
||||
latest >= eff.range.orangeMin &&
|
||||
latest <= eff.range.orangeMax
|
||||
) {
|
||||
classification = { category: "Elevated", color: "#f1fa8c" };
|
||||
} else {
|
||||
classification = { category: "High", color: "#ff5555" };
|
||||
}
|
||||
}
|
||||
|
||||
targets.push({
|
||||
type: `BLOOD_GLUCOSE_${ctx}`,
|
||||
label: labelKeyByContext[ctx],
|
||||
current: latest,
|
||||
average30: avg30,
|
||||
trend: null,
|
||||
unit: glucoseUnit,
|
||||
range,
|
||||
classification,
|
||||
source: eff.isOverride ? "Custom" : "ADA 2024 / DDG",
|
||||
});
|
||||
}
|
||||
|
||||
annotate({ action: { name: "insights.targets" }, meta: { targetCount: targets.length } });
|
||||
|
||||
return apiSuccess({
|
||||
@@ -584,6 +665,7 @@ export const GET = apiHandler(async () => {
|
||||
heightCm,
|
||||
age,
|
||||
gender,
|
||||
glucoseUnit,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "@/lib/insights/weight-status";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -13,9 +14,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const localeParam = request.nextUrl.searchParams.get("locale");
|
||||
const locale = resolveWeightStatusLocale(
|
||||
localeParam ?? user.locale ?? "de",
|
||||
);
|
||||
const resolved = await resolveServerLocale({
|
||||
request,
|
||||
userLocale: user.locale ?? null,
|
||||
override: localeParam,
|
||||
});
|
||||
const locale = resolveWeightStatusLocale(resolved);
|
||||
|
||||
const result = await generateWeightStatusForUser(user.id, {
|
||||
locale,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { NextRequest } from "next/server";
|
||||
import type {
|
||||
MeasurementType,
|
||||
MeasurementSource,
|
||||
GlucoseContext,
|
||||
} from "@/generated/prisma/client";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
@@ -85,6 +86,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
source: (m.source ?? "MANUAL") as MeasurementSource,
|
||||
measuredAt: m.measuredAt,
|
||||
notes: m.notes ?? null,
|
||||
glucoseContext:
|
||||
(m.glucoseContext as GlucoseContext | undefined) ?? null,
|
||||
},
|
||||
}),
|
||||
),
|
||||
@@ -116,7 +119,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
}
|
||||
|
||||
const { type, value, measuredAt, notes, source } = parsed.data;
|
||||
const { type, value, measuredAt, notes, source, glucoseContext } =
|
||||
parsed.data;
|
||||
|
||||
// Handle unique constraint violation
|
||||
let measurement;
|
||||
@@ -130,6 +134,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
source: (source ?? "MANUAL") as MeasurementSource,
|
||||
measuredAt,
|
||||
notes: notes ?? null,
|
||||
glucoseContext: (glucoseContext as GlucoseContext | undefined) ?? null,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@@ -11,6 +11,8 @@ import { getClientIp } from "@/lib/api-response";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { locales, type Locale } from "@/lib/i18n/config";
|
||||
|
||||
interface TelegramUpdate {
|
||||
update_id: number;
|
||||
@@ -56,6 +58,20 @@ function escapeHtml(input: string): string {
|
||||
.replaceAll(">", ">");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the bot locale for an existing Telegram user.
|
||||
*
|
||||
* Defaults to "de" when User.locale is null — existing bots were set up in
|
||||
* German and we don't want to silently flip them. Users who have switched
|
||||
* the UI to English (User.locale = "en") get an English bot too.
|
||||
*/
|
||||
function resolveBotLocale(value: string | null | undefined): Locale {
|
||||
if (value && (locales as readonly string[]).includes(value)) {
|
||||
return value as Locale;
|
||||
}
|
||||
return "de";
|
||||
}
|
||||
|
||||
async function cleanupReminderTracking(medicationId: string): Promise<void> {
|
||||
try {
|
||||
await prisma.telegramReminderMessage.deleteMany({
|
||||
@@ -98,6 +114,7 @@ async function findTelegramUser(chatId: string) {
|
||||
select: {
|
||||
id: true,
|
||||
telegramBotToken: true,
|
||||
locale: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -106,13 +123,15 @@ async function markMedicationTaken(
|
||||
userId: string,
|
||||
medicationId: string,
|
||||
idempotencyKey: string,
|
||||
locale: Locale,
|
||||
): Promise<{ ok: boolean; message: string; medicationName?: string }> {
|
||||
const { t } = getServerTranslator(locale);
|
||||
const medication = await prisma.medication.findFirst({
|
||||
where: { id: medicationId, userId, active: true },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
if (!medication) {
|
||||
return { ok: false, message: "Medikament nicht gefunden oder inaktiv." };
|
||||
return { ok: false, message: t("telegram.errorMedicationInactive") };
|
||||
}
|
||||
|
||||
const existing = await prisma.medicationIntakeEvent.findFirst({
|
||||
@@ -143,8 +162,8 @@ async function markMedicationTaken(
|
||||
return {
|
||||
ok: true,
|
||||
message: existing
|
||||
? `${medication.name} war bereits erfasst.`
|
||||
: `${medication.name} als eingenommen erfasst.`,
|
||||
? t("telegram.alreadyRecorded", { name: medication.name })
|
||||
: t("telegram.recordedAsTaken", { name: medication.name }),
|
||||
medicationName: medication.name,
|
||||
};
|
||||
}
|
||||
@@ -160,6 +179,8 @@ async function handleCallback(update: TelegramUpdate) {
|
||||
const user = await findTelegramUser(chatId);
|
||||
if (!user?.telegramBotToken) return;
|
||||
const botToken = decrypt(user.telegramBotToken);
|
||||
const locale = resolveBotLocale(user.locale);
|
||||
const { t } = getServerTranslator(locale);
|
||||
|
||||
const data = callback.data ?? "";
|
||||
const messageId = callback.message?.message_id;
|
||||
@@ -170,7 +191,7 @@ async function handleCallback(update: TelegramUpdate) {
|
||||
await answerTelegramCallbackQuery(
|
||||
botToken,
|
||||
callback.id,
|
||||
"Ungültige Aktion.",
|
||||
t("telegram.errorInvalidAction"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -183,6 +204,7 @@ async function handleCallback(update: TelegramUpdate) {
|
||||
user.id,
|
||||
medicationId,
|
||||
idempotencyKey,
|
||||
locale,
|
||||
);
|
||||
await answerTelegramCallbackQuery(botToken, callback.id, result.message);
|
||||
if (messageId) {
|
||||
@@ -198,7 +220,7 @@ async function handleCallback(update: TelegramUpdate) {
|
||||
await answerTelegramCallbackQuery(
|
||||
botToken,
|
||||
callback.id,
|
||||
"Ungültige Aktion.",
|
||||
t("telegram.errorInvalidAction"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -211,7 +233,7 @@ async function handleCallback(update: TelegramUpdate) {
|
||||
await answerTelegramCallbackQuery(
|
||||
botToken,
|
||||
callback.id,
|
||||
"Medikament nicht gefunden.",
|
||||
t("telegram.errorMedicationNotFound"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -221,11 +243,12 @@ async function handleCallback(update: TelegramUpdate) {
|
||||
data: { snoozedUntil: new Date(Date.now() + minutes * 60000) },
|
||||
});
|
||||
|
||||
const label = minutes <= 60 ? "1 Stunde" : "3 Stunden";
|
||||
const duration =
|
||||
minutes <= 60 ? t("telegram.snoozeOneHour") : t("telegram.snoozeThreeHours");
|
||||
await answerTelegramCallbackQuery(
|
||||
botToken,
|
||||
callback.id,
|
||||
`${medication.name} für ${label} zurückgestellt.`,
|
||||
t("telegram.snoozedFor", { name: medication.name, duration }),
|
||||
);
|
||||
if (messageId) {
|
||||
await deleteMessage(botToken, chatId, messageId);
|
||||
@@ -237,7 +260,7 @@ async function handleCallback(update: TelegramUpdate) {
|
||||
await answerTelegramCallbackQuery(
|
||||
botToken,
|
||||
callback.id,
|
||||
"Ungültige Aktion.",
|
||||
t("telegram.errorInvalidAction"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -250,7 +273,7 @@ async function handleCallback(update: TelegramUpdate) {
|
||||
await answerTelegramCallbackQuery(
|
||||
botToken,
|
||||
callback.id,
|
||||
"Medikament nicht gefunden.",
|
||||
t("telegram.errorMedicationNotFound"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -290,7 +313,7 @@ async function handleCallback(update: TelegramUpdate) {
|
||||
await answerTelegramCallbackQuery(
|
||||
botToken,
|
||||
callback.id,
|
||||
`${medication.name} übersprungen.`,
|
||||
t("telegram.skipped", { name: medication.name }),
|
||||
);
|
||||
if (messageId) {
|
||||
await deleteMessage(botToken, chatId, messageId);
|
||||
@@ -299,7 +322,11 @@ async function handleCallback(update: TelegramUpdate) {
|
||||
} else if (data.startsWith("ack:")) {
|
||||
const medicationId = data.slice("ack:".length).trim();
|
||||
if (!medicationId) {
|
||||
await answerTelegramCallbackQuery(botToken, callback.id, "Ungültige Aktion.");
|
||||
await answerTelegramCallbackQuery(
|
||||
botToken,
|
||||
callback.id,
|
||||
t("telegram.errorInvalidAction"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -311,7 +338,9 @@ async function handleCallback(update: TelegramUpdate) {
|
||||
await answerTelegramCallbackQuery(
|
||||
botToken,
|
||||
callback.id,
|
||||
medication ? `${medication.name} bestätigt.` : "Bestätigt.",
|
||||
medication
|
||||
? t("telegram.confirmed", { name: medication.name })
|
||||
: t("telegram.genericConfirmed"),
|
||||
);
|
||||
if (messageId) {
|
||||
await deleteMessage(botToken, chatId, messageId);
|
||||
@@ -323,7 +352,11 @@ async function handleCallback(update: TelegramUpdate) {
|
||||
const umidIdx = withoutPrefix.indexOf(":umid:");
|
||||
const medicationId = umidIdx >= 0 ? withoutPrefix.slice(0, umidIdx) : withoutPrefix.trim();
|
||||
if (!medicationId) {
|
||||
await answerTelegramCallbackQuery(botToken, callback.id, "Ungültige Aktion.");
|
||||
await answerTelegramCallbackQuery(
|
||||
botToken,
|
||||
callback.id,
|
||||
t("telegram.errorInvalidAction"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -331,7 +364,12 @@ async function handleCallback(update: TelegramUpdate) {
|
||||
const idempotencyKey =
|
||||
`telegram:add:${chatId}:${msgId}:${medicationId}`.slice(0, 128);
|
||||
|
||||
const result = await markMedicationTaken(user.id, medicationId, idempotencyKey);
|
||||
const result = await markMedicationTaken(
|
||||
user.id,
|
||||
medicationId,
|
||||
idempotencyKey,
|
||||
locale,
|
||||
);
|
||||
await answerTelegramCallbackQuery(botToken, callback.id, result.message);
|
||||
if (messageId) {
|
||||
await deleteMessage(botToken, chatId, messageId);
|
||||
@@ -357,12 +395,12 @@ async function handleCallback(update: TelegramUpdate) {
|
||||
await deleteMessage(botToken, chatId, userMsgId).catch(() => {});
|
||||
}
|
||||
}
|
||||
await answerTelegramCallbackQuery(botToken, callback.id, "Abgebrochen.");
|
||||
await answerTelegramCallbackQuery(botToken, callback.id, t("telegram.cancelled"));
|
||||
} else {
|
||||
await answerTelegramCallbackQuery(
|
||||
botToken,
|
||||
callback.id,
|
||||
"Unbekannte Aktion.",
|
||||
t("telegram.errorUnknownAction"),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -376,23 +414,21 @@ async function handleTextMessage(update: TelegramUpdate) {
|
||||
const user = await findTelegramUser(chatId);
|
||||
if (!user?.telegramBotToken) return;
|
||||
const botToken = decrypt(user.telegramBotToken);
|
||||
const locale = resolveBotLocale(user.locale);
|
||||
const { t } = getServerTranslator(locale);
|
||||
|
||||
if (/^\/help\b/i.test(text) || /^\/start\b/i.test(text) || /^hilfe$/i.test(text)) {
|
||||
// "Help" / start: accept English + German keyword aliases independent of locale
|
||||
if (
|
||||
/^\/help\b/i.test(text) ||
|
||||
/^\/start\b/i.test(text) ||
|
||||
/^hilfe$/i.test(text) ||
|
||||
/^help$/i.test(text)
|
||||
) {
|
||||
const userMsgId = message?.message_id;
|
||||
const resp = await sendTelegramMessage(
|
||||
botToken,
|
||||
chatId,
|
||||
`<b>Verfügbare Befehle:</b>\n\n` +
|
||||
`/help — Diese Hilfe anzeigen\n` +
|
||||
`/add — Einnahme erfassen\n` +
|
||||
`/start — Bot starten\n\n` +
|
||||
`<b>Textbefehle:</b>\n` +
|
||||
`genommen <Name> — Einnahme bestätigen\n\n` +
|
||||
`<b>Über die Buttons in Erinnerungen:</b>\n` +
|
||||
`• Genommen — Einnahme bestätigen\n` +
|
||||
`• 🕐 1h / 🕐 3h — Erinnerung verschieben\n` +
|
||||
`• ⏭ Überspringen — Einnahme überspringen\n` +
|
||||
`• ✓ Bestätigen — Verpasste Einnahme bestätigen`,
|
||||
`${t("telegram.helpHeader")}\n\n${t("telegram.helpBody")}`,
|
||||
);
|
||||
const toDelete = [userMsgId, resp.messageId].filter(
|
||||
(id): id is number => id != null,
|
||||
@@ -415,7 +451,7 @@ async function handleTextMessage(update: TelegramUpdate) {
|
||||
const resp = await sendTelegramMessage(
|
||||
botToken,
|
||||
chatId,
|
||||
"Keine aktiven Medikamente gefunden.",
|
||||
t("telegram.noActiveMedications"),
|
||||
);
|
||||
const toDelete = [userMsgId, resp.messageId].filter(
|
||||
(id): id is number => id != null,
|
||||
@@ -433,6 +469,7 @@ async function handleTextMessage(update: TelegramUpdate) {
|
||||
user.id,
|
||||
meds[0].id,
|
||||
idempotencyKey,
|
||||
locale,
|
||||
);
|
||||
const resp = await sendTelegramMessage(
|
||||
botToken,
|
||||
@@ -462,7 +499,7 @@ async function handleTextMessage(update: TelegramUpdate) {
|
||||
]),
|
||||
[
|
||||
{
|
||||
text: "Abbrechen",
|
||||
text: t("telegram.cancelButton"),
|
||||
callback_data: `cancel_add${umidSuffix}`,
|
||||
},
|
||||
],
|
||||
@@ -471,7 +508,7 @@ async function handleTextMessage(update: TelegramUpdate) {
|
||||
const resp = await sendTelegramMessage(
|
||||
botToken,
|
||||
chatId,
|
||||
"<b>Welches Medikament eingenommen?</b>",
|
||||
t("telegram.whichMedication"),
|
||||
{ parseMode: "HTML", replyMarkup: keyboard },
|
||||
);
|
||||
// Fallback auto-delete if user never interacts with the selection
|
||||
@@ -484,8 +521,8 @@ async function handleTextMessage(update: TelegramUpdate) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Greeting responses
|
||||
const greetings = ["hi", "hallo", "hey", "moin"];
|
||||
// Greeting responses (kept locale-independent)
|
||||
const greetings = ["hi", "hallo", "hey", "moin", "hello"];
|
||||
const lowerText = text.toLowerCase();
|
||||
const matchedGreeting = greetings.find((g) => lowerText === g);
|
||||
if (matchedGreeting) {
|
||||
@@ -501,10 +538,13 @@ async function handleTextMessage(update: TelegramUpdate) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^genommen\b/i.test(text)) return;
|
||||
// Accept both "genommen <name>" (DE) and "taken <name>" (EN) regardless of
|
||||
// the user's UI locale — keeps backwards compatibility.
|
||||
const intakeKeyword = /^(?:genommen|taken)\b/i;
|
||||
if (!intakeKeyword.test(text)) return;
|
||||
|
||||
const userMsgId = message?.message_id;
|
||||
const nameInput = text.replace(/^genommen\b/i, "").trim();
|
||||
const nameInput = text.replace(intakeKeyword, "").trim();
|
||||
|
||||
let medicationId: string | null = null;
|
||||
if (nameInput) {
|
||||
@@ -530,7 +570,7 @@ async function handleTextMessage(update: TelegramUpdate) {
|
||||
const resp = await sendTelegramMessage(
|
||||
botToken,
|
||||
chatId,
|
||||
"Bitte Medikament angeben, z.B. <b>genommen Ramipril</b>.",
|
||||
t("telegram.askMedicationName"),
|
||||
);
|
||||
const toDelete = [userMsgId, resp.messageId].filter(
|
||||
(id): id is number => id != null,
|
||||
@@ -546,7 +586,7 @@ async function handleTextMessage(update: TelegramUpdate) {
|
||||
const resp = await sendTelegramMessage(
|
||||
botToken,
|
||||
chatId,
|
||||
"Medikament nicht gefunden. Bitte Namen exakt wie in HealthLog senden.",
|
||||
t("telegram.medicationNotFoundExact"),
|
||||
);
|
||||
const toDelete = [userMsgId, resp.messageId].filter(
|
||||
(id): id is number => id != null,
|
||||
@@ -563,6 +603,7 @@ async function handleTextMessage(update: TelegramUpdate) {
|
||||
user.id,
|
||||
medicationId,
|
||||
idempotencyKey,
|
||||
locale,
|
||||
);
|
||||
const resp = await sendTelegramMessage(
|
||||
botToken,
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import { isPublicUrl } from "@/lib/validations/notifications";
|
||||
import { encrypt, decrypt } from "@/lib/crypto";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const ALLOWED = new Set(["OPENAI", "ANTHROPIC", "LOCAL", "CHATGPT_OAUTH"]);
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "user.ai-provider.get" } });
|
||||
|
||||
const u = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
aiProvider: true,
|
||||
aiModel: true,
|
||||
aiBaseUrl: true,
|
||||
aiAnthropicKeyEncrypted: true,
|
||||
aiLocalKeyEncrypted: true,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
provider: u?.aiProvider ?? null,
|
||||
model: u?.aiModel ?? null,
|
||||
baseUrl: u?.aiBaseUrl ?? null,
|
||||
hasAnthropicKey: Boolean(u?.aiAnthropicKeyEncrypted),
|
||||
anthropicKeyPreview: u?.aiAnthropicKeyEncrypted
|
||||
? `...${decrypt(u.aiAnthropicKeyEncrypted).slice(-4)}`
|
||||
: null,
|
||||
hasLocalKey: Boolean(u?.aiLocalKeyEncrypted),
|
||||
});
|
||||
});
|
||||
|
||||
export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "user.ai-provider.update" } });
|
||||
|
||||
const { data: body, error } = await safeJson<Record<string, unknown>>(request);
|
||||
if (error) return error;
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
|
||||
if (body.provider !== undefined) {
|
||||
if (body.provider === null || body.provider === "") {
|
||||
updates.aiProvider = null;
|
||||
} else if (typeof body.provider === "string" && ALLOWED.has(body.provider)) {
|
||||
updates.aiProvider = body.provider;
|
||||
} else {
|
||||
throw new HttpError(422, "Invalid provider");
|
||||
}
|
||||
}
|
||||
|
||||
if (body.model !== undefined) {
|
||||
if (body.model === null || body.model === "") {
|
||||
updates.aiModel = null;
|
||||
} else if (typeof body.model === "string") {
|
||||
updates.aiModel = body.model.trim() || null;
|
||||
}
|
||||
}
|
||||
|
||||
if (body.baseUrl !== undefined) {
|
||||
if (body.baseUrl === null || body.baseUrl === "") {
|
||||
updates.aiBaseUrl = null;
|
||||
} else if (typeof body.baseUrl === "string") {
|
||||
const trimmed = body.baseUrl.trim();
|
||||
// SSRF guard: by default reject private/internal hostnames so a
|
||||
// compromised user account cannot point the server at the cloud
|
||||
// metadata endpoint or internal admin panels. Ops can enable local
|
||||
// Ollama / LM Studio on this instance via env flag.
|
||||
const allowPrivate = process.env.ALLOW_LOCAL_AI_PRIVATE_HOSTS === "true";
|
||||
if (!allowPrivate && !isPublicUrl(trimmed)) {
|
||||
return apiError(
|
||||
"Base URL points to an internal/private host. Ops must set ALLOW_LOCAL_AI_PRIVATE_HOSTS=true on this instance to allow it (intended for self-hosted Ollama / LM Studio).",
|
||||
422,
|
||||
);
|
||||
}
|
||||
updates.aiBaseUrl = trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
if (body.anthropicKey !== undefined) {
|
||||
if (body.anthropicKey === null || body.anthropicKey === "") {
|
||||
updates.aiAnthropicKeyEncrypted = null;
|
||||
} else if (typeof body.anthropicKey === "string") {
|
||||
updates.aiAnthropicKeyEncrypted = encrypt(body.anthropicKey.trim());
|
||||
}
|
||||
}
|
||||
|
||||
if (body.localKey !== undefined) {
|
||||
if (body.localKey === null || body.localKey === "") {
|
||||
updates.aiLocalKeyEncrypted = null;
|
||||
} else if (typeof body.localKey === "string") {
|
||||
updates.aiLocalKeyEncrypted = encrypt(body.localKey.trim());
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
throw new HttpError(422, "No valid fields");
|
||||
}
|
||||
|
||||
await prisma.user.update({ where: { id: user.id }, data: updates });
|
||||
|
||||
return apiSuccess({ updated: true });
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* GET / PUT / DELETE per-user threshold overrides.
|
||||
*
|
||||
* GET returns `{ defaults, overrides, effective }` so the settings UI can
|
||||
* show a side-by-side comparison.
|
||||
* PUT accepts a partial map — only the provided metrics are updated, others
|
||||
* keep their existing override or remain on the default.
|
||||
* DELETE with ?metric=... resets one metric to default.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError, safeJson, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
getAllEffectiveRanges,
|
||||
type ThresholdOverridesJson,
|
||||
type ThresholdMetric,
|
||||
} from "@/lib/analytics/effective-range";
|
||||
import {
|
||||
thresholdsUpdateSchema,
|
||||
ALL_METRICS,
|
||||
} from "@/lib/validations/thresholds";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
async function loadProfileAndOverrides(userId: string) {
|
||||
const profile = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
heightCm: true,
|
||||
dateOfBirth: true,
|
||||
gender: true,
|
||||
thresholdsJson: true,
|
||||
},
|
||||
});
|
||||
if (!profile) return null;
|
||||
const overrides = (profile.thresholdsJson ?? null) as
|
||||
| ThresholdOverridesJson
|
||||
| null;
|
||||
return { profile, overrides };
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "user.thresholds.get" } });
|
||||
|
||||
const result = await loadProfileAndOverrides(user.id);
|
||||
if (!result) return apiError("User not found", 404);
|
||||
|
||||
const ranges = getAllEffectiveRanges(result.profile, result.overrides);
|
||||
return apiSuccess({
|
||||
effective: ranges,
|
||||
overrides: result.overrides ?? {},
|
||||
});
|
||||
});
|
||||
|
||||
export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "user.thresholds.update" } });
|
||||
|
||||
// 30 writes / 5 min — generous for legitimate UI edits, tight enough to
|
||||
// make audit-log enumeration unattractive. Per-user, not per-IP.
|
||||
const rl = await checkRateLimit(
|
||||
`thresholds:update:${user.id}`,
|
||||
30,
|
||||
5 * 60 * 1000,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many requests, please slow down", 429);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = thresholdsUpdateSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
}
|
||||
|
||||
const existing = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { thresholdsJson: true },
|
||||
});
|
||||
const before = (existing?.thresholdsJson ?? {}) as ThresholdOverridesJson;
|
||||
const merged: ThresholdOverridesJson = { ...before, ...parsed.data };
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { thresholdsJson: merged },
|
||||
});
|
||||
|
||||
await auditLog("thresholds.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { before, after: merged },
|
||||
});
|
||||
|
||||
return apiSuccess({ overrides: merged });
|
||||
});
|
||||
|
||||
export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
const url = new URL(request.url);
|
||||
const metric = url.searchParams.get("metric") as ThresholdMetric | null;
|
||||
|
||||
annotate({
|
||||
action: { name: "thresholds.reset" },
|
||||
meta: { metric: metric ?? "all" },
|
||||
});
|
||||
|
||||
const existing = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { thresholdsJson: true },
|
||||
});
|
||||
const before = (existing?.thresholdsJson ?? {}) as ThresholdOverridesJson;
|
||||
|
||||
let after: ThresholdOverridesJson = before;
|
||||
if (metric) {
|
||||
if (!ALL_METRICS.includes(metric)) {
|
||||
return apiError("Unknown metric", 400);
|
||||
}
|
||||
after = { ...before };
|
||||
delete after[metric];
|
||||
} else {
|
||||
after = {};
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
thresholdsJson:
|
||||
Object.keys(after).length === 0 ? Prisma.JsonNull : after,
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("thresholds.reset", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { metric: metric ?? "all", before, after },
|
||||
});
|
||||
|
||||
return apiSuccess({ overrides: after });
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+108
-91
@@ -1,55 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
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 { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
Bug,
|
||||
GitPullRequest,
|
||||
Info,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
interface BugReportStatus {
|
||||
configured: boolean;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
const CATEGORIES = [
|
||||
{ value: "BUG", labelKey: "bugreport.categoryBug" },
|
||||
{ value: "FEATURE_REQUEST", labelKey: "bugreport.categoryFeature" },
|
||||
{ value: "QUESTION", labelKey: "bugreport.categoryQuestion" },
|
||||
{ value: "OTHER", labelKey: "bugreport.categoryOther" },
|
||||
] as const;
|
||||
|
||||
type CategoryValue = (typeof CATEGORIES)[number]["value"];
|
||||
|
||||
export default function BugReportPage() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { t, locale } = useTranslations();
|
||||
const [category, setCategory] = useState<CategoryValue>("BUG");
|
||||
const [subject, setSubject] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [screenshot, setScreenshot] = useState<string | null>(null);
|
||||
const [screenshotName, setScreenshotName] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<{
|
||||
type: "success" | "error";
|
||||
message: string;
|
||||
} | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const { t } = useTranslations();
|
||||
|
||||
async function handleScreenshot(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!file.type.startsWith("image/")) {
|
||||
setResult({ type: "error", message: t("bugreport.imageOnly") });
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
setResult({
|
||||
type: "error",
|
||||
message: t("bugreport.tooLarge"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
setScreenshot(reader.result as string);
|
||||
setScreenshotName(file.name);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
function removeScreenshot() {
|
||||
setScreenshot(null);
|
||||
setScreenshotName(null);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}
|
||||
// Status is now informational only — submission always works.
|
||||
const { data: status } = 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 handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
@@ -57,23 +67,28 @@ export default function BugReportPage() {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/bugreport", {
|
||||
const res = await fetch("/api/feedback", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
category,
|
||||
subject: subject || description.slice(0, 60),
|
||||
description,
|
||||
...(screenshot ? { screenshot } : {}),
|
||||
metadata: {
|
||||
locale,
|
||||
userAgent:
|
||||
typeof navigator !== "undefined" ? navigator.userAgent : null,
|
||||
url:
|
||||
typeof window !== "undefined" ? window.location.pathname : null,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const json = await res.json();
|
||||
if (res.ok) {
|
||||
setResult({
|
||||
type: "success",
|
||||
message: t("bugreport.success"),
|
||||
});
|
||||
setResult({ type: "success", message: t("bugreport.success") });
|
||||
setSubject("");
|
||||
setDescription("");
|
||||
removeScreenshot();
|
||||
} else {
|
||||
setResult({
|
||||
type: "error",
|
||||
@@ -111,59 +126,70 @@ export default function BugReportPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{status?.configured && (
|
||||
<div className="bg-card border-border flex gap-2 rounded-lg border-l-4 border-l-dracula-cyan p-3 text-sm">
|
||||
<GitPullRequest className="mt-0.5 h-4 w-4 shrink-0 text-dracula-cyan" />
|
||||
<p className="text-muted-foreground">
|
||||
{t("bugreport.githubEscalationNote")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!status?.configured && (
|
||||
<div className="bg-card border-border flex gap-2 rounded-lg border-l-4 border-l-dracula-purple p-3 text-sm">
|
||||
<Info className="mt-0.5 h-4 w-4 shrink-0 text-dracula-purple" />
|
||||
<p className="text-muted-foreground">
|
||||
{t("bugreport.internalOnlyNote")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-card border-border w-full rounded-xl border p-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-[200px_1fr]">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category">{t("bugreport.category")}</Label>
|
||||
<Select
|
||||
value={category}
|
||||
onValueChange={(v) => setCategory(v as CategoryValue)}
|
||||
>
|
||||
<SelectTrigger id="category">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CATEGORIES.map((c) => (
|
||||
<SelectItem key={c.value} value={c.value}>
|
||||
{t(c.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subject">{t("bugreport.bugTitle")}</Label>
|
||||
<Input
|
||||
id="subject"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
placeholder={t("bugreport.bugTitlePlaceholder")}
|
||||
maxLength={200}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bug-desc">{t("bugreport.description")}</Label>
|
||||
<textarea
|
||||
id="bug-desc"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder=""
|
||||
required
|
||||
minLength={10}
|
||||
maxLength={5000}
|
||||
rows={9}
|
||||
rows={8}
|
||||
className="border-input bg-background text-foreground placeholder:text-muted-foreground focus-visible:ring-ring w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("bugreport.screenshot")}</Label>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleScreenshot}
|
||||
className="hidden"
|
||||
/>
|
||||
{screenshot ? (
|
||||
<div className="bg-muted/50 relative rounded-lg p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ImageIcon className="text-muted-foreground h-4 w-4" />
|
||||
<span className="flex-1 truncate text-sm">
|
||||
{screenshotName}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={removeScreenshot}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={screenshot}
|
||||
alt={t("bugreport.screenshotPreview")}
|
||||
className="mt-2 max-h-48 rounded border object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<div
|
||||
className={`rounded-lg p-3 text-sm ${
|
||||
@@ -179,16 +205,7 @@ export default function BugReportPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{t("bugreport.attachScreenshot")}
|
||||
</Button>
|
||||
<div className="flex items-center justify-end pt-2">
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
<Bug className="mr-2 h-4 w-4" />
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
+26
-4
@@ -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
|
||||
@@ -72,7 +94,7 @@ export default async function RootLayout({
|
||||
/>
|
||||
</head>
|
||||
<body className={`${inter.variable} font-sans antialiased`}>
|
||||
<Providers>
|
||||
<Providers initialLocale={initialLocale}>
|
||||
<MonitoringBootstrap />
|
||||
<AuthShell>{children}</AuthShell>
|
||||
</Providers>
|
||||
|
||||
@@ -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");
|
||||
|
||||
+87
-51
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -39,45 +40,63 @@ export default function OnboardingPage() {
|
||||
async function finishOnboarding() {
|
||||
setSaving(true);
|
||||
|
||||
const body: Record<string, unknown> = {};
|
||||
if (heightCm) body.heightCm = parseFloat(heightCm);
|
||||
if (dateOfBirth) body.dateOfBirth = dateOfBirth;
|
||||
if (gender) body.gender = gender;
|
||||
try {
|
||||
const body: Record<string, unknown> = {};
|
||||
if (heightCm) body.heightCm = parseFloat(heightCm);
|
||||
if (dateOfBirth) body.dateOfBirth = dateOfBirth;
|
||||
if (gender) body.gender = gender;
|
||||
|
||||
await fetch("/api/onboarding/complete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
// Create medications if any
|
||||
for (const med of meds) {
|
||||
await fetch("/api/medications", {
|
||||
const profileRes = await fetch("/api/onboarding/complete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: med.name,
|
||||
dose: med.dose,
|
||||
schedules: [],
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
if (!profileRes.ok) {
|
||||
throw new Error(t("onboarding.errorProfile"));
|
||||
}
|
||||
|
||||
// Save targets if set
|
||||
if (targetSys || targetDia || targetWeight) {
|
||||
const targets: Record<string, unknown> = {};
|
||||
if (targetSys) targets.bpSysTarget = parseInt(targetSys);
|
||||
if (targetDia) targets.bpDiaTarget = parseInt(targetDia);
|
||||
if (targetWeight) targets.weightTarget = parseFloat(targetWeight);
|
||||
await fetch("/api/insights/targets", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(targets),
|
||||
});
|
||||
}
|
||||
// Create medications. Server validation requires at least one schedule,
|
||||
// so we attach a sensible default — the user is informed via medScheduleHint
|
||||
// and can refine it in Medications later. Phase 3.5 of the audit replaces
|
||||
// this entire wizard with an empty-state-driven flow.
|
||||
for (const med of meds) {
|
||||
const medRes = await fetch("/api/medications", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: med.name,
|
||||
dose: med.dose,
|
||||
schedules: [{ windowStart: "08:00", windowEnd: "09:00" }],
|
||||
}),
|
||||
});
|
||||
if (!medRes.ok) {
|
||||
throw new Error(t("onboarding.errorMedication", { name: med.name }));
|
||||
}
|
||||
}
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ["auth"] });
|
||||
router.replace("/");
|
||||
if (targetSys || targetDia || targetWeight) {
|
||||
const targets: Record<string, unknown> = {};
|
||||
if (targetSys) targets.bpSysTarget = parseInt(targetSys);
|
||||
if (targetDia) targets.bpDiaTarget = parseInt(targetDia);
|
||||
if (targetWeight) targets.weightTarget = parseFloat(targetWeight);
|
||||
const targetsRes = await fetch("/api/insights/targets", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(targets),
|
||||
});
|
||||
if (!targetsRes.ok) {
|
||||
throw new Error(t("onboarding.errorTargets"));
|
||||
}
|
||||
}
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ["auth"] });
|
||||
router.replace("/");
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : t("onboarding.errorGeneric");
|
||||
toast.error(message);
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function addMedication() {
|
||||
@@ -191,27 +210,40 @@ export default function OnboardingPage() {
|
||||
placeholder={t("onboarding.medDosePlaceholder")}
|
||||
className="w-24"
|
||||
/>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addMedication}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addMedication}
|
||||
>
|
||||
+
|
||||
</Button>
|
||||
</div>
|
||||
{meds.length > 0 && (
|
||||
<ul className="space-y-1 text-sm">
|
||||
{meds.map((m, i) => (
|
||||
<li key={i} className="bg-muted/50 flex items-center justify-between rounded px-3 py-1.5">
|
||||
<span>
|
||||
{m.name} {m.dose && `(${m.dose})`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-destructive text-xs"
|
||||
onClick={() => setMeds(meds.filter((_, j) => j !== i))}
|
||||
<>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{meds.map((m, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="bg-muted/50 flex items-center justify-between rounded px-3 py-1.5"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<span>
|
||||
{m.name} {m.dose && `(${m.dose})`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-destructive text-xs"
|
||||
onClick={() => setMeds(meds.filter((_, j) => j !== i))}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("onboarding.medScheduleHint")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -226,8 +258,12 @@ export default function OnboardingPage() {
|
||||
{t("onboarding.notificationsDescription")}
|
||||
</p>
|
||||
<div className="bg-muted/50 space-y-2 rounded-lg p-4 text-sm">
|
||||
<p>📱 <strong>Telegram</strong> — {t("onboarding.telegramHint")}</p>
|
||||
<p>🔔 <strong>Web Push</strong> — {t("onboarding.webPushHint")}</p>
|
||||
<p>
|
||||
📱 <strong>Telegram</strong> — {t("onboarding.telegramHint")}
|
||||
</p>
|
||||
<p>
|
||||
🔔 <strong>Web Push</strong> — {t("onboarding.webPushHint")}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("onboarding.notificationsLater")}
|
||||
|
||||
+454
-170
@@ -1,9 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import React, { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { Activity, Heart, Percent, Plus, Smile, TrendingUp, Waves } from "lucide-react";
|
||||
import {
|
||||
Activity,
|
||||
Droplet,
|
||||
Footprints,
|
||||
Heart,
|
||||
Moon,
|
||||
Percent,
|
||||
Pill,
|
||||
Plus,
|
||||
Smile,
|
||||
Target,
|
||||
TrendingUp,
|
||||
Waves,
|
||||
} from "lucide-react";
|
||||
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
|
||||
import {
|
||||
resolveDashboardLayout,
|
||||
type DashboardLayout,
|
||||
} from "@/lib/dashboard-layout";
|
||||
import type { DataSummary as DataSummaryType } from "@/lib/analytics/trends";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -36,7 +55,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 {
|
||||
@@ -55,6 +75,7 @@ import {
|
||||
interface AnalyticsData {
|
||||
summaries: Record<string, DataSummary>;
|
||||
bpInTargetPct: number | null;
|
||||
glucoseByContext?: Record<string, DataSummaryType>;
|
||||
}
|
||||
|
||||
interface RangeDisplayConfig {
|
||||
@@ -66,7 +87,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 +119,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 +154,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");
|
||||
@@ -149,8 +168,19 @@ export default function DashboardPage() {
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
const { data: layoutData } = useQuery({
|
||||
queryKey: ["user", "dashboardWidgets"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/dashboard/widgets");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const json = await res.json();
|
||||
return json.data as DashboardLayout;
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
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");
|
||||
@@ -165,9 +195,52 @@ export default function DashboardPage() {
|
||||
const dia = data?.summaries.BLOOD_PRESSURE_DIA;
|
||||
const p = data?.summaries.PULSE;
|
||||
const bf = data?.summaries.BODY_FAT;
|
||||
const showBodyFatCard = (bf?.count ?? 0) > 0;
|
||||
const sleepSummary = data?.summaries.SLEEP_DURATION;
|
||||
const stepsSummary = data?.summaries.ACTIVITY_STEPS;
|
||||
const moodSummary = moodData?.summary;
|
||||
const showMoodCard = (moodSummary?.count ?? 0) > 0;
|
||||
|
||||
// Resolve full dashboard layout — controls visibility + order of every widget
|
||||
const layout = resolveDashboardLayout(layoutData);
|
||||
const isWidgetVisible = (id: string) =>
|
||||
layout.widgets.find((widget) => widget.id === id)?.visible ?? false;
|
||||
const widgetOrder = (id: string) =>
|
||||
layout.widgets.find((widget) => widget.id === id)?.order ?? 999;
|
||||
|
||||
// Data-floor gates (widget shows iff visible AND has data)
|
||||
const hasWeight = (w?.count ?? 0) > 0;
|
||||
const hasBp = (sys?.count ?? 0) > 0 || (dia?.count ?? 0) > 0;
|
||||
const hasPulse = (p?.count ?? 0) > 0;
|
||||
const hasBodyFat = (bf?.count ?? 0) > 0;
|
||||
const hasMood = (moodSummary?.count ?? 0) > 0;
|
||||
const hasSleep = (sleepSummary?.count ?? 0) > 0;
|
||||
const hasSteps = (stepsSummary?.count ?? 0) > 0;
|
||||
const hasBpInTarget = data?.bpInTargetPct != null;
|
||||
|
||||
const showWeightCard = isWidgetVisible("weight") && hasWeight;
|
||||
const showBpCards = isWidgetVisible("bp") && hasBp;
|
||||
const showPulseCard = isWidgetVisible("pulse") && hasPulse;
|
||||
const showBodyFatCard = isWidgetVisible("bodyFat") && hasBodyFat;
|
||||
const showMoodCard = isWidgetVisible("mood") && hasMood;
|
||||
const showSleepCard = isWidgetVisible("sleep") && hasSleep;
|
||||
const showStepsCard = isWidgetVisible("steps") && hasSteps;
|
||||
const showBpInTargetCard = isWidgetVisible("bpInTarget") && hasBpInTarget;
|
||||
const showMedicationsCard = isWidgetVisible("medications");
|
||||
|
||||
// Glucose widget — visible iff layout enables it AND at least one reading exists.
|
||||
const glucoseWidgetVisible = isWidgetVisible("glucose");
|
||||
const displayGlucoseUnit = resolveGlucoseUnit(user?.glucoseUnit ?? null);
|
||||
const glucoseByContext = data?.glucoseByContext ?? {};
|
||||
const glucoseContexts = ["FASTING", "POSTPRANDIAL", "RANDOM", "BEDTIME"] as const;
|
||||
const glucoseSummariesPresent = glucoseContexts.filter(
|
||||
(ctx) => (glucoseByContext[ctx]?.count ?? 0) > 0,
|
||||
);
|
||||
const showGlucoseCards = glucoseWidgetVisible && glucoseSummariesPresent.length > 0;
|
||||
const glucoseLabelKey: Record<string, string> = {
|
||||
FASTING: "targets.glucoseFasting",
|
||||
POSTPRANDIAL: "targets.glucosePostprandial",
|
||||
RANDOM: "targets.glucoseRandom",
|
||||
BEDTIME: "targets.glucoseBedtime",
|
||||
};
|
||||
const bpTargets =
|
||||
user?.dateOfBirth != null ? getBpTargets(new Date(user.dateOfBirth)) : null;
|
||||
const pulseAge = getAgeFromDateOfBirth(user?.dateOfBirth ?? null);
|
||||
@@ -325,167 +398,378 @@ export default function DashboardPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5">
|
||||
<TrendCard
|
||||
label={t("dashboard.weight")}
|
||||
latest={w?.latest ?? null}
|
||||
unit="kg"
|
||||
avg7={w?.avg7 ?? null}
|
||||
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)}
|
||||
slope30={w?.slope30 ?? null}
|
||||
icon={Activity}
|
||||
/>
|
||||
<TrendCard
|
||||
label={t("dashboard.bloodPressureSys")}
|
||||
latest={sys?.latest ?? null}
|
||||
unit="mmHg"
|
||||
avg7={sys?.avg7 ?? null}
|
||||
avg30={sys?.avg30 ?? null}
|
||||
avg7ColorClass={getRangeColorClass(sys?.avg7, { range: bpSysRange })}
|
||||
avg30ColorClass={getRangeColorClass(sys?.avg30, {
|
||||
range: bpSysRange,
|
||||
})}
|
||||
avg7Hint={getRangeHint("mmHg", { range: bpSysRange }, t)}
|
||||
avg30Hint={getRangeHint("mmHg", { range: bpSysRange }, t)}
|
||||
slope30={sys?.slope30 ?? null}
|
||||
icon={Heart}
|
||||
/>
|
||||
<TrendCard
|
||||
label={t("dashboard.bloodPressureDia")}
|
||||
latest={dia?.latest ?? null}
|
||||
unit="mmHg"
|
||||
avg7={dia?.avg7 ?? null}
|
||||
avg30={dia?.avg30 ?? null}
|
||||
avg7ColorClass={getRangeColorClass(dia?.avg7, { range: bpDiaRange })}
|
||||
avg30ColorClass={getRangeColorClass(dia?.avg30, {
|
||||
range: bpDiaRange,
|
||||
})}
|
||||
avg7Hint={getRangeHint("mmHg", { range: bpDiaRange }, t)}
|
||||
avg30Hint={getRangeHint("mmHg", { range: bpDiaRange }, t)}
|
||||
slope30={dia?.slope30 ?? null}
|
||||
icon={Heart}
|
||||
/>
|
||||
<TrendCard
|
||||
label={t("dashboard.pulse")}
|
||||
latest={p?.latest ?? null}
|
||||
unit="bpm"
|
||||
avg7={p?.avg7 ?? null}
|
||||
avg30={p?.avg30 ?? null}
|
||||
avg7ColorClass={getRangeColorClass(p?.avg7, {
|
||||
range: pulseDisplayRange,
|
||||
})}
|
||||
avg30ColorClass={getRangeColorClass(p?.avg30, {
|
||||
range: pulseDisplayRange,
|
||||
})}
|
||||
avg7Hint={getRangeHint("bpm", { range: pulseDisplayRange }, t)}
|
||||
avg30Hint={getRangeHint("bpm", { range: pulseDisplayRange }, t)}
|
||||
slope30={p?.slope30 ?? null}
|
||||
icon={TrendingUp}
|
||||
/>
|
||||
{showBodyFatCard ? (
|
||||
<TrendCard
|
||||
label={t("dashboard.bodyFat")}
|
||||
latest={bf?.latest ?? null}
|
||||
unit="%"
|
||||
avg7={bf?.avg7 ?? null}
|
||||
avg30={bf?.avg30 ?? null}
|
||||
slope30={bf?.slope30 ?? null}
|
||||
icon={Percent}
|
||||
/>
|
||||
) : null}
|
||||
{showMoodCard ? (
|
||||
<TrendCard
|
||||
label={t("dashboard.mood")}
|
||||
latest={moodSummary?.latest ?? null}
|
||||
unit="/ 5"
|
||||
avg7={moodSummary?.avg7 ?? null}
|
||||
avg30={moodSummary?.avg30 ?? null}
|
||||
slope30={moodSummary?.slope30 ?? null}
|
||||
icon={Smile}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{(() => {
|
||||
type TrendEntry = { id: string; order: number; node: React.ReactNode };
|
||||
const trendCards: TrendEntry[] = [];
|
||||
|
||||
<HealthChart
|
||||
types={["WEIGHT"]}
|
||||
title={t("dashboard.weight")}
|
||||
colors={["#bd93f9"]}
|
||||
unit="kg"
|
||||
valueBands={weightBands}
|
||||
/>
|
||||
if (showWeightCard) {
|
||||
trendCards.push({
|
||||
id: "weight",
|
||||
order: widgetOrder("weight"),
|
||||
node: (
|
||||
<TrendCard
|
||||
key="weight"
|
||||
label={t("dashboard.weight")}
|
||||
latest={w?.latest ?? null}
|
||||
unit="kg"
|
||||
avg7={w?.avg7 ?? null}
|
||||
avg30={w?.avg30 ?? null}
|
||||
avg7ColorClass={getRangeColorClass(w?.avg7, { range: weightRange })}
|
||||
avg30ColorClass={getRangeColorClass(w?.avg30, { range: weightRange })}
|
||||
avg7Hint={getRangeHint("kg", { range: weightRange }, t, fmt.number)}
|
||||
avg30Hint={getRangeHint("kg", { range: weightRange }, t, fmt.number)}
|
||||
slope30={w?.slope30 ?? null}
|
||||
icon={Activity}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
if (showBpCards) {
|
||||
trendCards.push({
|
||||
id: "bp",
|
||||
order: widgetOrder("bp"),
|
||||
node: (
|
||||
<React.Fragment key="bp">
|
||||
<TrendCard
|
||||
label={t("dashboard.bloodPressureSys")}
|
||||
latest={sys?.latest ?? null}
|
||||
unit="mmHg"
|
||||
avg7={sys?.avg7 ?? null}
|
||||
avg30={sys?.avg30 ?? null}
|
||||
avg7ColorClass={getRangeColorClass(sys?.avg7, { range: bpSysRange })}
|
||||
avg30ColorClass={getRangeColorClass(sys?.avg30, { range: bpSysRange })}
|
||||
avg7Hint={getRangeHint("mmHg", { range: bpSysRange }, t, fmt.number)}
|
||||
avg30Hint={getRangeHint("mmHg", { range: bpSysRange }, t, fmt.number)}
|
||||
slope30={sys?.slope30 ?? null}
|
||||
icon={Heart}
|
||||
/>
|
||||
<TrendCard
|
||||
label={t("dashboard.bloodPressureDia")}
|
||||
latest={dia?.latest ?? null}
|
||||
unit="mmHg"
|
||||
avg7={dia?.avg7 ?? null}
|
||||
avg30={dia?.avg30 ?? null}
|
||||
avg7ColorClass={getRangeColorClass(dia?.avg7, { range: bpDiaRange })}
|
||||
avg30ColorClass={getRangeColorClass(dia?.avg30, { range: bpDiaRange })}
|
||||
avg7Hint={getRangeHint("mmHg", { range: bpDiaRange }, t, fmt.number)}
|
||||
avg30Hint={getRangeHint("mmHg", { range: bpDiaRange }, t, fmt.number)}
|
||||
slope30={dia?.slope30 ?? null}
|
||||
icon={Heart}
|
||||
/>
|
||||
</React.Fragment>
|
||||
),
|
||||
});
|
||||
}
|
||||
if (showPulseCard) {
|
||||
trendCards.push({
|
||||
id: "pulse",
|
||||
order: widgetOrder("pulse"),
|
||||
node: (
|
||||
<TrendCard
|
||||
key="pulse"
|
||||
label={t("dashboard.pulse")}
|
||||
latest={p?.latest ?? null}
|
||||
unit="bpm"
|
||||
avg7={p?.avg7 ?? null}
|
||||
avg30={p?.avg30 ?? null}
|
||||
avg7ColorClass={getRangeColorClass(p?.avg7, { range: pulseDisplayRange })}
|
||||
avg30ColorClass={getRangeColorClass(p?.avg30, { range: pulseDisplayRange })}
|
||||
avg7Hint={getRangeHint("bpm", { range: pulseDisplayRange }, t, fmt.number)}
|
||||
avg30Hint={getRangeHint("bpm", { range: pulseDisplayRange }, t, fmt.number)}
|
||||
slope30={p?.slope30 ?? null}
|
||||
icon={TrendingUp}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
if (showBodyFatCard) {
|
||||
trendCards.push({
|
||||
id: "bodyFat",
|
||||
order: widgetOrder("bodyFat"),
|
||||
node: (
|
||||
<TrendCard
|
||||
key="bodyFat"
|
||||
label={t("dashboard.bodyFat")}
|
||||
latest={bf?.latest ?? null}
|
||||
unit="%"
|
||||
avg7={bf?.avg7 ?? null}
|
||||
avg30={bf?.avg30 ?? null}
|
||||
slope30={bf?.slope30 ?? null}
|
||||
icon={Percent}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
if (showMoodCard) {
|
||||
trendCards.push({
|
||||
id: "mood",
|
||||
order: widgetOrder("mood"),
|
||||
node: (
|
||||
<TrendCard
|
||||
key="mood"
|
||||
label={t("dashboard.mood")}
|
||||
latest={moodSummary?.latest ?? null}
|
||||
unit="/ 5"
|
||||
avg7={moodSummary?.avg7 ?? null}
|
||||
avg30={moodSummary?.avg30 ?? null}
|
||||
slope30={moodSummary?.slope30 ?? null}
|
||||
icon={Smile}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
if (showSleepCard) {
|
||||
trendCards.push({
|
||||
id: "sleep",
|
||||
order: widgetOrder("sleep"),
|
||||
node: (
|
||||
<TrendCard
|
||||
key="sleep"
|
||||
label={t("dashboard.sleep") ?? "Sleep"}
|
||||
latest={sleepSummary?.latest ?? null}
|
||||
unit="h"
|
||||
avg7={sleepSummary?.avg7 ?? null}
|
||||
avg30={sleepSummary?.avg30 ?? null}
|
||||
slope30={sleepSummary?.slope30 ?? null}
|
||||
icon={Moon}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
if (showStepsCard) {
|
||||
trendCards.push({
|
||||
id: "steps",
|
||||
order: widgetOrder("steps"),
|
||||
node: (
|
||||
<TrendCard
|
||||
key="steps"
|
||||
label={t("dashboard.steps") ?? "Steps"}
|
||||
latest={stepsSummary?.latest ?? null}
|
||||
unit=""
|
||||
avg7={stepsSummary?.avg7 ?? null}
|
||||
avg30={stepsSummary?.avg30 ?? null}
|
||||
slope30={stepsSummary?.slope30 ?? null}
|
||||
icon={Footprints}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
if (showBpInTargetCard) {
|
||||
trendCards.push({
|
||||
id: "bpInTarget",
|
||||
order: widgetOrder("bpInTarget"),
|
||||
node: (
|
||||
<TrendCard
|
||||
key="bpInTarget"
|
||||
label={t("dashboard.bpInTarget")}
|
||||
latest={data?.bpInTargetPct ?? null}
|
||||
unit="%"
|
||||
avg7={null}
|
||||
avg30={null}
|
||||
slope30={null}
|
||||
icon={Target}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
if (showGlucoseCards) {
|
||||
const glucoseOrder = widgetOrder("glucose");
|
||||
glucoseSummariesPresent.forEach((ctx, idx) => {
|
||||
const s = glucoseByContext[ctx];
|
||||
trendCards.push({
|
||||
id: `glucose-${ctx}`,
|
||||
// sub-order so all glucose cards stay in a block and order stable
|
||||
order: glucoseOrder + idx / 1000,
|
||||
node: (
|
||||
<TrendCard
|
||||
key={`glucose-${ctx}`}
|
||||
label={t(glucoseLabelKey[ctx])}
|
||||
latest={
|
||||
s.latest != null
|
||||
? convertGlucose(s.latest, displayGlucoseUnit)
|
||||
: null
|
||||
}
|
||||
unit={displayGlucoseUnit}
|
||||
avg7={
|
||||
s.avg7 != null
|
||||
? convertGlucose(s.avg7, displayGlucoseUnit)
|
||||
: null
|
||||
}
|
||||
avg30={
|
||||
s.avg30 != null
|
||||
? convertGlucose(s.avg30, displayGlucoseUnit)
|
||||
: null
|
||||
}
|
||||
slope30={s.slope30 ?? null}
|
||||
icon={Droplet}
|
||||
/>
|
||||
),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
<HealthChart
|
||||
types={["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]}
|
||||
title={t("dashboard.bloodPressure")}
|
||||
colors={["#ff79c6", "#8be9fd"]}
|
||||
unit="mmHg"
|
||||
yAxisUnit="Hg"
|
||||
targetZones={bpTargetZones}
|
||||
/>
|
||||
trendCards.sort((a, b) => a.order - b.order);
|
||||
|
||||
<HealthChart
|
||||
types={["PULSE"]}
|
||||
title={t("dashboard.pulse")}
|
||||
colors={["#50fa7b"]}
|
||||
unit="bpm"
|
||||
valueBands={pulseBands}
|
||||
/>
|
||||
{user?.heightCm ? (
|
||||
<HealthChart
|
||||
types={["WEIGHT"]}
|
||||
title={t("targets.bmi")}
|
||||
colors={["#f1fa8c"]}
|
||||
unit="kg/m²"
|
||||
valueMode="bmi"
|
||||
valueBands={[
|
||||
{
|
||||
min: 0,
|
||||
max: 17,
|
||||
color: "#ff5555",
|
||||
opacity: 0.16,
|
||||
},
|
||||
{
|
||||
min: 17,
|
||||
max: 18.5,
|
||||
color: "#ffb86c",
|
||||
opacity: 0.18,
|
||||
},
|
||||
{
|
||||
min: 18.5,
|
||||
max: 24.9,
|
||||
color: "#50fa7b",
|
||||
opacity: 0.2,
|
||||
},
|
||||
{
|
||||
min: 24.9,
|
||||
max: 29.9,
|
||||
color: "#ffb86c",
|
||||
opacity: 0.18,
|
||||
},
|
||||
{
|
||||
min: 29.9,
|
||||
max: 120,
|
||||
color: "#ff5555",
|
||||
opacity: 0.16,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
type ChartEntry = { id: string; order: number; node: React.ReactNode };
|
||||
const charts: ChartEntry[] = [];
|
||||
if (showWeightCard) {
|
||||
charts.push({
|
||||
id: "weight-chart",
|
||||
order: widgetOrder("weight"),
|
||||
node: (
|
||||
<HealthChart
|
||||
key="weight-chart"
|
||||
types={["WEIGHT"]}
|
||||
title={t("dashboard.weight")}
|
||||
colors={["#bd93f9"]}
|
||||
unit="kg"
|
||||
valueBands={weightBands}
|
||||
/>
|
||||
),
|
||||
});
|
||||
if (user?.heightCm) {
|
||||
charts.push({
|
||||
id: "bmi-chart",
|
||||
order: widgetOrder("weight") + 0.5,
|
||||
node: (
|
||||
<HealthChart
|
||||
key="bmi-chart"
|
||||
types={["WEIGHT"]}
|
||||
title={t("targets.bmi")}
|
||||
colors={["#f1fa8c"]}
|
||||
unit="kg/m²"
|
||||
valueMode="bmi"
|
||||
valueBands={[
|
||||
{ min: 0, max: 17, color: "#ff5555", opacity: 0.16 },
|
||||
{ min: 17, max: 18.5, color: "#ffb86c", opacity: 0.18 },
|
||||
{ min: 18.5, max: 24.9, color: "#50fa7b", opacity: 0.2 },
|
||||
{ min: 24.9, max: 29.9, color: "#ffb86c", opacity: 0.18 },
|
||||
{ min: 29.9, max: 120, color: "#ff5555", opacity: 0.16 },
|
||||
]}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (showBpCards) {
|
||||
charts.push({
|
||||
id: "bp-chart",
|
||||
order: widgetOrder("bp"),
|
||||
node: (
|
||||
<HealthChart
|
||||
key="bp-chart"
|
||||
types={["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]}
|
||||
title={t("dashboard.bloodPressure")}
|
||||
colors={["#ff79c6", "#8be9fd"]}
|
||||
unit="mmHg"
|
||||
yAxisUnit="Hg"
|
||||
targetZones={bpTargetZones}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
if (showPulseCard) {
|
||||
charts.push({
|
||||
id: "pulse-chart",
|
||||
order: widgetOrder("pulse"),
|
||||
node: (
|
||||
<HealthChart
|
||||
key="pulse-chart"
|
||||
types={["PULSE"]}
|
||||
title={t("dashboard.pulse")}
|
||||
colors={["#50fa7b"]}
|
||||
unit="bpm"
|
||||
valueBands={pulseBands}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
if (showBodyFatCard) {
|
||||
charts.push({
|
||||
id: "bodyFat-chart",
|
||||
order: widgetOrder("bodyFat"),
|
||||
node: (
|
||||
<HealthChart
|
||||
key="bodyFat-chart"
|
||||
types={["BODY_FAT"]}
|
||||
title={t("dashboard.bodyFat")}
|
||||
colors={["#ffb86c"]}
|
||||
unit="%"
|
||||
valueBands={bodyFatBands}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
if (showMoodCard) {
|
||||
charts.push({
|
||||
id: "mood-chart",
|
||||
order: widgetOrder("mood"),
|
||||
node: <MoodChart key="mood-chart" />,
|
||||
});
|
||||
}
|
||||
if (showSleepCard) {
|
||||
charts.push({
|
||||
id: "sleep-chart",
|
||||
order: widgetOrder("sleep"),
|
||||
node: (
|
||||
<HealthChart
|
||||
key="sleep-chart"
|
||||
types={["SLEEP_DURATION"]}
|
||||
title={t("dashboard.sleep") ?? "Sleep"}
|
||||
colors={["#8be9fd"]}
|
||||
unit="h"
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
if (showStepsCard) {
|
||||
charts.push({
|
||||
id: "steps-chart",
|
||||
order: widgetOrder("steps"),
|
||||
node: (
|
||||
<HealthChart
|
||||
key="steps-chart"
|
||||
types={["ACTIVITY_STEPS"]}
|
||||
title={t("dashboard.steps") ?? "Steps"}
|
||||
colors={["#50fa7b"]}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
if (showMedicationsCard) {
|
||||
charts.push({
|
||||
id: "medications",
|
||||
order: widgetOrder("medications"),
|
||||
node: (
|
||||
<div
|
||||
key="medications"
|
||||
className="bg-card rounded-lg border p-4"
|
||||
>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Pill className="h-4 w-4" />
|
||||
<h3 className="text-sm font-medium">
|
||||
{t("dashboard.medications")}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.title") ?? ""}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
{showBodyFatCard && (
|
||||
<HealthChart
|
||||
types={["BODY_FAT"]}
|
||||
title={t("dashboard.bodyFat")}
|
||||
colors={["#ffb86c"]}
|
||||
unit="%"
|
||||
valueBands={bodyFatBands}
|
||||
/>
|
||||
)}
|
||||
{showMoodCard && <MoodChart />}
|
||||
charts.sort((a, b) => a.order - b.order);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{trendCards.map((entry) => entry.node)}
|
||||
</div>
|
||||
{charts.map((entry) => entry.node)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+271
-9
@@ -54,6 +54,10 @@ 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";
|
||||
import { ThresholdsSection } from "@/components/settings/thresholds-section";
|
||||
import { DashboardLayoutSection } from "@/components/settings/dashboard-layout-section";
|
||||
|
||||
function PasswordInput(props: React.ComponentProps<typeof Input>) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
@@ -208,8 +212,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);
|
||||
@@ -621,6 +626,17 @@ export default function SettingsPage() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section
|
||||
id="section-personalization"
|
||||
className="scroll-mt-28 space-y-3"
|
||||
>
|
||||
<h2 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">
|
||||
{t("settings.categoryPersonalization")}
|
||||
</h2>
|
||||
<DashboardLayoutSection id="dashboard-layout" />
|
||||
<ThresholdsSection id="thresholds" />
|
||||
</section>
|
||||
|
||||
<section id="section-integration" className="scroll-mt-28 space-y-3">
|
||||
<h2 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">
|
||||
{t("settings.categoryIntegration")}
|
||||
@@ -891,7 +907,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 +1551,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 +1589,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 +2551,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>
|
||||
@@ -2882,7 +2897,254 @@ function InsightsSettingsSection({
|
||||
{t("settings.regenerateInsights")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Multi-provider AI selection (per-user override) */}
|
||||
<UserAIProviderSubsection />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─────────────────────── User AI Provider Override ─────────────────────── */
|
||||
|
||||
function UserAIProviderSubsection() {
|
||||
const queryClient = useQueryClient();
|
||||
const [provider, setProvider] = useState<string>("");
|
||||
const [model, setModel] = useState<string>("");
|
||||
const [baseUrl, setBaseUrl] = useState<string>("");
|
||||
const [anthropicKey, setAnthropicKey] = useState<string>("");
|
||||
const [localKey, setLocalKey] = useState<string>("");
|
||||
const [saveMsg, setSaveMsg] = useState<string | null>(null);
|
||||
const [saveOk, setSaveOk] = useState<boolean>(false);
|
||||
const [testMsg, setTestMsg] = useState<string | null>(null);
|
||||
const [testOk, setTestOk] = useState<boolean>(false);
|
||||
const [testing, setTesting] = useState<boolean>(false);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["user", "ai-provider"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/user/ai-provider");
|
||||
if (!res.ok) return null;
|
||||
const json = await res.json();
|
||||
return json.data as {
|
||||
provider: string | null;
|
||||
model: string | null;
|
||||
baseUrl: string | null;
|
||||
hasAnthropicKey: boolean;
|
||||
anthropicKeyPreview: string | null;
|
||||
hasLocalKey: boolean;
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
// Sync-from-server pattern: form draft state is initialised from the
|
||||
// fetched user record. Could be reworked into a derived-state approach,
|
||||
// but that's a non-trivial refactor of this monolithic settings page —
|
||||
// tracked for the 1.4.0 settings split.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setProvider(data.provider ?? "");
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setModel(data.model ?? "");
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setBaseUrl(data.baseUrl ?? "");
|
||||
}, [data]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const body: Record<string, unknown> = {
|
||||
provider: provider || null,
|
||||
model: model || null,
|
||||
baseUrl: baseUrl || null,
|
||||
};
|
||||
if (anthropicKey.trim()) body.anthropicKey = anthropicKey.trim();
|
||||
if (localKey.trim()) body.localKey = localKey.trim();
|
||||
const res = await fetch("/api/user/ai-provider", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json.error || "Save failed");
|
||||
},
|
||||
onSuccess: () => {
|
||||
setSaveMsg("Gespeichert");
|
||||
setSaveOk(true);
|
||||
setAnthropicKey("");
|
||||
setLocalKey("");
|
||||
queryClient.invalidateQueries({ queryKey: ["user", "ai-provider"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["insights"] });
|
||||
},
|
||||
onError: (e) => {
|
||||
setSaveMsg(e instanceof Error ? e.message : "Fehler");
|
||||
setSaveOk(false);
|
||||
},
|
||||
});
|
||||
|
||||
async function runTest() {
|
||||
setTesting(true);
|
||||
setTestMsg(null);
|
||||
try {
|
||||
const res = await fetch("/api/ai/test", { method: "POST" });
|
||||
const json = await res.json();
|
||||
if (!res.ok) {
|
||||
setTestMsg(json.error || `HTTP ${res.status}`);
|
||||
setTestOk(false);
|
||||
return;
|
||||
}
|
||||
setTestMsg(
|
||||
`OK — ${json.data.providerType} (${json.data.model})${
|
||||
json.data.tokensUsed ? `, ${json.data.tokensUsed} tokens` : ""
|
||||
}`,
|
||||
);
|
||||
setTestOk(true);
|
||||
} catch (e) {
|
||||
setTestMsg(e instanceof Error ? e.message : "Test fehlgeschlagen");
|
||||
setTestOk(false);
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-muted/50 mt-2 rounded-lg p-4">
|
||||
<div className="mb-3">
|
||||
<p className="text-sm font-medium">KI-Provider (persönlich)</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Eigener KI-Anbieter überschreibt die Admin-Einstellung. Leer lassen
|
||||
für Standard.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="ai-provider-select">Provider</Label>
|
||||
<select
|
||||
id="ai-provider-select"
|
||||
value={provider}
|
||||
onChange={(e) => setProvider(e.target.value)}
|
||||
className="bg-background border-input mt-1 h-9 w-full rounded-md border px-2 text-sm"
|
||||
>
|
||||
<option value="">— Standard (Admin/Codex) —</option>
|
||||
<option value="OPENAI">OpenAI</option>
|
||||
<option value="ANTHROPIC">Anthropic (Claude)</option>
|
||||
<option value="LOCAL">Lokal (OpenAI-kompatibel)</option>
|
||||
<option value="CHATGPT_OAUTH">ChatGPT OAuth</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="ai-model-input">Modell</Label>
|
||||
<Input
|
||||
id="ai-model-input"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
placeholder={
|
||||
provider === "ANTHROPIC"
|
||||
? "claude-3-5-sonnet-latest"
|
||||
: provider === "LOCAL"
|
||||
? "llama3:8b"
|
||||
: "gpt-4o-mini"
|
||||
}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{provider === "ANTHROPIC" && (
|
||||
<div className="sm:col-span-2">
|
||||
<Label htmlFor="ai-anthropic-key">
|
||||
Anthropic API Key
|
||||
{data?.hasAnthropicKey && (
|
||||
<span className="text-muted-foreground ml-2 text-xs">
|
||||
(gespeichert {data.anthropicKeyPreview})
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="ai-anthropic-key"
|
||||
value={anthropicKey}
|
||||
onChange={(e) => setAnthropicKey(e.target.value)}
|
||||
placeholder="sk-ant-..."
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{provider === "LOCAL" && (
|
||||
<>
|
||||
<div className="sm:col-span-2">
|
||||
<Label htmlFor="ai-base-url">Base URL</Label>
|
||||
<Input
|
||||
id="ai-base-url"
|
||||
value={baseUrl}
|
||||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
placeholder="http://localhost:11434/v1"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<Label htmlFor="ai-local-key">API Key (optional)</Label>
|
||||
<PasswordInput
|
||||
id="ai-local-key"
|
||||
value={localKey}
|
||||
onChange={(e) => setLocalKey(e.target.value)}
|
||||
placeholder={data?.hasLocalKey ? "(gespeichert)" : ""}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Speichern
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={runTest}
|
||||
disabled={testing}
|
||||
>
|
||||
{testing ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Verbindung testen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{saveMsg && (
|
||||
<p
|
||||
className={`mt-2 text-xs ${
|
||||
saveOk ? "text-dracula-green" : "text-destructive"
|
||||
}`}
|
||||
>
|
||||
{saveMsg}
|
||||
</p>
|
||||
)}
|
||||
{testMsg && (
|
||||
<p
|
||||
className={`mt-2 text-xs ${
|
||||
testOk ? "text-dracula-green" : "text-destructive"
|
||||
}`}
|
||||
>
|
||||
{testMsg}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,9 @@ import {
|
||||
AlertCircle,
|
||||
ExternalLink,
|
||||
Smile,
|
||||
Droplet,
|
||||
} from "lucide-react";
|
||||
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
|
||||
|
||||
interface TargetData {
|
||||
type: string;
|
||||
@@ -58,6 +60,7 @@ interface TargetsResponse {
|
||||
heightCm: number | null;
|
||||
age: number | null;
|
||||
gender: string | null;
|
||||
glucoseUnit?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -71,6 +74,10 @@ const TYPE_ICONS: Record<string, typeof Scale> = {
|
||||
BMI: Scale,
|
||||
MOOD_SCORE: Smile,
|
||||
MOOD_STABILITY: Smile,
|
||||
BLOOD_GLUCOSE_FASTING: Droplet,
|
||||
BLOOD_GLUCOSE_POSTPRANDIAL: Droplet,
|
||||
BLOOD_GLUCOSE_RANDOM: Droplet,
|
||||
BLOOD_GLUCOSE_BEDTIME: Droplet,
|
||||
};
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
@@ -83,8 +90,19 @@ const TYPE_COLORS: Record<string, string> = {
|
||||
BMI: "text-dracula-yellow",
|
||||
MOOD_SCORE: "text-dracula-lavender",
|
||||
MOOD_STABILITY: "text-dracula-lavender",
|
||||
BLOOD_GLUCOSE_FASTING: "text-dracula-red",
|
||||
BLOOD_GLUCOSE_POSTPRANDIAL: "text-dracula-red",
|
||||
BLOOD_GLUCOSE_RANDOM: "text-dracula-red",
|
||||
BLOOD_GLUCOSE_BEDTIME: "text-dracula-red",
|
||||
};
|
||||
|
||||
const GLUCOSE_TYPES = new Set([
|
||||
"BLOOD_GLUCOSE_FASTING",
|
||||
"BLOOD_GLUCOSE_POSTPRANDIAL",
|
||||
"BLOOD_GLUCOSE_RANDOM",
|
||||
"BLOOD_GLUCOSE_BEDTIME",
|
||||
]);
|
||||
|
||||
function getTargetSourceLink(target: TargetData): string | null {
|
||||
if (target.type === "WEIGHT" || target.type === "BMI") {
|
||||
return "https://www.who.int/news-room/fact-sheets/detail/obesity-and-overweight";
|
||||
@@ -503,9 +521,32 @@ export default function TargetsPage() {
|
||||
}
|
||||
|
||||
const profileIncomplete = !data.profile.heightCm || !data.profile.age;
|
||||
const visibleTargets = data.targets.filter(
|
||||
(target) => target.current != null,
|
||||
const displayGlucoseUnit = resolveGlucoseUnit(
|
||||
data.profile.glucoseUnit ?? null,
|
||||
);
|
||||
const visibleTargets = data.targets
|
||||
.filter((target) => target.current != null)
|
||||
.map((target) => {
|
||||
if (!GLUCOSE_TYPES.has(target.type)) return target;
|
||||
// Server label is an i18n key for glucose; resolve here.
|
||||
const label = t(target.label);
|
||||
// Convert mg/dL canonical values to the user's display unit.
|
||||
const convert = (v: number | null) =>
|
||||
v == null ? null : convertGlucose(v, displayGlucoseUnit);
|
||||
return {
|
||||
...target,
|
||||
label,
|
||||
unit: displayGlucoseUnit,
|
||||
current: convert(target.current),
|
||||
average30: convert(target.average30),
|
||||
range: target.range
|
||||
? {
|
||||
min: convertGlucose(target.range.min, displayGlucoseUnit),
|
||||
max: convertGlucose(target.range.max, displayGlucoseUnit),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
@@ -105,6 +105,7 @@ const BASE_TYPE_LABEL_KEYS: Record<string, string> = {
|
||||
BODY_FAT: "charts.bodyFat",
|
||||
SLEEP_DURATION: "charts.sleep",
|
||||
ACTIVITY_STEPS: "charts.steps",
|
||||
BLOOD_GLUCOSE: "measurements.typeBloodGlucose",
|
||||
};
|
||||
|
||||
function getTypeLabel(
|
||||
@@ -216,6 +217,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 +492,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 +537,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>
|
||||
);
|
||||
}
|
||||
@@ -31,8 +31,11 @@ export function BottomNav() {
|
||||
const items = navItems;
|
||||
|
||||
return (
|
||||
<nav aria-label={t("nav.mobileNavigation")} className="bg-card/80 border-border fixed bottom-0 left-0 z-50 w-full border-t pb-[env(safe-area-inset-bottom)] backdrop-blur-md md:hidden">
|
||||
<div className="mx-auto flex h-14 max-w-lg items-center justify-around px-2">
|
||||
<nav
|
||||
aria-label={t("nav.mobileNavigation")}
|
||||
className="bg-card/80 border-border fixed bottom-0 left-0 z-50 w-full border-t pb-[env(safe-area-inset-bottom)] backdrop-blur-md md:hidden"
|
||||
>
|
||||
<div className="mx-auto flex h-16 max-w-lg items-stretch justify-around px-1">
|
||||
{items.map((item) => {
|
||||
const isActive =
|
||||
item.href === "/"
|
||||
@@ -44,8 +47,11 @@ export function BottomNav() {
|
||||
href={item.href}
|
||||
aria-label={t(item.tKey)}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
// Touch target sized to WCAG 2.5.5 minimum (44×44 CSS px). The
|
||||
// outer min-h-11 min-w-11 is the actual hit area; the icon
|
||||
// stays visually centered at 20px so the design doesn't shift.
|
||||
className={cn(
|
||||
"relative flex items-center justify-center rounded-lg p-2.5 transition-colors",
|
||||
"relative flex min-h-11 min-w-11 flex-1 items-center justify-center rounded-lg transition-colors",
|
||||
isActive
|
||||
? "text-primary"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
@@ -53,7 +59,7 @@ export function BottomNav() {
|
||||
>
|
||||
<item.icon className="h-5 w-5" />
|
||||
{isActive && (
|
||||
<span className="bg-primary absolute -bottom-1 h-1 w-1 rounded-full" />
|
||||
<span className="bg-primary absolute bottom-1.5 h-1 w-1 rounded-full" />
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -60,8 +61,38 @@ const MEASUREMENT_TYPES = [
|
||||
unitKey: "measurements.unitSteps",
|
||||
placeholder: "8000",
|
||||
},
|
||||
{
|
||||
value: "BLOOD_GLUCOSE",
|
||||
labelKey: "measurements.typeBloodGlucose",
|
||||
unit: "mg/dL",
|
||||
placeholder: "95",
|
||||
},
|
||||
{
|
||||
value: "TOTAL_BODY_WATER",
|
||||
labelKey: "measurements.typeTotalBodyWater",
|
||||
unit: "kg",
|
||||
placeholder: "42",
|
||||
},
|
||||
{
|
||||
value: "BONE_MASS",
|
||||
labelKey: "measurements.typeBoneMass",
|
||||
unit: "kg",
|
||||
placeholder: "3.2",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const GLUCOSE_CONTEXTS = [
|
||||
{ value: "FASTING", labelKey: "measurements.glucoseContextFasting" },
|
||||
{
|
||||
value: "POSTPRANDIAL",
|
||||
labelKey: "measurements.glucoseContextPostprandial",
|
||||
},
|
||||
{ value: "RANDOM", labelKey: "measurements.glucoseContextRandom" },
|
||||
{ value: "BEDTIME", labelKey: "measurements.glucoseContextBedtime" },
|
||||
] as const;
|
||||
|
||||
type GlucoseContextValue = (typeof GLUCOSE_CONTEXTS)[number]["value"];
|
||||
|
||||
interface MeasurementFormProps {
|
||||
onSuccess?: () => void;
|
||||
onCancel?: () => void;
|
||||
@@ -96,11 +127,14 @@ export function MeasurementForm({
|
||||
const [pulse, setPulse] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [measuredAt, setMeasuredAt] = useState(getDefaultMeasuredAtValue);
|
||||
const [glucoseContext, setGlucoseContext] =
|
||||
useState<GlucoseContextValue>("FASTING");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const typeInfo = MEASUREMENT_TYPES.find((t) => t.value === type);
|
||||
const isBpMode = type === "BLOOD_PRESSURE";
|
||||
const isGlucoseMode = type === "BLOOD_GLUCOSE";
|
||||
|
||||
function resetForm() {
|
||||
setType(normalizedDefault || "BLOOD_PRESSURE");
|
||||
@@ -174,6 +208,7 @@ export function MeasurementForm({
|
||||
value: parseFloat(value),
|
||||
measuredAt: timestamp,
|
||||
notes: notes || undefined,
|
||||
...(isGlucoseMode ? { glucoseContext } : {}),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -191,7 +226,7 @@ export function MeasurementForm({
|
||||
setDiaBp("");
|
||||
setPulse("");
|
||||
setNotes("");
|
||||
await queryClient.invalidateQueries({ queryKey: ["measurements"] });
|
||||
await invalidateKeys(queryClient, measurementDependentKeys);
|
||||
toast.success(t("common.saved"));
|
||||
onSuccess?.();
|
||||
} catch {
|
||||
@@ -292,6 +327,29 @@ export function MeasurementForm({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isGlucoseMode && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="glucose-context">
|
||||
{t("measurements.glucoseContext")}
|
||||
</Label>
|
||||
<Select
|
||||
value={glucoseContext}
|
||||
onValueChange={(v) => setGlucoseContext(v as GlucoseContextValue)}
|
||||
>
|
||||
<SelectTrigger id="glucose-context">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{GLUCOSE_CONTEXTS.map((ctx) => (
|
||||
<SelectItem key={ctx.value} value={ctx.value}>
|
||||
{t(ctx.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="measuredAt">{t("measurements.timestamp")}</Label>
|
||||
<Input
|
||||
@@ -325,7 +383,11 @@ export function MeasurementForm({
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div role="alert" aria-live="assertive" className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm">
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
@@ -77,6 +78,10 @@ function parseTimeToMinutes(value: string): number {
|
||||
}
|
||||
|
||||
function toBerlinDate(date: Date): Date {
|
||||
// Intentionally en-US: this is not user-facing display — it produces a
|
||||
// parseable string ("1/2/2026, 3:04:05 PM") that we feed back into Date
|
||||
// to shift the *value* from UTC to Berlin-local for arithmetic. Display
|
||||
// formatting goes through useFormatters() elsewhere.
|
||||
return new Date(
|
||||
date.toLocaleString("en-US", {
|
||||
timeZone: "Europe/Berlin",
|
||||
@@ -189,6 +194,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 +236,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);
|
||||
@@ -318,7 +319,11 @@ export function MedicationCard({ medication, onEdit }: MedicationCardProps) {
|
||||
// Don't show late/very_late if all overdue schedules are covered by events
|
||||
if (status !== "in_window" && !hasUncoveredOverdue) return best;
|
||||
// Don't show in_window if last intake is already within this window today
|
||||
if (status === "in_window" && isLastIntakeInCurrentWindow(medication.lastTakenAt, s, nowBerlin)) return best;
|
||||
if (
|
||||
status === "in_window" &&
|
||||
isLastIntakeInCurrentWindow(medication.lastTakenAt, s, nowBerlin)
|
||||
)
|
||||
return best;
|
||||
const priority = { in_window: 3, late: 2, very_late: 1 };
|
||||
if (!best.status || priority[status] > priority[best.status]) {
|
||||
return { status, schedule: s };
|
||||
@@ -330,6 +335,9 @@ export function MedicationCard({ medication, onEdit }: MedicationCardProps) {
|
||||
: { status: null, schedule: null };
|
||||
|
||||
function formatLastTakenAt(value: string): string {
|
||||
// Intentionally en-CA: gives YYYY-MM-DD which is locale-independent and
|
||||
// string-comparable for the today / yesterday / older bucketing below.
|
||||
// The actual user-facing display goes through formatTime / formatDateTime.
|
||||
const dayFormatter = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Europe/Berlin",
|
||||
year: "numeric",
|
||||
@@ -377,7 +385,13 @@ export function MedicationCard({ medication, onEdit }: MedicationCardProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" asChild aria-label={t("medications.intakeHistory")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
asChild
|
||||
aria-label={t("medications.intakeHistory")}
|
||||
>
|
||||
<Link href={`/medications/${medication.id}/history`}>
|
||||
<History className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
@@ -462,11 +476,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>
|
||||
|
||||
@@ -51,9 +51,24 @@ const PHASES: {
|
||||
dotClass: string;
|
||||
direction: "before" | "after";
|
||||
}[] = [
|
||||
{ key: "green", color: "green", dotClass: "bg-green-500", direction: "before" },
|
||||
{ key: "yellow", color: "yellow", dotClass: "bg-yellow-500", direction: "before" },
|
||||
{ key: "orange", color: "orange", dotClass: "bg-orange-500", direction: "after" },
|
||||
{
|
||||
key: "green",
|
||||
color: "green",
|
||||
dotClass: "bg-green-500",
|
||||
direction: "before",
|
||||
},
|
||||
{
|
||||
key: "yellow",
|
||||
color: "yellow",
|
||||
dotClass: "bg-yellow-500",
|
||||
direction: "before",
|
||||
},
|
||||
{
|
||||
key: "orange",
|
||||
color: "orange",
|
||||
dotClass: "bg-orange-500",
|
||||
direction: "after",
|
||||
},
|
||||
{ key: "red", color: "red", dotClass: "bg-red-500", direction: "after" },
|
||||
];
|
||||
|
||||
@@ -92,7 +107,9 @@ export function PhaseConfigDialog({
|
||||
},
|
||||
onSuccess: () => {
|
||||
setLocalForm(null);
|
||||
queryClient.invalidateQueries({ queryKey: ["phase-config", medicationId] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["phase-config", medicationId],
|
||||
});
|
||||
setStatusMessage(t("medications.phaseSaved"));
|
||||
setTimeout(() => setStatusMessage(null), 2000);
|
||||
},
|
||||
@@ -108,7 +125,9 @@ export function PhaseConfigDialog({
|
||||
},
|
||||
onSuccess: () => {
|
||||
setLocalForm(null);
|
||||
queryClient.invalidateQueries({ queryKey: ["phase-config", medicationId] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["phase-config", medicationId],
|
||||
});
|
||||
setStatusMessage(t("medications.phaseReset"));
|
||||
setTimeout(() => setStatusMessage(null), 2000);
|
||||
},
|
||||
@@ -129,10 +148,14 @@ export function PhaseConfigDialog({
|
||||
|
||||
const phaseLabel = (key: PhaseKey): string => {
|
||||
switch (key) {
|
||||
case "green": return t("medications.phaseGreen");
|
||||
case "yellow": return t("medications.phaseYellow");
|
||||
case "orange": return t("medications.phaseOrange");
|
||||
case "red": return t("medications.phaseRed");
|
||||
case "green":
|
||||
return t("medications.phaseGreen");
|
||||
case "yellow":
|
||||
return t("medications.phaseYellow");
|
||||
case "orange":
|
||||
return t("medications.phaseOrange");
|
||||
case "red":
|
||||
return t("medications.phaseRed");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -160,8 +183,11 @@ export function PhaseConfigDialog({
|
||||
|
||||
return (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<div className={`h-3 w-3 rounded-full ${dotClass} shrink-0`} />
|
||||
<span className="w-14 text-sm font-medium shrink-0">
|
||||
<div
|
||||
className={`h-3 w-3 rounded-full ${dotClass} shrink-0`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="w-14 shrink-0 text-sm font-medium">
|
||||
{phaseLabel(key)}
|
||||
</span>
|
||||
<Input
|
||||
@@ -172,20 +198,20 @@ export function PhaseConfigDialog({
|
||||
onChange={(e) =>
|
||||
updateValue(key, parseInt(e.target.value, 10) || 0)
|
||||
}
|
||||
className="w-20 h-8 text-sm"
|
||||
className="h-8 w-20 text-sm"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 w-12 text-xs px-0"
|
||||
className="h-8 w-12 px-0 text-xs"
|
||||
onClick={() => toggleMode(key)}
|
||||
>
|
||||
{mode === "MINUTES"
|
||||
? t("medications.phaseMinutes")
|
||||
: t("medications.phasePercent")}
|
||||
</Button>
|
||||
<span className="text-muted-foreground text-xs shrink-0">
|
||||
<span className="text-muted-foreground shrink-0 text-xs">
|
||||
{direction === "before"
|
||||
? t("medications.phaseBeforeEnd")
|
||||
: t("medications.phaseAfterEnd")}
|
||||
@@ -197,12 +223,12 @@ export function PhaseConfigDialog({
|
||||
)}
|
||||
|
||||
{statusMessage && (
|
||||
<div className="text-sm text-green-500 text-center">
|
||||
<div className="text-center text-sm text-green-500">
|
||||
{statusMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="flex-row justify-between sm:justify-between gap-2">
|
||||
<DialogFooter className="flex-row justify-between gap-2 sm:justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
|
||||
// ── Theme Context ────────────────────────────────────
|
||||
@@ -100,7 +101,13 @@ function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
// ── Root Providers ───────────────────────────────────
|
||||
|
||||
export function Providers({ children }: { children: ReactNode }) {
|
||||
export function Providers({
|
||||
children,
|
||||
initialLocale,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
initialLocale?: Locale;
|
||||
}) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
@@ -118,7 +125,7 @@ export function Providers({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<I18nProvider>
|
||||
<I18nProvider initialLocale={initialLocale}>
|
||||
{children}
|
||||
<Toaster position="bottom-right" richColors />
|
||||
</I18nProvider>
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
RotateCcw,
|
||||
Loader2,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
type DashboardLayout,
|
||||
type DashboardWidgetId,
|
||||
DEFAULT_DASHBOARD_LAYOUT,
|
||||
} from "@/lib/dashboard-layout";
|
||||
|
||||
const WIDGET_LABEL_KEYS: Record<DashboardWidgetId, string> = {
|
||||
weight: "dashboard.weight",
|
||||
bp: "dashboard.bloodPressure",
|
||||
pulse: "dashboard.pulse",
|
||||
bodyFat: "dashboard.bodyFat",
|
||||
mood: "dashboard.mood",
|
||||
medications: "dashboard.medications",
|
||||
sleep: "measurements.typeSleep",
|
||||
steps: "measurements.typeSteps",
|
||||
glucose: "measurements.typeBloodGlucose",
|
||||
totalBodyWater: "measurements.typeTotalBodyWater",
|
||||
boneMass: "measurements.typeBoneMass",
|
||||
bpInTarget: "dashboard.bpInTarget",
|
||||
};
|
||||
|
||||
export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: remote, isLoading } = useQuery({
|
||||
queryKey: ["user", "dashboardWidgets"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/dashboard/widgets");
|
||||
if (!res.ok) throw new Error("failed");
|
||||
const json = await res.json();
|
||||
return json.data as DashboardLayout;
|
||||
},
|
||||
});
|
||||
|
||||
// Local draft state — null means "use server copy". User edits create the
|
||||
// draft so reordering/toggling doesn't fire a network call per click; Save
|
||||
// flushes it, Cancel clears it. Avoids a setState-in-effect (eslint
|
||||
// react-hooks/set-state-in-effect is strict in this repo).
|
||||
const [draft, setDraft] = useState<DashboardLayout | null>(null);
|
||||
const layout = draft ?? remote ?? null;
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (next: DashboardLayout) => {
|
||||
const res = await fetch("/api/dashboard/widgets", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(next),
|
||||
});
|
||||
if (!res.ok) throw new Error("save failed");
|
||||
return (await res.json()).data as DashboardLayout;
|
||||
},
|
||||
onSuccess: (saved) => {
|
||||
queryClient.setQueryData(["user", "dashboardWidgets"], saved);
|
||||
setDraft(null);
|
||||
toast.success(t("dashboard.layoutSaveSuccess"));
|
||||
},
|
||||
onError: () => toast.error(t("dashboard.layoutSaveError")),
|
||||
});
|
||||
|
||||
const resetMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch("/api/dashboard/widgets", { method: "DELETE" });
|
||||
if (!res.ok) throw new Error("reset failed");
|
||||
return (await res.json()).data as DashboardLayout;
|
||||
},
|
||||
onSuccess: (saved) => {
|
||||
queryClient.setQueryData(["user", "dashboardWidgets"], saved);
|
||||
setDraft(null);
|
||||
toast.success(t("dashboard.layoutResetSuccess"));
|
||||
},
|
||||
});
|
||||
|
||||
function toggle(widgetId: DashboardWidgetId, visible: boolean) {
|
||||
if (!layout) return;
|
||||
setDraft({
|
||||
...layout,
|
||||
widgets: layout.widgets.map((w) =>
|
||||
w.id === widgetId ? { ...w, visible } : w,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function move(widgetId: DashboardWidgetId, delta: -1 | 1) {
|
||||
if (!layout) return;
|
||||
const sorted = [...layout.widgets].sort((a, b) => a.order - b.order);
|
||||
const idx = sorted.findIndex((w) => w.id === widgetId);
|
||||
const targetIdx = idx + delta;
|
||||
if (idx < 0 || targetIdx < 0 || targetIdx >= sorted.length) return;
|
||||
[sorted[idx], sorted[targetIdx]] = [sorted[targetIdx], sorted[idx]];
|
||||
setDraft({
|
||||
...layout,
|
||||
widgets: sorted.map((w, i) => ({ ...w, order: i })),
|
||||
});
|
||||
}
|
||||
|
||||
// Presence of a draft implies dirty — no JSON comparison needed.
|
||||
const dirty = draft !== null && layout !== null;
|
||||
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className="bg-card border-border scroll-mt-28 space-y-5 rounded-xl border p-6"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<LayoutDashboard className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("dashboard.customizeTitle")}
|
||||
</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => resetMutation.mutate()}
|
||||
disabled={resetMutation.isPending}
|
||||
>
|
||||
<RotateCcw className="mr-2 h-3.5 w-3.5" />
|
||||
{t("dashboard.layoutReset")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("dashboard.customizeSubtitle")}
|
||||
</p>
|
||||
|
||||
{isLoading || !layout ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{[...layout.widgets]
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((widget, index, arr) => {
|
||||
const labelKey = WIDGET_LABEL_KEYS[widget.id] ?? widget.id;
|
||||
return (
|
||||
<div
|
||||
key={widget.id}
|
||||
className="border-border bg-background/30 flex items-center gap-3 rounded-md border p-3"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={() => move(widget.id, -1)}
|
||||
disabled={index === 0 || saveMutation.isPending}
|
||||
aria-label={t("dashboard.moveUp")}
|
||||
>
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={() => move(widget.id, 1)}
|
||||
disabled={
|
||||
index === arr.length - 1 || saveMutation.isPending
|
||||
}
|
||||
aria-label={t("dashboard.moveDown")}
|
||||
>
|
||||
<ArrowDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<span className="flex-1 text-sm">{t(labelKey)}</span>
|
||||
<Switch
|
||||
checked={widget.visible}
|
||||
onCheckedChange={(v) => toggle(widget.id, v)}
|
||||
aria-label={t(labelKey)}
|
||||
disabled={saveMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dirty && (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setDraft(null)}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => layout && saveMutation.mutate(layout)}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{saveMutation.isPending && (
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!dirty && remote && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{layout &&
|
||||
JSON.stringify(layout.widgets) ===
|
||||
JSON.stringify(DEFAULT_DASHBOARD_LAYOUT.widgets)
|
||||
? t("dashboard.layoutUsingDefaults")
|
||||
: t("dashboard.layoutCustomized")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { SlidersHorizontal, RotateCcw, Loader2, AlertTriangle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import {
|
||||
METRIC_BOUNDS,
|
||||
type ThresholdMetric,
|
||||
type EffectiveRange,
|
||||
} from "@/lib/analytics/effective-range";
|
||||
|
||||
interface ThresholdsApiResponse {
|
||||
effective: Record<ThresholdMetric, EffectiveRange>;
|
||||
overrides: Partial<Record<ThresholdMetric, { min: number; max: number }>>;
|
||||
}
|
||||
|
||||
const METRIC_ORDER: ThresholdMetric[] = [
|
||||
"WEIGHT",
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"PULSE",
|
||||
"BODY_FAT",
|
||||
"SLEEP_DURATION",
|
||||
"ACTIVITY_STEPS",
|
||||
"BLOOD_GLUCOSE_FASTING",
|
||||
"BLOOD_GLUCOSE_POSTPRANDIAL",
|
||||
"BLOOD_GLUCOSE_RANDOM",
|
||||
"BLOOD_GLUCOSE_BEDTIME",
|
||||
];
|
||||
|
||||
const METRIC_LABEL_KEYS: Record<ThresholdMetric, string> = {
|
||||
WEIGHT: "thresholds.metricWeight",
|
||||
BLOOD_PRESSURE_SYS: "thresholds.metricBpSys",
|
||||
BLOOD_PRESSURE_DIA: "thresholds.metricBpDia",
|
||||
PULSE: "thresholds.metricPulse",
|
||||
BODY_FAT: "thresholds.metricBodyFat",
|
||||
SLEEP_DURATION: "thresholds.metricSleep",
|
||||
ACTIVITY_STEPS: "thresholds.metricSteps",
|
||||
BLOOD_GLUCOSE_FASTING: "thresholds.metricGlucoseFasting",
|
||||
BLOOD_GLUCOSE_POSTPRANDIAL: "thresholds.metricGlucosePostprandial",
|
||||
BLOOD_GLUCOSE_RANDOM: "thresholds.metricGlucoseRandom",
|
||||
BLOOD_GLUCOSE_BEDTIME: "thresholds.metricGlucoseBedtime",
|
||||
};
|
||||
|
||||
export function ThresholdsSection({ id }: { id: string }) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["user", "thresholds"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/user/thresholds");
|
||||
if (!res.ok) throw new Error("failed");
|
||||
const json = await res.json();
|
||||
return json.data as ThresholdsApiResponse;
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async (
|
||||
payload: Partial<Record<ThresholdMetric, { min: number; max: number }>>,
|
||||
) => {
|
||||
const res = await fetch("/api/user/thresholds", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => null);
|
||||
throw new Error(err?.error ?? "save failed");
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["user", "thresholds"] });
|
||||
// Every chart/band depends on these thresholds — invalidate everything.
|
||||
queryClient.invalidateQueries({ queryKey: ["analytics"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["insights"] });
|
||||
toast.success(t("thresholds.saveSuccess"));
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof Error ? err.message : t("thresholds.saveError")),
|
||||
});
|
||||
|
||||
const resetMutation = useMutation({
|
||||
mutationFn: async (metric: ThresholdMetric | null) => {
|
||||
const url = metric
|
||||
? `/api/user/thresholds?metric=${metric}`
|
||||
: "/api/user/thresholds";
|
||||
const res = await fetch(url, { method: "DELETE" });
|
||||
if (!res.ok) throw new Error("reset failed");
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["user", "thresholds"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["analytics"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["insights"] });
|
||||
toast.success(t("thresholds.resetSuccess"));
|
||||
},
|
||||
onError: () => toast.error(t("thresholds.saveError")),
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className="bg-card border-border scroll-mt-28 rounded-xl border p-6 space-y-5"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<SlidersHorizontal className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">{t("thresholds.title")}</h2>
|
||||
</div>
|
||||
{data && Object.keys(data.overrides).length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => resetMutation.mutate(null)}
|
||||
disabled={resetMutation.isPending}
|
||||
>
|
||||
<RotateCcw className="mr-2 h-3.5 w-3.5" />
|
||||
{t("thresholds.resetAllAction")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("thresholds.subtitle")}
|
||||
</p>
|
||||
|
||||
{isLoading || !data ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{METRIC_ORDER.map((metric) => (
|
||||
<MetricRow
|
||||
key={metric}
|
||||
metric={metric}
|
||||
effective={data.effective[metric]}
|
||||
override={data.overrides[metric] ?? null}
|
||||
onSave={(range) => updateMutation.mutate({ [metric]: range })}
|
||||
onReset={() => resetMutation.mutate(metric)}
|
||||
busy={updateMutation.isPending || resetMutation.isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface MetricRowProps {
|
||||
metric: ThresholdMetric;
|
||||
effective: EffectiveRange | undefined;
|
||||
override: { min: number; max: number } | null;
|
||||
onSave: (range: { min: number; max: number }) => void;
|
||||
onReset: () => void;
|
||||
busy: boolean;
|
||||
}
|
||||
|
||||
function MetricRow({
|
||||
metric,
|
||||
effective,
|
||||
override,
|
||||
onSave,
|
||||
onReset,
|
||||
busy,
|
||||
}: MetricRowProps) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const bounds = METRIC_BOUNDS[metric];
|
||||
const hasOverride = override !== null;
|
||||
const [overrideMode, setOverrideMode] = useState(hasOverride);
|
||||
const [minStr, setMinStr] = useState(
|
||||
String(override?.min ?? effective?.default?.greenMin ?? bounds.min),
|
||||
);
|
||||
const [maxStr, setMaxStr] = useState(
|
||||
String(override?.max ?? effective?.default?.greenMax ?? bounds.max),
|
||||
);
|
||||
|
||||
const minNum = parseFloat(minStr);
|
||||
const maxNum = parseFloat(maxStr);
|
||||
const valid =
|
||||
Number.isFinite(minNum) &&
|
||||
Number.isFinite(maxNum) &&
|
||||
minNum >= bounds.min &&
|
||||
maxNum <= bounds.max &&
|
||||
minNum < maxNum;
|
||||
|
||||
const defaultRange = effective?.default;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border p-4 space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">{t(METRIC_LABEL_KEYS[metric])}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{defaultRange
|
||||
? `${t("thresholds.defaultLabel")}: ${fmt.number(defaultRange.greenMin, 1)}–${fmt.number(defaultRange.greenMax, 1)} ${bounds.unit}`
|
||||
: t("thresholds.unsetExplanation")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Label htmlFor={`override-${metric}`} className="text-xs">
|
||||
{overrideMode
|
||||
? t("thresholds.overrideModeLabel")
|
||||
: t("thresholds.autoModeLabel")}
|
||||
</Label>
|
||||
<Switch
|
||||
id={`override-${metric}`}
|
||||
checked={overrideMode}
|
||||
onCheckedChange={(next) => {
|
||||
setOverrideMode(next);
|
||||
if (!next && hasOverride) onReset();
|
||||
}}
|
||||
disabled={busy}
|
||||
/>
|
||||
{hasOverride && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{t("thresholds.sourceOverride")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{overrideMode && (
|
||||
<>
|
||||
<div className="grid gap-3 sm:grid-cols-[1fr_1fr_auto]">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={`min-${metric}`} className="text-xs">
|
||||
{t("thresholds.minLabel")}{" "}
|
||||
{t("thresholds.unitSuffix", { unit: bounds.unit })}
|
||||
</Label>
|
||||
<Input
|
||||
id={`min-${metric}`}
|
||||
type="number"
|
||||
step={metric === "ACTIVITY_STEPS" ? 100 : 0.1}
|
||||
min={bounds.min}
|
||||
max={bounds.max}
|
||||
value={minStr}
|
||||
onChange={(e) => setMinStr(e.target.value)}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={`max-${metric}`} className="text-xs">
|
||||
{t("thresholds.maxLabel")}{" "}
|
||||
{t("thresholds.unitSuffix", { unit: bounds.unit })}
|
||||
</Label>
|
||||
<Input
|
||||
id={`max-${metric}`}
|
||||
type="number"
|
||||
step={metric === "ACTIVITY_STEPS" ? 100 : 0.1}
|
||||
min={bounds.min}
|
||||
max={bounds.max}
|
||||
value={maxStr}
|
||||
onChange={(e) => setMaxStr(e.target.value)}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<Button
|
||||
onClick={() => valid && onSave({ min: minNum, max: maxNum })}
|
||||
disabled={busy || !valid}
|
||||
size="sm"
|
||||
>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
{hasOverride && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onReset}
|
||||
disabled={busy}
|
||||
>
|
||||
{t("thresholds.resetAction")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("thresholds.outOfBoundsHint", {
|
||||
min: bounds.min,
|
||||
max: bounds.max,
|
||||
unit: bounds.unit,
|
||||
})}
|
||||
</p>
|
||||
{hasOverride &&
|
||||
defaultRange &&
|
||||
(override!.min < defaultRange.greenMin * 0.7 ||
|
||||
override!.max > defaultRange.greenMax * 1.3) && (
|
||||
<div className="flex items-start gap-2 rounded-md border-l-2 border-dracula-orange/50 bg-dracula-orange/5 p-2 text-xs">
|
||||
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0 text-dracula-orange" />
|
||||
<span>{t("thresholds.overrideWarning")}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { translateZxcvbn } from "@/lib/zxcvbn-de";
|
||||
import { getZxcvbnTranslations } from "@/lib/zxcvbn-i18n";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
interface PasswordStrengthProps {
|
||||
@@ -22,7 +22,11 @@ export function PasswordStrength({
|
||||
password,
|
||||
minLength = 12,
|
||||
}: PasswordStrengthProps) {
|
||||
const { t } = useTranslations();
|
||||
const { t, locale } = useTranslations();
|
||||
const { translate } = useMemo(
|
||||
() => getZxcvbnTranslations(locale),
|
||||
[locale],
|
||||
);
|
||||
const [result, setResult] = useState<{
|
||||
score: number;
|
||||
feedback: { warning: string; suggestions: string[] };
|
||||
@@ -33,6 +37,7 @@ export function PasswordStrength({
|
||||
|
||||
useEffect(() => {
|
||||
if (!password) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setResult(null);
|
||||
return;
|
||||
}
|
||||
@@ -70,10 +75,10 @@ export function PasswordStrength({
|
||||
feedback.push(t("passwordStrength.minLength", { count: minLength }));
|
||||
}
|
||||
if (result?.feedback.warning) {
|
||||
feedback.push(translateZxcvbn(result.feedback.warning));
|
||||
feedback.push(translate(result.feedback.warning));
|
||||
}
|
||||
if (result?.feedback.suggestions) {
|
||||
feedback.push(...result.feedback.suggestions.map(translateZxcvbn));
|
||||
feedback.push(...result.feedback.suggestions.map(translate));
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface AuthUser {
|
||||
timezone: string;
|
||||
onboardingCompletedAt: string | null;
|
||||
gravatarUrl: string | null;
|
||||
glucoseUnit: string | null;
|
||||
}
|
||||
|
||||
async function fetchMe(): Promise<AuthUser> {
|
||||
|
||||
@@ -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,66 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
mgdlToMmol,
|
||||
mmolToMgdl,
|
||||
convertGlucose,
|
||||
resolveGlucoseUnit,
|
||||
thresholdMetricForContext,
|
||||
} from "../glucose";
|
||||
|
||||
describe("glucose conversion", () => {
|
||||
it("converts mg/dL to mmol/L with 1 decimal", () => {
|
||||
expect(mgdlToMmol(100)).toBe(5.5);
|
||||
expect(mgdlToMmol(126)).toBeCloseTo(7.0, 1); // diabetes threshold
|
||||
expect(mgdlToMmol(70)).toBeCloseTo(3.9, 1); // hypoglycemia threshold
|
||||
});
|
||||
|
||||
it("converts mmol/L to mg/dL rounded to integer", () => {
|
||||
expect(mmolToMgdl(5.5)).toBe(99);
|
||||
expect(mmolToMgdl(7.0)).toBe(126);
|
||||
});
|
||||
|
||||
it("roundtrips within 1 mg/dL", () => {
|
||||
for (const mgdl of [70, 99, 100, 126, 140, 200, 300]) {
|
||||
const back = mmolToMgdl(mgdlToMmol(mgdl));
|
||||
expect(Math.abs(back - mgdl)).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("convertGlucose dispatches to the right unit", () => {
|
||||
expect(convertGlucose(100, "mg/dL")).toBe(100);
|
||||
expect(convertGlucose(100, "mmol/L")).toBe(5.5);
|
||||
});
|
||||
|
||||
it("resolveGlucoseUnit defaults to mg/dL", () => {
|
||||
expect(resolveGlucoseUnit(null)).toBe("mg/dL");
|
||||
expect(resolveGlucoseUnit(undefined)).toBe("mg/dL");
|
||||
expect(resolveGlucoseUnit("mmol/L")).toBe("mmol/L");
|
||||
expect(resolveGlucoseUnit("mg/dL")).toBe("mg/dL");
|
||||
expect(resolveGlucoseUnit("random-garbage")).toBe("mg/dL");
|
||||
});
|
||||
|
||||
it("thresholdMetricForContext maps every context", () => {
|
||||
expect(thresholdMetricForContext("FASTING")).toBe("BLOOD_GLUCOSE_FASTING");
|
||||
expect(thresholdMetricForContext("POSTPRANDIAL")).toBe(
|
||||
"BLOOD_GLUCOSE_POSTPRANDIAL",
|
||||
);
|
||||
expect(thresholdMetricForContext("RANDOM")).toBe("BLOOD_GLUCOSE_RANDOM");
|
||||
expect(thresholdMetricForContext("BEDTIME")).toBe("BLOOD_GLUCOSE_BEDTIME");
|
||||
});
|
||||
|
||||
// Display-unit logic guard for the targets/dashboard surfaces:
|
||||
// canonical mg/dL stays integer; mmol/L stays 1-decimal; ranges convert too.
|
||||
it("display unit conversion preserves integer/decimal precision", () => {
|
||||
const fastingMin = 70; // mg/dL
|
||||
const fastingMax = 99;
|
||||
const ada126 = 126; // diabetes threshold
|
||||
|
||||
expect(convertGlucose(fastingMin, "mg/dL")).toBe(70);
|
||||
expect(convertGlucose(fastingMax, "mg/dL")).toBe(99);
|
||||
expect(convertGlucose(ada126, "mg/dL")).toBe(126);
|
||||
|
||||
expect(convertGlucose(fastingMin, "mmol/L")).toBe(3.9);
|
||||
expect(convertGlucose(fastingMax, "mmol/L")).toBe(5.5);
|
||||
expect(convertGlucose(ada126, "mmol/L")).toBe(7);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const ROOT = join(__dirname, "../../..");
|
||||
const EN_PATH = join(ROOT, "messages/en.json");
|
||||
const DE_PATH = join(ROOT, "messages/de.json");
|
||||
|
||||
// JSON.parse silently keeps the last value when an object has duplicate keys,
|
||||
// which lets shadowed keys hide bugs. Walk the source manually to surface them.
|
||||
function findDuplicateKeys(src: string): { path: string; line: number; firstLine: number }[] {
|
||||
const dups: { path: string; line: number; firstLine: number }[] = [];
|
||||
const pathStack: string[] = [];
|
||||
let i = 0;
|
||||
let line = 1;
|
||||
|
||||
function err(msg: string): never {
|
||||
throw new Error(`${msg} at line ${line}`);
|
||||
}
|
||||
|
||||
function skipWs() {
|
||||
while (i < src.length) {
|
||||
const c = src[i];
|
||||
if (c === " " || c === "\t" || c === "\r") i++;
|
||||
else if (c === "\n") { i++; line++; }
|
||||
else break;
|
||||
}
|
||||
}
|
||||
|
||||
function readString(): { value: string; line: number } {
|
||||
if (src[i] !== '"') err("expected string");
|
||||
const startLine = line;
|
||||
i++;
|
||||
let raw = "";
|
||||
while (i < src.length) {
|
||||
const c = src[i];
|
||||
if (c === "\\") {
|
||||
raw += c + src[i + 1];
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (c === '"') {
|
||||
i++;
|
||||
return { value: JSON.parse(`"${raw}"`), line: startLine };
|
||||
}
|
||||
if (c === "\n") line++;
|
||||
raw += c;
|
||||
i++;
|
||||
}
|
||||
err("unterminated string");
|
||||
}
|
||||
|
||||
function parseValue() {
|
||||
skipWs();
|
||||
const c = src[i];
|
||||
if (c === "{") return parseObject();
|
||||
if (c === "[") return parseArray();
|
||||
if (c === '"') { readString(); return; }
|
||||
while (i < src.length) {
|
||||
const ch = src[i];
|
||||
if (ch === "," || ch === "}" || ch === "]" || ch === " " || ch === "\n" || ch === "\t" || ch === "\r") break;
|
||||
if (ch === "\n") line++;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
function parseArray() {
|
||||
i++; skipWs();
|
||||
if (src[i] === "]") { i++; return; }
|
||||
while (true) {
|
||||
parseValue();
|
||||
skipWs();
|
||||
if (src[i] === ",") { i++; skipWs(); continue; }
|
||||
if (src[i] === "]") { i++; return; }
|
||||
err("expected , or ] in array");
|
||||
}
|
||||
}
|
||||
|
||||
function parseObject() {
|
||||
i++;
|
||||
const seen = new Map<string, number>();
|
||||
skipWs();
|
||||
if (src[i] === "}") { i++; return; }
|
||||
while (true) {
|
||||
skipWs();
|
||||
const { value: key, line: keyLine } = readString();
|
||||
const seenAt = seen.get(key);
|
||||
if (seenAt != null) {
|
||||
dups.push({ path: [...pathStack, key].join("."), line: keyLine, firstLine: seenAt });
|
||||
} else {
|
||||
seen.set(key, keyLine);
|
||||
}
|
||||
skipWs();
|
||||
if (src[i] !== ":") err("expected : after key");
|
||||
i++;
|
||||
pathStack.push(key);
|
||||
parseValue();
|
||||
pathStack.pop();
|
||||
skipWs();
|
||||
if (src[i] === ",") { i++; continue; }
|
||||
if (src[i] === "}") { i++; return; }
|
||||
err("expected , or } in object");
|
||||
}
|
||||
}
|
||||
|
||||
parseValue();
|
||||
return dups;
|
||||
}
|
||||
|
||||
describe("i18n locale file integrity", () => {
|
||||
it("messages/en.json has no duplicate keys", () => {
|
||||
const dups = findDuplicateKeys(readFileSync(EN_PATH, "utf8"));
|
||||
expect(
|
||||
dups,
|
||||
`Duplicate keys silently shadow earlier definitions:\n` +
|
||||
dups
|
||||
.map((d) => ` ${d.path} at line ${d.line} (first defined at line ${d.firstLine})`)
|
||||
.join("\n"),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("messages/de.json has no duplicate keys", () => {
|
||||
const dups = findDuplicateKeys(readFileSync(DE_PATH, "utf8"));
|
||||
expect(
|
||||
dups,
|
||||
`Duplicate keys silently shadow earlier definitions:\n` +
|
||||
dups
|
||||
.map((d) => ` ${d.path} at line ${d.line} (first defined at line ${d.firstLine})`)
|
||||
.join("\n"),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("en and de share the same key shape (locale parity)", () => {
|
||||
const en = JSON.parse(readFileSync(EN_PATH, "utf8")) as Record<string, unknown>;
|
||||
const de = JSON.parse(readFileSync(DE_PATH, "utf8")) as Record<string, unknown>;
|
||||
|
||||
function flatten(obj: unknown, prefix: string, out: string[]) {
|
||||
if (obj == null || typeof obj !== "object") return;
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
const key = prefix ? `${prefix}.${k}` : k;
|
||||
if (typeof v === "string") out.push(key);
|
||||
else if (typeof v === "object") flatten(v, key, out);
|
||||
}
|
||||
}
|
||||
|
||||
const enKeys = new Set<string>();
|
||||
const deKeys = new Set<string>();
|
||||
const enArr: string[] = [];
|
||||
const deArr: string[] = [];
|
||||
flatten(en, "", enArr);
|
||||
flatten(de, "", deArr);
|
||||
enArr.forEach((k) => enKeys.add(k));
|
||||
deArr.forEach((k) => deKeys.add(k));
|
||||
|
||||
const onlyInEn = [...enKeys].filter((k) => !deKeys.has(k)).sort();
|
||||
const onlyInDe = [...deKeys].filter((k) => !enKeys.has(k)).sort();
|
||||
|
||||
expect(
|
||||
{ onlyInEn, onlyInDe },
|
||||
"Locale files drifted apart — every key must exist in both locales.",
|
||||
).toEqual({ onlyInEn: [], onlyInDe: [] });
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { getServerTranslator } from "../i18n/server-translator";
|
||||
|
||||
describe("getServerTranslator", () => {
|
||||
it("returns the German string for a known key when locale=de", () => {
|
||||
const { t } = getServerTranslator("de");
|
||||
expect(t("telegram.cancelled")).toBe("Abgebrochen.");
|
||||
});
|
||||
|
||||
it("returns the English string for a known key when locale=en", () => {
|
||||
const { t } = getServerTranslator("en");
|
||||
expect(t("telegram.cancelled")).toBe("Cancelled.");
|
||||
});
|
||||
|
||||
it("interpolates {param} placeholders", () => {
|
||||
const { t } = getServerTranslator("en");
|
||||
expect(t("telegram.snoozedFor", { name: "Ramipril", duration: "1 hour" })).toBe(
|
||||
"Ramipril snoozed for 1 hour.",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to English when a key is missing in the German bundle", () => {
|
||||
// Sanity: every telegram.* key currently exists in both bundles, so
|
||||
// simulate a missing key via a deeply nested path.
|
||||
const { t } = getServerTranslator("de");
|
||||
expect(t("telegram.__definitely_missing__")).toBe(
|
||||
"telegram.__definitely_missing__",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the raw key when missing in both locales", () => {
|
||||
const { t } = getServerTranslator("en");
|
||||
expect(t("nope.also.missing")).toBe("nope.also.missing");
|
||||
});
|
||||
|
||||
it("exposes the active locale on the returned translator", () => {
|
||||
expect(getServerTranslator("de").locale).toBe("de");
|
||||
expect(getServerTranslator("en").locale).toBe("en");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { AnthropicClient } from "../anthropic-client";
|
||||
|
||||
describe("AnthropicClient", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("sends correct request shape with required headers", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
content: [{ type: "text", text: '{"summary":"ok"}' }],
|
||||
usage: { input_tokens: 30, output_tokens: 12 },
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const client = new AnthropicClient({
|
||||
apiKey: "sk-ant-test-key",
|
||||
model: "claude-3-5-sonnet-latest",
|
||||
});
|
||||
|
||||
const result = await client.generateCompletion({
|
||||
systemPrompt: "You are a doctor.",
|
||||
userPrompt: "Analyze this data.",
|
||||
temperature: 0.4,
|
||||
maxTokens: 800,
|
||||
});
|
||||
|
||||
expect(result.content).toBe('{"summary":"ok"}');
|
||||
expect(result.tokensUsed).toBe(42);
|
||||
expect(result.model).toBe("claude-3-5-sonnet-latest");
|
||||
expect(result.providerType).toBe("anthropic");
|
||||
|
||||
const [url, init] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe("https://api.anthropic.com/v1/messages");
|
||||
|
||||
expect(init.headers["x-api-key"]).toBe("sk-ant-test-key");
|
||||
expect(init.headers["anthropic-version"]).toBe("2023-06-01");
|
||||
expect(init.headers["Content-Type"]).toBe("application/json");
|
||||
|
||||
const body = JSON.parse(init.body);
|
||||
expect(body.model).toBe("claude-3-5-sonnet-latest");
|
||||
expect(body.max_tokens).toBe(800);
|
||||
expect(body.temperature).toBe(0.4);
|
||||
// System prompt is a separate top-level field, NOT a message.
|
||||
expect(body.system).toBe("You are a doctor.");
|
||||
expect(body.messages).toEqual([
|
||||
expect.objectContaining({ role: "user" }),
|
||||
]);
|
||||
// User message should be wrapped to coerce JSON output.
|
||||
expect(body.messages[0].content).toContain("Analyze this data.");
|
||||
expect(body.messages[0].content.toLowerCase()).toContain("json");
|
||||
});
|
||||
|
||||
it("uses custom baseUrl when provided", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
content: [{ type: "text", text: "{}" }],
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const client = new AnthropicClient({
|
||||
apiKey: "sk-ant-x",
|
||||
model: "claude-3-haiku-20240307",
|
||||
baseUrl: "https://api.anthropic.example/v1/",
|
||||
});
|
||||
|
||||
await client.generateCompletion({ systemPrompt: "s", userPrompt: "u" });
|
||||
|
||||
expect(mockFetch.mock.calls[0][0]).toBe(
|
||||
"https://api.anthropic.example/v1/messages",
|
||||
);
|
||||
});
|
||||
|
||||
it("captures structured error fields and redacts the API key on failure", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 529,
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
'overloaded; debug=sk-ant-supersecretkey1234567890 leaked',
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
const client = new AnthropicClient({
|
||||
apiKey: "sk-ant-test",
|
||||
model: "claude-3-5-sonnet-latest",
|
||||
});
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
await client.generateCompletion({
|
||||
systemPrompt: "s",
|
||||
userPrompt: "u",
|
||||
});
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
|
||||
expect(caught).toBeInstanceOf(Error);
|
||||
const err = caught as Error & {
|
||||
httpStatus?: number;
|
||||
upstream?: string;
|
||||
bodyExcerpt?: string;
|
||||
model?: string;
|
||||
};
|
||||
expect(err.message).toBe("Anthropic request failed (529)");
|
||||
expect(err.httpStatus).toBe(529);
|
||||
expect(err.upstream).toBe("anthropic");
|
||||
expect(err.model).toBe("claude-3-5-sonnet-latest");
|
||||
expect(err.bodyExcerpt).toContain("sk-ant-***redacted***");
|
||||
expect(err.bodyExcerpt).not.toContain("supersecretkey");
|
||||
});
|
||||
|
||||
it("throws when content is empty", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ content: [], usage: {} }),
|
||||
}),
|
||||
);
|
||||
|
||||
const client = new AnthropicClient({
|
||||
apiKey: "sk-ant-x",
|
||||
model: "claude-3-5-sonnet-latest",
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.generateCompletion({ systemPrompt: "s", userPrompt: "u" }),
|
||||
).rejects.toThrow("Anthropic returned empty content");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { LocalOpenAICompatibleClient } from "../local-client";
|
||||
|
||||
describe("LocalOpenAICompatibleClient", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("targets the configured baseUrl + /chat/completions", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
choices: [{ message: { content: '{"summary":"local"}' } }],
|
||||
usage: { total_tokens: 17 },
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const client = new LocalOpenAICompatibleClient({
|
||||
apiKey: null,
|
||||
model: "llama3:8b",
|
||||
baseUrl: "http://localhost:11434/v1",
|
||||
});
|
||||
|
||||
const result = await client.generateCompletion({
|
||||
systemPrompt: "system",
|
||||
userPrompt: "user-input",
|
||||
});
|
||||
|
||||
expect(result.content).toBe('{"summary":"local"}');
|
||||
expect(result.tokensUsed).toBe(17);
|
||||
expect(result.model).toBe("llama3:8b");
|
||||
expect(result.providerType).toBe("local");
|
||||
|
||||
const [url, init] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe("http://localhost:11434/v1/chat/completions");
|
||||
|
||||
const body = JSON.parse(init.body);
|
||||
expect(body.model).toBe("llama3:8b");
|
||||
expect(body.messages).toHaveLength(2);
|
||||
expect(body.messages[0]).toEqual({ role: "system", content: "system" });
|
||||
// User message should be prefixed with the strict-JSON instruction.
|
||||
expect(body.messages[1].role).toBe("user");
|
||||
expect(body.messages[1].content).toContain("user-input");
|
||||
expect(body.messages[1].content.toLowerCase()).toContain("json");
|
||||
|
||||
// CRUCIAL: do not send response_format because many local servers reject it.
|
||||
expect(body).not.toHaveProperty("response_format");
|
||||
});
|
||||
|
||||
it("omits Authorization header when no API key is provided", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
choices: [{ message: { content: "{}" } }],
|
||||
usage: { total_tokens: 1 },
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const client = new LocalOpenAICompatibleClient({
|
||||
apiKey: "",
|
||||
model: "phi3",
|
||||
baseUrl: "http://localhost:11434/v1",
|
||||
});
|
||||
|
||||
await client.generateCompletion({ systemPrompt: "s", userPrompt: "u" });
|
||||
|
||||
const init = mockFetch.mock.calls[0][1];
|
||||
expect(init.headers.Authorization).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sends Bearer Authorization when an API key is provided", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
choices: [{ message: { content: "{}" } }],
|
||||
usage: { total_tokens: 1 },
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const client = new LocalOpenAICompatibleClient({
|
||||
apiKey: "lm-studio-secret",
|
||||
model: "phi3",
|
||||
baseUrl: "http://192.168.1.20:1234/v1",
|
||||
});
|
||||
|
||||
await client.generateCompletion({ systemPrompt: "s", userPrompt: "u" });
|
||||
|
||||
const init = mockFetch.mock.calls[0][1];
|
||||
expect(init.headers.Authorization).toBe("Bearer lm-studio-secret");
|
||||
});
|
||||
|
||||
it("captures structured error fields and redacts bearer tokens on failure", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
'upstream rejected: Bearer sk-localabcdef12345 hit /v1',
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
const client = new LocalOpenAICompatibleClient({
|
||||
apiKey: "sk-local-test",
|
||||
model: "llama3",
|
||||
baseUrl: "http://localhost:11434/v1",
|
||||
});
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
await client.generateCompletion({ systemPrompt: "s", userPrompt: "u" });
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
|
||||
expect(caught).toBeInstanceOf(Error);
|
||||
const err = caught as Error & {
|
||||
httpStatus?: number;
|
||||
upstream?: string;
|
||||
bodyExcerpt?: string;
|
||||
baseUrl?: string;
|
||||
};
|
||||
expect(err.message).toBe("Local AI request failed (500)");
|
||||
expect(err.httpStatus).toBe(500);
|
||||
expect(err.upstream).toBe("local");
|
||||
expect(err.baseUrl).toBe("http://localhost:11434/v1");
|
||||
expect(err.bodyExcerpt).toContain("Bearer ***redacted***");
|
||||
expect(err.bodyExcerpt).not.toContain("sk-localabcdef12345");
|
||||
});
|
||||
|
||||
it("throws when content is empty", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({ choices: [{ message: { content: "" } }] }),
|
||||
}),
|
||||
);
|
||||
|
||||
const client = new LocalOpenAICompatibleClient({
|
||||
apiKey: null,
|
||||
model: "llama3",
|
||||
baseUrl: "http://localhost:11434/v1",
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.generateCompletion({ systemPrompt: "s", userPrompt: "u" }),
|
||||
).rejects.toThrow("Local AI returned empty content");
|
||||
});
|
||||
});
|
||||
@@ -12,43 +12,85 @@ vi.mock("@/lib/crypto", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/ai/codex-oauth", () => ({
|
||||
refreshAccessToken: vi.fn(),
|
||||
encryptTokens: vi.fn((t: { accessToken: string; refreshToken: string }) => ({ accessEncrypted: `enc:${t.accessToken}`, refreshEncrypted: `enc:${t.refreshToken}` })),
|
||||
encryptTokens: vi.fn((t: { accessToken: string; refreshToken: string }) => ({
|
||||
accessEncrypted: `enc:${t.accessToken}`,
|
||||
refreshEncrypted: `enc:${t.refreshToken}`,
|
||||
})),
|
||||
decryptTokens: vi.fn(),
|
||||
}));
|
||||
|
||||
import { resolveProvider } from "../provider";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decryptTokens } from "@/lib/ai/codex-oauth";
|
||||
import { decrypt, encrypt } from "@/lib/crypto";
|
||||
import {
|
||||
decryptTokens,
|
||||
encryptTokens,
|
||||
} from "@/lib/ai/codex-oauth";
|
||||
|
||||
/**
|
||||
* resolveProvider() makes up to TWO findUnique calls per invocation:
|
||||
* 1. Read user-level AI config (aiProvider/...).
|
||||
* 2. If falling back to codex, read codex token fields.
|
||||
* We use mockResolvedValueOnce in order so each call returns the right shape.
|
||||
*/
|
||||
|
||||
describe("resolveProvider", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset mocks AND mockResolvedValueOnce queues, then re-establish
|
||||
// the deterministic factory implementations for crypto/oauth helpers.
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(decrypt).mockImplementation((v: string) => `decrypted:${v}`);
|
||||
vi.mocked(encrypt).mockImplementation((v: string) => `encrypted:${v}`);
|
||||
vi.mocked(encryptTokens).mockImplementation(
|
||||
(t: { accessToken: string; refreshToken: string }) => ({
|
||||
accessEncrypted: `enc:${t.accessToken}`,
|
||||
refreshEncrypted: `enc:${t.refreshToken}`,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns codex provider when user has valid tokens", async () => {
|
||||
it("returns codex provider when user has valid tokens (no explicit choice)", async () => {
|
||||
vi.mocked(decryptTokens).mockReturnValue({
|
||||
accessToken: "decrypted-access",
|
||||
refreshToken: "decrypted-refresh",
|
||||
});
|
||||
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
codexAccessTokenEncrypted: "enc-access",
|
||||
codexRefreshTokenEncrypted: "enc-refresh",
|
||||
codexTokenExpiresAt: new Date(Date.now() + 3600000),
|
||||
codexConnectionStatus: "connected",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique)
|
||||
// 1st call: user AI config — none selected
|
||||
.mockResolvedValueOnce({
|
||||
aiProvider: null,
|
||||
aiModel: null,
|
||||
aiBaseUrl: null,
|
||||
aiAnthropicKeyEncrypted: null,
|
||||
aiLocalKeyEncrypted: null,
|
||||
} as never)
|
||||
// 2nd call: codex tokens
|
||||
.mockResolvedValueOnce({
|
||||
codexAccessTokenEncrypted: "enc-access",
|
||||
codexRefreshTokenEncrypted: "enc-refresh",
|
||||
codexTokenExpiresAt: new Date(Date.now() + 3600000),
|
||||
codexConnectionStatus: "connected",
|
||||
} as never);
|
||||
|
||||
const provider = await resolveProvider("user-123");
|
||||
expect(provider.type).toBe("codex");
|
||||
});
|
||||
|
||||
it("returns admin-key when user has no codex but admin key exists", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
codexAccessTokenEncrypted: null,
|
||||
codexRefreshTokenEncrypted: null,
|
||||
codexTokenExpiresAt: null,
|
||||
codexConnectionStatus: "disconnected",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique)
|
||||
.mockResolvedValueOnce({
|
||||
aiProvider: null,
|
||||
aiModel: null,
|
||||
aiBaseUrl: null,
|
||||
aiAnthropicKeyEncrypted: null,
|
||||
aiLocalKeyEncrypted: null,
|
||||
} as never)
|
||||
.mockResolvedValueOnce({
|
||||
codexAccessTokenEncrypted: null,
|
||||
codexRefreshTokenEncrypted: null,
|
||||
codexTokenExpiresAt: null,
|
||||
codexConnectionStatus: "disconnected",
|
||||
} as never);
|
||||
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
adminAiKeyEncrypted: "enc-admin-key",
|
||||
@@ -61,10 +103,20 @@ describe("resolveProvider", () => {
|
||||
});
|
||||
|
||||
it("returns none when nothing is configured", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
codexAccessTokenEncrypted: null,
|
||||
codexConnectionStatus: "disconnected",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique)
|
||||
.mockResolvedValueOnce({
|
||||
aiProvider: null,
|
||||
aiModel: null,
|
||||
aiBaseUrl: null,
|
||||
aiAnthropicKeyEncrypted: null,
|
||||
aiLocalKeyEncrypted: null,
|
||||
} as never)
|
||||
.mockResolvedValueOnce({
|
||||
codexAccessTokenEncrypted: null,
|
||||
codexRefreshTokenEncrypted: null,
|
||||
codexTokenExpiresAt: null,
|
||||
codexConnectionStatus: "disconnected",
|
||||
} as never);
|
||||
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
adminAiKeyEncrypted: null,
|
||||
@@ -75,12 +127,20 @@ describe("resolveProvider", () => {
|
||||
});
|
||||
|
||||
it("falls back to admin-key when codex status is expired", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
codexAccessTokenEncrypted: "enc-access",
|
||||
codexRefreshTokenEncrypted: "enc-refresh",
|
||||
codexTokenExpiresAt: new Date(Date.now() - 1000),
|
||||
codexConnectionStatus: "expired",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique)
|
||||
.mockResolvedValueOnce({
|
||||
aiProvider: null,
|
||||
aiModel: null,
|
||||
aiBaseUrl: null,
|
||||
aiAnthropicKeyEncrypted: null,
|
||||
aiLocalKeyEncrypted: null,
|
||||
} as never)
|
||||
.mockResolvedValueOnce({
|
||||
codexAccessTokenEncrypted: "enc-access",
|
||||
codexRefreshTokenEncrypted: "enc-refresh",
|
||||
codexTokenExpiresAt: new Date(Date.now() - 1000),
|
||||
codexConnectionStatus: "expired",
|
||||
} as never);
|
||||
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
adminAiKeyEncrypted: "enc-admin",
|
||||
@@ -91,4 +151,101 @@ describe("resolveProvider", () => {
|
||||
const provider = await resolveProvider("user-123");
|
||||
expect(provider.type).toBe("admin-key");
|
||||
});
|
||||
|
||||
it("returns Anthropic provider when user picks ANTHROPIC and has key", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
aiProvider: "ANTHROPIC",
|
||||
aiModel: "claude-3-5-sonnet-latest",
|
||||
aiBaseUrl: null,
|
||||
aiAnthropicKeyEncrypted: "enc-anthropic-key",
|
||||
aiLocalKeyEncrypted: null,
|
||||
} as never);
|
||||
|
||||
const provider = await resolveProvider("user-123");
|
||||
expect(provider.type).toBe("anthropic");
|
||||
// Codex/admin lookups must NOT happen when user provider resolved.
|
||||
expect(prisma.appSettings.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back when user picks ANTHROPIC but has no key", async () => {
|
||||
vi.mocked(prisma.user.findUnique)
|
||||
.mockResolvedValueOnce({
|
||||
aiProvider: "ANTHROPIC",
|
||||
aiModel: "claude-3-5-sonnet-latest",
|
||||
aiBaseUrl: null,
|
||||
aiAnthropicKeyEncrypted: null,
|
||||
aiLocalKeyEncrypted: null,
|
||||
} as never)
|
||||
.mockResolvedValueOnce({
|
||||
codexAccessTokenEncrypted: null,
|
||||
codexConnectionStatus: "disconnected",
|
||||
} as never);
|
||||
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
adminAiKeyEncrypted: "enc-admin",
|
||||
adminAiModel: "gpt-4o-mini",
|
||||
adminAiBaseUrl: "https://api.openai.com/v1",
|
||||
} as never);
|
||||
|
||||
// ANTHROPIC explicit choice ≠ CHATGPT_OAUTH/null → codex branch is skipped.
|
||||
const provider = await resolveProvider("user-123");
|
||||
expect(provider.type).toBe("admin-key");
|
||||
});
|
||||
|
||||
it("returns Local provider when user picks LOCAL with baseUrl", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
aiProvider: "LOCAL",
|
||||
aiModel: "llama3:8b",
|
||||
aiBaseUrl: "http://localhost:11434/v1",
|
||||
aiAnthropicKeyEncrypted: null,
|
||||
aiLocalKeyEncrypted: null,
|
||||
} as never);
|
||||
|
||||
const provider = await resolveProvider("user-123");
|
||||
expect(provider.type).toBe("local");
|
||||
});
|
||||
|
||||
it("OPENAI selection routes through admin OpenAI key", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
aiProvider: "OPENAI",
|
||||
aiModel: null,
|
||||
aiBaseUrl: null,
|
||||
aiAnthropicKeyEncrypted: null,
|
||||
aiLocalKeyEncrypted: null,
|
||||
} as never);
|
||||
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
adminAiKeyEncrypted: "enc-admin",
|
||||
adminAiModel: "gpt-4o-mini",
|
||||
adminAiBaseUrl: "https://api.openai.com/v1",
|
||||
} as never);
|
||||
|
||||
const provider = await resolveProvider("user-123");
|
||||
expect(provider.type).toBe("admin-key");
|
||||
});
|
||||
|
||||
it("CHATGPT_OAUTH selection routes through Codex when connected", async () => {
|
||||
vi.mocked(decryptTokens).mockReturnValue({
|
||||
accessToken: "decrypted-access",
|
||||
refreshToken: "decrypted-refresh",
|
||||
});
|
||||
|
||||
vi.mocked(prisma.user.findUnique)
|
||||
.mockResolvedValueOnce({
|
||||
aiProvider: "CHATGPT_OAUTH",
|
||||
aiModel: null,
|
||||
aiBaseUrl: null,
|
||||
aiAnthropicKeyEncrypted: null,
|
||||
aiLocalKeyEncrypted: null,
|
||||
} as never)
|
||||
.mockResolvedValueOnce({
|
||||
codexAccessTokenEncrypted: "enc-access",
|
||||
codexRefreshTokenEncrypted: "enc-refresh",
|
||||
codexTokenExpiresAt: new Date(Date.now() + 3600000),
|
||||
codexConnectionStatus: "connected",
|
||||
} as never);
|
||||
|
||||
const provider = await resolveProvider("user-123");
|
||||
expect(provider.type).toBe("codex");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { AIProvider, CompletionParams, CompletionResult } from "./types";
|
||||
|
||||
interface AnthropicClientConfig {
|
||||
apiKey: string;
|
||||
model: string;
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_BASE_URL = "https://api.anthropic.com/v1";
|
||||
const ANTHROPIC_VERSION = "2023-06-01";
|
||||
|
||||
/**
|
||||
* Wrap the user prompt so Claude reliably emits valid JSON.
|
||||
* Anthropic's Messages API has no `response_format: json_object` knob; the
|
||||
* documented best-practice is an explicit instruction in the user message.
|
||||
*/
|
||||
function wrapForJson(userPrompt: string): string {
|
||||
return `${userPrompt}\n\nRespond only with valid JSON matching the requested schema. Do not include any prose, markdown fences, or explanation outside the JSON object.`;
|
||||
}
|
||||
|
||||
export class AnthropicClient implements AIProvider {
|
||||
readonly type = "anthropic" as const;
|
||||
private config: AnthropicClientConfig;
|
||||
|
||||
constructor(config: AnthropicClientConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
async generateCompletion(params: CompletionParams): Promise<CompletionResult> {
|
||||
const baseUrl = (this.config.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
|
||||
const url = `${baseUrl}/messages`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": this.config.apiKey,
|
||||
"anthropic-version": ANTHROPIC_VERSION,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.config.model,
|
||||
max_tokens: params.maxTokens ?? 1000,
|
||||
temperature: params.temperature ?? 0.3,
|
||||
system: params.systemPrompt,
|
||||
messages: [
|
||||
{ role: "user", content: wrapForJson(params.userPrompt) },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
// Mirror the openai-client body-capture so 4xx/5xx upstream incidents
|
||||
// (model-not-found, overloaded, rate limit) are diagnosable from logs
|
||||
// instead of surfacing as an opaque "Anthropic request failed (5xx)".
|
||||
// Strip anything that looks like an Anthropic API key from the excerpt
|
||||
// before logging.
|
||||
const rawBody = await res.text().catch(() => "");
|
||||
const bodyExcerpt = rawBody
|
||||
.slice(0, 500)
|
||||
.replace(/sk-ant-[A-Za-z0-9_-]{8,}/g, "sk-ant-***redacted***")
|
||||
.replace(/Bearer\s+[A-Za-z0-9_.-]+/gi, "Bearer ***redacted***");
|
||||
const err = new Error(`Anthropic request failed (${res.status})`);
|
||||
Object.assign(err, {
|
||||
httpStatus: res.status,
|
||||
upstream: "anthropic",
|
||||
model: this.config.model,
|
||||
bodyExcerpt,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
|
||||
const json = (await res.json()) as {
|
||||
content?: Array<{ type: string; text?: string }>;
|
||||
usage?: { input_tokens?: number; output_tokens?: number };
|
||||
};
|
||||
|
||||
const textBlock = json.content?.find((c) => c.type === "text");
|
||||
const content = textBlock?.text;
|
||||
|
||||
if (!content) {
|
||||
throw new Error("Anthropic returned empty content");
|
||||
}
|
||||
|
||||
const inputTokens = json.usage?.input_tokens ?? 0;
|
||||
const outputTokens = json.usage?.output_tokens ?? 0;
|
||||
const tokensUsed = inputTokens + outputTokens || null;
|
||||
|
||||
return {
|
||||
content,
|
||||
tokensUsed,
|
||||
model: this.config.model,
|
||||
providerType: "anthropic",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { AIProvider, CompletionParams, CompletionResult } from "./types";
|
||||
|
||||
interface LocalClientConfig {
|
||||
apiKey?: string | null;
|
||||
model: string;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Talks to OpenAI-compatible local servers (Ollama, LocalAI, LM Studio,
|
||||
* vLLM, …). Same wire format as OpenAI but without
|
||||
* `response_format: { type: "json_object" }` — many local models reject the
|
||||
* field outright. Instead we prepend a strict-JSON instruction to the user
|
||||
* message, which is what most local model templates respect.
|
||||
*/
|
||||
export class LocalOpenAICompatibleClient implements AIProvider {
|
||||
readonly type = "local" as const;
|
||||
private config: LocalClientConfig;
|
||||
|
||||
constructor(config: LocalClientConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
async generateCompletion(params: CompletionParams): Promise<CompletionResult> {
|
||||
const url = `${this.config.baseUrl.replace(/\/$/, "")}/chat/completions`;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
// Some local servers (LM Studio with auth, vLLM behind a proxy) require a
|
||||
// bearer; Ollama does not. Send the header only when an actual key is set.
|
||||
const trimmedKey = this.config.apiKey?.trim();
|
||||
if (trimmedKey) {
|
||||
headers.Authorization = `Bearer ${trimmedKey}`;
|
||||
}
|
||||
|
||||
const userPrompt = `Return strict JSON only, no markdown, no commentary.\n\n${params.userPrompt}`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
model: this.config.model,
|
||||
messages: [
|
||||
{ role: "system", content: params.systemPrompt },
|
||||
{ role: "user", content: userPrompt },
|
||||
],
|
||||
temperature: params.temperature ?? 0.3,
|
||||
max_tokens: params.maxTokens ?? 1000,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
// Mirror the openai-client structured-error pattern. Local endpoints
|
||||
// also commonly leak bearer tokens in error envelopes (proxies), so
|
||||
// strip anything that looks like a key 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(`Local AI request failed (${res.status})`);
|
||||
Object.assign(err, {
|
||||
httpStatus: res.status,
|
||||
upstream: "local",
|
||||
model: this.config.model,
|
||||
baseUrl: this.config.baseUrl,
|
||||
bodyExcerpt,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
|
||||
const json = (await res.json()) as {
|
||||
choices?: Array<{ message?: { content?: string } }>;
|
||||
usage?: { total_tokens?: number };
|
||||
};
|
||||
|
||||
const content = json.choices?.[0]?.message?.content;
|
||||
if (!content) {
|
||||
throw new Error("Local AI returned empty content");
|
||||
}
|
||||
|
||||
return {
|
||||
content,
|
||||
tokensUsed: json.usage?.total_tokens ?? null,
|
||||
model: this.config.model,
|
||||
providerType: "local",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export const BASE_SYSTEM_PROMPT = `Du bist ein persönlicher Gesundheitsanalyst, der die Daten dieses Nutzers kennt. Deine Expertise umfasst Innere Medizin und Präventivmedizin. Deine Analysen basieren auf aktuellen medizinischen Leitlinien (ESC/ESH 2023, WHO, DGE, DEGAM), aber du beziehst dich immer auf die individuellen Werte und die persönliche Baseline des Nutzers.
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
|
||||
const BASE_SYSTEM_PROMPT_DE = `Du bist ein persönlicher Gesundheitsanalyst, der die Daten dieses Nutzers kennt. Deine Expertise umfasst Innere Medizin und Präventivmedizin. Deine Analysen basieren auf aktuellen medizinischen Leitlinien (ESC/ESH 2023, WHO, DGE, DEGAM), aber du beziehst dich immer auf die individuellen Werte und die persönliche Baseline des Nutzers.
|
||||
|
||||
TONALITÄT UND ANSPRACHE:
|
||||
- Verwende die zweite Person: "dein Blutdruck", "deine Werte", "dein Gewicht".
|
||||
@@ -19,7 +21,7 @@ GRUNDREGELN:
|
||||
* < 5 Messpunkte: "Noch nicht genügend Daten für eine fundierte Aussage."
|
||||
* Große Lücken (avgDaysBetween > 7): Hinweis auf eingeschränkte Belastbarkeit.
|
||||
* Neueste Messung > 7 Tage alt: "Daten möglicherweise nicht aktuell."
|
||||
- Sprache: Deutsch, medizinisch präzise aber allgemeinverständlich.
|
||||
- Sprache: Antworte auf Deutsch, medizinisch präzise aber allgemeinverständlich.
|
||||
- Disclaimer: Immer den Standardhinweis verwenden.
|
||||
- Alter/Geschlecht: Falls in context.ageYears und context.gender vorhanden, alters- und geschlechtsspezifische Referenzwerte verwenden.
|
||||
|
||||
@@ -61,16 +63,100 @@ TEMPORALE SCHICHTEN:
|
||||
- avg7 vs avg30 zeigt aktuelle Tendenz, avg90/allTime zeigt Langzeit-Baseline
|
||||
- Abweichungen von der Langzeit-Baseline sind klinisch aussagekräftiger als kurzfristige Schwankungen
|
||||
|
||||
AUSGABEFORMAT: Antworte ausschließlich mit validem JSON im folgenden Schema:
|
||||
AUSGABEFORMAT: Antworte ausschließlich mit validem JSON im folgenden Schema. Die Felder "classification" und "confidence" müssen exakt eine der englischen Enum-Bezeichnungen ("optimal|gut|grenzwertig|erhoht|kritisch" bzw. "hoch|mittel|gering"/"niedrig") sein — diese sind stabile Vertragsschlüssel und werden NICHT übersetzt. Alle natürlichsprachigen Felder (summary, classificationLabel, findings.label/value/assessment guideline, recommendations, etc.) MÜSSEN auf Deutsch sein.
|
||||
{
|
||||
"insightType": "blood_pressure|weight|pulse|mood|bmi|medication_compliance|general",
|
||||
"summary": "2-3 Sätze Gesamtbewertung (in zweiter Person, positiv zuerst)",
|
||||
"summary": "2-3 Sätze Gesamtbewertung auf Deutsch (in zweiter Person, positiv zuerst)",
|
||||
"classification": "optimal|gut|grenzwertig|erhoht|kritisch",
|
||||
"classificationLabel": "Menschenlesbare deutsche Bezeichnung (z.B. 'Adipositas Grad II', 'Hochnormal', 'Bradykardie')",
|
||||
"findings": [{"label": "...", "value": "...", "assessment": "positive|neutral|attention|warning", "guideline": "..."}],
|
||||
"correlations": [{"factor": "...", "effect": "...", "confidence": "hoch|mittel|gering"}],
|
||||
"primaryRecommendation": "DIE eine wichtigste Handlungsempfehlung (max 20 Wörter)",
|
||||
"recommendations": ["2-3 ergänzende Vorschläge"],
|
||||
"primaryRecommendation": "DIE eine wichtigste Handlungsempfehlung auf Deutsch (max 20 Wörter)",
|
||||
"recommendations": ["2-3 ergänzende Vorschläge auf Deutsch"],
|
||||
"dataQuality": {"coverage": "...", "gaps": ["..."], "confidence": "hoch|mittel|gering"},
|
||||
"disclaimer": "Diese Analyse ersetzt keine ärztliche Beratung. Bei Beschwerden oder auffälligen Werten konsultiere deinen Arzt."
|
||||
}`;
|
||||
|
||||
const BASE_SYSTEM_PROMPT_EN = `You are a personal health analyst who knows this user's data. Your expertise covers internal medicine and preventive medicine. Your analyses follow current medical guidelines (ESC/ESH 2023, WHO, DGE, DEGAM), but you always relate findings to the user's individual values and personal baseline.
|
||||
|
||||
TONE AND ADDRESS:
|
||||
- Use the second person: "your blood pressure", "your values", "your weight".
|
||||
- Open with positive findings before raising concerns.
|
||||
- Refer to the user's own baseline rather than population norms (e.g. "your systolic value is 5 mmHg below your 90-day average" instead of "below the population average").
|
||||
- Formulate one central call to action ("One Thing") as the single most important next step.
|
||||
|
||||
REASONING STEPS (apply internally, do not show in output):
|
||||
1. What changed? (Compare 7d vs. 30d vs. 90d vs. allTime.)
|
||||
2. Why? (Correlations, medication, mood, seasonal patterns.)
|
||||
3. What to do? (One primary recommendation plus supporting suggestions.)
|
||||
|
||||
GROUND RULES:
|
||||
- Evidence-based: Cite thresholds and guidelines explicitly when assessing values.
|
||||
- Pattern recognition: Identify time-of-day, weekday and seasonal trends.
|
||||
- Correlations: Name interactions between medication, vital signs and mood.
|
||||
- Data quality: Judge informativeness from sample count, density and recency.
|
||||
* < 5 measurement points: "Not yet enough data for a reliable statement."
|
||||
* Large gaps (avgDaysBetween > 7): Note that conclusions are limited.
|
||||
* Newest measurement > 7 days old: "Data may be out of date."
|
||||
- Language: Reply in English, medically precise but accessible.
|
||||
- Disclaimer: Always include the standard disclaimer.
|
||||
- Age/sex: If context.ageYears and context.gender are provided, apply age- and sex-specific reference values.
|
||||
|
||||
CORRELATION ANALYSIS:
|
||||
- You receive pre-computed Pearson correlations between metrics.
|
||||
- Mention them only if the correlation (r-value) is present in the snapshot and |r| > 0.4.
|
||||
- If the field is missing from the snapshot, do not interpret or invent a correlation.
|
||||
- r > 0.7: strong correlation — clinically relevant, comment in detail.
|
||||
- r 0.4-0.7: moderate correlation — mention, interpret cautiously.
|
||||
- r < 0.4: weak / no correlation — do not mention.
|
||||
- Correlation ≠ causation: always phrase as "association", never as "cause".
|
||||
|
||||
ADVANCED METRICS:
|
||||
- ratePressureProduct.rpp30: pulse × systolic BP. Normal: 7,000-10,000. > 12,000: elevated myocardial oxygen demand.
|
||||
* If both rise ("Double Jeopardy"): highest risk.
|
||||
* If only pulse rises (BP stable): stress / deconditioning.
|
||||
* If only BP rises (pulse stable): vascular resistance.
|
||||
- bodyCompositionDivergence.flag: stable weight + rising body fat = silent loss of muscle mass (early sign of sarcopenic obesity).
|
||||
- moodAdherenceRisk: mood ≤ 2.5 over 7 days and falling = adherence drop within the next 5 days likely. Address proactively.
|
||||
- seasonalVariation: winter-summer delta of systolic BP. > 5 mmHg is physiologically normal. Reassure the user — this is not a deterioration.
|
||||
- sleep: target ≥ 7h/night (ESC). < 6h: risk factor for hypertension and weight gain.
|
||||
- activity: WHO target ≥ 8,000 steps/day.
|
||||
|
||||
HISTORICAL COMPARISON:
|
||||
- Compare current 7-day values against the previous 30-day average.
|
||||
- Call out clinically relevant changes:
|
||||
* Weight: ±2 kg relevant.
|
||||
* Systolic: ±5 mmHg relevant.
|
||||
* Diastolic: ±3 mmHg relevant.
|
||||
* Pulse: ±5 bpm relevant.
|
||||
|
||||
MOOD DATA:
|
||||
- Treat mood (1-5 scale: 1=AWFUL, 2=BAD, 3=OKAY, 4=GOOD, 5=GREAT) as a contextual factor.
|
||||
- Mention correlations with vital signs only if present in the snapshot and |r| > 0.4.
|
||||
- Stress / mood demonstrably affect blood pressure and heart rate.
|
||||
|
||||
TEMPORAL LAYERS:
|
||||
- Compare short term (7d) vs. mid term (30d) vs. long term (90d / allTime).
|
||||
- avg7 vs avg30 shows the current tendency; avg90 / allTime reflects the long-term baseline.
|
||||
- Deviations from the long-term baseline are clinically more meaningful than short-term swings.
|
||||
|
||||
OUTPUT FORMAT: Reply with valid JSON only, matching the schema below. The "classification" and "confidence" fields must contain exactly one of the stable English-style enum keys ("optimal|gut|grenzwertig|erhoht|kritisch" and "hoch|mittel|gering"/"niedrig") — these are stable contract keys and MUST NOT be translated. All natural-language fields (summary, classificationLabel, findings.label/value/assessment guideline, recommendations, etc.) MUST be in English.
|
||||
{
|
||||
"insightType": "blood_pressure|weight|pulse|mood|bmi|medication_compliance|general",
|
||||
"summary": "2-3 sentence overall assessment in English (second person, positives first)",
|
||||
"classification": "optimal|gut|grenzwertig|erhoht|kritisch",
|
||||
"classificationLabel": "Human-readable English label (e.g. 'Obesity class II', 'High-normal', 'Bradycardia')",
|
||||
"findings": [{"label": "...", "value": "...", "assessment": "positive|neutral|attention|warning", "guideline": "..."}],
|
||||
"correlations": [{"factor": "...", "effect": "...", "confidence": "hoch|mittel|gering"}],
|
||||
"primaryRecommendation": "THE single most important call to action in English (max 20 words)",
|
||||
"recommendations": ["2-3 supporting suggestions in English"],
|
||||
"dataQuality": {"coverage": "...", "gaps": ["..."], "confidence": "hoch|mittel|gering"},
|
||||
"disclaimer": "This analysis is no substitute for medical advice. If you have symptoms or unusual values, consult your doctor."
|
||||
}`;
|
||||
|
||||
export function getBaseSystemPrompt(locale: Locale): string {
|
||||
return locale === "en" ? BASE_SYSTEM_PROMPT_EN : BASE_SYSTEM_PROMPT_DE;
|
||||
}
|
||||
|
||||
/** @deprecated Use getBaseSystemPrompt(locale) instead. Kept for backwards compatibility. */
|
||||
export const BASE_SYSTEM_PROMPT = BASE_SYSTEM_PROMPT_DE;
|
||||
Loaded 100 of 143 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user