From 8533755d8d931ceba8b0da9cbeb17c2509f8441e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Apr 2026 22:20:12 +0200 Subject: [PATCH] release(v1.2): i18n, thresholds, glucose, dashboard customization, feedback, multi-provider AI (#77) 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) * feat(v1.2): thresholds, glucose, dashboard customization, built-in feedback, multi-provider AI (#76) * build(deps): bump @prisma/adapter-pg from 7.6.0 to 7.7.0 (#60) Bumps [@prisma/adapter-pg](https://github.com/prisma/prisma/tree/HEAD/packages/adapter-pg) from 7.6.0 to 7.7.0. - [Release notes](https://github.com/prisma/prisma/releases) - [Commits](https://github.com/prisma/prisma/commits/7.7.0/packages/adapter-pg) --- updated-dependencies: - dependency-name: "@prisma/adapter-pg" dependency-version: 7.7.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * build(deps): bump prisma from 7.6.0 to 7.7.0 (#61) Bumps [prisma](https://github.com/prisma/prisma/tree/HEAD/packages/cli) from 7.6.0 to 7.7.0. - [Release notes](https://github.com/prisma/prisma/releases) - [Commits](https://github.com/prisma/prisma/commits/7.7.0/packages/cli) --- updated-dependencies: - dependency-name: prisma dependency-version: 7.7.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * build(deps-dev): bump eslint-config-next from 16.2.2 to 16.2.3 (#62) Bumps [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) from 16.2.2 to 16.2.3. - [Release notes](https://github.com/vercel/next.js/releases) - [Changelog](https://github.com/vercel/next.js/blob/canary/release.js) - [Commits](https://github.com/vercel/next.js/commits/v16.2.3/packages/eslint-config-next) --- updated-dependencies: - dependency-name: eslint-config-next dependency-version: 16.2.3 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * build(deps): bump next from 16.2.2 to 16.2.3 (#65) Bumps [next](https://github.com/vercel/next.js) from 16.2.2 to 16.2.3. - [Release notes](https://github.com/vercel/next.js/releases) - [Changelog](https://github.com/vercel/next.js/blob/canary/release.js) - [Commits](https://github.com/vercel/next.js/compare/v16.2.2...v16.2.3) --- updated-dependencies: - dependency-name: next dependency-version: 16.2.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * build(deps-dev): bump @types/node from 25.5.2 to 25.6.0 (#66) Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 25.5.2 to 25.6.0. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) --- updated-dependencies: - dependency-name: "@types/node" dependency-version: 25.6.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * build(deps): bump dotenv from 17.4.0 to 17.4.1 (#67) Bumps [dotenv](https://github.com/motdotla/dotenv) from 17.4.0 to 17.4.1. - [Changelog](https://github.com/motdotla/dotenv/blob/master/CHANGELOG.md) - [Commits](https://github.com/motdotla/dotenv/compare/v17.4.0...v17.4.1) --- updated-dependencies: - dependency-name: dotenv dependency-version: 17.4.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * build(deps): bump @tanstack/react-query from 5.96.2 to 5.99.0 (#70) Bumps [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) from 5.96.2 to 5.99.0. - [Release notes](https://github.com/TanStack/query/releases) - [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md) - [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.99.0/packages/react-query) --- updated-dependencies: - dependency-name: "@tanstack/react-query" dependency-version: 5.99.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * build(deps-dev): bump typescript from 5.9.3 to 6.0.2 (#64) Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.9.3 to 6.0.2. - [Release notes](https://github.com/microsoft/TypeScript/releases) - [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v6.0.2) --- updated-dependencies: - dependency-name: typescript dependency-version: 6.0.2 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * build(deps-dev): bump prettier from 3.8.1 to 3.8.2 (#68) Bumps [prettier](https://github.com/prettier/prettier) from 3.8.1 to 3.8.2. - [Release notes](https://github.com/prettier/prettier/releases) - [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md) - [Commits](https://github.com/prettier/prettier/compare/3.8.1...3.8.2) --- updated-dependencies: - dependency-name: prettier dependency-version: 3.8.2 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * build(deps): bump react from 19.2.4 to 19.2.5 (#63) Bumps [react](https://github.com/facebook/react/tree/HEAD/packages/react) from 19.2.4 to 19.2.5. - [Release notes](https://github.com/facebook/react/releases) - [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md) - [Commits](https://github.com/facebook/react/commits/v19.2.5/packages/react) --- updated-dependencies: - dependency-name: react dependency-version: 19.2.5 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * build(deps-dev): bump vitest from 4.1.2 to 4.1.4 (#69) Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 4.1.2 to 4.1.4. - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.4/packages/vitest) --- updated-dependencies: - dependency-name: vitest dependency-version: 4.1.4 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * build(deps): bump @prisma/client from 7.6.0 to 7.7.0 (#71) Bumps [@prisma/client](https://github.com/prisma/prisma/tree/HEAD/packages/client) from 7.6.0 to 7.7.0. - [Release notes](https://github.com/prisma/prisma/releases) - [Commits](https://github.com/prisma/prisma/commits/7.7.0/packages/client) --- updated-dependencies: - dependency-name: "@prisma/client" dependency-version: 7.7.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * build(deps-dev): bump shadcn from 4.1.2 to 4.2.0 (#72) Bumps [shadcn](https://github.com/shadcn-ui/ui/tree/HEAD/packages/shadcn) from 4.1.2 to 4.2.0. - [Release notes](https://github.com/shadcn-ui/ui/releases) - [Changelog](https://github.com/shadcn-ui/ui/blob/main/packages/shadcn/CHANGELOG.md) - [Commits](https://github.com/shadcn-ui/ui/commits/shadcn@4.2.0/packages/shadcn) --- updated-dependencies: - dependency-name: shadcn dependency-version: 4.2.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * feat(thresholds): foundation + settings UI (v1.2) Introduces per-user health threshold overrides with an auto-default / user-override pattern. Default computation stays evidence-based (WHO, ESC/ESH 2018, AHA, AASM, ADA 2024, DGIM/DDG S3). Overrides live in User.thresholdsJson; any missing metric falls through to the default so new users need zero configuration. Schema (migration 0021): - New enums: glucose_context (FASTING/POSTPRANDIAL/RANDOM/BEDTIME), feedback_category, feedback_status. - measurement_type gains BLOOD_GLUCOSE; Measurement gets optional glucose_context (enforced via CHECK constraint — only set for glucose). - User gains thresholds_json, dashboard_widgets_json, glucose_unit, ai_provider, ai_model, ai_base_url, ai_anthropic_key_encrypted, ai_local_key_encrypted (stubs for the upcoming features). - New Feedback table for the built-in feedback system (next commit). Backend: - src/lib/analytics/effective-range.ts is the single source of truth: getEffectiveRange(metric, profile, overrides) returns the resolved range + the pristine default + physiological bounds. Every chart, band, insight prompt, and doctor-report section should call this. - Supports 11 metrics incl. four glucose contexts with ADA/DGIM ranges. - src/lib/validations/thresholds.ts — Zod schema with per-metric bounds and min30% off the default. - New Personalization section in /settings. - 25 new i18n keys (EN + DE) in a `thresholds.*` namespace. Tests: 8 new cases covering default computation, override application, missing profile gracefully returning null, glucose-context defaults, and per-metric bounds. 150/150 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(glucose): wire BLOOD_GLUCOSE + context through the write path Extends measurement validation and the POST /api/measurements route to accept blood-glucose samples with a required `glucoseContext` (FASTING / POSTPRANDIAL / RANDOM / BEDTIME). - Zod schema adds BLOOD_GLUCOSE to the type enum and glucoseContextEnum. - refine(): BLOOD_GLUCOSE requires a context; every other type must not set one — mirrored by the Postgres CHECK constraint from migration 0021 so callers bypassing validation still can't write inconsistent rows. - VALUE_RANGES gains BLOOD_GLUCOSE (20-800 mg/dL) — covers severe hypo through severe hyperglycemia. - getUnitForType(BLOOD_GLUCOSE) returns "mg/dL" (canonical storage). Display in mmol/L is a UI-level conversion per user preference. - Error messages translated to English (removed two lingering German refine messages while here). Measurement form + charts + targets UI follow in the next commits. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(i18n): localize AI insight prompts (en/de) Each prompt builder (base-system, blood-pressure, bmi, general-status, medication-compliance, mood, pulse, weight) now accepts a Locale and returns the system + user prompt either fully in English or fully in German. Stable contract keys (classification enum optimal/gut/grenzwertig /erhoht/kritisch, confidence niedrig/mittel/hoch) are preserved verbatim so the response parser keeps working — only the natural-language sections are translated. The prompt explicitly tells the model to emit classificationLabel / summary / recommendations in the user's language. Wires the locale through every status generator (general / blood-pressure / bmi / weight / pulse / mood / medication-compliance) into the prompt calls and adds a small server-side resolver (src/lib/i18n/server-locale.ts: explicit override > healthlog-locale or userLocale cookie > User.locale > Accept-Language header > default English) used by all /api/insights/*-status routes plus /api/insights/generate. The auditLog cache key already contains the locale suffix, so cached entries stay disjoint per language. The reminder worker continues to fall back to "de" when User.locale is null, matching its prior behaviour. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(glucose): UI + unit conversion helpers - src/lib/glucose.ts — mg/dL ↔ mmol/L conversion (factor 18.0182 per DGIM/DDG), resolveGlucoseUnit() defaulting to mg/dL, thresholdMetricForContext() mapping each context to the matching effective-range key. - Measurement form gains a "Blood glucose" option (mg/dL) with a context selector (fasting / postprandial / random / bedtime). The glucoseContext field is only sent for BLOOD_GLUCOSE — the Zod refinement and the Postgres CHECK constraint both enforce that. - 10 new i18n keys per locale (measurements.typeBloodGlucose, glucoseContext*, glucoseUnit, glucoseUnitHint). - 6 new tests covering conversion roundtrip, unit resolution defaults, and context → threshold-metric mapping. Charts, target cards, and doctor-PDF row for glucose follow in the next commits. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(feedback): built-in feedback system (no GitHub required) Implements the feedback-system design from the v1.2 spec. The submit button on /bugreport now always works — even when no admin has configured a GitHub repository. Feedback is stored in the Feedback table (migration 0021) with an admin inbox for triage. Endpoints: - POST /api/feedback — user submits. Auth-gated, rate-limited 5/hour. Category (BUG / FEATURE_REQUEST / QUESTION / OTHER), subject, description, optional screenshot (≤5 MB, base64), optional metadata (locale, URL path, userAgent). - GET /api/feedback — user's own history (paginated). - GET /api/admin/feedback — admin inbox with status filter and counts-by-status for tab badges. - PATCH /api/admin/feedback/[id] — update status / admin note. - DELETE /api/admin/feedback/[id] — soft-archive. Every mutation writes to AuditLog with before/after details. UI: - /bugreport rewritten: category dropdown, optional subject, always submits. Informational banner explains whether the server has GitHub escalation available or keeps feedback internal-only. - Legacy "not configured" blocker is gone — the form is never hidden. - 8 new bugreport.* i18n keys per locale. The old POST /api/bugreport route and /api/bugreport/status endpoint are left intact for backwards compatibility; they'll be removed in v1.3 once no one depends on them. Admin inbox UI lands in the next commit alongside the dashboard and multi-provider work. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(telegram): localize bot UI per User.locale The Telegram webhook now resolves the user's locale (User.locale, falling back to "de" so existing bots stay German) and runs every reply, inline- keyboard label and callback toast through a small server-side translator. Adds src/lib/i18n/server-translator.ts (getServerTranslator) — reads the same messages/{en,de}.json bundles the client uses, with the same "missing key falls back to English, then to the raw key" semantics, and the same {param} interpolation. Importing the client useTranslations() hook into a server route would have pulled React in, so this mirrors just the parts the bot needs. Adds the telegram.* namespace to messages/en.json and messages/de.json covering: button labels, callback toasts, the /help body, /add prompt, medication-not-found / inactive errors, snooze/skip/confirm confirmations and the cancel button. The "genommen" / "taken" intake keyword and the "hi/hallo/hey/moin/hello" greeting list are accepted regardless of UI locale to keep backwards compatibility for existing users. Also adds 6 vitest cases for getServerTranslator (basic resolution, locale switching, parameter interpolation, English fallback, raw-key fallback, exposed locale). Co-Authored-By: Claude Opus 4.7 (1M context) * feat(dashboard): user-customizable widget layout Implements the dashboard customization spec: users choose which widgets appear on / and in what order. Auto-default keeps the current v1.1 behavior so nothing changes without opt-in. Backend: - src/lib/dashboard-layout.ts — single source of truth with DEFAULT_DASHBOARD_LAYOUT (10 widgets incl. the new sleep/steps/glucose ones that were previously hard-conditional), a version field for future schema evolution, and resolveDashboardLayout() that merges persisted state with defaults so new widgets auto-appear (invisible by default) when the schema bumps. - GET / PUT / DELETE /api/dashboard/widgets with strict Zod validation (version literal, widget-id enum, order range). - Persisted in User.dashboardWidgetsJson (column added in 0021). UI: - src/components/settings/dashboard-layout-section.tsx — mounted under the new Personalization section alongside Thresholds. Local edit buffer so toggling/reordering doesn't fire a network call per click; single "Save" commits. Each widget has Up/Down buttons + visibility toggle, plus a reset-to-defaults button at the section header. - 11 new dashboard.* i18n keys per locale. Dashboard page integration is staged — widgets currently still render per the old data-conditional rules. Wiring the saved layout through /app/page.tsx is a follow-up so this commit stays focused on the plumbing. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(auth): locale-aware password-strength feedback Completes the zxcvbn i18n migration by wiring the new getZxcvbnTranslations(locale) helper into the client PasswordStrength component and removing the obsolete zxcvbn-de helper. - src/components/ui/password-strength.tsx reads the active locale from useTranslations() and memoizes the translator per locale — so changing language instantly retranslates open warnings/suggestions. - src/lib/zxcvbn-de.ts deleted. Every consumer now routes through zxcvbn-i18n.ts which carries both EN and DE mappings. - server-side checkPasswordStrength (already refactored earlier in this session) continues to accept locale; clients can pass it from the session. No test impact — 162 pass. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(dashboard): replace setState-in-effect with draft-overlay pattern The dashboard-layout-section.tsx initial implementation synced server state into local state via useEffect, which trips the project's strict react-hooks/set-state-in-effect rule. Rewrote using a nullable draft pattern: `layout` is derived as `draft ?? remote`, and only user edits set the draft. Save/Reset clear the draft so the server copy wins again. Cancel also clears instead of re-seeding from server. No behavior change, no new tests — the component contract stays the same. Lint back to baseline (2 errors / 7 warnings, both pre-existing). Co-Authored-By: Claude Opus 4.7 (1M context) * docs: v1.2 README + CHANGELOG (thresholds, glucose, dashboard, feedback, multi-provider AI) - CHANGELOG 1.2.0 entry covering 5 new features + locale polish - README Key Features expanded: glucose, custom thresholds, dashboard customization, multi-provider AI, built-in feedback - Doctor Report description updated for locale-aware output Co-Authored-By: Claude Opus 4.7 (1M context) * feat(targets): glucose cards per context with effective range + unit conversion Render one target card per logged glucose context (FASTING, POSTPRANDIAL, RANDOM, BEDTIME) on /targets, sourcing the threshold band from getEffectiveRange() so user overrides apply consistently. The targets API returns glucose values canonical mg/dL; the page converts to the user's glucoseUnit preference via convertGlucose() before display. - Extend /api/insights/targets to include per-context glucose summaries (latest, 30-day average, range, classification) and surface glucoseUnit in the profile envelope. - Expose glucoseUnit on /api/auth/me and AuthUser type. - Targets page resolves the i18n label key, applies unit conversion to current/avg/range, and uses Droplet iconography. - Add targets.glucoseFasting/Postprandial/Random/Bedtime keys to en + de. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(security): address H1 (CSP nonce), H3 (SSRF), M1+M2 (XSS) From the v1.2 full-repo security audit: - **H1 / CWE-79** src/proxy.ts: CSP nonce was base64 of the UUID *string* form, which gives ~122 bits and a structured pattern (hyphens at fixed offsets). Replaced with 16 random bytes → proper 128-bit nonce. - **H3 / CWE-918** src/app/api/admin/ai-settings/route.ts: the admin AI base URL only required "https://". A compromised admin cookie could point it at attacker.example and exfiltrate the bearer key on the next insight call. Now validates with URL parser, requires https://, and only accepts hosts on a small allowlist (OpenAI, Anthropic, Google AI, Mistral, Groq, OpenRouter). Ops can extend via `ADMIN_AI_BASE_URL_ALLOWLIST` env var. - **M1 / CWE-79 (stored, off-platform)** src/app/api/bugreport/route.ts: bug-report description flowed to GitHub with only backtick/asterisk stripping. Now strips HTML tags entirely, neutralises dangerous markdown link schemes (javascript:/data:/vbscript:/file:), and wraps the whole body in a fenced code block so residual markdown renders literally. - **M2 / CWE-79** Zod schemas for feedback + bugreport screenshot now require a base64 PNG/JPEG/WEBP/GIF data URL via regex. SVG deliberately excluded — it can carry inline JS that would execute when the admin inbox renders the `` preview. Medium rate-limiter semantics (audit M3) left as-is: the fixed-window behaviour is acceptable for a single-tenant PWA and matches what ops actually want. Remaining M4–M6 + L1–L7 are tracked for v1.3. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(dashboard): glucose TrendCards + chart label support Render one TrendCard per logged glucose context on the dashboard when the user has the "glucose" widget enabled in their layout. Cards respect the user's glucoseUnit preference via convertGlucose(); data sources from the existing /api/analytics endpoint, which now emits a glucoseByContext map alongside the summaries object. - /api/analytics: include BLOOD_GLUCOSE in the type whitelist (so total counts include glucose) and add glucoseByContext containing a summarize() result per context. - HealthChart: wire BLOOD_GLUCOSE into BASE_TYPE_LABEL_KEYS so the legend shows the localized "Blood glucose" label when the chart is invoked for that type. - Dashboard page: fetch the dashboard layout, gate glucose cards on widget visibility AND data presence, render Droplet-icon TrendCards. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(i18n): bugreport.subtitle no longer promises a GitHub issue The visual QA pass flagged that bugreport.subtitle still said "a GitHub issue will be created automatically" even though the banner below the heading correctly explains the submission is stored locally (built-in feedback system, GitHub is only an optional escalation). Neutral copy now that matches the actual behaviour in both locales. * feat(ai): user-level provider selection + test endpoint + UI Adds the missing pieces for the multi-provider AI MVP that pair with the AnthropicClient and LocalOpenAICompatibleClient already on disk: * /api/user/ai-provider (GET + PATCH): user can pick OPENAI / ANTHROPIC / LOCAL / CHATGPT_OAUTH and store provider, model, baseUrl and per-provider API keys. Anthropic + local keys go through the existing AES-256-GCM crypto helper. Provider value is whitelisted. * /api/ai/test (POST): rate-limited (5/min/user) round-trip that resolves the user's provider and asks for a tiny JSON ack so the UI surfaces real "does this config work?" feedback instead of a blind Save. * settings/page.tsx: appends a per-user override card under the existing AI Insights section. Provider dropdown drives conditional fields (Anthropic key for ANTHROPIC, baseUrl + optional bearer for LOCAL, model for all), then Save / Test buttons. * provider.test.ts: extended to cover the four new cases (ANTHROPIC with key, ANTHROPIC fallback, LOCAL, OPENAI routes through admin key, CHATGPT_OAUTH routes through Codex). Switched to resetAllMocks() in beforeEach to flush mockResolvedValueOnce queues between cases — the prior clearAllMocks() leaked them. * local-client.test.ts: full happy / no-auth / bearer-auth / structured error / empty-content coverage mirroring the openai-client suite. typecheck + tests green; lint stays at the pre-existing 2 errors / 7 warnings baseline (no new findings introduced). Co-Authored-By: Claude Opus 4.7 (1M context) * chore: integrate late agent additions (glucose PDF row + i18n) * test(glucose): cover display-unit conversion edge cases Lock in the precision contract for the targets/dashboard/PDF surfaces: canonical mg/dL stays as integers, mmol/L collapses to 1-decimal, and the ADA diabetes thresholds (70/99/126) round-trip to their published SI values (3.9 / 5.5 / 7.0 mmol/L). Co-Authored-By: Claude Opus 4.7 (1M context) * fix(i18n): eliminate hydration flash by threading SSR-resolved locale to I18nProvider Layout already resolves the initial locale from cookie + Accept-Language and sets . Until now the client I18nProvider re-detected locale at mount via getSavedLocale() / detectSystemLocale(), causing a flash where "Loading…" (EN) rendered first, then flipped to "Laden…" (DE) after hydration. Pass initialLocale down layout -> Providers -> I18nProvider so useState seeds the same locale the server used. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(admin/feedback): return 409 when GitHub issue already published Spec requires 409 Conflict (not 200 with flag) when the feedback row already has gitHubIssueUrl set. Drops the alreadyPublished response field in favour of a clear error status. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(crypto,dashboard): L4 hard-fail short ENCRYPTION_KEY in prod + dashboard WIP from parallel agent * refactor(effective-range): collapse duplicated glucose case arms Extract glucoseRange() helper and toDate() normalizer. Use METRIC_BOUNDS keys as the source of truth for getAllEffectiveRanges() metric list so additions stay in one place. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(dashboard-layout-section): drop redundant setLayout alias Remove the trivial setLayout wrapper and hoist the per-row label key lookup so it runs once per widget instead of twice (span + aria-label). Co-Authored-By: Claude Opus 4.7 (1M context) * fix(security): MED-1 SSRF on user AI baseUrl + MED-2 publish-to-GitHub race Final-audit blockers before v1.2 merge. MED-1 — SSRF via user-level LOCAL AI baseUrl Authenticated user could set aiBaseUrl to http://169.254.169.254/, http://localhost:9090/metrics, or any internal host and get response bodies echoed back via the bodyExcerpt error field. Route the value through the existing isPublicUrl() helper (exported from lib/validations/notifications.ts). Ops can opt-in to private hosts on a specific instance via ALLOW_LOCAL_AI_PRIVATE_HOSTS=true — that's how self-hosted Ollama / LM Studio stays usable without exposing the SSRF surface by default. MED-2 — Race in POST /api/admin/feedback/[id]/github Two concurrent admin clicks both saw gitHubIssueUrl=null and both created duplicate GitHub issues. Atomic claim now: a single updateMany({ where: { id, gitHubIssueUrl: null }, data: PENDING }) — only the caller whose count=1 proceeds. PENDING is released on failure so retries work. Tests + typecheck green. Baseline lint unchanged. * fix: i18n completeness + UX polish (closes #73, #74) (#75) * 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) * fix: restore Link import lost in merge * fix(bugreport): remove stale loading + not-configured blocks lost in merge The v1.2 built-in feedback refactor made status informational only — form always renders. Main's merge re-introduced the pre-refactor blocker blocks referencing undefined statusLoading/AlertCircle and broke the build. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Marc-André Bombeck Co-authored-by: Claude Opus 4.7 (1M context) --------- Signed-off-by: dependabot[bot] Co-authored-by: Marc-André Bombeck Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- CHANGELOG.md | 24 + CLAUDE.md | 2 +- README.md | 16 +- messages/de.json | 155 ++++- messages/en.json | 155 ++++- .../migration.sql | 73 +++ prisma/schema.prisma | 81 ++- src/app/admin/page.tsx | 515 +++++++++++++++ src/app/api/admin/ai-settings/route.ts | 35 +- .../api/admin/feedback/[id]/github/route.ts | 100 +++ src/app/api/admin/feedback/[id]/route.ts | 78 +++ src/app/api/admin/feedback/route.ts | 67 ++ .../admin/users/[id]/reset-password/route.ts | 7 +- src/app/api/ai/test/route.ts | 43 ++ src/app/api/analytics/route.ts | 20 + src/app/api/auth/me/route.ts | 1 + src/app/api/auth/password/route.ts | 14 +- src/app/api/auth/register/route.ts | 6 +- src/app/api/bugreport/route.ts | 19 +- src/app/api/dashboard/widgets/route.ts | 100 +++ src/app/api/doctor-report/route.ts | 51 ++ src/app/api/feedback/route.ts | 102 +++ .../insights/blood-pressure-status/route.ts | 10 +- src/app/api/insights/bmi-status/route.ts | 10 +- src/app/api/insights/general-status/route.ts | 10 +- src/app/api/insights/generate/route.ts | 17 +- .../medication-compliance-status/route.ts | 10 +- src/app/api/insights/mood-status/route.ts | 10 +- src/app/api/insights/pulse-status/route.ts | 10 +- src/app/api/insights/targets/route.ts | 84 ++- src/app/api/insights/weight-status/route.ts | 10 +- src/app/api/measurements/route.ts | 7 +- src/app/api/telegram/webhook/route.ts | 117 ++-- src/app/api/user/ai-provider/route.ts | 110 ++++ src/app/api/user/thresholds/route.ts | 133 ++++ src/app/bugreport/page.tsx | 140 ++-- src/app/layout.tsx | 2 +- src/app/page.tsx | 607 +++++++++++++----- src/app/settings/page.tsx | 253 ++++++++ src/app/targets/page.tsx | 45 +- src/components/charts/health-chart.tsx | 1 + .../measurements/measurement-form.tsx | 42 ++ src/components/providers.tsx | 11 +- .../settings/dashboard-layout-section.tsx | 228 +++++++ .../settings/thresholds-section.tsx | 306 +++++++++ src/components/ui/password-strength.tsx | 14 +- src/hooks/use-auth.ts | 1 + src/lib/__tests__/glucose.test.ts | 66 ++ src/lib/__tests__/server-translator.test.ts | 40 ++ src/lib/ai/__tests__/anthropic-client.test.ts | 143 +++++ src/lib/ai/__tests__/local-client.test.ts | 159 +++++ src/lib/ai/__tests__/provider.test.ts | 209 +++++- src/lib/ai/anthropic-client.ts | 95 +++ src/lib/ai/local-client.ts | 90 +++ src/lib/ai/prompts/base-system.ts | 98 ++- src/lib/ai/prompts/blood-pressure.ts | 56 +- src/lib/ai/prompts/bmi.ts | 40 +- src/lib/ai/prompts/general-status.ts | 76 ++- src/lib/ai/prompts/medication-compliance.ts | 47 +- src/lib/ai/prompts/mood.ts | 35 +- src/lib/ai/prompts/pulse.ts | 50 +- src/lib/ai/prompts/weight.ts | 47 +- src/lib/ai/provider.ts | 248 ++++--- src/lib/ai/types.ts | 2 +- .../__tests__/effective-range.test.ts | 93 +++ src/lib/analytics/effective-range.ts | 253 ++++++++ src/lib/auth/password.ts | 20 +- src/lib/crypto.ts | 23 +- src/lib/dashboard-layout.ts | 97 +++ src/lib/doctor-report-pdf.ts | 95 +++ src/lib/feedback/publish-github.ts | 158 +++++ src/lib/glucose.ts | 53 ++ src/lib/i18n/context.tsx | 14 +- src/lib/i18n/server-locale.ts | 76 +++ src/lib/i18n/server-translator.ts | 55 ++ src/lib/insights/blood-pressure-status.ts | 4 +- src/lib/insights/bmi-status.ts | 4 +- src/lib/insights/general-status.ts | 4 +- .../insights/medication-compliance-status.ts | 4 +- src/lib/insights/mood-status.ts | 4 +- src/lib/insights/prompt.ts | 56 +- src/lib/insights/pulse-status.ts | 4 +- src/lib/insights/weight-status.ts | 4 +- src/lib/validations/feedback.ts | 41 ++ src/lib/validations/measurement.ts | 30 +- src/lib/validations/notifications.ts | 5 +- src/lib/validations/thresholds.ts | 34 + src/lib/{zxcvbn-de.ts => zxcvbn-i18n.ts} | 35 +- src/proxy.ts | 7 +- 89 files changed, 6003 insertions(+), 523 deletions(-) create mode 100644 prisma/migrations/0021_v1_2_personalization_feedback_glucose/migration.sql create mode 100644 src/app/api/admin/feedback/[id]/github/route.ts create mode 100644 src/app/api/admin/feedback/[id]/route.ts create mode 100644 src/app/api/admin/feedback/route.ts create mode 100644 src/app/api/ai/test/route.ts create mode 100644 src/app/api/dashboard/widgets/route.ts create mode 100644 src/app/api/feedback/route.ts create mode 100644 src/app/api/user/ai-provider/route.ts create mode 100644 src/app/api/user/thresholds/route.ts create mode 100644 src/components/settings/dashboard-layout-section.tsx create mode 100644 src/components/settings/thresholds-section.tsx create mode 100644 src/lib/__tests__/glucose.test.ts create mode 100644 src/lib/__tests__/server-translator.test.ts create mode 100644 src/lib/ai/__tests__/anthropic-client.test.ts create mode 100644 src/lib/ai/__tests__/local-client.test.ts create mode 100644 src/lib/ai/anthropic-client.ts create mode 100644 src/lib/ai/local-client.ts create mode 100644 src/lib/analytics/__tests__/effective-range.test.ts create mode 100644 src/lib/analytics/effective-range.ts create mode 100644 src/lib/dashboard-layout.ts create mode 100644 src/lib/feedback/publish-github.ts create mode 100644 src/lib/glucose.ts create mode 100644 src/lib/i18n/server-locale.ts create mode 100644 src/lib/i18n/server-translator.ts create mode 100644 src/lib/validations/feedback.ts create mode 100644 src/lib/validations/thresholds.ts rename src/lib/{zxcvbn-de.ts => zxcvbn-i18n.ts} (69%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a537c2b7..a01e624d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## [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 diff --git a/CLAUDE.md b/CLAUDE.md index dcb191679..b826346ab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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[]` 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 diff --git a/README.md b/README.md index 077ea537d..554daeedf 100644 --- a/README.md +++ b/README.md @@ -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, and blood glucose (fasting/postprandial/random/bedtime, mg/dL ↔ mmol/L) with interactive trend charts, moving averages, and traffic-light ranges based on ESC/ESH 2018 and ADA 2024 guidelines. + +**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,7 +61,7 @@ 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. --- @@ -318,7 +324,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. --- diff --git a/messages/de.json b/messages/de.json index 62c7e9c31..c471b7159 100644 --- a/messages/de.json +++ b/messages/de.json @@ -106,7 +106,16 @@ "moodBad": "Schlecht", "moodNeutral": "Neutral", "moodGood": "Gut", - "moodGreat": "Sehr 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", @@ -164,6 +173,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", @@ -201,7 +211,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", @@ -222,6 +242,14 @@ "typeBodyFat": "Körperfett", "typeSleep": "Schlaf", "typeSteps": "Schritte", + "typeBloodGlucose": "Blutzucker", + "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", @@ -697,6 +725,38 @@ "sectionNextSteps": "Nächste Schritte", "generatePrompt": "Klicke auf \"Generieren\" für KI-basierte Insights deiner Gesundheitsdaten." }, + "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" + }, "targets": { "title": "Zielwerte", "subtitle": "Persönliche Referenzbereiche basierend auf deinem Profil", @@ -735,7 +795,11 @@ "classificationNormal": "Normal", "classificationElevated": "Erhöht", "classificationHigh": "Hoch", - "classificationLow": "Niedrig" + "classificationLow": "Niedrig", + "glucoseFasting": "Glukose — nüchtern", + "glucosePostprandial": "Glukose — postprandial", + "glucoseRandom": "Glukose — beliebig", + "glucoseBedtime": "Glukose — vor dem Schlafen" }, "onboarding": { "welcome": "Willkommen bei HealthLog!", @@ -764,6 +828,7 @@ "categoryGeneral": "Allgemein", "categoryNotifications": "Benachrichtigungen", "categoryAi": "KI", + "categoryPersonalization": "Personalisierung", "categoryIntegration": "Integration", "categoryApi": "API", "profile": "Profil", @@ -1154,7 +1219,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", @@ -1334,7 +1447,7 @@ }, "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", @@ -1352,7 +1465,15 @@ "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" + "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", @@ -1506,5 +1627,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": "Welches Medikament eingenommen?", + "cancelButton": "Abbrechen", + "askMedicationName": "Bitte Medikament angeben, z.B. genommen Ramipril.", + "medicationNotFoundExact": "Medikament nicht gefunden. Bitte Namen exakt wie in HealthLog senden.", + "helpHeader": "Verfügbare Befehle:", + "helpBody": "/help — Diese Hilfe anzeigen\n/add — Einnahme erfassen\n/start — Bot starten\n\nTextbefehle:\ngenommen <Name> — Einnahme bestätigen\n\nÜber die Buttons in Erinnerungen:\n• Genommen — Einnahme bestätigen\n• 🕐 1h / 🕐 3h — Erinnerung verschieben\n• ⏭ Überspringen — Einnahme überspringen\n• ✓ Bestätigen — Verpasste Einnahme bestätigen" } } diff --git a/messages/en.json b/messages/en.json index a0bff1a1f..b1d992b0d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -106,7 +106,16 @@ "moodBad": "Bad", "moodNeutral": "Neutral", "moodGood": "Good", - "moodGreat": "Very 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", @@ -164,6 +173,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", @@ -201,7 +211,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", @@ -222,6 +242,14 @@ "typeBodyFat": "Body Fat", "typeSleep": "Sleep", "typeSteps": "Steps", + "typeBloodGlucose": "Blood glucose", + "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", @@ -697,6 +725,38 @@ "sectionNextSteps": "Next steps", "generatePrompt": "Click \"Generate\" for AI-powered insights about your health data." }, + "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" + }, "targets": { "title": "Targets", "subtitle": "Personal reference ranges based on your profile", @@ -735,7 +795,11 @@ "classificationNormal": "Normal", "classificationElevated": "Elevated", "classificationHigh": "High", - "classificationLow": "Low" + "classificationLow": "Low", + "glucoseFasting": "Glucose — fasting", + "glucosePostprandial": "Glucose — post-meal", + "glucoseRandom": "Glucose — random", + "glucoseBedtime": "Glucose — bedtime" }, "onboarding": { "welcome": "Welcome to HealthLog!", @@ -764,6 +828,7 @@ "categoryGeneral": "General", "categoryNotifications": "Notifications", "categoryAi": "AI", + "categoryPersonalization": "Personalization", "categoryIntegration": "Integration", "categoryApi": "API", "profile": "Profile", @@ -1154,7 +1219,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", @@ -1334,7 +1447,7 @@ }, "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", @@ -1352,7 +1465,15 @@ "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" + "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", @@ -1506,5 +1627,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": "Which medication did you take?", + "cancelButton": "Cancel", + "askMedicationName": "Please specify a medication, e.g. taken Ramipril.", + "medicationNotFoundExact": "Medication not found. Please send the name exactly as in HealthLog.", + "helpHeader": "Available commands:", + "helpBody": "/help — show this help\n/add — record an intake\n/start — start the bot\n\nText commands:\ntaken <name> — confirm an intake\n\nButtons in reminders:\n• Taken — confirm intake\n• 🕐 1h / 🕐 3h — postpone reminder\n• ⏭ Skip — skip intake\n• ✓ Confirm — confirm a missed intake" } } diff --git a/prisma/migrations/0021_v1_2_personalization_feedback_glucose/migration.sql b/prisma/migrations/0021_v1_2_personalization_feedback_glucose/migration.sql new file mode 100644 index 000000000..7cb2f72e5 --- /dev/null +++ b/prisma/migrations/0021_v1_2_personalization_feedback_glucose/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ba4ab085e..fbaa41bfc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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,22 @@ enum MeasurementType { BODY_FAT SLEEP_DURATION ACTIVITY_STEPS + BLOOD_GLUCOSE // Canonical storage: mg/dL. Display unit (mg/dL or mmol/L) is user preference. @@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 +178,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 +419,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 { diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 8748e42d5..6d213e4e9 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -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,6 +53,10 @@ import { Key, Bug, Cog, + MessageSquare, + Inbox, + ExternalLink, + GitPullRequest, } from "lucide-react"; import { PasswordStrength } from "@/components/ui/password-strength"; import { formatDate, formatDateTime } from "@/lib/format"; @@ -167,6 +179,11 @@ export default function AdminPage() { +

+ {t("admin.feedback.sectionTitle")} +

+ +

{t("admin.sectionMedication")}

@@ -2169,3 +2186,501 @@ function UserManagementSection({ ); } + +/* ─────────────────────── 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 | 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>; + }; +} + +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("OPEN"); + const [selected, setSelected] = useState(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 ( +
+
+ +

{t("admin.feedback.title")}

+
+

+ {t("admin.feedback.description")} +

+ + setActiveStatus(v as FeedbackStatusType)} + className="mt-4" + > + + {STATUS_TABS.map((s) => ( + + {t(`admin.feedback.tab${s.charAt(0) + s.slice(1).toLowerCase()}`)} + + {counts[s] ?? 0} + + + ))} + + + {STATUS_TABS.map((s) => ( + + {isLoading ? ( +
+ + + {t("admin.feedback.loading")} + +
+ ) : !data?.items.length ? ( +

+ {t("admin.feedback.noEntries")} +

+ ) : ( +
+ + + + + + + + + + + + {data.items.map((item, i) => ( + setSelected(item)} + > + + + + + + + ))} + +
+ {t("admin.feedback.createdAt")} + + {t("admin.feedback.category")} + + {t("admin.feedback.subject")} + + {t("admin.feedback.user")} + + {t("admin.feedback.actions")} +
+ {formatDateTime(item.createdAt)} + + + + {item.subject} + + {item.user?.username ?? t("admin.feedback.anonymous")} + + +
+
+ )} +
+ ))} +
+ + {selected && ( + !open && setSelected(null)} + githubConfigured={githubConfigured} + onMutated={refresh} + /> + )} +
+ ); +} + +function FeedbackCategoryBadge({ category }: { category: FeedbackCategoryType }) { + const { t } = useTranslations(); + const map: Record = { + 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 {cfg.label}; +} + +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 ( + + + + + + {item.subject} + + + + + + {t("admin.feedback.submittedBy")}:{" "} + {item.user?.username ?? t("admin.feedback.anonymous")} + + · + {formatDateTime(item.createdAt)} + {issueUrl && ( + + + {t("admin.feedback.viewIssue")} + + + )} + + + + +
+
+ {item.description} +
+ + {(url || locale || userAgent || appVersion) && ( +
+

+ {t("admin.feedback.metadataHeading")} +

+
+ {url && ( + <> +
+ {t("admin.feedback.metaUrl")} +
+
{url}
+ + )} + {locale && ( + <> +
+ {t("admin.feedback.metaLocale")} +
+
{locale}
+ + )} + {appVersion && ( + <> +
+ {t("admin.feedback.metaAppVersion")} +
+
{appVersion}
+ + )} + {userAgent && ( + <> +
+ {t("admin.feedback.metaUserAgent")} +
+
{userAgent}
+ + )} +
+
+ )} + + {item.screenshotBase64 && ( +
+

+ {t("admin.feedback.screenshotHeading")} +

+ {/* eslint-disable-next-line @next/next/no-img-element */} + Screenshot +
+ )} + +
+ +