chore(repo): untrack docs/superpowers + ignore AI tool artefacts

Past Claude / Superpowers session output (plans + design specs) was
landing in the public repo. Files stay on disk locally but are no
longer tracked. .gitignore extended to cover the usual AI-tooling
scratch directories (.claude/, .cursor/, .codex/, .aider*) so future
sessions don't sneak anything in either.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-04-28 22:30:33 +02:00
1 parent 0783261b44
commit 4a205f5348
8 files changed
+7 -2963

No files matched your search

+7
View File
@@ -39,3 +39,10 @@ next-env.d.ts
# prisma generated
/src/generated/prisma
# AI session artefacts (kept locally, not committed)
/docs/superpowers/
.claude/
.cursor/
.codex/
.aider*
File diff suppressed because it is too large. Load diff
@@ -1,359 +0,0 @@
# Codex OAuth + Medizinische Insights — Design Spec
**Datum:** 2026-04-06
**Status:** Approved
**Scope:** Replace OpenAI API-Key flow with ChatGPT Codex OAuth, improve medical insight prompts
## 1. Problem
HealthLog uses user-provided OpenAI API keys for AI-powered health insights. The API quota ran out (`insufficient_quota`), breaking all insight generation and background jobs. Users shouldn't need to manage API billing.
## 2. Solution
Replace the API-key model with **OpenAI Codex OAuth** — users authenticate with their ChatGPT Pro/Max subscription, and insights run against their flat-rate plan. An admin-level API key serves as a global fallback.
## 3. Provider Hierarchy
```
1. User has Codex OAuth connection → Codex Responses API (gpt-5.3-codex)
2. Admin key configured → Standard OpenAI Chat Completions API (configurable model)
3. Nothing configured → Local fallback texts (no-key-fallbacks.ts)
```
## 4. Architecture
### New Files
```
src/lib/ai/
provider.ts — resolveProvider(userId) → { type, client, model }
codex-oauth.ts — PKCE generation, token exchange, refresh
codex-client.ts — Codex Responses API client with auto-refresh
openai-client.ts — Standard OpenAI client (admin key, configurable base URL)
types.ts — Shared types (InsightResult, ProviderConfig, etc.)
prompts/
base-system.ts — Shared medical context, role, constraints
general-status.ts — Holistic health assessment prompt
blood-pressure.ts — ESC/ESH guideline-based BP analysis
weight.ts — BMI context, trend analysis, plateau detection
pulse.ts — Resting HR trends, stress correlation
bmi.ts — WHO classification, age-adjusted
medication-compliance.ts — Intake patterns, efficacy correlation
schema.ts — Zod schema for InsightResult validation
```
### Modified Files
```
src/app/api/auth/codex/authorize/route.ts — NEW: Start OAuth flow
src/app/api/auth/codex/callback/route.ts — NEW: Handle OAuth callback
src/app/api/auth/codex/disconnect/route.ts — NEW: Remove tokens
src/app/api/admin/ai-settings/route.ts — NEW: Admin key management
src/app/api/insights/generate/route.ts — MODIFY: Use provider abstraction
src/app/api/insights/settings/route.ts — MODIFY: Show Codex status instead of API key
src/app/api/insights/*/route.ts — MODIFY: New output schema
src/lib/insights/*.ts — MODIFY: Use new prompts + provider
src/lib/jobs/reminder-worker.ts — MODIFY: Use provider with auto-refresh
src/proxy.ts — MODIFY: Add /api/auth/codex/callback to PUBLIC_PATHS
prisma/schema.prisma — MODIFY: New fields, remove openaiKeyEncrypted
```
### Removed Files/Code
```
src/app/api/insights/settings/route.ts — OpenAI key PUT handler (replaced)
src/lib/insights/prompt.ts — Single prompt file (replaced by prompts/)
User.openaiKeyEncrypted — DB field removed
```
## 5. OAuth Flow
### Authorization (User-initiated)
```
1. User clicks "Mit ChatGPT verbinden" in Settings
2. GET /api/auth/codex/authorize
- Generate PKCE code_verifier (128 bytes, base64url)
- Compute code_challenge = SHA-256(code_verifier), base64url
- Generate random state parameter
- Store code_verifier + state in session
- Redirect to: chatgpt.com/authorize?
response_type=code
&code_challenge=<challenge>
&code_challenge_method=S256
&redirect_uri=https://healthlog.bombeck.io/api/auth/codex/callback
&state=<state>
3. User logs in at ChatGPT, grants access
4. Redirect to /api/auth/codex/callback?code=...&state=...
5. Validate state against session
6. Exchange code for tokens (POST chatgpt.com/oauth/token)
7. Encrypt access_token + refresh_token with AES-256-GCM
8. Store in User record with expiresAt
9. Redirect to /settings with success toast
```
### Token Refresh (Automatic)
```
Before every API call:
if codexTokenExpiresAt < now + 5min:
POST chatgpt.com/oauth/token (grant_type=refresh_token)
Store new encrypted tokens + new expiresAt
If refresh fails:
Increment failCount
If failCount >= 3 over 72h:
Set codexConnectionStatus = 'expired'
Fall back to admin key silently
Else:
Retry on next call
On 401 response:
Refresh token immediately, retry request once
```
### Disconnect
```
DELETE /api/auth/codex/disconnect
- Delete tokens from DB
- Revoke at OpenAI if endpoint exists
- Audit log entry
```
## 6. Database Changes
### Prisma Schema
```prisma
model User {
// REMOVE:
// openaiKeyEncrypted String?
// ADD:
codexAccessTokenEncrypted String?
codexRefreshTokenEncrypted String?
codexTokenExpiresAt DateTime?
codexConnectedAt DateTime?
codexConnectionStatus String @default("disconnected")
// Values: "connected", "disconnected", "expired"
}
model AppSettings {
// ADD:
adminAiKeyEncrypted String?
adminAiModel String @default("gpt-4o-mini")
adminAiBaseUrl String @default("https://api.openai.com/v1")
}
```
### Migration
```sql
-- Add new columns
ALTER TABLE users ADD COLUMN codex_access_token_encrypted TEXT;
ALTER TABLE users ADD COLUMN codex_refresh_token_encrypted TEXT;
ALTER TABLE users ADD COLUMN codex_token_expires_at TIMESTAMP;
ALTER TABLE users ADD COLUMN codex_connected_at TIMESTAMP;
ALTER TABLE users ADD COLUMN codex_connection_status TEXT NOT NULL DEFAULT 'disconnected';
ALTER TABLE app_settings ADD COLUMN admin_ai_key_encrypted TEXT;
ALTER TABLE app_settings ADD COLUMN admin_ai_model TEXT NOT NULL DEFAULT 'gpt-4o-mini';
ALTER TABLE app_settings ADD COLUMN admin_ai_base_url TEXT NOT NULL DEFAULT 'https://api.openai.com/v1';
-- Remove old column
ALTER TABLE users DROP COLUMN openai_key_encrypted;
```
## 7. Insight Output Schema
### New InsightResult (replaces old changed/stable/drivers/nextSteps)
```typescript
interface InsightResult {
summary: string
classification: 'optimal' | 'gut' | 'grenzwertig' | 'erhoht' | 'kritisch'
findings: {
label: string
value: string
assessment: 'positive' | 'neutral' | 'attention' | 'warning'
guideline?: string
}[]
correlations: {
factor: string
effect: string
confidence: 'hoch' | 'mittel' | 'gering'
}[]
recommendations: string[]
dataQuality: {
coverage: string
gaps: string[]
confidence: 'hoch' | 'mittel' | 'gering'
}
disclaimer: string
}
```
### Zod Validation
Every AI response is validated against a Zod schema before being stored.
Invalid responses are logged as errors and the user gets a generic fallback.
## 8. Prompt Design
### Base System Prompt
```
Du bist ein klinischer Gesundheitsanalyst mit Expertise in Innerer Medizin
und Präventivmedizin. Deine Analyse basiert auf aktuellen Leitlinien
(ESC/ESH 2023, WHO, DGE, DEGAM).
Regeln:
- Evidenzbasiert: Referenziere Grenzwerte und Leitlinien explizit
- Muster erkennen: Tageszeit, Wochentag, saisonale Trends
- Korrelationen: Wechselwirkungen zwischen Medikation und Vitalwerten benennen
- Limitationen: Klar kommunizieren was die Datenlage hergibt und was nicht
- Kein Ersatz für ärztliche Diagnose — immer explizit als Disclaimer
- Sprache: Deutsch, medizinisch präzise aber allgemeinverständlich
- Antworte ausschließlich im vorgegebenen JSON-Schema
```
### Per-Type Specialization
Each insight type adds domain-specific instructions:
- **Blood Pressure**: ESC/ESH classification (optimal <120/80, normal <130/85, ...), morning surge detection, white-coat vs. masked hypertension hints, medication correlation
- **Weight**: BMI-adjusted analysis, 7/30/90-day slope, plateau detection (±0.5kg over 14+ days), realistic goal projection
- **Pulse**: Resting HR zones (bradycardia <50, athletic 50-60, normal 60-100, tachycardia >100), trend stability, stress/recovery indicators
- **BMI**: WHO classification, waist-to-height hints if available, age context
- **Medication Compliance**: Pattern analysis (morning vs evening adherence), streak tracking, correlation with vital sign improvements
- **General Status**: Cross-domain synthesis, risk stratification, most impactful change recommendation
## 9. UI Changes
### User Settings (/settings)
Replace "OpenAI API Key" section with:
```
KI-Insights
┌─────────────────────────────────────────┐
│ Status: 🟢 Verbunden mit ChatGPT │
│ Verbunden seit: 02.04.2026 │
│ │
│ [Trennen] │
│ │
│ ℹ️ Insights werden über dein ChatGPT- │
│ Abo generiert (keine zusätzlichen │
│ Kosten). │
└─────────────────────────────────────────┘
```
Or when disconnected:
```
KI-Insights
┌─────────────────────────────────────────┐
│ Status: ⚪ Nicht verbunden │
│ │
│ [Mit ChatGPT verbinden] │
│ │
│ ℹ️ Verbinde dein ChatGPT Pro/Max-Konto │
│ um KI-gestützte Gesundheitsanalysen zu │
│ erhalten. │
└─────────────────────────────────────────┘
```
### Admin Settings (/settings — ADMIN only)
```
KI-Anbieter (Global)
┌─────────────────────────────────────────┐
│ API Key: [sk-...****________] [Testen] │
│ Modell: [gpt-4o-mini ▾] │
│ Base URL: [https://api.openai.com/v1] │
│ │
│ ℹ️ Wird als Fallback verwendet, wenn │
│ ein User keine eigene ChatGPT- │
│ Verbindung hat. │
│ │
│ [Speichern] │
└─────────────────────────────────────────┘
```
### Insights Display
Update insight cards to render the richer InsightResult:
- Classification badge with color (green/yellow/orange/red)
- Findings list with assessment icons
- Collapsible correlations section
- Data quality indicator (small badge)
- Disclaimer footer (smaller text)
## 10. Security
### OAuth Security
- PKCE (S256) prevents authorization code interception
- State parameter prevents CSRF
- Tokens encrypted at rest (AES-256-GCM via existing crypto.ts)
- Tokens never sent to client/browser
- Refresh token rotation on every refresh
- Callback route validates state against session
### Admin Key Security
- Only ADMIN role can read/write
- Key displayed masked in UI (sk-...****)
- Rate limiting on admin key usage (prevents abuse by many users)
- Encrypted at rest
### Audit Trail
- Every OAuth connect/disconnect logged
- Every insight generation logs: provider type, user, model, duration
- Admin key changes logged
### Security Audits (3x)
1. **Pre-Implementation**: OAuth flow validated against OWASP OAuth Security Checklist
2. **Post-Implementation**: Code review agent checks all new files for injection, token leaks, unencrypted secrets
3. **Post-Deploy**: Penetration test of OAuth endpoints (CSRF, token theft, callback manipulation)
## 11. Testing Strategy (TDD)
### Unit Tests (vitest)
| Suite | Coverage |
|-------|----------|
| `ai/provider.test.ts` | Provider hierarchy, correct client selection |
| `ai/codex-oauth.test.ts` | PKCE generation, token exchange, refresh, error scenarios |
| `ai/codex-client.test.ts` | Request format, auto-refresh on 401, retry with backoff |
| `ai/openai-client.test.ts` | Chat completions format, custom base URL, error handling |
| `ai/prompts/*.test.ts` | Prompt assembly per type, output schema validation with Zod |
| `insights/*.test.ts` | Updated generators with new output schema |
| `api/codex-callback.test.ts` | State validation, CSRF protection, token storage |
| `api/admin-settings.test.ts` | ADMIN-only access, key encryption, validation |
### Integration Tests
- OAuth flow end-to-end (mocked ChatGPT endpoint)
- Background job token refresh + insight generation
- Provider fallback chain under different DB states
## 12. CSP Changes
```diff
- connect-src 'self' https://api.openai.com https://wbsapi.withings.net
+ connect-src 'self' https://api.openai.com https://chatgpt.com https://wbsapi.withings.net
```
## 13. Migration Path
1. Deploy new code with both old (openaiKeyEncrypted) and new fields
2. Run migration to add new columns
3. Remove old column in follow-up migration
4. Users see "Nicht verbunden" in settings, can connect via OAuth
5. Admin can configure fallback key immediately
6. Old cached insights remain visible until refreshed
## 14. Out of Scope
- Multiple AI providers per user (only Codex OAuth OR admin key)
- Custom model selection per user (model is fixed per provider)
- Streaming responses (batch only, like current implementation)
- OpenAI API key input for individual users (removed entirely)
@@ -1,207 +0,0 @@
# 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.
@@ -1,162 +0,0 @@
# 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.
@@ -1,227 +0,0 @@
# 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.
@@ -1,117 +0,0 @@
# 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.
@@ -1,216 +0,0 @@
# 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.