From c5ac74d91e19059fa46039854a156109fce5f5af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 1 Jul 2026 23:44:54 +0200 Subject: [PATCH 01/16] i18n: unify action verbs, user noun, adherence term, loading voice, and ellipsis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sweep the DE bundle onto one word per concept: Nutzer (was Benutzer), Hinzufügen for add actions (was Anlegen), Therapietreue (was Adhärenz/ Compliance), Medikamente in the medications subtitle. Standardise the loading voice to the passive form and normalise every ellipsis to a single Unicode glyph with no leading space. EN follows where its own wording drifted; token placeholders keep their literal continuation. --- messages/de.json | 116 +++++++++++++++++++++++------------------------ messages/en.json | 36 +++++++-------- 2 files changed, 76 insertions(+), 76 deletions(-) diff --git a/messages/de.json b/messages/de.json index 38556d3ae..444284b28 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8,11 +8,11 @@ "moreOptions": "Weitere Optionen", "create": "Erstellen", "close": "Schließen", - "loading": "Laden...", + "loading": "Laden…", "loadFailed": "Konnte nicht geladen werden", "error": "Fehler", "saved": "Gespeichert", - "pullToRefresh": "Wird aktualisiert …", + "pullToRefresh": "Wird aktualisiert…", "no": "Nein", "noData": "Keine Daten vorhanden", "optional": "Optional", @@ -220,7 +220,7 @@ "themeDark": "Dunkel", "themeLight": "Hell", "collapseSidebar": "Einklappen", - "userMenu": "Benutzermenü", + "userMenu": "Nutzermenü", "accountSettings": "Kontoeinstellungen", "mood": "Stimmung", "coach": "Coach", @@ -252,11 +252,11 @@ "auth": { "login": "Anmelden", "register": "Registrieren", - "username": "Benutzername", + "username": "Nutzername", "password": "Passwort", "email": "E-Mail-Adresse", "emailPlaceholder": "name@beispiel.de", - "emailOrUsername": "E-Mail-Adresse oder Benutzername", + "emailOrUsername": "E-Mail-Adresse oder Nutzername", "emailOrUsernamePlaceholder": "name@beispiel.de oder benutzername", "loginWithPasskey": "Mit Passkey anmelden", "loginWithPassword": "Mit Passwort anmelden", @@ -306,7 +306,7 @@ "hero": { "scoreLabel": "Health Score", "scoreProvisional": "Noch nicht genug Daten", - "scoreComputing": "Score wird berechnet …", + "scoreComputing": "Score wird berechnet…", "doses": { "summary": "{taken} von {scheduled} Dosen genommen", "nextAt": "Nächste um {time}", @@ -378,7 +378,7 @@ "saveError": "Einnahme konnte nicht gespeichert werden.", "emptyTitle": "Noch keine Medikamente angelegt", "emptyDescription": "Lege zuerst ein Medikament mit Zeitfenster an, damit du die Einnahme protokollieren kannst.", - "emptyCta": "Medikament anlegen" + "emptyCta": "Medikament hinzufügen" }, "quickEntryDiscard": { "title": "Eintrag verwerfen?", @@ -772,10 +772,10 @@ }, "medications": { "title": "Medikamente", - "subtitle": "Medikation, Zeitpläne & Einnahmen", + "subtitle": "Medikamente, Zeitpläne & Einnahmen", "loginRequired": "Bitte anmelden, um Medikamente zu verwalten.", "addMedication": "Hinzufügen", - "firstMedication": "Erstes Medikament anlegen", + "firstMedication": "Erstes Medikament hinzufügen", "addChoice": { "logIntake": "Einnahme erfassen", "newMedication": "Neues Medikament" @@ -817,7 +817,7 @@ "importIntakes": "Einnahmen importieren", "importDescription": "JSON-Array mit Einnahme-Daten einfügen. Erwartetes Format:", "importUploadFile": "JSON-Datei hochladen", - "importPaste": "JSON hier einfügen...", + "importPaste": "JSON hier einfügen…", "importSelected": "Ausgewählt: {name}", "importFileLoaded": "Datei \"{name}\" geladen", "importInvalidJson": "Datei enthält kein gültiges JSON", @@ -949,7 +949,7 @@ "description": "Beschreibe das Medikament in eigenen Worten — das Formular wird automatisch vorausgefüllt.", "placeholder": "z. B. Mounjaro 5 mg wöchentlich am Mittwochmorgen ab nächstem Montag", "submit": "Extrahieren", - "submitBusy": "Wird verarbeitet …", + "submitBusy": "Wird verarbeitet…", "cancel": "Abbrechen", "examples": { "label": "Eines dieser Beispiele übernehmen", @@ -1412,7 +1412,7 @@ } }, "verlauf": { - "loading": "Verlauf wird geladen …", + "loading": "Verlauf wird geladen…", "loadError": "Verlauf konnte nicht geladen werden.", "empty": "Noch keine Einnahmen in diesem Zeitraum.", "summaryEmpty": "Noch keine auswertbaren Dosen.", @@ -1682,7 +1682,7 @@ "zeitplan": { "title": "Zeitplan", "timesTitle": "Einnahmezeiten", - "timesNote": "Passe die Uhrzeiten und das Zeitfenster jeder Dosis an. Die Häufigkeit (täglich, wöchentlich …) änderst du im Editor unter Erweitert.", + "timesNote": "Passe die Uhrzeiten und das Zeitfenster jeder Dosis an. Die Häufigkeit (täglich, wöchentlich…) änderst du im Editor unter Erweitert.", "scheduleLabel": "Zeitplan {index}", "fullEditNote": "Häufigkeit, Wirkstoffklasse und Kursfenster bearbeitest du im Editor unter Erweitert → Lebenszyklus.", "graceEcho": "Erinnerungsfenster: {minutes} Minuten", @@ -2152,7 +2152,7 @@ "send": "Senden", "stop": "Stopp", "thinking": "Denke nach…", - "composerPlaceholder": "Frag mich etwas …", + "composerPlaceholder": "Frag mich etwas…", "nudgeBubbleLabel": "Neue Nachricht vom Coach — Chat öffnen", "fabLabel": "Coach öffnen", "readAloud": "Vorlesen", @@ -2393,7 +2393,7 @@ "hrv": "Was sagt mir meine Herzfrequenzvariabilität zuletzt über Erholung und Stress?", "sleep": "Geh mit mir meinen letzten Schlaf durch — Dauer, Regelmäßigkeit und was sich zu ändern lohnt.", "mood": "Welche Muster siehst du in meiner Stimmung über die letzten Wochen?", - "medications": "Wie sieht meine Medikamentenadhärenz aus, und passt sie dazu, wie ich mich gefühlt habe?", + "medications": "Wie sieht meine Therapietreue aus, und passt sie dazu, wie ich mich gefühlt habe?", "steps": "Wie aktiv war ich zuletzt gemessen an meinen Schritten, und wie ist das im Vergleich zu einem gesunden Richtwert?", "activeEnergy": "Was sagt mein Aktivenergie-Verlauf über mein Aktivitätsniveau zuletzt?", "cardioFitness": "Was bedeutet mein Cardio-Fitness-Verlauf (VO₂max), und wie bewege ich ihn in die richtige Richtung?", @@ -2406,7 +2406,7 @@ "healthScore": "Warum liegt mein Gesundheits-Score bei {score} von 100, und was würde ihn am stärksten bewegen?", "periodWeek": "Geh mit mir durch, wie meine letzte Woche über meine Gesundheitswerte hinweg lief — was sticht heraus?", "periodMonth": "Geh mit mir durch, wie mein letzter Monat über meine Gesundheitswerte hinweg lief — was sticht heraus?", - "correlationBpCompliance": "Wie hängt meine Medikamentenadhärenz mit meinem Blutdruck zusammen? Geh mit mir durch, was dieser Zusammenhang für mich bedeutet.", + "correlationBpCompliance": "Wie hängt meine Therapietreue mit meinem Blutdruck zusammen? Geh mit mir durch, was dieser Zusammenhang für mich bedeutet.", "correlationMoodPulse": "Gibt es einen echten Zusammenhang zwischen meiner Stimmung und meinem Puls? Hilf mir, diesen Zusammenhang zu lesen.", "correlationWeightWeekday": "Folgt mein Gewicht einem Wochenmuster? Was sagt mir dieser Wochentags-Zusammenhang?", "recommendation": "Erzähl mir mehr zu dieser Empfehlung: \"{text}\" — warum trifft sie auf mich zu, und was sollte ich tun?" @@ -2534,7 +2534,7 @@ "stimmungEmptyAction": "Stimmungs-Tagebuch öffnen", "medikamenteDescription": "Einnahme-Kalender pro Medikament plus eine textbasierte Einschätzung.", "medikamenteEmptyTitle": "Erstes Medikament hinzufügen", - "medikamenteEmptyDescription": "Adhärenz-Insights brauchen mindestens ein erfasstes Medikament.", + "medikamenteEmptyDescription": "Therapietreue-Insights brauchen mindestens ein erfasstes Medikament.", "medikamenteEmptyAction": "Medikamente öffnen", "bmiDescription": "BMI aus deinem Gewicht und deiner Größe abgeleitet, mit Einschätzung.", "target": { @@ -3820,7 +3820,7 @@ }, "medications": { "title": "Medikamente & Einnahmen", - "body": "Medikamente verwaltet Zeitpläne, Einnahmen, Bestand und Titration. Versteckt: wiederkehrende Pläne (RRULE/rollierend) und Bestandswarnungen. Deine Einnahmequote speist die Compliance-Säule im Dashboard und den Kontext der KI." + "body": "Medikamente verwaltet Zeitpläne, Einnahmen, Bestand und Titration. Versteckt: wiederkehrende Pläne (RRULE/rollierend) und Bestandswarnungen. Deine Einnahmequote speist die Therapietreue-Säule im Dashboard und den Kontext der KI." }, "labs": { "title": "Laborwerte", @@ -3832,7 +3832,7 @@ }, "vorsorge": { "title": "Vorsorge", - "body": "Vorsorge erinnert an Untersuchungen und Impfungen mit eigenem Intervall. Tipp: „Erledigt“ kann direkt eine Messung oder ein Laborergebnis anlegen. Fällige Vorsorgen erscheinen als Reminder und im Arztbericht." + "body": "Vorsorge erinnert an Untersuchungen und Impfungen mit eigenem Intervall. Tipp: „Erledigt“ kann direkt eine Messung oder ein Laborergebnis hinzufügen. Fällige Vorsorgen erscheinen als Reminder und im Arztbericht." }, "cycle": { "title": "Zyklus", @@ -3994,7 +3994,7 @@ "profileTitle": "Profil einrichten", "profileDescription": "Größe, Geburtsdatum und Geschlecht schalten personalisierte Zielwerte frei.", "profileCta": "Profil öffnen", - "measurementTitle": "Erste Messung anlegen", + "measurementTitle": "Erste Messung hinzufügen", "measurementDescription": "Ein Messpunkt reicht, um die Kachel zu aktivieren — der Trend zeichnet sich ab dem 5. Eintrag.", "measurementCta": "Messung hinzufügen", "medicationTitle": "Medikament hinzufügen", @@ -4421,7 +4421,7 @@ "saved": "Datumsformat gespeichert.", "saveError": "Speichern des Datumsformats fehlgeschlagen." }, - "username": "Benutzername", + "username": "Nutzername", "height": "Körpergröße (cm)", "dateOfBirth": "Geburtsdatum", "dateOfBirthHint": "Wird für die automatische Berechnung der Blutdruck-Zielwerte benötigt.", @@ -4673,7 +4673,7 @@ "aboutMe": { "title": "Über mich", "description": "Persönlicher Kontext, den Coach und Tagesbriefing zusätzlich zu deinen Daten lesen.", - "placeholder": "Z. B. Schichtarbeit, vegetarische Ernährung, Knieverletzung, Training für einen Halbmarathon …", + "placeholder": "Z. B. Schichtarbeit, vegetarische Ernährung, Knieverletzung, Training für einen Halbmarathon…", "charCount": "{used} / {max} Zeichen", "clear": "Leeren", "save": "Speichern", @@ -4685,11 +4685,11 @@ "profileHint": "Alter und Geschlecht stammen aus deinem Profil und werden automatisch mitgegeben.", "freeTextLabel": "Über mich (Freitext)", "conditionsLabel": "Chronische Erkrankungen", - "conditionsPlaceholder": "Z. B. Bluthochdruck, Typ-2-Diabetes …", + "conditionsPlaceholder": "Z. B. Bluthochdruck, Typ-2-Diabetes…", "allergiesLabel": "Allergien & Unverträglichkeiten", - "allergiesPlaceholder": "Z. B. Laktoseintoleranz, Penicillin-Allergie …", + "allergiesPlaceholder": "Z. B. Laktoseintoleranz, Penicillin-Allergie…", "focusLabel": "Worauf soll der Coach achten?", - "focusPlaceholder": "Z. B. behalte meinen Blutdruck im Blick, motiviere mich zu mehr Bewegung …", + "focusPlaceholder": "Z. B. behalte meinen Blutdruck im Blick, motiviere mich zu mehr Bewegung…", "completeness": "{answered} von {total} beantwortet", "questionsTitle": "Der Coach hat Rückfragen", "questionsOpenCoach": "Im Coach beantworten", @@ -4892,9 +4892,9 @@ "expand": "Ausklappen", "dangerZone": "Alle Daten löschen", "dangerZoneTitle": "Gefahrenzone", - "dangerZoneDescription": "Löscht alle deine Gesundheitsdaten und Integrationen. Dein Benutzerkonto bleibt erhalten.", + "dangerZoneDescription": "Löscht alle deine Gesundheitsdaten und Integrationen. Dein Nutzerkonto bleibt erhalten.", "dangerZoneConfirm": "Alle Daten löschen?", - "dangerZoneConfirmDescription": "Dieser Vorgang löscht alle deine Gesundheitsdaten und Integrationen unwiderruflich. Dein Benutzerkonto bleibt erhalten.", + "dangerZoneConfirmDescription": "Dieser Vorgang löscht alle deine Gesundheitsdaten und Integrationen unwiderruflich. Dein Nutzerkonto bleibt erhalten.", "dangerZoneSuccess": "Alle persönlichen Daten wurden gelöscht", "dangerZoneDeleteFailed": "Löschen fehlgeschlagen", "finalDelete": "Endgültig löschen", @@ -5343,7 +5343,7 @@ }, "admin": { "title": "Administration", - "subtitle": "Systemstatus, Benutzer und Einstellungen", + "subtitle": "Systemstatus, Nutzer und Einstellungen", "settingsSaveError": "Einstellungen konnten nicht gespeichert werden", "systemStatus": "Systemstatus", "systemStatusDescription": "Laufzeitdaten der Instanz: Datenbank, Worker und Bestandszähler.", @@ -5351,11 +5351,11 @@ "databaseConnected": "Verbunden", "databaseError": "Fehler", "startedAt": "Gestartet", - "users": "Benutzer", + "users": "Nutzer", "measurementsCount": "Messwerte", "activeTokens": "Aktive API-Tokens", "activeSessions": "Aktive Sessions", - "loadingStatus": "Lade Status...", + "loadingStatus": "Status wird geladen…", "systemStatusLoadError": "System-Status konnte nicht geladen werden.", "systemStatusRetry": "Erneut versuchen", "adminSettingsLoadError": "Admin-Einstellungen konnten nicht geladen werden. Schalter unten zeigen evtl. nicht den Live-Zustand — Seite neu laden.", @@ -5387,7 +5387,7 @@ }, "aiQuality": { "title": "Auswertungs-Qualität", - "loading": "Feedback-Zusammenfassung wird geladen...", + "loading": "Feedback-Zusammenfassung wird geladen…", "loadError": "Qualitätszusammenfassung konnte nicht geladen werden. Bitte die Seite neu laden.", "empty": "Noch kein Feedback. Der Aggregator läuft täglich um 04:00 — sobald Nutzer Empfehlungen bewerten, erscheinen hier die Hilfreich-Quoten.", "windowLabel": "Letzte {days} Tage", @@ -5402,7 +5402,7 @@ }, "coachFeedback": { "title": "Coach-Feedback", - "loading": "Coach-Feedback-Zusammenfassung wird geladen...", + "loading": "Coach-Feedback-Zusammenfassung wird geladen…", "loadError": "Coach-Feedback-Zusammenfassung konnte nicht geladen werden. Bitte die Seite neu laden.", "empty": "Noch kein Coach-Feedback. Der Aggregator läuft täglich um 04:00 — sobald Nutzer Coach-Antworten bewerten, erscheinen hier die Hilfreich-Quoten.", "windowLabel": "Letzte {days} Tage", @@ -5422,14 +5422,14 @@ "appSettings": "Anwendungseinstellungen", "appSettingsDescription": "Globale Standardwerte für Registrierung, Sprache und Zeitzone neuer Konten.", "registrationEnabled": "Registrierung erlauben", - "registrationEnabledDescription": "Wenn deaktiviert, können sich keine neuen Benutzer registrieren.", + "registrationEnabledDescription": "Wenn deaktiviert, können sich keine neuen Nutzer registrieren.", "defaultLanguage": "Standard-Sprache", - "defaultLanguageDescription": "Wird für neue Benutzer und als Fallback verwendet.", + "defaultLanguageDescription": "Wird für neue Nutzer und als Fallback verwendet.", "defaultUserTimezone": "Standard-Zeitzone für neue Konten", - "defaultUserTimezoneDescription": "Wird für neue Konten ohne erkannte Browser-Zeitzone verwendet. Bestehende Benutzer sind nicht betroffen.", + "defaultUserTimezoneDescription": "Wird für neue Konten ohne erkannte Browser-Zeitzone verwendet. Bestehende Nutzer sind nicht betroffen.", "defaultUserTimezoneFallback": "Europe/Berlin (Standard)", "servicesGlobal": "Dienste (global)", - "servicesGlobalDescription": "Deaktivierte Dienste stehen keinem Benutzer zur Verfügung.", + "servicesGlobalDescription": "Deaktivierte Dienste stehen keinem Nutzer zur Verfügung.", "telegramGlobal": "Telegram-Benachrichtigungen erlauben.", "ntfyGlobal": "ntfy-Benachrichtigungen erlauben.", "webPushGlobal": "Browser-Push-Benachrichtigungen erlauben.", @@ -5553,7 +5553,7 @@ "revokedToast": "Einladung widerrufen.", "revokeError": "Einladung konnte nicht widerrufen werden." }, - "userManagement": "Benutzerverwaltung", + "userManagement": "Nutzerverwaltung", "userManagementDescription": "Konten verwalten: Rollen, Passwort-Resets und erzwungene Abmeldungen.", "userName": "Name", "userEmail": "E-Mail", @@ -5561,11 +5561,11 @@ "userPasskeys": "Passkeys", "userCreated": "Erstellt", "userActions": "Aktionen", - "editUser": "Benutzer bearbeiten", - "editUserTitle": "Benutzer bearbeiten: {name}", + "editUser": "Nutzer bearbeiten", + "editUserTitle": "Nutzer bearbeiten: {name}", "demoteToUser": "Zu User degradieren", "promoteToAdmin": "Zum Admin befördern", - "toUser": "Zum Benutzer", + "toUser": "Zum Nutzer", "toAdmin": "Zum Admin", "ownRoleUnchangeable": "Eigene Rolle nicht änderbar", "resetPassword": "Passwort zurücksetzen", @@ -5574,10 +5574,10 @@ "newPasswordPlaceholder": "Mindestens 12 Zeichen", "passwordReset": "Passwort wurde zurückgesetzt", "reset": "Zurücksetzen", - "loadingUsers": "Lade Benutzer...", + "loadingUsers": "Nutzer werden geladen…", "apiTokens": "API-Tokens", "apiTokensDescription": "Alle aktiven und widerrufenen API-Tokens über alle Konten hinweg.", - "tokenUser": "Benutzer", + "tokenUser": "Nutzer", "tokenName": "Name", "tokenPermissions": "Berechtigungen", "tokenPermissionAll": "Alle", @@ -5613,9 +5613,9 @@ "outcomeFailed": "Fehlgeschlagen", "auditDetails": "Details", "deleteAllData": "Alle Daten global löschen", - "deleteAllDescription": "Löscht alle Mess-, Medikamenten-, Integrations- und Protokoll-Daten aller Benutzer. Benutzerkonten bleiben bestehen.", + "deleteAllDescription": "Löscht alle Mess-, Medikamenten-, Integrations- und Protokoll-Daten aller Nutzer. Nutzerkonten bleiben bestehen.", "deleteAllConfirm": "Alle Daten wirklich löschen?", - "deleteAllConfirmDescription": "Dieser Vorgang löscht alle Messwerte, Medikamente, Einnahmen, API-Tokens, Withings-Verbindungen, offene Authentifizierungs-Challenges, Benachrichtigungskanäle (Telegram, ntfy, Web Push), Push-Abonnements und in der Warteschlange befindliche Telegram-Nachrichtenlöschungen aller Benutzer unwiderruflich. Benutzerkonten, das Audit-Log und eingereichtes Feedback bleiben erhalten, damit der Löschvorgang nachvollziehbar bleibt und Bug-Reports weiter triagierbar sind.", + "deleteAllConfirmDescription": "Dieser Vorgang löscht alle Messwerte, Medikamente, Einnahmen, API-Tokens, Withings-Verbindungen, offene Authentifizierungs-Challenges, Benachrichtigungskanäle (Telegram, ntfy, Web Push), Push-Abonnements und in der Warteschlange befindliche Telegram-Nachrichtenlöschungen aller Nutzer unwiderruflich. Nutzerkonten, das Audit-Log und eingereichtes Feedback bleiben erhalten, damit der Löschvorgang nachvollziehbar bleibt und Bug-Reports weiter triagierbar sind.", "deleteButton": "Alle Daten löschen", "deleteAllPromptLabel": "Tippe {token} zum Fortfahren", "finalDelete": "Endgültig löschen", @@ -5681,11 +5681,11 @@ "versionTileUpdateAvailable": "Update verfügbar: {tag}", "versionTileUpToDate": "Aktuell", "snapshotLoadError": "System-Snapshot konnte nicht geladen werden.", - "snapshotLoading": "Snapshot wird geladen...", + "snapshotLoading": "Snapshot wird geladen…", "auditTitle": "Letzte Aktivitäten", "auditEmpty": "Keine aktuellen Audit-Einträge.", "auditLoadError": "Letzte Aktivitäten konnten nicht geladen werden.", - "auditLoading": "Aktivitäten werden geladen...", + "auditLoading": "Aktivitäten werden geladen…", "auditViewAll": "Alle anzeigen" }, "section": { @@ -5714,7 +5714,7 @@ "subtitle": "Schwellwerte für Medikamentenerinnerungen anpassen." }, "users": { - "title": "Benutzer", + "title": "Nutzer", "subtitle": "Konten, Rollen und aktive Sitzungen verwalten.", "filter": { "all": "Alle", @@ -5723,7 +5723,7 @@ }, "forceLogout": "Abmeldung erzwingen", "forceLogoutConfirmTitle": "{name} abmelden?", - "forceLogoutConfirmBody": "Alle aktiven Sitzungen dieses Benutzers werden widerrufen. Auf allen Geräten ist eine erneute Anmeldung erforderlich.", + "forceLogoutConfirmBody": "Alle aktiven Sitzungen dieses Nutzers werden widerrufen. Auf allen Geräten ist eine erneute Anmeldung erforderlich.", "forceLogoutSuccess": "{name} aus {count} Sitzung(en) abgemeldet", "forceLogoutFailed": "Abmeldung fehlgeschlagen", "cannotLogoutSelf": "Eigene Sitzungen können nicht beendet werden", @@ -5744,7 +5744,7 @@ "subtitle": "Audit-Spur für Authentifizierungs- und Admin-Ereignisse." }, "auditLog": { - "filterActor": "Akteur (Benutzer-ID oder -Name)", + "filterActor": "Akteur (Nutzer-ID oder -Name)", "filterAction": "Aktion", "filterActionAll": "Alle Aktionen", "filterTarget": "Ziel (ID oder Pfad)", @@ -5792,7 +5792,7 @@ "runNow": "Jetzt sichern", "runEnqueued": "Backup-Job eingeplant", "runFailed": "Backup konnte nicht eingeplant werden", - "loading": "Backups werden geladen...", + "loading": "Backups werden geladen…", "loadError": "Backups konnten nicht geladen werden.", "emptyTitle": "Noch keine Backups", "emptyDescription": "Backups laufen automatisch jeden Sonntag um 03:00 Uhr — oder erstelle jetzt einen ersten Snapshot.", @@ -5820,7 +5820,7 @@ "restoreDescription": "Dies ersetzt ALLE aktuellen Daten von {username} mit dem Snapshot von {when}. Vorhandene Messungen, Medikamente, Einnahmen, Stimmungseinträge, Benachrichtigungs-Kanäle, Push-Abos und Telegram-Lösch-Aufträge werden entfernt.", "restorePromptLabel": "Tippe RESTORE zum Fortfahren", "restoreConfirm": "Jetzt wiederherstellen", - "restoreInProgress": "Wiederherstellung läuft...", + "restoreInProgress": "Wiederherstellung läuft…", "restoreSuccess": "Wiederherstellung abgeschlossen", "restoreFailed": "Wiederherstellung fehlgeschlagen", "docsLink": "Dokumentation" @@ -5840,7 +5840,7 @@ "encryption": { "title": "Verschlüsselung", "subtitle": "Verschlüsselungsabdeckung und Schlüsselrotations-Status für ruhende Daten.", - "loading": "Verschlüsselungsstatus wird geladen...", + "loading": "Verschlüsselungsstatus wird geladen…", "loadError": "Verschlüsselungsstatus konnte nicht geladen werden.", "coverageTitle": "Abdeckung", "coverageDescription": "Wie viele verschlüsselte Zeilen unter welcher Schlüssel-ID liegen. Es werden nur Schlüssel-IDs und Anzahlen gezeigt — niemals Schlüsselmaterial.", @@ -7068,7 +7068,7 @@ "saveError": "Wert konnte nicht gespeichert werden", "biomarker": "Biomarker", "biomarkerPlaceholder": "Biomarker auswählen", - "defineNew": "+ Neuen Biomarker anlegen", + "defineNew": "+ Neuen Biomarker hinzufügen", "noBiomarkersHint": "Lege zuerst einen Biomarker an, um einen Wert zu erfassen.", "pickBiomarkerError": "Bitte zuerst einen Biomarker auswählen.", "resultType": "Ergebnistyp", @@ -7114,9 +7114,9 @@ "biomarker": { "manageTitle": "Biomarker", "managerDescription": "Marker einmal definieren — Einheit und Bereich gelten für jede Messung.", - "define": "Anlegen", - "defineFirst": "Ersten Biomarker anlegen", - "defineTitle": "Biomarker anlegen", + "define": "Hinzufügen", + "defineFirst": "Ersten Biomarker hinzufügen", + "defineTitle": "Biomarker hinzufügen", "defineDescription": "Name, Einheit und Referenzbereich einmalig festlegen. Beim Erfassen wählst du nur den Marker.", "editTitle": "Biomarker bearbeiten", "edit": "Bearbeiten", @@ -7332,7 +7332,7 @@ }, "medications": { "label": "Medikamente", - "description": "Pläne, Einnahmen und Compliance." + "description": "Pläne, Einnahmen und Therapietreue." }, "illness": { "label": "Krankheitstagebuch", @@ -7893,7 +7893,7 @@ "review": { "title": "Erkannte Angaben prüfen", "confirm": "Bestätigte Angaben speichern", - "confirming": "Wird gespeichert …", + "confirming": "Wird gespeichert…", "needsReview": "Prüfung nötig", "approve": "Übernehmen", "reject": "Ablehnen", diff --git a/messages/en.json b/messages/en.json index b2da6e5f8..468dcbe75 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8,7 +8,7 @@ "moreOptions": "More options", "create": "Create", "close": "Close", - "loading": "Loading...", + "loading": "Loading…", "loadFailed": "Could not load", "error": "Error", "saved": "Saved", @@ -306,7 +306,7 @@ "hero": { "scoreLabel": "Health Score", "scoreProvisional": "Not enough data yet", - "scoreComputing": "Calculating score …", + "scoreComputing": "Calculating score…", "doses": { "summary": "{taken} of {scheduled} doses taken", "nextAt": "Next at {time}", @@ -817,7 +817,7 @@ "importIntakes": "Import intakes", "importDescription": "Paste a JSON array with intake data. Expected format:", "importUploadFile": "Upload JSON file", - "importPaste": "Paste JSON here...", + "importPaste": "Paste JSON here…", "importSelected": "Selected: {name}", "importFileLoaded": "File \"{name}\" loaded", "importInvalidJson": "File does not contain valid JSON", @@ -1412,7 +1412,7 @@ } }, "verlauf": { - "loading": "Loading history …", + "loading": "Loading history…", "loadError": "Could not load the history.", "empty": "No intakes in this period yet.", "summaryEmpty": "No doses to score yet.", @@ -1682,7 +1682,7 @@ "zeitplan": { "title": "Schedule", "timesTitle": "Dose times", - "timesNote": "Adjust each dose's time and on-time window. The frequency (daily, weekly …) is edited in the editor under Advanced.", + "timesNote": "Adjust each dose's time and on-time window. The frequency (daily, weekly…) is edited in the editor under Advanced.", "scheduleLabel": "Schedule {index}", "fullEditNote": "Frequency, treatment class and course window are edited in the editor under Advanced → Lifecycle.", "graceEcho": "Reminder window: {minutes} minutes", @@ -2152,7 +2152,7 @@ "send": "Send", "stop": "Stop", "thinking": "Thinking…", - "composerPlaceholder": "Ask me anything …", + "composerPlaceholder": "Ask me anything…", "nudgeBubbleLabel": "New message from the Coach — open chat", "fabLabel": "Open the Coach", "readAloud": "Read aloud", @@ -2534,7 +2534,7 @@ "stimmungEmptyAction": "Open mood journal", "medikamenteDescription": "Per-medication adherence calendars and a written assessment.", "medikamenteEmptyTitle": "Add your first medication", - "medikamenteEmptyDescription": "Compliance insights need at least one tracked medication.", + "medikamenteEmptyDescription": "Adherence insights need at least one tracked medication.", "medikamenteEmptyAction": "Open medications", "bmiDescription": "BMI derived from your weight and height, with a written assessment.", "target": { @@ -3820,7 +3820,7 @@ }, "medications": { "title": "Medications & intakes", - "body": "Medications manages schedules, intakes, stock, and titration. Hidden: recurring plans (RRULE/rolling) and low-stock warnings. Your intake rate feeds the compliance pillar on the dashboard and the AI’s context." + "body": "Medications manages schedules, intakes, stock, and titration. Hidden: recurring plans (RRULE/rolling) and low-stock warnings. Your intake rate feeds the adherence pillar on the dashboard and the AI’s context." }, "labs": { "title": "Lab values", @@ -5355,7 +5355,7 @@ "measurementsCount": "Measurements", "activeTokens": "Active API Tokens", "activeSessions": "Active Sessions", - "loadingStatus": "Loading status...", + "loadingStatus": "Loading status…", "systemStatusLoadError": "Failed to load system status.", "systemStatusRetry": "Retry", "adminSettingsLoadError": "Failed to load admin settings. Toggles below may not reflect the live state — refresh the page.", @@ -5387,7 +5387,7 @@ }, "aiQuality": { "title": "Insights Quality", - "loading": "Loading feedback summary...", + "loading": "Loading feedback summary…", "loadError": "Failed to load Insights quality summary. Try refreshing the page.", "empty": "No feedback yet. The aggregator runs daily at 04:00 — once users start rating recommendations, helpful-rate bands appear here.", "windowLabel": "Last {days} days", @@ -5402,7 +5402,7 @@ }, "coachFeedback": { "title": "Coach Feedback", - "loading": "Loading Coach feedback summary...", + "loading": "Loading Coach feedback summary…", "loadError": "Failed to load Coach feedback summary. Try refreshing the page.", "empty": "No Coach feedback yet. The aggregator runs daily at 04:00 — once users rate Coach replies, helpful-rate bands appear here.", "windowLabel": "Last {days} days", @@ -5574,7 +5574,7 @@ "newPasswordPlaceholder": "At least 12 characters", "passwordReset": "Password has been reset", "reset": "Reset", - "loadingUsers": "Loading users...", + "loadingUsers": "Loading users…", "apiTokens": "API Tokens", "apiTokensDescription": "All active and revoked API tokens across all accounts.", "tokenUser": "User", @@ -5681,11 +5681,11 @@ "versionTileUpdateAvailable": "Update available: {tag}", "versionTileUpToDate": "Up to date", "snapshotLoadError": "Failed to load system snapshot.", - "snapshotLoading": "Loading snapshot...", + "snapshotLoading": "Loading snapshot…", "auditTitle": "Recent activity", "auditEmpty": "No recent audit entries.", "auditLoadError": "Failed to load recent activity.", - "auditLoading": "Loading activity...", + "auditLoading": "Loading activity…", "auditViewAll": "View all" }, "section": { @@ -5792,7 +5792,7 @@ "runNow": "Backup now", "runEnqueued": "Backup job enqueued", "runFailed": "Failed to enqueue backup", - "loading": "Loading backups...", + "loading": "Loading backups…", "loadError": "Failed to load backups.", "emptyTitle": "No backups yet", "emptyDescription": "Backups run automatically every Sunday at 03:00 — or trigger one now to create the first snapshot.", @@ -5820,7 +5820,7 @@ "restoreDescription": "This will replace ALL current data for {username} with the snapshot from {when}. Existing measurements, medications, intake events, mood entries, notification channels, push subscriptions and queued Telegram deletions will be deleted.", "restorePromptLabel": "Type RESTORE to continue", "restoreConfirm": "Restore now", - "restoreInProgress": "Restoring...", + "restoreInProgress": "Restoring…", "restoreSuccess": "Restore complete", "restoreFailed": "Restore failed", "docsLink": "Documentation" @@ -5840,7 +5840,7 @@ "encryption": { "title": "Encryption", "subtitle": "Encryption coverage and key-rotation status for data at rest.", - "loading": "Loading encryption status...", + "loading": "Loading encryption status…", "loadError": "Failed to load encryption status.", "coverageTitle": "Coverage", "coverageDescription": "How many encrypted rows sit under each key id. Only key ids and counts are shown — never key material.", @@ -7332,7 +7332,7 @@ }, "medications": { "label": "Medications", - "description": "Schedules, intakes, and compliance." + "description": "Schedules, intakes, and adherence." }, "illness": { "label": "Illness journal", From f219072881c611bee3c38e04790e3642b91e1390 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 1 Jul 2026 23:44:55 +0200 Subject: [PATCH 02/16] style(theme): soften the light-mode neutral spine Lift the light theme off the pure-black-on-white extreme. Foreground moves from a near-black L0.24 to a dark tinted neutral (foreground-on- card drops from 16.5:1 to a comfortable 13.2:1), the card leaves pure white so surfaces separate by lightness, borders lift to stay visible, and every neutral carries a faint mauve tint toward the brand hue. Adds an Alucard light value for the yellow accent so charts stop glaring. Accents, chart hues, and the dark theme are unchanged. themeColor for light matches the new background. --- src/app/globals.css | 66 ++++++++++++++++++++++++++------------------- src/app/layout.tsx | 2 +- 2 files changed, 39 insertions(+), 29 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index 95ee9ba77..acc5100af 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -288,25 +288,34 @@ /* ── Light mode (Alucard — official Dracula light) ── */ :root.light { - --background: #f5f5f5; - --foreground: #1f1f1f; - --card: #ffffff; - --card-foreground: #1f1f1f; - --popover: #ffffff; - --popover-foreground: #1f1f1f; + /* + * Neutral spine (retuned v1.26.0). Softened off the pure-white / pure-black + * high-contrast set — a faint mauve tint (hue ~295–300) runs through every + * neutral so the light theme reads gentle rather than harsh. Foreground is a + * tinted near-black (not #000), dropping fg-on-card from ~16.5:1 to a + * comfortable ~13:1. Accent + semantic tokens below stay as the Alucard + * AA-tuned set. oklch throughout so lightness steps are perceptually even. + */ + --background: oklch(0.964 0.004 300); + --foreground: oklch(0.3 0.012 295); + --card: oklch(0.988 0.003 300); + --card-foreground: oklch(0.3 0.012 295); + --popover: oklch(0.992 0.002 300); + --popover-foreground: oklch(0.3 0.012 295); --primary: #644ac9; - --primary-foreground: #ffffff; - --secondary: #dcdeef; - --secondary-foreground: #1f1f1f; - --muted: #dcdeef; - --muted-foreground: #5b6273; - /* Inactive tab-trigger text — the light muted-foreground already clears - * AA (4.58:1) on the light muted strip, so the token mirrors it. */ - --tab-inactive: #5b6273; - /* Light-theme counterpart of the NativeSelect chevron (see dark block). */ - --native-select-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%235b6273' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E"); - --accent: #dcdeef; - --accent-foreground: #1f1f1f; + --primary-foreground: oklch(0.99 0.002 300); + --secondary: oklch(0.95 0.008 300); + --secondary-foreground: oklch(0.3 0.012 295); + --muted: oklch(0.955 0.006 300); + --muted-foreground: oklch(0.515 0.02 295); + /* Inactive tab-trigger text — the light muted-foreground clears AA on the + * light muted strip, so the token mirrors it. */ + --tab-inactive: oklch(0.515 0.02 295); + /* Light-theme counterpart of the NativeSelect chevron (see dark block). + * Stroke tracks the muted-foreground hex (#686572). */ + --native-select-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23686572' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E"); + --accent: oklch(0.95 0.008 300); + --accent-foreground: oklch(0.3 0.012 295); --destructive: #cb3a2a; /* * Alucard light-mode overrides for the semantic tokens — straight Dracula @@ -318,22 +327,23 @@ --info: #036a96; /* White glyph on the darker light-mode green — ~6.1:1 on `#14720a`. */ --success-foreground: #ffffff; - --border: #cfcfde; - --input: #cfcfde; - --ring: #644ac9; + --border: oklch(0.885 0.008 300); + --input: oklch(0.87 0.009 300); + --ring: oklch(0.6 0.13 292); --chart-1: #644ac9; --chart-2: #14720a; --chart-3: #a3134d; --chart-4: #036a96; --chart-5: #a34d14; - --sidebar: #ffffff; - --sidebar-foreground: #1f1f1f; + --dracula-yellow: #846e15; + --sidebar: oklch(0.988 0.003 300); + --sidebar-foreground: oklch(0.3 0.012 295); --sidebar-primary: #644ac9; - --sidebar-primary-foreground: #ffffff; - --sidebar-accent: #dcdeef; - --sidebar-accent-foreground: #1f1f1f; - --sidebar-border: #cfcfde; - --sidebar-ring: #644ac9; + --sidebar-primary-foreground: oklch(0.99 0.002 300); + --sidebar-accent: oklch(0.95 0.008 300); + --sidebar-accent-foreground: oklch(0.3 0.012 295); + --sidebar-border: oklch(0.885 0.008 300); + --sidebar-ring: oklch(0.6 0.13 292); /* * v1.18.7 — wellness-ring per-metric arc colours (Alucard light), CALM pass. diff --git a/src/app/layout.tsx b/src/app/layout.tsx index debecd730..1322ebb68 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -88,7 +88,7 @@ export const viewport: Viewport = { // `--background` from `app/globals.css` for each theme so the bar // edge never seams against the page on cold paint. themeColor: [ - { media: "(prefers-color-scheme: light)", color: "#ffffff" }, + { media: "(prefers-color-scheme: light)", color: "#f3f2f5" }, { media: "(prefers-color-scheme: dark)", color: "#282a36" }, ], }; From c6dea5eb5615120581f34a73de4379ea1289747d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 1 Jul 2026 23:44:55 +0200 Subject: [PATCH 03/16] refactor(insights): route chart and classification colors through theme tokens Reference-band fills, classification tones, and the trend-chart palette carried hardcoded hex that rendered identically in both themes and glared on the light surface. Route them through the semantic and chart CSS variables, which already hold per-theme values, so the light theme gets the muted set and the dark theme stays byte-identical. --- src/lib/analytics/classifications.ts | 108 ++++++++++++------ src/lib/analytics/pulse-targets.ts | 14 ++- src/lib/analytics/value-bands.ts | 40 +++++-- .../dashboard/__tests__/target-bands.test.ts | 20 +++- src/lib/dashboard/bands.ts | 20 +++- src/lib/insights/trend-chart-select.ts | 24 ++-- src/lib/signals/registry.ts | 6 +- src/lib/targets/build-response.ts | 33 +++--- 8 files changed, 180 insertions(+), 85 deletions(-) diff --git a/src/lib/analytics/classifications.ts b/src/lib/analytics/classifications.ts index 9eba5b3df..293a4d27d 100644 --- a/src/lib/analytics/classifications.ts +++ b/src/lib/analytics/classifications.ts @@ -13,35 +13,39 @@ export interface BmiClassification { export function classifyBMI(bmi: number): BmiClassification { if (bmi < 18.5) { - return { category: "Underweight", color: "#f1fa8c", severity: "warning" }; + return { + category: "Underweight", + color: "var(--dracula-yellow)", + severity: "warning", + }; } if (bmi < 25) { - return { category: "Normal", color: "#50fa7b", severity: "normal" }; + return { category: "Normal", color: "var(--success)", severity: "normal" }; } if (bmi < 30) { return { category: "Overweight", - color: "#ffb86c", + color: "var(--warning)", severity: "warning", }; } if (bmi < 35) { return { category: "Obesity Grade I", - color: "#ff79c6", + color: "var(--chart-3)", severity: "danger", }; } if (bmi < 40) { return { category: "Obesity Grade II", - color: "#ff5555", + color: "var(--destructive)", severity: "danger", }; } return { category: "Obesity Grade III", - color: "#ff5555", + color: "var(--destructive)", severity: "danger", }; } @@ -56,35 +60,35 @@ export interface BpClassification { export function classifyBP(sys: number, dia: number): BpClassification { if (sys < 120 && dia < 80) { - return { category: "Optimal", color: "#50fa7b", severity: "normal" }; + return { category: "Optimal", color: "var(--success)", severity: "normal" }; } if (sys < 130 && dia < 85) { - return { category: "Normal", color: "#8be9fd", severity: "normal" }; + return { category: "Normal", color: "var(--info)", severity: "normal" }; } if (sys < 140 && dia < 90) { return { category: "High-normal", - color: "#f1fa8c", + color: "var(--dracula-yellow)", severity: "elevated", }; } if (sys < 160 && dia < 100) { return { category: "Hypertension Grade 1", - color: "#ffb86c", + color: "var(--warning)", severity: "warning", }; } if (sys < 180 && dia < 110) { return { category: "Hypertension Grade 2", - color: "#ff79c6", + color: "var(--chart-3)", severity: "danger", }; } return { category: "Hypertension Grade 3", - color: "#ff5555", + color: "var(--destructive)", severity: "danger", }; } @@ -99,15 +103,23 @@ export interface PulseClassification { export function classifyPulse(bpm: number): PulseClassification { if (bpm < 60) { - return { category: "Bradycardia", color: "#8be9fd", severity: "info" }; + return { category: "Bradycardia", color: "var(--info)", severity: "info" }; } if (bpm <= 100) { - return { category: "Normal", color: "#50fa7b", severity: "normal" }; + return { category: "Normal", color: "var(--success)", severity: "normal" }; } if (bpm <= 120) { - return { category: "Elevated", color: "#ffb86c", severity: "warning" }; + return { + category: "Elevated", + color: "var(--warning)", + severity: "warning", + }; } - return { category: "Tachycardia", color: "#ff5555", severity: "danger" }; + return { + category: "Tachycardia", + color: "var(--destructive)", + severity: "danger", + }; } // ── Sleep Duration Classification (AASM/SRS) ─────────── @@ -124,22 +136,34 @@ export function classifySleepDuration( if (hours < 6) { return { category: "Far too short", - color: "#ff5555", + color: "var(--destructive)", severity: "warning", }; } if (hours < 7) { - return { category: "Too short", color: "#ffb86c", severity: "warning" }; + return { + category: "Too short", + color: "var(--warning)", + severity: "warning", + }; } if (hours <= 9) { - return { category: "On target", color: "#50fa7b", severity: "normal" }; + return { + category: "On target", + color: "var(--success)", + severity: "normal", + }; } if (hours <= 10) { - return { category: "Slightly long", color: "#8be9fd", severity: "info" }; + return { + category: "Slightly long", + color: "var(--info)", + severity: "info", + }; } return { category: "Far too long", - color: "#ff79c6", + color: "var(--chart-3)", severity: "warning", }; } @@ -220,23 +244,35 @@ export function classifyBodyFat( // hormonal / cardiac risk in chronic depletion). return { category: "Below essential", - color: "#ff5555", + color: "var(--destructive)", severity: "danger", }; } if (pct <= essentialMax) { - return { category: "Essential", color: "#f1fa8c", severity: "warning" }; + return { + category: "Essential", + color: "var(--dracula-yellow)", + severity: "warning", + }; } if (pct <= athleteMax) { - return { category: "Athletic", color: "#50fa7b", severity: "normal" }; + return { + category: "Athletic", + color: "var(--success)", + severity: "normal", + }; } if (pct <= fitnessMax) { - return { category: "Fitness", color: "#50fa7b", severity: "normal" }; + return { category: "Fitness", color: "var(--success)", severity: "normal" }; } if (pct <= acceptableMax) { - return { category: "Acceptable", color: "#ffb86c", severity: "warning" }; + return { + category: "Acceptable", + color: "var(--warning)", + severity: "warning", + }; } - return { category: "Obese", color: "#ff5555", severity: "danger" }; + return { category: "Obese", color: "var(--destructive)", severity: "danger" }; } // ── Activity Steps Classification ─────────────────────── @@ -259,24 +295,32 @@ export function classifySteps(steps: number): StepsClassification { if (steps < 3000) { return { category: "Very low", - color: "#ff5555", + color: "var(--destructive)", severity: "warning", }; } if (steps < 5000) { - return { category: "Low active", color: "#ffb86c", severity: "warning" }; + return { + category: "Low active", + color: "var(--warning)", + severity: "warning", + }; } if (steps < 7500) { return { category: "Moderately active", - color: "#f1fa8c", + color: "var(--dracula-yellow)", severity: "info", }; } if (steps < 10000) { - return { category: "Active", color: "#50fa7b", severity: "normal" }; + return { category: "Active", color: "var(--success)", severity: "normal" }; } - return { category: "Very active", color: "#50fa7b", severity: "normal" }; + return { + category: "Very active", + color: "var(--success)", + severity: "normal", + }; } /** diff --git a/src/lib/analytics/pulse-targets.ts b/src/lib/analytics/pulse-targets.ts index 58c2c6cda..4acc0ad56 100644 --- a/src/lib/analytics/pulse-targets.ts +++ b/src/lib/analytics/pulse-targets.ts @@ -110,30 +110,34 @@ export function classifyPulseByTarget( if (bpm < target.orangeMin) { return { category: "Significantly low", - color: "#8be9fd", + color: "var(--info)", severity: "info", }; } if (bpm < target.greenMin) { return { category: "Slightly low", - color: "#ffb86c", + color: "var(--warning)", severity: "warning", }; } if (bpm <= target.greenMax) { - return { category: "On target", color: "#50fa7b", severity: "normal" }; + return { + category: "On target", + color: "var(--success)", + severity: "normal", + }; } if (bpm <= target.orangeMax) { return { category: "Slightly elevated", - color: "#ffb86c", + color: "var(--warning)", severity: "warning", }; } return { category: "Significantly elevated", - color: "#ff5555", + color: "var(--destructive)", severity: "danger", }; } diff --git a/src/lib/analytics/value-bands.ts b/src/lib/analytics/value-bands.ts index 05c71c06a..38fe9fa2b 100644 --- a/src/lib/analytics/value-bands.ts +++ b/src/lib/analytics/value-bands.ts @@ -39,11 +39,21 @@ export function buildTrafficLightBands( const upperBound = options.upperBound ?? orangeHigh + Math.max(span, 1) * 2; const bands: ValueBand[] = [ - { min: lowerBound, max: orangeLow, color: "#ff5555", opacity: 0.16 }, - { min: orangeLow, max: min, color: "#ffb86c", opacity: 0.18 }, - { min, max, color: "#50fa7b", opacity: 0.2 }, - { min: max, max: orangeHigh, color: "#ffb86c", opacity: 0.18 }, - { min: orangeHigh, max: upperBound, color: "#ff5555", opacity: 0.16 }, + { + min: lowerBound, + max: orangeLow, + color: "var(--destructive)", + opacity: 0.16, + }, + { min: orangeLow, max: min, color: "var(--warning)", opacity: 0.18 }, + { min, max, color: "var(--success)", opacity: 0.2 }, + { min: max, max: orangeHigh, color: "var(--warning)", opacity: 0.18 }, + { + min: orangeHigh, + max: upperBound, + color: "var(--destructive)", + opacity: 0.16, + }, ]; return bands.filter((band) => band.max > band.min); @@ -88,26 +98,36 @@ export function buildWeightBandsFromHeight( const upperBound = options.upperBound ?? 250; return [ - { min: lowerBound, max: range.orangeMin, color: "#ff5555", opacity: 0.16 }, + { + min: lowerBound, + max: range.orangeMin, + color: "var(--destructive)", + opacity: 0.16, + }, { min: range.orangeMin, max: range.greenMin, - color: "#ffb86c", + color: "var(--warning)", opacity: 0.18, }, { min: range.greenMin, max: range.greenMax, - color: "#50fa7b", + color: "var(--success)", opacity: 0.2, }, { min: range.greenMax, max: range.orangeMax, - color: "#ffb86c", + color: "var(--warning)", opacity: 0.18, }, - { min: range.orangeMax, max: upperBound, color: "#ff5555", opacity: 0.16 }, + { + min: range.orangeMax, + max: upperBound, + color: "var(--destructive)", + opacity: 0.16, + }, ].filter((band) => band.max > band.min); } diff --git a/src/lib/dashboard/__tests__/target-bands.test.ts b/src/lib/dashboard/__tests__/target-bands.test.ts index 56aa6d1ab..7f76f903a 100644 --- a/src/lib/dashboard/__tests__/target-bands.test.ts +++ b/src/lib/dashboard/__tests__/target-bands.test.ts @@ -58,26 +58,36 @@ function clientBands(profile: { orangeMax: pulseTarget.orangeMax, }, pulseBands: [ - { min: 30, max: pulseTarget.orangeMin, color: "#ff5555", opacity: 0.16 }, + { + min: 30, + max: pulseTarget.orangeMin, + color: "var(--destructive)", + opacity: 0.16, + }, { min: pulseTarget.orangeMin, max: pulseTarget.greenMin, - color: "#ffb86c", + color: "var(--warning)", opacity: 0.18, }, { min: pulseTarget.greenMin, max: pulseTarget.greenMax, - color: "#50fa7b", + color: "var(--success)", opacity: 0.2, }, { min: pulseTarget.greenMax, max: pulseTarget.orangeMax, - color: "#ffb86c", + color: "var(--warning)", opacity: 0.18, }, - { min: pulseTarget.orangeMax, max: 220, color: "#ff5555", opacity: 0.16 }, + { + min: pulseTarget.orangeMax, + max: 220, + color: "var(--destructive)", + opacity: 0.16, + }, ].filter((b) => b.max > b.min), bodyFatRange, bodyFatBands: buildTrafficLightBands(bodyFatRange.min, bodyFatRange.max, { diff --git a/src/lib/dashboard/bands.ts b/src/lib/dashboard/bands.ts index d7d7ad397..381ed55aa 100644 --- a/src/lib/dashboard/bands.ts +++ b/src/lib/dashboard/bands.ts @@ -88,26 +88,36 @@ export function buildDashboardBands(profile: { ? buildTrafficRange(bpTargets.diaLow, bpTargets.diaHigh) : null; const pulseBands = [ - { min: 30, max: pulseTarget.orangeMin, color: "#ff5555", opacity: 0.16 }, + { + min: 30, + max: pulseTarget.orangeMin, + color: "var(--destructive)", + opacity: 0.16, + }, { min: pulseTarget.orangeMin, max: pulseTarget.greenMin, - color: "#ffb86c", + color: "var(--warning)", opacity: 0.18, }, { min: pulseTarget.greenMin, max: pulseTarget.greenMax, - color: "#50fa7b", + color: "var(--success)", opacity: 0.2, }, { min: pulseTarget.greenMax, max: pulseTarget.orangeMax, - color: "#ffb86c", + color: "var(--warning)", opacity: 0.18, }, - { min: pulseTarget.orangeMax, max: 220, color: "#ff5555", opacity: 0.16 }, + { + min: pulseTarget.orangeMax, + max: 220, + color: "var(--destructive)", + opacity: 0.16, + }, ].filter((band) => band.max > band.min); const bodyFatBands = buildTrafficLightBands( bodyFatRange.min, diff --git a/src/lib/insights/trend-chart-select.ts b/src/lib/insights/trend-chart-select.ts index cf0fe3c65..31e04a6aa 100644 --- a/src/lib/insights/trend-chart-select.ts +++ b/src/lib/insights/trend-chart-select.ts @@ -100,7 +100,7 @@ export const TREND_CHART_CONFIG: Record< metric: "bp", kind: "health-chart", types: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"], - colors: ["#ff79c6", "#8be9fd"], + colors: ["var(--chart-3)", "var(--chart-4)"], unit: "mmHg", yAxisUnit: "mmHg", titleKey: "charts.bloodPressure", @@ -112,7 +112,7 @@ export const TREND_CHART_CONFIG: Record< metric: "weight", kind: "health-chart", types: ["WEIGHT"], - colors: ["#bd93f9"], + colors: ["var(--chart-1)"], unit: "kg", titleKey: "charts.weight", captionKey: "insights.trendsRow.caption.weight", @@ -123,7 +123,7 @@ export const TREND_CHART_CONFIG: Record< metric: "pulse", kind: "health-chart", types: ["PULSE"], - colors: ["#50fa7b"], + colors: ["var(--chart-2)"], unit: "bpm", yAxisUnit: "bpm", titleKey: "charts.pulse", @@ -144,7 +144,7 @@ export const TREND_CHART_CONFIG: Record< metric: "sleep", kind: "health-chart", types: ["SLEEP_DURATION"], - colors: ["#8be9fd"], + colors: ["var(--chart-4)"], unit: "h", yAxisUnit: "h", titleKey: "charts.sleep", @@ -155,7 +155,7 @@ export const TREND_CHART_CONFIG: Record< metric: "steps", kind: "health-chart", types: ["ACTIVITY_STEPS"], - colors: ["#50fa7b"], + colors: ["var(--chart-2)"], unit: "steps", yAxisUnit: "steps", titleKey: "charts.steps", @@ -166,7 +166,7 @@ export const TREND_CHART_CONFIG: Record< metric: "hrv", kind: "health-chart", types: ["HEART_RATE_VARIABILITY"], - colors: ["#bd93f9"], + colors: ["var(--chart-1)"], unit: "ms", yAxisUnit: "ms", titleKey: "charts.hrv", @@ -177,7 +177,7 @@ export const TREND_CHART_CONFIG: Record< metric: "resting_hr", kind: "health-chart", types: ["RESTING_HEART_RATE"], - colors: ["#ff79c6"], + colors: ["var(--chart-3)"], unit: "bpm", yAxisUnit: "bpm", titleKey: "charts.restingHeartRate", @@ -188,7 +188,7 @@ export const TREND_CHART_CONFIG: Record< metric: "active_energy", kind: "health-chart", types: ["ACTIVE_ENERGY_BURNED"], - colors: ["#ffb86c"], + colors: ["var(--chart-5)"], unit: "kcal", yAxisUnit: "kcal", titleKey: "charts.activeEnergy", @@ -199,7 +199,7 @@ export const TREND_CHART_CONFIG: Record< metric: "flights", kind: "health-chart", types: ["FLIGHTS_CLIMBED"], - colors: ["#f1fa8c"], + colors: ["var(--dracula-yellow)"], unit: "flights", yAxisUnit: "flights", titleKey: "charts.flights", @@ -210,7 +210,7 @@ export const TREND_CHART_CONFIG: Record< metric: "distance", kind: "health-chart", types: ["WALKING_RUNNING_DISTANCE"], - colors: ["#8be9fd"], + colors: ["var(--chart-4)"], unit: "km", yAxisUnit: "km", titleKey: "charts.distance", @@ -221,7 +221,7 @@ export const TREND_CHART_CONFIG: Record< metric: "vo2_max", kind: "health-chart", types: ["VO2_MAX"], - colors: ["#50fa7b"], + colors: ["var(--chart-2)"], unit: "mL/kg·min", yAxisUnit: "mL/kg·min", titleKey: "charts.vo2Max", @@ -232,7 +232,7 @@ export const TREND_CHART_CONFIG: Record< metric: "body_temp", kind: "health-chart", types: ["BODY_TEMPERATURE"], - colors: ["#ff5555"], + colors: ["var(--destructive)"], unit: "°C", yAxisUnit: "°C", titleKey: "charts.bodyTemperature", diff --git a/src/lib/signals/registry.ts b/src/lib/signals/registry.ts index 8f17aa48b..788b7e7c3 100644 --- a/src/lib/signals/registry.ts +++ b/src/lib/signals/registry.ts @@ -171,7 +171,11 @@ export const SIGNALS: Record = { archetype: "physiological-vital", normalRange: { low: 50, high: 100 }, referenceMetric: "RESTING_HEART_RATE", - chart: { overlayKey: "restingHr", color: "#ff5555", yAxisUnit: "bpm" }, + chart: { + overlayKey: "restingHr", + color: "var(--destructive)", + yAxisUnit: "bpm", + }, surfaces: { detailPage: true, correlationEligible: false, diff --git a/src/lib/targets/build-response.ts b/src/lib/targets/build-response.ts index 89b728e66..c00ffc3ee 100644 --- a/src/lib/targets/build-response.ts +++ b/src/lib/targets/build-response.ts @@ -828,10 +828,10 @@ export async function buildTargetsResponse(user: AuthedUser) { classification: bpInTargetRate != null ? bpInTargetRate >= 70 - ? { category: "Good", color: "#50fa7b" } + ? { category: "Good", color: "var(--success)" } : bpInTargetRate >= 40 - ? { category: "Moderate", color: "#f1fa8c" } - : { category: "Low", color: "#ff5555" } + ? { category: "Moderate", color: "var(--dracula-yellow)" } + : { category: "Low", color: "var(--destructive)" } : null, source: "ESH 2023", ...inTargetConsistency, @@ -1233,10 +1233,10 @@ export async function buildTargetsResponse(user: AuthedUser) { classification: complianceRate7 != null ? complianceRate7 >= 90 - ? { category: "Very good", color: "#50fa7b" } + ? { category: "Very good", color: "var(--success)" } : complianceRate7 >= 70 - ? { category: "Good", color: "#f1fa8c" } - : { category: "Low", color: "#ff5555" } + ? { category: "Good", color: "var(--dracula-yellow)" } + : { category: "Low", color: "var(--destructive)" } : null, source: "7-day", details: { @@ -1358,10 +1358,10 @@ export async function buildTargetsResponse(user: AuthedUser) { const moodClassification = latestMoodScore != null ? latestMoodScore >= 3.5 - ? { category: "Good", color: "#50fa7b" } + ? { category: "Good", color: "var(--success)" } : latestMoodScore >= 2 - ? { category: "Moderate", color: "#f1fa8c" } - : { category: "Low", color: "#ff5555" } + ? { category: "Moderate", color: "var(--dracula-yellow)" } + : { category: "Low", color: "var(--destructive)" } : null; const moodRange = { min: 3.5, max: 5 }; @@ -1397,10 +1397,10 @@ export async function buildTargetsResponse(user: AuthedUser) { const stabilityClassification = stdDev <= 0.5 - ? { category: "Very stable", color: "#50fa7b" } + ? { category: "Very stable", color: "var(--success)" } : stdDev <= 1.0 - ? { category: "Stable", color: "#f1fa8c" } - : { category: "Fluctuating", color: "#ff5555" }; + ? { category: "Stable", color: "var(--dracula-yellow)" } + : { category: "Fluctuating", color: "var(--destructive)" }; // Mood stability is computed from the same recent-mood window; // consistency mirrors MOOD_SCORE so the strip aligns with the @@ -1476,11 +1476,14 @@ export async function buildTargetsResponse(user: AuthedUser) { let classification: { category: string; color: string } | null = null; if (range && effRange) { if (latest >= range.min && latest <= range.max) { - classification = { category: "Optimal", color: "#50fa7b" }; + classification = { category: "Optimal", color: "var(--success)" }; } else if (latest >= effRange.orangeMin && latest <= effRange.orangeMax) { - classification = { category: "Elevated", color: "#f1fa8c" }; + classification = { + category: "Elevated", + color: "var(--dracula-yellow)", + }; } else { - classification = { category: "High", color: "#ff5555" }; + classification = { category: "High", color: "var(--destructive)" }; } } From 86d80d2c041b19022ccc0657383a46b0dbdd52d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 1 Jul 2026 23:44:55 +0200 Subject: [PATCH 04/16] refactor(admin): migrate section cards onto the shared card primitives The admin tree hand-rolled its card surfaces and stepped padding at sm: instead of md:, so it read denser than every other route on a tablet. Move the sections onto SettingsCard/SettingsCardHeader and the mini-rows onto ListRow; no behaviour, data, or copy changes. --- .../api-token-overview-responsive.test.tsx | 9 +- .../user-management-responsive.test.tsx | 4 +- src/components/admin/ai-quality-section.tsx | 5 +- .../admin/ai-server-key-section.tsx | 8 +- .../admin/api-token-overview-section.tsx | 85 ++++++++++-------- .../admin/app-log-preview-section.tsx | 5 +- src/components/admin/assistant-section.tsx | 5 +- src/components/admin/backups-section.tsx | 89 ++++++++++--------- .../admin/coach-feedback-section.tsx | 5 +- .../admin/general-settings-section.tsx | 5 +- src/components/admin/glitchtip-section.tsx | 5 +- src/components/admin/host-metrics-chart.tsx | 29 ++---- .../admin/login-overview-section.tsx | 5 +- .../admin/module-availability-section.tsx | 5 +- src/components/admin/recent-audit-preview.tsx | 8 +- src/components/admin/reminders-section.tsx | 9 +- src/components/admin/services-section.tsx | 5 +- .../admin/system-status-section.tsx | 9 +- .../admin/system-status-summary.tsx | 7 +- src/components/admin/umami-section.tsx | 5 +- .../admin/user-management-section.tsx | 63 +++++++------ src/components/admin/version-tile-section.tsx | 8 +- .../admin/web-push-vapid-section.tsx | 5 +- 23 files changed, 200 insertions(+), 183 deletions(-) diff --git a/src/components/admin/__tests__/api-token-overview-responsive.test.tsx b/src/components/admin/__tests__/api-token-overview-responsive.test.tsx index 4ab40afa5..16a1eeff5 100644 --- a/src/components/admin/__tests__/api-token-overview-responsive.test.tsx +++ b/src/components/admin/__tests__/api-token-overview-responsive.test.tsx @@ -115,11 +115,12 @@ describe("ApiTokenOverviewSection — responsive", () => { expect(html).toContain("All"); }); - it("uses a smaller card padding on mobile (p-4 sm:p-6)", () => { + it("uses a smaller card padding on mobile (p-4 md:p-6)", () => { const html = render(); - // The card root carries the responsive padding to leave more room - // for content within a 393 CSS-px viewport. - expect(html).toMatch(/class="[^"]*\bp-4\b[^"]*sm:p-6/); + // The card root carries the shared SettingsCard padding contract + // (`p-4 md:p-6`) to leave more room for content within a 393 CSS-px + // viewport while stepping up only from `md`, not `sm`. + expect(html).toMatch(/class="[^"]*\bp-4\b[^"]*md:p-6/); }); it("desktop date cells drop whitespace-nowrap (v1.4.22 C2 5th-attempt fix)", () => { diff --git a/src/components/admin/__tests__/user-management-responsive.test.tsx b/src/components/admin/__tests__/user-management-responsive.test.tsx index 3dc75b13d..60c5dedb9 100644 --- a/src/components/admin/__tests__/user-management-responsive.test.tsx +++ b/src/components/admin/__tests__/user-management-responsive.test.tsx @@ -115,9 +115,9 @@ describe("UserManagementSection — mobile responsive", () => { expect(mobileSlice).toContain("Force logout"); }); - it("uses smaller card padding on mobile (p-4 sm:p-6)", () => { + it("uses smaller card padding on mobile (p-4 md:p-6)", () => { const html = render(); - expect(html).toMatch(/class="[^"]*\bp-4\b[^"]*sm:p-6/); + expect(html).toMatch(/class="[^"]*\bp-4\b[^"]*md:p-6/); }); it("wraps the filter pills below the title on narrow viewports", () => { diff --git a/src/components/admin/ai-quality-section.tsx b/src/components/admin/ai-quality-section.tsx index c43f24e3f..3b0e75ab0 100644 --- a/src/components/admin/ai-quality-section.tsx +++ b/src/components/admin/ai-quality-section.tsx @@ -21,6 +21,7 @@ import { useQuery } from "@tanstack/react-query"; import { Sparkles, Loader2 } from "lucide-react"; +import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -75,7 +76,7 @@ export function AiQualitySection() { // resolves. Mirrors (header outside the // fetch-state branch). return ( -
+
)} - + ); } diff --git a/src/components/admin/ai-server-key-section.tsx b/src/components/admin/ai-server-key-section.tsx index 5057b824d..16767c2a3 100644 --- a/src/components/admin/ai-server-key-section.tsx +++ b/src/components/admin/ai-server-key-section.tsx @@ -9,6 +9,7 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -92,10 +93,7 @@ export function AiServerKeySection() { } return ( -
+
- + ); } diff --git a/src/components/admin/api-token-overview-section.tsx b/src/components/admin/api-token-overview-section.tsx index 114c2917f..aa3af44c2 100644 --- a/src/components/admin/api-token-overview-section.tsx +++ b/src/components/admin/api-token-overview-section.tsx @@ -11,7 +11,9 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { formatDate, formatDateTime } from "@/lib/format"; +import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { ListRow } from "@/components/ui/list-row"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { type ApiTokenInfo } from "./_shared"; @@ -157,7 +159,7 @@ export function ApiTokenOverviewSection() { }); return ( -
+ {tokens.map((token) => { return ( -
  • -
    -
    -
    +
  • +
    +
    +
    + + +
    -
    -
    -
  • -
    - {token.permissions.map((p) => ( - - {p === "*" ? t("admin.tokenPermissionAll") : p} - - ))} -
    -

    - {t("admin.tokenLastUsed")}:{" "} - {token.lastUsedAt - ? formatDateTime(token.lastUsedAt) - : t("admin.tokenNeverUsed")} -

    -

    - {t("admin.tokenCreated")}: {formatDate(token.createdAt)} -

    - +
    + {token.permissions.map((p) => ( + + {p === "*" ? t("admin.tokenPermissionAll") : p} + + ))} +
    +

    + {t("admin.tokenLastUsed")}:{" "} + {token.lastUsedAt + ? formatDateTime(token.lastUsedAt) + : t("admin.tokenNeverUsed")} +

    +

    + {t("admin.tokenCreated")}: {formatDate(token.createdAt)} +

    + + ); })} )} - + ); } diff --git a/src/components/admin/app-log-preview-section.tsx b/src/components/admin/app-log-preview-section.tsx index 61a46d8a0..995619b62 100644 --- a/src/components/admin/app-log-preview-section.tsx +++ b/src/components/admin/app-log-preview-section.tsx @@ -46,6 +46,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { formatDateTime } from "@/lib/format"; +import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -115,7 +116,7 @@ export function AppLogPreviewSection() { const events = data?.events ?? []; return ( -
    + -
    + ); } diff --git a/src/components/admin/assistant-section.tsx b/src/components/admin/assistant-section.tsx index 8b49aaee4..a54adbe21 100644 --- a/src/components/admin/assistant-section.tsx +++ b/src/components/admin/assistant-section.tsx @@ -4,6 +4,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { Sparkles } from "lucide-react"; import { toast } from "sonner"; +import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -99,7 +100,7 @@ export function AssistantSection() { const disabledSubs = !masterOn || mutation.isPending; return ( -
    +
    - + ); } diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index f63244557..bc01e77e5 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -39,7 +39,9 @@ import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { ListRow } from "@/components/ui/list-row"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import type { BackupRow, BackupsList } from "@/types/backups"; @@ -332,7 +334,7 @@ export function BackupsSection() { } return ( -
    + {/* v1.18.1 E3 — the snapshot count leads (numbers first), the "Run now" button follows, and the explainer + docs link move into the header's description slot. Previously the description @@ -550,48 +552,51 @@ export function BackupsSection() { data-testid="admin-backups-mobile-list" > {rows.map((row) => ( -
  • -
    - {row.username} - - {formatBackupType(row.type, t)} - -
    -

    - {t("admin.section.backups.colSize")}:{" "} - {formatBytes(row.sizeBytes, fmt)} ·{" "} - {fmt.dateTime(row.createdAt)} -

    -
    - - restore.mutate(row)} - /> -
    -
  • +
  • +
    + {row.username} + + {formatBackupType(row.type, t)} + +
    +

    + {t("admin.section.backups.colSize")}:{" "} + {formatBytes(row.sizeBytes, fmt)} ·{" "} + {fmt.dateTime(row.createdAt)} +

    +
    + + restore.mutate(row)} + /> +
    +
  • + ))} @@ -602,6 +607,6 @@ export function BackupsSection() {

    )} - + ); } diff --git a/src/components/admin/coach-feedback-section.tsx b/src/components/admin/coach-feedback-section.tsx index c78619301..0308625c4 100644 --- a/src/components/admin/coach-feedback-section.tsx +++ b/src/components/admin/coach-feedback-section.tsx @@ -21,6 +21,7 @@ import { useQuery } from "@tanstack/react-query"; import { Loader2, Sparkles } from "lucide-react"; +import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -70,7 +71,7 @@ export function CoachFeedbackSection() { // /admin/coach-feedback. Mirrors the canonical structure used by // (header outside the fetch-state branch). return ( -
    +
    )} - + ); } diff --git a/src/components/admin/general-settings-section.tsx b/src/components/admin/general-settings-section.tsx index 62cb0d438..008edf4fb 100644 --- a/src/components/admin/general-settings-section.tsx +++ b/src/components/admin/general-settings-section.tsx @@ -2,6 +2,7 @@ import { useMemo } from "react"; import { Settings } from "lucide-react"; +import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; import { NativeSelect } from "@/components/ui/native-select"; @@ -26,7 +27,7 @@ export function GeneralSettingsSection() { const currentDefaultTz = settings?.defaultUserTimezone ?? ""; return ( -
    +
    - + ); } diff --git a/src/components/admin/glitchtip-section.tsx b/src/components/admin/glitchtip-section.tsx index c14a18cc8..a608f876c 100644 --- a/src/components/admin/glitchtip-section.tsx +++ b/src/components/admin/glitchtip-section.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { useMutation } from "@tanstack/react-query"; import { AlertTriangle, Loader2 } from "lucide-react"; import { toast } from "sonner"; +import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -69,7 +70,7 @@ export function GlitchtipSection() { } return ( -
    +
    - + ); } diff --git a/src/components/admin/host-metrics-chart.tsx b/src/components/admin/host-metrics-chart.tsx index 7188f90f3..0ef11da16 100644 --- a/src/components/admin/host-metrics-chart.tsx +++ b/src/components/admin/host-metrics-chart.tsx @@ -39,6 +39,7 @@ import { useMemo } from "react"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { Skeleton } from "@/components/ui/skeleton"; +import { SettingsCard } from "@/components/settings/settings-card"; import { apiGet } from "@/lib/api/api-fetch"; interface HostMetricsApiSample { @@ -156,10 +157,7 @@ export function HostMetricsChart() { // jump when data lands. Animated only when motion isn't reduced. if (isLoading) { return ( -
    +

    {t("admin.hostMetrics.title")} @@ -169,16 +167,13 @@ export function HostMetricsChart() {

    -
    + ); } if (isError) { return ( -
    +

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

    @@ -188,7 +183,7 @@ export function HostMetricsChart() { > {t("admin.hostMetrics.loadError")}

    -
    + ); } @@ -197,10 +192,7 @@ export function HostMetricsChart() { // instead of an empty plot. if (rows.length === 0) { return ( -
    +

    {t("admin.hostMetrics.title")} @@ -212,15 +204,12 @@ export function HostMetricsChart() {

    {t("admin.hostMetrics.empty")}

    -

    + ); } return ( -
    +

    {t("admin.hostMetrics.title")} @@ -354,6 +343,6 @@ export function HostMetricsChart() {

    -
    + ); } diff --git a/src/components/admin/login-overview-section.tsx b/src/components/admin/login-overview-section.tsx index 54a6059fc..601a67d80 100644 --- a/src/components/admin/login-overview-section.tsx +++ b/src/components/admin/login-overview-section.tsx @@ -25,6 +25,7 @@ import { SelectValue, } from "@/components/ui/select"; import { formatDateTime } from "@/lib/format"; +import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -219,7 +220,7 @@ export function LoginOverviewSection() { } return ( -
    + )}
    - + ); } diff --git a/src/components/admin/module-availability-section.tsx b/src/components/admin/module-availability-section.tsx index c552c0245..e578d451f 100644 --- a/src/components/admin/module-availability-section.tsx +++ b/src/components/admin/module-availability-section.tsx @@ -4,6 +4,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { ToggleLeft } from "lucide-react"; import { toast } from "sonner"; +import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -87,7 +88,7 @@ export function ModuleAvailabilitySection() { const availability = data?.availability; return ( -
    + -
    + ); } diff --git a/src/components/admin/recent-audit-preview.tsx b/src/components/admin/recent-audit-preview.tsx index 486afb127..6f112b24c 100644 --- a/src/components/admin/recent-audit-preview.tsx +++ b/src/components/admin/recent-audit-preview.tsx @@ -20,6 +20,7 @@ import { } from "lucide-react"; import { EmptyState } from "@/components/ui/empty-state"; import { formatDateTime } from "@/lib/format"; +import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -53,10 +54,7 @@ export function RecentAuditPreview() { const entries = data?.entries ?? []; return ( -
    + )} -
    + ); } diff --git a/src/components/admin/reminders-section.tsx b/src/components/admin/reminders-section.tsx index 18dae6626..3c8b39f0b 100644 --- a/src/components/admin/reminders-section.tsx +++ b/src/components/admin/reminders-section.tsx @@ -15,6 +15,7 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -53,7 +54,7 @@ function NotificationHealthPanel() { }); return ( -
    + )}
    - + ); } @@ -194,7 +195,7 @@ export function RemindersSection() { return ( <> -
    + {/* v1.4.22 D / F-58 — icon swap so the section header matches the admin-shell nav tile (Bell). Clock now lives on the timing chips inline instead of competing as the page icon. */} @@ -404,7 +405,7 @@ export function RemindersSection() {
    )} - + ); diff --git a/src/components/admin/services-section.tsx b/src/components/admin/services-section.tsx index 17a3bd784..27ff8e02b 100644 --- a/src/components/admin/services-section.tsx +++ b/src/components/admin/services-section.tsx @@ -1,6 +1,7 @@ "use client"; import { Bell, Globe, Key, MessageCircle, Smile } from "lucide-react"; +import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; import { SettingsToggle, useAdminSettings, useUpdateSettings } from "./_shared"; @@ -11,7 +12,7 @@ export function ServicesSection() { const updateSettings = useUpdateSettings(); return ( -
    +
    - + ); } diff --git a/src/components/admin/system-status-section.tsx b/src/components/admin/system-status-section.tsx index e14543a84..244087d7c 100644 --- a/src/components/admin/system-status-section.tsx +++ b/src/components/admin/system-status-section.tsx @@ -13,13 +13,14 @@ import { Key, Loader2, Map, - RotateCw, + RefreshCw, Server, Users, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { formatDateTime } from "@/lib/format"; +import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { StatusItem, usePublicVersion, useSystemStatus } from "./_shared"; @@ -51,7 +52,7 @@ export function SystemStatusSection() { return (
    -
    + ) : ( - + )} {t("admin.systemStatusRetry")} @@ -218,7 +219,7 @@ export function SystemStatusSection() {
    )} -
    + ); } diff --git a/src/components/admin/system-status-summary.tsx b/src/components/admin/system-status-summary.tsx index 72583f105..c21a0ed3e 100644 --- a/src/components/admin/system-status-summary.tsx +++ b/src/components/admin/system-status-summary.tsx @@ -23,6 +23,7 @@ import { Tag, } from "lucide-react"; import { formatDateTime } from "@/lib/format"; +import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; import { StatusItem, usePublicVersion, useSystemStatus } from "./_shared"; @@ -33,9 +34,9 @@ export function SystemStatusSummary() { const { data: version } = usePublicVersion(); return ( -
    )} -
    + ); } diff --git a/src/components/admin/umami-section.tsx b/src/components/admin/umami-section.tsx index 6a92dd0a8..b19d6327a 100644 --- a/src/components/admin/umami-section.tsx +++ b/src/components/admin/umami-section.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { useMutation } from "@tanstack/react-query"; import { Activity, Loader2 } from "lucide-react"; import { toast } from "sonner"; +import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -72,7 +73,7 @@ export function UmamiSection() { } return ( -
    +
    - + ); } diff --git a/src/components/admin/user-management-section.tsx b/src/components/admin/user-management-section.tsx index 320618621..f9412c43b 100644 --- a/src/components/admin/user-management-section.tsx +++ b/src/components/admin/user-management-section.tsx @@ -30,7 +30,9 @@ import { } from "@/components/ui/alert-dialog"; import { useAuth } from "@/hooks/use-auth"; import { formatDate } from "@/lib/format"; +import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { ListRow } from "@/components/ui/list-row"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { type AdminUser, PasswordInput } from "./_shared"; @@ -253,7 +255,7 @@ export function UserManagementSection() { ); return ( -
    + {filteredUsers.map((u) => ( -
  • -
    -
    -
    - - {u.username} - - - {u.role} - +
  • +
    +
    +
    + + {u.username} + + + {u.role} + +
    +

    + {u.email || "—"} +

    +

    + {t("admin.userPasskeys")}: {u.passkeyCount} ·{" "} + {formatDate(u.createdAt)} +

    -

    - {u.email || "—"} -

    -

    - {t("admin.userPasskeys")}: {u.passkeyCount} ·{" "} - {formatDate(u.createdAt)} -

    -
  • -
    - {renderUserActions(u)} -
    - +
    + {renderUserActions(u)} +
    + + ))} @@ -594,6 +601,6 @@ export function UserManagementSection() { - + ); } diff --git a/src/components/admin/version-tile-section.tsx b/src/components/admin/version-tile-section.tsx index fd7e0eda3..7389d6c0b 100644 --- a/src/components/admin/version-tile-section.tsx +++ b/src/components/admin/version-tile-section.tsx @@ -25,6 +25,7 @@ import { } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; import { formatDateTime } from "@/lib/format"; +import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -82,10 +83,7 @@ export function VersionTileSection() { const sha = shortSha(version?.buildSha ?? null); return ( -
    + -
    + ); } diff --git a/src/components/admin/web-push-vapid-section.tsx b/src/components/admin/web-push-vapid-section.tsx index 16092aa65..c3acb4213 100644 --- a/src/components/admin/web-push-vapid-section.tsx +++ b/src/components/admin/web-push-vapid-section.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { BellRing, KeyRound, Loader2 } from "lucide-react"; +import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { Button } from "@/components/ui/button"; import { @@ -119,7 +120,7 @@ export function WebPushVapidSection() { } return ( -
    + -
    + ); } From 9aa0a3597a9327739f8a3cf1edb9b06f4ab38fbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 1 Jul 2026 23:45:08 +0200 Subject: [PATCH 05/16] fix(api): constrain client-writable sources and batch timestamps Narrow the workout create schema's source to the client-writable set (MANUAL, APPLE_HEALTH), matching the measurement write surfaces, so a client cannot forge a server-owned source into the cross-source picker. Apply the single-entry plausibility bound to the measurement batch timestamps so a backdated or future-dated row can no longer slip past the batch path. Regenerate the OpenAPI contract. --- docs/api/openapi.yaml | 10 ---------- src/app/api/measurements/batch/route.ts | 20 +++++++++++++++++-- src/lib/validations/__tests__/workout.test.ts | 12 +++++++++++ src/lib/validations/workout.ts | 10 ++++++++-- 4 files changed, 38 insertions(+), 14 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 73ee36bbd..e553979c5 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2664,17 +2664,7 @@ paths: type: string enum: - MANUAL - - WITHINGS - - IMPORT - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP externalId: type: string maxLength: 128 diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index 3748b429c..aac262593 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -60,6 +60,7 @@ import { type MergeCandidate, } from "@/lib/measurements/cross-source-merge"; import { validateMeasurementRange } from "@/lib/validations/measurement"; +import { isPlausibleEntryInstant } from "@/lib/validations/entry-instant"; import { deviceTypeEnum } from "@/lib/validations/source-priority"; import { checkRateLimit } from "@/lib/rate-limit"; import { enqueuePrDetection } from "@/lib/jobs/pr-detection"; @@ -115,8 +116,23 @@ const batchEntrySchema = z.object({ hkIdentifier: z.string().min(1).max(120), value: z.number().finite(), unit: z.string().min(1).max(60), - startDate: z.iso.datetime({ offset: true }), - endDate: z.iso.datetime({ offset: true }), + // Same plausibility bound the single-entry POST applies to `measuredAt` + // via `validateEntryInstant` (shared `entry-instant` helper): no future + // instant beyond a 5-min clock-skew tolerance, nothing before 1900. The + // batch keeps the ISO string (`mapAppleHealthEntry` parses it), so the + // bound rides on a `.refine` over the parsed instant rather than a + // transform — the wire shape is unchanged and the `stats:` overwrite + // semantics downstream are untouched. + startDate: z.iso + .datetime({ offset: true }) + .refine((s) => isPlausibleEntryInstant(new Date(s)), { + message: "startDate must not be in the future or predate 1900", + }), + endDate: z.iso + .datetime({ offset: true }) + .refine((s) => isPlausibleEntryInstant(new Date(s)), { + message: "endDate must not be in the future or predate 1900", + }), sleepStage: z.number().int().min(0).max(20).optional(), // v1.10.0 — categorical events (WX-B). The integer `HKCategoryValue` // codepoint for an EVENT-class category sample (irregular-rhythm, diff --git a/src/lib/validations/__tests__/workout.test.ts b/src/lib/validations/__tests__/workout.test.ts index dd7904542..5ca5e9812 100644 --- a/src/lib/validations/__tests__/workout.test.ts +++ b/src/lib/validations/__tests__/workout.test.ts @@ -217,6 +217,18 @@ describe("createWorkoutSchema", () => { ).toThrow(); }); + it("rejects a server-owned source a client cannot forge", () => { + // The client-writable set is exactly {MANUAL, APPLE_HEALTH}. WHOOP / + // FITBIT rows are minted server-side by the sync workers; letting a + // client attribute a row to them would pollute the per-source canonical + // workout picker with rows the integration never produced. + for (const forged of ["WHOOP", "FITBIT", "WITHINGS", "COMPUTED"]) { + expect(() => + createWorkoutSchema.parse({ ...minimalRun, source: forged }), + ).toThrow(); + } + }); + it("rejects a max heart rate below the resting floor", () => { expect(() => createWorkoutSchema.parse({ ...minimalRun, maxHeartRate: 5 }), diff --git a/src/lib/validations/workout.ts b/src/lib/validations/workout.ts index 2aa0636bd..dfd25584e 100644 --- a/src/lib/validations/workout.ts +++ b/src/lib/validations/workout.ts @@ -1,5 +1,5 @@ import { z } from "zod/v4"; -import { measurementSourceEnum } from "./measurement"; +import { writableMeasurementSourceEnum } from "./measurement"; /** * Workout sport-type union. @@ -166,7 +166,13 @@ export const createWorkoutSchema = z stepCount: z.number().int().min(0).max(200_000).optional(), elevationM: z.number().min(-500).max(10_000).optional(), pauseDurationSec: z.number().int().min(0).max(86_400).optional(), - source: measurementSourceEnum.optional().default("MANUAL"), + // Narrowed to the client-writable subset ({MANUAL, APPLE_HEALTH}) — the + // same allowlist the measurement writes use (`WRITABLE_MEASUREMENT_SOURCES` + // / `batchSourceEnum`). Server-owned sources (WHOOP, FITBIT) write directly + // via `prisma.workout.upsert` with a hardcoded `source`, never through this + // schema, so a client cannot forge a row attributed to an integration it + // does not own. + source: writableMeasurementSourceEnum.optional().default("MANUAL"), externalId: z.string().max(128).optional(), externalSourceVersion: z.string().max(64).optional(), metadata: z.record(z.string(), z.unknown()).optional(), From 336ce5aef32da033e86e7a71661acc233b8e365f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 1 Jul 2026 23:45:08 +0200 Subject: [PATCH 06/16] fix(api): narrow swallowed database errors and redact the incident URL The incident forwarder stripped the query string but not path-segment secrets from the reported URL; route it through the same redaction the message and stack already use. Several delete-and-swallow paths caught every error as the expected unique/absent-row case, masking real failures on security-notification, logout, and integration-disconnect paths and the Telegram reply-consume guard; narrow each to the specific Prisma code and let real failures surface. --- src/app/api/fitbit/credentials/route.ts | 9 ++++++- src/app/api/telegram/webhook/route.ts | 19 ++++++++++---- src/app/api/whoop/credentials/route.ts | 9 ++++++- src/app/api/withings/credentials/route.ts | 10 ++++++-- src/lib/api-handler.ts | 15 ++++++++--- src/lib/auth/__tests__/login-alert.test.ts | 25 +++++++++++++++++-- src/lib/auth/__tests__/session.test.ts | 29 +++++++++++++++++++++- src/lib/auth/login-alert.ts | 12 +++++++-- src/lib/auth/session.ts | 9 ++++++- src/lib/prisma-errors.ts | 21 +++++++++++++--- 10 files changed, 136 insertions(+), 22 deletions(-) diff --git a/src/app/api/fitbit/credentials/route.ts b/src/app/api/fitbit/credentials/route.ts index 9ecb39a6e..bef303a15 100644 --- a/src/app/api/fitbit/credentials/route.ts +++ b/src/app/api/fitbit/credentials/route.ts @@ -1,5 +1,6 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { isP2025 } from "@/lib/prisma-errors"; import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; @@ -71,9 +72,15 @@ export const DELETE = apiHandler(async () => { const { user } = await requireAuth(); annotate({ action: { name: "fitbit.credentials.delete" } }); + // A missing connection row (P2025) is a benign "already disconnected" + // no-op; any other failure must propagate rather than be swallowed — + // otherwise the encrypted OAuth tokens can be left orphaned while the user + // is told the integration was disconnected. await prisma.fitbitConnection .delete({ where: { userId: user.id } }) - .catch(() => {}); + .catch((err) => { + if (!isP2025(err)) throw err; + }); await prisma.user.update({ where: { id: user.id }, diff --git a/src/app/api/telegram/webhook/route.ts b/src/app/api/telegram/webhook/route.ts index 7de67e696..d6e9f1555 100644 --- a/src/app/api/telegram/webhook/route.ts +++ b/src/app/api/telegram/webhook/route.ts @@ -1,5 +1,6 @@ import { timingSafeEqual } from "node:crypto"; import { prisma } from "@/lib/db"; +import { isP2025 } from "@/lib/prisma-errors"; import { decrypt } from "@/lib/crypto"; import { answerTelegramCallbackQuery, @@ -945,11 +946,19 @@ async function handleTextMessage(update: TelegramUpdate) { context.userId === user.id && (context.kind === "mood_note" || context.kind === "measure_value") ) { - // Consume the context row regardless of outcome so a reply can't be - // replayed onto the entry twice. - await prisma.telegramPromptContext - .delete({ where: { id: context.id } }) - .catch(() => {}); + // The delete IS the replay guard: consuming the context row is what + // stops a redelivered reply from being applied to the entry twice. Make + // it authoritative — if the delete fails for any reason OTHER than the + // row already being gone (P2025), do NOT apply the reply; abort and let + // Telegram redeliver against a still-present row. P2025 means the row + // was already consumed, which is the guard succeeding, so proceed. + try { + await prisma.telegramPromptContext.delete({ + where: { id: context.id }, + }); + } catch (err) { + if (!isP2025(err)) return; + } const userMsgId = message?.message_id; if (context.expiresAt.getTime() < Date.now()) { const resp = await sendTelegramMessage( diff --git a/src/app/api/whoop/credentials/route.ts b/src/app/api/whoop/credentials/route.ts index 6675fa11e..ffc7e9183 100644 --- a/src/app/api/whoop/credentials/route.ts +++ b/src/app/api/whoop/credentials/route.ts @@ -1,5 +1,6 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { isP2025 } from "@/lib/prisma-errors"; import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; @@ -69,9 +70,15 @@ export const DELETE = apiHandler(async () => { const { user } = await requireAuth(); annotate({ action: { name: "whoop.credentials.delete" } }); + // A missing connection row (P2025) is a benign "already disconnected" + // no-op; any other failure must propagate rather than be swallowed — + // otherwise the encrypted OAuth tokens can be left orphaned while the user + // is told the integration was disconnected. await prisma.whoopConnection .delete({ where: { userId: user.id } }) - .catch(() => {}); + .catch((err) => { + if (!isP2025(err)) throw err; + }); await prisma.user.update({ where: { id: user.id }, diff --git a/src/app/api/withings/credentials/route.ts b/src/app/api/withings/credentials/route.ts index 0bd669f86..429514f95 100644 --- a/src/app/api/withings/credentials/route.ts +++ b/src/app/api/withings/credentials/route.ts @@ -1,6 +1,7 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { isP2025 } from "@/lib/prisma-errors"; import { annotate } from "@/lib/logging/context"; import { encrypt } from "@/lib/crypto"; import { withingsCredentialsSchema } from "@/lib/validations/withings"; @@ -64,10 +65,15 @@ export const DELETE = apiHandler(async () => { const { user } = await requireAuth(); annotate({ action: { name: "withings.credentials.delete" } }); - // Remove connection first + // Remove connection first. A missing connection row (P2025) is a benign + // "already disconnected" no-op; any other failure must propagate rather + // than be swallowed — otherwise the encrypted OAuth tokens can be left + // orphaned while the user is told the integration was disconnected. await prisma.withingsConnection .delete({ where: { userId: user.id } }) - .catch(() => {}); + .catch((err) => { + if (!isP2025(err)) throw err; + }); // Remove credentials await prisma.user.update({ diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index 0250bbe3c..3de3383e9 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -340,9 +340,11 @@ export async function requireAuth( * * The validation itself lives in the transport-agnostic `resolveBearerToken` * (`./auth/bearer`) — the single source of truth shared with the MCP wire. This - * wrapper adds the HTTP-edge concerns: the `auth.bearer.*` audit trail for both - * outcomes and the Wide-Event `auth_method: "bearer"` annotation, then maps the - * result onto the `AuthContext` contract (`session.id` carries the token id). + * wrapper adds the HTTP-edge concerns: the `auth.bearer.failure` audit trail + * (only the failure path writes a durable audit row — the success row was + * intentionally dropped for perf) and the Wide-Event `auth_method: "bearer"` + * annotation, then maps the result onto the `AuthContext` contract + * (`session.id` carries the token id). * * Authorisation contract (unchanged): a route that declares no * `requiredPermission` accepts any valid token; one that declares a scope @@ -648,7 +650,12 @@ async function reportToGlitchtip( level: "error", type: err.name || "Error", stack: redactOptional(err.stack), - url: scrubbedUrl, + // Query string is already stripped above, but path-segment secrets + // (e.g. `/api/withings/webhook/`, `/api/whoop/webhook/`) + // survive that strip. Run the same `redactSecrets` pass that guards the + // message/stack so a `PATH_SECRET_PATHS`-registered secret cannot reach + // the external incident UI. + url: redactSecrets(scrubbedUrl), sourceTag: "healthlog-api-handler", requestId: evt.getRequestId(), }, diff --git a/src/lib/auth/__tests__/login-alert.test.ts b/src/lib/auth/__tests__/login-alert.test.ts index a4ab165a9..34bc65e09 100644 --- a/src/lib/auth/__tests__/login-alert.test.ts +++ b/src/lib/auth/__tests__/login-alert.test.ts @@ -117,10 +117,10 @@ describe("recordSignInDevice — new-device dedupe", () => { expect(dispatchNotification).not.toHaveBeenCalled(); }); - it("a unique-index race on create does not double-alert", async () => { + it("a unique-index race (P2002) on create does not double-alert", async () => { vi.mocked(prisma.userKnownDevice.findUnique).mockResolvedValue(null); vi.mocked(prisma.userKnownDevice.create).mockRejectedValue( - new Error("unique violation"), + Object.assign(new Error("unique violation"), { code: "P2002" }), ); const res = await recordSignInDevice({ @@ -133,6 +133,27 @@ describe("recordSignInDevice — new-device dedupe", () => { expect(dispatchNotification).not.toHaveBeenCalled(); }); + it("a non-P2002 create failure is NOT masked as a known device", async () => { + // A real DB fault must not be misclassified as "device already known" — + // that would silently skip the SECURITY_ALERT + audit row. The narrowed + // catch rethrows it to the outer handler, which records the failure and + // returns the safe `{ known: false, alerted: false }` verdict without + // dispatching a bogus alert. + vi.mocked(prisma.userKnownDevice.findUnique).mockResolvedValue(null); + vi.mocked(prisma.userKnownDevice.create).mockRejectedValue( + Object.assign(new Error("connection reset"), { code: "P1001" }), + ); + + const res = await recordSignInDevice({ + userId: "u1", + ip: "203.0.113.9", + userAgent: UA, + }); + + expect(res).toEqual({ known: false, alerted: false }); + expect(dispatchNotification).not.toHaveBeenCalled(); + }); + it("never persists the raw User-Agent — only the salted hash + coarse label", async () => { vi.mocked(prisma.userKnownDevice.findUnique).mockResolvedValue(null); vi.mocked(prisma.userKnownDevice.create).mockResolvedValue({} as never); diff --git a/src/lib/auth/__tests__/session.test.ts b/src/lib/auth/__tests__/session.test.ts index e07eb858d..57a01f035 100644 --- a/src/lib/auth/__tests__/session.test.ts +++ b/src/lib/auth/__tests__/session.test.ts @@ -10,6 +10,7 @@ vi.mock("@/lib/db", () => ({ prisma: { session: { findUnique: vi.fn(), + delete: vi.fn(), deleteMany: vi.fn(), update: vi.fn(), }, @@ -46,7 +47,7 @@ vi.mock("next/headers", () => ({ })); import { prisma } from "@/lib/db"; -import { getSession, destroyAllSessions } from "../session"; +import { getSession, destroyAllSessions, destroySession } from "../session"; beforeEach(() => { vi.resetAllMocks(); @@ -74,6 +75,32 @@ describe("getSession", () => { }); }); +describe("destroySession", () => { + it("treats an already-deleted session row (P2025) as an idempotent logout", async () => { + cookieState.sessionId = "sess-gone"; + vi.mocked(prisma.session.delete).mockRejectedValue( + Object.assign(new Error("record not found"), { code: "P2025" }) as never, + ); + + await expect(destroySession()).resolves.toBeUndefined(); + + expect(prisma.session.delete).toHaveBeenCalledWith({ + where: { id: "sess-gone" }, + }); + expect(cookieState.delete).toHaveBeenCalledWith("healthlog_session"); + expect(cookieState.delete).toHaveBeenCalledWith("hl_onboarding"); + }); + + it("propagates a real delete failure instead of reporting a false logout", async () => { + cookieState.sessionId = "sess-live"; + vi.mocked(prisma.session.delete).mockRejectedValue( + Object.assign(new Error("connection reset"), { code: "P1001" }) as never, + ); + + await expect(destroySession()).rejects.toThrow(/connection reset/); + }); +}); + describe("destroyAllSessions", () => { it("revokes web sessions, API tokens, and refresh tokens for the user", async () => { vi.mocked(prisma.session.deleteMany).mockResolvedValue({ diff --git a/src/lib/auth/login-alert.ts b/src/lib/auth/login-alert.ts index 1cad93740..6a2a8b309 100644 --- a/src/lib/auth/login-alert.ts +++ b/src/lib/auth/login-alert.ts @@ -24,6 +24,7 @@ import { dispatchNotification } from "@/lib/notifications/dispatcher"; import { getServerTranslator } from "@/lib/i18n/server-translator"; import { defaultLocale, type Locale } from "@/lib/i18n/config"; import { getEvent } from "@/lib/logging/context"; +import { isP2002 } from "@/lib/prisma-errors"; function isLocale(value: string | null | undefined): value is Locale { return ( @@ -105,8 +106,15 @@ export async function recordSignInDevice(params: { await prisma.userKnownDevice.create({ data: { userId, deviceHash, label: storedLabel }, }); - } catch { - return { known: true, alerted: false }; + } catch (err) { + // ONLY the unique-index race (a concurrent first-login inserting the + // same (userId, deviceHash)) is a legitimate "already known" outcome — + // swallow it and skip the alert so we never double-alert. Any other + // failure (a real DB fault) must NOT be silently classified as a known + // device, which would suppress the SECURITY_ALERT + audit row: rethrow + // so the outer catch records it via `addWarning`. + if (isP2002(err)) return { known: true, alerted: false }; + throw err; } if (!alertOnNew || isBaseline) return { known: false, alerted: false }; diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts index 61511b4f2..7dd461668 100644 --- a/src/lib/auth/session.ts +++ b/src/lib/auth/session.ts @@ -4,6 +4,7 @@ import type { User } from "@/generated/prisma/client"; import { ensureDbCompatibility } from "@/lib/db-compat"; import { getEvent } from "@/lib/logging/context"; import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; +import { isP2025 } from "@/lib/prisma-errors"; const SESSION_COOKIE = "healthlog_session"; const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days @@ -179,7 +180,13 @@ export async function destroySession(): Promise { const cookieStore = await cookies(); const sessionId = cookieStore.get(SESSION_COOKIE)?.value; if (sessionId) { - await prisma.session.delete({ where: { id: sessionId } }).catch(() => {}); + // Logout is idempotent: a session row that no longer exists (P2025) is a + // no-op success — the cookie clear below still runs. Any other failure is + // a real delete error that must NOT be masked as a successful logout, so + // it propagates rather than being swallowed. + await prisma.session.delete({ where: { id: sessionId } }).catch((err) => { + if (!isP2025(err)) throw err; + }); } cookieStore.delete(SESSION_COOKIE); cookieStore.delete(ONBOARDING_COOKIE); diff --git a/src/lib/prisma-errors.ts b/src/lib/prisma-errors.ts index bf6096656..67745ad7a 100644 --- a/src/lib/prisma-errors.ts +++ b/src/lib/prisma-errors.ts @@ -13,12 +13,27 @@ * unique-constraint code. */ -/** Narrow a thrown Prisma error to the P2002 unique-constraint code. */ -export function isP2002(err: unknown): boolean { +/** Narrow a thrown Prisma error to a specific known-request code. */ +function hasPrismaCode(err: unknown, code: string): boolean { return ( typeof err === "object" && err !== null && "code" in err && - (err as { code: unknown }).code === "P2002" + (err as { code: unknown }).code === code ); } + +/** Narrow a thrown Prisma error to the P2002 unique-constraint code. */ +export function isP2002(err: unknown): boolean { + return hasPrismaCode(err, "P2002"); +} + +/** + * Narrow a thrown Prisma error to the P2025 "record not found" code — the + * error `delete` / `update` raise when the target row no longer exists. Lets + * an idempotent delete treat "already gone" as success while still surfacing a + * genuine failure (a real DB fault raises a different code and rethrows). + */ +export function isP2025(err: unknown): boolean { + return hasPrismaCode(err, "P2025"); +} From 887c79ec865f74ca6e3a416a8266d87ca71413ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 2 Jul 2026 00:00:37 +0200 Subject: [PATCH 07/16] refactor(ui): unify page headers and surface read failures Add a PageHeader primitive (one H1 vocabulary, optional description, back-link, and action slots) and route the hand-rolled headers through it, so every module reads with the same title/subtitle rhythm and no icon beside the page title. Mental Wellbeing gains a visible header. Add a QueryErrorCard and wire the list surfaces so a failed read shows a retry card instead of falling through to the empty state, and swap their full-page spinners for skeletons that hold the layout. --- .../__tests__/dashboard-hero-wiring.test.ts | 2 +- src/app/achievements/page.tsx | 31 ++- src/app/coach/conversations/page.tsx | 15 +- src/app/labs/page.tsx | 122 ++++++------ src/app/measurements/page.tsx | 27 +-- src/app/mood/page.tsx | 76 ++++---- src/app/notifications/page.tsx | 35 ++-- src/components/cycle/cycle-view.tsx | 22 +-- src/components/dashboard/dashboard-header.tsx | 182 +++++++++--------- src/components/illness/illness-view.tsx | 104 ++++------ src/components/labs/lab-list.tsx | 12 +- .../measurements/measurement-list.tsx | 14 +- .../mental-health/mental-wellbeing.tsx | 15 +- src/components/mood/mood-list.tsx | 32 ++- src/components/ui/page-header.tsx | 43 +++++ src/components/ui/query-error-card.tsx | 59 ++++++ 16 files changed, 413 insertions(+), 378 deletions(-) create mode 100644 src/components/ui/page-header.tsx create mode 100644 src/components/ui/query-error-card.tsx diff --git a/src/app/__tests__/dashboard-hero-wiring.test.ts b/src/app/__tests__/dashboard-hero-wiring.test.ts index 898830436..c5f562cd0 100644 --- a/src/app/__tests__/dashboard-hero-wiring.test.ts +++ b/src/app/__tests__/dashboard-hero-wiring.test.ts @@ -74,7 +74,7 @@ describe("dashboard hero — header handoff", () => { // prop gates the paragraph, and the `min-h-5` line-box reservation // keeps the header stable through the post-hydration name swap. expect(header).toContain("showGreeting"); - expect(header).toMatch(/\{showGreeting\s*\?/); + expect(header).toMatch(/\{\s*showGreeting\s*\?/); expect(header).toContain("min-h-5"); expect(header).toContain('data-slot="dashboard-header-greeting"'); }); diff --git a/src/app/achievements/page.tsx b/src/app/achievements/page.tsx index 40a60c63f..6e4c2fae1 100644 --- a/src/app/achievements/page.tsx +++ b/src/app/achievements/page.tsx @@ -32,6 +32,7 @@ import { import { Progress } from "@/components/ui/progress"; import { Badge } from "@/components/ui/badge"; import { EmptyState } from "@/components/ui/empty-state"; +import { PageHeader } from "@/components/ui/page-header"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { formatDate } from "@/lib/format"; @@ -293,12 +294,10 @@ export default function AchievementsPage() { if (!isAuthenticated) { return (
    -

    - {t("achievements.title")} -

    -

    - {t("achievements.loginRequired")} -

    +
    ); } @@ -313,20 +312,14 @@ export default function AchievementsPage() { return (
    -
    -
    -

    + {t("achievements.title")} -

    -

    - {t("achievements.subtitle")} -

    -
    -
    -
    + + } + description={t("achievements.subtitle")} + />
    diff --git a/src/app/coach/conversations/page.tsx b/src/app/coach/conversations/page.tsx index dfa955015..c2677c2d7 100644 --- a/src/app/coach/conversations/page.tsx +++ b/src/app/coach/conversations/page.tsx @@ -6,6 +6,7 @@ import { MessagesSquare, Search, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { PageHeader } from "@/components/ui/page-header"; import { COACH_SCROLLBAR } from "@/components/insights/coach-panel/message-thread"; import { useCoachConversations, @@ -122,13 +123,13 @@ function CoachConversationsBody() { return (
    -

    -

    + + {t("insights.coach.historyTitle")} + + } + />
    ); } @@ -146,14 +145,10 @@ export default function NotificationsPage() { if (isError) { return (
    -
    -

    - {t("notifications.title")} -

    -

    - {t("notifications.subtitle")} -

    -
    +
    -
    -

    - {t("notifications.title")} -

    -

    - {t("notifications.subtitle")} -

    -
    +
    diff --git a/src/components/cycle/cycle-view.tsx b/src/components/cycle/cycle-view.tsx index 7489fd3c1..7016b61cd 100644 --- a/src/components/cycle/cycle-view.tsx +++ b/src/components/cycle/cycle-view.tsx @@ -6,6 +6,7 @@ import { Loader2, Plus, RefreshCw, Sparkles } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; +import { PageHeader } from "@/components/ui/page-header"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; @@ -125,19 +126,10 @@ export function CycleView() { return (
    -
    -
    -

    - {t("cycle.title")} -

    -

    - {t("cycle.subtitle")} -

    -
    -
    + {t("cycle.title")}} + description={t("cycle.subtitle")} + actions={ -
    -
    + } + /> {/* Desktop: ring (left) + tabs (right). Single column below lg. */}
    diff --git a/src/components/dashboard/dashboard-header.tsx b/src/components/dashboard/dashboard-header.tsx index 72d0b7540..7d686cac7 100644 --- a/src/components/dashboard/dashboard-header.tsx +++ b/src/components/dashboard/dashboard-header.tsx @@ -22,11 +22,11 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { PageHeader } from "@/components/ui/page-header"; import { useTranslations } from "@/lib/i18n/context"; import { useAuth } from "@/hooks/use-auth"; import { useMounted } from "@/hooks/use-mounted"; import { getHourForTimeZone } from "@/components/dashboard/range-display"; -import { cn } from "@/lib/utils"; import type { QuickEntryDialog } from "@/components/dashboard/quick-entry-sheets"; export function DashboardHeader({ @@ -68,106 +68,102 @@ export function DashboardHeader({ : t("dashboard.welcomeBack", { greeting: timeGreeting }); return ( - /* With the greeting the title block is two lines on narrow - viewports: centre the buttons against it on mobile, top-align on - sm+ (the pre-hero posture). Without the greeting the single-line - centre alignment stands. */ -
    -
    -

    - {t("dashboard.title")} -

    - {showGreeting ? ( + {welcomeText} -

    - ) : null} -
    -
    - {/* Customize shortcut to the dashboard-customization settings - (the tile/layout editor at /settings/dashboard). Sits to - the left of the add button as a monochrome ghost icon, with - a `min-h-11 min-w-11` mobile floor so it meets the 44 px - touch-target contract the add button also honours, shrinking - back to the 40 px icon footprint on sm+. */} - - - - {/* v1.4.33 maintainer-item-7 — restore proportional sizing - across viewports. The v1.4.27 fix pinned a `size="sm" - min-h-11` combo to hit WCAG 2.5.5's 44 px touch-target - contract on mobile, but `size="sm"` is h-8 (32 px) and - the `min-h-11` override stretched the cap vertically - while keeping the small horizontal padding — the - button read as klobig on Pixel 5. Switch to - `size="default"` (h-10 = 40 px) on mobile with a - responsive `min-h-11 sm:min-h-9` so the button is - 44 px tall under finger pressure and shrinks back to - the desktop-friendly 36 px on `sm:` upwards. The icon - + label keep the same visual contract. */} - - - - {/* Menu items must each carry a self-contained verb-phrase - ("Log measurement", "Log mood") — the trigger above already - says "Add", and the icon is `aria-hidden`, so the visible - text is the only thing distinguishing the rows. v1.4.15 - phase-A3 fix #1 hardened this with a unit guard at - `src/app/__tests__/quick-add-labels.test.ts` — both labels - must differ from each other AND from `common.add`. */} - onQuickEntry("measurement")}> - - onQuickEntry("mood")}> - - {/* v1.4.37 W7b — third quick-add row: medication intake. - Same Sheet-on-mobile / Dialog-on-desktop primitive as - the other two; the menu label is a self-contained - verb-phrase so it doesn't collide with the trigger or - the sibling rows (cf. quick-add-labels.test.ts). */} - onQuickEntry("medicationIntake")}> - - - -
    -
    +
    - setBackfillEnd(e.target.value)} + onChange={setBackfillEnd} />
    diff --git a/src/components/settings/integrations/fitbit-card.tsx b/src/components/settings/integrations/fitbit-card.tsx index 5a5915f1b..c51bd675a 100644 --- a/src/components/settings/integrations/fitbit-card.tsx +++ b/src/components/settings/integrations/fitbit-card.tsx @@ -201,7 +201,7 @@ export function FitbitCard({ {t("settings.fitbitExperimentalBadge")} From 40859613aa7fcfcb769f3f260480ea236346e325 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 2 Jul 2026 00:16:02 +0200 Subject: [PATCH 10/16] fix(insights): bound the tiered rollup series to the requested window The week/month/year rollup read anchored on now and always returned the trailing window, so a historic from/to range (API and iOS callers) came back with buckets outside the requested span. Thread the resolved from/to bounds through so the read is bounded on both ends, matching the day-tier and live-SQL fallback contract; tier selection is unchanged. --- .../daily-series-read-tiered.test.ts | 5 +- src/lib/measurements/daily-series-read.ts | 7 +- .../__tests__/measurement-read-wmy.test.ts | 72 +++++++++++++++++-- src/lib/rollups/measurement-read-wmy.ts | 42 ++++++++--- 4 files changed, 108 insertions(+), 18 deletions(-) diff --git a/src/lib/measurements/__tests__/daily-series-read-tiered.test.ts b/src/lib/measurements/__tests__/daily-series-read-tiered.test.ts index dca7b0525..7a494c151 100644 --- a/src/lib/measurements/__tests__/daily-series-read-tiered.test.ts +++ b/src/lib/measurements/__tests__/daily-series-read-tiered.test.ts @@ -112,7 +112,10 @@ describe("readDailySeries — long-range tier step-up", () => { }); expect(mocks.readTieredRollupSeries).toHaveBeenCalledTimes(1); - expect(mocks.readTieredRollupSeries.mock.calls[0][0].windowDays).toBe(3650); + // v1.26.0 SEAM-N2 — the resolved `[from, to]` bounds are threaded so the + // tier reads the REQUESTED window, not a trailing "to now" slice. + expect(mocks.readTieredRollupSeries.mock.calls[0][0].from).toBe(from); + expect(mocks.readTieredRollupSeries.mock.calls[0][0].to).toBe(to); // Whole-history coverage: the earliest 2017 bucket survives. expect(result[0].measuredAt).toBe("2017-01-01T00:00:00.000Z"); expect(result).toHaveLength(2); diff --git a/src/lib/measurements/daily-series-read.ts b/src/lib/measurements/daily-series-read.ts index 8759c4e72..98cafad59 100644 --- a/src/lib/measurements/daily-series-read.ts +++ b/src/lib/measurements/daily-series-read.ts @@ -79,7 +79,12 @@ export async function readDailySeries(opts: { const tiered = await readTieredRollupSeries({ userId, type, - windowDays: windowDaysFull, + // v1.26.0 SEAM-N2 — pass the resolved `[from, to]` bounds so the tier + // reads the REQUESTED window (which may be entirely historic), not a + // trailing "to now" slice. `windowDaysFull` still gates entry above and + // drives the tier width inside the reader. + from, + to, priorityJson, }); if (tiered && tiered.rows.length > 0) { diff --git a/src/lib/rollups/__tests__/measurement-read-wmy.test.ts b/src/lib/rollups/__tests__/measurement-read-wmy.test.ts index 733937bb6..67826add9 100644 --- a/src/lib/rollups/__tests__/measurement-read-wmy.test.ts +++ b/src/lib/rollups/__tests__/measurement-read-wmy.test.ts @@ -296,12 +296,23 @@ describe("readBestGranularityRollups — cross-consumer routing parity", () => { * miss; and that the wire shape mirrors the daily reader. */ describe("readTieredRollupSeries", () => { + const DAY_MS = 86_400_000; + // A fixed "now-ish" anchor for the current-window cases; the historic case + // uses its own explicit past bounds. Nothing in the reader reads + // `Date.now()` any more, so these are just span endpoints. + const NOW = new Date("2026-06-21T00:00:00.000Z"); + const win = (days: number, to: Date = NOW) => ({ + from: new Date(to.getTime() - days * DAY_MS), + to, + }); + it("returns null on a non-positive window without touching the db", async () => { expect( await readTieredRollupSeries({ userId: "u", type: "WEIGHT", - windowDays: 0, + from: NOW, + to: NOW, }), ).toBeNull(); expect(findMany).not.toHaveBeenCalled(); @@ -320,7 +331,7 @@ describe("readTieredRollupSeries", () => { const result = await readTieredRollupSeries({ userId: "u", type: "WEIGHT", - windowDays: 3650, + ...win(3650), }); expect(result).not.toBeNull(); @@ -332,12 +343,59 @@ describe("readTieredRollupSeries", () => { expect(result?.rows.length).toBe(10); }); + // v1.26.0 SEAM-N2 — a historic window entirely in the past must bound the + // rollup read on BOTH ends. Pre-fix the reader anchored on `now − windowDays` + // and would have queried `bucketStart >= now − 730d` (mid-2024 onward), + // returning recent buckets for a 2020–2022 ask. + it("bounds the rollup read on BOTH ends for a historic past window", async () => { + const from = new Date("2020-01-01T00:00:00.000Z"); + const to = new Date("2022-01-01T00:00:00.000Z"); + const inWindow: RollupBucketRow[] = [ + bucket("2020-02-01T00:00:00.000Z", { count: 12, mean: 80 }), + bucket("2021-06-01T00:00:00.000Z", { count: 12, mean: 81 }), + bucket("2021-12-01T00:00:00.000Z", { count: 12, mean: 82 }), + ]; + findMany.mockResolvedValueOnce(inWindow); + + const result = await readTieredRollupSeries({ + userId: "u", + type: "WEIGHT", + from, + to, + }); + + // The `bucketStart` filter carries BOTH the requested lower AND upper + // bound — not a trailing "to now" slice. + const where = findMany.mock.calls[0][0].where; + expect(where.bucketStart).toEqual({ gte: from, lte: to }); + // Every returned bucket_start falls inside the requested window. + for (const row of result?.rows ?? []) { + const t = new Date(row.measuredAt).getTime(); + expect(t).toBeGreaterThanOrEqual(from.getTime()); + expect(t).toBeLessThanOrEqual(to.getTime()); + } + // Tier width is 2 years (731 days) → still the MONTH tier for >730 d. + expect(result?.granularity).toBe("MONTH"); + }); + + // The web-chart "All" path passes `to ≈ now`; prove that case still bounds + // on both ends (no regression) — the historic fix must not perturb it. + it("bounds a current-window (to = now) read on both ends too", async () => { + findMany.mockResolvedValueOnce([bucket("2026-01-01T00:00:00.000Z")]); + const { from, to } = win(3650); + await readTieredRollupSeries({ userId: "u", type: "WEIGHT", from, to }); + expect(findMany.mock.calls[0][0].where.bucketStart).toEqual({ + gte: from, + lte: to, + }); + }); + it("reads the WEEK tier for a 1–2 year window", async () => { findMany.mockResolvedValueOnce([bucket("2025-08-04T00:00:00.000Z")]); const result = await readTieredRollupSeries({ userId: "u", type: "WEIGHT", - windowDays: 540, + ...win(540), }); expect(result?.granularity).toBe("WEEK"); expect(findMany.mock.calls[0][0].where.granularity).toBe("WEEK"); @@ -351,7 +409,7 @@ describe("readTieredRollupSeries", () => { const result = await readTieredRollupSeries({ userId: "u", type: "WEIGHT", - windowDays: 3650, + ...win(3650), }); expect(result?.granularity).toBe("DAY"); expect(findMany.mock.calls[0][0].where.granularity).toBe("MONTH"); @@ -364,7 +422,7 @@ describe("readTieredRollupSeries", () => { const result = await readTieredRollupSeries({ userId: "u", type: "WEIGHT", - windowDays: 3650, + ...win(3650), }); expect(result).toBeNull(); // MONTH, WEEK, DAY all probed (never coarser than the target). @@ -384,7 +442,7 @@ describe("readTieredRollupSeries", () => { const result = await readTieredRollupSeries({ userId: "u", type: "ACTIVITY_STEPS", - windowDays: 3650, + ...win(3650), }); expect(result?.rows[0].value).toBe(240_000); expect(result?.rows[0].minValue).toBeUndefined(); @@ -403,7 +461,7 @@ describe("readTieredRollupSeries", () => { const result = await readTieredRollupSeries({ userId: "u", type: "WEIGHT", - windowDays: 3650, + ...win(3650), }); expect(result?.rows[0].value).toBe(81.5); expect(result?.rows[0].minValue).toBe(78); diff --git a/src/lib/rollups/measurement-read-wmy.ts b/src/lib/rollups/measurement-read-wmy.ts index 46eb1235d..779adf443 100644 --- a/src/lib/rollups/measurement-read-wmy.ts +++ b/src/lib/rollups/measurement-read-wmy.ts @@ -164,6 +164,10 @@ export async function readBestGranularityRollups( type, floor.granularity, since, + // Trailing-window semantics: no upper bound. This router serves the + // "last N days to now" probes (summaries-slice / health-score); the + // requested-window bounding lives on `readTieredRollupSeries`. + null, priority, ); if (rows && rows.length > 0) { @@ -229,20 +233,27 @@ async function readGranularity( userId: string, type: MeasurementType, granularity: RollupGranularity, - since: Date, + from: Date, + to: Date | null, userPriorityJson: unknown, ): Promise { // Bounded `findMany`: `(userId, type, granularity, bucketStart, source)` // is the composite primary key so the planner picks the index path - // every time. `bucketStart >= since` is the same shape - // `readRollupBuckets` uses; we don't carry an upper bound because - // the helpers are always reading "trailing window to now". + // every time. When `to` is supplied the `bucketStart` filter is bounded + // on BOTH ends (`gte from` AND `lte to`) so a caller asking for an + // arbitrary historic window — `[2020-01-01, 2022-01-01]` — reads the + // buckets INSIDE that window rather than the trailing "to now" slice. + // This mirrors the DAY-tier `readRollup` in `daily-series-read.ts` + // (`bucketStart: { gte: from, lte: to }`) so the tiered read reproduces + // the same window contract the live-SQL fallback bounds on both ends. + // `to === null` keeps the legacy trailing-window semantics for the + // `readBestGranularityRollups` router. const rows = await prisma.measurementRollup.findMany({ where: { userId, type, granularity, - bucketStart: { gte: since }, + bucketStart: to === null ? { gte: from } : { gte: from, lte: to }, }, orderBy: { bucketStart: "asc" }, select: { @@ -346,20 +357,32 @@ const TIER_ORDER: RollupGranularity[] = ["DAY", "WEEK", "MONTH", "YEAR"]; * (≤ ~104 weeks for the WEEK tier, ≤ ~12 months/year for MONTH). If a * future tier ever risks an unbounded count it must step coarser rather * than truncate; no silent cap lives on this path. + * + * v1.26.0 SEAM-N2 — the reader is bounded to the REQUESTED `[from, to]` + * window on BOTH ends, not a trailing "now − windowDays … now" slice. + * The caller passes arbitrary ISO instants (a historic "All" range whose + * `to` need not be ≈ now), so anchoring on `Date.now()` returned the wrong + * buckets entirely. The tier SELECTION still keys off the window WIDTH + * (`windowDays`, derived from the span) — only the window the tier READS + * changed. This matches the DAY-tier `readRollup` + the live-SQL + * `readLiveDaily` fallback, both of which bound on `[from, to]`. */ export async function readTieredRollupSeries(opts: { userId: string; type: MeasurementType; - windowDays: number; + from: Date; + to: Date; priorityJson?: unknown; }): Promise<{ granularity: RollupGranularity; rows: TieredSeriesRow[]; } | null> { - const { userId, type, windowDays, priorityJson } = opts; + const { userId, type, from, to, priorityJson } = opts; + // Tier selection keys off the window WIDTH; derive it the same way the + // caller (`daily-series-read`) does so the chosen tier is identical. + const windowDays = Math.ceil((to.getTime() - from.getTime()) / 86_400_000); if (!Number.isFinite(windowDays) || windowDays <= 0) return null; - const since = new Date(Date.now() - windowDays * 24 * 60 * 60 * 1000); const priority = priorityJson !== undefined ? priorityJson @@ -375,7 +398,8 @@ export async function readTieredRollupSeries(opts: { userId, type, granularity, - since, + from, + to, priority, ); if (rows && rows.length > 0) { From fda0e504f069fcce1f826acc52363b2efe624d80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 2 Jul 2026 00:16:03 +0200 Subject: [PATCH 11/16] fix(insights): compute the adherence storyline from the schedule-anchored engine The Coach adherence storyline derived its rate from the raw coverage rollup, whose denominator counts logged slots rather than scheduled doses, so a user who logged only taken doses read ~100% and the dip gate suppressed a real decline. Extract the dashboard tile's schedule-anchored, cadence-aware compliance into one shared engine and route both surfaces through it, so Coach and the dashboard report the same number. --- src/app/api/medications/intake/route.ts | 168 +-------------- .../analytics/schedule-anchored-compliance.ts | 171 +++++++++++++++ .../adherence-storyline-engine-parity.test.ts | 196 ++++++++++++++++++ .../insights/derived/adherence-storyline.ts | 18 +- 4 files changed, 387 insertions(+), 166 deletions(-) create mode 100644 src/lib/analytics/schedule-anchored-compliance.ts create mode 100644 src/lib/insights/derived/__tests__/adherence-storyline-engine-parity.test.ts diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index d50e5766c..fb7f2bd4c 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -25,16 +25,15 @@ import { import { annotate } from "@/lib/logging/context"; import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; -import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; import { - buildComplianceMedicationContext, - expectedSlotCountForDay, - lastNonSkippedTakenAt, -} from "@/lib/analytics/compliance"; -import { getUserTodayBounds, startOfLocalDayInTz } from "@/lib/tz/local-day"; + buildScheduleAnchoredComplianceBuckets, + type ScheduleAnchoredComplianceBucket, +} from "@/lib/analytics/schedule-anchored-compliance"; +import { startOfLocalDayInTz } from "@/lib/tz/local-day"; import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/project-today-intakes"; import { applyCanonicalSlotWrite, @@ -204,7 +203,7 @@ export const GET = apiHandler(async (request: NextRequest) => { // makes the rate genuinely reflect taken-of-expected and lets the 7/30/90 // windows diverge with partial adherence. const result = await cached( - caches.medicationsIntake as ServerCache, + caches.medicationsIntake as ServerCache, `${user.id}|compliance|${days}|${userTz}`, () => buildScheduleAnchoredComplianceBuckets(user.id, days, userTz), annotate, @@ -218,161 +217,6 @@ export const GET = apiHandler(async (request: NextRequest) => { return apiSuccess(result); }); -interface ComplianceBucket { - date: string; - scheduled: number; - taken: number; -} - -/** - * v1.15.9 — schedule-anchored per-day compliance buckets for the dashboard - * tile. Replaces the rollup/legacy path whose `scheduled` was the count of - * logged intake rows (BUG #1: rate `taken / scheduled` ≈ 100% across every - * window because every logged row was both numerator and denominator). - * - * `scheduled` is now the canonical recurrence engine's expected-dose count - * for the day, summed across the user's active medications, so days the - * schedule expected a dose that the user missed pull the rate down — and the - * 7/30/90 windows genuinely diverge with partial adherence. `taken` stays the - * count of taken (non-skipped, non-auto-missed) doses that day, capped at the - * day's expected count so a duplicate log can't push a day above 100%. - * - * Pure-ish over a single pinned `now` so a 15-minute cached row is internally - * consistent. Bounded: one active-medications query + one window-events query, - * then per-medication-per-day engine expansion over the trailing window. - */ -async function buildScheduleAnchoredComplianceBuckets( - userId: string, - days: number, - userTz: string, -): Promise { - const now = new Date(); - const nowMs = now.getTime(); - const start = new Date(nowMs - days * 86_400_000); - - const medications = await prisma.medication.findMany({ - where: { userId, active: true }, - include: { - schedules: true, - // v1.16.3 — archived schedule eras for era-aware expected counts. - scheduleRevisions: { orderBy: { validFrom: "asc" } }, - // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. - pauseEras: { select: { pausedAt: true, resumedAt: true } }, - }, - }); - - const events = await prisma.medicationIntakeEvent.findMany({ - // v1.7.0 sync — exclude tombstoned rows from the compliance buckets. - where: { userId, deletedAt: null, scheduledFor: { gte: start } }, - select: { - medicationId: true, - scheduledFor: true, - takenAt: true, - skipped: true, - autoMissed: true, - }, - }); - - // Pre-compute each local day's [start, end) bounds + key, oldest → newest. - // Each day's representative instant anchors on the user's LOCAL NOON of - // the current day and steps back in 24-hour increments: noon stays inside - // the intended local day across DST shifts (23/25-hour days), and — - // unlike the previous `now − 12 h` anchor — it cannot slip into - // yesterday when the user's local time is before noon, which silently - // dropped TODAY from the buckets for every pre-noon read. - const { start: todayStart } = getUserTodayBounds(now, userTz); - const todayNoonMs = todayStart.getTime() + 12 * 60 * 60 * 1000; - const dayKeys: string[] = []; - const dayBounds = new Map(); - for (let i = days - 1; i >= 0; i--) { - const representative = new Date(todayNoonMs - i * 86_400_000); - const { start: dayStart, end: dayEndInclusive } = getUserTodayBounds( - representative, - userTz, - ); - const key = userDayKey(dayStart, userTz); - if (dayBounds.has(key)) continue; - dayKeys.push(key); - dayBounds.set(key, { - start: dayStart, - end: new Date(dayEndInclusive.getTime() + 1), // half-open [start, end) - }); - } - - const totals = new Map(); - for (const key of dayKeys) totals.set(key, { scheduled: 0, taken: 0 }); - - // Group events by medication so each med's engine context is built once. - const eventsByMed = new Map< - string, - { - scheduledFor: Date; - takenAt: Date | null; - skipped: boolean; - autoMissed: boolean; - }[] - >(); - for (const e of events) { - const list = eventsByMed.get(e.medicationId) ?? []; - list.push({ - scheduledFor: e.scheduledFor, - takenAt: e.takenAt, - skipped: e.skipped, - autoMissed: e.autoMissed, - }); - eventsByMed.set(e.medicationId, list); - } - - for (const med of medications) { - if (med.schedules.length === 0) continue; - const medEvents = eventsByMed.get(med.id) ?? []; - const ctx = buildComplianceMedicationContext( - med, - lastNonSkippedTakenAt(medEvents), - userTz, - ); - - for (const key of dayKeys) { - const bounds = dayBounds.get(key)!; - // Skip days before the medication existed so a young med doesn't paint - // missed-denominator days it could not have been dosed on. - if (bounds.end <= med.createdAt) continue; - - const scheduled = expectedSlotCountForDay( - med.schedules, - bounds.start, - bounds.end, - ctx, - medEvents, - ); - if (scheduled === 0) continue; - - // Taken doses that landed in this day's window (non-skipped, non-auto- - // missed), capped at the expected count so a duplicate log can't push a - // single day above 100%. - const takenThisDay = medEvents.filter( - (e) => - e.takenAt !== null && - !e.skipped && - !e.autoMissed && - e.scheduledFor >= bounds.start && - e.scheduledFor < bounds.end, - ).length; - - const bucket = totals.get(key)!; - bucket.scheduled += scheduled; - bucket.taken += Math.min(takenThisDay, scheduled); - } - } - - return dayKeys - .map((date) => { - const v = totals.get(date)!; - return { date, scheduled: v.scheduled, taken: v.taken }; - }) - .sort((a, b) => a.date.localeCompare(b.date)); -} - export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); diff --git a/src/lib/analytics/schedule-anchored-compliance.ts b/src/lib/analytics/schedule-anchored-compliance.ts new file mode 100644 index 000000000..da736adab --- /dev/null +++ b/src/lib/analytics/schedule-anchored-compliance.ts @@ -0,0 +1,171 @@ +/** + * v1.26.0 SEAM-N3 — the ONE schedule-anchored, cadence-aware daily + * compliance computation. + * + * Extracted verbatim from the `GET /api/medications/intake?scope=compliance` + * dashboard-tile handler so every surface that surfaces a "taken of expected" + * adherence number reads through the SAME engine instead of the raw coverage + * rollup (`readMedicationCompliance`), whose `scheduled` counts LOGGED intake + * slots — a denominator that collapses to ~100% for a user who logs only the + * doses they took and never mints `takenAt:null` reminder rows for the ones + * they missed. See the adherence storyline (`adherence-storyline.ts`) and the + * dashboard tile: both now consume this. + * + * `scheduled` is the canonical recurrence engine's expected-dose count for the + * day, summed across the user's active medications, so days the schedule + * expected a dose that the user missed pull the rate down — and the 7/30/90 + * windows genuinely diverge with partial adherence. `taken` stays the count of + * taken (non-skipped, non-auto-missed) doses that day, capped at the day's + * expected count so a duplicate log can't push a day above 100%. + * + * Pure-ish over a single pinned `now` so a cached row is internally + * consistent. Bounded: one active-medications query + one window-events query, + * then per-medication-per-day engine expansion over the trailing window. + */ +import { prisma } from "@/lib/db"; +import { + buildComplianceMedicationContext, + expectedSlotCountForDay, + lastNonSkippedTakenAt, +} from "@/lib/analytics/compliance"; +import { getUserTodayBounds } from "@/lib/tz/local-day"; +import { userDayKey } from "@/lib/tz/resolver"; + +export interface ScheduleAnchoredComplianceBucket { + /** YYYY-MM-DD in the user's tz. */ + date: string; + scheduled: number; + taken: number; +} + +export async function buildScheduleAnchoredComplianceBuckets( + userId: string, + days: number, + userTz: string, + now: Date = new Date(), +): Promise { + const nowMs = now.getTime(); + const start = new Date(nowMs - days * 86_400_000); + + const medications = await prisma.medication.findMany({ + where: { userId, active: true }, + include: { + schedules: true, + // v1.16.3 — archived schedule eras for era-aware expected counts. + scheduleRevisions: { orderBy: { validFrom: "asc" } }, + // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. + pauseEras: { select: { pausedAt: true, resumedAt: true } }, + }, + }); + + const events = await prisma.medicationIntakeEvent.findMany({ + // v1.7.0 sync — exclude tombstoned rows from the compliance buckets. + where: { userId, deletedAt: null, scheduledFor: { gte: start } }, + select: { + medicationId: true, + scheduledFor: true, + takenAt: true, + skipped: true, + autoMissed: true, + }, + }); + + // Pre-compute each local day's [start, end) bounds + key, oldest → newest. + // Each day's representative instant anchors on the user's LOCAL NOON of + // the current day and steps back in 24-hour increments: noon stays inside + // the intended local day across DST shifts (23/25-hour days), and — + // unlike the previous `now − 12 h` anchor — it cannot slip into + // yesterday when the user's local time is before noon, which silently + // dropped TODAY from the buckets for every pre-noon read. + const { start: todayStart } = getUserTodayBounds(now, userTz); + const todayNoonMs = todayStart.getTime() + 12 * 60 * 60 * 1000; + const dayKeys: string[] = []; + const dayBounds = new Map(); + for (let i = days - 1; i >= 0; i--) { + const representative = new Date(todayNoonMs - i * 86_400_000); + const { start: dayStart, end: dayEndInclusive } = getUserTodayBounds( + representative, + userTz, + ); + const key = userDayKey(dayStart, userTz); + if (dayBounds.has(key)) continue; + dayKeys.push(key); + dayBounds.set(key, { + start: dayStart, + end: new Date(dayEndInclusive.getTime() + 1), // half-open [start, end) + }); + } + + const totals = new Map(); + for (const key of dayKeys) totals.set(key, { scheduled: 0, taken: 0 }); + + // Group events by medication so each med's engine context is built once. + const eventsByMed = new Map< + string, + { + scheduledFor: Date; + takenAt: Date | null; + skipped: boolean; + autoMissed: boolean; + }[] + >(); + for (const e of events) { + const list = eventsByMed.get(e.medicationId) ?? []; + list.push({ + scheduledFor: e.scheduledFor, + takenAt: e.takenAt, + skipped: e.skipped, + autoMissed: e.autoMissed, + }); + eventsByMed.set(e.medicationId, list); + } + + for (const med of medications) { + if (med.schedules.length === 0) continue; + const medEvents = eventsByMed.get(med.id) ?? []; + const ctx = buildComplianceMedicationContext( + med, + lastNonSkippedTakenAt(medEvents), + userTz, + ); + + for (const key of dayKeys) { + const bounds = dayBounds.get(key)!; + // Skip days before the medication existed so a young med doesn't paint + // missed-denominator days it could not have been dosed on. + if (bounds.end <= med.createdAt) continue; + + const scheduled = expectedSlotCountForDay( + med.schedules, + bounds.start, + bounds.end, + ctx, + medEvents, + ); + if (scheduled === 0) continue; + + // Taken doses that landed in this day's window (non-skipped, non-auto- + // missed), capped at the expected count so a duplicate log can't push a + // single day above 100%. + const takenThisDay = medEvents.filter( + (e) => + e.takenAt !== null && + !e.skipped && + !e.autoMissed && + e.scheduledFor >= bounds.start && + e.scheduledFor < bounds.end, + ).length; + + const bucket = totals.get(key)!; + bucket.scheduled += scheduled; + bucket.taken += Math.min(takenThisDay, scheduled); + } + } + + return dayKeys + .map((date) => { + const v = totals.get(date)!; + return { date, scheduled: v.scheduled, taken: v.taken }; + }) + .sort((a, b) => a.date.localeCompare(b.date)); +} diff --git a/src/lib/insights/derived/__tests__/adherence-storyline-engine-parity.test.ts b/src/lib/insights/derived/__tests__/adherence-storyline-engine-parity.test.ts new file mode 100644 index 000000000..ff5fc0e81 --- /dev/null +++ b/src/lib/insights/derived/__tests__/adherence-storyline-engine-parity.test.ts @@ -0,0 +1,196 @@ +/** + * v1.26.0 SEAM-N3 — the adherence storyline must read the SAME + * schedule-anchored, cadence-aware adherence number the dashboard tile does. + * + * The regression this pins: the storyline used to derive its adherence % from + * `readMedicationCompliance`, the RAW coverage rollup whose `scheduled` counts + * LOGGED intake slots. A user who logs only the doses they TOOK — never + * minting `takenAt:null` reminder rows for the doses they missed — therefore + * read ~100 % adherence (every logged row was both numerator and denominator), + * and the dip gate (`adherencePct > 80 → null`) wrongly suppressed the whole + * storyline. Anchoring on the recurrence engine's EXPECTED-dose count makes the + * missed days pull the rate below 100 %. + * + * The real compliance engine (`@/lib/analytics/compliance`) and the shared + * `buildScheduleAnchoredComplianceBuckets` run unmocked here — only the DB and + * the vital-series reader are stubbed — so the test exercises the actual + * expected-dose expansion, not a re-mock of it. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + medicationFindMany: vi.fn(), + intakeFindMany: vi.fn(), + probeRollupCoverage: vi.fn(), + readDayMeanSeries: vi.fn(), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + medication: { findMany: mocks.medicationFindMany }, + medicationIntakeEvent: { findMany: mocks.intakeFindMany }, + }, +})); + +vi.mock("@/lib/rollups/measurement-coverage", () => ({ + probeRollupCoverage: mocks.probeRollupCoverage, +})); + +vi.mock("@/lib/insights/derived/baseline", () => ({ + readDayMeanSeries: mocks.readDayMeanSeries, +})); + +import { buildAdherenceStoryline } from "@/lib/insights/derived/adherence-storyline"; +import { buildScheduleAnchoredComplianceBuckets } from "@/lib/analytics/schedule-anchored-compliance"; + +const DAY_MS = 86_400_000; +const NOW = new Date("2026-06-21T12:00:00.000Z"); +const TZ = "UTC"; + +/** A legacy daily schedule — one expected dose/day at 08:00, every day. */ +const dailySchedule = { + id: "sched-1", + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + timesOfDay: [], + rrule: null, + rollingIntervalDays: null, + reminderGraceMinutes: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + doseWindows: null, +}; + +const medication = { + id: "med-1", + name: "ramipril", + treatmentClass: "GENERIC", + active: true, + oneShot: false, + startsOn: null, + endsOn: null, + // Created well before the window so no day is skipped for being pre-creation. + createdAt: new Date("2024-01-01T00:00:00.000Z"), + schedules: [dailySchedule], + scheduleRevisions: [], + pauseEras: [], +}; + +/** ISO 08:00 on a given YYYY-MM-DD. */ +const at8 = (day: string) => new Date(`${day}T08:00:00.000Z`); + +beforeEach(() => { + mocks.medicationFindMany.mockReset(); + mocks.intakeFindMany.mockReset(); + mocks.probeRollupCoverage.mockReset(); + mocks.readDayMeanSeries.mockReset(); + + // The same medication row satisfies both call sites: the storyline's + // `select: { name, treatmentClass }` and the engine's `include: {...}`. + mocks.medicationFindMany.mockResolvedValue([medication]); + + // Only-taken intakes: 7 taken doses across 14 scheduled days. The 7 missed + // days carry NO row at all (the user never minted a takenAt:null miss row). + const takenDays = [ + "2026-06-08", + "2026-06-10", + "2026-06-12", + "2026-06-14", + "2026-06-16", + "2026-06-18", + "2026-06-20", + ]; + mocks.intakeFindMany.mockResolvedValue( + takenDays.map((d) => ({ + medicationId: "med-1", + scheduledFor: at8(d), + takenAt: new Date(`${d}T08:05:00.000Z`), + skipped: false, + autoMissed: false, + })), + ); + + mocks.probeRollupCoverage.mockResolvedValue(new Map()); + + // Vital before/after: 8 prior days ~124, 8 recent days ~134 — a material + // move on a ~7.4 robust spread, so a storyline is produced once the dip + // gate lets it through. + const priorDays: { day: string; mean: number }[] = []; + for (let i = 0; i < 8; i++) { + const d = new Date( + new Date("2026-05-25T00:00:00.000Z").getTime() + i * DAY_MS, + ); + priorDays.push({ day: d.toISOString().slice(0, 10), mean: 124 }); + } + const recentDays: { day: string; mean: number }[] = []; + for (let i = 0; i < 8; i++) { + const d = new Date( + new Date("2026-06-08T00:00:00.000Z").getTime() + i * DAY_MS, + ); + recentDays.push({ day: d.toISOString().slice(0, 10), mean: 134 }); + } + mocks.readDayMeanSeries.mockResolvedValue({ + points: [...priorDays, ...recentDays], + source: "live", + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("adherence storyline ↔ dashboard-tile engine parity", () => { + it("derives adherence from the schedule-anchored engine (missed doses pull it below 100)", async () => { + // The dashboard-tile number, straight from the shared engine. + const buckets = await buildScheduleAnchoredComplianceBuckets( + "user-1", + 14, + TZ, + NOW, + ); + let scheduled = 0; + let taken = 0; + for (const b of buckets) { + if (b.scheduled > 0) { + scheduled += b.scheduled; + taken += b.taken; + } + } + // Sanity: 14 expected days, 7 taken → genuinely below 100 %. The raw + // coverage rollup would have read scheduled == taken == 7 → 100 %. + expect(scheduled).toBeGreaterThan(taken); + const dashboardPct = (100 * taken) / scheduled; + expect(dashboardPct).toBeLessThan(100); + + // The storyline must surface (the dip gate no longer wrongly suppresses it) + // and its adherence number must equal the dashboard-tile number. + const story = await buildAdherenceStoryline("user-1", TZ, NOW); + expect(story).not.toBeNull(); + expect(story!.adherencePct).toBeLessThan(100); + expect(story!.adherencePct).toBe(Math.round(dashboardPct)); + }); + + it("still suppresses the storyline when adherence is genuinely high (dip gate honoured)", async () => { + // Log a taken dose on EVERY one of the 14 scheduled days → ~100 %. + const allDays: { day: string }[] = []; + for (let i = 0; i < 14; i++) { + const d = new Date(NOW.getTime() - i * DAY_MS); + allDays.push({ day: d.toISOString().slice(0, 10) }); + } + mocks.intakeFindMany.mockResolvedValue( + allDays.map(({ day }) => ({ + medicationId: "med-1", + scheduledFor: at8(day), + takenAt: new Date(`${day}T08:05:00.000Z`), + skipped: false, + autoMissed: false, + })), + ); + + const story = await buildAdherenceStoryline("user-1", TZ, NOW); + // High adherence → the dip gate (adherencePct > 80) returns null. + expect(story).toBeNull(); + }); +}); diff --git a/src/lib/insights/derived/adherence-storyline.ts b/src/lib/insights/derived/adherence-storyline.ts index d2c4f0575..abc06010d 100644 --- a/src/lib/insights/derived/adherence-storyline.ts +++ b/src/lib/insights/derived/adherence-storyline.ts @@ -26,7 +26,8 @@ import type { MeasurementType } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage"; import { readDayMeanSeries } from "@/lib/insights/derived/baseline"; -import { readMedicationCompliance } from "@/lib/rollups/medication-compliance-rollups"; +import { buildScheduleAnchoredComplianceBuckets } from "@/lib/analytics/schedule-anchored-compliance"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; import { inferMedTargetClass, MED_TARGET_MAP, @@ -171,11 +172,20 @@ export async function buildAdherenceStoryline( .filter((c): c is { label: string; cls: MedTargetClass } => c.cls !== null); if (candidates.length === 0) return null; - // 2. Overall recent adherence (slot-level, cadence-aware via the rollup). - const buckets = await readMedicationCompliance( + // 2. Overall recent adherence — schedule-anchored + cadence-aware, the SAME + // engine the dashboard tile reads (`buildScheduleAnchoredComplianceBuckets`). + // v1.26.0 SEAM-N3 — this replaced `readMedicationCompliance`, whose + // `scheduled` was a RAW coverage count of LOGGED intake slots: a user who + // logs only the doses they took (never minting `takenAt:null` reminder + // rows for missed ones) read ~100% adherence, and the dip gate below + // (adherencePct > 80 → null) wrongly suppressed the storyline. The + // schedule-anchored `scheduled` is the recurrence engine's EXPECTED-dose + // count, so genuinely missed doses pull the rate down. One engine, one + // number — the storyline's % now equals the dashboard tile's %. + const buckets = await buildScheduleAnchoredComplianceBuckets( userId, ADHERENCE_WINDOW_DAYS, - timezone ?? "Europe/Berlin", + timezone ?? DEFAULT_TIMEZONE, now, ); let scheduled = 0; From ba8118546d9ebf2cb57e0604c3e58124dd721d47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 2 Jul 2026 00:16:03 +0200 Subject: [PATCH 12/16] build(lint): ban raw palette colors and guard the design contract Add the healthlog/no-raw-palette-color rule so UI code routes color through the semantic tokens instead of Tailwind palette shades, and sweep the remaining offenders (password-strength scale, injection-site accents, redundant dark overrides). Add guard tests for width stability, the single page-header vocabulary, and motion-reduce coverage on indefinite animations. --- eslint-plugins/healthlog/index.js | 9 +- .../healthlog/no-raw-palette-color.js | 122 ++++++++++++++++++ eslint.config.mjs | 7 + .../design-consistency-guards.test.tsx | 92 +++++++++++++ .../motion-reduce-spin-coverage.test.ts | 61 +++++++++ src/components/admin/reminders-section.tsx | 16 +-- src/components/clinician/clinician-view.tsx | 2 +- .../__tests__/intake-history-list-v2.test.tsx | 4 +- .../medications/injection-site-picker.tsx | 2 +- .../medications/intake-history-list-v2.tsx | 2 +- .../medications/log-injection-site-dialog.tsx | 2 +- src/components/ui/password-strength.tsx | 19 +-- 12 files changed, 312 insertions(+), 26 deletions(-) create mode 100644 eslint-plugins/healthlog/no-raw-palette-color.js create mode 100644 src/components/__tests__/design-consistency-guards.test.tsx diff --git a/eslint-plugins/healthlog/index.js b/eslint-plugins/healthlog/index.js index d5849ae5f..1119c7d7b 100644 --- a/eslint-plugins/healthlog/index.js +++ b/eslint-plugins/healthlog/index.js @@ -2,9 +2,10 @@ * @fileoverview Aggregates the project-local `healthlog/*` ESLint rules * into a single plugin object for the flat config. * - * - `queryKey-factory` — bare-array queryKey / mutationKey bypass guard. - * - `safe-fetch-required` — outbound fetch must route through safeFetch. - * - `api-fetch-required` — client /api/ calls must route through apiFetch. + * - `queryKey-factory` — bare-array queryKey / mutationKey bypass guard. + * - `safe-fetch-required` — outbound fetch must route through safeFetch. + * - `api-fetch-required` — client /api/ calls must route through apiFetch. + * - `no-raw-palette-color` — ban raw Tailwind palette utilities in app UI. */ "use strict"; @@ -12,11 +13,13 @@ const queryKeyFactory = require("./queryKey-factory.js"); const safeFetchRequired = require("./safe-fetch-required.js"); const apiFetchRequired = require("./api-fetch-required.js"); +const noRawPaletteColor = require("./no-raw-palette-color.js"); module.exports = { rules: { "queryKey-factory": queryKeyFactory.rules["queryKey-factory"], "safe-fetch-required": safeFetchRequired, "api-fetch-required": apiFetchRequired, + "no-raw-palette-color": noRawPaletteColor, }, }; diff --git a/eslint-plugins/healthlog/no-raw-palette-color.js b/eslint-plugins/healthlog/no-raw-palette-color.js new file mode 100644 index 000000000..6fe38790a --- /dev/null +++ b/eslint-plugins/healthlog/no-raw-palette-color.js @@ -0,0 +1,122 @@ +/** + * @fileoverview ESLint rule — raw Tailwind palette color utilities are + * banned in app UI; route every tone through a semantic token. + * + * v1.26.0 — the design-consistency wave collapsed the app's colour + * vocabulary onto semantic tokens (`text-warning` / `text-info` / + * `text-success` / `text-destructive` and their `bg-*` / `border-*` / + * opacity forms, plus `stroke-[var(--warning)]` for SVG accents). A raw + * `text-amber-500` / `bg-green-500` / `dark:text-red-400` re-introduces a + * hard-coded Tailwind stock tone that drifts off the token system, breaks + * light/dark parity, and often fails AA against the card surface. This + * rule flags those utilities at authoring time and in CI so the tokenised + * palette cannot regress. + * + * Match: a className fragment + * (text|bg|border|ring|stroke|fill|from|to|via)-- + * where is one of the Tailwind stock hues and is a + * numeric 50…950 shade. The `-` suffix is what pins it to the raw + * palette — bare `bg-white` / `text-black` (QR quiet-zone) and semantic + * tokens (`text-warning`, `bg-success/10`) never carry a numeric shade and + * so never match. + * + * Scope: `src/components/**` + `src/app/**`. The check inspects string + * literals and template-literal chunks (covering `className="…"`, + * `cn("…")`, cva variant maps, and `clsx` calls alike) — a syntactic + * match, mirroring the queryKey-factory / safe-fetch-required rules. + * + * Exempt files (narrow, path-suffix matched): + * - `src/app/global-error.tsx` — the App-Router global error boundary + * renders WITHOUT the token stylesheet loaded, so a future edit there + * legitimately needs raw palette / inline colour. + * Test files (`*.test.ts(x)`, `__tests__/`, `__mocks__/`) are exempt too. + * + * @see src/app/globals.css — the semantic token definitions. + */ + +"use strict"; + +// Files exempt from the rule. Posix-style suffix match against the +// absolute filename via `String#includes`, mirroring the sibling rules. +const EXEMPT_FILES = [ + // Runs without the token stylesheet — see the header note. + "src/app/global-error.tsx", +]; + +// Only enforce inside the app UI roots. +const ENFORCED_ROOTS = ["src/components/", "src/app/"]; + +// Raw Tailwind palette utility: a color property prefix, a stock hue, and +// a numeric 50…950 shade. The `\b` bounds let a `dark:` / `hover:` / group +// modifier prefix and a `/10` opacity suffix ride along while still +// pinning the numeric shade that distinguishes raw palette from tokens. +const RAW_PALETTE_RE = + /\b(?:text|bg|border|ring|stroke|fill|from|to|via)-(?:amber|sky|red|green|emerald|blue|orange|yellow|pink|purple|violet|zinc|gray|slate|neutral|stone|rose|lime|teal|cyan|indigo|fuchsia)-(?:50|100|200|300|400|500|600|700|800|900|950)\b/; + +function toPosix(filename) { + return filename.replace(/\\/g, "/"); +} + +function isTestFile(posix) { + return ( + /\.test\.[cm]?[jt]sx?$/.test(posix) || + /\.spec\.[cm]?[jt]sx?$/.test(posix) || + posix.includes("/__tests__/") || + posix.includes("/__mocks__/") + ); +} + +function isEnforced(filename) { + const posix = toPosix(filename); + if (!ENFORCED_ROOTS.some((root) => posix.includes(root))) return false; + if (EXEMPT_FILES.some((f) => posix.includes(f))) return false; + if (isTestFile(posix)) return false; + return true; +} + +/** @type {import("eslint").Rule.RuleModule} */ +const noRawPaletteColorRule = { + meta: { + type: "problem", + docs: { + description: + "Ban raw Tailwind palette color utilities in app UI; route every tone through a semantic token (text-warning / text-info / text-success / text-destructive and their bg / border / opacity forms).", + }, + schema: [], + messages: { + rawPalette: + 'Raw Tailwind palette utility "{{match}}" bypasses the semantic token system (drifts off the palette, breaks light/dark parity, often fails AA). Use a token: caution → text-warning / border-warning / bg-warning/10, info → text-info, success → text-success, destructive → text-destructive; SVG accents → stroke-[var(--warning)].', + }, + }, + create(context) { + const filename = context.filename ?? context.getFilename?.(); + if (!filename || !isEnforced(filename)) { + return {}; + } + + function checkText(node, text) { + if (typeof text !== "string") return; + const m = RAW_PALETTE_RE.exec(text); + if (m) { + context.report({ + node, + messageId: "rawPalette", + data: { match: m[0] }, + }); + } + } + + return { + Literal(node) { + if (typeof node.value === "string") { + checkText(node, node.value); + } + }, + TemplateElement(node) { + checkText(node, node.value.cooked ?? node.value.raw); + }, + }; + }, +}; + +module.exports = noRawPaletteColorRule; diff --git a/eslint.config.mjs b/eslint.config.mjs index 560d86560..3662faa09 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -44,6 +44,13 @@ const eslintConfig = defineConfig([ // envelope unwrap, one ApiError shape. Wrapper internals and test // files are exempt; see the rule file for the allowlist. "healthlog/api-fetch-required": "error", + // v1.26.0 — the design-consistency wave locked the app's colour + // vocabulary onto semantic tokens. Raw Tailwind palette utilities + // (`text-amber-500`, `bg-green-500`, `dark:text-red-400`, …) under + // src/components + src/app are banned so the tokenised palette can't + // regress. `src/app/global-error.tsx` (renders without the token + // stylesheet) and test files are exempt; see the rule file. + "healthlog/no-raw-palette-color": "error", }, }, ]); diff --git a/src/components/__tests__/design-consistency-guards.test.tsx b/src/components/__tests__/design-consistency-guards.test.tsx new file mode 100644 index 000000000..3187b2754 --- /dev/null +++ b/src/components/__tests__/design-consistency-guards.test.tsx @@ -0,0 +1,92 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { PageHeader } from "@/components/ui/page-header"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; + +/** + * v1.26.0 — design-consistency guardrails. + * + * These pin the invariants the design-consistency wave established so a + * later edit cannot silently undo them: + * 1. the authenticated shell's scroll container keeps its + * scrollbar-gutter reservation and single centred max-width box (the + * width-stability contract — a 4th width-shift regression must not + * recur); + * 2. the canonical `` renders exactly one, correctly-styled + * `

    ` (one-H1-per-page vocabulary); + * 3. `` renders its retry affordance so a failed read + * never falls through to an honest-empty state. + */ + +function render(node: React.ReactNode, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + {node}, + ); +} + +describe("width stability — auth-shell scroll container", () => { + const source = readFileSync( + join(process.cwd(), "src", "components", "layout", "auth-shell.tsx"), + "utf8", + ); + + it("keeps [scrollbar-gutter:stable] on the scroll
    ", () => { + // The reserved gutter is what stops the whole viewport widening by a + // scrollbar's width when a short page (e.g. Integrations) hides the + // scroll. Without it the centred container shifts on every route swap. + expect(source).toContain("[scrollbar-gutter:stable]"); + }); + + it("keeps the single centred `mx-auto max-w-screen-xl` container", () => { + // Every authenticated surface shares ONE normalised content box. The + // literal ordering `mx-auto max-w-screen-xl` is the class the shell + // ships; a duplicate or differently-capped box would reintroduce the + // per-route width drift this guard exists to prevent. + expect(source).toContain("mx-auto max-w-screen-xl"); + const occurrences = source.split("mx-auto max-w-screen-xl").length - 1; + expect(occurrences).toBe(1); + }); +}); + +describe(" — one canonical H1", () => { + it("renders exactly one

    with the canonical type scale", () => { + const html = render( + , + ); + const h1Count = (html.match(/]*class="[^"]*text-2xl font-bold tracking-tight[^"]*"/, + ); + expect(html).toContain("Dashboard"); + }); + + it("renders no second heading when only a title is supplied", () => { + const html = render(); + expect((html.match(/ — retry affordance", () => { + it("renders a retry button when onRetry is provided", () => { + const html = render( {}} />); + expect(html).toContain('data-slot="query-error-card"'); + expect(html).toContain('role="alert"'); + // The retry affordance is a real