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 +
+ )} + +
+ +