From 9b593902245e566c2a4df3475ac2eb62a91dba50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Apr 2026 17:15:43 +0200 Subject: [PATCH] fix: i18n completeness + UX polish (closes #73, #74) (#75) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(i18n,ux): package A — missing keys, chart labels, query invalidation, admin error UX Addresses bugs surfaced in issues #73 and #74 plus drift found during audit. - i18n: add 21 missing translation keys (targets.* namespace was only partially populated after the English-first migration; common.reset and settings.configured were referenced but absent), plus common helpers (copy, copyDetails, retry, reportIssue) and admin.settingsSaveError. - charts: rename points7Label/30Label/90Label from cryptic "7P/30P/90P" to "7 pts / 30 pts / 90 pts" in EN and "7 Pkt / 30 Pkt / 90 Pkt" in DE. - queries: add src/lib/query-keys.ts as a single source of truth with measurement/mood/medication-dependent key sets. Wire them into measurement-form/list, mood-form/list, medication-form/card, intake-history-list, intake-timeline, medications page, and Withings sync in settings. Fixes dashboard not auto-refreshing after adding a measurement (old code only invalidated ["measurements"], not ["analytics"] / ["insights"] / ["insights","targets"]). - admin: useUpdateSettings + updateUser mutations now surface real error messages via sonner toasts and invoke getApiErrorMessage() so saves fail loudly instead of silently. Success also gets a toast. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(i18n): package B — locale-aware formatters throughout Removes the hard-coded de-DE locale that lingered from the German-first era and makes every number, date, and time locale-aware. English stays the default per user preference; German remains selectable. New: - src/lib/format-locale.ts exposes makeFormatters(locale) + a cached Europe/Berlin timezone. Covers number, integer, percent, date, dateShort, dateTime, time, dateWithWeekday, monthShort. Also exports parseLocaleFromAcceptLanguage() for server code. - useFormatters() hook in @/lib/i18n/context resolves from the active locale, memoized per render. Migrated (all Intl.NumberFormat("de-DE") / toLocaleString("de-DE") / Intl.DateTimeFormat("de-DE") call sites): - src/app/page.tsx (dashboard) - src/app/insights/page.tsx - src/app/admin/page.tsx - src/app/settings/page.tsx (Withings last-sync, doctor-report caller) - src/components/charts/trend-card.tsx (+ i18n "7d"/"30d" short labels) - src/components/charts/health-chart.tsx - src/components/medications/medication-card.tsx - src/components/medications/medication-form.tsx - src/components/measurements/measurement-list.tsx - src/components/mood/mood-list.tsx - src/components/insights/insight-status-card.tsx (also removed 5 German hard-coded strings with TODO: i18n markers — now translated) - src/components/insights/insight-advisor-card.tsx (same — 10+ strings) Server: reminder-check / reminder-worker / umami-test switched from "de-DE" to neutral en-GB/en-US where the string is machine-compared rather than user-facing. Bug-report GitHub issue titles use ISO-8601 UTC now — the repo is English-speaking. Next.js metadata: - OpenGraph locale from "de_DE" → "en_US" with "de_DE" as alternateLocale. - Root layout now reads healthlog-locale cookie (falls back to Accept-Language) and sets server-side, avoiding an EN-flashes-then-DE hydration gap and fixing SEO/a11y. - I18nProvider mirrors locale to a 1-year SameSite=Lax cookie so the next SSR pass is correct. Doctor-report PDF: - generateDoctorReportPDF(data, { t, locale }) now accepts injected translator + locale. All section titles, column headers, BMI / BP classifications, mood labels, and the footer disclaimer are i18n-driven. File name slug switches between gesundheitsbericht / health-report. - 40+ new doctorReport.* keys in EN + DE. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(ux): package C — typed passkey errors, bug-report gating, error boundary Addresses issue #73 points 1, 5, 6 and lays groundwork for copy-paste error reporting requested during review. Passkey registration / login (#73.1): - src/lib/passkey-errors.ts maps DOMException.name + SimpleWebAuthn code to specific i18n keys instead of swallowing every failure as "cancelled". Distinguishes NotAllowed/InvalidState/NotSupported/ Security/Abort errors and falls back to a diagnostic message. - settings passkey + login passkey flows both use describePasskeyError now, so origin mismatches and previous-registration collisions show actionable copy. - 5 new passkey.* i18n keys in EN + DE. Bug report (#73.5): - New GET /api/bugreport/status reports {configured, hasToken, hasRepo, isAdmin}. - /bugreport page now queries status first. When not configured it shows a notice (different copy for admins vs. regular users) with a direct link to the admin panel. Form is hidden, not silently broken. - When POST /api/bugreport can't create issues it returns 503 instead of 500 with the same user-facing message. - 4 new bugreport.* i18n keys. Global error UX: - src/components/error-details.tsx — reusable panel with retry + copy diagnostics (message, digest, stack (trimmed), URL, user-agent, request-id, locale, timestamp) + report-issue link. Clipboard fallback prompt covers insecure-context cases. - src/app/error.tsx uses it. - src/app/global-error.tsx is provider-free (no i18n dependency) since it replaces the root layout on catastrophic failures — hardcoded English + Dracula palette so it still looks like HealthLog. - 2 new errorBoundary.* keys and 4 common.* keys (copy, copyDetails, retry, reportIssue). Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(query-keys): collapse repeated invalidation blocks into invalidateKeys helper Eleven mutation handlers repeated `Promise.all(keys.map(k => invalidate({queryKey: k})))` verbatim. Adds `invalidateKeys(qc, keys)` to the existing query-keys factory and adopts it everywhere, including replacing the local `invalidateMedicationRelated` helper that medication-form.tsx had grown. Also fixes the import-sandwich that helper introduced. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor: small simplifications in i18n/UX changes - passkey-errors: drop the typeof-name dance now that error.name is already string after the instanceof Error narrow - doctor-report-pdf: replace nested gender ternary with a small lookup map; clarify unitFor's null-as-translation-marker convention - dashboard page: route the analytics queries through the new queryKeys factory so future invalidation stays in one place - health-chart: add missing fmt dependency to trendInfo useMemo (introduced when formatSigned was switched to the locale-aware formatter) Co-Authored-By: Claude Opus 4.7 (1M context) * fix(ai): capture upstream response body in OpenAI errors A production insights burst at 12:19 UTC failed with "OpenAI request failed (500)". The 500 was the upstream OpenAI HTTP status (confirmed via logs), but we had no way to tell from logs whether it was an OpenAI incident, a model-not-found, or a response_format rejection because the response body was thrown away. Now the error message includes the first 500 chars of the response body and structured fields (httpStatus, upstream, model) that Wide Event logging can attach. No behavior change for the happy path. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(review): address code-review + security findings Review round produced five real issues — fix them all, plus three nits that were cheap enough to bundle. Must-fix: - security: error-details.tsx + global-error.tsx were copying window.location.href into the clipboard, which on OAuth callback routes (/api/withings/*, password reset) would leak code/state tokens into GitHub issues. Strip to pathname + searchParamCount. - fe: query-keys.invalidateKeys now uses Promise.allSettled, so a transient invalidate failure doesn't abort the chain and no unhandled rejection leaks from void invalidateKeys(...) call sites. - fe: resolveInitialLocale in app/layout.tsx wraps cookies()/headers() in try/catch — a dynamic-server error in the root layout now degrades to the "en" default instead of crashing into global-error. - be: reminder-worker + reminder-check parseTimeToMinutes normalize "24:00" → "00:00" (some ICU builds emit that for midnight under en-GB). Prevents a 1440-minute comparison against schedule windows. - security: openai-client error body excerpt redacts sk-* / Bearer tokens before logging, and moves the excerpt off Error.message into a structured field (bodyExcerpt) so log transports can filter it. - i18n: /api/withings/connect German error message → English. - i18n: /insights/page.tsx hardcoded "Fehler beim Laden" → t(...) Hardening: - /api/bugreport/status collapses to {configured, isAdmin} and adds a 30/min rate limit per user. Client interface updated. - i18n/context.tsx: I18nProvider's initializer now reads the cookie (matches SSR) before falling back to localStorage or browser detection — eliminates the locale-flash hydration class. - format.ts legacy helpers also read cookie first, matching SSR. Tests: - new src/lib/__tests__/format-locale.test.ts — 9 cases covering makeFormatters, parseLocaleFromAcceptLanguage, resolveIntlLocale - new passkey-errors.test.ts — 8 cases covering every DOMException name and SimpleWebAuthn code we map - new query-keys.test.ts — bundles + invalidateKeys allSettled semantics (incl. partial-failure test) 118 → 142 tests. All pass. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Marc-André Bombeck Co-authored-by: Claude Opus 4.7 (1M context) --- ...026-04-18-blood-glucose-tracking-design.md | 207 ++++++++++++++ .../2026-04-18-builtin-feedback-design.md | 162 +++++++++++ .../2026-04-18-custom-thresholds-design.md | 227 ++++++++++++++++ ...26-04-18-dashboard-customization-design.md | 117 ++++++++ .../2026-04-18-multi-provider-ai-design.md | 216 +++++++++++++++ messages/de.json | 141 +++++++++- messages/en.json | 141 +++++++++- src/app/admin/page.tsx | 26 +- .../api/admin/monitoring/umami-test/route.ts | 2 +- .../notifications/reminder-check/route.ts | 6 +- src/app/api/bugreport/route.ts | 12 +- src/app/api/bugreport/status/route.ts | 49 ++++ src/app/api/withings/connect/route.ts | 2 +- src/app/auth/login/page.tsx | 6 +- src/app/bugreport/page.tsx | 92 ++++++- src/app/error.tsx | 24 ++ src/app/global-error.tsx | 108 ++++++++ src/app/insights/page.tsx | 28 +- src/app/layout.tsx | 28 +- src/app/medications/page.tsx | 3 +- src/app/page.tsx | 33 ++- src/app/settings/page.tsx | 20 +- src/components/charts/health-chart.tsx | 21 +- src/components/charts/trend-card.tsx | 12 +- src/components/error-details.tsx | 109 ++++++++ .../insights/insight-advisor-card.tsx | 68 ++--- .../insights/insight-status-card.tsx | 44 ++- .../measurements/measurement-form.tsx | 3 +- .../measurements/measurement-list.tsx | 10 +- .../medications/intake-history-list.tsx | 22 +- .../medications/intake-timeline.tsx | 8 +- .../medications/medication-card.tsx | 17 +- .../medications/medication-form.tsx | 25 +- src/components/mood/mood-form.tsx | 3 +- src/components/mood/mood-list.tsx | 10 +- src/lib/__tests__/format-locale.test.ts | 64 +++++ src/lib/__tests__/passkey-errors.test.ts | 63 +++++ src/lib/__tests__/query-keys.test.ts | 88 ++++++ src/lib/ai/openai-client.ts | 21 +- src/lib/doctor-report-pdf.ts | 254 +++++++++++------- src/lib/format-locale.ts | 144 ++++++++++ src/lib/format.ts | 88 +++--- src/lib/i18n/context.tsx | 35 ++- src/lib/jobs/reminder-worker.ts | 8 +- src/lib/passkey-errors.ts | 49 ++++ src/lib/query-keys.ts | 124 +++++++++ 46 files changed, 2570 insertions(+), 370 deletions(-) create mode 100644 docs/superpowers/specs/2026-04-18-blood-glucose-tracking-design.md create mode 100644 docs/superpowers/specs/2026-04-18-builtin-feedback-design.md create mode 100644 docs/superpowers/specs/2026-04-18-custom-thresholds-design.md create mode 100644 docs/superpowers/specs/2026-04-18-dashboard-customization-design.md create mode 100644 docs/superpowers/specs/2026-04-18-multi-provider-ai-design.md create mode 100644 src/app/api/bugreport/status/route.ts create mode 100644 src/app/error.tsx create mode 100644 src/app/global-error.tsx create mode 100644 src/components/error-details.tsx create mode 100644 src/lib/__tests__/format-locale.test.ts create mode 100644 src/lib/__tests__/passkey-errors.test.ts create mode 100644 src/lib/__tests__/query-keys.test.ts create mode 100644 src/lib/format-locale.ts create mode 100644 src/lib/passkey-errors.ts create mode 100644 src/lib/query-keys.ts diff --git a/docs/superpowers/specs/2026-04-18-blood-glucose-tracking-design.md b/docs/superpowers/specs/2026-04-18-blood-glucose-tracking-design.md new file mode 100644 index 000000000..e0c6b99be --- /dev/null +++ b/docs/superpowers/specs/2026-04-18-blood-glucose-tracking-design.md @@ -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 `