From b424c37ab0216860b765ead91e8e2e7e562836f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 02:52:44 +0200 Subject: [PATCH 01/94] i18n: add keys for the UX and accessibility pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chart and heatmap text-alternative summaries, the version-update toast, the touch-help trigger label, list load-error messages, and the target-editor validation hints — across all six locales. --- messages/de.json | 38 ++++++++++++++++++++++++++++++++------ messages/en.json | 38 ++++++++++++++++++++++++++++++++------ messages/es.json | 38 ++++++++++++++++++++++++++++++++------ messages/fr.json | 38 ++++++++++++++++++++++++++++++++------ messages/it.json | 38 ++++++++++++++++++++++++++++++++------ messages/pl.json | 38 ++++++++++++++++++++++++++++++++------ 6 files changed, 192 insertions(+), 36 deletions(-) diff --git a/messages/de.json b/messages/de.json index ce28bb09c..43740d2e4 100644 --- a/messages/de.json +++ b/messages/de.json @@ -35,7 +35,8 @@ "showPassword": "Passwort anzeigen", "hidePassword": "Passwort verbergen", "learnMore": "Mehr erfahren", - "dismiss": "Schließen" + "dismiss": "Schließen", + "moreInfo": "Mehr Informationen" }, "doctorReport": { "title": "Gesundheitsbericht", @@ -750,7 +751,8 @@ "sectionFactors": "Faktoren", "sectionNoteDetails": "Notiz & Details", "quickTagsHelp": "Häufig genutzte Tags – ein Tipp genügt", - "quickTagsExpand": "Weitere" + "quickTagsExpand": "Weitere", + "loadError": "Stimmungseinträge konnten nicht geladen werden." }, "medications": { "title": "Medikamente", @@ -1852,7 +1854,20 @@ "sat": "Samstag", "sun": "Sonntag" }, - "rangeBand": "Bereich" + "rangeBand": "Bereich", + "a11y": { + "summary": "{metric}-Diagramm. Bereich {min} bis {max}, aktuell {latest}, Trend {trend}.", + "noData": "{metric}-Diagramm. Keine Daten verfügbar.", + "trendUp": "steigend", + "trendDown": "fallend", + "trendFlat": "gleichbleibend", + "scatterSummary": "Korrelationsdiagramm, {count} Punkte: {xLabel} gegen {yLabel}.", + "scatterNoData": "Korrelationsdiagramm {xLabel} gegen {yLabel}. Keine Daten verfügbar.", + "moodHeatmap": "Stimmungs-Heatmap von {start} bis {end}, {days} Tage erfasst.", + "moodHeatmapEmpty": "Stimmungs-Heatmap, noch keine Tage erfasst.", + "complianceHeatmap": "Heatmap zur Medikamenteneinnahme von {start} bis {end}, {days} aktive Tage.", + "complianceHeatmapEmpty": "Heatmap zur Medikamenteneinnahme, noch keine aktiven Tage." + } }, "chart": { "overlay": { @@ -6173,7 +6188,9 @@ "resetToDefault": "Auf Standard zurücksetzen", "saveSuccess": "Zielbereich aktualisiert", "saveError": "Zielbereich konnte nicht gespeichert werden", - "resetSuccess": "Zielbereich auf Standard zurückgesetzt" + "resetSuccess": "Zielbereich auf Standard zurückgesetzt", + "validationOrder": "Der Minimalwert muss unter dem Maximalwert liegen.", + "validationBounds": "Werte innerhalb des unten angegebenen Bereichs eingeben." }, "currentValue": "Aktuell: {value} {unit}", "targetRangeValue": "Zielbereich: {min}–{max} {unit}", @@ -6831,7 +6848,8 @@ "empty": "Keine anstehenden Vorsorge-Erinnerungen.", "openLink": "Vorsorge öffnen" }, - "showMeasurements": "Messergebnisse anzeigen" + "showMeasurements": "Messergebnisse anzeigen", + "loadError": "Erinnerungen konnten nicht geladen werden." }, "moduleList": { "viewToggleLabel": "Ansicht", @@ -7312,7 +7330,8 @@ "BLOOD_PRESSURE_DIA": "Diastolic blood pressure", "PULSE": "Pulse", "WEIGHT": "Weight" - } + }, + "listLoadError": "Episoden konnten nicht geladen werden." }, "coach": { "reminderSuggestion": { @@ -7372,5 +7391,12 @@ "glucoseHighDoctor": "Zwei aktuelle Messungen lagen bei {value} mg/dL oder höher. Miss erneut, trink ausreichend und kontaktiere möglichst bald deine Ärztin oder deinen Arzt, wenn es so hoch bleibt.", "glucoseHighEmergency": "Zwei aktuelle Messungen lagen bei {value} mg/dL oder höher und du hast Beschwerden angegeben. Bei Erbrechen, schneller Atmung oder Verwirrtheit sofort den Notruf wählen." } + }, + "version": { + "updateAvailable": { + "title": "Eine neue Version ist verfügbar", + "reload": "Neu laden", + "dismiss": "Schließen" + } } } diff --git a/messages/en.json b/messages/en.json index 14fa8b085..2ebe72db7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -35,7 +35,8 @@ "showPassword": "Show password", "hidePassword": "Hide password", "learnMore": "Learn more", - "dismiss": "Dismiss" + "dismiss": "Dismiss", + "moreInfo": "More information" }, "doctorReport": { "title": "Health Report", @@ -750,7 +751,8 @@ "sectionFactors": "Factors", "sectionNoteDetails": "Note & details", "quickTagsHelp": "Your frequent tags — one tap", - "quickTagsExpand": "More" + "quickTagsExpand": "More", + "loadError": "Could not load your mood entries." }, "medications": { "title": "Medications", @@ -1852,7 +1854,20 @@ "sat": "Saturday", "sun": "Sunday" }, - "rangeBand": "Range" + "rangeBand": "Range", + "a11y": { + "summary": "{metric} chart. Range {min} to {max}, latest {latest}, trend {trend}.", + "noData": "{metric} chart. No data available.", + "trendUp": "rising", + "trendDown": "falling", + "trendFlat": "steady", + "scatterSummary": "Correlation chart, {count} points plotting {xLabel} against {yLabel}.", + "scatterNoData": "Correlation chart of {xLabel} against {yLabel}. No data available.", + "moodHeatmap": "Mood heatmap from {start} to {end}, {days} days logged.", + "moodHeatmapEmpty": "Mood heatmap, no days logged yet.", + "complianceHeatmap": "Medication compliance heatmap from {start} to {end}, {days} active days.", + "complianceHeatmapEmpty": "Medication compliance heatmap, no active days yet." + } }, "chart": { "overlay": { @@ -6173,7 +6188,9 @@ "resetToDefault": "Reset to default", "saveSuccess": "Target range updated", "saveError": "Could not save the target range", - "resetSuccess": "Target range reset to default" + "resetSuccess": "Target range reset to default", + "validationOrder": "The minimum must be lower than the maximum.", + "validationBounds": "Enter values within the allowed range shown below." }, "currentValue": "Current: {value} {unit}", "targetRangeValue": "Target: {min}–{max} {unit}", @@ -6831,7 +6848,8 @@ "empty": "No upcoming checkup reminders.", "openLink": "Open checkups" }, - "showMeasurements": "Show measurements" + "showMeasurements": "Show measurements", + "loadError": "Could not load your reminders." }, "moduleList": { "viewToggleLabel": "View", @@ -7312,7 +7330,8 @@ "BLOOD_PRESSURE_DIA": "Diastolic blood pressure", "PULSE": "Pulse", "WEIGHT": "Weight" - } + }, + "listLoadError": "Could not load your episodes." }, "coach": { "reminderSuggestion": { @@ -7372,5 +7391,12 @@ "glucoseHighDoctor": "Two recent readings were {value} mg/dL or higher. Re-check, hydrate, and contact your doctor as soon as possible if it stays this high.", "glucoseHighEmergency": "Two recent readings were {value} mg/dL or higher and you noted symptoms. If you have vomiting, fast breathing, or confusion, call emergency services now." } + }, + "version": { + "updateAvailable": { + "title": "A new version is available", + "reload": "Reload", + "dismiss": "Dismiss" + } } } diff --git a/messages/es.json b/messages/es.json index ce2ee6de9..bbbaa6b8b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -35,7 +35,8 @@ "showPassword": "Mostrar contraseña", "hidePassword": "Ocultar contraseña", "learnMore": "Más información", - "dismiss": "Descartar" + "dismiss": "Descartar", + "moreInfo": "Más información" }, "doctorReport": { "title": "Informe de salud", @@ -750,7 +751,8 @@ "sectionFactors": "Factores", "sectionNoteDetails": "Nota y detalles", "quickTagsHelp": "Tus etiquetas frecuentes: un toque", - "quickTagsExpand": "Más" + "quickTagsExpand": "Más", + "loadError": "No se pudieron cargar tus registros de estado de ánimo." }, "medications": { "title": "Medicamentos", @@ -1852,7 +1854,20 @@ "sat": "Sábado", "sun": "Domingo" }, - "rangeBand": "Rango" + "rangeBand": "Rango", + "a11y": { + "summary": "Gráfico de {metric}. Rango de {min} a {max}, último {latest}, tendencia {trend}.", + "noData": "Gráfico de {metric}. No hay datos disponibles.", + "trendUp": "en aumento", + "trendDown": "en descenso", + "trendFlat": "estable", + "scatterSummary": "Gráfico de correlación, {count} puntos que comparan {xLabel} con {yLabel}.", + "scatterNoData": "Gráfico de correlación de {xLabel} frente a {yLabel}. No hay datos disponibles.", + "moodHeatmap": "Mapa de calor de estado de ánimo de {start} a {end}, {days} días registrados.", + "moodHeatmapEmpty": "Mapa de calor de estado de ánimo, aún no hay días registrados.", + "complianceHeatmap": "Mapa de calor de cumplimiento de medicación de {start} a {end}, {days} días activos.", + "complianceHeatmapEmpty": "Mapa de calor de cumplimiento de medicación, aún no hay días activos." + } }, "chart": { "overlay": { @@ -6173,7 +6188,9 @@ "resetToDefault": "Restablecer por defecto", "saveSuccess": "Rango objetivo actualizado", "saveError": "No se ha podido guardar el rango objetivo", - "resetSuccess": "Rango objetivo restablecido por defecto" + "resetSuccess": "Rango objetivo restablecido por defecto", + "validationOrder": "El mínimo debe ser inferior al máximo.", + "validationBounds": "Introduce valores dentro del rango permitido que se muestra debajo." }, "currentValue": "Actual: {value} {unit}", "targetRangeValue": "Rango objetivo: {min}–{max} {unit}", @@ -6831,7 +6848,8 @@ "empty": "No hay revisiones próximas.", "openLink": "Abrir revisiones" }, - "showMeasurements": "Ver mediciones" + "showMeasurements": "Ver mediciones", + "loadError": "No se pudieron cargar tus recordatorios." }, "moduleList": { "viewToggleLabel": "Vista", @@ -7312,7 +7330,8 @@ "BLOOD_PRESSURE_DIA": "Diastolic blood pressure", "PULSE": "Pulse", "WEIGHT": "Weight" - } + }, + "listLoadError": "No se pudieron cargar tus episodios." }, "coach": { "reminderSuggestion": { @@ -7372,5 +7391,12 @@ "glucoseHighDoctor": "Dos lecturas recientes fueron de {value} mg/dL o más. Vuelve a medir, hidrátate y contacta a tu médico cuanto antes si sigue así de alta.", "glucoseHighEmergency": "Dos lecturas recientes fueron de {value} mg/dL o más e indicaste síntomas. Si tienes vómitos, respiración rápida o confusión, llama a emergencias ya." } + }, + "version": { + "updateAvailable": { + "title": "Hay una nueva versión disponible", + "reload": "Recargar", + "dismiss": "Descartar" + } } } diff --git a/messages/fr.json b/messages/fr.json index 69f085b74..b3002dc3e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -35,7 +35,8 @@ "showPassword": "Afficher le mot de passe", "hidePassword": "Masquer le mot de passe", "learnMore": "En savoir plus", - "dismiss": "Ignorer" + "dismiss": "Ignorer", + "moreInfo": "Plus d'informations" }, "doctorReport": { "title": "Rapport de santé", @@ -750,7 +751,8 @@ "sectionFactors": "Facteurs", "sectionNoteDetails": "Note et détails", "quickTagsHelp": "Vos tags fréquents — en un geste", - "quickTagsExpand": "Plus" + "quickTagsExpand": "Plus", + "loadError": "Impossible de charger vos entrées d'humeur." }, "medications": { "title": "Médicaments", @@ -1852,7 +1854,20 @@ "sat": "Samedi", "sun": "Dimanche" }, - "rangeBand": "Plage" + "rangeBand": "Plage", + "a11y": { + "summary": "Graphique {metric}. Plage de {min} à {max}, dernier {latest}, tendance {trend}.", + "noData": "Graphique {metric}. Aucune donnée disponible.", + "trendUp": "en hausse", + "trendDown": "en baisse", + "trendFlat": "stable", + "scatterSummary": "Graphique de corrélation, {count} points comparant {xLabel} et {yLabel}.", + "scatterNoData": "Graphique de corrélation de {xLabel} et {yLabel}. Aucune donnée disponible.", + "moodHeatmap": "Carte thermique de l'humeur du {start} au {end}, {days} jours enregistrés.", + "moodHeatmapEmpty": "Carte thermique de l'humeur, aucun jour enregistré pour l'instant.", + "complianceHeatmap": "Carte thermique d'observance des médicaments du {start} au {end}, {days} jours actifs.", + "complianceHeatmapEmpty": "Carte thermique d'observance des médicaments, aucun jour actif pour l'instant." + } }, "chart": { "overlay": { @@ -6173,7 +6188,9 @@ "resetToDefault": "Restaurer la valeur par défaut", "saveSuccess": "Plage cible mise à jour", "saveError": "Impossible d’enregistrer la plage cible", - "resetSuccess": "Plage cible réinitialisée" + "resetSuccess": "Plage cible réinitialisée", + "validationOrder": "Le minimum doit être inférieur au maximum.", + "validationBounds": "Saisissez des valeurs dans la plage autorisée indiquée ci-dessous." }, "currentValue": "Actuel : {value} {unit}", "targetRangeValue": "Plage cible : {min}–{max} {unit}", @@ -6831,7 +6848,8 @@ "empty": "Aucun rappel d’examen à venir.", "openLink": "Ouvrir les examens" }, - "showMeasurements": "Afficher les mesures" + "showMeasurements": "Afficher les mesures", + "loadError": "Impossible de charger vos rappels." }, "moduleList": { "viewToggleLabel": "Affichage", @@ -7312,7 +7330,8 @@ "BLOOD_PRESSURE_DIA": "Diastolic blood pressure", "PULSE": "Pulse", "WEIGHT": "Weight" - } + }, + "listLoadError": "Impossible de charger vos épisodes." }, "coach": { "reminderSuggestion": { @@ -7372,5 +7391,12 @@ "glucoseHighDoctor": "Deux mesures récentes étaient à {value} mg/dL ou plus. Remesure, hydrate-toi et contacte ton médecin dès que possible si cela reste aussi élevé.", "glucoseHighEmergency": "Deux mesures récentes étaient à {value} mg/dL ou plus et tu as signalé des symptômes. En cas de vomissements, de respiration rapide ou de confusion, appelle les secours immédiatement." } + }, + "version": { + "updateAvailable": { + "title": "Une nouvelle version est disponible", + "reload": "Recharger", + "dismiss": "Ignorer" + } } } diff --git a/messages/it.json b/messages/it.json index 60e5288d4..2a234387f 100644 --- a/messages/it.json +++ b/messages/it.json @@ -35,7 +35,8 @@ "showPassword": "Mostra password", "hidePassword": "Nascondi password", "learnMore": "Scopri di più", - "dismiss": "Ignora" + "dismiss": "Ignora", + "moreInfo": "Maggiori informazioni" }, "doctorReport": { "title": "Rapporto sanitario", @@ -750,7 +751,8 @@ "sectionFactors": "Fattori", "sectionNoteDetails": "Nota e dettagli", "quickTagsHelp": "I tuoi tag frequenti: un tocco", - "quickTagsExpand": "Altro" + "quickTagsExpand": "Altro", + "loadError": "Impossibile caricare le voci dell'umore." }, "medications": { "title": "Farmaci", @@ -1852,7 +1854,20 @@ "sat": "Sabato", "sun": "Domenica" }, - "rangeBand": "Intervallo" + "rangeBand": "Intervallo", + "a11y": { + "summary": "Grafico {metric}. Intervallo da {min} a {max}, ultimo {latest}, tendenza {trend}.", + "noData": "Grafico {metric}. Nessun dato disponibile.", + "trendUp": "in aumento", + "trendDown": "in calo", + "trendFlat": "stabile", + "scatterSummary": "Grafico di correlazione, {count} punti che confrontano {xLabel} con {yLabel}.", + "scatterNoData": "Grafico di correlazione di {xLabel} e {yLabel}. Nessun dato disponibile.", + "moodHeatmap": "Mappa di calore dell'umore dal {start} al {end}, {days} giorni registrati.", + "moodHeatmapEmpty": "Mappa di calore dell'umore, nessun giorno ancora registrato.", + "complianceHeatmap": "Mappa di calore dell'aderenza ai farmaci dal {start} al {end}, {days} giorni attivi.", + "complianceHeatmapEmpty": "Mappa di calore dell'aderenza ai farmaci, nessun giorno attivo." + } }, "chart": { "overlay": { @@ -6173,7 +6188,9 @@ "resetToDefault": "Ripristina predefinito", "saveSuccess": "Range obiettivo aggiornato", "saveError": "Impossibile salvare il range obiettivo", - "resetSuccess": "Range obiettivo ripristinato" + "resetSuccess": "Range obiettivo ripristinato", + "validationOrder": "Il minimo deve essere inferiore al massimo.", + "validationBounds": "Inserisci valori entro l'intervallo consentito indicato sotto." }, "currentValue": "Attuale: {value} {unit}", "targetRangeValue": "Range obiettivo: {min}–{max} {unit}", @@ -6831,7 +6848,8 @@ "empty": "Nessun promemoria di controllo in arrivo.", "openLink": "Apri controlli" }, - "showMeasurements": "Mostra le misurazioni" + "showMeasurements": "Mostra le misurazioni", + "loadError": "Impossibile caricare i promemoria." }, "moduleList": { "viewToggleLabel": "Vista", @@ -7312,7 +7330,8 @@ "BLOOD_PRESSURE_DIA": "Diastolic blood pressure", "PULSE": "Pulse", "WEIGHT": "Weight" - } + }, + "listLoadError": "Impossibile caricare gli episodi." }, "coach": { "reminderSuggestion": { @@ -7372,5 +7391,12 @@ "glucoseHighDoctor": "Due misurazioni recenti erano di {value} mg/dL o superiori. Rimisura, idratati e contatta il medico al più presto se resta così alta.", "glucoseHighEmergency": "Due misurazioni recenti erano di {value} mg/dL o superiori e hai segnalato sintomi. In caso di vomito, respiro affannoso o confusione, chiama subito i soccorsi." } + }, + "version": { + "updateAvailable": { + "title": "È disponibile una nuova versione", + "reload": "Ricarica", + "dismiss": "Ignora" + } } } diff --git a/messages/pl.json b/messages/pl.json index 5631b6f90..8d04bfe04 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -35,7 +35,8 @@ "showPassword": "Pokaż hasło", "hidePassword": "Ukryj hasło", "learnMore": "Dowiedz się więcej", - "dismiss": "Odrzuć" + "dismiss": "Odrzuć", + "moreInfo": "Więcej informacji" }, "doctorReport": { "title": "Raport zdrowotny", @@ -750,7 +751,8 @@ "sectionFactors": "Czynniki", "sectionNoteDetails": "Notatka i szczegóły", "quickTagsHelp": "Twoje częste tagi — jedno dotknięcie", - "quickTagsExpand": "Więcej" + "quickTagsExpand": "Więcej", + "loadError": "Nie udało się załadować wpisów nastroju." }, "medications": { "title": "Leki", @@ -1852,7 +1854,20 @@ "sat": "Sobota", "sun": "Niedziela" }, - "rangeBand": "Zakres" + "rangeBand": "Zakres", + "a11y": { + "summary": "Wykres {metric}. Zakres od {min} do {max}, ostatnio {latest}, trend {trend}.", + "noData": "Wykres {metric}. Brak dostępnych danych.", + "trendUp": "rosnący", + "trendDown": "malejący", + "trendFlat": "stały", + "scatterSummary": "Wykres korelacji, {count} punktów porównujących {xLabel} z {yLabel}.", + "scatterNoData": "Wykres korelacji {xLabel} i {yLabel}. Brak dostępnych danych.", + "moodHeatmap": "Mapa cieplna nastroju od {start} do {end}, zarejestrowano {days} dni.", + "moodHeatmapEmpty": "Mapa cieplna nastroju, brak zarejestrowanych dni.", + "complianceHeatmap": "Mapa cieplna przyjmowania leków od {start} do {end}, {days} aktywnych dni.", + "complianceHeatmapEmpty": "Mapa cieplna przyjmowania leków, brak aktywnych dni." + } }, "chart": { "overlay": { @@ -6173,7 +6188,9 @@ "resetToDefault": "Przywróć wartość domyślną", "saveSuccess": "Zakres docelowy zaktualizowany", "saveError": "Nie udało się zapisać zakresu docelowego", - "resetSuccess": "Zakres docelowy przywrócono do domyślnego" + "resetSuccess": "Zakres docelowy przywrócono do domyślnego", + "validationOrder": "Wartość minimalna musi być niższa niż maksymalna.", + "validationBounds": "Wprowadź wartości w dozwolonym zakresie podanym poniżej." }, "currentValue": "Aktualnie: {value} {unit}", "targetRangeValue": "Zakres docelowy: {min}–{max} {unit}", @@ -6831,7 +6848,8 @@ "empty": "Brak nadchodzących przypomnień o badaniach.", "openLink": "Otwórz badania" }, - "showMeasurements": "Pokaż pomiary" + "showMeasurements": "Pokaż pomiary", + "loadError": "Nie udało się załadować przypomnień." }, "moduleList": { "viewToggleLabel": "Widok", @@ -7312,7 +7330,8 @@ "BLOOD_PRESSURE_DIA": "Diastolic blood pressure", "PULSE": "Pulse", "WEIGHT": "Weight" - } + }, + "listLoadError": "Nie udało się załadować epizodów." }, "coach": { "reminderSuggestion": { @@ -7372,5 +7391,12 @@ "glucoseHighDoctor": "Dwa ostatnie pomiary wyniosły {value} mg/dL lub więcej. Zmierz ponownie, nawodnij się i jak najszybciej skontaktuj się z lekarzem, jeśli pozostaje tak wysoki.", "glucoseHighEmergency": "Dwa ostatnie pomiary wyniosły {value} mg/dL lub więcej i zgłosiłeś objawy. W razie wymiotów, przyspieszonego oddechu lub dezorientacji natychmiast zadzwoń po pomoc." } + }, + "version": { + "updateAvailable": { + "title": "Dostępna jest nowa wersja", + "reload": "Załaduj ponownie", + "dismiss": "Odrzuć" + } } } From 51d67af28f837e74fb0cf8f82c065eb91ede695c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 02:52:53 +0200 Subject: [PATCH 02/94] fix(charts): give charts and heatmaps a screen-reader text alternative Wrap the shared HealthChart plus the mood, medication-compliance, and scatter-correlation charts in role="img" with a concise aria-label that states the metric, value range, latest reading, and trend direction, so VoiceOver and TalkBack announce something for every chart cell and the ~50 insight pages behind the shared component. Add the same role="img" summary to the mood and compliance heatmaps, route their tooltip dates through the locale- and timezone-aware formatter instead of a hardcoded German format, and clamp the pinned tooltip to the viewport so it no longer overflows the right edge on a narrow phone. --- .../health-chart-a11y-label.test.tsx | 69 +++++++++++++++++++ src/components/charts/compliance-heatmap.tsx | 41 +++++++---- src/components/charts/health-chart.tsx | 51 +++++++++++++- .../charts/medication-compliance-chart.tsx | 47 ++++++++++++- src/components/charts/mood-chart.tsx | 35 ++++++++++ src/components/charts/mood-heatmap.tsx | 38 +++++++--- .../charts/scatter-correlation-chart.tsx | 19 +++++ 7 files changed, 276 insertions(+), 24 deletions(-) create mode 100644 src/components/charts/__tests__/health-chart-a11y-label.test.tsx diff --git a/src/components/charts/__tests__/health-chart-a11y-label.test.tsx b/src/components/charts/__tests__/health-chart-a11y-label.test.tsx new file mode 100644 index 000000000..8a02ddda0 --- /dev/null +++ b/src/components/charts/__tests__/health-chart-a11y-label.test.tsx @@ -0,0 +1,69 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import deMessages from "../../../../messages/de.json"; + +/** + * Chart accessibility — the rendered chart must expose an image role with + * a non-empty data-summary label so a screen reader announces the series + * instead of skipping a silent SVG. Mirrors the empty-window render test: + * mock auth + react-query, render to static markup, assert the DOM. + */ + +function buildData(): unknown[] { + const out: Array<{ date: string; timestamp: number; PULSE: number }> = []; + const base = Date.UTC(2026, 0, 1, 12, 0, 0); + for (let i = 0; i < 10; i++) { + out.push({ + date: `2026-01-${String(i + 1).padStart(2, "0")}`, + timestamp: base + i * 86_400_000, + PULSE: 60 + i, + }); + } + return out; +} + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + isAuthenticated: true, + user: null, + isLoading: false, + }), +})); + +describe(" — accessibility label", () => { + beforeEach(() => { + vi.resetModules(); + }); + + it("wraps the chart in role=img with a non-empty aria-label when data is present", async () => { + const data = buildData(); + + vi.doMock("@tanstack/react-query", () => ({ + useQuery: () => ({ data, isLoading: false }), + useQueryClient: () => ({ + cancelQueries: () => Promise.resolve(), + getQueryData: () => undefined, + setQueryData: () => undefined, + invalidateQueries: () => Promise.resolve(), + }), + useMutation: () => ({ mutate: () => undefined, isPending: false }), + })); + + const { I18nProvider } = await import("@/lib/i18n/context"); + const { HealthChart } = await import("../health-chart"); + + const html = renderToStaticMarkup( + + + , + ); + + expect(html).toContain('role="img"'); + // A role=img wrapper with an empty/absent label is worse than none — + // assert the aria-label attribute carries actual text. + const match = html.match(/role="img"[^>]*aria-label="([^"]*)"/); + expect(match?.[1] ?? "").not.toBe(""); + + vi.doUnmock("@tanstack/react-query"); + }); +}); diff --git a/src/components/charts/compliance-heatmap.tsx b/src/components/charts/compliance-heatmap.tsx index e92a03f48..06071fb69 100644 --- a/src/components/charts/compliance-heatmap.tsx +++ b/src/components/charts/compliance-heatmap.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useMemo, useEffect, useRef } from "react"; -import { useTranslations } from "@/lib/i18n/context"; +import { useTranslations, useFormatters } from "@/lib/i18n/context"; interface DailyData { expected: number; @@ -72,22 +72,17 @@ function getColor(data: DailyData): string { return "var(--dracula-red)"; } -function formatDateDE(dateStr: string): string { - // v1.4.27 B7 / BL-P4-2 — parse the day-key against UTC so the - // formatted tooltip label matches the dateKey computation below - // (which uses `toISOString().slice(0, 10)`). Without the UTC - // anchor an SSR server in a non-Berlin timezone could format the - // tick a day off the dateKey it sits under. - const d = new Date(dateStr + "T00:00:00Z"); - return `${d.getUTCDate()}.${d.getUTCMonth() + 1}.${d.getUTCFullYear()}`; -} - export function ComplianceHeatmap({ dailyCompliance, days = 90, stretch = false, }: ComplianceHeatmapProps) { const { t } = useTranslations(); + const fmt = useFormatters(); + // Day keys are UTC-anchored "YYYY-MM-DD"; format at noon UTC so the + // locale-aware renderer never lands a day off in either direction. + const formatDay = (dateKey: string) => + fmt.date(new Date(`${dateKey}T12:00:00Z`)); const containerRef = useRef(null); const [containerWidth, setContainerWidth] = useState(0); const [tooltip, setTooltip] = useState<{ @@ -230,6 +225,16 @@ export function ComplianceHeatmap({ return { cells: cellList, weeks: col + 1, monthMarkers: markers }; }, [dailyCompliance, days, t]); + const activeDays = cells.filter((cell) => cell.data.expected > 0).length; + const summaryLabel = + cells.length === 0 || activeDays === 0 + ? t("charts.a11y.complianceHeatmapEmpty") + : t("charts.a11y.complianceHeatmap", { + start: formatDay(cells[0].dateKey), + end: formatDay(cells[cells.length - 1].dateKey), + days: activeDays, + }); + const labelWidth = stretch ? 0 : 76; const headerHeight = 18; // v1.4.27 MB7 / CF-10 — clamp the stretch-branch adaptive cell to the @@ -268,6 +273,8 @@ export function ComplianceHeatmap({ {tooltip.text} diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index 6ea6cff27..60f97d96a 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -1386,6 +1386,51 @@ export function HealthChart({ const formatTooltipValue = (value: number) => fmt.number(value, 1); + // Accessibility — a concise spoken summary of the primary series so a screen + // reader announces the shape of the chart (range, latest value, direction) + // instead of an unlabelled graphic. Mirrors the visible-range stats the + // chart already holds; falls back to the raw min/max when the stat strip + // is suppressed (mini mode). + const primaryType = types[0]; + const chartAriaLabel = useMemo(() => { + const metric = getTypeLabel(primaryType, valueMode, t); + const source = chartDataWithCompare ?? chartData; + const values = (source ?? []) + .map((point) => point[primaryType]) + .filter((v): v is number => typeof v === "number" && Number.isFinite(v)); + if (values.length === 0) { + return t("charts.a11y.noData", { metric }); + } + const stats = visibleStatsByType?.[primaryType]; + const minVal = stats?.min ?? Math.min(...values); + const maxVal = stats?.max ?? Math.max(...values); + const latestVal = values[values.length - 1]; + const firstVal = values[0]; + const epsilon = Math.max(Math.abs(maxVal - minVal) * 0.02, 0.01); + const delta = latestVal - firstVal; + const trend = + delta > epsilon + ? t("charts.a11y.trendUp") + : delta < -epsilon + ? t("charts.a11y.trendDown") + : t("charts.a11y.trendFlat"); + return t("charts.a11y.summary", { + metric, + min: fmt.number(minVal, 1), + max: fmt.number(maxVal, 1), + latest: fmt.number(latestVal, 1), + trend, + }); + }, [ + primaryType, + valueMode, + t, + chartDataWithCompare, + chartData, + visibleStatsByType, + fmt, + ]); + const trendInfo = useMemo(() => { if (!showTrend || !chartData?.length) return []; @@ -1734,7 +1779,11 @@ export function HealthChart({ ) : null} -
+
`${fmt.integer(value)} %`; const animationsEnabled = !prefersReducedMotion(); + // Accessibility — a concise spoken summary of the compliance series so a + // screen reader announces the adherence range, the latest day and the + // direction rather than an unlabelled graphic. Reuses the same 7-day trend + // the chip surfaces; falls back to a first-vs-last comparison when the + // window is too short for the trend helper. + const complianceAriaLabel = ((): string => { + const metric = displayTitle; + const rates = chartData + .map((p) => p.rate) + .filter((v): v is number => typeof v === "number" && Number.isFinite(v)); + if (rates.length === 0) { + return t("charts.a11y.noData", { metric }); + } + const pct = (v: number) => `${fmt.integer(v)} %`; + const minVal = Math.min(...rates); + const maxVal = Math.max(...rates); + const latestVal = rates[rates.length - 1]; + let trendWord: string; + if (trend && trend.direction !== "stable") { + trendWord = + trend.direction === "up" + ? t("charts.a11y.trendUp") + : t("charts.a11y.trendDown"); + } else { + const delta = latestVal - rates[0]; + trendWord = + delta > 1 + ? t("charts.a11y.trendUp") + : delta < -1 + ? t("charts.a11y.trendDown") + : t("charts.a11y.trendFlat"); + } + return t("charts.a11y.summary", { + metric, + min: pct(minVal), + max: pct(maxVal), + latest: pct(latestVal), + trend: trendWord, + }); + })(); + // Empty-state guard: if the user has zero scheduled doses across the // whole window we render the title + a "no data" hint, mirroring how // the other charts handle the empty case (HealthChart returns null @@ -475,7 +516,11 @@ export function MedicationComplianceChart({ /> ) ) : ( -
+
{ + const metric = displayTitle; + const scores = (chartData ?? []) + .map((d) => d.score) + .filter((v): v is number => typeof v === "number" && Number.isFinite(v)); + if (scores.length === 0) { + return t("charts.a11y.noData", { metric }); + } + const fmtScore = (v: number) => String(Math.round(v * 10) / 10); + const minVal = Math.min(...scores); + const maxVal = Math.max(...scores); + const latestVal = scores[scores.length - 1]; + const firstVal = scores[0]; + const epsilon = 0.1; + const delta = latestVal - firstVal; + const trend = + delta > epsilon + ? t("charts.a11y.trendUp") + : delta < -epsilon + ? t("charts.a11y.trendDown") + : t("charts.a11y.trendFlat"); + return t("charts.a11y.summary", { + metric, + min: fmtScore(minVal), + max: fmtScore(maxVal), + latest: fmtScore(latestVal), + trend, + }); + })(); + const chartBody = isLoading ? ( // v1.16.0 — height-matched skeleton band instead of the former // `h-48` spinner box: the loading state occupies the same box the @@ -680,6 +713,8 @@ export function MoodChart({ ? "h-[var(--chart-height,140px)]" : "h-[var(--chart-height,200px)] md:h-[var(--chart-height-md,220px)]" } touch-pan-y`} + role="img" + aria-label={moodAriaLabel} > + fmt.date(new Date(`${dateKey}T12:00:00Z`)); const containerRef = useRef(null); const [containerWidth, setContainerWidth] = useState(0); const [tooltip, setTooltip] = useState<{ @@ -185,6 +185,16 @@ export function MoodHeatmap({ return { cells: cellList, weeks: col + 1, monthMarkers: markers }; }, [cellData, days, t]); + const loggedDays = cells.filter((cell) => cell.cell !== null).length; + const summaryLabel = + cells.length === 0 || loggedDays === 0 + ? t("charts.a11y.moodHeatmapEmpty") + : t("charts.a11y.moodHeatmap", { + start: formatDay(cells[0].dateKey), + end: formatDay(cells[cells.length - 1].dateKey), + days: loggedDays, + }); + const labelWidth = stretch ? 0 : 76; const headerHeight = 18; const cellSize = @@ -214,6 +224,8 @@ export function MoodHeatmap({ { const buildText = (): string => { if (!cell.cell) { - return `${formatDateDE(cell.dateKey)}: ${t("insights.mood.heatmapNoEntry")}`; + return `${formatDay(cell.dateKey)}: ${t("insights.mood.heatmapNoEntry")}`; } const labelKey = moodLabelKeyForScore( Math.round(cell.cell.score), ); const moodLabel = labelKey ? t(labelKey) : ""; - return `${formatDateDE(cell.dateKey)}: ${cell.cell.score.toFixed(1)}${moodLabel ? ` · ${moodLabel}` : ""}`; + return `${formatDay(cell.dateKey)}: ${cell.cell.score.toFixed(1)}${moodLabel ? ` · ${moodLabel}` : ""}`; }; return ( {tooltip.text}
diff --git a/src/components/charts/scatter-correlation-chart.tsx b/src/components/charts/scatter-correlation-chart.tsx index 1f6f9f3ed..a75d3d7ac 100644 --- a/src/components/charts/scatter-correlation-chart.tsx +++ b/src/components/charts/scatter-correlation-chart.tsx @@ -10,6 +10,7 @@ import { XAxis, YAxis, } from "recharts"; +import { useTranslations } from "@/lib/i18n/context"; /** * Self-contained correlation scatter chart used by /insights. @@ -82,6 +83,22 @@ export function ScatterCorrelationChart>({ tooltipFormatter, height = 250, }: ScatterCorrelationChartProps) { + const { t } = useTranslations(); + // Accessibility — a concise spoken summary so a screen reader announces + // what the scatter relates and how many points it plots rather than an + // unlabelled graphic. + const count = data?.length ?? 0; + const summary = + count === 0 + ? t("charts.a11y.scatterNoData", { + xLabel: xAxis.name, + yLabel: yAxis.name, + }) + : t("charts.a11y.scatterSummary", { + count, + xLabel: xAxis.name, + yLabel: yAxis.name, + }); // v1.4.27 MB7 / CF-37 — drop the fixed pixel height in favour of a // responsive aspect-ratio on mobile so the chart never compresses // below readable axes. The `aspect-square` ratio at `>({ className="aspect-square min-h-[180px] touch-pan-y sm:aspect-[3/2] sm:h-auto" style={{ height: undefined }} data-explicit-height={height} + role="img" + aria-label={summary} > From a74a0c9b0925aa7f2ff81797cabd51c11e039e14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 02:52:59 +0200 Subject: [PATCH 03/94] fix(version-poller): surface a reload toast instead of a silent hard reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 60s poller used to unregister service workers, wipe CacheStorage, and call location.reload() the moment it saw a version bump, with no prompt — firing mid-form, mid-message, or mid-wizard and discarding whatever the user had typed. Keep detecting the new build, but now show a non-blocking, dismissible toast with a Reload action; the eviction and reload run only when the user chooses to reload. Show the toast once per detected version. --- .../__tests__/version-poller.test.ts | 16 +++--- src/components/version-poller.tsx | 54 ++++++++++++++----- 2 files changed, 50 insertions(+), 20 deletions(-) diff --git a/src/components/__tests__/version-poller.test.ts b/src/components/__tests__/version-poller.test.ts index b079fea7a..fb3a6b938 100644 --- a/src/components/__tests__/version-poller.test.ts +++ b/src/components/__tests__/version-poller.test.ts @@ -1,12 +1,16 @@ /** - * Unit tests for the version-poller reload decision. + * Unit tests for the version-poller decision. * * The guard is keyed on the TARGET (live) version: a repeat mismatch - * against the SAME version is suppressed (no reload loop on a misset - * server), but a SECOND deploy in one session moves the live version - * past the recorded one and the heal flow re-arms. The pre-v1.16.8 - * guard disabled all polling after one attempt, which stranded stale - * shells on multi-deploy days. + * against the SAME version is suppressed (no loop on a misset server), + * but a SECOND deploy in one session moves the live version past the + * recorded one and the flow re-arms. + * + * The runtime behaviour — a detected bump surfaces a Reload toast and + * never reloads the page on its own — lives in `version-poller.tsx`: + * the service-worker eviction + `location.reload()` run only from the + * toast action's `onClick`, so an in-progress form or chat draft is + * never destroyed unprompted. */ import { describe, expect, it } from "vitest"; diff --git a/src/components/version-poller.tsx b/src/components/version-poller.tsx index a4514311e..b5a7420dc 100644 --- a/src/components/version-poller.tsx +++ b/src/components/version-poller.tsx @@ -1,8 +1,10 @@ "use client"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; +import { toast } from "sonner"; import { apiGet } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; /** * v1.4.38.4 — runtime self-heal for the stale-shell post-deploy @@ -20,7 +22,10 @@ import { apiGet } from "@/lib/api/api-fetch"; * `/api/version` and compares the live version string against * `NEXT_PUBLIC_APP_VERSION` (injected from `package.json` at build * time by `next.config.ts`). When the live version moves ahead of - * the running shell: + * the running shell it surfaces a non-blocking toast with a Reload + * action. The eviction + reload only runs when the user clicks + * Reload — an in-progress form or chat draft is never destroyed by a + * silent mid-session reload. On that click: * * 1. Unregister every active service worker so the next page load * doesn't reinstall the old SW. @@ -30,15 +35,15 @@ import { apiGet } from "@/lib/api/api-fetch"; * 3. `window.location.reload()` — fetches the fresh shell + new * chunk graph. * - * The reload guard is keyed on the TARGET version: `sessionStorage` - * records which live version we last reloaded for, and only a repeat - * mismatch against that SAME version is suppressed (a misset version — - * server briefly serves a stale image mid-deploy — cannot loop). - * Polling itself never stops, so on a multi-deploy day a SECOND - * release moves the live version past the recorded one and the heal - * flow re-arms. The pre-v1.16.8 guard disabled all polling for the - * rest of the session after one attempt, which left a stale shell - * with 404ing chunks stuck forever once the guard was spent. + * The toast is shown once per detected version (the poll keeps + * running, but a repeated mismatch against a version we already + * surfaced doesn't re-spam every 60 s). The reload guard is keyed on + * the TARGET version: `sessionStorage` records which live version we + * last reloaded for, and only a repeat mismatch against that SAME + * version is suppressed (a misset version — server briefly serves a + * stale image mid-deploy — cannot loop). On a multi-deploy day a + * SECOND release moves the live version past the recorded one and the + * flow re-arms. */ const POLL_INTERVAL_MS = 60_000; @@ -116,6 +121,11 @@ async function evictAndReload(targetVersion: string): Promise { } export function VersionPoller(): null { + const { t } = useTranslations(); + // Tracks the live version we've already surfaced a toast for, so a + // repeated mismatch on the recurring poll doesn't re-spam. + const notifiedForRef = useRef(null); + useEffect(() => { if (!SHELL_VERSION) return; if (typeof window === "undefined") return; @@ -137,11 +147,27 @@ export function VersionPoller(): null { lastReloadedFor, ); if (decision !== "reload") return; - await evictAndReload(live as string); + + const target = live as string; + // One toast per detected version — don't re-open it every poll. + if (notifiedForRef.current === target) return; + notifiedForRef.current = target; + + toast(t("version.updateAvailable.title"), { + duration: Infinity, + action: { + label: t("version.updateAvailable.reload"), + onClick: () => void evictAndReload(target), + }, + cancel: { + label: t("version.updateAvailable.dismiss"), + onClick: () => {}, + }, + }); } // First check 5 s after mount — gives the app a chance to settle - // before we touch caches. Then on the recurring interval. + // before we surface anything. Then on the recurring interval. const initial = window.setTimeout(() => void checkOnce(), 5_000); const interval = window.setInterval( () => void checkOnce(), @@ -153,7 +179,7 @@ export function VersionPoller(): null { window.clearInterval(interval); controller.abort(); }; - }, []); + }, [t]); return null; } From b02e3e4072579270fbcb08387be390eef9173c4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 02:53:06 +0200 Subject: [PATCH 04/94] fix(mobile): close safe-area gaps and lift sub-44px tap targets Subtract both the top and bottom safe-area insets from the full-page Coach height so the composer no longer slides behind the bottom-nav on a notched phone, and pad the Coach drawer composer and the ResponsiveSheet bottom-sheet footer for the home-indicator band. Raise the cycle and illness day-log severity controls to a 44px touch floor, add hit-slop to the custom-symptom remove button, give the shared day-log chips a min height, and let the mood-tag action cluster wrap so a 360px row no longer clips the tag name. --- src/app/coach/page.tsx | 2 +- src/components/cycle/log-day-sheet.tsx | 6 +- src/components/illness/log-day-sheet.tsx | 6 +- .../coach-panel/coach-drawer-body.tsx | 2 +- .../mood/manage/tag-manager-card.tsx | 206 +++++++++--------- src/components/ui/responsive-sheet.tsx | 2 +- 6 files changed, 114 insertions(+), 110 deletions(-) diff --git a/src/app/coach/page.tsx b/src/app/coach/page.tsx index 85870cf99..c956134fc 100644 --- a/src/app/coach/page.tsx +++ b/src/app/coach/page.tsx @@ -136,7 +136,7 @@ export default function CoachPage() { // `env(safe-area-inset-bottom)`) so the docked composer / Stop control // always clears the nav instead of sitting under it. On `md+` the nav // is hidden, so the page reclaims the full height below the TopBar. - className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6 md:h-[calc(100dvh-4rem)]" + className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-top,0px)-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6 md:h-[calc(100dvh-4rem)]" > {/* `useSearchParams` requires a Suspense boundary so the client-search bailout never opts the whole route out of static optimisation. */} diff --git a/src/components/cycle/log-day-sheet.tsx b/src/components/cycle/log-day-sheet.tsx index 00a78ba19..9a0c4797e 100644 --- a/src/components/cycle/log-day-sheet.tsx +++ b/src/components/cycle/log-day-sheet.tsx @@ -423,7 +423,7 @@ function Chip({ aria-pressed={active} onClick={onClick} className={cn( - "focus-visible:ring-ring/50 rounded-full border px-3 py-1.5 text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none", + "focus-visible:ring-ring/50 inline-flex min-h-11 items-center rounded-full border px-3 py-1.5 text-sm transition-colors focus-visible:ring-2 focus-visible:outline-none sm:min-h-8", active ? "border-primary bg-primary/10 text-primary" : "border-border text-foreground hover:bg-accent", @@ -1225,7 +1225,7 @@ function SymptomChip({ onClick={handleRemove} disabled={deleteCustom.isPending} aria-label={t("cycle.symptom.custom.remove", { label })} - className="border-border bg-background text-muted-foreground hover:text-destructive hover:border-destructive focus-visible:ring-ring/50 -ml-1.5 grid size-4 shrink-0 place-items-center rounded-full border transition-colors focus-visible:ring-2 focus-visible:outline-none" + className="border-border bg-background text-muted-foreground hover:text-destructive hover:border-destructive focus-visible:ring-ring/50 relative -ml-1.5 grid size-4 shrink-0 place-items-center rounded-full border transition-colors before:absolute before:-inset-2 before:content-[''] focus-visible:ring-2 focus-visible:outline-none" >
); } diff --git a/src/components/ui/responsive-sheet.tsx b/src/components/ui/responsive-sheet.tsx index e212b3358..d302c99af 100644 --- a/src/components/ui/responsive-sheet.tsx +++ b/src/components/ui/responsive-sheet.tsx @@ -180,7 +180,7 @@ export function ResponsiveSheet({ {footer ? ( {footer} From c38271bc44ca05dcce368fd8d5e48b2a90be4e5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 02:53:14 +0200 Subject: [PATCH 05/94] fix(a11y): distinguish errors from empty states and close naming gaps Add isError branches with a retry affordance to the mood, illness, and vorsorge lists so a failed read no longer renders as "no entries yet" and invites duplicate re-entry, matching the labs and cycle pattern. Make the admin backups table reachable on a phone with a card fallback, name the admin language and timezone selects and the AI raw-data switch for screen readers, and replace the touch-unreachable title= help on the glucose clinical panels with a tap-capable info popover. Route the password-strength label through the AA-tuned semantic tokens, underline the register login link so it is not distinguished by colour alone, give the labs load error alert semantics, and explain why the target editor Save button is disabled with aria-invalid and a live alert. --- src/app/auth/register/page.tsx | 8 +- src/components/admin/backups-section.tsx | 194 ++++++++++++------ .../admin/general-settings-section.tsx | 2 + src/components/illness/illness-view.tsx | 33 ++- .../glucose/glucose-advanced-disclosure.tsx | 4 +- .../glucose/glucose-clinical-panel.tsx | 13 +- src/components/labs/lab-list.tsx | 2 +- .../vorsorge-section.tsx | 30 ++- src/components/mood/mood-list.tsx | 21 +- .../settings/ai/runtime-actions-row.tsx | 1 + src/components/targets/target-edit-sheet.tsx | 60 ++++++ src/components/ui/info-hint.tsx | 56 +++++ src/components/ui/password-strength.tsx | 14 +- 13 files changed, 350 insertions(+), 88 deletions(-) create mode 100644 src/components/ui/info-hint.tsx diff --git a/src/app/auth/register/page.tsx b/src/app/auth/register/page.tsx index 4c6173af1..73c1f10a6 100644 --- a/src/app/auth/register/page.tsx +++ b/src/app/auth/register/page.tsx @@ -194,7 +194,13 @@ export default function RegisterPage() {

{t("auth.hasAccount")}{" "} - + {t("auth.login")}

diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index 1ec8bbb11..f63244557 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -467,74 +467,134 @@ export function BackupsSection() { />
) : ( -
- - - - - - - - - - - - {rows.map((row, i) => ( - - - - - - +
+ {/* Wide layout: the canonical table. Hidden below `md`, where the + five columns push Download / Restore off-screen behind a + horizontal scroll; the card list below takes over there. */} +
+
- {t("admin.section.backups.colUser")} - - {t("admin.section.backups.colType")} - - {t("admin.section.backups.colSize")} - - {t("admin.section.backups.colCreatedAt")} - - {t("admin.section.backups.colActions")} -
{row.username} - - {formatBackupType(row.type, t)} - - - {formatBytes(row.sizeBytes, fmt)} - - {fmt.dateTime(row.createdAt)} - -
- - restore.mutate(row)} - /> -
-
+ + + + + + + - ))} - -
+ {t("admin.section.backups.colUser")} + + {t("admin.section.backups.colType")} + + {t("admin.section.backups.colSize")} + + {t("admin.section.backups.colCreatedAt")} + + {t("admin.section.backups.colActions")} +
+ + + {rows.map((row, i) => ( + + {row.username} + + + {formatBackupType(row.type, t)} + + + + {formatBytes(row.sizeBytes, fmt)} + + + {fmt.dateTime(row.createdAt)} + + +
+ + restore.mutate(row)} + /> +
+ + + ))} + + +
+ + {/* Narrow layout: each backup as a self-contained card with the + meta line + a flex-wrapping action row, so Download / Restore + stay reachable on a phone instead of scrolling off-screen. */} +
    + {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)} + /> +
    +
  • + ))} +
+

{t("admin.section.backups.retentionLabel", { days: data!.retentionDays, diff --git a/src/components/admin/general-settings-section.tsx b/src/components/admin/general-settings-section.tsx index 8e1dc1011..62cb0d438 100644 --- a/src/components/admin/general-settings-section.tsx +++ b/src/components/admin/general-settings-section.tsx @@ -68,6 +68,7 @@ export function GeneralSettingsSection() {

updateSettings.mutate({ defaultLocale: e.target.value }) @@ -94,6 +95,7 @@ export function GeneralSettingsSection() {

updateSettings.mutate({ defaultUserTimezone: e.target.value }) diff --git a/src/components/illness/illness-view.tsx b/src/components/illness/illness-view.tsx index 4db8ab460..a6559cc06 100644 --- a/src/components/illness/illness-view.tsx +++ b/src/components/illness/illness-view.tsx @@ -17,7 +17,13 @@ */ import { useId, useMemo, useState } from "react"; import Link from "next/link"; -import { ChevronDown, Plus, Stethoscope, Wrench } from "lucide-react"; +import { + ChevronDown, + Plus, + RefreshCw, + Stethoscope, + Wrench, +} from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -308,7 +314,12 @@ function EpisodeGroup({ export function IllnessView() { const { t } = useTranslations(); - const { data: episodes, isLoading } = useIllnessEpisodes(true); + const { + data: episodes, + isLoading, + isError, + refetch, + } = useIllnessEpisodes(true); const resolve = useResolveEpisode(); const { prefs } = useModuleListPrefs("illness"); @@ -418,6 +429,24 @@ export function IllnessView() { + ) : isError ? ( +
+ {t("illness.listLoadError")} + +
) : hasEpisodes ? ( <> {/* Retrospective summary sits above the list so a long history diff --git a/src/components/insights/glucose/glucose-advanced-disclosure.tsx b/src/components/insights/glucose/glucose-advanced-disclosure.tsx index 362dfcc29..4392173f2 100644 --- a/src/components/insights/glucose/glucose-advanced-disclosure.tsx +++ b/src/components/insights/glucose/glucose-advanced-disclosure.tsx @@ -3,6 +3,7 @@ import { useId, useState } from "react"; import { ChevronDown } from "lucide-react"; +import { InfoHint } from "@/components/ui/info-hint"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import type { GlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics"; @@ -146,8 +147,9 @@ function AdvancedStat({ }) { return (
-
+
{label} +
{value} diff --git a/src/components/insights/glucose/glucose-clinical-panel.tsx b/src/components/insights/glucose/glucose-clinical-panel.tsx index cbc1535fc..b27317673 100644 --- a/src/components/insights/glucose/glucose-clinical-panel.tsx +++ b/src/components/insights/glucose/glucose-clinical-panel.tsx @@ -5,6 +5,7 @@ import { Activity, Droplet, FlaskConical, TrendingUp } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { InfoHint } from "@/components/ui/info-hint"; import { LearningGate } from "@/components/ui/learning-gate"; import { LearnMoreLink } from "@/components/ui/learn-more-link"; import { Skeleton } from "@/components/ui/skeleton"; @@ -195,11 +196,9 @@ export function GlucoseClinicalPanel() { ) : null} {cv ? (
- + {t("insights.bloodGlucose.clinical.cv.label")} +
@@ -283,12 +282,10 @@ function Metric({ }) { return (
- + {icon} {label} + {help ? : null} {value} diff --git a/src/components/labs/lab-list.tsx b/src/components/labs/lab-list.tsx index 1f45b1518..e0c597f9d 100644 --- a/src/components/labs/lab-list.tsx +++ b/src/components/labs/lab-list.tsx @@ -165,7 +165,7 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { if (isError) { return ( -

+

{t("labs.loadError")}

); diff --git a/src/components/measurement-reminders/vorsorge-section.tsx b/src/components/measurement-reminders/vorsorge-section.tsx index 280702666..e43368c38 100644 --- a/src/components/measurement-reminders/vorsorge-section.tsx +++ b/src/components/measurement-reminders/vorsorge-section.tsx @@ -35,6 +35,7 @@ import { CalendarClock, CheckCircle2, Plus, + RefreshCw, Wrench, } from "lucide-react"; @@ -219,7 +220,12 @@ export function VorsorgeSection({ variant?: "settings" | "page"; }) { const { t } = useTranslations(); - const { data: reminders, isLoading } = useMeasurementReminders(enabled); + const { + data: reminders, + isLoading, + isError, + refetch, + } = useMeasurementReminders(enabled); const { create, update, remove, satisfy } = useMeasurementReminderMutations(); // v1.18.6 (MOD-03) — page view (cards/list) + manual order persist // client-side; the settings page writes them, the page reads them. @@ -662,7 +668,27 @@ export function VorsorgeSection({
)} - {!isLoading && (reminders?.length ?? 0) === 0 && ( + {!isLoading && isError && ( +
+ {t("measurementReminders.loadError")} + +
+ )} + + {!isLoading && !isError && (reminders?.length ?? 0) === 0 && ( } title={t("measurementReminders.empty.title")} diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index 772f902f6..d5a90d6f4 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -43,6 +43,7 @@ import { Loader2, Pencil, Plus, + RefreshCw, Smile, Trash2, ChevronLeft, @@ -219,7 +220,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { const toParam = toDay || undefined; const sourceParam = sourceFilter === "ALL" ? undefined : sourceFilter; - const { data, isLoading } = useQuery({ + const { data, isLoading, isError, refetch } = useQuery({ queryKey: queryKeys.moodEntriesList({ mood: moodFilter === "ALL" ? undefined : moodFilter, source: sourceParam, @@ -492,6 +493,24 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
+ ) : isError ? ( +
+ {t("mood.loadError")} + +
) : !data?.entries?.length ? ( // v1.4.15 phase-C5: replace bare-text empty rectangle with // EmptyState. Filter-aware copy splits "no mood entries yet" diff --git a/src/components/settings/ai/runtime-actions-row.tsx b/src/components/settings/ai/runtime-actions-row.tsx index c83a8f670..070ef3730 100644 --- a/src/components/settings/ai/runtime-actions-row.tsx +++ b/src/components/settings/ai/runtime-actions-row.tsx @@ -245,6 +245,7 @@ export function RuntimeActionsRow({

diff --git a/src/components/targets/target-edit-sheet.tsx b/src/components/targets/target-edit-sheet.tsx index 27df912ae..9edd6110a 100644 --- a/src/components/targets/target-edit-sheet.tsx +++ b/src/components/targets/target-edit-sheet.tsx @@ -307,6 +307,52 @@ function TargetEditSheetBody({ const canSave = !isDerivedMetric && primaryValid && secondaryValid && !busy; + // Per-field validity so the inputs can carry `aria-invalid` and the + // disabled Save button gets a spoken explanation instead of greying out + // silently. The order check (min < max) flags both ends of the pair. + const primaryOrderInvalid = + Number.isFinite(minNum) && Number.isFinite(maxNum) && minNum >= maxNum; + const primaryMinInvalid = + primaryBounds != null && + (!Number.isFinite(minNum) || + minNum < primaryBounds.min || + minNum > primaryBounds.max || + primaryOrderInvalid); + const primaryMaxInvalid = + primaryBounds != null && + (!Number.isFinite(maxNum) || + maxNum < primaryBounds.min || + maxNum > primaryBounds.max || + primaryOrderInvalid); + + const secondaryOrderInvalid = + isBp && + Number.isFinite(diaMinNum) && + Number.isFinite(diaMaxNum) && + diaMinNum >= diaMaxNum; + const secondaryMinInvalid = + isBp && + secondaryBounds != null && + (!Number.isFinite(diaMinNum) || + diaMinNum < secondaryBounds.min || + diaMinNum > secondaryBounds.max || + secondaryOrderInvalid); + const secondaryMaxInvalid = + isBp && + secondaryBounds != null && + (!Number.isFinite(diaMaxNum) || + diaMaxNum < secondaryBounds.min || + diaMaxNum > secondaryBounds.max || + secondaryOrderInvalid); + + let validationMessage: string | null = null; + if (!isDerivedMetric && primaryBounds && (!primaryValid || !secondaryValid)) { + validationMessage = + primaryOrderInvalid || secondaryOrderInvalid + ? t("targets.edit.validationOrder") + : t("targets.edit.validationBounds"); + } + const handleSave = () => { if (!canSave) return; if (isBp) { @@ -426,6 +472,7 @@ function TargetEditSheetBody({ value={displayMin} onChange={(e) => setMinStr(e.target.value)} disabled={busy} + aria-invalid={primaryMinInvalid} data-slot="target-edit-min-input" />
@@ -444,6 +491,7 @@ function TargetEditSheetBody({ value={displayMax} onChange={(e) => setMaxStr(e.target.value)} disabled={busy} + aria-invalid={primaryMaxInvalid} data-slot="target-edit-max-input" />
@@ -467,6 +515,7 @@ function TargetEditSheetBody({ value={displayDiaMin} onChange={(e) => setDiaMinStr(e.target.value)} disabled={busy} + aria-invalid={secondaryMinInvalid} data-slot="target-edit-dia-min-input" /> @@ -483,6 +532,7 @@ function TargetEditSheetBody({ value={displayDiaMax} onChange={(e) => setDiaMaxStr(e.target.value)} disabled={busy} + aria-invalid={secondaryMaxInvalid} data-slot="target-edit-dia-max-input" /> @@ -496,6 +546,16 @@ function TargetEditSheetBody({ unit: primaryBounds.unit, })}

+ + {validationMessage && ( +

+ {validationMessage} +

+ )} )} diff --git a/src/components/ui/info-hint.tsx b/src/components/ui/info-hint.tsx new file mode 100644 index 000000000..671b09cec --- /dev/null +++ b/src/components/ui/info-hint.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { Info } from "lucide-react"; + +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; + +/** + * A small, tap-reachable help affordance. Replaces the bare `title=` + * attribute, which never surfaces on touch. The trigger is a real button + * with an accessible name, so keyboard and screen-reader users reach the + * same hint a pointer user gets on hover; tapping the button focuses it, + * which opens the tooltip on touch devices. + */ +export function InfoHint({ + label, + className, +}: { + /** The hint text shown inside the tooltip. */ + label: string; + className?: string; +}) { + const { t } = useTranslations(); + return ( + + + + + + + {label} + + + + ); +} diff --git a/src/components/ui/password-strength.tsx b/src/components/ui/password-strength.tsx index 03a67e15c..32011560f 100644 --- a/src/components/ui/password-strength.tsx +++ b/src/components/ui/password-strength.tsx @@ -10,12 +10,16 @@ interface PasswordStrengthProps { minLength?: number; } +// The bar fill stays vivid (it's a non-text indicator), but the label text +// routes through the AA-tuned semantic tokens — the raw Tailwind 500 tones +// failed contrast against the white card. Weakest is destructive, the middle +// band warning, the strong band success. const SCORE_COLORS = [ - { color: "bg-red-500", textColor: "text-red-500" }, - { color: "bg-orange-500", textColor: "text-orange-500" }, - { color: "bg-yellow-500", textColor: "text-yellow-500" }, - { color: "bg-green-500", textColor: "text-green-500" }, - { color: "bg-emerald-600", textColor: "text-emerald-600" }, + { color: "bg-red-500", textColor: "text-destructive" }, + { color: "bg-orange-500", textColor: "text-destructive" }, + { color: "bg-yellow-500", textColor: "text-warning" }, + { color: "bg-green-500", textColor: "text-success" }, + { color: "bg-emerald-600", textColor: "text-success" }, ]; export function PasswordStrength({ From 5ea17398bc8f3f11109baa76420412deaf442efb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 03:05:10 +0200 Subject: [PATCH 06/94] feat(security): encrypt medication free-text notes at rest + password-change MFA step-up Encrypt MedicationSideEffect.notes, MedicationDoseChange.note and MedicationInventoryItem.notes with AES-256-GCM, closing the last plaintext PHI columns left after the v1.23 note-encryption rollout. Add the three *Encrypted Bytes columns (migration 0208), register them in the encrypted- column rotation set, route every read/write through the note cipher, and add a transactional fail-closed boot-time backfill for legacy rows. Plaintext columns drop in a later release once the backfill reports zero remaining rows. Require a fresh second factor on password change for MFA-enrolled accounts, matching the existing step-up on MFA-disable and recovery-code regen. Redact free-text note values from the Zod validation excerpt that can reach a wide event. --- .../migration.sql | 35 ++ prisma/schema.prisma | 12 + scripts/rotate-encryption-key.ts | 27 ++ .../inventory-write-isolation.test.ts | 6 +- .../api/auth/password/__tests__/route.test.ts | 63 ++++ src/app/api/auth/password/route.ts | 13 +- src/app/api/dashboard/widgets/route.ts | 8 +- src/app/api/insights/glp1-timeline/route.ts | 3 +- src/app/api/insights/layout/route.ts | 6 +- src/app/api/measurements/series/route.ts | 6 +- src/app/api/medications/[id]/glp1/route.ts | 13 +- .../[id]/inventory/[itemId]/route.ts | 13 +- .../api/medications/[id]/inventory/route.ts | 12 +- .../[id]/side-effects/__tests__/route.test.ts | 43 ++- .../medications/[id]/side-effects/route.ts | 14 +- src/app/api/medications/layout/route.ts | 6 +- src/lib/ai/coach/glp1-snapshot.ts | 17 +- src/lib/crypto/__tests__/note-cipher.test.ts | 65 ++++ src/lib/crypto/encrypted-columns.ts | 7 + src/lib/crypto/note-cipher.ts | 41 +++ src/lib/doctor-report-data.ts | 3 +- .../med-notes-encryption-backfill.test.ts | 228 +++++++++++++ src/lib/jobs/med-notes-encryption-backfill.ts | 305 ++++++++++++++++++ src/lib/jobs/reminder/register-maintenance.ts | 97 ++++++ src/lib/medications/inventory/service.ts | 5 +- .../__tests__/redact-payload.test.ts | 58 ++++ src/lib/observability/redact-payload.ts | 65 +++- 27 files changed, 1111 insertions(+), 60 deletions(-) create mode 100644 prisma/migrations/0208_v125_med_notes_encryption/migration.sql create mode 100644 src/lib/jobs/__tests__/med-notes-encryption-backfill.test.ts create mode 100644 src/lib/jobs/med-notes-encryption-backfill.ts diff --git a/prisma/migrations/0208_v125_med_notes_encryption/migration.sql b/prisma/migrations/0208_v125_med_notes_encryption/migration.sql new file mode 100644 index 000000000..f711bdd0d --- /dev/null +++ b/prisma/migrations/0208_v125_med_notes_encryption/migration.sql @@ -0,0 +1,35 @@ +-- v1.25 — encrypt medication free-text notes at rest. +-- +-- Adds the AES-256-GCM ciphertext columns for the three medication +-- free-text fields that were still plaintext: the side-effect note +-- (`medication_side_effects.notes`), the dose-change titration note +-- (`medication_dose_changes.note`), and the inventory-item note +-- (`medication_inventory_items.notes`). These were the last plaintext +-- PHI columns left after the v1.23 note-encryption rollout. +-- +-- STORAGE CONVENTION: Bytes (`bytea`), matching the dominant free-text +-- encrypted-note convention already in the schema (mood_entries.note_ +-- encrypted, measurements.notes_encrypted, illness_day_logs.note_encrypted, +-- labs.note_encrypted, coach_*.{...}_encrypted) — the `encrypt()` +-- ciphertext string stored UTF-8 as `bytea`, the shape the rotation +-- script's `rotateBytesColumn` already covers. All three columns are +-- registered in ENCRYPTED_COLUMNS + the rotation script in the same change. +-- +-- This migration is ADDITIVE ONLY. The legacy plaintext columns +-- (`medication_side_effects.notes`, `medication_dose_changes.note`, +-- `medication_inventory_items.notes`) are intentionally NOT dropped here: +-- an idempotent, transactional, fail-closed boot-time job encrypts the +-- existing rows (plaintext -> ciphertext, then nulls the plaintext) so the +-- read path can fall back to plaintext for not-yet-migrated rows. Dropping +-- the plaintext columns is a deliberate FOLLOW-UP release, once the backfill +-- reports zero remaining plaintext-but-no-ciphertext rows — the same +-- boundary the encryption-key-rotation playbook uses before dropping a +-- legacy key. +ALTER TABLE "medication_side_effects" + ADD COLUMN IF NOT EXISTS "notes_encrypted" BYTEA; + +ALTER TABLE "medication_dose_changes" + ADD COLUMN IF NOT EXISTS "note_encrypted" BYTEA; + +ALTER TABLE "medication_inventory_items" + ADD COLUMN IF NOT EXISTS "notes_encrypted" BYTEA; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5bbd4cb75..eb099fc3f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -2266,6 +2266,10 @@ model MedicationDoseChange { /// User-entered titration note ("stepped up after 4 weeks on 5 mg, /// tolerated 7.5 mg with mild nausea day 2"). note String? + /// v1.25 — AES-256-GCM ciphertext for `note`. The plaintext column + /// stays for read-fallback until the backfill reports zero remaining + /// rows; the plaintext drop is DEFERRED to a later release. + noteEncrypted Bytes? @map("note_encrypted") createdAt DateTime @default(now()) @map("created_at") medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade) @@ -2357,6 +2361,10 @@ model MedicationInventoryItem { purchasedAt DateTime? @map("purchased_at") /// Free-text note, application-layer max 200 chars. notes String? + /// v1.25 — AES-256-GCM ciphertext for `notes`. The plaintext column + /// stays for read-fallback until the backfill reports zero remaining + /// rows; the plaintext drop is DEFERRED to a later release. + notesEncrypted Bytes? @map("notes_encrypted") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -2413,6 +2421,10 @@ model MedicationSideEffect { /// application layer. The DB column is unbounded TEXT for /// consistency with sibling "notes" columns on this schema. notes String? + /// v1.25 — AES-256-GCM ciphertext for `notes`. The plaintext column + /// stays for read-fallback until the backfill reports zero remaining + /// rows; the plaintext drop is DEFERRED to a later release. + notesEncrypted Bytes? @map("notes_encrypted") createdAt DateTime @default(now()) @map("created_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index 79e11d648..3663f3f56 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -421,6 +421,33 @@ async function main() { ), ); + // ───── v1.25 medication free-text notes (Bytes columns) ───── + // "notesEncrypted" (MedicationSideEffect + MedicationInventoryItem) + + // "noteEncrypted" (MedicationDoseChange). Same shared-codec shape as the + // other free-text note columns. NULL on rows whose note is still in the + // legacy plaintext column (pre-backfill) — `rotateBytesColumn` skips those. + results.push( + await rotateBytesColumn( + "MedicationSideEffect", + "notesEncrypted", + prisma.medicationSideEffect, + ), + ); + results.push( + await rotateBytesColumn( + "MedicationDoseChange", + "noteEncrypted", + prisma.medicationDoseChange, + ), + ); + results.push( + await rotateBytesColumn( + "MedicationInventoryItem", + "notesEncrypted", + prisma.medicationInventoryItem, + ), + ); + console.log("\n=== Rotation summary ==="); let totalRotated = 0; let totalErrors = 0; diff --git a/src/__tests__/inventory-write-isolation.test.ts b/src/__tests__/inventory-write-isolation.test.ts index 4c02bc53a..6e38fb16b 100644 --- a/src/__tests__/inventory-write-isolation.test.ts +++ b/src/__tests__/inventory-write-isolation.test.ts @@ -28,7 +28,10 @@ const SKIP_DIRS = new Set(["__tests__", "node_modules", ".next", "generated"]); * Files allowed to write `medicationInventoryItem` rows: * - the consumption module (intake-driven consume / restore); * - the inventory persistence helpers (expire cron `updateMany`); - * - the two inventory CRUD routes (register / mutate / delete). + * - the two inventory CRUD routes (register / mutate / delete); + * - the v1.25 note-encryption backfill, whose update only rewrites the + * `notes` / `notesEncrypted` columns (never units / state) and so cannot + * touch the consumption stamp this guard protects. */ const ALLOWED_WRITERS = new Set( [ @@ -36,6 +39,7 @@ const ALLOWED_WRITERS = new Set( "src/lib/medications/inventory/service.ts", "src/app/api/medications/[id]/inventory/route.ts", "src/app/api/medications/[id]/inventory/[itemId]/route.ts", + "src/lib/jobs/med-notes-encryption-backfill.ts", ].map((p) => p.split("/").join(sep)), ); diff --git a/src/app/api/auth/password/__tests__/route.test.ts b/src/app/api/auth/password/__tests__/route.test.ts index 5f56f677e..f5fe53763 100644 --- a/src/app/api/auth/password/__tests__/route.test.ts +++ b/src/app/api/auth/password/__tests__/route.test.ts @@ -4,6 +4,20 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; +// v1.25 — password change requires a fresh MFA step-up for enrolled accounts. +// Spread the real module so `apiHandler` keeps working; override the step-up +// guard so each test drives the enrolled / fresh-factor outcome directly. +vi.mock("@/lib/api-handler", async () => { + const actual = + await vi.importActual( + "@/lib/api-handler", + ); + return { + ...actual, + requireFreshMfaIfEnrolled: vi.fn(), + }; +}); + vi.mock("@/lib/db", () => ({ prisma: { user: { update: vi.fn() }, @@ -36,6 +50,11 @@ vi.mock("@/lib/i18n/server-locale", () => ({ resolveServerLocale: vi.fn().mockResolvedValue("en"), })); +// Fail-open breach check: a null result (corpus unreachable) never blocks. +vi.mock("@/lib/auth/hibp", () => ({ + checkPasswordBreach: vi.fn().mockResolvedValue(null), +})); + vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), @@ -50,8 +69,14 @@ vi.mock("next/headers", () => ({ })); import { POST } from "../route"; +import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { checkRateLimit } from "@/lib/rate-limit"; +import { verifyPassword, checkPasswordStrength } from "@/lib/auth/password"; +import { + requireFreshMfaIfEnrolled, + StepUpRequiredError, +} from "@/lib/api-handler"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -78,6 +103,44 @@ beforeEach(() => { vi.resetAllMocks(); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never); + // Default: account clears the step-up (no MFA enrolled, or a fresh factor). + vi.mocked(requireFreshMfaIfEnrolled).mockResolvedValue(SESSION_OK as never); +}); + +describe("POST /api/auth/password — MFA step-up (v1.25)", () => { + it("blocks an MFA-enrolled account without a fresh second factor", async () => { + vi.mocked(requireFreshMfaIfEnrolled).mockRejectedValue( + new StepUpRequiredError(), + ); + const res = await POST( + postReq({ + currentPassword: "old-pass-123", + newPassword: "New-Pass-456!", + }), + ); + expect(res.status).toBe(401); + // The credential is never rotated when the step-up fails. + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("proceeds when the step-up is satisfied (fresh factor / no MFA)", async () => { + // vi.resetAllMocks() wiped the factory defaults; restore the success path. + vi.mocked(verifyPassword).mockResolvedValue(true); + vi.mocked(checkPasswordStrength).mockReturnValue({ + isAcceptable: true, + feedback: [], + } as never); + const res = await POST( + postReq({ + currentPassword: "old-pass-123", + newPassword: "New-Pass-456!", + confirmPassword: "New-Pass-456!", + }), + ); + expect(res.status).toBe(200); + expect(requireFreshMfaIfEnrolled).toHaveBeenCalledWith(expect.any(Number)); + expect(prisma.user.update).toHaveBeenCalled(); + }); }); describe("POST /api/auth/password — 422 multi-issue (v1.4.43 W6)", () => { diff --git a/src/app/api/auth/password/route.ts b/src/app/api/auth/password/route.ts index 38cf11b42..3371b0a0c 100644 --- a/src/app/api/auth/password/route.ts +++ b/src/app/api/auth/password/route.ts @@ -15,7 +15,11 @@ import { safeJson, } from "@/lib/api-response"; import { checkRateLimit } from "@/lib/rate-limit"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiHandler, + requireFreshMfaIfEnrolled, + MFA_STEP_UP_MAX_AGE_SECONDS, +} from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { destroyAllSessions, createSession } from "@/lib/auth/session"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; @@ -23,7 +27,12 @@ import { checkPasswordBreach } from "@/lib/auth/hibp"; import { getServerTranslator } from "@/lib/i18n/server-translator"; export const POST = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + // v1.25 — for an account with a second factor active a password change + // requires a fresh step-up (within MFA_STEP_UP_MAX_AGE_SECONDS) in addition + // to the current-password re-proof below, so a hijacked live session cannot + // rotate the credential. Accounts without MFA keep the current-password-only + // contract. Mirrors the step-up on MFA-disable + recovery-code regen. + const { user } = await requireFreshMfaIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS); const rl = await checkRateLimit( `auth:password:${user.id}`, diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts index 591eae953..15121fc65 100644 --- a/src/app/api/dashboard/widgets/route.ts +++ b/src/app/api/dashboard/widgets/route.ts @@ -29,7 +29,7 @@ import { Prisma } from "@/generated/prisma/client"; import { z } from "zod/v4"; import { invalidateUserDashboardWidgets } from "@/lib/cache/invalidate"; import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; -import { redactSensitiveFields } from "@/lib/observability/redact-payload"; +import { redactForExcerpt } from "@/lib/observability/redact-payload"; import type { NextRequest } from "next/server"; // Single source of truth — every widget id the layout accepts. The @@ -211,13 +211,11 @@ export const PUT = apiHandler(async (request: NextRequest) => { // the full body — so PII / token-like fields cannot leak. The // diagnostic shape is built by the shared `buildPayloadDiagnostic` // helper (v1.4.49) so the widget + series routes can't drift; the - // body is routed through `redactSensitiveFields` first so any + // body is routed through `redactForExcerpt` first so any // future field matching the denylist (password / token / secret / // apiKey / authorization / csrfState / nonce) lands as the literal // `"[redacted]"` instead of its raw value. - const payloadDiagnostic = buildPayloadDiagnostic( - redactSensitiveFields(body), - ); + const payloadDiagnostic = buildPayloadDiagnostic(redactForExcerpt(body)); annotate({ action: { name: "dashboard.widgets.validation-failed" }, meta: { diff --git a/src/app/api/insights/glp1-timeline/route.ts b/src/app/api/insights/glp1-timeline/route.ts index c5b8711d7..8992b233f 100644 --- a/src/app/api/insights/glp1-timeline/route.ts +++ b/src/app/api/insights/glp1-timeline/route.ts @@ -12,6 +12,7 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiSuccess } from "@/lib/api-response"; +import { readNote } from "@/lib/crypto/note-cipher"; import { NextRequest } from "next/server"; const DEFAULT_LIMIT = 60; @@ -131,7 +132,7 @@ export const GET = apiHandler(async (request: NextRequest) => { doseValue: dc.doseValue, doseUnit: dc.doseUnit, doseDelta: delta, - note: dc.note, + note: readNote(dc.noteEncrypted, dc.note), }); } for (const intake of med.intakeEvents) { diff --git a/src/app/api/insights/layout/route.ts b/src/app/api/insights/layout/route.ts index 88a242ece..5fb3c20ad 100644 --- a/src/app/api/insights/layout/route.ts +++ b/src/app/api/insights/layout/route.ts @@ -35,7 +35,7 @@ import { Prisma } from "@/generated/prisma/client"; import { z } from "zod/v4"; import { invalidateUserInsightsLayout } from "@/lib/cache/invalidate"; import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; -import { redactSensitiveFields } from "@/lib/observability/redact-payload"; +import { redactForExcerpt } from "@/lib/observability/redact-payload"; import { shouldEmitAuditRow } from "@/lib/audit-dedup"; import type { NextRequest } from "next/server"; @@ -129,9 +129,7 @@ export const PUT = apiHandler(async (request: NextRequest) => { // round-trip; the wide-event line carries the redacted payload // shape for operator debugging without leaking the raw body. const issues = sanitiseZodIssues(parsed.error.issues); - const payloadDiagnostic = buildPayloadDiagnostic( - redactSensitiveFields(body), - ); + const payloadDiagnostic = buildPayloadDiagnostic(redactForExcerpt(body)); annotate({ action: { name: "insights.layout.validation-failed" }, meta: { diff --git a/src/app/api/measurements/series/route.ts b/src/app/api/measurements/series/route.ts index 23f16bc75..ca27049ec 100644 --- a/src/app/api/measurements/series/route.ts +++ b/src/app/api/measurements/series/route.ts @@ -20,7 +20,7 @@ import { } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { summarize, type DataPoint } from "@/lib/analytics/trends"; -import { redactSensitiveFields } from "@/lib/observability/redact-payload"; +import { redactForExcerpt } from "@/lib/observability/redact-payload"; import type { MeasurementType, SleepStage } from "@/generated/prisma/client"; import { reconstructSleepNights } from "@/lib/analytics/sleep-night"; import { VALUE_RANGES } from "@/lib/validations/measurement"; @@ -158,11 +158,11 @@ export const GET = apiHandler(async (request: NextRequest) => { // do not currently accept any but the truncation keeps that // invariant cheap) cannot leak. Shared helper via v1.4.49 so the // widget + series routes can't drift on the diagnostic shape; the - // query map is routed through `redactSensitiveFields` first so any + // query map is routed through `redactForExcerpt` first so any // future credential-shaped query string (`token`, `apiKey`, // `csrfState`) redacts to `"[redacted]"` before the truncate. const payloadDiagnostic = buildPayloadDiagnostic( - redactSensitiveFields(rawQuery), + redactForExcerpt(rawQuery), ); annotate({ action: { name: "measurements.series.validation-failed" }, diff --git a/src/app/api/medications/[id]/glp1/route.ts b/src/app/api/medications/[id]/glp1/route.ts index ab3de40cb..220c1d7b2 100644 --- a/src/app/api/medications/[id]/glp1/route.ts +++ b/src/app/api/medications/[id]/glp1/route.ts @@ -39,6 +39,11 @@ import { resolveReorderLeadDays, } from "@/lib/validations/notification-prefs"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; +import { + encryptNote, + readNote, + shapeDoseChangeNote, +} from "@/lib/crypto/note-cipher"; import { NextRequest } from "next/server"; type RouteParams = { params: Promise<{ id: string }> }; @@ -193,7 +198,7 @@ export const GET = apiHandler( effectiveFrom: dc.effectiveFrom.toISOString(), doseValue: dc.doseValue, doseUnit: dc.doseUnit, - note: dc.note, + note: readNote(dc.noteEncrypted, dc.note), })), recentIntakes: medication.intakeEvents.map((iv) => ({ takenAt: iv.takenAt ? iv.takenAt.toISOString() : null, @@ -261,7 +266,9 @@ export const POST = apiHandler( effectiveFrom, doseValue, doseUnit, - note: note ?? null, + // Encrypt the titration note at rest; the plaintext column stays null. + noteEncrypted: encryptNote(note), + note: null, }, }); @@ -291,7 +298,7 @@ export const POST = apiHandler( // buckets so the card reflects the new runway on the next read. invalidateUserMedications(user.id, { evict: true }); - return apiSuccess({ doseChange: created }, 201); + return apiSuccess({ doseChange: shapeDoseChangeNote(created) }, 201); } // The schema's XOR refinement guarantees `inventory` is present diff --git a/src/app/api/medications/[id]/inventory/[itemId]/route.ts b/src/app/api/medications/[id]/inventory/[itemId]/route.ts index 2125b3c8f..df65ba109 100644 --- a/src/app/api/medications/[id]/inventory/[itemId]/route.ts +++ b/src/app/api/medications/[id]/inventory/[itemId]/route.ts @@ -37,6 +37,7 @@ import { } from "@/lib/medications/inventory/state-machine"; import { serializeInventoryItem } from "@/lib/medications/inventory/service"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; +import { encryptNote, shapeInventoryItemNotes } from "@/lib/crypto/note-cipher"; type RouteParams = { params: Promise<{ id: string; itemId: string }> }; @@ -142,6 +143,14 @@ export const PATCH = apiHandler( Date.now(), ); + // Only touch the note columns when the caller supplied a value; an absent + // `notes` leaves the existing ciphertext untouched. When present, encrypt + // at rest and null the plaintext column. + const notesData = + notes === undefined + ? {} + : { notesEncrypted: encryptNote(notes), notes: null }; + const updated = await prisma.medicationInventoryItem.update({ where: { id: itemId }, data: { @@ -150,7 +159,7 @@ export const PATCH = apiHandler( unitsRemaining: nextUnitsRemaining, printedExpiry: nextPrintedExpiry, expiresAt: nextExpiresAt, - notes: notes === undefined ? existing.notes : notes, + ...notesData, }, }); @@ -180,7 +189,7 @@ export const PATCH = apiHandler( // read instead of after the `cachedSwr` stale window. invalidateUserMedications(user.id, { evict: true }); - return apiSuccess(serializeInventoryItem(updated)); + return apiSuccess(serializeInventoryItem(shapeInventoryItemNotes(updated))); }, ); diff --git a/src/app/api/medications/[id]/inventory/route.ts b/src/app/api/medications/[id]/inventory/route.ts index a5ce6d0f2..d553ddee7 100644 --- a/src/app/api/medications/[id]/inventory/route.ts +++ b/src/app/api/medications/[id]/inventory/route.ts @@ -35,6 +35,7 @@ import { } from "@/lib/medications/inventory/service"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; +import { shapeInventoryItemNotes } from "@/lib/crypto/note-cipher"; type RouteParams = { params: Promise<{ id: string }> }; @@ -60,7 +61,11 @@ export const GET = apiHandler( }), ]); - const serialized = items.map(serializeInventoryItem); + // Decrypt each note and strip the raw `notesEncrypted` ciphertext before + // the decimal-unit serialisation runs. + const serialized = items.map((item) => + serializeInventoryItem(shapeInventoryItemNotes(item)), + ); // v1.19.0 (iOS#25) — the canonical supply summary is computed HERE, // server-side, through the one source of truth (`summariseSupply`). // The detail-page client renders these ready figures instead of @@ -162,6 +167,9 @@ export const POST = apiHandler( // serve the pre-write stock for the rest of the stale window. invalidateUserMedications(user.id, { evict: true }); - return apiSuccess(serializeInventoryItem(created), 201); + return apiSuccess( + serializeInventoryItem(shapeInventoryItemNotes(created)), + 201, + ); }, ); diff --git a/src/app/api/medications/[id]/side-effects/__tests__/route.test.ts b/src/app/api/medications/[id]/side-effects/__tests__/route.test.ts index edff25b8c..e30b8372a 100644 --- a/src/app/api/medications/[id]/side-effects/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/side-effects/__tests__/route.test.ts @@ -57,6 +57,13 @@ import { DELETE } from "../[logId]/route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { checkRateLimit } from "@/lib/rate-limit"; +import { readNote } from "@/lib/crypto/note-cipher"; + +// The POST path encrypts the free-text note at rest; drive the legacy single +// key so encryptNote has material to work with. +vi.stubEnv("ENCRYPTION_KEYS", ""); +vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", ""); +vi.stubEnv("ENCRYPTION_KEY", "a".repeat(64)); const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -233,17 +240,33 @@ describe("POST /api/medications/[id]/side-effects", () => { { params: Promise.resolve({ id: "med-1" }) }, ); expect(res.status).toBe(201); - expect(prisma.medicationSideEffect.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ - userId: "user-1", - medicationId: "med-1", - category: "GI", - entry: "NAUSEA", - severity: 2, - occurredAt: new Date(occurredAt), - notes: "after breakfast", - }), + const createArgs = vi.mocked(prisma.medicationSideEffect.create).mock + .calls[0][0] as { + data: { + userId: string; + medicationId: string; + category: string; + entry: string; + severity: number; + occurredAt: Date; + notes: string | null; + notesEncrypted: Uint8Array | null; + }; + }; + expect(createArgs.data).toMatchObject({ + userId: "user-1", + medicationId: "med-1", + category: "GI", + entry: "NAUSEA", + severity: 2, + occurredAt: new Date(occurredAt), + // v1.25 — the plaintext column is nulled; the note lands encrypted. + notes: null, }); + expect(createArgs.data.notesEncrypted).toBeInstanceOf(Uint8Array); + expect(readNote(createArgs.data.notesEncrypted, null)).toBe( + "after breakfast", + ); }); it("defaults occurredAt to now when omitted", async () => { diff --git a/src/app/api/medications/[id]/side-effects/route.ts b/src/app/api/medications/[id]/side-effects/route.ts index 899fa0067..5562e4873 100644 --- a/src/app/api/medications/[id]/side-effects/route.ts +++ b/src/app/api/medications/[id]/side-effects/route.ts @@ -36,6 +36,7 @@ import { } from "@/lib/medications/side-effects/validators"; import { categoryForEntry } from "@/lib/medications/side-effects/taxonomy"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; +import { encryptNote, shapeSideEffectNotes } from "@/lib/crypto/note-cipher"; type RouteParams = { params: Promise<{ id: string }> }; @@ -85,7 +86,12 @@ export const GET = apiHandler( meta: { medication_id: id, total: items.length }, }); - return apiSuccess({ items, meta: { total: items.length } }); + // Decrypt each note and strip the raw `notesEncrypted` ciphertext so it + // never leaves the server. + return apiSuccess({ + items: items.map(shapeSideEffectNotes), + meta: { total: items.length }, + }); }, ); @@ -139,7 +145,9 @@ export const POST = apiHandler( entry, severity, occurredAt: occurredAt ?? new Date(), - notes: notes ?? null, + // Encrypt the free-text note at rest; the plaintext column stays null. + notesEncrypted: encryptNote(notes), + notes: null, }, }); @@ -163,6 +171,6 @@ export const POST = apiHandler( meta: { medication_id: id, entry, severity }, }); - return apiSuccess(created, 201); + return apiSuccess(shapeSideEffectNotes(created), 201); }, ); diff --git a/src/app/api/medications/layout/route.ts b/src/app/api/medications/layout/route.ts index 1fb39de3d..5f501a98d 100644 --- a/src/app/api/medications/layout/route.ts +++ b/src/app/api/medications/layout/route.ts @@ -34,7 +34,7 @@ import { Prisma } from "@/generated/prisma/client"; import { z } from "zod/v4"; import { invalidateUserMedicationListLayout } from "@/lib/cache/invalidate"; import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; -import { redactSensitiveFields } from "@/lib/observability/redact-payload"; +import { redactForExcerpt } from "@/lib/observability/redact-payload"; import { shouldEmitAuditRow } from "@/lib/audit-dedup"; import type { NextRequest } from "next/server"; @@ -92,9 +92,7 @@ export const PUT = apiHandler(async (request: NextRequest) => { // layout routes; the wide-event line carries the redacted payload // shape for operator debugging without leaking the raw body. const issues = sanitiseZodIssues(parsed.error.issues); - const payloadDiagnostic = buildPayloadDiagnostic( - redactSensitiveFields(body), - ); + const payloadDiagnostic = buildPayloadDiagnostic(redactForExcerpt(body)); annotate({ action: { name: "medication.layout.validation-failed" }, meta: { diff --git a/src/lib/ai/coach/glp1-snapshot.ts b/src/lib/ai/coach/glp1-snapshot.ts index 312f0385e..316114f2a 100644 --- a/src/lib/ai/coach/glp1-snapshot.ts +++ b/src/lib/ai/coach/glp1-snapshot.ts @@ -20,6 +20,7 @@ */ import { prisma } from "@/lib/db"; import { sanitizeForPrompt } from "@/lib/insights/sanitize"; +import { readNote } from "@/lib/crypto/note-cipher"; /** * Recommended generic name for the canonical GLP-1 drug brand. The Coach @@ -325,12 +326,16 @@ export async function buildGlp1SnapshotBlock( const genericSafe = sanitizeForPrompt(generic, 80); const latestChange = med.doseChanges[med.doseChanges.length - 1] ?? null; - const doseHistory: DoseHistoryEntry[] = med.doseChanges.map((dc) => ({ - value: dc.doseValue, - unit: sanitizeForPrompt(dc.doseUnit, 20), - effectiveFrom: isoDate(dc.effectiveFrom), - note: dc.note === null ? null : sanitizeForPrompt(dc.note, 200), - })); + const doseHistory: DoseHistoryEntry[] = med.doseChanges.map((dc) => { + const decryptedNote = readNote(dc.noteEncrypted, dc.note); + return { + value: dc.doseValue, + unit: sanitizeForPrompt(dc.doseUnit, 20), + effectiveFrom: isoDate(dc.effectiveFrom), + note: + decryptedNote === null ? null : sanitizeForPrompt(decryptedNote, 200), + }; + }); const currentDose: CurrentDose | null = latestChange ? { diff --git a/src/lib/crypto/__tests__/note-cipher.test.ts b/src/lib/crypto/__tests__/note-cipher.test.ts index 8b5873ddb..1214701f0 100644 --- a/src/lib/crypto/__tests__/note-cipher.test.ts +++ b/src/lib/crypto/__tests__/note-cipher.test.ts @@ -4,6 +4,9 @@ import { readNote, shapeMeasurementNotes, shapeMoodNote, + shapeSideEffectNotes, + shapeInventoryItemNotes, + shapeDoseChangeNote, } from "../note-cipher"; // A 32-byte key as 64 hex chars (the legacy single-key path). @@ -102,3 +105,65 @@ describe("shapeMeasurementNotes / shapeMoodNote", () => { expect(shaped.notes).toBe("legacy plaintext"); }); }); + +describe("v1.25 medication note shapers", () => { + it("shapeSideEffectNotes decrypts onto `notes` and strips the ciphertext", () => { + const notesEncrypted = encryptNote("nausea on day 2"); + const row = { + id: "se1", + entry: "NAUSEA", + severity: 3, + notes: null, + notesEncrypted, + }; + const shaped = shapeSideEffectNotes(row); + expect(shaped.notes).toBe("nausea on day 2"); + expect("notesEncrypted" in shaped).toBe(false); + expect(shaped.entry).toBe("NAUSEA"); + expect(shaped.severity).toBe(3); + }); + + it("shapeInventoryItemNotes decrypts onto `notes` and strips the ciphertext", () => { + const notesEncrypted = encryptNote("opened pen #2"); + const row = { id: "i1", state: "IN_USE", notes: null, notesEncrypted }; + const shaped = shapeInventoryItemNotes(row); + expect(shaped.notes).toBe("opened pen #2"); + expect("notesEncrypted" in shaped).toBe(false); + expect(shaped.state).toBe("IN_USE"); + }); + + it("shapeDoseChangeNote decrypts onto `note` and strips the ciphertext", () => { + const noteEncrypted = encryptNote("stepped up to 7.5 mg"); + const row = { id: "dc1", doseValue: 7.5, note: null, noteEncrypted }; + const shaped = shapeDoseChangeNote(row); + expect(shaped.note).toBe("stepped up to 7.5 mg"); + expect("noteEncrypted" in shaped).toBe(false); + expect(shaped.doseValue).toBe(7.5); + }); + + it("falls back to the legacy plaintext for not-yet-backfilled rows", () => { + expect( + shapeSideEffectNotes({ id: "se2", notes: "legacy", notesEncrypted: null }) + .notes, + ).toBe("legacy"); + expect( + shapeInventoryItemNotes({ + id: "i2", + notes: "legacy", + notesEncrypted: null, + }).notes, + ).toBe("legacy"); + expect( + shapeDoseChangeNote({ id: "dc2", note: "legacy", noteEncrypted: null }) + .note, + ).toBe("legacy"); + }); + + it("is fail-closed: a side-effect ciphertext under a rotated key throws", () => { + const notesEncrypted = encryptNote("secret note"); + vi.stubEnv("ENCRYPTION_KEY", KEY_B); + expect(() => + shapeSideEffectNotes({ id: "se3", notes: null, notesEncrypted }), + ).toThrow(); + }); +}); diff --git a/src/lib/crypto/encrypted-columns.ts b/src/lib/crypto/encrypted-columns.ts index dfad22edc..4b0c91191 100644 --- a/src/lib/crypto/encrypted-columns.ts +++ b/src/lib/crypto/encrypted-columns.ts @@ -149,6 +149,13 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [ // IllnessDayLog / LabResult note columns above; Bytes via the shared codec. { model: "MoodEntry", field: "noteEncrypted", kind: "bytes" }, { model: "Measurement", field: "notesEncrypted", kind: "bytes" }, + + // ───── v1.25 medication free-text notes (Bytes columns) ───── + // Side-effect log note, dose-change titration note, and inventory-item + // note — the last plaintext PHI columns left after the v1.23 rollout. + { model: "MedicationSideEffect", field: "notesEncrypted", kind: "bytes" }, + { model: "MedicationDoseChange", field: "noteEncrypted", kind: "bytes" }, + { model: "MedicationInventoryItem", field: "notesEncrypted", kind: "bytes" }, ] as const; /** Stable `Model.field` key for a registry entry. */ diff --git a/src/lib/crypto/note-cipher.ts b/src/lib/crypto/note-cipher.ts index f768e9a68..dc63001dd 100644 --- a/src/lib/crypto/note-cipher.ts +++ b/src/lib/crypto/note-cipher.ts @@ -7,6 +7,13 @@ * encrypt-on-write + prefer-ciphertext-fallback-plaintext contract stays * consistent. * + * v1.25 extends the same boundary to the three medication free-text columns — + * `MedicationSideEffect.notes` -> `notesEncrypted`, + * `MedicationDoseChange.note` -> `noteEncrypted`, and + * `MedicationInventoryItem.notes` -> `notesEncrypted` — the last plaintext PHI + * columns left after the v1.23 rollout. They reuse `encryptNote` / `readNote` + * unchanged and get their own row-shapers below. + * * Storage shape is the shared Bytes codec (`@/lib/ai/coach/bytes-codec`) used * by every other free-text encrypted-note column (IllnessDayLog, LabResult, * Coach memory) — the `encrypt()` ciphertext string stored UTF-8 as `bytea`. @@ -73,3 +80,37 @@ export function shapeMoodNote< const { noteEncrypted, ...rest } = row; return { ...rest, note: readNote(noteEncrypted, rest.note) }; } + +/** + * v1.25 — shape a medication side-effect row for a response/export: surface + * the decrypted note on `notes` and strip the raw `notesEncrypted` ciphertext + * so it never leaves the server. + */ +export function shapeSideEffectNotes< + T extends { notes: string | null; notesEncrypted: Uint8Array | null }, +>(row: T): Omit & { notes: string | null } { + const { notesEncrypted, ...rest } = row; + return { ...rest, notes: readNote(notesEncrypted, rest.notes) }; +} + +/** + * v1.25 — shape a medication inventory-item row for a response/export: surface + * the decrypted note on `notes` and strip the raw `notesEncrypted` ciphertext. + */ +export function shapeInventoryItemNotes< + T extends { notes: string | null; notesEncrypted: Uint8Array | null }, +>(row: T): Omit & { notes: string | null } { + const { notesEncrypted, ...rest } = row; + return { ...rest, notes: readNote(notesEncrypted, rest.notes) }; +} + +/** + * v1.25 — shape a medication dose-change row for a response/export: surface the + * decrypted note on `note` and strip the raw `noteEncrypted` ciphertext. + */ +export function shapeDoseChangeNote< + T extends { note: string | null; noteEncrypted: Uint8Array | null }, +>(row: T): Omit & { note: string | null } { + const { noteEncrypted, ...rest } = row; + return { ...rest, note: readNote(noteEncrypted, rest.note) }; +} diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts index bc2fc0702..fda353ad3 100644 --- a/src/lib/doctor-report-data.ts +++ b/src/lib/doctor-report-data.ts @@ -28,6 +28,7 @@ import type { MeasurementType, } from "@/generated/prisma/client"; import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority"; +import { readNote } from "@/lib/crypto/note-cipher"; import { metricKeyForType } from "@/lib/measurements/cumulative-day-sum"; import { userDayKey } from "@/lib/tz/resolver"; import { resolveCanonicalRecovery } from "@/lib/insights/derived/recovery-resolve"; @@ -1244,7 +1245,7 @@ export async function collectDoctorReportData( value: dc.doseValue, unit: dc.doseUnit, effectiveFrom: dc.effectiveFrom.toISOString(), - note: dc.note, + note: readNote(dc.noteEncrypted, dc.note), })), lastInjection: lastIntake && lastIntake.takenAt diff --git a/src/lib/jobs/__tests__/med-notes-encryption-backfill.test.ts b/src/lib/jobs/__tests__/med-notes-encryption-backfill.test.ts new file mode 100644 index 000000000..23f5a9119 --- /dev/null +++ b/src/lib/jobs/__tests__/med-notes-encryption-backfill.test.ts @@ -0,0 +1,228 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { readNote } from "@/lib/crypto/note-cipher"; + +// ── in-memory store backing a minimal prisma mock ────────────────────────── +interface SideEffectRow { + id: string; + userId: string; + notes: string | null; + notesEncrypted: Uint8Array | null; +} +interface DoseChangeRow { + id: string; + // Dose-changes carry no userId column — ownership is the parent medication's. + medicationUserId: string; + note: string | null; + noteEncrypted: Uint8Array | null; +} +interface InventoryRow { + id: string; + userId: string; + notes: string | null; + notesEncrypted: Uint8Array | null; +} + +const store = vi.hoisted(() => ({ + sideEffects: [] as SideEffectRow[], + doseChanges: [] as DoseChangeRow[], + inventory: [] as InventoryRow[], +})); + +vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: () => null })); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); + +vi.mock("@/lib/db", () => { + const delegates = { + medicationSideEffect: { + findMany: async (args: { where: { userId: string }; take: number }) => + store.sideEffects + .filter( + (r) => + r.userId === args.where.userId && + r.notes !== null && + r.notesEncrypted === null, + ) + .slice(0, args.take) + .map((r) => ({ id: r.id })), + findUnique: async (args: { where: { id: string } }) => { + const r = store.sideEffects.find((x) => x.id === args.where.id); + return r ? { notes: r.notes, notesEncrypted: r.notesEncrypted } : null; + }, + update: async (args: { + where: { id: string }; + data: { notes: string | null; notesEncrypted: Uint8Array | null }; + }) => { + const r = store.sideEffects.find((x) => x.id === args.where.id)!; + r.notes = args.data.notes; + r.notesEncrypted = args.data.notesEncrypted; + return r; + }, + }, + medicationDoseChange: { + findMany: async (args: { + where: { medication: { userId: string } }; + take: number; + }) => + store.doseChanges + .filter( + (r) => + r.medicationUserId === args.where.medication.userId && + r.note !== null && + r.noteEncrypted === null, + ) + .slice(0, args.take) + .map((r) => ({ id: r.id })), + findUnique: async (args: { where: { id: string } }) => { + const r = store.doseChanges.find((x) => x.id === args.where.id); + return r ? { note: r.note, noteEncrypted: r.noteEncrypted } : null; + }, + update: async (args: { + where: { id: string }; + data: { note: string | null; noteEncrypted: Uint8Array | null }; + }) => { + const r = store.doseChanges.find((x) => x.id === args.where.id)!; + r.note = args.data.note; + r.noteEncrypted = args.data.noteEncrypted; + return r; + }, + }, + medicationInventoryItem: { + findMany: async (args: { where: { userId: string }; take: number }) => + store.inventory + .filter( + (r) => + r.userId === args.where.userId && + r.notes !== null && + r.notesEncrypted === null, + ) + .slice(0, args.take) + .map((r) => ({ id: r.id })), + findUnique: async (args: { where: { id: string } }) => { + const r = store.inventory.find((x) => x.id === args.where.id); + return r ? { notes: r.notes, notesEncrypted: r.notesEncrypted } : null; + }, + update: async (args: { + where: { id: string }; + data: { notes: string | null; notesEncrypted: Uint8Array | null }; + }) => { + const r = store.inventory.find((x) => x.id === args.where.id)!; + r.notes = args.data.notes; + r.notesEncrypted = args.data.notesEncrypted; + return r; + }, + }, + }; + const prisma = { + ...delegates, + $transaction: async (fn: (tx: typeof delegates) => unknown) => + fn(delegates), + }; + return { prisma }; +}); + +import { runMedNotesEncryptionBackfillForUser } from "@/lib/jobs/med-notes-encryption-backfill"; + +const KEY = "a".repeat(64); + +beforeEach(() => { + vi.stubEnv("ENCRYPTION_KEYS", ""); + vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", ""); + vi.stubEnv("ENCRYPTION_KEY", KEY); + store.sideEffects = [ + { id: "se1", userId: "u1", notes: "felt dizzy", notesEncrypted: null }, + { id: "se2", userId: "u1", notes: null, notesEncrypted: null }, // no note + { id: "se3", userId: "u2", notes: "other user", notesEncrypted: null }, + ]; + store.doseChanges = [ + { + id: "dc1", + medicationUserId: "u1", + note: "stepped up to 7.5mg", + noteEncrypted: null, + }, + { + id: "dc2", + medicationUserId: "u2", + note: "other user dose note", + noteEncrypted: null, + }, + ]; + store.inventory = [ + { id: "i1", userId: "u1", notes: "opened pen #2", notesEncrypted: null }, + { + id: "i2", + userId: "u1", + notes: null, + notesEncrypted: new Uint8Array(0), // already migrated-ish; left alone + }, + ]; +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("runMedNotesEncryptionBackfillForUser", () => { + it("encrypts plaintext notes, nulls the plaintext, and round-trips each table", async () => { + const summary = await runMedNotesEncryptionBackfillForUser("u1"); + + expect(summary.sideEffectsMigrated).toBe(1); + expect(summary.doseChangesMigrated).toBe(1); + expect(summary.inventoryItemsMigrated).toBe(1); + + const se1 = store.sideEffects.find((r) => r.id === "se1")!; + expect(se1.notes).toBeNull(); + expect(readNote(se1.notesEncrypted, null)).toBe("felt dizzy"); + + const dc1 = store.doseChanges.find((r) => r.id === "dc1")!; + expect(dc1.note).toBeNull(); + expect(readNote(dc1.noteEncrypted, null)).toBe("stepped up to 7.5mg"); + + const i1 = store.inventory.find((r) => r.id === "i1")!; + expect(i1.notes).toBeNull(); + expect(readNote(i1.notesEncrypted, null)).toBe("opened pen #2"); + }); + + it("leaves note-less rows untouched and never writes a both-null content row", async () => { + await runMedNotesEncryptionBackfillForUser("u1"); + const se2 = store.sideEffects.find((r) => r.id === "se2")!; + expect(se2.notes).toBeNull(); + expect(se2.notesEncrypted).toBeNull(); + }); + + it("does not touch another user's rows (incl. dose-changes via the parent medication)", async () => { + await runMedNotesEncryptionBackfillForUser("u1"); + const se3 = store.sideEffects.find((r) => r.id === "se3")!; + expect(se3.notes).toBe("other user"); + expect(se3.notesEncrypted).toBeNull(); + const dc2 = store.doseChanges.find((r) => r.id === "dc2")!; + expect(dc2.note).toBe("other user dose note"); + expect(dc2.noteEncrypted).toBeNull(); + }); + + it("is idempotent: a second run migrates zero rows and keeps the ciphertext", async () => { + await runMedNotesEncryptionBackfillForUser("u1"); + const se1First = store.sideEffects.find( + (r) => r.id === "se1", + )!.notesEncrypted; + + const second = await runMedNotesEncryptionBackfillForUser("u1"); + expect(second.sideEffectsMigrated).toBe(0); + expect(second.doseChangesMigrated).toBe(0); + expect(second.inventoryItemsMigrated).toBe(0); + + const se1 = store.sideEffects.find((r) => r.id === "se1")!; + expect(se1.notesEncrypted).toBe(se1First); + expect(se1.notes).toBeNull(); + expect(readNote(se1.notesEncrypted, null)).toBe("felt dizzy"); + }); + + it("is fail-closed: a key error leaves every plaintext row intact (no data loss)", async () => { + vi.stubEnv("ENCRYPTION_KEY", ""); + vi.stubEnv("ENCRYPTION_KEYS", ""); + await expect(runMedNotesEncryptionBackfillForUser("u1")).rejects.toThrow(); + const se1 = store.sideEffects.find((r) => r.id === "se1")!; + expect(se1.notes).toBe("felt dizzy"); + expect(se1.notesEncrypted).toBeNull(); + }); +}); diff --git a/src/lib/jobs/med-notes-encryption-backfill.ts b/src/lib/jobs/med-notes-encryption-backfill.ts new file mode 100644 index 000000000..8e8bdb047 --- /dev/null +++ b/src/lib/jobs/med-notes-encryption-backfill.ts @@ -0,0 +1,305 @@ +/** + * v1.25 — boot-time converging backfill that migrates the three medication + * free-text note columns from plaintext to AES-256-GCM at rest: + * `MedicationSideEffect.notes` -> `notesEncrypted`, + * `MedicationDoseChange.note` -> `noteEncrypted`, and + * `MedicationInventoryItem.notes` -> `notesEncrypted`. These are the last + * plaintext PHI columns left after the v1.23 note-encryption rollout. + * + * Modelled on `note-encryption-backfill.ts`: a discovery query enqueues one + * job per user still holding an un-migrated row, the per-user handler walks + * that user's rows, and the pass is idempotent across reboots — once a row is + * migrated it drops off the discovery + candidate sets. + * + * OWNERSHIP: `MedicationSideEffect` and `MedicationInventoryItem` carry a + * direct `userId` column, so they page/discover on `userId` directly. + * `MedicationDoseChange` has no `userId` — it hangs off a parent `Medication` + * — so its candidate + discovery queries join through `medication: { userId }`. + * + * DATA-LOSS SAFETY (the property the security review must confirm): + * - Per row, the encrypt-then-null happens in a SINGLE interactive + * transaction guarded by a re-read inside the tx, so the plaintext column is + * only nulled AFTER the ciphertext is committed in the same atomic unit. A + * row that had content is never left both-null. + * - FAIL-CLOSED: `encryptNote` throws on a missing / malformed key. The throw + * aborts the transaction (the plaintext row is untouched) and propagates so + * pg-boss retries — the backfill never silently drops a note or writes a + * plaintext-shaped value into the ciphertext column. + * - IDEMPOTENT: the per-row guard (`*Encrypted IS NULL AND * != null`) is + * re-checked inside the tx, so a re-run — or two workers racing — migrates a + * row at most once; a second pass migrates zero rows. + * + * The legacy plaintext columns are intentionally NOT dropped in this release. + * That is a deliberate FOLLOW-UP release boundary, once this backfill reports + * zero remaining un-migrated rows on every instance — the same discipline the + * encryption-key-rotation playbook uses before dropping a legacy key. + * + * The queue name MUST be registered in the maintenance registrar + * (`src/lib/jobs/reminder/register-maintenance.ts`) so pg-boss provisions it at + * boot; an unregistered queue silently never drains. + */ +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { getGlobalBoss } from "@/lib/jobs/boss-instance"; +import { encryptNote } from "@/lib/crypto/note-cipher"; + +export const MED_NOTES_ENCRYPTION_BACKFILL_QUEUE = + "med-notes-encryption-backfill"; + +/** + * Serial concurrency — the populator walks a user's plaintext-note rows and + * writes one transaction per row; concurrency-1 keeps it off the request pool, + * matching the v1.23 note-encryption-backfill convention. + */ +export const MED_NOTES_ENCRYPTION_BACKFILL_CONCURRENCY = 1; + +/** How many candidate rows to pull per page within a user's pass. */ +const PAGE_SIZE = 200; + +export interface MedNotesEncryptionBackfillPayload { + /** Absent on the daily discovery tick; present for a per-user job. */ + userId?: string; + enqueuedAt?: string; +} + +export interface MedNotesEncryptionBackfillSummary { + sideEffectsMigrated: number; + doseChangesMigrated: number; + inventoryItemsMigrated: number; +} + +/** + * Migrate one side-effect row's plaintext `notes` into `notesEncrypted` and + * null the plaintext — atomically and idempotently. Returns true if it + * migrated the row, false if a concurrent pass already did. + */ +async function migrateSideEffectRow(id: string): Promise { + return prisma.$transaction(async (tx) => { + const fresh = await tx.medicationSideEffect.findUnique({ + where: { id }, + select: { notes: true, notesEncrypted: true }, + }); + // Already migrated (ciphertext present) or nothing to migrate. + if (!fresh || fresh.notesEncrypted || fresh.notes === null) return false; + // FAIL-CLOSED: a key error throws here and rolls the tx back, leaving the + // plaintext row untouched. + const encrypted = encryptNote(fresh.notes); + await tx.medicationSideEffect.update({ + where: { id }, + data: { notesEncrypted: encrypted, notes: null }, + }); + return true; + }); +} + +/** + * Migrate one dose-change row's plaintext `note` into `noteEncrypted` and null + * the plaintext — atomically and idempotently. + */ +async function migrateDoseChangeRow(id: string): Promise { + return prisma.$transaction(async (tx) => { + const fresh = await tx.medicationDoseChange.findUnique({ + where: { id }, + select: { note: true, noteEncrypted: true }, + }); + if (!fresh || fresh.noteEncrypted || fresh.note === null) return false; + const encrypted = encryptNote(fresh.note); + await tx.medicationDoseChange.update({ + where: { id }, + data: { noteEncrypted: encrypted, note: null }, + }); + return true; + }); +} + +/** + * Migrate one inventory-item row's plaintext `notes` into `notesEncrypted` and + * null the plaintext — atomically and idempotently. + */ +async function migrateInventoryItemRow(id: string): Promise { + return prisma.$transaction(async (tx) => { + const fresh = await tx.medicationInventoryItem.findUnique({ + where: { id }, + select: { notes: true, notesEncrypted: true }, + }); + if (!fresh || fresh.notesEncrypted || fresh.notes === null) return false; + const encrypted = encryptNote(fresh.notes); + await tx.medicationInventoryItem.update({ + where: { id }, + data: { notesEncrypted: encrypted, notes: null }, + }); + return true; + }); +} + +/** + * Per-user queue handler. Walks every un-migrated medication note for one + * account across all three tables and migrates each in its own transaction. + * Safe to re-run. Dose-changes carry no `userId`, so they are scoped through + * the parent medication's owner. + */ +export async function runMedNotesEncryptionBackfillForUser( + userId: string, +): Promise { + let sideEffectsMigrated = 0; + let doseChangesMigrated = 0; + let inventoryItemsMigrated = 0; + + // Side-effects — direct userId. Each migration nulls `notes`, so a migrated + // row drops out of the next `notes: { not: null }, notesEncrypted: null` page. + for (;;) { + const rows = await prisma.medicationSideEffect.findMany({ + where: { userId, notes: { not: null }, notesEncrypted: null }, + select: { id: true }, + take: PAGE_SIZE, + }); + if (rows.length === 0) break; + let migratedThisPage = 0; + for (const { id } of rows) { + if (await migrateSideEffectRow(id)) { + sideEffectsMigrated += 1; + migratedThisPage += 1; + } + } + // Guard against an unexpected non-converging page (every row already + // migrated by a racing worker) so the loop always terminates. + if (migratedThisPage === 0) break; + } + + // Dose-changes — no userId column; scope through the parent medication owner. + for (;;) { + const rows = await prisma.medicationDoseChange.findMany({ + where: { + medication: { userId }, + note: { not: null }, + noteEncrypted: null, + }, + select: { id: true }, + take: PAGE_SIZE, + }); + if (rows.length === 0) break; + let migratedThisPage = 0; + for (const { id } of rows) { + if (await migrateDoseChangeRow(id)) { + doseChangesMigrated += 1; + migratedThisPage += 1; + } + } + if (migratedThisPage === 0) break; + } + + // Inventory items — direct userId. + for (;;) { + const rows = await prisma.medicationInventoryItem.findMany({ + where: { userId, notes: { not: null }, notesEncrypted: null }, + select: { id: true }, + take: PAGE_SIZE, + }); + if (rows.length === 0) break; + let migratedThisPage = 0; + for (const { id } of rows) { + if (await migrateInventoryItemRow(id)) { + inventoryItemsMigrated += 1; + migratedThisPage += 1; + } + } + if (migratedThisPage === 0) break; + } + + annotate({ + action: { + name: "med.notes.encryption.backfill", + details: { + side_effects_migrated: sideEffectsMigrated, + dose_changes_migrated: doseChangesMigrated, + inventory_items_migrated: inventoryItemsMigrated, + }, + }, + }); + + return { sideEffectsMigrated, doseChangesMigrated, inventoryItemsMigrated }; +} + +/** + * Boot-time discovery. Finds every user holding at least one medication note + * still in the legacy plaintext column (no ciphertext yet) across any of the + * three tables and enqueues one backfill job per account. + * + * Idempotent across reboots: once a user's rows are migrated, the + * `*Encrypted IS NULL AND * != null` predicate drops them from the discovery + * set. pg-boss `singletonKey` coalesces duplicate sends. Best-effort: errors + * come back through the result value so worker boot never fails on a miss. + */ +export async function enqueueBootTimeMedNotesEncryptionBackfill(): Promise<{ + enqueued: number; + skipped: number; + error: string | null; +}> { + const boss = getGlobalBoss(); + if (!boss) { + return { enqueued: 0, skipped: 0, error: null }; + } + + try { + const [sideEffectUsers, doseChangeRows, inventoryUsers] = await Promise.all( + [ + prisma.medicationSideEffect.findMany({ + where: { notes: { not: null }, notesEncrypted: null }, + select: { userId: true }, + distinct: ["userId"], + }), + // Dose-changes carry no userId — surface the parent medication's owner + // and dedupe in JS (distinct cannot span a relation field). + prisma.medicationDoseChange.findMany({ + where: { note: { not: null }, noteEncrypted: null }, + select: { medication: { select: { userId: true } } }, + }), + prisma.medicationInventoryItem.findMany({ + where: { notes: { not: null }, notesEncrypted: null }, + select: { userId: true }, + distinct: ["userId"], + }), + ], + ); + + const userIds = new Set(); + for (const { userId } of sideEffectUsers) userIds.add(userId); + for (const { medication } of doseChangeRows) userIds.add(medication.userId); + for (const { userId } of inventoryUsers) userIds.add(userId); + + if (userIds.size === 0) { + return { enqueued: 0, skipped: 0, error: null }; + } + + let enqueued = 0; + let skipped = 0; + for (const userId of userIds) { + const payload: MedNotesEncryptionBackfillPayload = { + userId, + enqueuedAt: new Date().toISOString(), + }; + const jobId = await boss.send( + MED_NOTES_ENCRYPTION_BACKFILL_QUEUE, + payload, + { + retryLimit: 5, + retryDelay: 60, + retryBackoff: true, + singletonKey: `med-notes-encryption-backfill|${userId}`, + }, + ); + if (jobId) { + enqueued += 1; + } else { + skipped += 1; + } + } + return { enqueued, skipped, error: null }; + } catch (err) { + return { + enqueued: 0, + skipped: 0, + error: err instanceof Error ? err.message : String(err), + }; + } +} diff --git a/src/lib/jobs/reminder/register-maintenance.ts b/src/lib/jobs/reminder/register-maintenance.ts index ac11b6eec..33ad4e751 100644 --- a/src/lib/jobs/reminder/register-maintenance.ts +++ b/src/lib/jobs/reminder/register-maintenance.ts @@ -62,6 +62,13 @@ import { enqueueBootTimeNoteEncryptionBackfill, type NoteEncryptionBackfillPayload, } from "@/lib/jobs/note-encryption-backfill"; +import { + MED_NOTES_ENCRYPTION_BACKFILL_QUEUE, + MED_NOTES_ENCRYPTION_BACKFILL_CONCURRENCY, + runMedNotesEncryptionBackfillForUser, + enqueueBootTimeMedNotesEncryptionBackfill, + type MedNotesEncryptionBackfillPayload, +} from "@/lib/jobs/med-notes-encryption-backfill"; import { ENCRYPTION_KEY_ROTATE_QUEUE, ENCRYPTION_KEY_ROTATE_CONCURRENCY, @@ -198,6 +205,15 @@ const NOTE_ENCRYPTION_BACKFILL_CRON = "55 3 * * *"; const MCP_TOKEN_CLEANUP_QUEUE = "mcp-token-cleanup"; const MCP_TOKEN_CLEANUP_CRON = "0 4 * * *"; +// v1.25 — converging backfill that migrates the three medication free-text +// note columns (side-effect note, dose-change note, inventory-item note) from +// plaintext to AES-256-GCM at rest. Boot discovery enqueues one per-user job; +// a daily 04:05 Europe/Berlin discovery tick (empty payload) re-fans for any +// rows that landed between worker reboots. Idempotent + fail-closed per row. +// Slots after the MCP-token prune (04:00) so the two don't pile up on the same +// boss poll. + +const MED_NOTES_ENCRYPTION_BACKFILL_CRON = "5 4 * * *"; const allQueues = [ DATA_BACKUP_QUEUE, @@ -253,6 +269,11 @@ const allQueues = [ // entry the daily schedule silently no-ops and the api_tokens table grows // unbounded with dead 60-minute connector rows. MCP_TOKEN_CLEANUP_QUEUE, + // v1.25 — medication free-text note encryption backfill. Boot discovery + a + // daily discovery cron enqueue one per-user job per account still holding a + // plaintext medication note; without this entry pg-boss never provisions the + // queue and both the boot enqueue and the cron silently no-op. + MED_NOTES_ENCRYPTION_BACKFILL_QUEUE, ]; const schedules: ScheduleEntry[] = [ @@ -310,6 +331,11 @@ const schedules: ScheduleEntry[] = [ [NOTE_ENCRYPTION_BACKFILL_QUEUE, NOTE_ENCRYPTION_BACKFILL_CRON], // v1.24 — daily 04:00 Europe/Berlin prune for dead MCP connector tokens. [MCP_TOKEN_CLEANUP_QUEUE, MCP_TOKEN_CLEANUP_CRON], + // v1.25 — daily 04:05 Europe/Berlin medication-note encryption backfill + // discovery. The empty cron payload (no `userId`) is the handler's signal to + // fan out one per-user job per account still holding a plaintext medication + // note. + [MED_NOTES_ENCRYPTION_BACKFILL_QUEUE, MED_NOTES_ENCRYPTION_BACKFILL_CRON], ]; /** @@ -531,6 +557,50 @@ export async function registerMaintenanceQueues( handleEncryptionKeyRotate, ); + // v1.25 — medication-note encryption backfill worker. The boot enqueue + // helper (and the daily 04:05 discovery tick) send one job per user still + // holding a plaintext medication note; this handler migrates that user's + // rows across all three tables to the encrypted columns. An empty-userId + // payload is the daily discovery tick and re-fans the per-user jobs + // (singletonKey-coalesced, identical to the boot pass). Serial concurrency + // so the migration never crowds the request pool. + await boss.work>( + MED_NOTES_ENCRYPTION_BACKFILL_QUEUE, + { localConcurrency: MED_NOTES_ENCRYPTION_BACKFILL_CONCURRENCY }, + async (jobs) => { + for (const job of jobs) { + const { userId } = job.data; + if (!userId) { + const result = await enqueueBootTimeMedNotesEncryptionBackfill(); + workerLog( + "info", + `[med-notes-encryption-backfill] daily discovery enqueued=${result.enqueued} skipped=${result.skipped}${result.error ? ` error=${result.error}` : ""}`, + ); + continue; + } + try { + const { + sideEffectsMigrated, + doseChangesMigrated, + inventoryItemsMigrated, + } = await runMedNotesEncryptionBackfillForUser(userId); + workerLog( + "info", + `[med-notes-encryption-backfill] user=${userId} sideEffects=${sideEffectsMigrated} doseChanges=${doseChangesMigrated} inventoryItems=${inventoryItemsMigrated}`, + ); + } catch (err) { + recordError(); + workerLog( + "error", + `[med-notes-encryption-backfill] user=${userId} failed`, + err, + ); + throw err; + } + } + }, + ); + return allQueues; } @@ -586,4 +656,31 @@ export async function enqueueMaintenanceBootDiscovery(): Promise { err, ); } + + // v1.25 — medication free-text note encryption backfill. Finds every user + // still holding a plaintext side-effect / dose-change / inventory-item note + // (no ciphertext yet) and enqueues one migration job per account. Idempotent + // across reboots: a migrated row nulls its plaintext column and drops off the + // discovery predicate. + try { + const { enqueued, skipped, error } = + await enqueueBootTimeMedNotesEncryptionBackfill(); + if (error) { + workerLog( + "error", + `[med-notes-encryption-backfill] boot discovery failed: ${error}`, + ); + } else { + workerLog( + "info", + `[med-notes-encryption-backfill] boot discovery: enqueued=${enqueued} skipped=${skipped}`, + ); + } + } catch (err) { + workerLog( + "error", + "[med-notes-encryption-backfill] boot discovery threw an unexpected error", + err, + ); + } } diff --git a/src/lib/medications/inventory/service.ts b/src/lib/medications/inventory/service.ts index f317b9551..13b4c4919 100644 --- a/src/lib/medications/inventory/service.ts +++ b/src/lib/medications/inventory/service.ts @@ -13,6 +13,7 @@ import { prisma } from "@/lib/db"; import type { MedicationContainerType } from "@/generated/prisma/client"; +import { encryptNote } from "@/lib/crypto/note-cipher"; import { computeExpiresAt } from "./state-machine"; import { summariseSupply, @@ -156,6 +157,8 @@ export function buildCreateInventoryInput(input: { printedExpiry: input.printedExpiry, purchasedAt: input.purchasedAt, expiresAt: computeExpiresAt(null, input.printedExpiry), - notes: input.notes, + // Encrypt the free-text note at rest; the plaintext column stays null. + notesEncrypted: encryptNote(input.notes), + notes: null, }; } diff --git a/src/lib/observability/__tests__/redact-payload.test.ts b/src/lib/observability/__tests__/redact-payload.test.ts index 0f9491efb..2e9c7728a 100644 --- a/src/lib/observability/__tests__/redact-payload.test.ts +++ b/src/lib/observability/__tests__/redact-payload.test.ts @@ -1,8 +1,10 @@ import { describe, it, expect } from "vitest"; import { redactSensitiveFields, + redactForExcerpt, SENSITIVE_KEY_PATTERNS, } from "../redact-payload"; +import { buildPayloadDiagnostic } from "@/lib/api-response"; // v1.4.49 — pinned redactor for the wide-event `received_shape_excerpt` // surface introduced in v1.4.48 H-iOS-1 / H-iOS-2. The helper must @@ -178,3 +180,59 @@ describe("redactSensitiveFields", () => { }); }); }); + +describe("redactForExcerpt (v1.25 — note/notes excerpt-only redaction)", () => { + it("redacts a free-text `notes` value the generic denylist deliberately keeps", () => { + const body = { + entry: "NAUSEA", + severity: 3, + notes: "felt dizzy after the 7.5 mg step-up — Nürnberg", + }; + // The generic denylist leaves note/notes verbatim (too generic to add). + expect(redactSensitiveFields(body)).toEqual(body); + // The excerpt path redacts the value so it cannot reach a wide event. + expect(redactForExcerpt(body)).toEqual({ + entry: "NAUSEA", + severity: 3, + notes: "[redacted]", + }); + }); + + it("redacts a singular `note` key and recurses into nested objects + arrays", () => { + const body = { + doseChange: { doseValue: 7.5, note: "titration note" }, + logs: [ + { id: "1", note: "a" }, + { id: "2", note: "b" }, + ], + }; + expect(redactForExcerpt(body)).toEqual({ + doseChange: { doseValue: 7.5, note: "[redacted]" }, + logs: [ + { id: "1", note: "[redacted]" }, + { id: "2", note: "[redacted]" }, + ], + }); + }); + + it("does not over-redact note-adjacent keys (noteId / footnotes stay)", () => { + const body = { noteId: "abc", footnotes: "kept", noteCount: 3 }; + expect(redactForExcerpt(body)).toEqual(body); + }); + + it("still redacts the global sensitive keys on the excerpt path", () => { + const body = { note: "secret", token: "abc", keep: 1 }; + expect(redactForExcerpt(body)).toEqual({ + note: "[redacted]", + token: "[redacted]", + keep: 1, + }); + }); + + it("keeps a free-text note value out of the wide-event excerpt end to end", () => { + const body = { notes: "a very private free-text health note value" }; + const diag = buildPayloadDiagnostic(redactForExcerpt(body)); + expect(diag.received_shape_excerpt).not.toContain("private free-text"); + expect(diag.received_shape_excerpt).toContain("[redacted]"); + }); +}); diff --git a/src/lib/observability/redact-payload.ts b/src/lib/observability/redact-payload.ts index ef66fff89..327ad6d68 100644 --- a/src/lib/observability/redact-payload.ts +++ b/src/lib/observability/redact-payload.ts @@ -98,6 +98,18 @@ export const SENSITIVE_KEY_PATTERNS: readonly RegExp[] = [ /verifier/i, ]; +/** + * v1.25 — keys whose VALUE must be kept out of a wide-event excerpt but which + * are too generic to live on the global `SENSITIVE_KEY_PATTERNS` denylist: + * redacting every `note` / `notes` everywhere `redactSensitiveFields` runs + * would over-redact unrelated observability. These apply ONLY on the + * excerpt path via {@link redactForExcerpt}, where a verbatim free-text health + * note (now encrypted at rest) must never land in `received_shape_excerpt`. + * Anchored so only the exact `note` / `notes` keys match — not, say, `noteId` + * or `footnotes`. + */ +export const EXCERPT_ONLY_KEY_PATTERNS: readonly RegExp[] = [/^notes?$/i]; + const REDACTED = "[redacted]"; function keyIsSensitive(key: string): boolean { @@ -107,20 +119,28 @@ function keyIsSensitive(key: string): boolean { return false; } +function keyIsExcerptSensitive(key: string): boolean { + for (const re of EXCERPT_ONLY_KEY_PATTERNS) { + if (re.test(key)) return true; + } + return false; +} + /** - * Returns a deep clone of `body` with any key matching the sensitive - * denylist replaced by the literal `"[redacted]"`. Arrays + plain - * objects are recursed into; scalars, `null`, `undefined`, `Date` and - * other non-plain objects pass through unchanged. - * - * The clone is intentionally shallow on non-plain-object boundaries so - * the helper stays cheap on the hot 422 path; the only goal is to keep - * the credentialed key's value out of the eventual JSON excerpt. + * Deep clone of `body` with any key matching `isSensitive` replaced by the + * literal `"[redacted]"`. Arrays + plain objects are recursed into; scalars, + * `null`, `undefined`, `Date` and other non-plain objects pass through + * unchanged. The clone is intentionally shallow on non-plain-object boundaries + * so the helper stays cheap on the hot 422 path; the only goal is to keep the + * matched key's value out of the eventual JSON excerpt. */ -export function redactSensitiveFields(body: unknown): unknown { +function redactWith( + body: unknown, + isSensitive: (key: string) => boolean, +): unknown { if (body === null || body === undefined) return body; if (Array.isArray(body)) { - return body.map((entry) => redactSensitiveFields(entry)); + return body.map((entry) => redactWith(entry, isSensitive)); } if (typeof body !== "object") return body; // Skip non-plain objects (Date, Map, Set, Buffer, ...) — they'll @@ -131,11 +151,32 @@ export function redactSensitiveFields(body: unknown): unknown { const out: Record = {}; for (const [key, value] of Object.entries(body as Record)) { - if (keyIsSensitive(key)) { + if (isSensitive(key)) { out[key] = REDACTED; continue; } - out[key] = redactSensitiveFields(value); + out[key] = redactWith(value, isSensitive); } return out; } + +/** + * Returns a deep clone of `body` with any key matching the sensitive + * denylist replaced by the literal `"[redacted]"`. + */ +export function redactSensitiveFields(body: unknown): unknown { + return redactWith(body, keyIsSensitive); +} + +/** + * v1.25 — excerpt-path redactor. Applies the global sensitive denylist PLUS + * the excerpt-only `note` / `notes` keys, so a free-text health-note value can + * never reach the `received_shape_excerpt` verbatim. Use this — not + * `redactSensitiveFields` — for anything that feeds `buildPayloadDiagnostic`. + */ +export function redactForExcerpt(body: unknown): unknown { + return redactWith( + body, + (key) => keyIsSensitive(key) || keyIsExcerptSensitive(key), + ); +} From 84683f4ce9d029f03682f88dc9d15b6fb5a1021f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 03:07:15 +0200 Subject: [PATCH 07/94] perf: bound heavy-tenant memory on feature, consolidation, backup and auth paths Cap and page the unbounded reads that let a multi-year tenant pull its whole measurement history into memory on hot paths. - extractFeatures: read newest-first with a 6000-row cap (mirrors the Coach snapshot) and reverse to ascending, so a 365-400 day window no longer materialises hundreds of thousands of PULSE / glucose rows per Coach turn and nightly briefing. Full-history extremes still come from readAllTimeExtremes, so the cap never relabels an extreme. - Add a (userId, measuredAt) index on measurements (migration 0210) for the type-less range scans that extractFeatures and the consolidation passes drive; the existing (userId, type, measuredAt) cannot serve them as a prefix. - runConsolidation: keyset-paginate the per-(user, type) source scan on (measuredAt, id) instead of one unbounded findMany, removing the giant single result-set allocation that drove the boot storm. Output is identical; covered by a paging unit test. - Stagger the boot-time backfill enqueues with increasing startAfter offsets so the heavy full-history loads no longer all drain in parallel onto one tenant at startup. Non-boot callers keep their immediate send. - Backups: replace load-all-then-stringify with paged narrow-select reads for the weekly and off-host writers, bounding transient heap. Off-host still includes soft-deleted rows. Covered by a serialize/restore round-trip test. - Drop the per-request auth.bearer.success audit insert; the wide event already records auth_method and user_id, so the polling client no longer drives a write + pool checkout on every request. Failure-path audit stays. - Coach conversation detail: cap the message include to the newest 200 so a long thread no longer decrypts every message on each open; response shape unchanged. - Admin audit-log: floor the scan at the retention horizon so the listing and its count stop sweeping open-ended over the substring filters. --- .../migration.sql | 30 +++ prisma/schema.prisma | 6 + src/app/api/admin/audit-log/route.ts | 31 ++- src/lib/__tests__/require-auth-bearer.test.ts | 11 +- src/lib/ai/coach/persistence.ts | 21 +- src/lib/api-handler.ts | 10 +- src/lib/insights/features.ts | 61 +++-- .../mean-consolidation-queue.test.ts | 4 +- .../backup/__tests__/measurement-page.test.ts | 238 ++++++++++++++++++ src/lib/jobs/backup/measurement-page.ts | 126 ++++++++++ src/lib/jobs/dense-intraday-retention.ts | 3 +- src/lib/jobs/mean-consolidation.ts | 9 +- src/lib/jobs/note-encryption-backfill.ts | 9 +- src/lib/jobs/offhost-backup.ts | 50 +++- src/lib/jobs/reminder/backup-handlers.ts | 60 +++-- src/lib/jobs/reminder/register-maintenance.ts | 11 +- src/lib/jobs/reminder/register-rollup.ts | 45 +++- src/lib/jobs/reminder/shared.ts | 11 + src/lib/jobs/step-consolidation.ts | 9 +- .../consolidation-scan-paged.test.ts | 195 ++++++++++++++ src/lib/measurements/consolidation-base.ts | 82 +++++- src/lib/rollups/measurement-rollups.ts | 9 +- .../rollups/medication-compliance-rollups.ts | 9 +- src/lib/rollups/mood-rollups.ts | 9 +- 24 files changed, 971 insertions(+), 78 deletions(-) create mode 100644 prisma/migrations/0210_v125_measurement_useractivity_index/migration.sql create mode 100644 src/lib/jobs/backup/__tests__/measurement-page.test.ts create mode 100644 src/lib/jobs/backup/measurement-page.ts create mode 100644 src/lib/measurements/__tests__/consolidation-scan-paged.test.ts diff --git a/prisma/migrations/0210_v125_measurement_useractivity_index/migration.sql b/prisma/migrations/0210_v125_measurement_useractivity_index/migration.sql new file mode 100644 index 000000000..436837646 --- /dev/null +++ b/prisma/migrations/0210_v125_measurement_useractivity_index/migration.sql @@ -0,0 +1,30 @@ +-- v1.25 — type-less `(user_id, measured_at)` supporting index on `measurements`. +-- +-- The bulk feature read (`extractFeatures`, src/lib/insights/features.ts) and +-- the consolidation scans walk a user's measurements ordered by `measured_at` +-- with no `type` predicate. The existing composite indexes cover +-- `(user_id, type, measured_at)`, `(user_id, source, measured_at)`, and +-- `(user_id, updated_at, id)` — none serves a type-less `(user_id, measured_at)` +-- range scan as a prefix. Without this index Postgres filters by `user_id` then +-- sorts the user's entire measurement set in memory on each of those reads, +-- which on a multi-year HealthKit tenant is hundreds of thousands of rows. +-- +-- The composite `(user_id, measured_at)` index lets those reads stream straight +-- off the index in time order, which the newest-first row caps then bound. +-- +-- Additive + order-safe: a pure `CREATE INDEX IF NOT EXISTS`, no column add, no +-- backfill, no rewrite of `measurements`. +-- +-- Lock note: this is a plain, non-`CONCURRENTLY` index build, so it runs inside +-- Prisma's migration transaction and holds an ACCESS EXCLUSIVE lock on +-- `measurements` for the build's duration. The index covers every row (no +-- partial predicate), so on the largest multi-year HealthKit tenants the build +-- is slow; run the deploy at a low-traffic window on such deployments. A +-- `CONCURRENTLY` build cannot run inside the migration transaction; a plain +-- build is acceptable at this table size in the maintenance window. +-- +-- Reversibility: down migration is +-- DROP INDEX IF EXISTS "measurements_user_id_measured_at_idx"; + +CREATE INDEX IF NOT EXISTS "measurements_user_id_measured_at_idx" + ON "measurements" ("user_id", "measured_at"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5bbd4cb75..1a84d6487 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1352,6 +1352,12 @@ model Measurement { // (Postgres semantics) so manual entries don't collide here. @@unique([userId, type, source, externalId]) @@index([userId, type, measuredAt]) + // v1.25 — type-less `(userId, measuredAt)` range scan. The bulk feature read + // (`extractFeatures`) and consolidation scans walk a user's measurements by + // time with no `type` predicate; `(userId, type, measuredAt)` can't serve + // that as a prefix, so without this index Postgres scans the user's whole set + // and sorts in memory. See migration 0210. + @@index([userId, measuredAt]) // v1.15.13 — leads with `(userId, source)` so the management list's // source filter + recency sort is an index range scan instead of a // `userId` scan + in-memory sort. See migration 0136. diff --git a/src/app/api/admin/audit-log/route.ts b/src/app/api/admin/audit-log/route.ts index 6ba1fb45a..26a0e970f 100644 --- a/src/app/api/admin/audit-log/route.ts +++ b/src/app/api/admin/audit-log/route.ts @@ -3,6 +3,7 @@ import { apiHandler, requireAdmin } from "@/lib/api-handler"; import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { redactSecrets } from "@/lib/logging/redact"; +import { getAuditLogRetentionDays } from "@/lib/jobs/audit-log-cleanup"; import { NextRequest } from "next/server"; import { z } from "zod/v4"; @@ -39,7 +40,9 @@ export const dynamic = "force-dynamic"; * - actor — substring match against `userId` OR `user.username` * - action — exact match (e.g. `auth.login.failed`) * - target — substring match against the JSON-encoded `details` field - * - since — ISO-8601 lower bound on `createdAt` + * - since — ISO-8601 lower bound on `createdAt`. Absent, the scan + * floors at the audit-log retention horizon so an + * open-ended sweep can't seqscan the table unbounded. * - until — ISO-8601 upper bound on `createdAt` * - filter — legacy shortcut: `auth` restricts to `auth.*` actions * @@ -117,15 +120,25 @@ export const GET = apiHandler(async (request: NextRequest) => { ands.push({ details: { contains: parsed.target } }); } - if (parsed.since || parsed.until) { - const range: { gte?: Date; lte?: Date } = {}; - if (parsed.since) range.gte = new Date(parsed.since); - if (parsed.until) range.lte = new Date(parsed.until); - ands.push({ createdAt: range }); - } + // Bound the lower edge of the scan. `actor`/`target` are substring + // matches (seqscans) and the exact `count` below runs a second pass over + // the same predicate, so an open-ended history sweep on this admin-only, + // low-frequency surface would scan the table twice with no floor. The + // cleanup job already prunes rows past the retention horizon, so aligning + // the default floor to that same horizon bounds both passes without ever + // hiding a row the deployment still keeps. An explicit `since` overrides. + const lowerBound = parsed.since + ? new Date(parsed.since) + : new Date(Date.now() - getAuditLogRetentionDays() * 86_400_000); + const createdAt: { gte: Date; lte?: Date } = { gte: lowerBound }; + if (parsed.until) createdAt.lte = new Date(parsed.until); - const where: Record | undefined = - ands.length === 0 ? undefined : ands.length === 1 ? ands[0] : { AND: ands }; + // Keep `createdAt` a top-level sibling of the optional filter clause so a + // single substring/exact filter stays flat (no `AND` wrapper); the + // collapse below only folds the independent filter `ands` entries. + const filterWhere: Record = + ands.length === 0 ? {} : ands.length === 1 ? ands[0] : { AND: ands }; + const where: Record = { ...filterWhere, createdAt }; const [entries, total] = await Promise.all([ prisma.auditLog.findMany({ diff --git a/src/lib/__tests__/require-auth-bearer.test.ts b/src/lib/__tests__/require-auth-bearer.test.ts index b175ace28..21af979a5 100644 --- a/src/lib/__tests__/require-auth-bearer.test.ts +++ b/src/lib/__tests__/require-auth-bearer.test.ts @@ -105,9 +105,11 @@ describe("requireAuth — Bearer token path", () => { where: { id: "token-1" }, data: { lastUsedAt: expect.any(Date) }, }); - expect(auditLog).toHaveBeenCalledWith( + // v1.25 — the success path no longer writes a per-request audit row; the + // wide event carries `auth_method: "bearer"` + `user_id` instead. + expect(auditLog).not.toHaveBeenCalledWith( "auth.bearer.success", - expect.objectContaining({ userId: "user-1" }), + expect.anything(), ); }); @@ -219,9 +221,10 @@ describe("requireAuth — Bearer token path", () => { const ctx = await requireAuth(); expect(ctx.user.id).toBe("user-1"); expect(ctx.session.id).toBe("token-5a"); - expect(auditLog).toHaveBeenCalledWith( + // v1.25 — no per-request success audit row on the accept path. + expect(auditLog).not.toHaveBeenCalledWith( "auth.bearer.success", - expect.objectContaining({ userId: "user-1" }), + expect.anything(), ); }); diff --git a/src/lib/ai/coach/persistence.ts b/src/lib/ai/coach/persistence.ts index dfe7bef9a..b5955fc6b 100644 --- a/src/lib/ai/coach/persistence.ts +++ b/src/lib/ai/coach/persistence.ts @@ -253,11 +253,21 @@ export async function recordProactiveNudge( }); } +// Very long threads previously decrypted every message on each open; the +// newest messages up to this cap cover the rendered window without the +// unbounded per-open AES-decrypt cost. The response shape is unchanged — +// the messages array still arrives oldest->newest (see the reverse below). +const CONVERSATION_MESSAGE_DETAIL_CAP = 200; + /** - * Fetch one conversation + every message, decrypting each body on + * Fetch one conversation + its messages, decrypting each body on * read. Returns null when the conversation does not exist OR when the * supplied `userId` does not own it — callers should map both cases to * a 404 to avoid an existence-leak side channel. + * + * Only the newest `CONVERSATION_MESSAGE_DETAIL_CAP` messages are loaded + * and decrypted; the result stays ascending so the response envelope is + * byte-for-byte the same shape callers already consume. */ export async function fetchConversationWithMessages( userId: string, @@ -267,13 +277,18 @@ export async function fetchConversationWithMessages( where: { id: conversationId, userId }, include: { messages: { - orderBy: { createdAt: "asc" }, + // Fetch the newest N first, then restore ascending order in code + // so the unbounded per-open decrypt cost is capped without + // changing the oldest->newest contract the client renders. + orderBy: { createdAt: "desc" }, + take: CONVERSATION_MESSAGE_DETAIL_CAP, }, }, }); if (!row) return null; - const messages: CoachMessageDTO[] = row.messages.map((m) => ({ + const orderedMessages = [...row.messages].reverse(); + const messages: CoachMessageDTO[] = orderedMessages.map((m) => ({ id: m.id, role: m.role as CoachMessageRole, content: decryptFromBytes(m.encryptedContent), diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index 18bd0d40c..0250bbe3c 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -406,11 +406,11 @@ async function authenticateBearer( } } - auditLog("auth.bearer.success", { - userId: user.id, - details: { tokenId }, - }).catch(() => {}); - + // v1.25 — no per-request success audit row. The polling iOS client drove a + // constant INSERT + pool checkout on every authenticated Bearer request; the + // wide event below already records `auth_method: "bearer"` + `user_id`, so the + // success path stays fully observable without the write churn. The failure + // path keeps its audit row. const evt = getEvent(); if (evt) { evt.setAuth({ diff --git a/src/lib/insights/features.ts b/src/lib/insights/features.ts index 5d4c54872..1ac965970 100644 --- a/src/lib/insights/features.ts +++ b/src/lib/insights/features.ts @@ -752,6 +752,16 @@ const WORKOUT_WINDOW_DAYS = 90; const MS_PER_DAY = 24 * 60 * 60 * 1000; +/** + * v1.25 — newest-first row cap on the bulk measurement read in + * `extractFeatures`. Mirrors the Coach snapshot's `SNAPSHOT_MEASUREMENT_ROW_CAP` + * (6000): the prompt / insights aggregates only ever fold this read into a + * bounded set of summaries, so a year of dense PULSE / glucose rows is wasted + * I/O on the shared pool for a heavy-data tenant. Full-history extremes come + * from `readAllTimeExtremes`, not this read, so the cap stays correct. + */ +const FEATURE_MEASUREMENT_ROW_CAP = 6000; + /** * Strip control chars + collapse whitespace, then bound the length, before a * user-supplied label can reach the briefing prompt. Mirrors the labs / @@ -1010,24 +1020,39 @@ export async function extractFeatures( typeof sinceDays === "number" && sinceDays > 0 ? new Date(now - sinceDays * 24 * 60 * 60 * 1000) : null; - const measurements = await prisma.measurement.findMany({ - where: sinceCutoff - ? { userId, measuredAt: { gte: sinceCutoff }, deletedAt: null } - : { userId, deletedAt: null }, - orderBy: { measuredAt: "asc" }, - // Project only the columns every downstream consumer reads (`byType`, - // `summarize`, BP pairing, and `reconstructSleepNights`'s `SleepStageRow`). - // The PULSE / glucose windows are 200k-row-class; pulling every column - // (notes, externalId, …) is pure wasted I/O on the shared Prisma pool. - select: { - type: true, - value: true, - measuredAt: true, - sleepStage: true, - source: true, - deviceType: true, - }, - }); + // v1.25 — newest-first cap on the bulk feature read, mirroring the Coach + // snapshot (`SNAPSHOT_MEASUREMENT_ROW_CAP`). Callers pass windows of 365-400 + // days, and PULSE / glucose are 200k-row-class types, so an uncapped read + // pulled hundreds of thousands of rows per Coach turn and nightly briefing + // only to fold them into a handful of summaries. Read newest-first, cap, then + // reverse so every downstream consumer (`byType`, `summarize`, BP pairing, + // sleep reconstruction, oldest/newest span) still sees ascending order. The + // cap is safe even unbounded: the genuine all-time extremes are sourced + // separately via `readAllTimeExtremes` on the bounded branch, so capping only + // sheds the deepest rows of the bulk read and never relabels an extreme. + const measurements = await prisma.measurement + .findMany({ + where: sinceCutoff + ? { userId, measuredAt: { gte: sinceCutoff }, deletedAt: null } + : { userId, deletedAt: null }, + orderBy: { measuredAt: "desc" }, + take: FEATURE_MEASUREMENT_ROW_CAP, + // Project only the columns every downstream consumer reads (`byType`, + // `summarize`, BP pairing, and `reconstructSleepNights`'s `SleepStageRow`). + // The PULSE / glucose windows are 200k-row-class; pulling every column + // (notes, externalId, …) is pure wasted I/O on the shared Prisma pool. + select: { + type: true, + value: true, + measuredAt: true, + sleepStage: true, + source: true, + deviceType: true, + }, + }) + // Restore ascending order so order-sensitive consumers (oldest/newest span, + // BP pairing, sleep reconstruction) see the same shape as before the cap. + .then((rows) => rows.reverse()); // v1.18.11 P1 — when the bulk read is bounded to a recent window, the // windowed `summarize()` no longer covers the full history, so the `allTime*` diff --git a/src/lib/jobs/__tests__/mean-consolidation-queue.test.ts b/src/lib/jobs/__tests__/mean-consolidation-queue.test.ts index 344120834..3b5956ec6 100644 --- a/src/lib/jobs/__tests__/mean-consolidation-queue.test.ts +++ b/src/lib/jobs/__tests__/mean-consolidation-queue.test.ts @@ -41,6 +41,8 @@ describe("reminder-worker — mean-consolidation wiring", () => { }); it("fires the boot-discovery enqueue helper", () => { - expect(source).toMatch(/await enqueueBootTimeMeanConsolidation\(\)/); + // The boot wiring passes a stagger offset so the heavy backfills don't all + // drain in parallel onto one tenant; allow an optional argument here. + expect(source).toMatch(/await enqueueBootTimeMeanConsolidation\([^)]*\)/); }); }); diff --git a/src/lib/jobs/backup/__tests__/measurement-page.test.ts b/src/lib/jobs/backup/__tests__/measurement-page.test.ts new file mode 100644 index 000000000..fa668c0ac --- /dev/null +++ b/src/lib/jobs/backup/__tests__/measurement-page.test.ts @@ -0,0 +1,238 @@ +/** + * Round-trip + paging contract for the backup measurement reader. + * + * Runs without a database: the keyset pager takes a `fetchPage` callback, + * so an in-memory fixture stands in for Prisma. This pins the two things + * the heap-bounding refactor must not break: + * 1. the keyset scan visits every row exactly once, in id order, + * across page boundaries and the exact-multiple edge case; + * 2. a measurement survives the full serialize → encrypt-blob shape → + * `parseBackupPayload` → restore-mapping round-trip unchanged, + * including a soft-deleted (tombstoned) row, which both writers keep. + */ +import { describe, it, expect } from "vitest"; +import { + collectPagedMeasurements, + toWeeklyBackupMeasurement, + sortWeeklyMeasurementsDesc, + type WeeklyMeasurementRow, +} from "@/lib/jobs/backup/measurement-page"; +import { parseBackupPayload } from "@/lib/validations/backup"; + +/** Build an id-ascending fixture; `id` is zero-padded so string order == insert order. */ +function makeRows(n: number): WeeklyMeasurementRow[] { + return Array.from({ length: n }, (_, i) => ({ + id: `m${String(i).padStart(4, "0")}`, + type: "WEIGHT", + value: 70 + i, + unit: "kg", + source: "MANUAL", + measuredAt: new Date(Date.UTC(2026, 0, 1 + i, 8, 0, 0)), + notes: i % 2 === 0 ? `note ${i}` : null, + // notesEncrypted left null so readNote returns the plaintext fallback + // and the pure test needs no encryption key. + notesEncrypted: null, + })); +} + +/** In-memory keyset fetcher over an id-sorted fixture. */ +function fakeFetcher(rows: WeeklyMeasurementRow[]) { + const sorted = [...rows].sort((a, b) => + a.id < b.id ? -1 : a.id > b.id ? 1 : 0, + ); + return (afterId: string | null, take: number) => { + const start = afterId ? sorted.findIndex((r) => r.id === afterId) + 1 : 0; + return Promise.resolve(sorted.slice(start, start + take)); + }; +} + +describe("collectPagedMeasurements", () => { + it("visits every row exactly once, in id order, across pages", async () => { + const rows = makeRows(25); + const out = await collectPagedMeasurements({ + fetchPage: fakeFetcher(rows), + project: (r) => r.id, + pageSize: 10, + }); + expect(out).toEqual(rows.map((r) => r.id)); + expect(new Set(out).size).toBe(out.length); + }); + + it("terminates on the exact-multiple boundary without dropping rows", async () => { + const rows = makeRows(20); + const out = await collectPagedMeasurements({ + fetchPage: fakeFetcher(rows), + project: (r) => r.id, + pageSize: 10, + }); + expect(out).toHaveLength(20); + expect(out[out.length - 1]).toBe("m0019"); + }); + + it("handles an empty set", async () => { + const out = await collectPagedMeasurements({ + fetchPage: fakeFetcher([]), + project: (r) => r.id, + pageSize: 10, + }); + expect(out).toEqual([]); + }); +}); + +describe("weekly backup measurement round-trip", () => { + it("serialize → parseBackupPayload → restore-map equals originals", async () => { + const rows: WeeklyMeasurementRow[] = [ + { + id: "m0001", + type: "WEIGHT", + value: 82.4, + unit: "kg", + source: "MANUAL", + measuredAt: new Date("2026-02-10T07:30:00.000Z"), + notes: "morning", + notesEncrypted: null, + }, + { + id: "m0002", + type: "PULSE", + value: 58, + unit: "bpm", + source: "WITHINGS", + measuredAt: new Date("2026-02-11T06:00:00.000Z"), + notes: null, + notesEncrypted: null, + }, + { + // A tombstoned row: the weekly writer applies no deletedAt filter, + // so a soft-deleted measurement is still backed up. The narrow + // weekly projection does not carry deletedAt (the restore does not + // recreate it), but the row must still appear in the payload. + id: "m0003", + type: "BLOOD_GLUCOSE", + value: 95, + unit: "mg/dL", + source: "IMPORT", + measuredAt: new Date("2026-02-12T12:00:00.000Z"), + notes: "fasting", + notesEncrypted: null, + }, + ]; + + const measurements = sortWeeklyMeasurementsDesc( + await collectPagedMeasurements({ + fetchPage: fakeFetcher(rows), + project: toWeeklyBackupMeasurement, + pageSize: 2, + }), + ); + + // Same envelope the writer produces, then parse it back exactly as the + // admin restore route does. + const backupJson = JSON.stringify({ + schemaVersion: "1", + exportedAt: new Date("2026-02-13T00:00:00.000Z").toISOString(), + userId: "user-1", + measurements, + medications: [], + intakeEvents: [], + moodEntries: [], + }); + const payload = parseBackupPayload(backupJson); + + expect(payload.measurements).toHaveLength(rows.length); + + // measuredAt-desc ordering preserved (newest first). + expect(payload.measurements.map((m) => m.measuredAt)).toEqual([ + "2026-02-12T12:00:00.000Z", + "2026-02-11T06:00:00.000Z", + "2026-02-10T07:30:00.000Z", + ]); + + // Every original row survives the round-trip by value (the tombstoned + // m0003 included). + for (const original of rows) { + const restored = payload.measurements.find( + (m) => m.measuredAt === original.measuredAt.toISOString(), + ); + expect(restored, `row ${original.id} missing from payload`).toBeDefined(); + expect(restored!.type).toBe(original.type); + expect(restored!.value).toBe(original.value); + expect(restored!.unit).toBe(original.unit); + expect(restored!.source).toBe(original.source); + expect(restored!.notes ?? null).toBe(original.notes ?? null); + } + }); +}); + +describe("offhost backup measurement round-trip", () => { + it("full-row JSON dump → parse preserves all columns incl. soft-deleted", async () => { + // The off-host DR dump keeps the full row shape (identity projection). + // Mirror the writer's select output for a live row and a tombstoned row. + const liveRow = { + id: "m0001", + userId: "user-1", + type: "WEIGHT", + value: 80.1, + valueMin: null, + valueMax: null, + unit: "kg", + source: "MANUAL", + measuredAt: new Date("2026-03-01T07:00:00.000Z"), + notes: null, + notesEncrypted: null, + externalId: null, + externalSourceVersion: null, + glucoseContext: null, + sleepStage: null, + rhythmClassification: null, + deviceType: "scale", + syncVersion: 1, + deletedAt: null, + createdAt: new Date("2026-03-01T07:00:01.000Z"), + updatedAt: new Date("2026-03-01T07:00:01.000Z"), + }; + const tombstonedRow = { + ...liveRow, + id: "m0002", + value: 81.2, + measuredAt: new Date("2026-03-02T07:00:00.000Z"), + deletedAt: new Date("2026-03-03T09:00:00.000Z"), + }; + + const measurements = await collectPagedMeasurements({ + fetchPage: fakeFetcher([ + liveRow, + tombstonedRow, + ] as unknown as WeeklyMeasurementRow[]), + project: (row) => row, + pageSize: 1, + }); + + const payload = JSON.parse( + JSON.stringify({ + exportedAt: new Date("2026-03-04T00:00:00.000Z").toISOString(), + userId: "user-1", + measurements, + medications: [], + intakeEvents: [], + moodEntries: [], + cycleProfile: null, + cycles: [], + cycleDayLogs: [], + }), + ) as { measurements: Array> }; + + expect(payload.measurements).toHaveLength(2); + + // The soft-deleted row survives with its tombstone instant intact — + // the DR snapshot must include it. + const restoredTombstone = payload.measurements.find( + (m) => m.id === "m0002", + )!; + expect(restoredTombstone.deletedAt).toBe("2026-03-03T09:00:00.000Z"); + + // Every scalar column the writer selected is present on the dump. + const expectedKeys = Object.keys(liveRow).sort(); + expect(Object.keys(payload.measurements[0]).sort()).toEqual(expectedKeys); + }); +}); diff --git a/src/lib/jobs/backup/measurement-page.ts b/src/lib/jobs/backup/measurement-page.ts new file mode 100644 index 000000000..abbf1ab95 --- /dev/null +++ b/src/lib/jobs/backup/measurement-page.ts @@ -0,0 +1,126 @@ +/** + * Keyset-paged measurement reader shared by the two backup writers. + * + * Both writers — the weekly on-host `DataBackup` snapshot + * (`reminder/backup-handlers.ts`) and the nightly off-host DR dump + * (`offhost-backup.ts`) — used to load a user's ENTIRE measurement set + * as full ORM rows into one array and then run a single + * `JSON.stringify` over the whole payload. On a heavy multi-year tenant + * that holds every measurement row plus the giant serialized string in + * memory at the same instant, which is the dominant heap spike of either + * job and scales linearly with history depth. + * + * This reader walks the set in id-ascending keyset pages: at most one + * page of rows is resident at a time, and each page is projected to its + * compact backup shape before the next page is fetched. Transient peak + * heap is bounded by the page size, not by the size of the user's + * history. The accumulated output is the compact projection the payload + * needs anyway — not the wide ORM rows. + * + * Keyset (cursor on `id`) rather than `skip`/`take` offset paging so the + * scan stays O(rows) instead of O(rows^2) as the offset grows, and so a + * concurrent insert can't shift rows across a page boundary. + */ +import { readNote } from "@/lib/crypto/note-cipher"; + +/** + * Page size for the backup keyset scan. Large enough that the per-page + * round-trip overhead stays negligible, small enough that a single page + * of rows is a bounded slice of any tenant's history. + */ +export const MEASUREMENT_BACKUP_PAGE_SIZE = 2000; + +/** + * Walk a keyset-paged result set, projecting each row to its compact + * backup shape as it arrives. `fetchPage` returns the next page of rows + * after `afterId` (exclusive, id-ascending), bounded to `take` rows; + * returning fewer than `take` rows signals the final page. The generic + * stays free of any Prisma dependency so the paging contract is unit + * testable without a database. + */ +export async function collectPagedMeasurements< + Row extends { id: string }, + Out, +>(args: { + fetchPage: (afterId: string | null, take: number) => Promise; + project: (row: Row) => Out; + pageSize?: number; +}): Promise { + const { fetchPage, project } = args; + const pageSize = args.pageSize ?? MEASUREMENT_BACKUP_PAGE_SIZE; + const out: Out[] = []; + let afterId: string | null = null; + for (;;) { + const page = await fetchPage(afterId, pageSize); + if (page.length === 0) break; + for (const row of page) out.push(project(row)); + if (page.length < pageSize) break; + afterId = page[page.length - 1].id; + } + return out; +} + +/** + * Narrow row shape the weekly snapshot reads — exactly the columns the + * admin restore path recreates a measurement from + * (`api/admin/backups/[id]/restore`): `type`, `value`, `unit`, `source`, + * `measuredAt`, plus the note pair so the decrypted note rides into the + * (whole-blob-encrypted) payload. `id` rides along only as the keyset + * cursor. + */ +export interface WeeklyMeasurementRow { + id: string; + type: string; + value: number; + unit: string; + source: string; + measuredAt: Date; + notes: string | null; + notesEncrypted: Uint8Array | null; +} + +/** Compact measurement shape the weekly `DataBackup` JSON carries. */ +export interface WeeklyBackupMeasurement { + type: string; + value: number; + unit: string; + measuredAt: string; + source: string; + notes: string | null; +} + +/** + * Project a narrow measurement row into the weekly backup shape. The + * note is decrypted here so the restore re-encrypts on re-insert — the + * same contract the previous inline writer used. Shared with the writer + * so the round-trip test exercises the real projection. + */ +export function toWeeklyBackupMeasurement( + row: WeeklyMeasurementRow, +): WeeklyBackupMeasurement { + return { + type: row.type, + value: row.value, + unit: row.unit, + measuredAt: row.measuredAt.toISOString(), + source: row.source, + notes: readNote(row.notesEncrypted, row.notes), + }; +} + +/** + * Order the collected weekly measurements newest-first to match the + * pre-paging `orderBy: { measuredAt: "desc" }` output. The keyset scan + * reads id-ascending for a stable cursor; this restores the historical + * array order. Operates on the compact projections, so it does not + * reintroduce the wide-row heap spike. ISO-8601 strings sort + * lexicographically in chronological order, so a string compare is the + * chronological compare. + */ +export function sortWeeklyMeasurementsDesc( + measurements: WeeklyBackupMeasurement[], +): WeeklyBackupMeasurement[] { + return measurements.sort((a, b) => + a.measuredAt < b.measuredAt ? 1 : a.measuredAt > b.measuredAt ? -1 : 0, + ); +} diff --git a/src/lib/jobs/dense-intraday-retention.ts b/src/lib/jobs/dense-intraday-retention.ts index 7654e50fe..32cead3b4 100644 --- a/src/lib/jobs/dense-intraday-retention.ts +++ b/src/lib/jobs/dense-intraday-retention.ts @@ -199,7 +199,8 @@ export async function enqueueBootTimeDenseIntradayRetention(): Promise<{ retryDelay: 60, retryBackoff: true, // Defer past the boot storm so the drain never contends with - // startup + foreground for the shared connection pool. + // startup + foreground for the shared connection pool. This drain is + // the furthest-out stage among the boot backfills by design. startAfter: DENSE_INTRADAY_RETENTION_BOOT_DELAY_SECONDS, singletonKey: `dense-intraday-retention|${userId}`, }); diff --git a/src/lib/jobs/mean-consolidation.ts b/src/lib/jobs/mean-consolidation.ts index 6ed0b9dff..c337b801e 100644 --- a/src/lib/jobs/mean-consolidation.ts +++ b/src/lib/jobs/mean-consolidation.ts @@ -82,7 +82,13 @@ export async function runMeanConsolidationForUser( * Best-effort: errors are returned through the result value so the * worker boot never fails because of a consolidation miss. */ -export async function enqueueBootTimeMeanConsolidation(): Promise<{ +export async function enqueueBootTimeMeanConsolidation( + // Optional boot-storm stagger. When > 0 the per-user sends carry a + // `startAfter` delay (seconds) so this consolidation does not drain in + // parallel with the other boot backfills onto one heavy tenant. Default 0 + // keeps immediate semantics for any non-boot caller. + startAfterSeconds: number = 0, +): Promise<{ enqueued: number; skipped: number; error: string | null; @@ -127,6 +133,7 @@ export async function enqueueBootTimeMeanConsolidation(): Promise<{ retryDelay: 60, retryBackoff: true, singletonKey: `mean-consolidation|${userId}`, + ...(startAfterSeconds > 0 ? { startAfter: startAfterSeconds } : {}), }); if (jobId) { enqueued += 1; diff --git a/src/lib/jobs/note-encryption-backfill.ts b/src/lib/jobs/note-encryption-backfill.ts index 1d4c5ede8..04171bfbe 100644 --- a/src/lib/jobs/note-encryption-backfill.ts +++ b/src/lib/jobs/note-encryption-backfill.ts @@ -175,7 +175,13 @@ export async function runNoteEncryptionBackfillForUser( * set. pg-boss `singletonKey` coalesces duplicate sends. Best-effort: errors * come back through the result value so worker boot never fails on a miss. */ -export async function enqueueBootTimeNoteEncryptionBackfill(): Promise<{ +export async function enqueueBootTimeNoteEncryptionBackfill( + // Optional boot-storm stagger. When > 0 the per-user sends carry a + // `startAfter` delay (seconds) so this migration does not drain in parallel + // with the other boot backfills onto one heavy tenant. Default 0 keeps + // immediate semantics for any non-boot caller. + startAfterSeconds: number = 0, +): Promise<{ enqueued: number; skipped: number; error: string | null; @@ -219,6 +225,7 @@ export async function enqueueBootTimeNoteEncryptionBackfill(): Promise<{ retryDelay: 60, retryBackoff: true, singletonKey: `note-encryption-backfill|${userId}`, + ...(startAfterSeconds > 0 ? { startAfter: startAfterSeconds } : {}), }); if (jobId) { enqueued += 1; diff --git a/src/lib/jobs/offhost-backup.ts b/src/lib/jobs/offhost-backup.ts index ecfb5fb91..2a6118c1a 100644 --- a/src/lib/jobs/offhost-backup.ts +++ b/src/lib/jobs/offhost-backup.ts @@ -20,6 +20,10 @@ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; import type { PrismaClient } from "@/generated/prisma/client"; import { getEvent } from "@/lib/logging/context"; +import { + collectPagedMeasurements, + MEASUREMENT_BACKUP_PAGE_SIZE, +} from "@/lib/jobs/backup/measurement-page"; const ALGORITHM = "aes-256-gcm"; const IV_LENGTH = 12; @@ -226,7 +230,51 @@ export async function runOffhostBackup( // includes soft-deleted rows because this is the DR snapshot, // not a user-facing export — see // `/api/export/full-backup/route.ts` for the symmetric exclusion. - prisma.measurement.findMany({ where: { userId: user.id } }), + // + // Keyset-paged narrow read instead of one full-set findMany: a + // heavy multi-year tenant otherwise loads every measurement ORM + // row into a single array alongside the giant JSON.stringify + // below, the worker's dominant heap spike. The DR dump keeps the + // FULL row shape, so the select lists every scalar column (no + // relations) — identical bytes to the prior default findMany — + // while the page reader bounds peak heap to the page size. No + // `deletedAt` filter: the tombstoned rows stay in the snapshot. + collectPagedMeasurements({ + fetchPage: (afterId, take) => + prisma.measurement.findMany({ + where: { + userId: user.id, + ...(afterId ? { id: { gt: afterId } } : {}), + }, + select: { + id: true, + userId: true, + type: true, + value: true, + valueMin: true, + valueMax: true, + unit: true, + source: true, + measuredAt: true, + notes: true, + notesEncrypted: true, + externalId: true, + externalSourceVersion: true, + glucoseContext: true, + sleepStage: true, + rhythmClassification: true, + deviceType: true, + syncVersion: true, + deletedAt: true, + createdAt: true, + updatedAt: true, + }, + orderBy: { id: "asc" }, + take, + }), + project: (row) => row, + pageSize: MEASUREMENT_BACKUP_PAGE_SIZE, + }), prisma.medication.findMany({ where: { userId: user.id }, include: { schedules: true }, diff --git a/src/lib/jobs/reminder/backup-handlers.ts b/src/lib/jobs/reminder/backup-handlers.ts index 4f257d469..987cec260 100644 --- a/src/lib/jobs/reminder/backup-handlers.ts +++ b/src/lib/jobs/reminder/backup-handlers.ts @@ -7,10 +7,15 @@ import { type Job } from "pg-boss"; import { recordError } from "@/lib/jobs/worker-status"; import { encrypt } from "@/lib/crypto"; -import { readNote } from "@/lib/crypto/note-cipher"; import { withBackgroundEvent } from "@/lib/logging/background"; import { buildCycleBackupSection } from "@/lib/cycle/backup"; import { runOffhostBackup } from "@/lib/jobs/offhost-backup"; +import { + collectPagedMeasurements, + MEASUREMENT_BACKUP_PAGE_SIZE, + sortWeeklyMeasurementsDesc, + toWeeklyBackupMeasurement, +} from "@/lib/jobs/backup/measurement-page"; import { getWorkerPrisma } from "./shared"; export interface DataBackupPayload { @@ -61,10 +66,41 @@ export async function handleDataBackup(jobs: Job[]) { try { const [measurements, medications, intakeEvents, moodEntries, cycle] = await Promise.all([ - prisma.measurement.findMany({ - where: { userId: user.id }, - orderBy: { measuredAt: "desc" }, - }), + // Keyset-paged narrow read instead of one full-set + // findMany + map: a heavy multi-year tenant otherwise + // loads every measurement ORM row into a single array + // alongside the giant JSON.stringify below, the worker's + // dominant heap spike. The page reader projects each page + // to the compact backup shape before the next page loads, + // bounding peak heap to the page size. Row scope is + // unchanged — no `deletedAt` filter, so tombstoned rows + // still round-trip exactly as before. The order is + // restored to measuredAt-desc to match the prior output. + collectPagedMeasurements({ + fetchPage: (afterId, take) => + prisma.measurement.findMany({ + where: { + userId: user.id, + ...(afterId ? { id: { gt: afterId } } : {}), + }, + // Exactly the columns the admin restore recreates a + // row from, plus `id` for the keyset cursor. + select: { + id: true, + type: true, + value: true, + unit: true, + source: true, + measuredAt: true, + notes: true, + notesEncrypted: true, + }, + orderBy: { id: "asc" }, + take, + }), + project: toWeeklyBackupMeasurement, + pageSize: MEASUREMENT_BACKUP_PAGE_SIZE, + }).then(sortWeeklyMeasurementsDesc), prisma.medication.findMany({ where: { userId: user.id }, include: { schedules: true }, @@ -89,16 +125,10 @@ export async function handleDataBackup(jobs: Job[]) { schemaVersion: "1", exportedAt: new Date().toISOString(), userId: user.id, - measurements: measurements.map((m) => ({ - type: m.type, - value: m.value, - unit: m.unit, - measuredAt: m.measuredAt.toISOString(), - source: m.source, - // v1.23 — decrypt into the (whole-blob-encrypted) backup payload - // so an admin restore re-encrypts on re-insert. - notes: readNote(m.notesEncrypted, m.notes), - })), + // Already projected + ordered by the paged reader above; the + // decrypted note rode into each row so an admin restore + // re-encrypts on re-insert (v1.23 contract). + measurements, medications: medications.map((m) => ({ name: m.name, dose: m.dose, diff --git a/src/lib/jobs/reminder/register-maintenance.ts b/src/lib/jobs/reminder/register-maintenance.ts index ac11b6eec..789e2f0e0 100644 --- a/src/lib/jobs/reminder/register-maintenance.ts +++ b/src/lib/jobs/reminder/register-maintenance.ts @@ -69,7 +69,7 @@ import { type EncryptionKeyRotatePayload, } from "@/lib/jobs/encryption-key-rotate"; import { recordError } from "@/lib/jobs/worker-status"; -import { workerLog } from "./shared"; +import { workerLog, BOOT_BACKFILL_STAGGER_SECONDS } from "./shared"; import { createAndSchedule, type ScheduleEntry } from "./registrar-shared"; import { DataBackupPayload, @@ -566,8 +566,15 @@ export async function enqueueMaintenanceBootDiscovery(): Promise { // migration job per account. Idempotent across reboots: a migrated row nulls // its plaintext column and drops off the discovery predicate. try { + // Stage 5 — last of the staggered boot backfills. The heavy full-history + // loads each `localConcurrency: 1` used to drain in parallel onto one heavy + // tenant at boot; threading an increasing `startAfter` delay spreads them + // across a window. The daily cron tick (above) passes no offset and stays + // immediate. const { enqueued, skipped, error } = - await enqueueBootTimeNoteEncryptionBackfill(); + await enqueueBootTimeNoteEncryptionBackfill( + BOOT_BACKFILL_STAGGER_SECONDS * 5, + ); if (error) { workerLog( "error", diff --git a/src/lib/jobs/reminder/register-rollup.ts b/src/lib/jobs/reminder/register-rollup.ts index 56ccd1b8d..c38d8e41d 100644 --- a/src/lib/jobs/reminder/register-rollup.ts +++ b/src/lib/jobs/reminder/register-rollup.ts @@ -76,7 +76,11 @@ import { type DenseIntradayRetentionPayload, } from "@/lib/jobs/dense-intraday-retention"; import { runDenseIntradayRetention } from "@/lib/measurements/dense-intraday-retention"; -import { getWorkerPrisma, workerLog } from "./shared"; +import { + getWorkerPrisma, + workerLog, + BOOT_BACKFILL_STAGGER_SECONDS, +} from "./shared"; import { createAndSchedule, type ScheduleEntry } from "./registrar-shared"; // v1.4.37 W7c — nightly drain of per-sample APPLE_HEALTH cumulative rows. // Collapses each user × cumulative-type × calendar-day bucket into one @@ -445,6 +449,15 @@ export async function registerRollupQueues( * Fire-and-forget boot discovery for the self-converging rollup / consolidation * backfills. Each pass is idempotent across reboots and never fails worker boot * on a miss (errors come back through the helper's result value). + * + * Each backfill is `localConcurrency: 1` on its own, but every one of them used + * to drain from the first pg-boss poll at boot — on a heavy tenant that meant + * several full-history loads contending for the connection pool at once (a boot + * storm / crash-loop risk). Each type now gets an increasing + * `BOOT_BACKFILL_STAGGER_SECONDS` multiple as a `startAfter` delay so their + * loads spread across a window instead of all landing on the same poll. Dense + * intra-day retention keeps its own larger boot defer (the established P2028 + * pool-exhaustion fix) and stays the furthest-out stage. */ export async function enqueueRollupBootDiscovery(): Promise { // v1.4.35.1 — measurement-rollup backfill. Finds every user with @@ -452,7 +465,10 @@ export async function enqueueRollupBootDiscovery(): Promise { // The discovery query only matches accounts with zero rollup rows, so once // a fold completes the user drops off the list. try { - const { enqueued, skipped, error } = await enqueueBootTimeRollupBackfill(); + // Stage 0 — first off the line; no stagger delay. + const { enqueued, skipped, error } = await enqueueBootTimeRollupBackfill( + BOOT_BACKFILL_STAGGER_SECONDS * 0, + ); if (error) { workerLog( "error", @@ -483,8 +499,10 @@ export async function enqueueRollupBootDiscovery(): Promise { // account. Consolidated legacy rows are soft-deleted, so the // `deleted_at IS NULL` discovery predicate drops them. try { - const { enqueued, skipped, error } = - await enqueueBootTimeStepConsolidation(); + // Stage 1. + const { enqueued, skipped, error } = await enqueueBootTimeStepConsolidation( + BOOT_BACKFILL_STAGGER_SECONDS * 1, + ); if (error) { workerLog( "error", @@ -508,8 +526,10 @@ export async function enqueueRollupBootDiscovery(): Promise { // per-sample high-frequency mean-type rows and enqueues one job per account. // Consolidated rows are soft-deleted, so the discovery predicate drops them. try { - const { enqueued, skipped, error } = - await enqueueBootTimeMeanConsolidation(); + // Stage 2. + const { enqueued, skipped, error } = await enqueueBootTimeMeanConsolidation( + BOOT_BACKFILL_STAGGER_SECONDS * 2, + ); if (error) { workerLog( "error", @@ -534,6 +554,9 @@ export async function enqueueRollupBootDiscovery(): Promise { // and enqueues one job per account. Folded rows are soft-deleted, so the // discovery predicate drops them. try { + // Furthest-out stage — the helper self-defers past the boot window via its + // own larger constant (the P2028 pool-exhaustion fix), so no stagger + // argument is threaded here. const { enqueued, skipped, error } = await enqueueBootTimeDenseIntradayRetention(); if (error) { @@ -560,8 +583,11 @@ export async function enqueueRollupBootDiscovery(): Promise { // rollup coverage. Idempotent across reboots and singleton-keyed inside // pg-boss so a fast restart while a backfill is queued doesn't double up. try { + // Stage 3. const { enqueued, skipped, error } = - await enqueueBootTimeMoodRollupBackfill(); + await enqueueBootTimeMoodRollupBackfill( + BOOT_BACKFILL_STAGGER_SECONDS * 3, + ); if (error) { workerLog( "error", @@ -585,8 +611,11 @@ export async function enqueueRollupBootDiscovery(): Promise { // pattern: one job per user with intake events but no rollup coverage. // Idempotent across reboots and singleton-keyed inside pg-boss. try { + // Stage 4. const { enqueued, skipped, error } = - await enqueueBootTimeMedicationComplianceBackfill(); + await enqueueBootTimeMedicationComplianceBackfill( + BOOT_BACKFILL_STAGGER_SECONDS * 4, + ); if (error) { workerLog( "error", diff --git a/src/lib/jobs/reminder/shared.ts b/src/lib/jobs/reminder/shared.ts index 7c98e62eb..05cd9ba58 100644 --- a/src/lib/jobs/reminder/shared.ts +++ b/src/lib/jobs/reminder/shared.ts @@ -19,6 +19,17 @@ export function parseTimeToMinutes(value: string): number { export const DATABASE_URL = process.env.DATABASE_URL!; +// Boot-backfill stagger step (seconds). The self-converging full-history +// backfills are each `localConcurrency: 1` in isolation, but all of them used +// to drain from the very first pg-boss poll at worker boot — on a heavy tenant +// that meant ~half a dozen full-history loads contending for the same +// connection pool at once (a boot storm / crash-loop risk). The boot-discovery +// wrappers hand each backfill type an increasing multiple of this step as a +// `startAfter` delay so their loads start at staggered times instead of all on +// the first poll. Non-boot callers (cron) pass no offset and keep their +// immediate semantics. +export const BOOT_BACKFILL_STAGGER_SECONDS = 30; + // Reuse a single PrismaClient across all job handlers to avoid connection pool exhaustion let workerPrisma: PrismaClient | null = null; diff --git a/src/lib/jobs/step-consolidation.ts b/src/lib/jobs/step-consolidation.ts index f447169b9..0fd6ad896 100644 --- a/src/lib/jobs/step-consolidation.ts +++ b/src/lib/jobs/step-consolidation.ts @@ -76,7 +76,13 @@ export async function runStepConsolidationForUser( * Best-effort: errors are returned through the result value so the * worker boot never fails because of a consolidation miss. */ -export async function enqueueBootTimeStepConsolidation(): Promise<{ +export async function enqueueBootTimeStepConsolidation( + // Optional boot-storm stagger. When > 0 the per-user sends carry a + // `startAfter` delay (seconds) so this consolidation does not drain in + // parallel with the other boot backfills onto one heavy tenant. Default 0 + // keeps immediate semantics for any non-boot caller. + startAfterSeconds: number = 0, +): Promise<{ enqueued: number; skipped: number; error: string | null; @@ -119,6 +125,7 @@ export async function enqueueBootTimeStepConsolidation(): Promise<{ retryDelay: 60, retryBackoff: true, singletonKey: `step-consolidation|${id}`, + ...(startAfterSeconds > 0 ? { startAfter: startAfterSeconds } : {}), }); if (jobId) { enqueued += 1; diff --git a/src/lib/measurements/__tests__/consolidation-scan-paged.test.ts b/src/lib/measurements/__tests__/consolidation-scan-paged.test.ts new file mode 100644 index 000000000..78712a9e8 --- /dev/null +++ b/src/lib/measurements/__tests__/consolidation-scan-paged.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it, vi } from "vitest"; + +import { bucketRowsByDay, scanSourceRowsPaged } from "../consolidation-base"; +import type { PerSampleRow } from "../consolidation-tz"; +import type { Prisma, PrismaClient } from "@/generated/prisma/client"; + +// `scanSourceRowsPaged` keyset-paginates the per-(user, type) source scan so a +// multi-year tenant no longer materialises its whole history in one Prisma +// result set. These tests pin the two properties that matter: the accumulated +// array equals a single unbounded scan (same rows, same order, no gaps / +// duplicates), and the cursor advances on `(measuredAt, id)` after every full +// page. + +/** Build a deterministic per-sample row at a 1-minute-per-index offset. */ +function row(index: number): PerSampleRow { + return { + id: `m-${String(index).padStart(4, "0")}`, + type: "ACTIVITY_STEPS", + value: index, + measuredAt: new Date(Date.UTC(2026, 0, 1, 0, index, 0)), + externalId: `hk-uuid-${index}`, + }; +} + +/** + * A findMany mock that serves `allRows` in keyset order, honouring `take` + * (page size) and the `(measuredAt, id)` cursor clause the helper AND-combines + * onto the base where. This mirrors how Postgres would resolve the keyset. + */ +function buildPagingFindMany(allRows: PerSampleRow[]) { + const ordered = [...allRows].sort((a, b) => { + const byTime = a.measuredAt.getTime() - b.measuredAt.getTime(); + return byTime !== 0 ? byTime : a.id < b.id ? -1 : a.id > b.id ? 1 : 0; + }); + + return vi.fn( + async (args: { + where: Prisma.MeasurementWhereInput; + take?: number; + orderBy?: unknown; + }) => { + const take = args.take ?? ordered.length; + + // Extract the keyset cursor the helper threads through `where.AND[1].OR`. + let afterTime: number | null = null; + let afterId: string | null = null; + const and = (args.where as { AND?: unknown[] }).AND; + if (Array.isArray(and)) { + const or = (and[1] as { OR?: Array> }).OR; + const gt = or?.[0]?.measuredAt as { gt?: Date } | undefined; + const tie = or?.[1] as + | { measuredAt?: Date; id?: { gt?: string } } + | undefined; + afterTime = gt?.gt ? gt.gt.getTime() : null; + afterId = tie?.id?.gt ?? null; + } + + const remaining = + afterTime === null + ? ordered + : ordered.filter((r) => { + const t = r.measuredAt.getTime(); + return t > afterTime! || (t === afterTime! && r.id > afterId!); + }); + + return remaining.slice(0, take); + }, + ); +} + +function mockClient(findMany: ReturnType): PrismaClient { + return { + measurement: { findMany }, + } as unknown as PrismaClient; +} + +const BASE_WHERE: Prisma.MeasurementWhereInput = { + userId: "user-1", + type: "ACTIVITY_STEPS", +}; +const SELECT: Prisma.MeasurementSelect = { + id: true, + type: true, + value: true, + measuredAt: true, + externalId: true, +}; + +describe("scanSourceRowsPaged", () => { + it("accumulates every row in keyset order across multiple pages with no gaps or duplicates", async () => { + const all = Array.from({ length: 23 }, (_, i) => row(i)); + const findMany = buildPagingFindMany(all); + + const result = await scanSourceRowsPaged( + mockClient(findMany), + BASE_WHERE, + SELECT, + 10, + ); + + // 23 rows at page size 10 → pages of 10, 10, 3 (the short page ends it). + expect(result.map((r) => r.id)).toEqual(all.map((r) => r.id)); + expect(new Set(result.map((r) => r.id)).size).toBe(all.length); + expect(findMany).toHaveBeenCalledTimes(3); + }); + + it("issues exactly one extra probing page when the row count is an exact multiple of the page size", async () => { + const all = Array.from({ length: 20 }, (_, i) => row(i)); + const findMany = buildPagingFindMany(all); + + const result = await scanSourceRowsPaged( + mockClient(findMany), + BASE_WHERE, + SELECT, + 10, + ); + + // 20 rows at page size 10 → full pages 10, 10, then an empty probe page. + expect(result).toHaveLength(20); + expect(findMany).toHaveBeenCalledTimes(3); + }); + + it("advances the cursor on (measuredAt, id) after each full page", async () => { + const all = Array.from({ length: 12 }, (_, i) => row(i)); + const findMany = buildPagingFindMany(all); + + await scanSourceRowsPaged(mockClient(findMany), BASE_WHERE, SELECT, 5); + + // First page: no cursor (base where only). + const firstWhere = findMany.mock.calls[0]?.[0]?.where; + expect((firstWhere as { AND?: unknown }).AND).toBeUndefined(); + expect(firstWhere).toEqual(BASE_WHERE); + + // Second page: cursor resumes after the 5th row (id m-0004). + const secondWhere = findMany.mock.calls[1]?.[0]?.where as { + AND: unknown[]; + }; + expect(Array.isArray(secondWhere.AND)).toBe(true); + expect(secondWhere.AND[0]).toEqual(BASE_WHERE); + const cursorClause = secondWhere.AND[1] as { + OR: Array<{ measuredAt?: { gt?: Date }; id?: { gt?: string } }>; + }; + expect(cursorClause.OR[0].measuredAt?.gt).toEqual(row(4).measuredAt); + expect(cursorClause.OR[1].id?.gt).toBe(row(4).id); + + // Every page is requested in stable keyset order. + for (const call of findMany.mock.calls) { + expect(call[0]?.orderBy).toEqual([{ measuredAt: "asc" }, { id: "asc" }]); + } + }); + + it("returns the empty array when the first page is empty", async () => { + const findMany = buildPagingFindMany([]); + const result = await scanSourceRowsPaged( + mockClient(findMany), + BASE_WHERE, + SELECT, + 10, + ); + expect(result).toEqual([]); + expect(findMany).toHaveBeenCalledTimes(1); + }); + + it("produces output that buckets identically to a single unpaged scan", async () => { + // Rows spanning three calendar days; the paged accumulation must bucket + // byte-identically to the same rows fed in one array. + const all = [ + { ...row(1), measuredAt: new Date("2026-05-16T08:00:00.000Z") }, + { ...row(2), measuredAt: new Date("2026-05-16T14:00:00.000Z") }, + { ...row(3), measuredAt: new Date("2026-05-17T06:00:00.000Z") }, + { ...row(4), measuredAt: new Date("2026-05-17T20:00:00.000Z") }, + { ...row(5), measuredAt: new Date("2026-05-18T09:00:00.000Z") }, + ]; + const findMany = buildPagingFindMany(all); + + const paged = await scanSourceRowsPaged( + mockClient(findMany), + BASE_WHERE, + SELECT, + 2, + ); + + const baselineByDay = bucketRowsByDay(all, "Europe/Berlin", "stats:"); + const pagedByDay = bucketRowsByDay(paged, "Europe/Berlin", "stats:"); + + expect([...pagedByDay.keys()].sort()).toEqual( + [...baselineByDay.keys()].sort(), + ); + for (const [dateKey, rows] of baselineByDay) { + expect(pagedByDay.get(dateKey)?.map((r) => r.id)).toEqual( + rows.map((r) => r.id), + ); + } + }); +}); diff --git a/src/lib/measurements/consolidation-base.ts b/src/lib/measurements/consolidation-base.ts index b20f7861a..b643eb7af 100644 --- a/src/lib/measurements/consolidation-base.ts +++ b/src/lib/measurements/consolidation-base.ts @@ -267,6 +267,74 @@ const DEFAULT_SCAN_SELECT: Prisma.MeasurementSelect = { externalId: true, }; +/** + * Page size for the keyset-paginated source-row scan. Heavy multi-year + * tenants previously materialised the entire per-(user, type) history in a + * single unbounded `findMany` result set — the dominant Prisma intermediate + * cost and the root of the consolidation boot-storm on large accounts. + * Scanning in bounded keyset pages caps the per-query result set while still + * accumulating the same full row set. + */ +const CONSOLIDATION_SCAN_PAGE_SIZE = 5000; + +/** + * Keyset-paginate the per-(user, type) source-row scan on `(measuredAt, id)` + * ascending, accumulating every page into one array. Output is identical to a + * single unbounded `findMany` — same `where`, same `select`, same ascending + * order — but the Prisma client never holds more than one page of rows at a + * time, so a multi-year tenant no longer materialises its whole history in one + * result set at worker boot. + * + * `(measuredAt, id)` is a stable keyset: `measuredAt` is the primary order and + * `id` breaks ties, so each page resumes exactly after the last row with no + * gaps or duplicates. The cursor clause is AND-combined with the caller's + * `baseWhere` so the scope / soft-delete / grace filters always still apply. + */ +export async function scanSourceRowsPaged( + prismaClient: PrismaClient, + baseWhere: Prisma.MeasurementWhereInput, + scanSelect: Prisma.MeasurementSelect, + pageSize: number = CONSOLIDATION_SCAN_PAGE_SIZE, +): Promise { + const accumulated: PerSampleRow[] = []; + let cursor: { measuredAt: Date; id: string } | null = null; + + for (;;) { + const where: Prisma.MeasurementWhereInput = cursor + ? { + AND: [ + baseWhere, + { + OR: [ + { measuredAt: { gt: cursor.measuredAt } }, + { measuredAt: cursor.measuredAt, id: { gt: cursor.id } }, + ], + }, + ], + } + : baseWhere; + + const page = (await prismaClient.measurement.findMany({ + where, + select: scanSelect, + orderBy: [{ measuredAt: "asc" }, { id: "asc" }], + take: pageSize, + })) as PerSampleRow[]; + + if (page.length === 0) break; + + for (const row of page) accumulated.push(row); + + // A short page is the last page — no further rows can satisfy the keyset. + if (page.length < pageSize) break; + + const last = page[page.length - 1]!; + cursor = { measuredAt: last.measuredAt, id: last.id }; + } + + return accumulated; +} + /** * Drive one consolidation pass. Walks `users → types → days`, scans live * source rows inside the grace window, buckets them, reduces each day, @@ -298,16 +366,20 @@ export async function runConsolidation( const hkIdentifier = params.hkIdentifierForType(type); if (!hkIdentifier) continue; - const sourceRows = (await prismaClient.measurement.findMany({ - where: params.buildScanWhere({ + // Keyset-paginate the scan instead of loading the whole per-(user, type) + // history in one result set; the accumulated array is byte-identical to a + // single unbounded `findMany`, but a multi-year tenant no longer spikes + // worker memory at boot. See `scanSourceRowsPaged`. + const sourceRows = await scanSourceRowsPaged( + prismaClient, + params.buildScanWhere({ userId: user.id, type, cutoffAt, statsPrefix: params.statsPrefix, }), - select: scanSelect, - orderBy: { measuredAt: "asc" }, - })) as PerSampleRow[]; + scanSelect, + ); if (sourceRows.length === 0) continue; diff --git a/src/lib/rollups/measurement-rollups.ts b/src/lib/rollups/measurement-rollups.ts index 2b5e9ef73..c87db714c 100644 --- a/src/lib/rollups/measurement-rollups.ts +++ b/src/lib/rollups/measurement-rollups.ts @@ -841,7 +841,13 @@ async function ensureUserRollupsFreshImpl( * Best-effort: errors are swallowed and reported through the return * value so the worker boot never fails because of a backfill miss. */ -export async function enqueueBootTimeRollupBackfill(): Promise<{ +export async function enqueueBootTimeRollupBackfill( + // Optional boot-storm stagger. When > 0 the per-user sends carry a + // `startAfter` delay (seconds) so this full-history fold does not drain in + // parallel with the other boot backfills onto one heavy tenant. Default 0 + // keeps immediate semantics for any non-boot caller. + startAfterSeconds: number = 0, +): Promise<{ enqueued: number; skipped: number; error: string | null; @@ -947,6 +953,7 @@ export async function enqueueBootTimeRollupBackfill(): Promise<{ // Coalesce: if a backfill for this user is already queued and // we restart, pg-boss returns null instead of duplicating. singletonKey: `boot-backfill|${id}`, + ...(startAfterSeconds > 0 ? { startAfter: startAfterSeconds } : {}), }); if (jobId) { enqueued += 1; diff --git a/src/lib/rollups/medication-compliance-rollups.ts b/src/lib/rollups/medication-compliance-rollups.ts index 6df709a87..e423b59ce 100644 --- a/src/lib/rollups/medication-compliance-rollups.ts +++ b/src/lib/rollups/medication-compliance-rollups.ts @@ -541,7 +541,13 @@ export async function enqueueUserMedicationComplianceBackfill( * row inside the trailing window the discovery query drops them. * pg-boss `singletonKey` coalesces duplicate sends. */ -export async function enqueueBootTimeMedicationComplianceBackfill(): Promise<{ +export async function enqueueBootTimeMedicationComplianceBackfill( + // Optional boot-storm stagger. When > 0 the per-user sends carry a + // `startAfter` delay (seconds) so this full-history fold does not drain in + // parallel with the other boot backfills onto one heavy tenant. Default 0 + // keeps immediate semantics for any non-boot caller. + startAfterSeconds: number = 0, +): Promise<{ enqueued: number; skipped: number; error: string | null; @@ -587,6 +593,7 @@ export async function enqueueBootTimeMedicationComplianceBackfill(): Promise<{ retryDelay: 60, retryBackoff: true, singletonKey: `medication-compliance-boot-backfill|${id}`, + ...(startAfterSeconds > 0 ? { startAfter: startAfterSeconds } : {}), }, ); if (jobId) { diff --git a/src/lib/rollups/mood-rollups.ts b/src/lib/rollups/mood-rollups.ts index 42a61001a..c840db4b7 100644 --- a/src/lib/rollups/mood-rollups.ts +++ b/src/lib/rollups/mood-rollups.ts @@ -451,7 +451,13 @@ async function ensureUserMoodRollupsFreshImpl( * Best-effort: errors are swallowed and reported through the return * value so the worker boot never fails because of a backfill miss. */ -export async function enqueueBootTimeMoodRollupBackfill(): Promise<{ +export async function enqueueBootTimeMoodRollupBackfill( + // Optional boot-storm stagger. When > 0 the per-user sends carry a + // `startAfter` delay (seconds) so this full-history fold does not drain in + // parallel with the other boot backfills onto one heavy tenant. Default 0 + // keeps immediate semantics for any non-boot caller. + startAfterSeconds: number = 0, +): Promise<{ enqueued: number; skipped: number; error: string | null; @@ -500,6 +506,7 @@ export async function enqueueBootTimeMoodRollupBackfill(): Promise<{ // Coalesce: if a backfill for this user is already queued and // we restart, pg-boss returns null instead of duplicating. singletonKey: `mood-boot-backfill|${id}`, + ...(startAfterSeconds > 0 ? { startAfter: startAfterSeconds } : {}), }); if (jobId) { enqueued += 1; From 31c35baec2b6bfe17dbb57984ce692c93dc23a87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 03:20:28 +0200 Subject: [PATCH 08/94] feat: hydration logging and daily goal ring Add a WATER_INTAKE measurement type and a per-user daily goal so the Insights overview can track water intake against a goal ring. Logging rides the existing measurements create path (type WATER_INTAKE, unit ml, summed cumulatively); GET/PATCH /api/hydration read the day total and own the goal. The goal-ring math (empty / partial / met / exceeded) lives in a pure helper, free of Prisma and React, and is unit-tested in isolation. Wire the new type through the measurement enum, value ranges, cumulative HealthKit handling, categories, display transform, list metadata, record direction, and the chartable-token allowlist. The HealthKit DietaryWater import stays on the nutrition hold, so the type carries no import mapping by design. Document GET/PATCH /api/hydration in the OpenAPI contract. --- .../0209_v125_water_intake/migration.sql | 21 ++ prisma/schema.prisma | 12 + src/app/api/hydration/route.ts | 111 +++++++ src/components/insights/hydration-card.tsx | 288 ++++++++++++++++++ src/components/insights/hydration-ring.tsx | 121 ++++++++ .../measurements/measurement-list-meta.ts | 4 + .../measurement-type-enum-coverage.test.ts | 9 +- src/lib/hydration/__tests__/hydration.test.ts | 72 +++++ src/lib/hydration/hydration.ts | 80 +++++ src/lib/insights/chart-tokens.ts | 3 + .../__tests__/apple-health-mapping.test.ts | 6 + src/lib/measurements/apple-health-mapping.ts | 5 + src/lib/measurements/categories.ts | 2 + src/lib/measurements/display-transform.ts | 2 + src/lib/openapi/registry.ts | 1 + src/lib/openapi/routes/hydration.ts | 102 +++++++ src/lib/personal-records/pr-direction.ts | 5 + src/lib/query-keys/hydration.ts | 10 + src/lib/query-keys/index.ts | 4 + src/lib/validations/measurement.ts | 8 + 20 files changed, 865 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/0209_v125_water_intake/migration.sql create mode 100644 src/app/api/hydration/route.ts create mode 100644 src/components/insights/hydration-card.tsx create mode 100644 src/components/insights/hydration-ring.tsx create mode 100644 src/lib/hydration/__tests__/hydration.test.ts create mode 100644 src/lib/hydration/hydration.ts create mode 100644 src/lib/openapi/routes/hydration.ts create mode 100644 src/lib/query-keys/hydration.ts diff --git a/prisma/migrations/0209_v125_water_intake/migration.sql b/prisma/migrations/0209_v125_water_intake/migration.sql new file mode 100644 index 000000000..a3212ada1 --- /dev/null +++ b/prisma/migrations/0209_v125_water_intake/migration.sql @@ -0,0 +1,21 @@ +-- v1.25 — hydration logging. +-- +-- Two additive changes, no backfill, no existing row touched: +-- +-- 1. `WATER_INTAKE` — a new MeasurementType: a manual water-intake counter in +-- millilitres (ml). Cumulative within a day like ACTIVITY_STEPS — each +-- entry adds to the day's running total, summed for the goal ring. +-- DISTINCT from TOTAL_BODY_WATER (a scale body-composition reading in kg). +-- Forward-only: Postgres cannot remove an enum value; idempotent via +-- `IF NOT EXISTS`, so reruns are safe. +-- +-- 2. `users.hydration_goal_ml` — a nullable per-user daily hydration goal in +-- millilitres. Null means the in-code default (2000 ml). Edited inline on +-- the hydration card. +-- +-- The enum value is only added, never used in this same statement batch, so the +-- Postgres "unsafe use of new enum value" restriction does not apply. + +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'WATER_INTAKE'; + +ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "hydration_goal_ml" INTEGER; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5bbd4cb75..85f64b7ff 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -233,6 +233,10 @@ model User { // MDY pins month-day-year, YMD pins ISO yyyy-MM-dd. Display-time only — // canonical storage stays UTC instants. dateFormat DateFormatPreference @default(AUTO) @map("date_format") + // v1.25 — daily water-intake goal in millilitres for the hydration ring. + // Null = the in-code default (DEFAULT_HYDRATION_GOAL_ML, 2000 ml). Surfaced + // and edited inline on the hydration card; canonical storage stays ml. + hydrationGoalMl Int? @map("hydration_goal_ml") // AI provider selection for insights. Null = OPENAI fallback. aiProvider String? @map("ai_provider") // "OPENAI" | "ANTHROPIC" | "LOCAL" | "CHATGPT_OAUTH" aiModel String? @map("ai_model") @@ -1107,6 +1111,14 @@ enum MeasurementType { // `src/lib/oura/client.ts` (RESILIENCE_LEVELS). Stored as `source = OURA`. RESILIENCE // level (ordinal 1–5) — Oura `daily_resilience.level` (limited=1 … exceptional=5). + // ── v1.25 — hydration logging (additive) ── + // Manual water-intake counter in millilitres (ml). Cumulative within a day + // like ACTIVITY_STEPS / ACTIVE_ENERGY_BURNED — each entry adds to the day's + // running total; the daily total is summed for the goal ring. DISTINCT from + // TOTAL_BODY_WATER (a scale body-composition reading in kg). Manual entry + // only today (no device source maps to it), unit "ml", higher = more intake. + WATER_INTAKE // ml — manual hydration intake counter; daily total vs a user goal. + @@map("measurement_type") } diff --git a/src/app/api/hydration/route.ts b/src/app/api/hydration/route.ts new file mode 100644 index 000000000..0b056c59d --- /dev/null +++ b/src/app/api/hydration/route.ts @@ -0,0 +1,111 @@ +/** + * v1.25 — hydration daily-goal surface. + * + * GET /api/hydration → today's summed water intake vs the user's goal. + * PATCH /api/hydration → set the per-user daily goal (ml). + * + * Logging itself rides the existing measurements create path (POST + * /api/measurements with `type: "WATER_INTAKE"`, unit "ml") — this route only + * reads the day's total and owns the goal. `userId` is always narrowed from + * `requireAuth()`; the goal is built field-by-field, never mass-assigned. + */ +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; +import { + apiError, + apiSuccess, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; +import { prisma } from "@/lib/db"; +import { getUserTodayBounds } from "@/lib/tz/local-day"; +import { + MAX_HYDRATION_GOAL_ML, + MIN_HYDRATION_GOAL_ML, + resolveHydrationGoal, + summariseHydration, +} from "@/lib/hydration/hydration"; + +export const dynamic = "force-dynamic"; + +const goalPatchSchema = z.object({ + goalMl: z + .number() + .int() + .min(MIN_HYDRATION_GOAL_ML) + .max(MAX_HYDRATION_GOAL_ML), +}); + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + + const rl = await checkAnalyticsReadRateLimit(user.id); + if (!rl.allowed) { + return apiError("Too many requests. Please retry later.", 429); + } + + const now = new Date(); + const { start, end } = getUserTodayBounds(now, user.timezone); + + const rows = await prisma.measurement.findMany({ + where: { + userId: user.id, + type: "WATER_INTAKE", + measuredAt: { gte: start, lte: end }, + }, + select: { id: true, value: true, measuredAt: true }, + orderBy: { measuredAt: "asc" }, + }); + + const totalMl = rows.reduce((sum, r) => sum + r.value, 0); + const summary = summariseHydration( + totalMl, + resolveHydrationGoal(user.hydrationGoalMl), + ); + + annotate({ + action: { name: "hydration.today.read" }, + meta: { entries: rows.length, met: summary.met }, + }); + + return apiSuccess({ + date: start.toISOString(), + ...summary, + entries: rows.map((r) => ({ + id: r.id, + value: r.value, + measuredAt: r.measuredAt.toISOString(), + })), + }); +}); + +export const PATCH = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 1024, + }); + if (jsonError) return jsonError; + + const parsed = goalPatchSchema.safeParse(body); + if (!parsed.success) { + annotate({ + action: { name: "hydration.goal.validation-failed" }, + meta: { issue_count: parsed.error.issues.length }, + }); + return returnAllZodIssues(parsed.error, 422); + } + + const goalMl = parsed.data.goalMl; + await prisma.user.update({ + where: { id: user.id }, + data: { hydrationGoalMl: goalMl }, + }); + + annotate({ action: { name: "hydration.goal.update" }, meta: { goalMl } }); + + return apiSuccess({ goalMl }); +}); diff --git a/src/components/insights/hydration-card.tsx b/src/components/insights/hydration-card.tsx new file mode 100644 index 000000000..45e5c3919 --- /dev/null +++ b/src/components/insights/hydration-card.tsx @@ -0,0 +1,288 @@ +"use client"; + +import { useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { Check, GlassWater, Pencil, Plus } from "lucide-react"; +import { toast } from "sonner"; + +import { useTranslations } from "@/lib/i18n/context"; +import { useAuth } from "@/hooks/use-auth"; +import { cn } from "@/lib/utils"; +import { ApiError, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch"; +import { + queryKeys, + invalidateKeys, + measurementDependentKeys, +} from "@/lib/query-keys"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { SectionHeading } from "@/components/insights/section-heading"; +import { HydrationRing } from "@/components/insights/hydration-ring"; +import { + HYDRATION_QUICK_ADD_ML, + MAX_HYDRATION_ENTRY_ML, + MAX_HYDRATION_GOAL_ML, + MIN_HYDRATION_ENTRY_ML, + MIN_HYDRATION_GOAL_ML, +} from "@/lib/hydration/hydration"; + +/** + * v1.25 — hydration daily-goal card. + * + * A calm goal tracker for the Insights overview: a goal ring (today's summed + * WATER_INTAKE vs the user's goal), quick-add buttons (+250 / +500 ml and a + * custom amount), and an inline goal editor. Logging rides the existing + * measurements create path (POST /api/measurements, type WATER_INTAKE), so a + * new entry busts `measurementDependentKeys`, which carries the hydration key — + * the ring refreshes in lockstep. The goal write goes to PATCH /api/hydration. + * + * Always present (a goal ring at 0 / goal is still useful), unlike the + * insufficient-history read cards which un-mount. + */ + +interface HydrationTodayResponse { + date: string; + totalMl: number; + goalMl: number; + percent: number; + rawPercent: number; + met: boolean; + remainingMl: number; + entries: { id: string; value: number; measuredAt: string }[]; +} + +interface HydrationCardProps { + enabled?: boolean; + className?: string; +} + +export function HydrationCard({ + enabled = true, + className, +}: HydrationCardProps) { + const { t } = useTranslations(); + const { isAuthenticated } = useAuth(); + const queryClient = useQueryClient(); + + const [busy, setBusy] = useState(false); + const [customMl, setCustomMl] = useState(""); + const [editingGoal, setEditingGoal] = useState(false); + const [goalDraft, setGoalDraft] = useState(""); + + const gated = enabled && isAuthenticated; + + const { data } = useQuery({ + queryKey: queryKeys.hydrationToday(), + queryFn: () => apiGet("/api/hydration"), + enabled: gated, + staleTime: 60_000, + }); + + async function logAmount(ml: number) { + if (busy || ml <= 0) return; + setBusy(true); + try { + await apiPost("/api/measurements", { + type: "WATER_INTAKE", + value: ml, + measuredAt: new Date().toISOString(), + }); + await invalidateKeys(queryClient, measurementDependentKeys); + toast.success(t("hydration.added", { amount: ml })); + } catch (err) { + toast.error( + err instanceof ApiError && err.message + ? err.message + : t("hydration.saveError"), + ); + } finally { + setBusy(false); + } + } + + async function logCustom() { + const ml = Math.round(Number(customMl)); + if ( + !Number.isFinite(ml) || + ml < MIN_HYDRATION_ENTRY_ML || + ml > MAX_HYDRATION_ENTRY_ML + ) { + toast.error(t("hydration.invalidAmount")); + return; + } + await logAmount(ml); + setCustomMl(""); + } + + async function saveGoal() { + const ml = Math.round(Number(goalDraft)); + if ( + !Number.isFinite(ml) || + ml < MIN_HYDRATION_GOAL_ML || + ml > MAX_HYDRATION_GOAL_ML + ) { + toast.error(t("hydration.invalidGoal")); + return; + } + setBusy(true); + try { + await apiPatch("/api/hydration", { goalMl: ml }); + await invalidateKeys(queryClient, [queryKeys.hydration()]); + setEditingGoal(false); + toast.success(t("hydration.goalSaved")); + } catch (err) { + toast.error( + err instanceof ApiError && err.message + ? err.message + : t("hydration.saveError"), + ); + } finally { + setBusy(false); + } + } + + const totalMl = data?.totalMl ?? 0; + const goalMl = data?.goalMl ?? 0; + const percent = data?.percent ?? 0; + const met = data?.met ?? false; + const remainingMl = data?.remainingMl ?? 0; + + return ( +
+ { + setGoalDraft(String(goalMl || "")); + setEditingGoal(true); + }} + data-slot="hydration-edit-goal" + > +
+ ); +} diff --git a/src/components/insights/hydration-ring.tsx b/src/components/insights/hydration-ring.tsx new file mode 100644 index 000000000..6c73c7c24 --- /dev/null +++ b/src/components/insights/hydration-ring.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { useEffect, useId, useState } from "react"; + +import { cn } from "@/lib/utils"; +import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; + +/** + * v1.25 — hydration daily-goal ring. + * + * A hand-rolled SVG arc gauge in the same idiom as `score-ring.tsx` (fixed + * `0 0 100 100` viewBox, ~9% round-cap stroke, a faint same-hue track, a + * subtle first-paint sweep gated on `prefers-reduced-motion`). It is purpose- + * built for a goal counter rather than a 0–100 band score: the arc fills to the + * percent of goal (capped at 100), the centre shows the day's total in ml, and + * the hue is a single calm water tone — never the green/yellow/red band + * semantics a low score would otherwise paint, so "not much yet" never reads as + * an alarm. The `` is real SVG JSX, not innerHTML. + */ + +const VIEW = 100; +const STROKE = 9; +const R = (VIEW - STROKE) / 2; +const C = 2 * Math.PI * R; +const CX = VIEW / 2; + +export interface HydrationRingProps { + /** Progress toward the goal, 0..100 (already capped by the caller). */ + percent: number; + /** Today's total in ml — the centred number. */ + totalMl: number; + /** The goal in ml — rendered under the total. */ + goalMl: number; + /** Whether the goal has been met (adds a met affordance). */ + met?: boolean; + /** Render size in px. */ + size?: number; + className?: string; +} + +export function HydrationRing({ + percent, + totalMl, + goalMl, + met = false, + size = 168, + className, +}: HydrationRingProps) { + const gid = useId(); + const clamped = Math.min( + 100, + Math.max(0, Number.isFinite(percent) ? percent : 0), + ); + const dash = (clamped / 100) * C; + + const reduced = prefersReducedMotion(); + const [armed, setArmed] = useState(false); + useEffect(() => { + if (reduced) return; + const raf = requestAnimationFrame(() => setArmed(true)); + return () => cancelAnimationFrame(raf); + }, [reduced]); + const filled = reduced || armed; + + return ( +
+ +
+ + {totalMl} + + {`/ ${goalMl} ml`} +
+
+ ); +} diff --git a/src/components/measurements/measurement-list-meta.ts b/src/components/measurements/measurement-list-meta.ts index f27dd5ef2..56255d6b0 100644 --- a/src/components/measurements/measurement-list-meta.ts +++ b/src/components/measurements/measurement-list-meta.ts @@ -35,6 +35,7 @@ import { Sun, PersonStanding, Waves, + GlassWater, type LucideIcon, } from "lucide-react"; @@ -54,6 +55,7 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record = { HEART_RATE_VARIABILITY: "measurements.typeHeartRateVariability", RESTING_HEART_RATE: "measurements.typeRestingHeartRate", ACTIVE_ENERGY_BURNED: "measurements.typeActiveEnergyBurned", + WATER_INTAKE: "measurements.typeWaterIntake", FLIGHTS_CLIMBED: "measurements.typeFlightsClimbed", WALKING_RUNNING_DISTANCE: "measurements.typeWalkingRunningDistance", VO2_MAX: "measurements.typeVo2Max", @@ -140,6 +142,7 @@ export const MEASUREMENT_TYPE_ICONS: Record = { HEART_RATE_VARIABILITY: HeartPulse, RESTING_HEART_RATE: Heart, ACTIVE_ENERGY_BURNED: Flame, + WATER_INTAKE: GlassWater, FLIGHTS_CLIMBED: TrendingUp, WALKING_RUNNING_DISTANCE: Footprints, VO2_MAX: Gauge, @@ -263,6 +266,7 @@ export const MEASUREMENT_TYPE_COLORS: Record = { HEART_RATE_VARIABILITY: "bg-chart-5/20 text-chart-5", RESTING_HEART_RATE: "bg-chart-3/20 text-chart-3", ACTIVE_ENERGY_BURNED: "bg-chart-4/20 text-chart-4", + WATER_INTAKE: "bg-chart-2/20 text-chart-2", FLIGHTS_CLIMBED: "bg-chart-2/20 text-chart-2", WALKING_RUNNING_DISTANCE: "bg-chart-2/20 text-chart-2", VO2_MAX: "bg-chart-1/20 text-chart-1", diff --git a/src/lib/__tests__/measurement-type-enum-coverage.test.ts b/src/lib/__tests__/measurement-type-enum-coverage.test.ts index 17b9a1fdf..ceaf09f2c 100644 --- a/src/lib/__tests__/measurement-type-enum-coverage.test.ts +++ b/src/lib/__tests__/measurement-type-enum-coverage.test.ts @@ -98,10 +98,12 @@ const EXPECTED_TYPES = [ "BODY_TEMPERATURE_DEVIATION", // ── v1.19.0 — Oura resilience (ordinal-encoded level) ── "RESILIENCE", + // ── v1.25 — in-app water-intake logging (hydration goal ring) ── + "WATER_INTAKE", ] as const; describe("measurementTypeEnum coverage", () => { - it("exposes the 66 canonical measurement types", () => { + it("exposes the 67 canonical measurement types", () => { expect([...measurementTypeEnum.options].sort()).toEqual( [...EXPECTED_TYPES].sort(), ); @@ -244,6 +246,11 @@ describe("measurementTypeEnum coverage", () => { // Insights / measurement-list surfaces and never belongs in the clinical // vitals PDF. See doctor-report-pdf-core.ts for the matching rationale. "RESILIENCE", + // v1.25 — water intake is an in-app lifestyle log (the hydration goal + // ring), not a measured clinical vital; it surfaces on the Insights + // overview and never belongs in the clinical vitals PDF. See + // doctor-report-pdf-core.ts for the matching exclusion rationale. + "WATER_INTAKE", ]); it("doctor-report PDF vital types cover the canonical enum minus documented exclusions", () => { diff --git a/src/lib/hydration/__tests__/hydration.test.ts b/src/lib/hydration/__tests__/hydration.test.ts new file mode 100644 index 000000000..8939919be --- /dev/null +++ b/src/lib/hydration/__tests__/hydration.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from "vitest"; + +import { + DEFAULT_HYDRATION_GOAL_ML, + MAX_HYDRATION_GOAL_ML, + MIN_HYDRATION_GOAL_ML, + resolveHydrationGoal, + summariseHydration, +} from "@/lib/hydration/hydration"; + +describe("resolveHydrationGoal", () => { + it("falls back to the default when unset", () => { + expect(resolveHydrationGoal(null)).toBe(DEFAULT_HYDRATION_GOAL_ML); + expect(resolveHydrationGoal(undefined)).toBe(DEFAULT_HYDRATION_GOAL_ML); + expect(resolveHydrationGoal(Number.NaN)).toBe(DEFAULT_HYDRATION_GOAL_ML); + }); + + it("clamps below the minimum", () => { + expect(resolveHydrationGoal(10)).toBe(MIN_HYDRATION_GOAL_ML); + }); + + it("clamps above the maximum", () => { + expect(resolveHydrationGoal(99999)).toBe(MAX_HYDRATION_GOAL_ML); + }); + + it("keeps an in-band value (rounded)", () => { + expect(resolveHydrationGoal(2500.4)).toBe(2500); + }); +}); + +describe("summariseHydration", () => { + it("empty day", () => { + const s = summariseHydration(0, 2000); + expect(s).toMatchObject({ + totalMl: 0, + goalMl: 2000, + percent: 0, + rawPercent: 0, + met: false, + remainingMl: 2000, + }); + }); + + it("partial day", () => { + const s = summariseHydration(1000, 2000); + expect(s.percent).toBe(50); + expect(s.rawPercent).toBe(50); + expect(s.met).toBe(false); + expect(s.remainingMl).toBe(1000); + }); + + it("goal met exactly", () => { + const s = summariseHydration(2000, 2000); + expect(s.percent).toBe(100); + expect(s.met).toBe(true); + expect(s.remainingMl).toBe(0); + }); + + it("exceeded — percent caps at 100, rawPercent does not", () => { + const s = summariseHydration(2500, 2000); + expect(s.percent).toBe(100); + expect(s.rawPercent).toBe(125); + expect(s.met).toBe(true); + expect(s.remainingMl).toBe(0); + }); + + it("floors a negative total at zero", () => { + const s = summariseHydration(-500, 2000); + expect(s.totalMl).toBe(0); + expect(s.met).toBe(false); + }); +}); diff --git a/src/lib/hydration/hydration.ts b/src/lib/hydration/hydration.ts new file mode 100644 index 000000000..a16bce868 --- /dev/null +++ b/src/lib/hydration/hydration.ts @@ -0,0 +1,80 @@ +/** + * Hydration logging — pure helpers for the daily water-intake goal ring. + * + * Water intake is stored as `WATER_INTAKE` Measurement rows in millilitres + * (ml), one row per quick-add entry. The day's running total is the SUM of + * those rows (see `CUMULATIVE_HK_TYPES`); the goal ring compares that total + * against a per-user goal (`User.hydrationGoalMl`, null = the default). + * + * The math lives here, free of Prisma + React, so the ring states (empty / + * partial / met / exceeded) are unit-testable in isolation. + */ + +/** Default daily hydration goal when the user has not set their own (ml). */ +export const DEFAULT_HYDRATION_GOAL_ML = 2000; + +/** Bounds for a user-set goal — a plausible adult range, not a clinical limit. */ +export const MIN_HYDRATION_GOAL_ML = 250; +export const MAX_HYDRATION_GOAL_ML = 8000; + +/** The fixed quick-add amounts the card offers alongside a custom entry (ml). */ +export const HYDRATION_QUICK_ADD_ML = [250, 500] as const; + +/** Bounds for a single logged entry (ml) — mirrors the measurement clamp. */ +export const MIN_HYDRATION_ENTRY_ML = 1; +export const MAX_HYDRATION_ENTRY_ML = 5000; + +/** + * Resolve the effective goal from the (nullable) stored value: fall back to + * the default when unset, and clamp a stored value into the supported band so + * a stale out-of-range row never produces a broken ring. + */ +export function resolveHydrationGoal( + stored: number | null | undefined, +): number { + if (stored == null || !Number.isFinite(stored)) { + return DEFAULT_HYDRATION_GOAL_ML; + } + return Math.min( + MAX_HYDRATION_GOAL_ML, + Math.max(MIN_HYDRATION_GOAL_ML, Math.round(stored)), + ); +} + +export interface HydrationSummary { + /** Today's summed intake in ml. */ + totalMl: number; + /** The effective daily goal in ml. */ + goalMl: number; + /** Progress toward the goal, capped at 100 for the ring fill. */ + percent: number; + /** Uncapped progress (can exceed 100 once the goal is met). */ + rawPercent: number; + /** Whether the day's total has reached the goal. */ + met: boolean; + /** Remaining ml to the goal (0 once met). */ + remainingMl: number; +} + +/** + * Summarise a day's intake against a goal. `totalMl` is the already-summed + * day total; `goalMl` is the effective goal (run it through + * `resolveHydrationGoal` first). Negative totals are floored at 0. + */ +export function summariseHydration( + totalMl: number, + goalMl: number, +): HydrationSummary { + const total = Number.isFinite(totalMl) ? Math.max(0, Math.round(totalMl)) : 0; + const goal = resolveHydrationGoal(goalMl); + const rawPercent = goal > 0 ? Math.round((total / goal) * 100) : 0; + const percent = Math.min(100, rawPercent); + return { + totalMl: total, + goalMl: goal, + percent, + rawPercent, + met: total >= goal, + remainingMl: Math.max(0, goal - total), + }; +} diff --git a/src/lib/insights/chart-tokens.ts b/src/lib/insights/chart-tokens.ts index fca9edb1f..06c669fb0 100644 --- a/src/lib/insights/chart-tokens.ts +++ b/src/lib/insights/chart-tokens.ts @@ -38,6 +38,9 @@ export const ALLOWED_CHART_TOKENS = [ // components ship in v1.5 alongside the iOS app surface. "metric:HEART_RATE_VARIABILITY", "metric:RESTING_HEART_RATE", + // v1.25 — daily water intake (cumulative ml), charted alongside the + // hydration goal ring on the overview. + "metric:WATER_INTAKE", "metric:ACTIVE_ENERGY_BURNED", "metric:FLIGHTS_CLIMBED", "metric:WALKING_RUNNING_DISTANCE", diff --git a/src/lib/measurements/__tests__/apple-health-mapping.test.ts b/src/lib/measurements/__tests__/apple-health-mapping.test.ts index 0c0a7ef6b..ab5dbb6fd 100644 --- a/src/lib/measurements/__tests__/apple-health-mapping.test.ts +++ b/src/lib/measurements/__tests__/apple-health-mapping.test.ts @@ -77,6 +77,12 @@ const MEASUREMENT_TYPES_WITHOUT_HK_COUNTERPART = new Set([ // the Oura API (source = OURA), never from HealthKit: Apple ships no // resilience metric. No HK mapping by design. "RESILIENCE", + // v1.25 — water intake is logged in-app (the hydration goal ring writes + // WATER_INTAKE rows directly via POST /api/measurements). Apple's + // `HKQuantityTypeIdentifierDietaryWater` stays on the nutrition deferred + // hold (HK_QUANTITY_TYPE_DEFERRED), so the type carries no HK import mapping + // by design. + "WATER_INTAKE", ]); describe("APPLE_HEALTH_TYPE_MAP", () => { diff --git a/src/lib/measurements/apple-health-mapping.ts b/src/lib/measurements/apple-health-mapping.ts index d6be7265a..12a6d34aa 100644 --- a/src/lib/measurements/apple-health-mapping.ts +++ b/src/lib/measurements/apple-health-mapping.ts @@ -706,6 +706,11 @@ export const CUMULATIVE_HK_TYPES: ReadonlySet = // daily total is the meaningful reduction (SUM), matching the other // cumulative HK counts. "FALL_COUNT", + // v1.25 — manual hydration intake. Not a HealthKit type, but its daily + // reduction is a SUM (running total of the day's entries), so it rides the + // same cumulative daily-sum path as the counts above rather than averaging + // individual sips. + "WATER_INTAKE", ]); /** diff --git a/src/lib/measurements/categories.ts b/src/lib/measurements/categories.ts index f9457adf2..aaf1038f6 100644 --- a/src/lib/measurements/categories.ts +++ b/src/lib/measurements/categories.ts @@ -89,6 +89,8 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap< // ── Activity (cumulative + fitness + mobility) ── ["ACTIVITY_STEPS", "activity"], ["ACTIVE_ENERGY_BURNED", "activity"], + // v1.25 — hydration intake; cumulative daily counter, groups with activity. + ["WATER_INTAKE", "activity"], ["FLIGHTS_CLIMBED", "activity"], ["WALKING_RUNNING_DISTANCE", "activity"], ["VO2_MAX", "activity"], diff --git a/src/lib/measurements/display-transform.ts b/src/lib/measurements/display-transform.ts index 1bc059952..39b058505 100644 --- a/src/lib/measurements/display-transform.ts +++ b/src/lib/measurements/display-transform.ts @@ -105,6 +105,8 @@ const INTEGER_DISPLAY_TYPES: ReadonlySet = new Set([ // grain for these day-cumulative counts. "ACTIVE_ENERGY_BURNED", "TIME_IN_DAYLIGHT", + // ml of water — whole millilitres are the meaningful display grain. + "WATER_INTAKE", ]); /** diff --git a/src/lib/openapi/registry.ts b/src/lib/openapi/registry.ts index 147d50b24..afe62485e 100644 --- a/src/lib/openapi/registry.ts +++ b/src/lib/openapi/registry.ts @@ -77,6 +77,7 @@ const openApiBase: Pick< { name: "Illness" }, { name: "Admin" }, { name: "Meta" }, + { name: "Hydration" }, ], }; diff --git a/src/lib/openapi/routes/hydration.ts b/src/lib/openapi/routes/hydration.ts new file mode 100644 index 000000000..58dccb2f2 --- /dev/null +++ b/src/lib/openapi/routes/hydration.ts @@ -0,0 +1,102 @@ +/** + * OpenAPI route module — hydration daily-goal surface (v1.25). + * + * `GET /api/hydration` reads today's summed water intake vs the user's goal; + * `PATCH /api/hydration` sets the per-user daily goal. Logging rides the + * measurements create path (POST /api/measurements, type WATER_INTAKE), so it + * is documented there, not here. + * + * Part of the OpenAPI route table; aggregated in `./index.ts`. + */ +import { z } from "zod/v4"; +import type { ZodOpenApiObject } from "zod-openapi"; + +import { dataEnvelope, stdResponses } from "./shared"; + +const hydrationEntry = z.object({ + id: z.string(), + value: z.number().describe("Logged amount in millilitres."), + measuredAt: z.string().describe("ISO-8601 instant the entry was logged."), +}); + +const hydrationTodayResponse = z + .object({ + date: z.string().describe("ISO-8601 start of the user's local day."), + totalMl: z.number().describe("Today's summed intake in ml."), + goalMl: z.number().describe("Effective daily goal in ml."), + percent: z.number().describe("Progress toward the goal, capped at 100."), + rawPercent: z.number().describe("Uncapped progress; can exceed 100."), + met: z.boolean().describe("Whether the day's total reached the goal."), + remainingMl: z.number().describe("Remaining ml to the goal (0 once met)."), + entries: z.array(hydrationEntry), + }) + .meta({ + id: "HydrationToday", + description: "Today's water-intake total vs the user's daily goal.", + }); + +const hydrationGoalRequest = z + .object({ + goalMl: z + .number() + .int() + .min(250) + .max(8000) + .describe("Daily hydration goal in millilitres."), + }) + .meta({ + id: "HydrationGoalRequest", + description: "Set the per-user daily hydration goal (ml).", + }); + +const hydrationGoalResponse = z + .object({ goalMl: z.number() }) + .meta({ id: "HydrationGoalResponse" }); + +export const hydrationPaths: NonNullable = { + "/api/hydration": { + get: { + tags: ["Hydration"], + summary: "Today's hydration total vs goal", + description: + "Sums the calling user's WATER_INTAKE measurements for the current local day and compares them against the per-user goal (default 2000 ml).", + responses: { + "200": { + description: "Today's hydration summary.", + content: { + "application/json": { + schema: dataEnvelope( + hydrationTodayResponse, + "HydrationTodayEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + patch: { + tags: ["Hydration"], + summary: "Set the daily hydration goal", + description: "Updates the per-user daily hydration goal in millilitres.", + requestBody: { + required: true, + content: { "application/json": { schema: hydrationGoalRequest } }, + }, + responses: { + "200": { + description: "Goal updated.", + content: { + "application/json": { + schema: dataEnvelope( + hydrationGoalResponse, + "HydrationGoalEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, +}; diff --git a/src/lib/personal-records/pr-direction.ts b/src/lib/personal-records/pr-direction.ts index 0058a5131..9671186b8 100644 --- a/src/lib/personal-records/pr-direction.ts +++ b/src/lib/personal-records/pr-direction.ts @@ -194,6 +194,11 @@ export function getPRDirection( // inputs — a derived composite with no clean goal axis, so it stays null // like the other device-derived scores. case "RESILIENCE": + // v1.25 — water intake is a cumulative daily counter surfaced as a + // goal ring + logging streak, not a single-reading achievement. A + // "biggest sip" record would be meaningless and the daily-total record + // is better served by the streak rail, so it stays null. + case "WATER_INTAKE": return null; } } diff --git a/src/lib/query-keys/hydration.ts b/src/lib/query-keys/hydration.ts new file mode 100644 index 000000000..867dce37a --- /dev/null +++ b/src/lib/query-keys/hydration.ts @@ -0,0 +1,10 @@ +/** + * Query keys — hydration tree: today's water-intake total vs the daily goal + * (`/api/hydration`). Part of the centralized factory; aggregated in + * `./index.ts`. A `WATER_INTAKE` measurement write busts this key through the + * `measurementDependentKeys` bundle so the goal ring refreshes in lockstep. + */ +export const hydrationKeys = { + hydration: () => ["hydration"] as const, + hydrationToday: () => ["hydration", "today"] as const, +}; diff --git a/src/lib/query-keys/index.ts b/src/lib/query-keys/index.ts index d84854923..762f42536 100644 --- a/src/lib/query-keys/index.ts +++ b/src/lib/query-keys/index.ts @@ -19,6 +19,7 @@ import { authKeys } from "./auth"; import { coachKeys } from "./coach"; import { cycleKeys } from "./cycle"; import { dashboardKeys } from "./dashboard"; +import { hydrationKeys } from "./hydration"; import { illnessKeys } from "./illness"; import { insightsKeys } from "./insights"; import { integrationKeys } from "./integrations"; @@ -46,6 +47,7 @@ export const queryKeys = { ...measurementReminderKeys, ...labKeys, ...illnessKeys, + ...hydrationKeys, }; /** @@ -86,6 +88,8 @@ export const measurementDependentKeys = [ ["sleep-night"] as const, // v1.17.0 — refresh the sleep-debt + chronotype read when sleep rows change. ["sleep-rhythm"] as const, + // v1.25 — refresh the hydration goal ring when a WATER_INTAKE row is added. + queryKeys.hydration(), ]; /** diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index 2c11ba6f0..97a4876e0 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -122,6 +122,10 @@ export const measurementTypeEnum = z.enum([ // into the numeric value (limited=1 … exceptional=5), unit `level`. Ingests // server-side as `source = OURA`. See `RESILIENCE_LEVELS` in `src/lib/oura/client`. "RESILIENCE", + // ── v1.25 — hydration logging (additive) ── + // Manual water-intake counter in millilitres. Cumulative within a day like + // ACTIVITY_STEPS; distinct from TOTAL_BODY_WATER (a scale reading in kg). + "WATER_INTAKE", ]); /** @@ -239,6 +243,7 @@ const unitMap: Record = { HEART_RATE_VARIABILITY: "ms", RESTING_HEART_RATE: "bpm", ACTIVE_ENERGY_BURNED: "kcal", + WATER_INTAKE: "ml", FLIGHTS_CLIMBED: "flights", WALKING_RUNNING_DISTANCE: "m", VO2_MAX: "mL/(kg·min)", @@ -394,6 +399,9 @@ export const VALUE_RANGES: Record = { // Active energy kcal/sample — a daily-rollup sample tops out around // 6–8000 kcal even for ultra-endurance days. ACTIVE_ENERGY_BURNED: { min: 0, max: 10000 }, + // Daily cumulative intake in ml; a single quick-add entry is small but the + // running daily total can reach several litres on an active day. + WATER_INTAKE: { min: 0, max: 20000 }, // Flights — vertical-feet/3 ≈ flights; cap generous. FLIGHTS_CLIMBED: { min: 0, max: 1000 }, // Walking/running distance per sample (metres). 200 km covers an From ec3fcea84fc2d696d93fe31540206ec80f03a7f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 03:20:49 +0200 Subject: [PATCH 09/94] feat: baseline-drift, breathing-screening and lab-change insight cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add three read-only awareness cards to the Insights overview, each drawn off an existing engine and each honest about empty state — the section un-mounts rather than surfacing a husk. - Health status: surfaces what is drifting from the user's own normal by folding the personal-band deviations (coincident-deviation engine) with the dated, sustained level shifts (changepoint detector) at GET /api/insights/health-status. Awareness only, never a diagnosis. - Breathing screening: summarises the last 30 nights of the per-night sleep-breathing-disturbance index plus device-flagged events at GET /api/insights/breathing-screening. Mirrors the device's own classification and states plainly that it is a screening signal, not a diagnosis — discuss with a clinician. - Lab changes: pairs the two most-recent numeric lab panels and reports each shared analyte's signed delta and reference-band standing at GET /api/insights/labs-changes. Neutral framing only. Each reshaping helper is pure and unit-tested for its present/absent states. Register the three sections in the insights-layout universe so they merge in as default-visible, wire them into the overview and the edit-mode title map, add the query-factory keys, document the routes in the OpenAPI contract, and translate every new string across all six locales. --- docs/api/openapi.yaml | 453 ++++++++++++++++++ messages/de.json | 51 +- messages/en.json | 51 +- messages/es.json | 51 +- messages/fr.json | 51 +- messages/it.json | 51 +- messages/pl.json | 51 +- .../coach-route-gate-inventory.test.ts | 8 + .../api/insights/breathing-screening/route.ts | 73 +++ src/app/api/insights/health-status/route.ts | 67 +++ src/app/api/insights/labs-changes/route.ts | 67 +++ src/app/insights/page.tsx | 40 ++ .../insights/breathing-screening-card.tsx | 137 ++++++ .../insights/health-status-card.tsx | 152 ++++++ .../insights/insights-edit-mode.tsx | 4 + src/components/insights/labs-changes-card.tsx | 114 +++++ src/lib/__tests__/insights-layout.test.ts | 6 +- src/lib/insights-layout.ts | 4 + .../__tests__/breathing-screening.test.ts | 58 +++ .../insights/__tests__/health-status.test.ts | 62 +++ .../insights/__tests__/labs-changes.test.ts | 67 +++ src/lib/insights/breathing-screening.ts | 122 +++++ src/lib/insights/health-status.ts | 84 ++++ src/lib/insights/labs-changes.ts | 131 +++++ src/lib/openapi/routes/index.ts | 4 + src/lib/openapi/routes/insights-signals.ts | 177 +++++++ src/lib/query-keys/insights.ts | 25 + 27 files changed, 2148 insertions(+), 13 deletions(-) create mode 100644 src/app/api/insights/breathing-screening/route.ts create mode 100644 src/app/api/insights/health-status/route.ts create mode 100644 src/app/api/insights/labs-changes/route.ts create mode 100644 src/components/insights/breathing-screening-card.tsx create mode 100644 src/components/insights/health-status-card.tsx create mode 100644 src/components/insights/labs-changes-card.tsx create mode 100644 src/lib/insights/__tests__/breathing-screening.test.ts create mode 100644 src/lib/insights/__tests__/health-status.test.ts create mode 100644 src/lib/insights/__tests__/labs-changes.test.ts create mode 100644 src/lib/insights/breathing-screening.ts create mode 100644 src/lib/insights/health-status.ts create mode 100644 src/lib/insights/labs-changes.ts create mode 100644 src/lib/openapi/routes/insights-signals.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index afefa5a85..f7be19a22 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -44,6 +44,7 @@ tags: - name: Illness - name: Admin - name: Meta + - name: Hydration paths: /api/cycle/day-logs: get: @@ -2087,6 +2088,7 @@ paths: - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION - RESILIENCE + - WATER_INTAKE - in: query name: from schema: @@ -2276,6 +2278,7 @@ paths: - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION - RESILIENCE + - WATER_INTAKE value: type: number measuredAt: @@ -5770,6 +5773,7 @@ paths: - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION - RESILIENCE + - WATER_INTAKE description: "The measurement type to read (single metric — no fan-out). Closed enum: an unknown type 422s." required: true description: "The measurement type to read (single metric — no fan-out). Closed enum: an unknown type 422s." @@ -6178,6 +6182,60 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/insights/health-status: + get: + tags: + - Insights + summary: Baseline-drift health status + description: Surfaces vitals drifting from the user's personal normal — out-of-band deviations from the + personal-baseline engine plus dated, sustained level shifts from the changepoint detector. Pure compute over the + rollup tier; awareness only, never a diagnosis. Requires the insights module. Cookie or Bearer auth. + responses: + "200": + description: The drift summary. + content: + application/json: + schema: + $ref: "#/components/schemas/InsightsHealthStatusEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + /api/insights/breathing-screening: + get: + tags: + - Insights + summary: Sleep-breathing screening signal + description: Summarises the last ~30 nights of the per-night sleep-breathing-disturbance index plus device-flagged + events into a calm awareness read. A screening signal only — never a HealthLog diagnosis. Requires the insights + module. Cookie or Bearer auth. + responses: + "200": + description: The breathing-screening summary. + content: + application/json: + schema: + $ref: "#/components/schemas/InsightsBreathingScreeningEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + /api/insights/labs-changes: + get: + tags: + - Insights + summary: What changed since your last lab panel + description: Pairs the two most-recent numeric lab panels and reports each shared analyte's signed delta plus its + reference-band standing. Absent when there are fewer than two panels or no analyte is shared. Neutral framing, + never a diagnosis. Cookie or Bearer auth. + responses: + "200": + description: The lab-change summary. + content: + application/json: + schema: + $ref: "#/components/schemas/InsightsLabsChangesEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 /api/mood/tags: get: tags: @@ -7945,6 +8003,44 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/hydration: + get: + tags: + - Hydration + summary: Today's hydration total vs goal + description: Sums the calling user's WATER_INTAKE measurements for the current local day and compares them against the + per-user goal (default 2000 ml). + responses: + "200": + description: Today's hydration summary. + content: + application/json: + schema: + $ref: "#/components/schemas/HydrationTodayEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + patch: + tags: + - Hydration + summary: Set the daily hydration goal + description: Updates the per-user daily hydration goal in millilitres. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/HydrationGoalRequest" + responses: + "200": + description: Goal updated. + content: + application/json: + schema: + $ref: "#/components/schemas/HydrationGoalEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 components: schemas: EncryptedExportRequest: @@ -9245,6 +9341,10 @@ components: - cycle-summary - signals - rhythm-events + - hydration + - health-status + - breathing + - labs-changes visible: type: boolean order: @@ -9654,6 +9754,17 @@ components: - updatedAt description: Resumable module-tour progress point. `lastStopId` seeds the resume index; `status` is the running/terminal state. + HydrationGoalRequest: + type: object + properties: + goalMl: + type: integer + minimum: 250 + maximum: 8000 + description: Daily hydration goal in millilitres. + required: + - goalMl + description: Set the per-user daily hydration goal (ml). MedicationTreatmentClass: type: string enum: @@ -12255,6 +12366,7 @@ components: - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION - RESILIENCE + - WATER_INTAKE value: type: number unit: @@ -12571,6 +12683,7 @@ components: - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION - RESILIENCE + - WATER_INTAKE additionalProperties: type: array items: @@ -12648,6 +12761,7 @@ components: - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION - RESILIENCE + - WATER_INTAKE value: type: number description: Daily mean (spot metrics) or SUM (cumulative). @@ -12746,6 +12860,7 @@ components: - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION - RESILIENCE + - WATER_INTAKE description: Per-type daily series, keyed by MeasurementType. required: - series @@ -12979,6 +13094,7 @@ components: - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION - RESILIENCE + - WATER_INTAKE value: type: number unit: @@ -20663,6 +20779,244 @@ components: - windowDays - interpretation additionalProperties: false + InsightsHealthStatusEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/InsightsHealthStatus" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + InsightsHealthStatus: + type: object + properties: + present: + type: boolean + description: True when at least one deviation or shift is surfaced. + deviations: + type: array + items: + type: object + properties: + type: + type: string + description: MeasurementType token. + value: + type: number + description: Today's value. + center: + type: number + description: Personal band center (median). + low: + type: number + high: + type: number + direction: + type: string + enum: + - above + - below + required: + - type + - value + - center + - low + - high + - direction + additionalProperties: false + shifts: + type: array + items: + type: object + properties: + metric: + type: string + description: MeasurementType token. + breakDate: + type: string + description: YYYY-MM-DD of the new level's first day. + beforeMean: + type: number + afterMean: + type: number + direction: + type: string + enum: + - up + - down + required: + - metric + - breakDate + - beforeMean + - afterMean + - direction + additionalProperties: false + generatedAt: + type: string + description: ISO-8601 instant the read was computed. + required: + - present + - deviations + - shifts + - generatedAt + additionalProperties: false + description: Vitals drifting from the user's personal normal — band deviations plus dated, sustained level shifts. + Awareness only, never a diagnosis. + InsightsBreathingScreeningEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/InsightsBreathingScreening" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + InsightsBreathingScreening: + type: object + properties: + present: + type: boolean + nights: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Nights with a per-night index reading. + recentMeanIndex: + anyOf: + - type: number + - type: "null" + description: Mean of the index readings (lower-better); null when none. + trend: + anyOf: + - type: string + enum: + - up + - down + - stable + - type: "null" + description: Recent index vs the prior window; null when too few nights. + eventCount: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Device-flagged breathing-disturbance / apnea events. + classification: + anyOf: + - type: string + enum: + - not-elevated + - elevated + - type: "null" + description: The device's own classification; null when no data. + generatedAt: + type: string + required: + - present + - nights + - recentMeanIndex + - trend + - eventCount + - classification + - generatedAt + additionalProperties: false + description: Sleep-breathing-disturbance screening signal — a screening signal only, never a diagnosis. + InsightsLabsChangesEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/InsightsLabsChanges" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + InsightsLabsChanges: + type: object + properties: + present: + type: boolean + latestDate: + anyOf: + - type: string + - type: "null" + description: YYYY-MM-DD of the most-recent panel. + previousDate: + anyOf: + - type: string + - type: "null" + description: YYYY-MM-DD of the prior panel. + changes: + type: array + items: + type: object + properties: + analyte: + type: string + unit: + type: string + latest: + type: number + previous: + type: number + delta: + type: number + description: Signed latest − previous. + direction: + type: string + enum: + - up + - down + - flat + status: + type: string + enum: + - in-range + - below + - above + - unknown + required: + - analyte + - unit + - latest + - previous + - delta + - direction + - status + additionalProperties: false + generatedAt: + type: string + required: + - present + - latestDate + - previousDate + - changes + - generatedAt + additionalProperties: false + description: Per-analyte change between the two most-recent numeric lab panels. Neutral framing, never a diagnosis. MoodTagTreeEnvelope: type: object properties: @@ -23024,6 +23378,101 @@ components: - progress additionalProperties: false description: The persisted completion flag and resume point after the write. + HydrationTodayEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/HydrationToday" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + HydrationToday: + type: object + properties: + date: + type: string + description: ISO-8601 start of the user's local day. + totalMl: + type: number + description: Today's summed intake in ml. + goalMl: + type: number + description: Effective daily goal in ml. + percent: + type: number + description: Progress toward the goal, capped at 100. + rawPercent: + type: number + description: Uncapped progress; can exceed 100. + met: + type: boolean + description: Whether the day's total reached the goal. + remainingMl: + type: number + description: Remaining ml to the goal (0 once met). + entries: + type: array + items: + type: object + properties: + id: + type: string + value: + type: number + description: Logged amount in millilitres. + measuredAt: + type: string + description: ISO-8601 instant the entry was logged. + required: + - id + - value + - measuredAt + additionalProperties: false + required: + - date + - totalMl + - goalMl + - percent + - rawPercent + - met + - remainingMl + - entries + additionalProperties: false + description: Today's water-intake total vs the user's daily goal. + HydrationGoalEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/HydrationGoalResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + HydrationGoalResponse: + type: object + properties: + goalMl: + type: number + required: + - goalMl + additionalProperties: false MedicationScheduleOutput: type: object properties: @@ -23201,6 +23650,10 @@ components: - cycle-summary - signals - rhythm-events + - hydration + - health-status + - breathing + - labs-changes visible: type: boolean order: diff --git a/messages/de.json b/messages/de.json index ce28bb09c..52333ac05 100644 --- a/messages/de.json +++ b/messages/de.json @@ -574,7 +574,8 @@ "bulkDeleteSuccess": "{count} Messungen gelöscht", "bulkDeleteError": "Fehler beim Löschen der ausgewählten Messungen", "bulkDeleteConfirmTitle": "{count} Messungen löschen?", - "bulkDeleteConfirmBody": "Die {count} ausgewählten Messungen werden endgültig gelöscht." + "bulkDeleteConfirmBody": "Die {count} ausgewählten Messungen werden endgültig gelöscht.", + "typeWaterIntake": "Wasseraufnahme" }, "mood": { "title": "Stimmung", @@ -3666,7 +3667,38 @@ }, "updatedTodayAt": "Heute aktualisiert, {time}", "updatedYesterday": "Gestern aktualisiert", - "updatedOn": "Aktualisiert am {date}" + "updatedOn": "Aktualisiert am {date}", + "healthStatus": { + "sectionTitle": "Was sich verändert hat", + "subtitle": "Werte, die von deinem üblichen Bereich abweichen.", + "deviationAbove": "{vital} liegt über deinem üblichen Bereich.", + "deviationBelow": "{vital} liegt unter deinem üblichen Bereich.", + "shiftUp": "{vital} hat sich gegenüber deiner jüngsten Norm nach oben verschoben.", + "shiftDown": "{vital} hat sich gegenüber deiner jüngsten Norm nach unten verschoben.", + "rangeContext": "Aktuell {value}, sonst {low}–{high}.", + "shiftContext": "Etwa {before} davor, {after} seitdem." + }, + "breathingScreening": { + "sectionTitle": "Atmung im Schlaf", + "subtitle": "Ein Screening-Signal aus deinen Schlafdaten.", + "classificationElevated": "Dein Gerät hat erhöhte Atemstörungen gemeldet.", + "classificationNotElevated": "Die Einstufung deines Geräts ist nicht erhöht.", + "trendUp": "Höher als an deinen früheren Nächten.", + "trendDown": "Niedriger als an deinen früheren Nächten.", + "trendStable": "Stabil gegenüber deinen früheren Nächten.", + "nights": "{count} Nächte erfasst", + "indexLine": "jüngster Durchschnittsindex {value}", + "eventsFlagged": "{count} vom Gerät gemeldete Ereignisse", + "disclaimer": "Dies ist ein Screening-Signal, keine Diagnose — bitte mit einer Ärztin oder einem Arzt besprechen." + }, + "labsChanges": { + "sectionTitle": "Seit deinem letzten Laborbefund", + "subtitle": "Wie deine neuesten Werte im Vergleich zum vorherigen Befund stehen.", + "statusInRange": "im Normbereich", + "statusBelow": "unter dem Bereich", + "statusAbove": "über dem Bereich", + "directionFlat": "keine Änderung" + } }, "thresholds": { "defaultLabel": "Default", @@ -7372,5 +7404,20 @@ "glucoseHighDoctor": "Zwei aktuelle Messungen lagen bei {value} mg/dL oder höher. Miss erneut, trink ausreichend und kontaktiere möglichst bald deine Ärztin oder deinen Arzt, wenn es so hoch bleibt.", "glucoseHighEmergency": "Zwei aktuelle Messungen lagen bei {value} mg/dL oder höher und du hast Beschwerden angegeben. Bei Erbrechen, schneller Atmung oder Verwirrtheit sofort den Notruf wählen." } + }, + "hydration": { + "cardTitle": "Flüssigkeitszufuhr", + "editGoal": "Ziel bearbeiten", + "goalLabel": "Tagesziel (ml)", + "metMessage": "Du hast dein heutiges Ziel erreicht.", + "remainingMessage": "Noch {amount} ml heute.", + "add": "Hinzufügen", + "added": "{amount} ml hinzugefügt.", + "saveError": "Speichern fehlgeschlagen. Bitte erneut versuchen.", + "invalidAmount": "Gib eine Menge zwischen 1 und 5000 ml ein.", + "invalidGoal": "Gib ein Ziel zwischen 250 und 8000 ml ein.", + "goalSaved": "Ziel aktualisiert.", + "customPlaceholder": "Menge", + "customLabel": "Eigene Menge (ml)" } } diff --git a/messages/en.json b/messages/en.json index 14fa8b085..c47b3d0b7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -574,7 +574,8 @@ "bulkDeleteSuccess": "{count} measurements deleted", "bulkDeleteError": "Error deleting the selected measurements", "bulkDeleteConfirmTitle": "Delete {count} measurements?", - "bulkDeleteConfirmBody": "The {count} selected measurements will be permanently deleted." + "bulkDeleteConfirmBody": "The {count} selected measurements will be permanently deleted.", + "typeWaterIntake": "Water intake" }, "mood": { "title": "Mood", @@ -3666,7 +3667,38 @@ }, "updatedTodayAt": "Updated today, {time}", "updatedYesterday": "Updated yesterday", - "updatedOn": "Updated on {date}" + "updatedOn": "Updated on {date}", + "healthStatus": { + "sectionTitle": "What's changed", + "subtitle": "Signals drifting from your usual range.", + "deviationAbove": "{vital} is above your typical range.", + "deviationBelow": "{vital} is below your typical range.", + "shiftUp": "{vital} has shifted up from your recent normal.", + "shiftDown": "{vital} has shifted down from your recent normal.", + "rangeContext": "Now {value}, usually {low}–{high}.", + "shiftContext": "Around {before} before, {after} since." + }, + "breathingScreening": { + "sectionTitle": "Breathing during sleep", + "subtitle": "A screening signal from your sleep data.", + "classificationElevated": "Your device flagged elevated breathing disturbances.", + "classificationNotElevated": "Your device's classification is not elevated.", + "trendUp": "Trending higher than your earlier nights.", + "trendDown": "Trending lower than your earlier nights.", + "trendStable": "Holding steady against your earlier nights.", + "nights": "{count} nights tracked", + "indexLine": "recent average index {value}", + "eventsFlagged": "{count} events flagged by your device", + "disclaimer": "This is a screening signal, not a diagnosis — discuss with a clinician." + }, + "labsChanges": { + "sectionTitle": "Since your last panel", + "subtitle": "How your latest results compare with the panel before.", + "statusInRange": "in range", + "statusBelow": "below range", + "statusAbove": "above range", + "directionFlat": "no change" + } }, "thresholds": { "defaultLabel": "Default", @@ -7372,5 +7404,20 @@ "glucoseHighDoctor": "Two recent readings were {value} mg/dL or higher. Re-check, hydrate, and contact your doctor as soon as possible if it stays this high.", "glucoseHighEmergency": "Two recent readings were {value} mg/dL or higher and you noted symptoms. If you have vomiting, fast breathing, or confusion, call emergency services now." } + }, + "hydration": { + "cardTitle": "Hydration", + "editGoal": "Edit goal", + "goalLabel": "Daily goal (ml)", + "metMessage": "You've reached today's goal.", + "remainingMessage": "{amount} ml to go today.", + "add": "Add", + "added": "Added {amount} ml.", + "saveError": "Could not save. Please try again.", + "invalidAmount": "Enter an amount between 1 and 5000 ml.", + "invalidGoal": "Enter a goal between 250 and 8000 ml.", + "goalSaved": "Goal updated.", + "customPlaceholder": "Amount", + "customLabel": "Custom amount (ml)" } } diff --git a/messages/es.json b/messages/es.json index ce2ee6de9..6e03917c8 100644 --- a/messages/es.json +++ b/messages/es.json @@ -574,7 +574,8 @@ "bulkDeleteSuccess": "{count} mediciones eliminadas", "bulkDeleteError": "Error al eliminar las mediciones seleccionadas", "bulkDeleteConfirmTitle": "¿Eliminar {count} mediciones?", - "bulkDeleteConfirmBody": "Las {count} mediciones seleccionadas se eliminarán de forma permanente." + "bulkDeleteConfirmBody": "Las {count} mediciones seleccionadas se eliminarán de forma permanente.", + "typeWaterIntake": "Ingesta de agua" }, "mood": { "title": "Estado de ánimo", @@ -3666,7 +3667,38 @@ }, "updatedTodayAt": "Actualizado hoy, {time}", "updatedYesterday": "Actualizado ayer", - "updatedOn": "Actualizado el {date}" + "updatedOn": "Actualizado el {date}", + "healthStatus": { + "sectionTitle": "Qué ha cambiado", + "subtitle": "Señales que se desvían de tu rango habitual.", + "deviationAbove": "{vital} está por encima de tu rango habitual.", + "deviationBelow": "{vital} está por debajo de tu rango habitual.", + "shiftUp": "{vital} ha subido respecto a tu normal reciente.", + "shiftDown": "{vital} ha bajado respecto a tu normal reciente.", + "rangeContext": "Ahora {value}, normalmente {low}–{high}.", + "shiftContext": "Cerca de {before} antes, {after} desde entonces." + }, + "breathingScreening": { + "sectionTitle": "Respiración durante el sueño", + "subtitle": "Una señal de cribado de tus datos de sueño.", + "classificationElevated": "Tu dispositivo detectó alteraciones respiratorias elevadas.", + "classificationNotElevated": "La clasificación de tu dispositivo no es elevada.", + "trendUp": "Con tendencia al alza respecto a noches anteriores.", + "trendDown": "Con tendencia a la baja respecto a noches anteriores.", + "trendStable": "Estable respecto a noches anteriores.", + "nights": "{count} noches registradas", + "indexLine": "índice medio reciente {value}", + "eventsFlagged": "{count} eventos detectados por tu dispositivo", + "disclaimer": "Es una señal de cribado, no un diagnóstico: consultalo con un profesional médico." + }, + "labsChanges": { + "sectionTitle": "Desde tu último análisis", + "subtitle": "Cómo se comparan tus últimos resultados con el análisis anterior.", + "statusInRange": "dentro del rango", + "statusBelow": "por debajo del rango", + "statusAbove": "por encima del rango", + "directionFlat": "sin cambios" + } }, "thresholds": { "defaultLabel": "Por defecto", @@ -7372,5 +7404,20 @@ "glucoseHighDoctor": "Dos lecturas recientes fueron de {value} mg/dL o más. Vuelve a medir, hidrátate y contacta a tu médico cuanto antes si sigue así de alta.", "glucoseHighEmergency": "Dos lecturas recientes fueron de {value} mg/dL o más e indicaste síntomas. Si tienes vómitos, respiración rápida o confusión, llama a emergencias ya." } + }, + "hydration": { + "cardTitle": "Hidratación", + "editGoal": "Editar objetivo", + "goalLabel": "Objetivo diario (ml)", + "metMessage": "Has alcanzado el objetivo de hoy.", + "remainingMessage": "Faltan {amount} ml hoy.", + "add": "Añadir", + "added": "Añadidos {amount} ml.", + "saveError": "No se pudo guardar. Inténtalo de nuevo.", + "invalidAmount": "Introduce una cantidad entre 1 y 5000 ml.", + "invalidGoal": "Introduce un objetivo entre 250 y 8000 ml.", + "goalSaved": "Objetivo actualizado.", + "customPlaceholder": "Cantidad", + "customLabel": "Cantidad personalizada (ml)" } } diff --git a/messages/fr.json b/messages/fr.json index 69f085b74..1e40e6df6 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -574,7 +574,8 @@ "bulkDeleteSuccess": "{count} mesures supprimées", "bulkDeleteError": "Erreur lors de la suppression des mesures sélectionnées", "bulkDeleteConfirmTitle": "Supprimer {count} mesures ?", - "bulkDeleteConfirmBody": "Les {count} mesures sélectionnées seront définitivement supprimées." + "bulkDeleteConfirmBody": "Les {count} mesures sélectionnées seront définitivement supprimées.", + "typeWaterIntake": "Apport en eau" }, "mood": { "title": "Humeur", @@ -3666,7 +3667,38 @@ }, "updatedTodayAt": "Mis à jour aujourd'hui, {time}", "updatedYesterday": "Mis à jour hier", - "updatedOn": "Mis à jour le {date}" + "updatedOn": "Mis à jour le {date}", + "healthStatus": { + "sectionTitle": "Ce qui a changé", + "subtitle": "Signaux s'écartant de votre plage habituelle.", + "deviationAbove": "{vital} est au-dessus de votre plage habituelle.", + "deviationBelow": "{vital} est en dessous de votre plage habituelle.", + "shiftUp": "{vital} a augmenté par rapport à votre normale récente.", + "shiftDown": "{vital} a diminué par rapport à votre normale récente.", + "rangeContext": "Actuellement {value}, habituellement {low}–{high}.", + "shiftContext": "Environ {before} avant, {after} depuis." + }, + "breathingScreening": { + "sectionTitle": "Respiration pendant le sommeil", + "subtitle": "Un signal de dépistage issu de vos données de sommeil.", + "classificationElevated": "Votre appareil a signalé des perturbations respiratoires élevées.", + "classificationNotElevated": "La classification de votre appareil n'est pas élevée.", + "trendUp": "En hausse par rapport à vos nuits précédentes.", + "trendDown": "En baisse par rapport à vos nuits précédentes.", + "trendStable": "Stable par rapport à vos nuits précédentes.", + "nights": "{count} nuits suivies", + "indexLine": "indice moyen récent {value}", + "eventsFlagged": "{count} événements signalés par votre appareil", + "disclaimer": "Il s'agit d'un signal de dépistage, pas d'un diagnostic — parlez-en à un professionnel de santé." + }, + "labsChanges": { + "sectionTitle": "Depuis votre dernier bilan", + "subtitle": "Comparaison de vos derniers résultats avec le bilan précédent.", + "statusInRange": "dans la plage", + "statusBelow": "sous la plage", + "statusAbove": "au-dessus de la plage", + "directionFlat": "aucun changement" + } }, "thresholds": { "defaultLabel": "Par défaut", @@ -7372,5 +7404,20 @@ "glucoseHighDoctor": "Deux mesures récentes étaient à {value} mg/dL ou plus. Remesure, hydrate-toi et contacte ton médecin dès que possible si cela reste aussi élevé.", "glucoseHighEmergency": "Deux mesures récentes étaient à {value} mg/dL ou plus et tu as signalé des symptômes. En cas de vomissements, de respiration rapide ou de confusion, appelle les secours immédiatement." } + }, + "hydration": { + "cardTitle": "Hydratation", + "editGoal": "Modifier l'objectif", + "goalLabel": "Objectif quotidien (ml)", + "metMessage": "Vous avez atteint l'objectif du jour.", + "remainingMessage": "Encore {amount} ml aujourd'hui.", + "add": "Ajouter", + "added": "{amount} ml ajoutés.", + "saveError": "Échec de l'enregistrement. Réessayez.", + "invalidAmount": "Saisissez une quantité entre 1 et 5000 ml.", + "invalidGoal": "Saisissez un objectif entre 250 et 8000 ml.", + "goalSaved": "Objectif mis à jour.", + "customPlaceholder": "Quantité", + "customLabel": "Quantité personnalisée (ml)" } } diff --git a/messages/it.json b/messages/it.json index 60e5288d4..c8b3d58a2 100644 --- a/messages/it.json +++ b/messages/it.json @@ -574,7 +574,8 @@ "bulkDeleteSuccess": "{count} misurazioni eliminate", "bulkDeleteError": "Errore durante l'eliminazione delle misurazioni selezionate", "bulkDeleteConfirmTitle": "Eliminare {count} misurazioni?", - "bulkDeleteConfirmBody": "Le {count} misurazioni selezionate verranno eliminate definitivamente." + "bulkDeleteConfirmBody": "Le {count} misurazioni selezionate verranno eliminate definitivamente.", + "typeWaterIntake": "Assunzione di acqua" }, "mood": { "title": "Umore", @@ -3666,7 +3667,38 @@ }, "updatedTodayAt": "Aggiornato oggi, {time}", "updatedYesterday": "Aggiornato ieri", - "updatedOn": "Aggiornato il {date}" + "updatedOn": "Aggiornato il {date}", + "healthStatus": { + "sectionTitle": "Cosa è cambiato", + "subtitle": "Segnali che si discostano dal tuo intervallo abituale.", + "deviationAbove": "{vital} è sopra il tuo intervallo abituale.", + "deviationBelow": "{vital} è sotto il tuo intervallo abituale.", + "shiftUp": "{vital} si è spostato verso l'alto rispetto alla tua norma recente.", + "shiftDown": "{vital} si è spostato verso il basso rispetto alla tua norma recente.", + "rangeContext": "Ora {value}, di solito {low}–{high}.", + "shiftContext": "Circa {before} prima, {after} da allora." + }, + "breathingScreening": { + "sectionTitle": "Respirazione durante il sonno", + "subtitle": "Un segnale di screening dai tuoi dati del sonno.", + "classificationElevated": "Il tuo dispositivo ha segnalato disturbi respiratori elevati.", + "classificationNotElevated": "La classificazione del tuo dispositivo non è elevata.", + "trendUp": "In aumento rispetto alle notti precedenti.", + "trendDown": "In calo rispetto alle notti precedenti.", + "trendStable": "Stabile rispetto alle notti precedenti.", + "nights": "{count} notti registrate", + "indexLine": "indice medio recente {value}", + "eventsFlagged": "{count} eventi segnalati dal tuo dispositivo", + "disclaimer": "È un segnale di screening, non una diagnosi: parlane con un medico." + }, + "labsChanges": { + "sectionTitle": "Dal tuo ultimo pannello", + "subtitle": "Come i tuoi ultimi risultati si confrontano con il pannello precedente.", + "statusInRange": "nell'intervallo", + "statusBelow": "sotto l'intervallo", + "statusAbove": "sopra l'intervallo", + "directionFlat": "nessuna variazione" + } }, "thresholds": { "defaultLabel": "Predefinito", @@ -7372,5 +7404,20 @@ "glucoseHighDoctor": "Due misurazioni recenti erano di {value} mg/dL o superiori. Rimisura, idratati e contatta il medico al più presto se resta così alta.", "glucoseHighEmergency": "Due misurazioni recenti erano di {value} mg/dL o superiori e hai segnalato sintomi. In caso di vomito, respiro affannoso o confusione, chiama subito i soccorsi." } + }, + "hydration": { + "cardTitle": "Idratazione", + "editGoal": "Modifica obiettivo", + "goalLabel": "Obiettivo giornaliero (ml)", + "metMessage": "Hai raggiunto l'obiettivo di oggi.", + "remainingMessage": "Ancora {amount} ml oggi.", + "add": "Aggiungi", + "added": "Aggiunti {amount} ml.", + "saveError": "Salvataggio non riuscito. Riprova.", + "invalidAmount": "Inserisci una quantità tra 1 e 5000 ml.", + "invalidGoal": "Inserisci un obiettivo tra 250 e 8000 ml.", + "goalSaved": "Obiettivo aggiornato.", + "customPlaceholder": "Quantità", + "customLabel": "Quantità personalizzata (ml)" } } diff --git a/messages/pl.json b/messages/pl.json index 5631b6f90..603de4300 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -574,7 +574,8 @@ "bulkDeleteSuccess": "Usunięto pomiary: {count}", "bulkDeleteError": "Błąd podczas usuwania wybranych pomiarów", "bulkDeleteConfirmTitle": "Usunąć pomiary ({count})?", - "bulkDeleteConfirmBody": "Wybrane pomiary ({count}) zostaną trwale usunięte." + "bulkDeleteConfirmBody": "Wybrane pomiary ({count}) zostaną trwale usunięte.", + "typeWaterIntake": "Spożycie wody" }, "mood": { "title": "Nastrój", @@ -3666,7 +3667,38 @@ }, "updatedTodayAt": "Zaktualizowano dzisiaj, {time}", "updatedYesterday": "Zaktualizowano wczoraj", - "updatedOn": "Zaktualizowano {date}" + "updatedOn": "Zaktualizowano {date}", + "healthStatus": { + "sectionTitle": "Co się zmieniło", + "subtitle": "Sygnały odbiegające od Twojego zwykłego zakresu.", + "deviationAbove": "{vital} jest powyżej Twojego zwykłego zakresu.", + "deviationBelow": "{vital} jest poniżej Twojego zwykłego zakresu.", + "shiftUp": "{vital} przesunął się w górę względem Twojej ostatniej normy.", + "shiftDown": "{vital} przesunął się w dół względem Twojej ostatniej normy.", + "rangeContext": "Teraz {value}, zwykle {low}–{high}.", + "shiftContext": "Około {before} wcześniej, {after} od tego czasu." + }, + "breathingScreening": { + "sectionTitle": "Oddychanie podczas snu", + "subtitle": "Sygnał przesiewowy z Twoich danych o śnie.", + "classificationElevated": "Twoje urządzenie wykryło podwyższone zaburzenia oddychania.", + "classificationNotElevated": "Klasyfikacja Twojego urządzenia nie jest podwyższona.", + "trendUp": "Wzrost względem wcześniejszych nocy.", + "trendDown": "Spadek względem wcześniejszych nocy.", + "trendStable": "Stabilnie względem wcześniejszych nocy.", + "nights": "Śledzono {count} nocy", + "indexLine": "ostatni średni indeks {value}", + "eventsFlagged": "{count} zdarzeń oznaczonych przez urządzenie", + "disclaimer": "To sygnał przesiewowy, a nie diagnoza — skonsultuj się z lekarzem." + }, + "labsChanges": { + "sectionTitle": "Od ostatnich badań", + "subtitle": "Jak najnowsze wyniki wypadają na tle poprzednich badań.", + "statusInRange": "w normie", + "statusBelow": "poniżej zakresu", + "statusAbove": "powyżej zakresu", + "directionFlat": "bez zmian" + } }, "thresholds": { "defaultLabel": "Domyślne", @@ -7372,5 +7404,20 @@ "glucoseHighDoctor": "Dwa ostatnie pomiary wyniosły {value} mg/dL lub więcej. Zmierz ponownie, nawodnij się i jak najszybciej skontaktuj się z lekarzem, jeśli pozostaje tak wysoki.", "glucoseHighEmergency": "Dwa ostatnie pomiary wyniosły {value} mg/dL lub więcej i zgłosiłeś objawy. W razie wymiotów, przyspieszonego oddechu lub dezorientacji natychmiast zadzwoń po pomoc." } + }, + "hydration": { + "cardTitle": "Nawodnienie", + "editGoal": "Edytuj cel", + "goalLabel": "Cel dzienny (ml)", + "metMessage": "Osiągnięto dzisiejszy cel.", + "remainingMessage": "Jeszcze {amount} ml dzisiaj.", + "add": "Dodaj", + "added": "Dodano {amount} ml.", + "saveError": "Nie udało się zapisać. Spróbuj ponownie.", + "invalidAmount": "Podaj ilość od 1 do 5000 ml.", + "invalidGoal": "Podaj cel od 250 do 8000 ml.", + "goalSaved": "Cel zaktualizowany.", + "customPlaceholder": "Ilość", + "customLabel": "Własna ilość (ml)" } } diff --git a/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts b/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts index 939ff22f8..5e94255c4 100644 --- a/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts +++ b/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts @@ -92,6 +92,14 @@ const NOT_COACH_OWNED_ROUTES: ReadonlyArray = [ // wedge the user's ability to reorder the insights tile strip. "src/app/api/insights/layout/route.ts", "src/app/api/insights/targets/route.ts", + // v1.25 — read-only awareness cards for the overview. Each is pure compute + // over the rollup / lab tier (baseline-drift, sleep-breathing screening, + // last-panel lab deltas) and carries no assistant prose; they gate on the + // `insights` module, not on the Coach surface. Disabling Coach must not + // wedge these factual reads. + "src/app/api/insights/health-status/route.ts", + "src/app/api/insights/breathing-screening/route.ts", + "src/app/api/insights/labs-changes/route.ts", ]; const COACH_GATE_NEEDLE = 'requireAssistantSurface("coach")'; diff --git a/src/app/api/insights/breathing-screening/route.ts b/src/app/api/insights/breathing-screening/route.ts new file mode 100644 index 000000000..ee8cdf25b --- /dev/null +++ b/src/app/api/insights/breathing-screening/route.ts @@ -0,0 +1,73 @@ +/** + * v1.25 — breathing-disturbance screening read. + * + * GET /api/insights/breathing-screening + * + * Reads the last ~30 days of per-night sleep-breathing-disturbance index + * (`BREATHING_DISTURBANCES`) and device-flagged breathing-disturbance / apnea + * events (`BREATHING_DISTURBANCE_EVENT`) and summarises them into a calm + * awareness read (nights, recent mean, trend, event count, the device's own + * classification). SCREENING SIGNAL ONLY — never a HealthLog diagnosis; the + * card states this explicitly. + * + * Mirrors `/api/insights/derived`: `apiHandler`, `requireAuth`, the `insights` + * module gate, the shared analytics-read budget. `userId` is narrowed from the + * session. + */ +import { apiError, apiSuccess } from "@/lib/api-response"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { prisma } from "@/lib/db"; +import { summariseBreathing } from "@/lib/insights/breathing-screening"; + +export const dynamic = "force-dynamic"; + +const WINDOW_DAYS = 30; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + + const m = await requireModuleEnabled(user.id, "insights"); + if (!m.enabled) return m.response; + + const rl = await checkAnalyticsReadRateLimit(user.id); + if (!rl.allowed) { + return apiError("Too many analytics requests. Please retry later.", 429); + } + + const now = new Date(); + const since = new Date(now.getTime() - WINDOW_DAYS * 24 * 60 * 60 * 1000); + + const rows = await prisma.measurement.findMany({ + where: { + userId: user.id, + type: { in: ["BREATHING_DISTURBANCES", "BREATHING_DISTURBANCE_EVENT"] }, + measuredAt: { gte: since }, + }, + select: { type: true, value: true, measuredAt: true }, + orderBy: { measuredAt: "asc" }, + }); + + const indexRows = rows + .filter((r) => r.type === "BREATHING_DISTURBANCES") + .map((r) => ({ value: r.value, measuredAt: r.measuredAt })); + const eventRows = rows + .filter((r) => r.type === "BREATHING_DISTURBANCE_EVENT") + .map((r) => ({ value: r.value, measuredAt: r.measuredAt })); + + const summary = summariseBreathing(indexRows, eventRows); + + annotate({ + action: { name: "insights.breathing-screening.read" }, + meta: { + present: summary.present, + nights: summary.nights, + events: summary.eventCount, + classification: summary.classification ?? "none", + }, + }); + + return apiSuccess({ ...summary, generatedAt: now.toISOString() }); +}); diff --git a/src/app/api/insights/health-status/route.ts b/src/app/api/insights/health-status/route.ts new file mode 100644 index 000000000..0b179058a --- /dev/null +++ b/src/app/api/insights/health-status/route.ts @@ -0,0 +1,67 @@ +/** + * v1.25 — health-status / baseline-drift read. + * + * GET /api/insights/health-status + * + * Surfaces what is drifting from the user's own normal, drawn off two existing + * read-only engines — the personal-band deviations from the coincident- + * deviation flag (`coincident-deviation.ts`) and the dated, sustained level + * shifts from the changepoint detector (`changepoint.ts`). Pure compute over + * the rollup tier: no provider call, no fabricated value, never a diagnosis. + * + * Mirrors `/api/insights/derived`: `apiHandler` wrapper, `requireAuth`, the + * `insights` module gate, the shared analytics-read budget. `userId` is always + * narrowed from the session. The labels are mapped client-side from the + * MeasurementType, so the wire carries the type tokens only. + */ +import { apiError, apiSuccess } from "@/lib/api-response"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { prisma } from "@/lib/db"; +import { loadBaselineProfile } from "@/lib/insights/derived"; +import { computeCoincidentDeviation } from "@/lib/insights/derived/coincident-deviation"; +import { buildChangepointSignals } from "@/lib/insights/derived/changepoint"; +import { summariseHealthStatus } from "@/lib/insights/health-status"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + + const m = await requireModuleEnabled(user.id, "insights"); + if (!m.enabled) return m.response; + + const rl = await checkAnalyticsReadRateLimit(user.id); + if (!rl.allowed) { + return apiError("Too many analytics requests. Please retry later.", 429); + } + + const now = new Date(); + const profile = await loadBaselineProfile(prisma, user.id); + + const coincident = await computeCoincidentDeviation(user.id, profile, { + tz: user.timezone, + now, + }); + const shifts = await buildChangepointSignals(user.id, now); + + const vitals = coincident.status === "ok" ? coincident.value.vitals : []; + const summary = summariseHealthStatus(vitals, shifts); + + annotate({ + action: { name: "insights.health-status.read" }, + meta: { + deviations: summary.deviations.length, + shifts: summary.shifts.length, + }, + }); + + return apiSuccess({ + present: summary.present, + deviations: summary.deviations, + shifts: summary.shifts, + generatedAt: now.toISOString(), + }); +}); diff --git a/src/app/api/insights/labs-changes/route.ts b/src/app/api/insights/labs-changes/route.ts new file mode 100644 index 000000000..518fc417a --- /dev/null +++ b/src/app/api/insights/labs-changes/route.ts @@ -0,0 +1,67 @@ +/** + * v1.25 — "what changed since your last panel" read. + * + * GET /api/insights/labs-changes + * + * Groups the user's live numeric lab results by panel date, pairs the two + * most-recent panels, and reports the per-analyte delta + reference-band + * standing (`summariseLabChanges`). Absent when there are fewer than two + * panels or no analyte is shared. Neutral framing only — a delta is not a + * diagnosis. + * + * Mirrors `/api/insights/derived`: `apiHandler`, `requireAuth`, the `insights` + * module gate, the shared analytics-read budget. `userId` is narrowed from the + * session; soft-deleted rows (`deletedAt`) are filtered out. + */ +import { apiError, apiSuccess } from "@/lib/api-response"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { prisma } from "@/lib/db"; +import { summariseLabChanges } from "@/lib/insights/labs-changes"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + + const m = await requireModuleEnabled(user.id, "insights"); + if (!m.enabled) return m.response; + + const rl = await checkAnalyticsReadRateLimit(user.id); + if (!rl.allowed) { + return apiError("Too many analytics requests. Please retry later.", 429); + } + + const rows = await prisma.labResult.findMany({ + where: { userId: user.id, deletedAt: null, value: { not: null } }, + select: { + analyte: true, + unit: true, + value: true, + referenceLow: true, + referenceHigh: true, + takenAt: true, + }, + orderBy: { takenAt: "desc" }, + }); + + const summary = summariseLabChanges( + rows.map((r) => ({ + analyte: r.analyte, + unit: r.unit, + value: r.value as number, + referenceLow: r.referenceLow, + referenceHigh: r.referenceHigh, + takenAt: r.takenAt, + })), + ); + + annotate({ + action: { name: "insights.labs-changes.read" }, + meta: { present: summary.present, changes: summary.changes.length }, + }); + + return apiSuccess({ ...summary, generatedAt: new Date().toISOString() }); +}); diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index 000d31c73..846068a61 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -127,6 +127,42 @@ const RhythmEventsCard = dynamic( })), { ssr: false }, ); +// v1.25 — hydration daily-goal ring. Always present (a goal ring at 0 / goal is +// still useful); it owns its own today-total query and the goal editor. +const HydrationCard = dynamic( + () => + import("@/components/insights/hydration-card").then((mod) => ({ + default: mod.HydrationCard, + })), + { ssr: false }, +); +// v1.25 — baseline-drift card. Un-mounts when nothing is drifting; owns its own +// read-only `/api/insights/health-status` query. +const HealthStatusCard = dynamic( + () => + import("@/components/insights/health-status-card").then((mod) => ({ + default: mod.HealthStatusCard, + })), + { ssr: false }, +); +// v1.25 — sleep-breathing screening card. Un-mounts on no data; screening +// signal only, never a diagnosis. +const BreathingScreeningCard = dynamic( + () => + import("@/components/insights/breathing-screening-card").then((mod) => ({ + default: mod.BreathingScreeningCard, + })), + { ssr: false }, +); +// v1.25 — "what changed since your last lab panel" card. Un-mounts with fewer +// than two panels or no shared analyte. +const LabsChangesCard = dynamic( + () => + import("@/components/insights/labs-changes-card").then((mod) => ({ + default: mod.LabsChangesCard, + })), + { ssr: false }, +); // v1.10.3 — "Today's signal" headline card. Promotes COINCIDENT_DEVIATION from // a buried below-the-fold tile to the top-of-overview daily read (the // always-present Apple/WHOOP/Oura pattern). Deferred behind `next/dynamic`; it @@ -472,6 +508,10 @@ export default function InsightsPage() { ) : null, signals: , "rhythm-events": , + hydration: , + "health-status": , + breathing: , + "labs-changes": , }; const orderedSectionIds = orderedVisibleSectionIds(layout); diff --git a/src/components/insights/breathing-screening-card.tsx b/src/components/insights/breathing-screening-card.tsx new file mode 100644 index 000000000..0da2d22fc --- /dev/null +++ b/src/components/insights/breathing-screening-card.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { Wind } from "lucide-react"; + +import { useTranslations } from "@/lib/i18n/context"; +import { useAuth } from "@/hooks/use-auth"; +import { cn } from "@/lib/utils"; +import { apiGet } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; +import { SectionHeading } from "@/components/insights/section-heading"; +import type { + BreathingClassification, + BreathingTrend, +} from "@/lib/insights/breathing-screening"; + +/** + * v1.25 — sleep-breathing screening card. + * + * A calm read off the read-only `/api/insights/breathing-screening` route: the + * device's own classification, the recent index trend, and the device-flagged + * event count. It un-mounts when there is no data (`present === false`). Every + * render carries the mandatory screening-only disclaimer — this is a SCREENING + * SIGNAL, not a diagnosis. Neutral tone, no chart, no red. + */ + +interface BreathingScreeningResponse { + present: boolean; + nights: number; + recentMeanIndex: number | null; + trend: BreathingTrend; + eventCount: number; + classification: BreathingClassification; + generatedAt: string; +} + +interface BreathingScreeningCardProps { + enabled?: boolean; + className?: string; +} + +export function BreathingScreeningCard({ + enabled = true, + className, +}: BreathingScreeningCardProps) { + const { t } = useTranslations(); + const { isAuthenticated } = useAuth(); + + const { data } = useQuery({ + queryKey: queryKeys.insightsBreathingScreening(), + queryFn: () => + apiGet("/api/insights/breathing-screening"), + enabled: enabled && isAuthenticated, + staleTime: 60_000, + }); + + if (!data || data.present === false) return null; + + const classificationLabel = + data.classification === "elevated" + ? t("insights.breathingScreening.classificationElevated") + : data.classification === "not-elevated" + ? t("insights.breathingScreening.classificationNotElevated") + : null; + + const trendLabel = + data.trend === "up" + ? t("insights.breathingScreening.trendUp") + : data.trend === "down" + ? t("insights.breathingScreening.trendDown") + : data.trend === "stable" + ? t("insights.breathingScreening.trendStable") + : null; + + return ( +
+ +
+ {classificationLabel ? ( +

+ {classificationLabel} +

+ ) : null} + +

+ {t("insights.breathingScreening.nights", { count: data.nights })} + {data.recentMeanIndex != null + ? ` · ${t("insights.breathingScreening.indexLine", { + value: data.recentMeanIndex, + })}` + : ""} +

+ + {trendLabel ? ( +

+ {trendLabel} +

+ ) : null} + + {data.eventCount > 0 ? ( +

+ {t("insights.breathingScreening.eventsFlagged", { + count: data.eventCount, + })} +

+ ) : null} + +

+ {t("insights.breathingScreening.disclaimer")} +

+
+
+ ); +} diff --git a/src/components/insights/health-status-card.tsx b/src/components/insights/health-status-card.tsx new file mode 100644 index 000000000..4318ec413 --- /dev/null +++ b/src/components/insights/health-status-card.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { Activity, ArrowDownRight, ArrowUpRight } from "lucide-react"; + +import { useTranslations } from "@/lib/i18n/context"; +import { useAuth } from "@/hooks/use-auth"; +import { cn } from "@/lib/utils"; +import { apiGet } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; +import { SectionHeading } from "@/components/insights/section-heading"; +import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta"; +import type { + HealthStatusDeviation, + HealthStatusShift, +} from "@/lib/insights/health-status"; + +/** + * v1.25 — baseline-drift card. + * + * A calm read of what is drifting from the user's own normal, drawn off the + * read-only `/api/insights/health-status` route (which folds the personal-band + * deviations from the coincident engine with the dated changepoint shifts). The + * card un-mounts entirely when nothing is drifting (`present === false`) rather + * than surfacing an empty header. Awareness framing only — at most a neutral + * tone, never a red alert, never a diagnosis. + */ + +interface HealthStatusResponse { + present: boolean; + deviations: HealthStatusDeviation[]; + shifts: HealthStatusShift[]; + generatedAt: string; +} + +interface HealthStatusCardProps { + enabled?: boolean; + className?: string; +} + +export function HealthStatusCard({ + enabled = true, + className, +}: HealthStatusCardProps) { + const { t } = useTranslations(); + const { isAuthenticated } = useAuth(); + + const { data } = useQuery({ + queryKey: queryKeys.insightsHealthStatus(), + queryFn: () => apiGet("/api/insights/health-status"), + enabled: enabled && isAuthenticated, + staleTime: 60_000, + }); + + if (!data || data.present === false) return null; + + const vitalName = (type: string): string => { + const key = MEASUREMENT_TYPE_LABEL_KEYS[type]; + return key ? t(key) : type; + }; + + return ( +
+ +
+ {data.deviations.map((d) => ( +
+ {d.direction === "above" ? ( +
+ ))} + + {data.shifts.map((s) => ( +
+ {s.direction === "up" ? ( +
+ ))} +
+
+ ); +} diff --git a/src/components/insights/insights-edit-mode.tsx b/src/components/insights/insights-edit-mode.tsx index 7ac41f6b1..bf64ceea6 100644 --- a/src/components/insights/insights-edit-mode.tsx +++ b/src/components/insights/insights-edit-mode.tsx @@ -63,6 +63,10 @@ const SECTION_TITLE_KEYS: Record = { "cycle-summary": "cycle.insightsSummary.title", signals: "insights.derived.coincident.cardTitle", "rhythm-events": "insights.rhythmEvents.sectionTitle", + hydration: "hydration.cardTitle", + "health-status": "insights.healthStatus.sectionTitle", + breathing: "insights.breathingScreening.sectionTitle", + "labs-changes": "insights.labsChanges.sectionTitle", }; interface InsightsEditModeProps { diff --git a/src/components/insights/labs-changes-card.tsx b/src/components/insights/labs-changes-card.tsx new file mode 100644 index 000000000..386924758 --- /dev/null +++ b/src/components/insights/labs-changes-card.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { FlaskConical } from "lucide-react"; + +import { useTranslations } from "@/lib/i18n/context"; +import { useAuth } from "@/hooks/use-auth"; +import { cn } from "@/lib/utils"; +import { apiGet } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; +import { SectionHeading } from "@/components/insights/section-heading"; +import type { LabChange } from "@/lib/insights/labs-changes"; + +/** + * v1.25 — "what changed since your last panel" card. + * + * A neutral read off the read-only `/api/insights/labs-changes` route: for each + * analyte present in both the latest and the previous panel, the latest value, + * the signed delta, and where the latest value sits against its reference band. + * It un-mounts when there are fewer than two panels or no shared analyte + * (`present === false`). Never a diagnosis — neutral framing only. + */ + +interface LabsChangesResponse { + present: boolean; + latestDate: string | null; + previousDate: string | null; + changes: LabChange[]; + generatedAt: string; +} + +interface LabsChangesCardProps { + enabled?: boolean; + className?: string; +} + +function statusLabelKey(status: LabChange["status"]): string | null { + switch (status) { + case "in-range": + return "insights.labsChanges.statusInRange"; + case "below": + return "insights.labsChanges.statusBelow"; + case "above": + return "insights.labsChanges.statusAbove"; + default: + return null; + } +} + +export function LabsChangesCard({ + enabled = true, + className, +}: LabsChangesCardProps) { + const { t } = useTranslations(); + const { isAuthenticated } = useAuth(); + + const { data } = useQuery({ + queryKey: queryKeys.insightsLabsChanges(), + queryFn: () => apiGet("/api/insights/labs-changes"), + enabled: enabled && isAuthenticated, + staleTime: 60_000, + }); + + if (!data || data.present === false) return null; + + return ( +
+ +
+ {data.changes.map((c) => { + const labelKey = statusLabelKey(c.status); + const signedDelta = + c.direction === "flat" + ? t("insights.labsChanges.directionFlat") + : `${c.delta > 0 ? "+" : ""}${c.delta} ${c.unit}`.trim(); + return ( +
+
+

+ {c.analyte} +

+

+ {`${c.latest} ${c.unit}`.trim()} + {labelKey ? ` · ${t(labelKey)}` : ""} +

+
+

+ {signedDelta} +

+
+ ); + })} +
+
+ ); +} diff --git a/src/lib/__tests__/insights-layout.test.ts b/src/lib/__tests__/insights-layout.test.ts index b637a385f..0d216bed3 100644 --- a/src/lib/__tests__/insights-layout.test.ts +++ b/src/lib/__tests__/insights-layout.test.ts @@ -20,7 +20,7 @@ import { } from "@/lib/insights-layout"; describe("insights-layout v2 — section id universe", () => { - it("carries exactly the eight default sections in render order", () => { + it("carries exactly the default sections in render order", () => { expect([...INSIGHTS_SECTION_IDS]).toEqual([ "wellness-scores", "daily-briefing", @@ -30,6 +30,10 @@ describe("insights-layout v2 — section id universe", () => { "cycle-summary", "signals", "rhythm-events", + "hydration", + "health-status", + "breathing", + "labs-changes", ]); }); diff --git a/src/lib/insights-layout.ts b/src/lib/insights-layout.ts index 7b2d3367a..f3bff31be 100644 --- a/src/lib/insights-layout.ts +++ b/src/lib/insights-layout.ts @@ -113,6 +113,10 @@ export const INSIGHTS_SECTION_IDS = [ "cycle-summary", "signals", "rhythm-events", + "hydration", + "health-status", + "breathing", + "labs-changes", ] as const; export type InsightsSectionId = (typeof INSIGHTS_SECTION_IDS)[number]; diff --git a/src/lib/insights/__tests__/breathing-screening.test.ts b/src/lib/insights/__tests__/breathing-screening.test.ts new file mode 100644 index 000000000..952ae8fbd --- /dev/null +++ b/src/lib/insights/__tests__/breathing-screening.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from "vitest"; + +import { + summariseBreathing, + type BreathingRow, +} from "@/lib/insights/breathing-screening"; + +function night(daysAgo: number, value: number): BreathingRow { + return { + value, + measuredAt: new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000), + }; +} + +describe("summariseBreathing", () => { + it("is absent with no rows at all", () => { + const s = summariseBreathing([], []); + expect(s.present).toBe(false); + expect(s.nights).toBe(0); + expect(s.classification).toBeNull(); + expect(s.trend).toBeNull(); + }); + + it("classifies not-elevated with index nights and no events", () => { + const s = summariseBreathing([night(2, 3), night(1, 4)], []); + expect(s.present).toBe(true); + expect(s.nights).toBe(2); + expect(s.recentMeanIndex).toBe(3.5); + expect(s.classification).toBe("not-elevated"); + }); + + it("classifies elevated when the device flagged events", () => { + const s = summariseBreathing([night(1, 5)], [night(1, 1)]); + expect(s.present).toBe(true); + expect(s.eventCount).toBe(1); + expect(s.classification).toBe("elevated"); + }); + + it("computes an upward trend over enough nights", () => { + const rows = [ + night(8, 2), + night(7, 2), + night(6, 2), + night(5, 8), + night(4, 8), + night(3, 8), + night(2, 8), + night(1, 8), + ]; + const s = summariseBreathing(rows, []); + expect(s.trend).toBe("up"); + }); + + it("leaves the trend null with too few nights", () => { + const s = summariseBreathing([night(2, 4), night(1, 5)], []); + expect(s.trend).toBeNull(); + }); +}); diff --git a/src/lib/insights/__tests__/health-status.test.ts b/src/lib/insights/__tests__/health-status.test.ts new file mode 100644 index 000000000..f0750a2bc --- /dev/null +++ b/src/lib/insights/__tests__/health-status.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from "vitest"; + +import { summariseHealthStatus } from "@/lib/insights/health-status"; +import type { VitalDeviation } from "@/lib/insights/derived/coincident-deviation"; +import type { ChangepointSignal } from "@/lib/insights/derived/changepoint"; + +function vital( + outside: boolean, + direction: VitalDeviation["direction"], +): VitalDeviation { + return { + type: "RESTING_HEART_RATE", + value: 70, + center: 60, + low: 55, + high: 65, + outside, + direction, + }; +} + +function shift(): ChangepointSignal { + return { + metric: "WEIGHT", + breakDate: "2026-06-10", + beforeMean: 80, + afterMean: 82, + direction: "up", + magnitude: 1.6, + }; +} + +describe("summariseHealthStatus", () => { + it("is absent when nothing is outside and no shift fired", () => { + const s = summariseHealthStatus([vital(false, "in")], []); + expect(s.present).toBe(false); + expect(s.deviations).toHaveLength(0); + expect(s.shifts).toHaveLength(0); + }); + + it("surfaces an out-of-band deviation", () => { + const s = summariseHealthStatus([vital(true, "above")], []); + expect(s.present).toBe(true); + expect(s.deviations).toHaveLength(1); + expect(s.deviations[0]).toMatchObject({ + type: "RESTING_HEART_RATE", + direction: "above", + }); + }); + + it("surfaces a changepoint shift even with no deviation", () => { + const s = summariseHealthStatus([vital(false, "in")], [shift()]); + expect(s.present).toBe(true); + expect(s.shifts).toHaveLength(1); + expect(s.shifts[0]).toMatchObject({ metric: "WEIGHT", direction: "up" }); + }); + + it("drops a vital flagged outside but with an 'in' direction", () => { + const s = summariseHealthStatus([vital(true, "in")], []); + expect(s.present).toBe(false); + }); +}); diff --git a/src/lib/insights/__tests__/labs-changes.test.ts b/src/lib/insights/__tests__/labs-changes.test.ts new file mode 100644 index 000000000..a6d020465 --- /dev/null +++ b/src/lib/insights/__tests__/labs-changes.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from "vitest"; + +import { + summariseLabChanges, + type LabChangeRow, +} from "@/lib/insights/labs-changes"; + +function row( + analyte: string, + value: number, + day: string, + opts: { unit?: string; low?: number | null; high?: number | null } = {}, +): LabChangeRow { + return { + analyte, + unit: opts.unit ?? "mg/dL", + value, + referenceLow: opts.low ?? null, + referenceHigh: opts.high ?? null, + takenAt: new Date(`${day}T08:00:00.000Z`), + }; +} + +describe("summariseLabChanges", () => { + it("is absent with no rows", () => { + expect(summariseLabChanges([])).toMatchObject({ present: false }); + }); + + it("is absent with a single panel", () => { + const s = summariseLabChanges([row("LDL", 130, "2026-06-01")]); + expect(s.present).toBe(false); + }); + + it("is absent when no analyte is shared across panels", () => { + const s = summariseLabChanges([ + row("LDL", 130, "2026-06-01"), + row("HbA1c", 5.4, "2026-05-01"), + ]); + expect(s.present).toBe(false); + }); + + it("pairs the two most-recent panels for a shared analyte", () => { + const s = summariseLabChanges([ + row("LDL", 120, "2026-06-01", { high: 116 }), + row("LDL", 140, "2026-05-01"), + row("LDL", 150, "2026-04-01"), + ]); + expect(s.present).toBe(true); + expect(s.latestDate).toBe("2026-06-01"); + expect(s.previousDate).toBe("2026-05-01"); + expect(s.changes).toHaveLength(1); + const c = s.changes[0]; + expect(c.latest).toBe(120); + expect(c.previous).toBe(140); + expect(c.delta).toBe(-20); + expect(c.direction).toBe("down"); + expect(c.status).toBe("above"); + }); + + it("skips qualitative (non-finite) values", () => { + const s = summariseLabChanges([ + row("LDL", Number.NaN, "2026-06-01"), + row("LDL", 140, "2026-05-01"), + ]); + expect(s.present).toBe(false); + }); +}); diff --git a/src/lib/insights/breathing-screening.ts b/src/lib/insights/breathing-screening.ts new file mode 100644 index 000000000..94b294cb4 --- /dev/null +++ b/src/lib/insights/breathing-screening.ts @@ -0,0 +1,122 @@ +/** + * v1.25 — breathing-disturbance screening shaping. + * + * Reads the per-night sleep-breathing-disturbance index + * (`BREATHING_DISTURBANCES`, lower-better) and the device-flagged + * breathing-disturbance / possible-apnea events (`BREATHING_DISTURBANCE_EVENT`) + * a sync writes, and folds them into a calm awareness summary: how many nights + * are covered, the recent mean index, the short-window trend, and the device's + * own classification. + * + * This is a SCREENING SIGNAL ONLY — never a HealthLog diagnosis. The card built + * on top of this states that explicitly. The classification mirrors the device: + * a night the device flagged with one or more elevated-breathing events reads + * as "elevated"; with index nights and no flagged event it reads as + * "not-elevated". HealthLog invents no numeric threshold of its own. + * + * The math lives here, free of Prisma, so the present / absent / trend states + * are unit-testable in isolation. + */ + +/** A single per-night reading (index) or a device-flagged event row. */ +export interface BreathingRow { + value: number; + measuredAt: Date; +} + +export type BreathingTrend = "up" | "down" | "stable" | null; +export type BreathingClassification = "not-elevated" | "elevated" | null; + +export interface BreathingScreeningSummary { + /** True when there is any index night or any flagged event to surface. */ + present: boolean; + /** Number of nights with a per-night index reading. */ + nights: number; + /** Mean of the index readings (lower-better), rounded to 1dp; null when none. */ + recentMeanIndex: number | null; + /** + * Direction of the recent index vs the prior window. "up" is worse (more + * disturbance). Null when there are too few nights to compare. + */ + trend: BreathingTrend; + /** Count of device-flagged breathing-disturbance / apnea events. */ + eventCount: number; + /** + * The device's own classification: "elevated" when it flagged events, + * "not-elevated" when there are index nights and no flagged event, null when + * there is no data at all. + */ + classification: BreathingClassification; +} + +/** Window (most-recent nights) the trend compares against the prior block. */ +const TREND_WINDOW_NIGHTS = 7; +/** Need at least this many nights total before a trend is meaningful. */ +const MIN_TREND_NIGHTS = 6; +/** Relative band within which the recent mean reads as "stable". */ +const STABLE_BAND = 0.1; + +function mean(xs: readonly number[]): number { + if (xs.length === 0) return 0; + let sum = 0; + for (const x of xs) sum += x; + return sum / xs.length; +} + +const round1 = (n: number): number => Math.round(n * 10) / 10; + +/** + * Summarise the breathing-screening signal. `indexRows` are the per-night + * index readings; `eventRows` are the device-flagged events. Order-independent + * — the helper sorts by `measuredAt` for the trend. + */ +export function summariseBreathing( + indexRows: readonly BreathingRow[], + eventRows: readonly BreathingRow[], +): BreathingScreeningSummary { + const sorted = [...indexRows] + .filter((r) => Number.isFinite(r.value)) + .sort((a, b) => a.measuredAt.getTime() - b.measuredAt.getTime()); + + const nights = sorted.length; + const eventCount = eventRows.length; + + if (nights === 0 && eventCount === 0) { + return { + present: false, + nights: 0, + recentMeanIndex: null, + trend: null, + eventCount: 0, + classification: null, + }; + } + + const values = sorted.map((r) => r.value); + const recentMeanIndex = nights > 0 ? round1(mean(values)) : null; + + let trend: BreathingTrend = null; + if (nights >= MIN_TREND_NIGHTS) { + const recent = values.slice(-TREND_WINDOW_NIGHTS); + const prior = values.slice(0, -TREND_WINDOW_NIGHTS); + if (recent.length > 0 && prior.length > 0) { + const recentMean = mean(recent); + const priorMean = mean(prior); + const band = Math.abs(priorMean) * STABLE_BAND; + const delta = recentMean - priorMean; + trend = Math.abs(delta) <= band ? "stable" : delta > 0 ? "up" : "down"; + } + } + + const classification: BreathingClassification = + eventCount > 0 ? "elevated" : nights > 0 ? "not-elevated" : null; + + return { + present: true, + nights, + recentMeanIndex, + trend, + eventCount, + classification, + }; +} diff --git a/src/lib/insights/health-status.ts b/src/lib/insights/health-status.ts new file mode 100644 index 000000000..419f7dbc1 --- /dev/null +++ b/src/lib/insights/health-status.ts @@ -0,0 +1,84 @@ +/** + * v1.25 — health-status / baseline-drift shaping. + * + * Surfaces what is drifting from the user's own normal by combining two + * existing read-only engines: + * - the personal-band deviations from the coincident-deviation flag + * (`coincident-deviation.ts`, which fans `VITALS_BASELINE` across vitals), + * keeping only the vitals that sit OUTSIDE their band today, and + * - the dated, sustained level shifts from the changepoint detector + * (`changepoint.ts`). + * + * This module owns only the pure reshaping — taking the engine outputs and + * folding them into the flat response the card reads — so the present / absent + * states are unit-testable without Prisma. It never labels a cause and never + * diagnoses; it lists what changed and frames it as awareness only. + */ +import type { MeasurementType } from "@/generated/prisma/client"; +import type { VitalDeviation } from "@/lib/insights/derived/coincident-deviation"; +import type { ChangepointSignal } from "@/lib/insights/derived/changepoint"; + +/** One vital sitting outside its personal band today. */ +export interface HealthStatusDeviation { + type: MeasurementType; + /** Today's value. */ + value: number; + /** Band center (median). */ + center: number; + low: number; + high: number; + /** Which side of the band the value falls on. */ + direction: "above" | "below"; +} + +/** One dated, sustained level shift from the changepoint detector. */ +export interface HealthStatusShift { + metric: MeasurementType; + /** YYYY-MM-DD of the first day of the new level. */ + breakDate: string; + beforeMean: number; + afterMean: number; + direction: "up" | "down"; +} + +export interface HealthStatusSummary { + /** True when there is at least one deviation or one shift to surface. */ + present: boolean; + deviations: HealthStatusDeviation[]; + shifts: HealthStatusShift[]; +} + +/** + * Fold the band deviations + level shifts into the response shape. `vitals` is + * the full banded set from the coincident engine (any standing); only the + * out-of-band ones become deviations. `shifts` is the changepoint output as-is. + */ +export function summariseHealthStatus( + vitals: readonly VitalDeviation[], + shifts: readonly ChangepointSignal[], +): HealthStatusSummary { + const deviations: HealthStatusDeviation[] = vitals + .filter((v) => v.outside && v.direction !== "in") + .map((v) => ({ + type: v.type, + value: v.value, + center: v.center, + low: v.low, + high: v.high, + direction: v.direction === "above" ? "above" : "below", + })); + + const mappedShifts: HealthStatusShift[] = shifts.map((s) => ({ + metric: s.metric, + breakDate: s.breakDate, + beforeMean: s.beforeMean, + afterMean: s.afterMean, + direction: s.direction, + })); + + return { + present: deviations.length + mappedShifts.length > 0, + deviations, + shifts: mappedShifts, + }; +} diff --git a/src/lib/insights/labs-changes.ts b/src/lib/insights/labs-changes.ts new file mode 100644 index 000000000..19887667c --- /dev/null +++ b/src/lib/insights/labs-changes.ts @@ -0,0 +1,131 @@ +/** + * v1.25 — "what changed since your last panel" shaping. + * + * Groups the user's numeric lab results by panel DATE (the calendar day of + * `takenAt`, UTC), finds the two most-recent dates, and for every analyte that + * appears in BOTH the latest and the previous panel reports the signed delta, + * the direction, and where the latest value sits against its reference band + * (`classifyReferenceRange`). Qualitative (valueText-only) results carry no + * numeric value, so they are skipped — a delta is meaningless for them. + * + * Pure + Prisma-free so the present / absent states are unit-testable: it is + * absent when there are fewer than two panel dates or no analyte is shared. + * Neutral framing only — a delta is not a diagnosis. + */ +import { + classifyReferenceRange, + type ReferenceRangeStatus, +} from "@/lib/labs/reference-range"; + +/** A single numeric lab reading. */ +export interface LabChangeRow { + analyte: string; + unit: string; + value: number; + referenceLow: number | null; + referenceHigh: number | null; + takenAt: Date; +} + +export interface LabChange { + analyte: string; + unit: string; + latest: number; + previous: number; + /** Signed latest − previous, rounded to 2dp. */ + delta: number; + direction: "up" | "down" | "flat"; + status: ReferenceRangeStatus; +} + +export interface LabChangesSummary { + present: boolean; + /** YYYY-MM-DD of the most-recent panel, or null when absent. */ + latestDate: string | null; + /** YYYY-MM-DD of the prior panel, or null when absent. */ + previousDate: string | null; + changes: LabChange[]; +} + +const ABSENT: LabChangesSummary = { + present: false, + latestDate: null, + previousDate: null, + changes: [], +}; + +/** UTC calendar-day key (YYYY-MM-DD) for a sample instant. */ +function dayKey(d: Date): string { + return d.toISOString().slice(0, 10); +} + +/** Normalised analyte name for cross-panel matching (free-text tolerant). */ +function analyteKey(analyte: string): string { + return analyte.trim().toLowerCase(); +} + +const round2 = (n: number): number => Math.round(n * 100) / 100; + +/** + * Summarise the change between the two most-recent numeric lab panels. `rows` + * should already be the user's live (non-deleted) numeric results; this helper + * does the grouping + pairing. + */ +export function summariseLabChanges( + rows: readonly LabChangeRow[], +): LabChangesSummary { + const numeric = rows.filter((r) => Number.isFinite(r.value)); + if (numeric.length === 0) return ABSENT; + + // Group by panel day, keeping the latest reading per analyte within a day. + const byDay = new Map>(); + for (const row of numeric) { + const day = dayKey(row.takenAt); + let analytes = byDay.get(day); + if (!analytes) { + analytes = new Map(); + byDay.set(day, analytes); + } + const key = analyteKey(row.analyte); + const existing = analytes.get(key); + if (!existing || row.takenAt.getTime() >= existing.takenAt.getTime()) { + analytes.set(key, row); + } + } + + const days = [...byDay.keys()].sort((a, b) => (a < b ? 1 : a > b ? -1 : 0)); + if (days.length < 2) return ABSENT; + + const latestDate = days[0]; + const previousDate = days[1]; + const latest = byDay.get(latestDate)!; + const previous = byDay.get(previousDate)!; + + const changes: LabChange[] = []; + for (const [key, latestRow] of latest) { + const previousRow = previous.get(key); + if (!previousRow) continue; + const delta = round2(latestRow.value - previousRow.value); + const direction: LabChange["direction"] = + delta > 0 ? "up" : delta < 0 ? "down" : "flat"; + changes.push({ + analyte: latestRow.analyte, + unit: latestRow.unit, + latest: latestRow.value, + previous: previousRow.value, + delta, + direction, + status: classifyReferenceRange( + latestRow.value, + latestRow.referenceLow, + latestRow.referenceHigh, + ), + }); + } + + if (changes.length === 0) return ABSENT; + + changes.sort((a, b) => a.analyte.localeCompare(b.analyte)); + + return { present: true, latestDate, previousDate, changes }; +} diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index a57314ece..0df9ab23c 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -36,9 +36,11 @@ import { biomarkerPaths } from "./biomarkers"; import { devicePaths } from "./devices"; import { exportPaths } from "./export"; import { healthRecordPaths } from "./health-record"; +import { hydrationPaths } from "./hydration"; import { illnessPaths } from "./illness"; import { importPaths } from "./import"; import { insightsPaths } from "./insights"; +import { insightsSignalPaths } from "./insights-signals"; import { labsPaths } from "./labs"; import { ocrPaths } from "./ocr"; import { measurementPaths } from "./measurements"; @@ -72,6 +74,7 @@ export const openApiPaths: NonNullable = { ...coachFeedbackPaths, ...adminDiagnosticPaths, ...insightsPaths, + ...insightsSignalPaths, ...moodPaths, ...settingsPaths, ...consentPaths, @@ -82,6 +85,7 @@ export const openApiPaths: NonNullable = { ...biomarkerPaths, ...illnessPaths, ...onboardingPaths, + ...hydrationPaths, }; export const openApiComponents: NonNullable = { diff --git a/src/lib/openapi/routes/insights-signals.ts b/src/lib/openapi/routes/insights-signals.ts new file mode 100644 index 000000000..d52b8cfaa --- /dev/null +++ b/src/lib/openapi/routes/insights-signals.ts @@ -0,0 +1,177 @@ +/** + * OpenAPI route module — v1.25 read-only insight signals. + * + * Three calm awareness reads built on existing engines: + * - GET /api/insights/health-status baseline-drift (bands + changepoints) + * - GET /api/insights/breathing-screening sleep-breathing screening signal + * - GET /api/insights/labs-changes "what changed since your last panel" + * + * Each is pure compute over the rollup / lab tier — no provider call. Labels + * are resolved client-side from the type tokens, so the wire carries tokens + * only. Part of the OpenAPI route table; aggregated in `./index.ts`. + */ +import { z } from "zod/v4"; +import type { ZodOpenApiObject } from "zod-openapi"; + +import { dataEnvelope, stdResponses } from "./shared"; + +const healthStatusResponse = z + .object({ + present: z + .boolean() + .describe("True when at least one deviation or shift is surfaced."), + deviations: z.array( + z.object({ + type: z.string().describe("MeasurementType token."), + value: z.number().describe("Today's value."), + center: z.number().describe("Personal band center (median)."), + low: z.number(), + high: z.number(), + direction: z.enum(["above", "below"]), + }), + ), + shifts: z.array( + z.object({ + metric: z.string().describe("MeasurementType token."), + breakDate: z + .string() + .describe("YYYY-MM-DD of the new level's first day."), + beforeMean: z.number(), + afterMean: z.number(), + direction: z.enum(["up", "down"]), + }), + ), + generatedAt: z.string().describe("ISO-8601 instant the read was computed."), + }) + .meta({ + id: "InsightsHealthStatus", + description: + "Vitals drifting from the user's personal normal — band deviations plus dated, sustained level shifts. Awareness only, never a diagnosis.", + }); + +const breathingScreeningResponse = z + .object({ + present: z.boolean(), + nights: z.number().int().describe("Nights with a per-night index reading."), + recentMeanIndex: z + .number() + .nullable() + .describe("Mean of the index readings (lower-better); null when none."), + trend: z + .enum(["up", "down", "stable"]) + .nullable() + .describe("Recent index vs the prior window; null when too few nights."), + eventCount: z + .number() + .int() + .describe("Device-flagged breathing-disturbance / apnea events."), + classification: z + .enum(["not-elevated", "elevated"]) + .nullable() + .describe("The device's own classification; null when no data."), + generatedAt: z.string(), + }) + .meta({ + id: "InsightsBreathingScreening", + description: + "Sleep-breathing-disturbance screening signal — a screening signal only, never a diagnosis.", + }); + +const labsChangesResponse = z + .object({ + present: z.boolean(), + latestDate: z + .string() + .nullable() + .describe("YYYY-MM-DD of the most-recent panel."), + previousDate: z + .string() + .nullable() + .describe("YYYY-MM-DD of the prior panel."), + changes: z.array( + z.object({ + analyte: z.string(), + unit: z.string(), + latest: z.number(), + previous: z.number(), + delta: z.number().describe("Signed latest − previous."), + direction: z.enum(["up", "down", "flat"]), + status: z.enum(["in-range", "below", "above", "unknown"]), + }), + ), + generatedAt: z.string(), + }) + .meta({ + id: "InsightsLabsChanges", + description: + "Per-analyte change between the two most-recent numeric lab panels. Neutral framing, never a diagnosis.", + }); + +export const insightsSignalPaths: NonNullable = { + "/api/insights/health-status": { + get: { + tags: ["Insights"], + summary: "Baseline-drift health status", + description: + "Surfaces vitals drifting from the user's personal normal — out-of-band deviations from the personal-baseline engine plus dated, sustained level shifts from the changepoint detector. Pure compute over the rollup tier; awareness only, never a diagnosis. Requires the insights module. Cookie or Bearer auth.", + responses: { + "200": { + description: "The drift summary.", + content: { + "application/json": { + schema: dataEnvelope( + healthStatusResponse, + "InsightsHealthStatusEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/insights/breathing-screening": { + get: { + tags: ["Insights"], + summary: "Sleep-breathing screening signal", + description: + "Summarises the last ~30 nights of the per-night sleep-breathing-disturbance index plus device-flagged events into a calm awareness read. A screening signal only — never a HealthLog diagnosis. Requires the insights module. Cookie or Bearer auth.", + responses: { + "200": { + description: "The breathing-screening summary.", + content: { + "application/json": { + schema: dataEnvelope( + breathingScreeningResponse, + "InsightsBreathingScreeningEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/insights/labs-changes": { + get: { + tags: ["Insights"], + summary: "What changed since your last lab panel", + description: + "Pairs the two most-recent numeric lab panels and reports each shared analyte's signed delta plus its reference-band standing. Absent when there are fewer than two panels or no analyte is shared. Neutral framing, never a diagnosis. Cookie or Bearer auth.", + responses: { + "200": { + description: "The lab-change summary.", + content: { + "application/json": { + schema: dataEnvelope( + labsChangesResponse, + "InsightsLabsChangesEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, +}; diff --git a/src/lib/query-keys/insights.ts b/src/lib/query-keys/insights.ts index 0a3607438..5ce80bdb2 100644 --- a/src/lib/query-keys/insights.ts +++ b/src/lib/query-keys/insights.ts @@ -137,4 +137,29 @@ export const insightsKeys = { */ insightsCoachRead: (metric: string) => ["insights", "coach-read", metric] as const, + + /** + * v1.25 — baseline-drift read (`/api/insights/health-status`). Combines the + * personal-band deviations + the dated changepoint shifts into one awareness + * card. Pure compute over the rollup tier; the `["insights"]` prefix keeps it + * in the standard invalidation fan-out (a measurement write busts it). + */ + insightsHealthStatus: () => ["insights", "health-status"] as const, + + /** + * v1.25 — breathing-disturbance screening read + * (`/api/insights/breathing-screening`). Last ~30 nights of the per-night + * index + device-flagged events. The `["insights"]` prefix carries the + * invalidation fan-out from an Apple Health sleep batch write. + */ + insightsBreathingScreening: () => + ["insights", "breathing-screening"] as const, + + /** + * v1.25 — "what changed since your last panel" read + * (`/api/insights/labs-changes`). The two most-recent numeric lab panels' + * shared-analyte deltas. The `["insights"]` prefix keeps it in the standard + * invalidation fan-out. + */ + insightsLabsChanges: () => ["insights", "labs-changes"] as const, }; From c371310fc0d4fdd9cf208b797f1b143ee9c465d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 03:27:46 +0200 Subject: [PATCH 10/94] fix(integrations): anchor Oura and Polar daily records at local noon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Date-only daily scalars from Oura and Polar were stored at UTC midnight (`${day}T00:00:00.000Z`). On read these rows re-bucket through `userDayKey(measuredAt, userTz)`, so for any west-of-UTC user the day shifted back one: a June 1 SpO2 or readiness row filed under May 31 and fell out of alignment with the HR/sleep rows that carry true instants. Extend the existing `canonicalDailyTimestamp` helper to accept an optional timezone and anchor a date-only day at local noon (noon UTC when no zone is supplied) — a full twelve hours inside the calendar day, so it round-trips through `userDayKey` to the same day for every zone within plus/minus twelve hours. Route every Oura day-keyed mapper (via the shared `dayAnchor` fallback) and all five Polar daily mappers through it; real wake/measurement instants stay untouched. Fitbit already anchored at noon and is now consistent; Whoop and the Withings cursor anchors were verified not to be measurement anchors. --- .../canonical-daily-timestamp.test.ts | 76 +++++++++++++++++++ src/lib/measurements/consolidation-tz.ts | 28 +++++-- src/lib/oura/__tests__/client.test.ts | 6 +- src/lib/oura/client.ts | 15 +++- src/lib/polar/__tests__/client.test.ts | 6 +- src/lib/polar/client.ts | 15 ++-- 6 files changed, 124 insertions(+), 22 deletions(-) create mode 100644 src/lib/measurements/__tests__/canonical-daily-timestamp.test.ts diff --git a/src/lib/measurements/__tests__/canonical-daily-timestamp.test.ts b/src/lib/measurements/__tests__/canonical-daily-timestamp.test.ts new file mode 100644 index 000000000..5ba803cf7 --- /dev/null +++ b/src/lib/measurements/__tests__/canonical-daily-timestamp.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from "vitest"; + +import { canonicalDailyTimestamp } from "@/lib/measurements/consolidation-tz"; +import { userDayKey } from "@/lib/tz/format"; + +/** + * Wearable daily-record anchoring (H-TZ1 / H-TZ2). Day-grained (date-only) + * daily records from Oura / Polar / Fitbit must anchor at NOON, not UTC + * midnight: a UTC-midnight anchor (`${day}T00:00:00.000Z`) double-shifts the + * calendar day for west-of-UTC users on read, because the read path + * re-buckets via `userDayKey(measuredAt, userTz)`. A noon anchor sits a full + * 12 h inside the day, so it round-trips back to the same calendar day for + * every IANA zone within ±12 h of UTC. + * + * These tests pin that invariant directly: + * userDayKey(canonicalDailyTimestamp(day, tz), tz) === day + */ +describe("canonicalDailyTimestamp — day round-trip across TZ offsets", () => { + const days = [ + "2026-06-01", + "2026-01-01", + "2026-12-31", + "2025-03-09", // US spring-forward + "2025-11-02", // US fall-back + ]; + + const zones = [ + "UTC", + "America/Los_Angeles", // negative offset (UTC-7/-8) + "America/New_York", // negative offset (UTC-4/-5) + "Asia/Tokyo", // positive offset (UTC+9, no DST) + "Pacific/Kiritimati", // extreme positive (UTC+14) + "Etc/GMT+12", // extreme negative (UTC-12) + "Asia/Kathmandu", // sub-hour offset (UTC+5:45) + ]; + + for (const tz of zones) { + for (const day of days) { + it(`round-trips ${day} in ${tz}`, () => { + const anchor = canonicalDailyTimestamp(day, tz); + expect(userDayKey(anchor, tz)).toBe(day); + }); + } + } + + it("anchors at the user's local noon when a tz is given", () => { + // 2026-06-01 noon in America/Los_Angeles (PDT, UTC-7) === 19:00 UTC. + const anchor = canonicalDailyTimestamp("2026-06-01", "America/Los_Angeles"); + expect(anchor.toISOString()).toBe("2026-06-01T19:00:00.000Z"); + }); +}); + +describe("canonicalDailyTimestamp — noon-UTC fallback (no tz)", () => { + it("anchors at 12:00 UTC when tz is omitted", () => { + const anchor = canonicalDailyTimestamp("2026-06-01"); + expect(anchor.toISOString()).toBe("2026-06-01T12:00:00.000Z"); + }); + + it("the noon-UTC fallback round-trips for every zone within ±12 h", () => { + // This is the shape the wearable mappers (Oura/Polar/Fitbit) take, since + // they do not have the user's timezone in scope at map time. + const day = "2026-06-01"; + const anchor = canonicalDailyTimestamp(day); // 12:00 UTC + for (const tz of [ + "UTC", + "America/Los_Angeles", + "America/New_York", + "Asia/Tokyo", + "Australia/Sydney", + "Etc/GMT+11", + "Etc/GMT-11", + ]) { + expect(userDayKey(anchor, tz)).toBe(day); + } + }); +}); diff --git a/src/lib/measurements/consolidation-tz.ts b/src/lib/measurements/consolidation-tz.ts index 1f58be04c..41eb8e578 100644 --- a/src/lib/measurements/consolidation-tz.ts +++ b/src/lib/measurements/consolidation-tz.ts @@ -88,18 +88,32 @@ function tzOffsetMinutesAt(instant: Date, tz: string): number { } /** - * Compute the canonical timestamp for a calendar-day key in the user's - * timezone. Returns the JS-Date instant at the user's local 12:00 noon - * — matches the Withings activity sync convention (one daily row per - * type, anchored to midday so the row sorts cleanly between same-day - * spot samples). The string returned by `toISOString()` is UTC. + * Compute the canonical timestamp for a calendar-day key. With a `tz`, + * returns the JS-Date instant at the user's local 12:00 noon; without + * one, returns 12:00 UTC of the day. Either anchor sits a full 12 h + * inside its calendar day, so it round-trips back to the same day + * through `userDayKey()` for every zone within ±12 h — which is the + * whole point of anchoring date-only daily records at noon rather than + * midnight (a UTC-midnight anchor double-shifts the day for west-of-UTC + * users on read). Matches the Withings activity sync convention (one + * daily row per type, anchored to midday so the row sorts cleanly + * between same-day spot samples). The string returned by + * `toISOString()` is UTC. + * + * The optional-`tz` shape is what the wearable daily mappers (Oura, + * Polar, Fitbit) anchor on — they do not have the user's timezone in + * scope at map time, so they take the noon-UTC fallback, which is + * correct for every zone within ±12 h. */ -export function canonicalDailyTimestamp(dateKey: string, tz: string): Date { +export function canonicalDailyTimestamp(dateKey: string, tz?: string): Date { + const utcNoon = new Date(`${dateKey}T12:00:00.000Z`); + // No timezone in scope: 12:00 UTC is a safe day-stable anchor for + // every zone within ±12 h. + if (!tz) return utcNoon; // Compute the UTC offset for noon-local of the given day. We don't // have a lightweight TZ-math library on the server, so the trick is: // build "12:00 UTC of the day", read what wall-clock that shows in // the target zone, then shift by the resulting offset. - const utcNoon = new Date(`${dateKey}T12:00:00.000Z`); const offsetMinutes = tzOffsetMinutesAt(utcNoon, tz); // utcNoon represents 12:00 UTC. The user's local clock reads // 12:00 + offsetMinutes at that instant. To anchor at local 12:00, diff --git a/src/lib/oura/__tests__/client.test.ts b/src/lib/oura/__tests__/client.test.ts index 53e42414f..661ede829 100644 --- a/src/lib/oura/__tests__/client.test.ts +++ b/src/lib/oura/__tests__/client.test.ts @@ -473,10 +473,12 @@ describe("mapResilience", () => { } }); - it("anchors the row at the day's UTC midnight", () => { + it("anchors the date-only row at noon UTC so it round-trips the day", () => { + // Noon, not midnight: a UTC-midnight anchor double-shifts the calendar + // day for west-of-UTC users when the read path re-buckets via userDayKey. const mapped = mapResilience({ day: "2026-06-10", level: "adequate" }); expect(mapped[0]?.measuredAt.toISOString()).toBe( - "2026-06-10T00:00:00.000Z", + "2026-06-10T12:00:00.000Z", ); }); }); diff --git a/src/lib/oura/client.ts b/src/lib/oura/client.ts index 08c045cf0..e492c51bd 100644 --- a/src/lib/oura/client.ts +++ b/src/lib/oura/client.ts @@ -14,6 +14,7 @@ * `start_date` / `end_date` (YYYY-MM-DD) and page via `next_token`. */ import { getEvent } from "@/lib/logging/context"; +import { canonicalDailyTimestamp } from "@/lib/measurements/consolidation-tz"; import { safeFetch } from "@/lib/safe-fetch"; import { OuraApiError, classifyOuraResponse } from "./response-classifier"; @@ -455,10 +456,16 @@ export interface MappedMeasurement { } function dayAnchor(day: string, bedtimeEnd?: string): Date | null { - // Prefer the precise wake instant when present; else anchor the day at UTC - // midnight. The recovery resolver reads the local day key off this stamp. - const candidate = bedtimeEnd ?? `${day}T00:00:00.000Z`; - const d = new Date(candidate); + // Prefer the precise wake instant when present; else anchor the date-only + // day at noon (canonicalDailyTimestamp), NOT UTC midnight — a midnight + // anchor double-shifts the calendar day for west-of-UTC users when the + // recovery resolver re-buckets via userDayKey(). Noon sits a full 12 h + // inside the day, so it round-trips to the same day for every zone ±12 h. + if (bedtimeEnd) { + const d = new Date(bedtimeEnd); + return Number.isNaN(d.getTime()) ? null : d; + } + const d = canonicalDailyTimestamp(day); return Number.isNaN(d.getTime()) ? null : d; } diff --git a/src/lib/polar/__tests__/client.test.ts b/src/lib/polar/__tests__/client.test.ts index 35ddd1fe7..9798679d9 100644 --- a/src/lib/polar/__tests__/client.test.ts +++ b/src/lib/polar/__tests__/client.test.ts @@ -312,14 +312,16 @@ describe("mapSleep", () => { ); }); - it("falls back to a midnight-UTC anchor when the window is missing", () => { + it("falls back to a noon-UTC anchor when the window is missing", () => { const mapped = mapSleep({ date: "2026-06-10", light_sleep: 3600, sleep_score: 80, }); const core = mapped.find((m) => m.sleepStage === "CORE")!; - expect(core.measuredAt.toISOString()).toBe("2026-06-10T00:00:00.000Z"); + // Noon, not midnight: keeps the date-only row on the same calendar day for + // every zone when the read path re-buckets via userDayKey. + expect(core.measuredAt.toISOString()).toBe("2026-06-10T12:00:00.000Z"); expect(core.reconstructed).toBeUndefined(); // No IN_BED envelope without a window. expect(mapped.find((m) => m.sleepStage === "IN_BED")).toBeUndefined(); diff --git a/src/lib/polar/client.ts b/src/lib/polar/client.ts index 16065f34d..3b20ab991 100644 --- a/src/lib/polar/client.ts +++ b/src/lib/polar/client.ts @@ -21,6 +21,7 @@ * treated as success. */ import { getEvent } from "@/lib/logging/context"; +import { canonicalDailyTimestamp } from "@/lib/measurements/consolidation-tz"; import { safeFetch } from "@/lib/safe-fetch"; import { reconstructContiguousSleepTimeline } from "@/lib/sleep/reconstruct-timeline"; import { PolarApiError, classifyPolarResponse } from "./response-classifier"; @@ -387,13 +388,13 @@ function rechargeStatusToScore(status: number): number { * Map one Polar Nightly Recharge record. The recovery band → `RECOVERY_SCORE` * (source POLAR, distinct from the COMPUTED proxy); HRV → `HRV_RMSSD`; the * nightly average HR → `RESTING_HEART_RATE`; breathing rate → `RESPIRATORY_RATE`. - * `measuredAt` is the record's date at local midnight UTC anchor (the wake + * `measuredAt` is the record's date anchored at noon (the wake * morning the night belongs to). */ export function mapNightlyRecharge( r: PolarNightlyRecharge, ): MappedMeasurement[] { - const measuredAt = new Date(`${r.date}T00:00:00.000Z`); + const measuredAt = canonicalDailyTimestamp(r.date); if (Number.isNaN(measuredAt.getTime())) return []; const out: MappedMeasurement[] = []; @@ -537,10 +538,10 @@ export function mapSleep(s: PolarSleep): MappedMeasurement[] { return out; } - // Fallback: no usable window. Anchor at midnight UTC of the wake date and emit + // Fallback: no usable window. Anchor at noon of the wake date and emit // untimed stage rows (the pre-v1.17.1 shape) so a record missing the window // fields still contributes a night total. - const measuredAt = new Date(`${s.date}T00:00:00.000Z`); + const measuredAt = canonicalDailyTimestamp(s.date); if (Number.isNaN(measuredAt.getTime())) return []; for (const [sec, stage, fieldTag] of stages) { if (typeof sec === "number" && sec >= 0) { @@ -572,7 +573,7 @@ export function mapSleep(s: PolarSleep): MappedMeasurement[] { * total `calories`, which includes BMR — matching the Fitbit active-only * convention). */ export function mapActivity(a: PolarActivity): MappedMeasurement[] { - const measuredAt = new Date(`${a.date}T00:00:00.000Z`); + const measuredAt = canonicalDailyTimestamp(a.date); if (Number.isNaN(measuredAt.getTime())) return []; const out: MappedMeasurement[] = []; const steps = a["active-steps"]; @@ -615,7 +616,7 @@ export function mapActivity(a: PolarActivity): MappedMeasurement[] { * (Polar's device-native cardiovascular-strain figure). `0` is a valid load, so * guard on `number` only. */ export function mapCardioLoad(c: PolarCardioLoad): MappedMeasurement[] { - const measuredAt = new Date(`${c.date}T00:00:00.000Z`); + const measuredAt = canonicalDailyTimestamp(c.date); if (Number.isNaN(measuredAt.getTime())) return []; if (typeof c.cardio_load !== "number" || c.cardio_load < 0) return []; return [ @@ -632,7 +633,7 @@ export function mapCardioLoad(c: PolarCardioLoad): MappedMeasurement[] { /** Map one Polar SpO2 test result: `blood_oxygen_percentage` → * `OXYGEN_SATURATION` (percent, 0..100). */ export function mapSpo2(r: PolarSpo2): MappedMeasurement[] { - const measuredAt = new Date(`${r.date}T00:00:00.000Z`); + const measuredAt = canonicalDailyTimestamp(r.date); if (Number.isNaN(measuredAt.getTime())) return []; const pct = r.blood_oxygen_percentage; if (typeof pct !== "number" || pct <= 0 || pct > 100) return []; From d85ac3e555d27276aedc156cb61f19a6cba56a46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 03:27:55 +0200 Subject: [PATCH 11/94] fix(measurements): recompute rollups after cumulative consolidation drains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two cumulative drains soft-deleted per-sample rows and wrote a daily total but never recomputed the affected DAY rollup bucket. The rollup tier reads only `deleted_at IS NULL`, so the stored `sumValue` kept the pre-drain double-counted total indefinitely — dashboard and every rolled-up week/month/year step or energy figure stayed wrong and never self-healed, because the backfill only re-flags days with no DAY row and the daily-series read-swap only re-folds on a day-count change. Call `recomputeBucketsForMeasurement` after each per-day write, mirroring the daily-mean drain that already does this. Added per-drain (not hoisted) so the mean drain, which recomputes inside its own writer, is not double-recomputed. --- .../drain-per-sample-cumulative.test.ts | 29 +++++++++++++++++++ .../measurements/consolidate-legacy-steps.ts | 11 +++++++ .../drain-per-sample-cumulative.ts | 10 +++++++ 3 files changed, 50 insertions(+) diff --git a/src/lib/measurements/__tests__/drain-per-sample-cumulative.test.ts b/src/lib/measurements/__tests__/drain-per-sample-cumulative.test.ts index d391105e5..260a9c5b3 100644 --- a/src/lib/measurements/__tests__/drain-per-sample-cumulative.test.ts +++ b/src/lib/measurements/__tests__/drain-per-sample-cumulative.test.ts @@ -1,5 +1,14 @@ import { describe, expect, it, vi } from "vitest"; +// The SUM drain now recomputes the affected DAY rollup bucket after each +// per-day fold (so a drained day's stale double-counted sum self-heals). +// Mock it: these are pure-logic tests over a Prisma mock with no DB, and +// the real recompute issues raw SQL against the singleton client. +vi.mock("@/lib/rollups/measurement-rollups", () => ({ + recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined), +})); +import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups"; + import { bucketRowsByUserDay, canonicalDailyTimestamp, @@ -444,6 +453,26 @@ describe("drainPerSampleCumulative — late-sync fold into existing total", () = const createArg = create.mock.calls[0]?.[0] as { data: { value: number } }; expect(createArg.data.value).toBe(500); }); + + it("recomputes the DAY rollup bucket for the drained day so the stale sum self-heals", async () => { + vi.mocked(recomputeBucketsForMeasurement).mockClear(); + const { prisma } = buildFoldMock(null); + + await drainPerSampleCumulative(prisma, { log: () => {} }); + + // Without this the rollup tier keeps serving the pre-drain + // double-counted sumValue forever (it reads only deleted_at IS NULL). + expect(recomputeBucketsForMeasurement).toHaveBeenCalledTimes(1); + const [userId, type, canonicalTs] = vi.mocked( + recomputeBucketsForMeasurement, + ).mock.calls[0]!; + expect(userId).toBe("user-1"); + expect(type).toBe("ACTIVITY_STEPS"); + // Canonical local-noon of the Berlin day 2026-05-16 (CEST, UTC+2). + expect((canonicalTs as Date).toISOString()).toBe( + "2026-05-16T10:00:00.000Z", + ); + }); }); // Root cause of the v1.18.10 bloat: a single day whose mint collided with the diff --git a/src/lib/measurements/consolidate-legacy-steps.ts b/src/lib/measurements/consolidate-legacy-steps.ts index 4c3f8dc81..5ddfe1519 100644 --- a/src/lib/measurements/consolidate-legacy-steps.ts +++ b/src/lib/measurements/consolidate-legacy-steps.ts @@ -39,6 +39,7 @@ */ import type { PrismaClient } from "@/generated/prisma/client"; import { isP2002 as isUniqueConstraintViolation } from "@/lib/prisma-errors"; +import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups"; import { dailyStatsExternalId } from "./apple-health-mapping"; import { @@ -259,6 +260,16 @@ export async function consolidateLegacySteps( }); removed = del.count; }); + + // Re-aggregate the affected (user, type, day) rollup bucket after the + // soft-delete commits. The legacy granular rows that just dropped out + // of the live read were being summed alongside any existing daily-stats + // total, so the DAY rollup bucket carried a double-counted total. The + // rollup tier reads only `deleted_at IS NULL`; without this recompute + // the stale bucket persists forever. Mirrors the mean drain's + // post-write recompute exactly. + await recomputeBucketsForMeasurement(userId, type, canonicalTimestamp); + return { kind: "written", sourceRowsRemoved: removed }; } catch (err) { // A pre-existing row can collide on the second unique index diff --git a/src/lib/measurements/drain-per-sample-cumulative.ts b/src/lib/measurements/drain-per-sample-cumulative.ts index 9bd92ca3b..319b42933 100644 --- a/src/lib/measurements/drain-per-sample-cumulative.ts +++ b/src/lib/measurements/drain-per-sample-cumulative.ts @@ -34,6 +34,7 @@ import { Prisma } from "@/generated/prisma/client"; import type { MeasurementType, PrismaClient } from "@/generated/prisma/client"; import { isP2002 as isUniqueConstraintViolation } from "@/lib/prisma-errors"; +import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups"; import { CUMULATIVE_HK_TYPES, @@ -355,6 +356,15 @@ export async function drainPerSampleCumulative( if (!isUniqueConstraintViolation(err)) throw err; removed = await foldOnce(); } + + // Re-aggregate the affected (user, type, day) rollup bucket after the + // fold commits — the per-sample rows are now gone and the canonical + // daily-sum row is the only live reading. The rollup tier reads only + // `deleted_at IS NULL`, so without this the day's DAY bucket keeps the + // stale pre-drain (double-counted) sumValue forever and never + // self-heals. Mirrors the mean drain's post-write recompute exactly. + await recomputeBucketsForMeasurement(userId, type, canonicalTimestamp); + return { kind: "written", sourceRowsRemoved: removed }; }, recordBucket: ({ From 3405bb6fd39b4d958cb89519b979e7c0e532f29f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 03:28:02 +0200 Subject: [PATCH 12/94] fix(medications): size weekly-cadence supply runway correctly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `estimateDailyDoseCount` only special-cased FREQ=MONTHLY and FREQ=YEARLY. A modern FREQ=WEEKLY rrule (the encoding the create path stores, with an empty daysOfWeek) fell through to the legacy branch at seven days per week, so a once-weekly injection counted as one dose per day. The detail card's "lasts about N days" and the daily low-stock job — which share this math — fired reorder alerts roughly seven times too early (fourteen for bi-weekly). Parse FREQ=WEEKLY honouring INTERVAL and BYDAY count: perDay += (times * bydayCount) / (7 * interval). --- .../__tests__/supply-runway.test.ts | 37 +++++++++++++++++++ .../medications/detail/supply-runway.ts | 19 ++++++++++ 2 files changed, 56 insertions(+) diff --git a/src/components/medications/__tests__/supply-runway.test.ts b/src/components/medications/__tests__/supply-runway.test.ts index d1c11ad56..77ea502d7 100644 --- a/src/components/medications/__tests__/supply-runway.test.ts +++ b/src/components/medications/__tests__/supply-runway.test.ts @@ -68,6 +68,43 @@ describe("estimateDailyDoseCount", () => { ).toBeCloseTo(1 / 30); }); + it("treats a once-weekly RRULE as one dose per 7 days", () => { + // FREQ=WEEKLY;BYDAY=MO is the modern once-weekly injection encoding; + // it must NOT fall through to the daysPerWeek=7 legacy branch. + expect( + estimateDailyDoseCount([ + schedule({ timesOfDay: ["08:00"], rrule: "FREQ=WEEKLY;BYDAY=MO" }), + ]), + ).toBeCloseTo(1 / 7); + }); + + it("halves a bi-weekly RRULE via INTERVAL=2 (one dose per 14 days)", () => { + expect( + estimateDailyDoseCount([ + schedule({ + timesOfDay: ["08:00"], + rrule: "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO", + }), + ]), + ).toBeCloseTo(1 / 14); + }); + + it("counts each BYDAY pick on a weekly RRULE (Mon+Thu → 2 per 7 days)", () => { + expect( + estimateDailyDoseCount([ + schedule({ timesOfDay: ["08:00"], rrule: "FREQ=WEEKLY;BYDAY=MO,TH" }), + ]), + ).toBeCloseTo(2 / 7); + }); + + it("defaults a weekly RRULE with no BYDAY to one pick per week", () => { + expect( + estimateDailyDoseCount([ + schedule({ timesOfDay: ["08:00"], rrule: "FREQ=WEEKLY" }), + ]), + ).toBeCloseTo(1 / 7); + }); + it("sums across multiple schedules", () => { expect( estimateDailyDoseCount([ diff --git a/src/components/medications/detail/supply-runway.ts b/src/components/medications/detail/supply-runway.ts index b4972e946..bbc36a58f 100644 --- a/src/components/medications/detail/supply-runway.ts +++ b/src/components/medications/detail/supply-runway.ts @@ -46,6 +46,25 @@ export function estimateDailyDoseCount(schedules: RunwaySchedule[]): number { perDay += times / 365; continue; } + // FREQ=WEEKLY;BYDAY=…;INTERVAL=… is the modern weekly encoding (the + // create path stores the cadence on the rrule and leaves daysOfWeek + // empty). Honour the BYDAY pick count and the week INTERVAL: a + // once-weekly injection is one dose per 7 days, a bi-weekly one per + // 14. Without this branch a weekly rrule fell through to the legacy + // daysOfWeek fallback below with daysPerWeek=7, over-estimating the + // rate ~7× (≈14× bi-weekly) and firing low-stock alerts far too early. + if (/FREQ=WEEKLY/.test(rrule)) { + const byday = /BYDAY=([^;]+)/.exec(rrule); + const bydayCount = + byday && byday[1].length > 0 ? byday[1].split(",").length : 1; + const intervalMatch = /INTERVAL=(\d+)/.exec(rrule); + const interval = + intervalMatch && Number(intervalMatch[1]) >= 1 + ? Number(intervalMatch[1]) + : 1; + perDay += (times * bydayCount) / (7 * interval); + continue; + } const { daysOfWeek, intervalWeeks } = parseScheduleRecurrence(s.daysOfWeek); const daysPerWeek = daysOfWeek.length > 0 ? daysOfWeek.length : 7; const weeks = intervalWeeks >= 1 ? intervalWeeks : 1; From 9e6a58b1866d73ec699a26d3f880d651673124a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 03:28:11 +0200 Subject: [PATCH 13/94] fix(illness): escalate sustained fever and low SpO2 on write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sustained fever (three calendar-consecutive days at or above the fever red-flag floor) or sustained low SpO2 was already classified "seek care", but the only escalation path ran inside a GET on the episode correlation route — no push fired unless the user opened that tab, and no cron drove it. The proactive on-write safety check covered only blood pressure and glucose; the two signals with the strongest safety case had the weakest delivery. Add sustained-run temperature and SpO2 checks to the on-write `runSafetyFloorCheck`, wired from both measurement-ingest seams. The run length and clinical floors are shared with the retrospective detector (no duplicated thresholds), day bucketing honours the user's timezone, and escalation reuses the existing red-flag notification with its push-attempt dedup so a febrile user logging repeatedly is alerted at most once per reason per day. --- src/app/api/measurements/route.ts | 2 + .../__tests__/safety-floor-sustained.test.ts | 290 ++++++++++++++++++ src/lib/illness/correlation.ts | 10 +- src/lib/illness/safety-floor-check.ts | 230 ++++++++++++++ 4 files changed, 530 insertions(+), 2 deletions(-) create mode 100644 src/lib/illness/__tests__/safety-floor-sustained.test.ts diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index 989c5fba4..50ec6ebd8 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -802,6 +802,7 @@ async function postMeasurement(request: NextRequest) { symptomsPresent: parsed.data.measurements.some( (m) => m.symptomsPresent === true, ), + timezone: user.timezone ?? undefined, }).catch(() => {}); // v1.5.0 — refresh the persistent rollup table for every distinct @@ -948,6 +949,7 @@ async function postMeasurement(request: NextRequest) { }, ], symptomsPresent, + timezone: user.timezone ?? undefined, }).catch(() => {}); // v1.5.0 — refresh the persistent rollup row for the affected diff --git a/src/lib/illness/__tests__/safety-floor-sustained.test.ts b/src/lib/illness/__tests__/safety-floor-sustained.test.ts new file mode 100644 index 000000000..bc874e075 --- /dev/null +++ b/src/lib/illness/__tests__/safety-floor-sustained.test.ts @@ -0,0 +1,290 @@ +/** + * v1.25 — on-write sustained fever / low-SpO2 escalation (finding H-CS1). + * + * The retrospective correlation detector classifies a 3-day fever or sustained + * low-SpO2 run as "seek care", but that escalation only fired when the user + * opened the episode's correlation tab. These tests pin the on-write seam: + * a sustained run escalates on INGEST through the SAME illness red-flag push, + * with the SAME run length (RED_FLAG_RUN_DAYS) and absolute floors the detector + * uses, and the 24h ledger dedupe stops a 4th reading re-firing the alarm. + * + * Calendar-day bucketing runs against TZ=UTC (the gate's contract). + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +/** In-memory pushAttempt ledger so the 24h dedupe behaves across calls. */ +const ledger: Array<{ userId: string; reason: string }> = []; +const measurementFindMany = vi.fn(); +const dispatchMock = vi.fn(); + +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { + findMany: (...a: unknown[]) => measurementFindMany(...a), + }, + pushAttempt: { + findFirst: ({ where }: { where: { userId: string; reason: string } }) => { + const hit = ledger.find( + (r) => r.userId === where.userId && r.reason === where.reason, + ); + return Promise.resolve(hit ? { id: "prior" } : null); + }, + create: ({ data }: { data: { userId: string; reason: string } }) => { + ledger.push({ userId: data.userId, reason: data.reason }); + return Promise.resolve({}); + }, + }, + }, +})); + +vi.mock("@/lib/logging/context", () => ({ + getEvent: () => ({ addMeta: vi.fn(), addWarning: vi.fn() }), +})); + +vi.mock("@/lib/modules/gate", () => ({ + isModuleEnabled: vi.fn().mockResolvedValue(false), +})); + +vi.mock("@/lib/notifications/dispatch-localised", () => ({ + dispatchLocalisedNotification: (...a: unknown[]) => dispatchMock(...a), +})); + +import { runSafetyFloorCheck } from "../safety-floor-check"; + +/** A measured-at on a given UTC calendar day, mid-morning. */ +function utcDay(day: string): Date { + return new Date(`${day}T08:00:00Z`); +} + +beforeEach(() => { + ledger.length = 0; + measurementFindMany.mockReset(); + dispatchMock.mockReset().mockResolvedValue(undefined); +}); + +afterEach(() => vi.clearAllMocks()); + +describe("runSafetyFloorCheck — sustained fever", () => { + it("escalates once on a 3-day calendar-consecutive fever run", async () => { + // °C ≥ 38.5 on three consecutive UTC days. + measurementFindMany.mockResolvedValue([ + { value: 39.0, measuredAt: utcDay("2026-01-03") }, + { value: 39.1, measuredAt: utcDay("2026-01-02") }, + { value: 38.8, measuredAt: utcDay("2026-01-01") }, + ]); + + await runSafetyFloorCheck({ + userId: "u1", + written: [ + { + type: "BODY_TEMPERATURE", + value: 39.0, + measuredAt: utcDay("2026-01-03"), + }, + ], + timezone: "UTC", + }); + + expect(dispatchMock).toHaveBeenCalledTimes(1); + const opts = dispatchMock.mock.calls[0][0]; + expect(opts.eventType).toBe("SYSTEM_ALERT"); + expect(opts.urgent).toBe(true); + expect(opts.titleKey).toBe("illness.correlation.redFlagTitle"); + expect(opts.messageKey).toBe("illness.correlation.redFlagFever"); + }); + + it("does not re-fire on a 4th reading inside the dedupe window", async () => { + measurementFindMany.mockResolvedValue([ + { value: 39.0, measuredAt: utcDay("2026-01-03") }, + { value: 39.1, measuredAt: utcDay("2026-01-02") }, + { value: 38.8, measuredAt: utcDay("2026-01-01") }, + ]); + + // First ingest — fires. + await runSafetyFloorCheck({ + userId: "u1", + written: [ + { + type: "BODY_TEMPERATURE", + value: 39.0, + measuredAt: utcDay("2026-01-03"), + }, + ], + timezone: "UTC", + }); + // A later same-day reading — run still holds, but the ledger anchor dedupes. + await runSafetyFloorCheck({ + userId: "u1", + written: [ + { + type: "BODY_TEMPERATURE", + value: 39.2, + measuredAt: new Date("2026-01-03T20:00:00Z"), + }, + ], + timezone: "UTC", + }); + + expect(dispatchMock).toHaveBeenCalledTimes(1); + }); + + it("does NOT escalate on a 2-day fever run (below RED_FLAG_RUN_DAYS)", async () => { + measurementFindMany.mockResolvedValue([ + { value: 39.0, measuredAt: utcDay("2026-01-02") }, + { value: 38.9, measuredAt: utcDay("2026-01-01") }, + ]); + + await runSafetyFloorCheck({ + userId: "u1", + written: [ + { + type: "BODY_TEMPERATURE", + value: 39.0, + measuredAt: utcDay("2026-01-02"), + }, + ], + timezone: "UTC", + }); + + expect(dispatchMock).not.toHaveBeenCalled(); + }); + + it("does NOT escalate on three NON-consecutive febrile days", async () => { + measurementFindMany.mockResolvedValue([ + { value: 39.0, measuredAt: utcDay("2026-01-05") }, + { value: 39.0, measuredAt: utcDay("2026-01-03") }, + { value: 39.0, measuredAt: utcDay("2026-01-01") }, + ]); + + await runSafetyFloorCheck({ + userId: "u1", + written: [ + { + type: "BODY_TEMPERATURE", + value: 39.0, + measuredAt: utcDay("2026-01-05"), + }, + ], + timezone: "UTC", + }); + + expect(dispatchMock).not.toHaveBeenCalled(); + }); + + it("does NOT escalate when the run sits below the fever floor", async () => { + // 38.0 °C < 38.5 °C floor — elevated but not the escalation band. + measurementFindMany.mockResolvedValue([ + { value: 38.0, measuredAt: utcDay("2026-01-03") }, + { value: 38.0, measuredAt: utcDay("2026-01-02") }, + { value: 38.0, measuredAt: utcDay("2026-01-01") }, + ]); + + await runSafetyFloorCheck({ + userId: "u1", + written: [ + { + type: "BODY_TEMPERATURE", + value: 38.0, + measuredAt: utcDay("2026-01-03"), + }, + ], + timezone: "UTC", + }); + + expect(dispatchMock).not.toHaveBeenCalled(); + }); +}); + +describe("runSafetyFloorCheck — sustained low SpO2", () => { + it("escalates once on a 3-day calendar-consecutive low-SpO2 run", async () => { + // ≤ 92% on three consecutive UTC days (per-day MIN is the worst). + measurementFindMany.mockResolvedValue([ + { value: 89, measuredAt: utcDay("2026-01-03") }, + { value: 91, measuredAt: utcDay("2026-01-02") }, + { value: 92, measuredAt: utcDay("2026-01-01") }, + ]); + + await runSafetyFloorCheck({ + userId: "u1", + written: [ + { + type: "OXYGEN_SATURATION", + value: 89, + measuredAt: utcDay("2026-01-03"), + }, + ], + timezone: "UTC", + }); + + expect(dispatchMock).toHaveBeenCalledTimes(1); + const opts = dispatchMock.mock.calls[0][0]; + expect(opts.titleKey).toBe("illness.correlation.redFlagTitle"); + expect(opts.messageKey).toBe("illness.correlation.redFlagSpo2"); + }); + + it("does NOT escalate when a single day recovers above the floor", async () => { + measurementFindMany.mockResolvedValue([ + { value: 89, measuredAt: utcDay("2026-01-03") }, + { value: 96, measuredAt: utcDay("2026-01-02") }, // breaks the run + { value: 90, measuredAt: utcDay("2026-01-01") }, + ]); + + await runSafetyFloorCheck({ + userId: "u1", + written: [ + { + type: "OXYGEN_SATURATION", + value: 89, + measuredAt: utcDay("2026-01-03"), + }, + ], + timezone: "UTC", + }); + + expect(dispatchMock).not.toHaveBeenCalled(); + }); + + it("a fever escalation never suppresses a concurrent SpO2 one (distinct dedupe keys)", async () => { + // Two independent runs landing on the same write: both should fire once. + measurementFindMany.mockImplementation( + ({ where }: { where: { type: string } }) => { + if (where.type === "BODY_TEMPERATURE") { + return Promise.resolve([ + { value: 39.0, measuredAt: utcDay("2026-01-03") }, + { value: 39.0, measuredAt: utcDay("2026-01-02") }, + { value: 39.0, measuredAt: utcDay("2026-01-01") }, + ]); + } + return Promise.resolve([ + { value: 89, measuredAt: utcDay("2026-01-03") }, + { value: 90, measuredAt: utcDay("2026-01-02") }, + { value: 91, measuredAt: utcDay("2026-01-01") }, + ]); + }, + ); + + await runSafetyFloorCheck({ + userId: "u1", + written: [ + { + type: "BODY_TEMPERATURE", + value: 39.0, + measuredAt: utcDay("2026-01-03"), + }, + { + type: "OXYGEN_SATURATION", + value: 89, + measuredAt: utcDay("2026-01-03"), + }, + ], + timezone: "UTC", + }); + + expect(dispatchMock).toHaveBeenCalledTimes(2); + const keys = dispatchMock.mock.calls.map((c) => c[0].messageKey).sort(); + expect(keys).toEqual([ + "illness.correlation.redFlagFever", + "illness.correlation.redFlagSpo2", + ]); + }); +}); diff --git a/src/lib/illness/correlation.ts b/src/lib/illness/correlation.ts index 4cb561d16..e9edbe04c 100644 --- a/src/lib/illness/correlation.ts +++ b/src/lib/illness/correlation.ts @@ -408,8 +408,14 @@ export interface IllnessRedFlag { const SPO2_RED_FLAG = SPO2_RED_FLAG_PCT; /** Body temperature at/above this (°C) for ≥ RED_FLAG_RUN_DAYS escalates. */ const FEVER_RED_FLAG = FEVER_RED_FLAG_C; -/** Consecutive days an adverse clinical threshold must hold to escalate. */ -const RED_FLAG_RUN_DAYS = 3; +/** + * Consecutive days an adverse clinical threshold must hold to escalate. + * Exported so the on-write safety seam (`safety-floor-check.ts`) escalates a + * sustained fever / low-SpO2 run on ingest using the SAME run length as this + * retrospective detector — the two paths can never disagree on what "sustained" + * means. + */ +export const RED_FLAG_RUN_DAYS = 3; /* ── pure helpers ─────────────────────────────────────────────────────── */ diff --git a/src/lib/illness/safety-floor-check.ts b/src/lib/illness/safety-floor-check.ts index 1b25e1520..908c0cd4f 100644 --- a/src/lib/illness/safety-floor-check.ts +++ b/src/lib/illness/safety-floor-check.ts @@ -15,9 +15,11 @@ * The caller (the measurement POST route) passes the just-written rows plus * the transient `symptomsPresent` flag; this helper does the bounded reads. */ +import type { MeasurementType } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { getEvent } from "@/lib/logging/context"; import { isModuleEnabled } from "@/lib/modules/gate"; +import { FEVER_RED_FLAG_C, SPO2_RED_FLAG_PCT } from "@/lib/clinical-floors"; import { evaluateBloodPressure, evaluateGlucose, @@ -26,6 +28,13 @@ import { type GlucoseSample, } from "@/lib/illness/safety-floors"; import { notifySafetyFloor } from "@/lib/illness/safety-floor-notify"; +import { + RED_FLAG_RUN_DAYS, + dayDiff, + type IllnessRedFlag, +} from "@/lib/illness/correlation"; +import { notifyIllnessRedFlag } from "@/lib/illness/red-flag-notify"; +import { userDayKey } from "@/lib/tz/format"; /** A row the write hook just persisted (the subset the check needs). */ export interface WrittenReading { @@ -46,14 +55,30 @@ export async function runSafetyFloorCheck(input: { userId: string; written: WrittenReading[]; symptomsPresent?: boolean; + /** + * The user's IANA timezone, used to bucket the sustained fever / SpO2 run by + * CALENDAR day. Defaults to UTC when absent — a missing timezone must never + * silence a safety escalation, only shift the day boundary. + */ + timezone?: string; }): Promise { const { userId, written } = input; const symptomCoupled = input.symptomsPresent === true; + const tz = + input.timezone && input.timezone.length > 0 ? input.timezone : "UTC"; try { await Promise.all([ checkBloodPressure(userId, written, symptomCoupled), checkGlucose(userId, written, symptomCoupled), + // Sustained multi-day runs against ABSOLUTE clinical floors. Unlike the + // confirm-gated BP / glucose checks above (single breach + a confirming + // re-test inside an hour), a fever / low-SpO2 escalation only fires when + // the floor held for RED_FLAG_RUN_DAYS calendar-consecutive days — the + // SAME run length and floors the retrospective correlation detector uses, + // so the on-write seam and the correlation surface can never disagree. + checkSustainedFever(userId, written, tz), + checkSustainedLowSpo2(userId, written, tz), ]); } catch (err) { getEvent()?.addWarning( @@ -171,3 +196,208 @@ async function checkGlucose( const decision = evaluateGlucose({ candidate, recent, symptomCoupled }); await notifySafetyFloor({ userId, decision }); } + +/* ── sustained fever / low-SpO2 (multi-day calendar runs) ─────────────── */ + +/** + * Lookback span (calendar days, inclusive of the candidate day) the sustained + * run scan reads. RED_FLAG_RUN_DAYS days are needed to form a run that ends on + * the candidate day; one extra day absorbs UTC-vs-local boundary skew so a run + * that ends "today" in the user's timezone is never clipped. We only react to + * RECENT runs by design — an old run is the correlation surface's job, not the + * write hook's. + */ +const SUSTAINED_LOOKBACK_DAYS = RED_FLAG_RUN_DAYS + 1; + +/** + * Sustained fever: BODY_TEMPERATURE ≥ FEVER_RED_FLAG_C (°C) for + * RED_FLAG_RUN_DAYS calendar-consecutive days. Mirrors the correlation + * detector's fever path — per-day MAX is the worst (an evening spike must not + * be masked by a daily mean) and the floor + run length are the shared + * constants. Fires the SAME illness red-flag escalation the retrospective path + * fires, so the notification class matches exactly. + */ +async function checkSustainedFever( + userId: string, + written: WrittenReading[], + tz: string, +): Promise { + const fresh = written.find((r) => r.type === "BODY_TEMPERATURE"); + if (!fresh) return; + + const days = await loadDailyWorst( + userId, + "BODY_TEMPERATURE", + fresh.measuredAt, + tz, + "max", + ); + const flag = sustainedRunFlag( + days, + (v) => v >= FEVER_RED_FLAG_C, + "sustained_fever", + "BODY_TEMPERATURE", + "max", + ); + if (!flag) return; + await notifyIllnessRedFlag({ + userId, + episodeId: SUSTAINED_DEDUPE_KEY.sustained_fever, + redFlags: [flag], + }); +} + +/** + * Sustained low SpO2: OXYGEN_SATURATION ≤ SPO2_RED_FLAG_PCT for + * RED_FLAG_RUN_DAYS calendar-consecutive days. SpO2's worst is the per-day MIN, + * matching the correlation detector. Fires the SAME illness red-flag escalation. + */ +async function checkSustainedLowSpo2( + userId: string, + written: WrittenReading[], + tz: string, +): Promise { + const fresh = written.find((r) => r.type === "OXYGEN_SATURATION"); + if (!fresh) return; + + const days = await loadDailyWorst( + userId, + "OXYGEN_SATURATION", + fresh.measuredAt, + tz, + "min", + ); + const flag = sustainedRunFlag( + days, + (v) => v <= SPO2_RED_FLAG_PCT, + "sustained_low_spo2", + "OXYGEN_SATURATION", + "min", + ); + if (!flag) return; + await notifyIllnessRedFlag({ + userId, + episodeId: SUSTAINED_DEDUPE_KEY.sustained_low_spo2, + redFlags: [flag], + }); +} + +/** + * Synthetic, stable per-reason dedupe anchors. The on-write sustained checks + * may run with no active illness episode, so they cannot key the red-flag + * dedupe ledger on a real episode id. These per-reason keys ride the SAME + * `pushAttempt` ledger + 24h window `notifyIllnessRedFlag` already enforces, so + * a stubbornly febrile user logging a 4th reading inside the day re-runs the + * detector but never re-fires the alarm. Distinct keys per reason so a fever + * escalation never suppresses a concurrent SpO2 one. + */ +const SUSTAINED_DEDUPE_KEY: Record = { + sustained_fever: "onwrite:sustained_fever", + sustained_low_spo2: "onwrite:sustained_low_spo2", +}; + +/** A per-day worst value, keyed by the user-timezone calendar day. */ +interface DailyWorst { + day: string; + worst: number; +} + +/** + * Read the recent same-type readings in the lookback window and collapse them + * to one worst value per user-timezone calendar day (MAX for fever, MIN for + * SpO2). Sorted chronologically so the run scan sees calendar order. Bounded + * read (window + row cap); the candidate's own freshly-written rows are + * INCLUDED so a run that completes on this very write escalates. + */ +async function loadDailyWorst( + userId: string, + type: MeasurementType, + candidateAt: Date, + tz: string, + worst: "min" | "max", +): Promise { + const windowStart = new Date( + candidateAt.getTime() - SUSTAINED_LOOKBACK_DAYS * 24 * 60 * 60 * 1000, + ); + const rows = await prisma.measurement.findMany({ + where: { + userId, + type, + deletedAt: null, + measuredAt: { gte: windowStart, lte: candidateAt }, + }, + orderBy: { measuredAt: "desc" }, + take: 500, + select: { value: true, measuredAt: true }, + }); + + const byDay = new Map(); + for (const r of rows) { + const day = userDayKey(r.measuredAt, tz); + const prev = byDay.get(day); + if (prev === undefined) { + byDay.set(day, r.value); + } else { + byDay.set( + day, + worst === "min" ? Math.min(prev, r.value) : Math.max(prev, r.value), + ); + } + } + + return [...byDay.entries()] + .map(([day, w]) => ({ day, worst: w })) + .sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0)); +} + +/** + * Longest CALENDAR-consecutive run matching `predicate`; returns an + * {@link IllnessRedFlag} when the run reaches RED_FLAG_RUN_DAYS, else null. + * Mirrors the correlation engine's `runFlag`: present-days-only points (never + * zero-filled), so a sparse febrile series (days 01/10/15) must NOT count as + * one long run — a point only extends the run when it is the calendar day + * immediately after the previous matching one (`dayDiff === 1`). + */ +function sustainedRunFlag( + sorted: DailyWorst[], + predicate: (v: number) => boolean, + reason: IllnessRedFlag["reason"], + type: MeasurementType, + worst: "min" | "max", +): IllnessRedFlag | null { + let bestRun = 0; + let bestWorst: number | null = null; + let run = 0; + let runWorst: number | null = null; + let prevDay: string | null = null; + for (const p of sorted) { + if (predicate(p.worst)) { + const adjacent = prevDay !== null && dayDiff(prevDay, p.day) === 1; + if (adjacent) { + run++; + runWorst = + runWorst === null + ? p.worst + : worst === "min" + ? Math.min(runWorst, p.worst) + : Math.max(runWorst, p.worst); + } else { + run = 1; + runWorst = p.worst; + } + if (run > bestRun) { + bestRun = run; + bestWorst = runWorst; + } + prevDay = p.day; + } else { + run = 0; + runWorst = null; + prevDay = null; + } + } + if (bestRun >= RED_FLAG_RUN_DAYS && bestWorst !== null) { + return { type, reason, worstValue: bestWorst, days: bestRun }; + } + return null; +} From a827fe2ca752a8887da0e2775b132fd7a76b4fb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 03:28:37 +0200 Subject: [PATCH 14/94] fix(insights): correct medication compliance and per-user insight bucketing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cluster of related correctness gaps in the compliance denominator and the insight surfaces that read it: - Paused medications. A doctor-ordered pause flipped `active` off and stamped `pausedAt`, but resume nulled `pausedAt`, so the interval was irrecoverable and every paused day became a missed dose after resume — collapsing adherence across the Health Score, the BP gate, the Coach snapshot and the report. Persist pause intervals as durable eras (new `MedicationPauseEra`, migration 0211), close the open era on resume, thread the eras through the compliance context and drop slots inside a paused interval from the denominator. Every compliance call site now fetches and passes the eras. - Health Score eras. The Health-Score fast path was the only compliance caller that skipped the shared schedule select and schedule revisions, so after a schedule edit it scored past days against the current schedule while every other surface used the historical era. Switch it to the shared select and thread revisions (and pause eras) like the canonical path. - Insight bucketing timezone. The per-metric status series bucketed every user in Europe/Berlin, so a near-midnight reading landed in the wrong day and the relative-day phrasing was off for non-Berlin self-hosters. Thread the resolved user timezone and key off `userDayKey`. - Correlation significance. User-facing correlations surfaced from five paired points with strength read straight off the coefficient. Route them through the rigorous engine (at least twenty pairs, Student-t p below 0.05) so small-N coincidences no longer render as confident findings. - Resting-pulse input. The mood-versus-"resting pulse" correlation fed the raw PULSE day-mean, which folds in workout HR, under a "Resting pulse" label. Feed `resolveRestingPulseSeries` (clean RESTING_HEART_RATE, else a daily low-percentile proxy) so the label matches the input. --- .../0211_v125_med_pause_era/migration.sql | 31 +++ prisma/schema.prisma | 58 +++- .../comprehensive/__tests__/route.test.ts | 5 + src/app/api/insights/comprehensive/route.ts | 54 +++- .../api/medications/[id]/compliance/route.ts | 2 + src/app/api/medications/[id]/route.ts | 23 ++ src/app/api/medications/compliance/route.ts | 2 + src/app/api/medications/intake/route.ts | 2 + src/lib/ai/coach/snapshot.ts | 2 + .../__tests__/compliance-pause-era.test.ts | 250 ++++++++++++++++++ .../__tests__/correlations-fast-path.test.ts | 85 +++++- .../analytics/__tests__/correlations.test.ts | 46 +++- src/lib/analytics/compliance.ts | 57 +++- src/lib/analytics/correlations-fast-path.ts | 93 ++++--- src/lib/analytics/correlations.ts | 47 ++++ src/lib/analytics/health-score-fast-path.ts | 48 ++-- src/lib/doctor-report-data.ts | 6 + .../insights/__tests__/bucket-series.test.ts | 63 +++++ src/lib/insights/blood-pressure-status.ts | 52 ++-- src/lib/insights/bmi-status.ts | 8 +- src/lib/insights/bucket-series.ts | 71 +++-- .../insights/correlation-channel-series.ts | 2 + src/lib/insights/features.ts | 2 + src/lib/insights/general-status.ts | 30 ++- .../insights/medication-compliance-status.ts | 7 +- src/lib/insights/metric-status.ts | 11 +- src/lib/insights/mood-aggregates.ts | 34 ++- src/lib/insights/mood-narratives.ts | 14 +- src/lib/insights/mood-status.ts | 35 ++- src/lib/insights/pulse-status.ts | 4 +- src/lib/insights/weight-status.ts | 39 ++- src/lib/medications/compliance-payload.ts | 4 + src/lib/targets/build-response.ts | 2 + 33 files changed, 1007 insertions(+), 182 deletions(-) create mode 100644 prisma/migrations/0211_v125_med_pause_era/migration.sql create mode 100644 src/lib/analytics/__tests__/compliance-pause-era.test.ts diff --git a/prisma/migrations/0211_v125_med_pause_era/migration.sql b/prisma/migrations/0211_v125_med_pause_era/migration.sql new file mode 100644 index 000000000..11a18e6f1 --- /dev/null +++ b/prisma/migrations/0211_v125_med_pause_era/migration.sql @@ -0,0 +1,31 @@ +-- v1.25 — durable medication pause intervals (H-MED1). +-- +-- The `medications.paused_at` column is a single live marker: resume clears +-- it, so the paused window is irrecoverable once the medication resumes. +-- After a resume the compliance denominator spanned created_at..now and every +-- paused day's expected slot collapsed to "missed", deflating adherence. +-- +-- `medication_pause_eras` records each pause as its own durable row: a pause +-- stamps an open era (`resumed_at` NULL), resume closes the latest open era +-- (`resumed_at` = now). The compliance engine drops expected dose slots whose +-- anchor falls inside any `[paused_at, resumed_at ?? now)` interval so a +-- resumed medication never counts the paused days as missed. The `paused_at` +-- marker column on `medications` is left intact — current readers depend on it. +CREATE TABLE "medication_pause_eras" ( + "id" TEXT NOT NULL, + "medication_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "paused_at" TIMESTAMPTZ(6) NOT NULL, + "resumed_at" TIMESTAMPTZ(6), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "medication_pause_eras_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "medication_pause_eras_medication_id_idx" ON "medication_pause_eras"("medication_id"); + +CREATE INDEX "medication_pause_eras_user_id_idx" ON "medication_pause_eras"("user_id"); + +ALTER TABLE "medication_pause_eras" ADD CONSTRAINT "medication_pause_eras_medication_id_fkey" FOREIGN KEY ("medication_id") REFERENCES "medications"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "medication_pause_eras" ADD CONSTRAINT "medication_pause_eras_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5bbd4cb75..26e8c1281 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -583,6 +583,7 @@ model User { measurements Measurement[] medications Medication[] intakeEvents MedicationIntakeEvent[] + medicationPauseEras MedicationPauseEra[] apiTokens ApiToken[] // v1.24 — MCP remote OAuth 2.1 connections (1:N). Each row is a granted // authorization for an MCP client, audience-bound to health:read/health:write. @@ -1947,6 +1948,14 @@ model Medication { /// ranges into eras so past days read against the schedule that was /// live THEN, not the current one. scheduleRevisions MedicationScheduleRevision[] + /// v1.25 — durable pause intervals. A pause stamps an open era + /// (`resumedAt = null`); resume closes the latest open era. The + /// compliance engine drops expected slots whose anchor falls inside a + /// pause interval so a resumed medication never counts the paused days + /// as missed. The `pausedAt` column on this model stays the live + /// single-pause marker the reminder/today surfaces read; the era table + /// is the additive durable record the rate paths need. + pauseEras MedicationPauseEra[] user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -2097,6 +2106,35 @@ model MedicationScheduleRevision { @@map("medication_schedule_revisions") } +/// v1.25 — durable medication pause intervals. The `Medication.pausedAt` +/// column is a single live marker that resume clears, so the paused window +/// is irrecoverable once the medication resumes. This table records each +/// pause as its own row: a pause stamps an open era (`resumedAt = null`), +/// resume closes the latest open era (`resumedAt = now`). The compliance +/// engine reads the eras and drops expected dose slots whose anchor falls +/// inside any `[pausedAt, resumedAt ?? now)` interval so a resumed +/// medication never counts the paused days as missed. +model MedicationPauseEra { + id String @id @default(cuid()) + medicationId String @map("medication_id") + userId String @map("user_id") + /// Inclusive start of the pause interval (the instant the medication + /// was paused). + pausedAt DateTime @map("paused_at") @db.Timestamptz(6) + /// Exclusive end of the pause interval (the instant the medication was + /// resumed). NULL = still paused; readers treat an open era as running + /// to `now`. + resumedAt DateTime? @map("resumed_at") @db.Timestamptz(6) + createdAt DateTime @default(now()) @map("created_at") + + medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([medicationId]) + @@index([userId]) + @@map("medication_pause_eras") +} + enum IntakeSource { WEB API @@ -2553,21 +2591,21 @@ model TelegramPromptContext { // ─── API Tokens (for external ingest) ────────────────────── model ApiToken { - id String @id @default(cuid()) - userId String @map("user_id") - name String - tokenHash String @unique @map("token_hash") // SHA-256 of the token - permissions String[] @default(["medication:ingest"]) - lastUsedAt DateTime? @map("last_used_at") - expiresAt DateTime? @map("expires_at") - createdAt DateTime @default(now()) @map("created_at") - revoked Boolean @default(false) + id String @id @default(cuid()) + userId String @map("user_id") + name String + tokenHash String @unique @map("token_hash") // SHA-256 of the token + permissions String[] @default(["medication:ingest"]) + lastUsedAt DateTime? @map("last_used_at") + expiresAt DateTime? @map("expires_at") + createdAt DateTime @default(now()) @map("created_at") + revoked Boolean @default(false) // The MCP OAuth connection this access token belongs to, when it was minted // through the `/api/mcp/oauth` bridge. Null for manually-minted tokens and // native-client tokens. Lets a connection revoke kill every access row it // ever issued (H2), and lets the connector token list exclude the transient // 60-minute access rows so it does not flood (M2). - mcpConnectionId String? @map("mcp_connection_id") + mcpConnectionId String? @map("mcp_connection_id") user User @relation(fields: [userId], references: [id], onDelete: Cascade) mcpConnection McpOAuthConnection? @relation(fields: [mcpConnectionId], references: [id], onDelete: SetNull) diff --git a/src/app/api/insights/comprehensive/__tests__/route.test.ts b/src/app/api/insights/comprehensive/__tests__/route.test.ts index 97ea186cd..221b47d5f 100644 --- a/src/app/api/insights/comprehensive/__tests__/route.test.ts +++ b/src/app/api/insights/comprehensive/__tests__/route.test.ts @@ -128,6 +128,11 @@ beforeEach(() => { dateOfBirth: new Date("1985-01-01"), }); (prisma.moodEntry.findMany as ReturnType).mockResolvedValue([]); + // The resting-pulse pairing (M-CS2) reads RESTING_HEART_RATE rows (and the + // raw-PULSE proxy only when none exist) straight from the measurement table. + (prisma.measurement.findMany as ReturnType).mockResolvedValue( + [], + ); ( prisma.moodEntryRollup.findMany as ReturnType ).mockResolvedValue([]); diff --git a/src/app/api/insights/comprehensive/route.ts b/src/app/api/insights/comprehensive/route.ts index d59471136..5f5eb76ad 100644 --- a/src/app/api/insights/comprehensive/route.ts +++ b/src/app/api/insights/comprehensive/route.ts @@ -21,6 +21,7 @@ import { SCHEDULE_COMPLIANCE_SELECT, } from "@/lib/analytics/compliance"; import { getMedicationCategories } from "@/lib/medication-category"; +import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse"; import { apiHandler, requireAuth, type AuthContext } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { requireAssistantSurface } from "@/lib/feature-flags"; @@ -263,8 +264,55 @@ export async function buildComprehensiveResponse(user: AuthedUser) { weight: p.b, })); - const pulseDaily = aggregate.dailyByType.PULSE ?? []; - const moodPulsePairs = pairMoodWithDaily(dailyMoodEntries, pulseDaily); + // v1.2.5 (M-CS2) — this correlation is labelled "Resting pulse (bpm)"; + // score it against the RESTING series, not the raw PULSE day-mean. The + // aggregator's `dailyByType.PULSE` is a per-day AVG that folds in workout + // HR for Apple-Health users and inflates the "resting" figure. Prefer the + // clean RESTING_HEART_RATE rows; only pull RAW PULSE (for the + // low-percentile daily proxy) when the user has no resting rows — the + // proxy needs the per-day distribution, which the day-mean discards. + // Day keys follow this route's UTC-bucket convention (the per-user-tz + // bucketing is the documented v1.5 follow-up) so the resting series pairs + // against the same UTC day keys as the mood series. + const restingPulseRows = await prisma.measurement.findMany({ + where: { + userId, + type: "RESTING_HEART_RATE", + deletedAt: null, + measuredAt: { gte: ninetyDaysAgo }, + }, + select: { measuredAt: true, value: true }, + }); + const rawPulseRowsForResting = + restingPulseRows.length === 0 + ? await prisma.measurement.findMany({ + where: { + userId, + type: "PULSE", + deletedAt: null, + measuredAt: { gte: ninetyDaysAgo }, + }, + select: { measuredAt: true, value: true }, + }) + : []; + const utcDayKey = (d: Date) => d.toISOString().slice(0, 10); + const { series: restingPulseSeries } = resolveRestingPulseSeries({ + restingSamples: restingPulseRows, + pulseSamples: rawPulseRowsForResting, + dayKeyOf: utcDayKey, + }); + const restingByDay = new Map(); + for (const s of restingPulseSeries) { + const key = utcDayKey(s.measuredAt); + const cur = restingByDay.get(key) ?? { sum: 0, count: 0 }; + cur.sum += s.value; + cur.count += 1; + restingByDay.set(key, cur); + } + const restingPulseDaily = Array.from(restingByDay.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([day, stats]) => ({ day, value: stats.sum / stats.count })); + const moodPulsePairs = pairMoodWithDaily(dailyMoodEntries, restingPulseDaily); const moodPulseCorrelation = pearsonCorrelation(moodPulsePairs); const moodPulseScatterData = moodPulsePairs.map((p) => ({ mood: p.a, @@ -283,6 +331,8 @@ export async function buildComprehensiveResponse(user: AuthedUser) { schedules: { select: SCHEDULE_COMPLIANCE_SELECT }, // v1.16.3 — archived schedule eras for era-aware compliance. 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 categoryMap = await getMedicationCategories( diff --git a/src/app/api/medications/[id]/compliance/route.ts b/src/app/api/medications/[id]/compliance/route.ts index e6394b5e2..0a10216d2 100644 --- a/src/app/api/medications/[id]/compliance/route.ts +++ b/src/app/api/medications/[id]/compliance/route.ts @@ -71,6 +71,8 @@ export const GET = apiHandler( schedules: true, // v1.16.3 — archived schedule eras for era-aware compliance. scheduleRevisions: { orderBy: { validFrom: "asc" } }, + // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. + pauseEras: { select: { pausedAt: true, resumedAt: true } }, }, }); // Privacy gate — same sealed 404 for "absent" and "not yours". diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index c7fe4ecb3..ca2ec4e7a 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -679,6 +679,29 @@ export const PUT = apiHandler( } if (!medication) throw lastUpdateErr; + // v1.25 H-MED1 — durable pause intervals. The `pausedAt` column is a + // single live marker that resume clears, so the paused window is + // irrecoverable once resumed and every paused day's slot collapses to + // "missed" in the compliance denominator. Mirror the live transition + // onto an additive era record the rate paths read: a pause opens an era + // (resumedAt null), a resume closes the latest open era. `userId` comes + // from the authenticated session; the data object is built field-by-field. + if (active === false && existing.active) { + await prisma.medicationPauseEra.create({ + data: { + medicationId: id, + userId: user.id, + pausedAt: new Date(), + resumedAt: null, + }, + }); + } else if (active === true && !existing.active) { + await prisma.medicationPauseEra.updateMany({ + where: { medicationId: id, userId: user.id, resumedAt: null }, + data: { resumedAt: new Date() }, + }); + } + // Apply the v1.5.5 primary-schedule grace bridge after the // medication update so a failure here doesn't roll the medication // edit back. Re-read the row so the response shape carries the diff --git a/src/app/api/medications/compliance/route.ts b/src/app/api/medications/compliance/route.ts index 6ee1eebfa..6c6263e02 100644 --- a/src/app/api/medications/compliance/route.ts +++ b/src/app/api/medications/compliance/route.ts @@ -50,6 +50,8 @@ export const GET = apiHandler(async () => { schedules: true, // v1.16.3 — archived schedule eras for era-aware compliance. scheduleRevisions: { orderBy: { validFrom: "asc" } }, + // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. + pauseEras: { select: { pausedAt: true, resumedAt: true } }, }, orderBy: { createdAt: "desc" }, }); diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index 0a348d87b..d50e5766c 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -256,6 +256,8 @@ async function buildScheduleAnchoredComplianceBuckets( 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 } }, }, }); diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index 583a1b189..420aafeb7 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -1160,6 +1160,8 @@ async function buildCoachSnapshotImpl( supersededByRevisionId: true, }, }, + // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. + pauseEras: { select: { pausedAt: true, resumedAt: true } }, intakeEvents: { // Tombstoned intake rows must never reach the Coach snapshot. where: { deletedAt: null, scheduledFor: { gte: cutoff } }, diff --git a/src/lib/analytics/__tests__/compliance-pause-era.test.ts b/src/lib/analytics/__tests__/compliance-pause-era.test.ts new file mode 100644 index 000000000..a1a81f30c --- /dev/null +++ b/src/lib/analytics/__tests__/compliance-pause-era.test.ts @@ -0,0 +1,250 @@ +/** + * v1.25 correctness fixes — two compliance-denominator regressions. + * + * H-MED1: a paused medication's gap must NOT count as missed once the + * medication resumes. The pause interval lives in `MedicationPauseEra` + * (threaded onto `ComplianceMedicationContext.pauseEras`); the ledger drops + * expected slots whose anchor falls inside `[pausedAt, resumedAt ?? now)` so + * the denominator never inflates with the paused days. + * + * M-MED3: the compliance rate must honour archived schedule eras + * (`scheduleRevisions`). A past day scores against the schedule that was live + * THEN, not the current one. This pins that threading `scheduleRevisions` + * through `buildComplianceMedicationContext` changes the rate — the exact + * behaviour the Health-Score fast-path was missing (it never threaded eras). + * + * All instants are UTC; run under `TZ=UTC` so the day grid is deterministic. + */ +import { describe, it, expect } from "vitest"; + +import { + buildComplianceMedicationContext, + tallyComplianceFromLedger, + type ComplianceSchedule, + type MedicationPauseEraLike, +} from "../compliance"; +import type { ScheduleRevisionLike } from "@/lib/medications/scheduling/schedule-eras"; + +const TZ = "UTC"; + +/** A taken intake for the 08:00 slot of `day` (08:15 → on-time). */ +function taken08(day: Date) { + const scheduledFor = new Date(day); + scheduledFor.setUTCHours(8, 0, 0, 0); + const takenAt = new Date(scheduledFor.getTime() + 15 * 60_000); + return { scheduledFor, takenAt, skipped: false }; +} + +function dayUTC(y: number, m: number, d: number): Date { + return new Date(Date.UTC(y, m - 1, d, 0, 0, 0, 0)); +} + +describe("H-MED1 — paused days drop out of the compliance denominator", () => { + // Daily 08:00 medication. Window June 1 – June 28; the user takes every + // day EXCEPT June 10–19 (the pause window) where nothing is logged. + const createdAt = dayUTC(2026, 6, 1); + const endsOn = dayUTC(2026, 6, 28); + const from = dayUTC(2026, 6, 1); + const to = dayUTC(2026, 6, 29); + const now = dayUTC(2026, 6, 29); + + const schedules: ComplianceSchedule[] = [ + { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + rrule: "FREQ=DAILY", + rollingIntervalDays: null, + timesOfDay: ["08:00"], + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + doseWindows: null, + }, + ]; + + // Taken June 1–9 and June 20–28; nothing logged June 10–19 (paused). + const events = [ + ...[1, 2, 3, 4, 5, 6, 7, 8, 9].map((d) => taken08(dayUTC(2026, 6, d))), + ...[20, 21, 22, 23, 24, 25, 26, 27, 28].map((d) => + taken08(dayUTC(2026, 6, d)), + ), + ]; + + const pauseEras: MedicationPauseEraLike[] = [ + { pausedAt: dayUTC(2026, 6, 10), resumedAt: dayUTC(2026, 6, 20) }, + ]; + + const medBase = { + startsOn: createdAt, + endsOn, + oneShot: false, + createdAt, + }; + + it("WITHOUT pauseEras the paused days collapse to missed", () => { + const ctx = buildComplianceMedicationContext(medBase, null, TZ); + const tally = tallyComplianceFromLedger( + events, + schedules, + ctx, + from, + to, + now, + ); + // June 10–19 = 10 expected slots with no intake → 10 misses. + expect(tally.missed).toBe(10); + expect(tally.taken).toBe(18); + expect(tally.denominator).toBe(28); + }); + + it("WITH pauseEras the paused days are excluded → no missed", () => { + const ctx = buildComplianceMedicationContext( + { ...medBase, pauseEras }, + null, + TZ, + ); + const tally = tallyComplianceFromLedger( + events, + schedules, + ctx, + from, + to, + now, + ); + // The 10 paused slots are dropped from the ledger entirely. + expect(tally.missed).toBe(0); + expect(tally.taken).toBe(18); + expect(tally.denominator).toBe(18); + expect(tally.rate).toBe(100); + }); + + it("the resumed day (resumedAt boundary) still counts", () => { + // No intake on June 20 (the resume day) → it must count as missed: the + // exclusion is half-open `[pausedAt, resumedAt)`, so June 20 is live. + const eventsMissResumeDay = [ + ...[1, 2, 3, 4, 5, 6, 7, 8, 9].map((d) => taken08(dayUTC(2026, 6, d))), + ...[21, 22, 23, 24, 25, 26, 27, 28].map((d) => + taken08(dayUTC(2026, 6, d)), + ), + ]; + const ctx = buildComplianceMedicationContext( + { ...medBase, pauseEras }, + null, + TZ, + ); + const tally = tallyComplianceFromLedger( + eventsMissResumeDay, + schedules, + ctx, + from, + to, + now, + ); + // June 20 is past the pause window and was not taken → exactly 1 miss. + expect(tally.missed).toBe(1); + }); +}); + +describe("M-MED3 — the compliance rate honours archived schedule eras", () => { + // The user ran a TWICE-daily schedule (08:00 + 20:00) for the first half of + // the window, then edited it down to ONCE daily (08:00). Throughout, only + // the 08:00 dose is ever taken. With the era threaded, the old-era days + // expect two doses (one missed each); without it, every day is scored + // against the current one-dose schedule and the rate is a misleading 100 %. + const createdAt = dayUTC(2026, 6, 1); + const replaceAt = dayUTC(2026, 6, 15); // the edit instant + const endsOn = dayUTC(2026, 6, 28); + const from = dayUTC(2026, 6, 1); + const to = dayUTC(2026, 6, 29); + const now = dayUTC(2026, 6, 29); + + // Current (live) schedule: one dose at 08:00. + const liveSchedules: ComplianceSchedule[] = [ + { + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + rrule: "FREQ=DAILY", + rollingIntervalDays: null, + timesOfDay: ["08:00"], + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + doseWindows: null, + }, + ]; + + // Archived era: the superseded twice-daily schedule (08:00 + 20:00). + const oldEraRevision: ScheduleRevisionLike = { + id: "rev-twice-daily", + validFrom: createdAt, + validUntil: replaceAt, + payload: [ + { + timesOfDay: ["08:00", "20:00"], + windowStart: "08:00", + windowEnd: "20:00", + daysOfWeek: null, + rrule: "FREQ=DAILY", + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + doseWindows: null, + label: null, + dose: null, + reminderGraceMinutes: null, + }, + ], + }; + + // Only the 08:00 dose is taken, every day June 1–28. The 20:00 dose that + // the old era expected on June 1–14 is never taken. + const events = [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, + 22, 23, 24, 25, 26, 27, 28, + ].map((d) => taken08(dayUTC(2026, 6, d))); + + const medBase = { + startsOn: createdAt, + endsOn, + oneShot: false, + createdAt, + }; + + it("WITHOUT scheduleRevisions every day scores against the current one-dose schedule → 100 %", () => { + const ctx = buildComplianceMedicationContext(medBase, null, TZ); + const tally = tallyComplianceFromLedger( + events, + liveSchedules, + ctx, + from, + to, + now, + ); + expect(tally.missed).toBe(0); + expect(tally.rate).toBe(100); + }); + + it("WITH scheduleRevisions the old-era days expect the second dose → rate drops", () => { + const ctx = buildComplianceMedicationContext( + { ...medBase, scheduleRevisions: [oldEraRevision] }, + null, + TZ, + ); + const tally = tallyComplianceFromLedger( + events, + liveSchedules, + ctx, + from, + to, + now, + ); + // June 1–14 expected the 20:00 dose too; it was never taken → 14 misses. + expect(tally.missed).toBe(14); + expect(tally.rate).toBeLessThan(100); + // The era path strictly raises the denominator vs the no-era path. + expect(tally.denominator).toBeGreaterThan(28); + }); +}); diff --git a/src/lib/analytics/__tests__/correlations-fast-path.test.ts b/src/lib/analytics/__tests__/correlations-fast-path.test.ts index 3ef558d3b..5e447d2aa 100644 --- a/src/lib/analytics/__tests__/correlations-fast-path.test.ts +++ b/src/lib/analytics/__tests__/correlations-fast-path.test.ts @@ -77,8 +77,8 @@ describe("computeCorrelationHypothesesFastPath", () => { expect(CORRELATION_WINDOW_DAYS).toBe(28); }); - describe("rollup-fast-path — SYS / PULSE / WEIGHT covered", () => { - it("reads per-day means from measurement_rollups, never from measurements", async () => { + describe("rollup-fast-path — SYS / WEIGHT covered", () => { + it("reads SYS + WEIGHT per-day means from measurement_rollups (PULSE no longer)", async () => { const coverage = new Map([ ["BLOOD_PRESSURE_SYS", true], ["PULSE", true], @@ -88,7 +88,6 @@ describe("computeCorrelationHypothesesFastPath", () => { PROBE.mockResolvedValue(coverage); ROLLUP_FIND_MANY.mockResolvedValueOnce([]) // SYS buckets - .mockResolvedValueOnce([]) // PULSE buckets .mockResolvedValueOnce([]); // WEIGHT buckets const result = await computeCorrelationHypothesesFastPath({ @@ -101,9 +100,14 @@ describe("computeCorrelationHypothesesFastPath", () => { expect(result.path).toBe("rollup"); expect(result.windowDays).toBe(28); expect(result.degraded).toBe(false); - // No raw measurement reads when on rollup-fast-path. - expect(MEASUREMENT_FIND_MANY).not.toHaveBeenCalled(); - expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(3); + // v1.2.5 (M-CS2) — SYS + WEIGHT ride the rollup tier; PULSE no + // longer does. The mood × resting-pulse hypothesis instead reads the + // RESTING_HEART_RATE rows (and, only when absent, RAW PULSE for the + // low-percentile proxy) from `measurements`, so the rollup count + // drops to 2 and the resting reads land on `measurements`. + expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(2); + // RESTING read returns [] (default mock) → RAW PULSE proxy read too. + expect(MEASUREMENT_FIND_MANY).toHaveBeenCalledTimes(2); // Mood + intake reads always live. expect(MOOD_FIND_MANY).toHaveBeenCalledTimes(1); expect(INTAKE_FIND_MANY).toHaveBeenCalledTimes(1); @@ -145,9 +149,7 @@ describe("computeCorrelationHypothesesFastPath", () => { FULLY_COVERED.mockReturnValue(false); PROBE.mockResolvedValue(coverage); - MEASUREMENT_FIND_MANY.mockResolvedValueOnce([]) // SYS rows - .mockResolvedValueOnce([]) // PULSE rows - .mockResolvedValueOnce([]); // WEIGHT rows + MEASUREMENT_FIND_MANY.mockResolvedValue([]); // SYS / WEIGHT / RESTING / PULSE const result = await computeCorrelationHypothesesFastPath({ userId: "user-partial", @@ -157,7 +159,9 @@ describe("computeCorrelationHypothesesFastPath", () => { }); expect(result.path).toBe("live"); - expect(MEASUREMENT_FIND_MANY).toHaveBeenCalledTimes(3); + // SYS + WEIGHT (live series) + RESTING + RAW-PULSE proxy (resting + // series, since RESTING returns [] under the default mock) = 4 reads. + expect(MEASUREMENT_FIND_MANY).toHaveBeenCalledTimes(4); expect(ROLLUP_FIND_MANY).not.toHaveBeenCalled(); }); @@ -219,8 +223,9 @@ describe("computeCorrelationHypothesesFastPath", () => { }); expect(result.path).toBe("rollup"); - expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(3); - expect(MEASUREMENT_FIND_MANY).not.toHaveBeenCalled(); + expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(2); + // Resting-pulse reads (RESTING + RAW-PULSE proxy) land on measurements. + expect(MEASUREMENT_FIND_MANY).toHaveBeenCalledTimes(2); const calls = ANNOTATE.mock.calls.map((c) => c[0]); const corrCall = calls.find((c) => c?.meta?.correlations !== undefined); @@ -248,7 +253,7 @@ describe("computeCorrelationHypothesesFastPath", () => { }); expect(result.path).toBe("live"); - expect(MEASUREMENT_FIND_MANY).toHaveBeenCalledTimes(3); + expect(MEASUREMENT_FIND_MANY).toHaveBeenCalledTimes(4); expect(ROLLUP_FIND_MANY).not.toHaveBeenCalled(); const calls = ANNOTATE.mock.calls.map((c) => c[0]); @@ -280,4 +285,58 @@ describe("computeCorrelationHypothesesFastPath", () => { expect(ROLLUP_FIND_MANY).not.toHaveBeenCalled(); }); }); + + describe("mood × resting pulse uses the RESTING series, not the raw PULSE mean (M-CS2)", () => { + it("scores the low-percentile resting proxy, so workout HR can't inflate the pair", async () => { + // One day with a handful of resting-low readings buried under a dense + // workout burst. The OLD code paired mood against the day's raw PULSE + // MEAN (~120 bpm — workout-inflated). The fix routes through + // `resolveRestingPulseSeries`, whose proxy is the day's 20th-percentile + // (the resting floor), so the paired value sits in the ~50s, not ~120. + const day = new Date("2026-05-16T10:00:00.000Z"); // Berlin 12:00 → 05-16 + const pulseValues = [55, 57, 59, 150, 151, 152, 153, 154, 155, 156]; + const rawMean = + pulseValues.reduce((s, v) => s + v, 0) / pulseValues.length; // ~124.2 + + const coverage = new Map([ + ["BLOOD_PRESSURE_SYS", false], // force the live path + ]); + FULLY_COVERED.mockReturnValue(false); + PROBE.mockResolvedValue(coverage); + + MEASUREMENT_FIND_MANY.mockImplementation( + async (args: { + where?: { type?: string }; + }): Promise> => { + const type = args?.where?.type; + if (type === "PULSE") { + // No RESTING_HEART_RATE rows exist, so the proxy reads RAW PULSE. + return pulseValues.map((value, i) => ({ + id: `pulse-${i}`, + measuredAt: day, + value, + })); + } + // SYS / WEIGHT / RESTING_HEART_RATE → empty. + return []; + }, + ); + MOOD_FIND_MANY.mockResolvedValue([{ score: 4, moodLoggedAt: day }]); + + const result = await computeCorrelationHypothesesFastPath({ + userId: "user-resting", + userTz: "Europe/Berlin", + now: new Date("2026-05-17T12:00:00.000Z"), + coverage, + }); + + // n = 1 day → below the n>=20 surfacing gate, but the points preview + // still carries the paired resting value the runner computed. + expect(result.moodPulse.points).toHaveLength(1); + const y = result.moodPulse.points[0].y; + // The paired pulse is the resting floor (~50s), NOT the raw day-mean. + expect(y).toBeLessThan(70); + expect(y).toBeLessThan(rawMean - 40); + }); + }); }); diff --git a/src/lib/analytics/__tests__/correlations.test.ts b/src/lib/analytics/__tests__/correlations.test.ts index fdd7b0940..1e91db9f4 100644 --- a/src/lib/analytics/__tests__/correlations.test.ts +++ b/src/lib/analytics/__tests__/correlations.test.ts @@ -1,6 +1,11 @@ import { describe, it, expect } from "vitest"; import type { DataPoint } from "../trends"; -import { pairByTimestamp, pearsonCorrelation } from "../correlations"; +import { + pairByTimestamp, + pearsonCorrelation, + significantPearsonCorrelation, +} from "../correlations"; +import type { PairedPoint } from "../correlations"; function makePoints(values: number[], startDaysAgo = 30): DataPoint[] { const now = Date.now(); @@ -102,3 +107,42 @@ describe("pearsonCorrelation", () => { expect(result.strength).toBe("keine"); }); }); + +describe("significantPearsonCorrelation (M-CS3 — n>=20 AND p<0.05)", () => { + const mk = (a: number, b: number): PairedPoint => ({ + a, + b, + date: new Date(), + }); + + it("suppresses a 5-pair r≈0.7 dataset (below the n>=20 floor)", () => { + // Five points with a strong-looking slope — exactly the small-n fluke the + // plain `pearsonCorrelation` would surface as a "stark" correlation. + const pairs = [mk(1, 1), mk(2, 2), mk(3, 2), mk(4, 4), mk(5, 3)]; + // Sanity-check the legacy path WOULD have surfaced it (non-null, n=5). + const legacy = pearsonCorrelation(pairs); + expect(legacy).not.toBeNull(); + expect(legacy!.n).toBe(5); + // The gated path refuses it — too few pairs. + expect(significantPearsonCorrelation(pairs)).toBeNull(); + }); + + it("surfaces a >=20-pair significant dataset", () => { + // 20 points on a near-perfect line → high r, vanishingly small p. + const pairs = Array.from({ length: 20 }, (_, i) => + mk(i, i * 2 + (i % 2 === 0 ? 0.3 : -0.3)), + ); + const result = significantPearsonCorrelation(pairs); + expect(result).not.toBeNull(); + expect(result!.n).toBe(20); + expect(result!.strength).toBe("stark"); + expect(result!.r).toBeGreaterThan(0.9); + }); + + it("suppresses a 20-pair NON-significant (noisy) dataset", () => { + // 20 points with no real relationship → p well above 0.05. + const ys = [5, 2, 7, 1, 6, 3, 8, 2, 5, 4, 6, 1, 7, 3, 5, 2, 6, 4, 5, 3]; + const pairs = ys.map((y, i) => mk(i, y)); + expect(significantPearsonCorrelation(pairs)).toBeNull(); + }); +}); diff --git a/src/lib/analytics/compliance.ts b/src/lib/analytics/compliance.ts index d7d4be0a6..3dd7962ee 100644 --- a/src/lib/analytics/compliance.ts +++ b/src/lib/analytics/compliance.ts @@ -436,6 +436,24 @@ export interface ComplianceMedicationContext { * callers without revisions keep the live-only expansion. */ scheduleRevisions?: ScheduleRevisionLike[]; + /** + * v1.25 H-MED1 — durable pause intervals (`MedicationPauseEra`). When + * present, expected dose slots whose anchor falls inside any + * `[pausedAt, resumedAt ?? now)` interval are dropped from the ledger so a + * resumed medication never counts the paused days as missed. Optional: + * callers without eras keep counting every expected slot. + */ + pauseEras?: MedicationPauseEraLike[]; +} + +/** + * v1.25 H-MED1 — the pause-interval projection the compliance engine reads. + * A Prisma `MedicationPauseEra` row (or a `{ pausedAt, resumedAt }` literal) + * drops in. An open era (`resumedAt === null`) runs to `now`. + */ +export interface MedicationPauseEraLike { + pausedAt: Date; + resumedAt: Date | null; } /** @@ -452,6 +470,8 @@ export function buildComplianceMedicationContext( createdAt: Date; /** v1.16.3 — thread the archived eras when the bundle carries them. */ scheduleRevisions?: ScheduleRevisionLike[]; + /** v1.25 H-MED1 — thread the pause eras when the caller carries them. */ + pauseEras?: MedicationPauseEraLike[]; }, lastIntakeAt: Date | null, timeZone: string, @@ -464,6 +484,7 @@ export function buildComplianceMedicationContext( lastIntakeAt, timeZone, ...(med.scheduleRevisions && { scheduleRevisions: med.scheduleRevisions }), + ...(med.pauseEras && { pauseEras: med.pauseEras }), }; } @@ -1348,7 +1369,41 @@ export function buildComplianceLedgerRows( pinned: e.attributionSource === "USER_PIN", })); - return reconstructDoseHistory(bands, intakes, now); + const rows = reconstructDoseHistory(bands, intakes, now); + + // v1.25 H-MED1 — drop expected dose slots whose anchor falls inside a + // pause interval. While a medication is paused no dose is expected, so a + // slot minted across the paused window must never count as "missed" (the + // denominator-inflating status). Only `slot` rows are dropped, and only + // those whose status feeds the tally (taken / missed) — skip / ad-hoc / + // upcoming rows are already excluded from the denominator, so dropping + // them here would be a redundant double-exclusion. An open era + // (`resumedAt === null`) runs to `now`. + const pauseEras = ctx.pauseEras; + if (pauseEras && pauseEras.length > 0) { + const isInPause = (at: Date): boolean => { + const t = at.getTime(); + for (const era of pauseEras) { + const start = era.pausedAt.getTime(); + const end = (era.resumedAt ?? now).getTime(); + if (t >= start && t < end) return true; + } + return false; + }; + return rows.filter((row) => { + if (row.kind !== "slot") return true; + if ( + row.status !== "taken_on_time" && + row.status !== "taken_late" && + row.status !== "missed" + ) { + return true; + } + return !isInPause(row.at); + }); + } + + return rows; } /** diff --git a/src/lib/analytics/correlations-fast-path.ts b/src/lib/analytics/correlations-fast-path.ts index b09a89bc5..79370b404 100644 --- a/src/lib/analytics/correlations-fast-path.ts +++ b/src/lib/analytics/correlations-fast-path.ts @@ -23,14 +23,17 @@ * The meta-dict annotate carries `window_days: 28` as a sentinel * so the UI / docs can quote the truthful window. * - * 2. **Probe-gated SYS / PULSE / WEIGHT** — when DAY-bucket coverage - * exists for all three types the runner hydrates the per-day-mean - * maps from `measurement_rollups`. Each rollup row already + * 2. **Probe-gated SYS / WEIGHT** — when DAY-bucket coverage exists + * for both types the runner hydrates the per-day-mean / per-day + * weight maps from `measurement_rollups`. Each rollup row already * carries the day's `mean` value computed by Postgres' `AVG`, so * the pairing logic is byte-for-byte identical to the live path's - * "average the day's raw values" step. + * "average the day's raw values" step. PULSE is intentionally NOT + * hydrated from the rollup tier — the mood × resting-pulse + * hypothesis scores the RESTING series (RESTING_HEART_RATE rows, + * else a low-percentile RAW-PULSE proxy), never the day-mean. * - * 3. **Mood + medication intake** — no rollup equivalent today; both + * 3. **Mood + medication intake + resting pulse** — no rollup equivalent today; both * reads stay live regardless of coverage. The mood read is small * (per-day entries, bounded by the 28-day window) and the * medication intake read scales with active medications × 28 days @@ -57,6 +60,7 @@ import { correlateWeightWeekday, type CorrelationResult, } from "@/lib/insights/correlations"; +import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse"; import { isNearUtc, userDayKey } from "@/lib/tz/resolver"; import { wallClockInTz } from "@/lib/tz/wall-clock"; @@ -141,19 +145,24 @@ export async function computeCorrelationHypothesesFastPath( // local-day buckets and this guard can come back down. const userNearUtc = isNearUtc(userTz, now); - // v1.4.38.8 — gate only on the three types this helper actually - // reads (SYS / PULSE / WEIGHT). The prior `isFullyCovered &&` AND - // poisoned the correlations fast-path with any unrelated brand-new - // type the user had logged, even when SYS + PULSE + WEIGHT were - // fully covered. Per-type coverage is the correct gate. + // v1.4.38.8 — gate only on the types this helper reads from the rollup + // tier (SYS / WEIGHT). The prior `isFullyCovered &&` AND poisoned the + // correlations fast-path with any unrelated brand-new type the user had + // logged, even when those were fully covered. Per-type coverage is the + // correct gate. + // + // v1.2.5 (M-CS2) — PULSE is no longer hydrated from the rollup tier: + // the mood × "resting pulse" hypothesis now scores the RESTING series + // (RESTING_HEART_RATE rows, else a low-percentile PULSE proxy from RAW + // samples), not the day-mean PULSE the rollup carries. A day-mean folds + // in workout HR and inflates the "resting" figure for Apple-Health + // users, so the rollup pulse-mean is structurally the wrong input here. const measurementsOnRollups = userNearUtc && coverage.get("BLOOD_PRESSURE_SYS") === true && - coverage.get("PULSE") === true && coverage.get("WEIGHT") === true; let dailySysMean: Map; - let dailyPulseMean: Map; let weightRows: ChunkedRow[]; if (measurementsOnRollups) { @@ -173,7 +182,7 @@ export async function computeCorrelationHypothesesFastPath( // v1.11.2 — load the source-priority blob once and thread it into every // reader so the per-type fan-out doesn't re-query the user 3×. const priority = await loadUserSourcePriority(userId); - const [sysBuckets, pulseBuckets, weightBuckets] = await Promise.all([ + const [sysBuckets, weightBuckets] = await Promise.all([ readRollupBuckets( userId, "BLOOD_PRESSURE_SYS", @@ -182,17 +191,12 @@ export async function computeCorrelationHypothesesFastPath( now, priority, ), - readRollupBuckets(userId, "PULSE", "DAY", since, now, priority), readRollupBuckets(userId, "WEIGHT", "DAY", since, now, priority), ]); dailySysMean = new Map(); for (const b of sysBuckets) { dailySysMean.set(userDayKey(b.bucketStart, userTz), b.mean); } - dailyPulseMean = new Map(); - for (const b of pulseBuckets) { - dailyPulseMean.set(userDayKey(b.bucketStart, userTz), b.mean); - } // The weekday hypothesis needs the per-event weight series for // ANOVA — the runner expects one row per measurement so the group // counts reflect reality. Hydrate from buckets by emitting one @@ -207,9 +211,8 @@ export async function computeCorrelationHypothesesFastPath( })); } else { // Live fallback — chunked reads against the measurements table. - const [sysRows, pulseRows, weightRowsLive] = await Promise.all([ + const [sysRows, weightRowsLive] = await Promise.all([ fetchSeriesChunked(userId, "BLOOD_PRESSURE_SYS", since), - fetchSeriesChunked(userId, "PULSE", since), fetchSeriesChunked(userId, "WEIGHT", since), ]); dailySysMean = new Map(); @@ -224,21 +227,39 @@ export async function computeCorrelationHypothesesFastPath( const sum = list.reduce((s, v) => s + v, 0); dailySysMean.set(key, sum / list.length); } - dailyPulseMean = new Map(); - const pulseBuckets = new Map(); - for (const r of pulseRows) { - const key = userDayKey(r.measuredAt, userTz); - const list = pulseBuckets.get(key) ?? []; - list.push(r.value); - pulseBuckets.set(key, list); - } - for (const [key, list] of pulseBuckets.entries()) { - const sum = list.reduce((s, v) => s + v, 0); - dailyPulseMean.set(key, sum / list.length); - } weightRows = weightRowsLive; } + // v1.2.5 (M-CS2) — resolve the RESTING-pulse series the mood × pulse + // hypothesis scores against. Prefer the clean RESTING_HEART_RATE rows; + // only when the user has none do we pull RAW PULSE samples for the + // low-percentile daily proxy (the proxy needs the per-day distribution, + // not the day-mean — a day-mean folds in workout HR). Both reads are + // raw because the proxy floor is a percentile, not an AVG. Keying the + // proxy on `userDayKey(d, userTz)` aligns its buckets with the mood / + // intake day keys used below. + const restingSamples = await fetchSeriesChunked( + userId, + "RESTING_HEART_RATE", + since, + ); + const pulseSamplesForResting = + restingSamples.length === 0 + ? await fetchSeriesChunked(userId, "PULSE", since) + : []; + const { series: restingPulseSeries } = resolveRestingPulseSeries({ + restingSamples, + pulseSamples: pulseSamplesForResting, + dayKeyOf: (d) => userDayKey(d, userTz), + }); + // One resting figure per user-local day. RESTING_HEART_RATE can carry + // more than one row/day (rare); last-write-wins is fine for a single + // daily resting figure, and the proxy already emits one row per day. + const dailyRestingPulse = new Map(); + for (const s of restingPulseSeries) { + dailyRestingPulse.set(userDayKey(s.measuredAt, userTz), s.value); + } + // Mood + intake — always live. Both reads are small (28-day window, // narrow projections) and neither has a rollup equivalent today. const [moodRows, intakeRows] = await Promise.all([ @@ -298,13 +319,13 @@ export async function computeCorrelationHypothesesFastPath( restingPulse: number; }> = []; for (const [key, moodScores] of dailyMood.entries()) { - const meanPulse = dailyPulseMean.get(key); - if (meanPulse === undefined) continue; + const restingPulse = dailyRestingPulse.get(key); + if (restingPulse === undefined) continue; const meanMood = moodScores.reduce((s, v) => s + v, 0) / moodScores.length; moodPulsePairs.push({ date: dateFromDayKey(key), mood: meanMood, - restingPulse: meanPulse, + restingPulse, }); } const moodPulse = correlateMoodPulse({ daily: moodPulsePairs }); @@ -349,7 +370,7 @@ export async function computeCorrelationHypothesesFastPath( async function fetchSeriesChunked( userId: string, - type: "BLOOD_PRESSURE_SYS" | "PULSE" | "WEIGHT", + type: "BLOOD_PRESSURE_SYS" | "PULSE" | "WEIGHT" | "RESTING_HEART_RATE", since: Date, ): Promise { const out: ChunkedRow[] = []; diff --git a/src/lib/analytics/correlations.ts b/src/lib/analytics/correlations.ts index 1becbeaec..10270f1f5 100644 --- a/src/lib/analytics/correlations.ts +++ b/src/lib/analytics/correlations.ts @@ -4,6 +4,7 @@ */ import type { DataPoint } from "./trends"; +import { pearson, MAX_P_VALUE } from "@/lib/insights/correlations"; export interface PairedPoint { a: number; @@ -104,3 +105,49 @@ export function pearsonCorrelation( return { r, strength, n }; } + +/** + * v1.2.5 (M-CS3) — significance-gated Pearson for USER-FACING surfaces. + * + * The plain `pearsonCorrelation` above assigns a strength band from |r| + * alone with no significance test and a default floor of only 5 pairs — + * so a 5-point r ≈ 0.7 fluke surfaces as a "stark" correlation. Every + * surfaced insight-card / status-snapshot correlation must instead clear + * the same rigorous bar the `/insights` correlation engine enforces: + * + * - n >= 20 paired points (the engine's `MIN_PAIRED_N`), AND + * - a two-sided Student-t p-value < 0.05 (`MAX_P_VALUE`). + * + * Below either bar this returns `null` so the caller suppresses the + * correlation exactly as it already does for the < minPairs case — no + * call-site shape change. The returned `{ r, strength, n }` keeps the + * legacy shape (German strength band) so the snapshot byte-shape is + * unchanged on the happy path. + * + * This routes through the engine's `pearson` (exact regularised- + * incomplete-beta p-value) rather than re-deriving the t-test, so the + * surface decision shares ONE definition with the dedicated cards. + */ +export function significantPearsonCorrelation( + pairs: PairedPoint[], +): CorrelationResult | null { + const result = pearson({ + xs: pairs.map((p) => p.a), + ys: pairs.map((p) => p.b), + }); + // `pearson` defaults `minPairs` to MIN_PAIRED_N (20); below it (or with + // zero variance) it reports `insufficient` and we suppress the card. + if (result.status !== "ok") return null; + // Significance gate — a non-significant coefficient never surfaces. + if (result.pValue >= MAX_P_VALUE) return null; + + const r = Math.round(result.r * 1000) / 1000; + const absR = Math.abs(r); + let strength: CorrelationResult["strength"]; + if (absR >= 0.7) strength = "stark"; + else if (absR >= 0.4) strength = "moderat"; + else if (absR >= 0.2) strength = "schwach"; + else strength = "keine"; + + return { r, strength, n: result.n }; +} diff --git a/src/lib/analytics/health-score-fast-path.ts b/src/lib/analytics/health-score-fast-path.ts index 4659d0169..a74b3f929 100644 --- a/src/lib/analytics/health-score-fast-path.ts +++ b/src/lib/analytics/health-score-fast-path.ts @@ -50,6 +50,7 @@ import { buildComplianceMedicationContext, calculateCompliance, lastNonSkippedTakenAt, + SCHEDULE_COMPLIANCE_SELECT, } from "./compliance"; import { resolveUserTimezone } from "@/lib/tz/resolver"; import { @@ -345,29 +346,26 @@ export async function computeUserHealthScoreFastPath( startsOn: true, endsOn: true, oneShot: true, - schedules: { + // v1.25 M-MED3 — route through the SHARED compliance select like every + // other caller, so the Health-Score pillar honours the same per-dose + // on-time windows + cadence fields the dashboard / detail surfaces use. + schedules: { select: SCHEDULE_COMPLIANCE_SELECT }, + // v1.25 M-MED3 — archived schedule eras so a past day scores against + // the schedule that was live THEN, not the current one (every other + // compliance caller already threads these). The builder copies them + // through into the context. + scheduleRevisions: { + orderBy: { validFrom: "asc" }, select: { - windowStart: true, - windowEnd: true, - // v1.5.0 — cadence-aware compliance reads daysOfWeek so a - // weekly med (Mondays only) doesn't get a 30-day denominator - // that depresses the score by ~85 percentage points. Closes #214. - daysOfWeek: true, - // v1.7.0 SB-SCHED-2 — widen the select so the engine reads - // RRULE / rolling / PRN / cyclic, not just the legacy - // daysOfWeek string. - rrule: true, - rollingIntervalDays: true, - timesOfDay: true, - reminderGraceMinutes: true, - scheduleType: true, - cyclicOnWeeks: true, - cyclicOffWeeks: true, - // v1.15.18 — per-dose window so the score's compliance honours the - // same on-time band the detail % + history use. - doseWindows: true, + id: true, + validFrom: true, + validUntil: true, + payload: true, + supersededByRevisionId: true, }, }, + // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. + pauseEras: { select: { pausedAt: true, resumedAt: true } }, }, }), ]); @@ -428,6 +426,16 @@ export async function computeUserHealthScoreFastPath( takenAt: e.takenAt ? new Date(e.takenAt.getTime() + 7 * DAY_MS) : null, skipped: e.skipped, })); + // v1.25 M-MED3 — `med` (and so its scheduleRevisions + pauseEras) is + // passed UNshifted while the intake events shift +7d (the pre-existing + // approximation that lets the pinned `now` anchor still capture the prior + // logical 30 days). The revisions / pause eras are absolute historical + // records; no canonical previous-window caller shifts them, so they stay + // as-is here for consistency with the rest of the engine. Trade-off: in + // the trailing-30-day DELTA the era / pause segmentation can be up to 7 + // days out of phase with the shifted slots. The primary (current-window) + // score — `medicationCompliance30` above — is exact; only the secondary + // trend delta carries this best-effort approximation. const medicationContext = buildComplianceMedicationContext( med, lastNonSkippedTakenAt(shifted), diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts index bc2fc0702..345f974e8 100644 --- a/src/lib/doctor-report-data.ts +++ b/src/lib/doctor-report-data.ts @@ -47,6 +47,7 @@ import { tallyComplianceFromLedger, type ComplianceSchedule, type IntakeEvent, + type MedicationPauseEraLike, } from "@/lib/analytics/compliance"; import type { ScheduleRevisionLike } from "@/lib/medications/scheduling/schedule-eras"; @@ -639,6 +640,8 @@ export interface DoctorReportComplianceMedication { createdAt: Date; schedules: ComplianceSchedule[]; scheduleRevisions?: ScheduleRevisionLike[]; + /** v1.25 H-MED1 — pause eras so paused days drop out of the denominator. */ + pauseEras?: MedicationPauseEraLike[]; } /** A window-bounded intake row keyed to its medication. */ @@ -797,6 +800,8 @@ export async function collectDoctorReportData( supersededByRevisionId: true, }, }, + // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. + pauseEras: { select: { pausedAt: true, resumedAt: true } }, // v1.4.25 W4d — eager-load dose history + recent intake site // for any active medication. Generic meds carry empty arrays // so the legacy data path is byte-identical. @@ -965,6 +970,7 @@ export async function collectDoctorReportData( createdAt: m.createdAt, schedules: m.schedules, scheduleRevisions: m.scheduleRevisions, + pauseEras: m.pauseEras, })), intakeEvents.map((e) => ({ medicationId: e.medicationId, diff --git a/src/lib/insights/__tests__/bucket-series.test.ts b/src/lib/insights/__tests__/bucket-series.test.ts index cea7be1f9..2ddde27d9 100644 --- a/src/lib/insights/__tests__/bucket-series.test.ts +++ b/src/lib/insights/__tests__/bucket-series.test.ts @@ -137,6 +137,69 @@ describe("dayOffsetToBerlinDayKey() across DST boundaries", () => { }); }); +describe("bucketSeries() honours the per-user timezone (M-TZ3)", () => { + // A reading at 23:30 in America/Los_Angeles (PDT, UTC-7) on 2026-05-09 is + // already 08:30 on 2026-05-10 in Europe/Berlin (CEST, UTC+2). The same + // raw instant therefore lands on a different calendar day — and a + // different `dayOffset` — depending on which timezone the buckets key in. + const reading = new Date("2026-05-10T06:30:00.000Z"); // LA 05-09 23:30 + const now = new Date("2026-05-10T20:00:00.000Z"); // LA 05-10 13:00 + + it("buckets a near-midnight reading on the user's LOCAL day for LA", () => { + const { daily } = bucketSeries([{ measuredAt: reading, value: 42 }], { + now, + tz: "America/Los_Angeles", + }); + // LA: now is 05-10, reading is 05-09 → one local day ago. + expect(daily).toHaveLength(1); + expect(daily[0].dayOffset).toBe(1); + }); + + it("gives a DIFFERENT dayOffset for the same reading under Berlin", () => { + const { daily } = bucketSeries([{ measuredAt: reading, value: 42 }], { + now, + tz: "Europe/Berlin", + }); + // Berlin: both now and reading fall on 05-10 → same local day (offset 0). + expect(daily).toHaveLength(1); + expect(daily[0].dayOffset).toBe(0); + }); + + it("leaves a Berlin user's bucketing identical to the default", () => { + const records = pointsAtOffsets(now, [0, 1, 5], 100).concat([ + { measuredAt: reading, value: 42 }, + ]); + const def = bucketSeries(records, { now }); + const berlin = bucketSeries(records, { now, tz: "Europe/Berlin" }); + expect(berlin).toEqual(def); + }); + + it("dayOffsetToBerlinDayKey resolves the offset in the passed timezone", () => { + // `now` is 05-09 in LA but 05-10 in Berlin. + expect(dayOffsetToBerlinDayKey(reading, 0, "America/Los_Angeles")).toBe( + "2026-05-09", + ); + // Default (no tz) and explicit Berlin agree. + expect(dayOffsetToBerlinDayKey(reading, 0)).toBe("2026-05-10"); + expect(dayOffsetToBerlinDayKey(reading, 0, "Europe/Berlin")).toBe( + "2026-05-10", + ); + }); + + it("applyPayloadBudget threads the timezone through to the buckets", () => { + const la = applyPayloadBudget([{ measuredAt: reading, value: 42 }], { + now, + tz: "America/Los_Angeles", + }); + const berlin = applyPayloadBudget([{ measuredAt: reading, value: 42 }], { + now, + tz: "Europe/Berlin", + }); + expect(la.daily[0].dayOffset).toBe(1); + expect(berlin.daily[0].dayOffset).toBe(0); + }); +}); + describe("applyPayloadBudget()", () => { const now = new Date("2026-05-09T12:00:00Z"); diff --git a/src/lib/insights/blood-pressure-status.ts b/src/lib/insights/blood-pressure-status.ts index c79383912..991eba764 100644 --- a/src/lib/insights/blood-pressure-status.ts +++ b/src/lib/insights/blood-pressure-status.ts @@ -16,7 +16,7 @@ import { getRelevantCorrelationsForMetric } from "@/lib/insights/metric-correlat import { getBpTargets } from "@/lib/analytics/bp-targets"; import { isBpReadingInTarget } from "@/lib/analytics/bp-in-target"; import { - pearsonCorrelation, + significantPearsonCorrelation, type PairedPoint, } from "@/lib/analytics/correlations"; import { @@ -25,7 +25,6 @@ import { lastNonSkippedTakenAt, SCHEDULE_COMPLIANCE_SELECT, } from "@/lib/analytics/compliance"; -import { resolveUserTimezone } from "@/lib/tz/resolver"; import { getMedicationCategories } from "@/lib/medication-category"; import { sanitizeForPrompt } from "@/lib/insights/sanitize"; import { getNoKeyBloodPressureStatusText } from "@/lib/insights/no-key-fallbacks"; @@ -63,7 +62,12 @@ import { type StatusCardResult, } from "@/lib/insights/status-card-generation"; import { annotate } from "@/lib/logging/context"; -import { toBerlinDayKey } from "@/lib/tz/resolver"; +import { + resolveUserTimezone, + toBerlinDayKey, + userDayKey, +} from "@/lib/tz/resolver"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; /** * Cap on the embedded correlation / paired-daily arrays. The Pearson @@ -83,6 +87,7 @@ function pairDailyBuckets( seriesA: DailyBucket[], seriesB: DailyBucket[], now: Date, + tz: string = DEFAULT_TIMEZONE, ): Array { const mapB = new Map(seriesB.map((entry) => [entry.dayOffset, entry.value])); @@ -90,7 +95,7 @@ function pairDailyBuckets( .map((entry) => { const b = mapB.get(entry.dayOffset); if (b == null) return null; - const dayKey = dayOffsetToBerlinDayKey(now, entry.dayOffset); + const dayKey = dayOffsetToBerlinDayKey(now, entry.dayOffset, tz); // UTC midnight of the Berlin day — formatting this Date with // `toBerlinDayKey()` is guaranteed DST-safe because the y-m-d // fields below are the Berlin calendar day fields by construction. @@ -243,6 +248,12 @@ export async function prepareBloodPressureStatusForUser( const now = new Date(); + // v1.7.0 SB-SCHED-2 / v1.2.5 (M-TZ3) — resolve the user timezone once, + // up front, so BOTH the BP-status compliance gate AND every day-bucketing + // pass below (applyPayloadBudget, pairDailyBuckets, the continuity series) + // key on the user's own calendar day, not Berlin's. + const userTz = await resolveUserTimezone(userId); + const weightSeries = applyPayloadBudget( measurements .filter((measurement) => measurement.type === "WEIGHT") @@ -250,7 +261,7 @@ export async function prepareBloodPressureStatusForUser( measuredAt: measurement.measuredAt, value: measurement.value, })), - { now }, + { now, tz: userTz }, ); const sysSeries = applyPayloadBudget( @@ -260,7 +271,7 @@ export async function prepareBloodPressureStatusForUser( measuredAt: measurement.measuredAt, value: measurement.value, })), - { now }, + { now, tz: userTz }, ); const diaSeries = applyPayloadBudget( @@ -270,7 +281,7 @@ export async function prepareBloodPressureStatusForUser( measuredAt: measurement.measuredAt, value: measurement.value, })), - { now }, + { now, tz: userTz }, ); const bpTargets = getBpTargets(user?.dateOfBirth ?? null); @@ -278,6 +289,7 @@ export async function prepareBloodPressureStatusForUser( sysSeries.daily, diaSeries.daily, now, + userTz, ).map((entry) => ({ day: entry.dayKey, sys: entry.a, @@ -305,8 +317,11 @@ export async function prepareBloodPressureStatusForUser( weightSeries.daily, sysSeries.daily, now, + userTz, + ); + const weightVsSystolicCorrelation = significantPearsonCorrelation( + weightVsSystolicPairs, ); - const weightVsSystolicCorrelation = pearsonCorrelation(weightVsSystolicPairs); const activeMedications = await prisma.medication.findMany({ // v1.16.11 — as-needed (PRN) medications never feed the BP-status @@ -318,6 +333,8 @@ export async function prepareBloodPressureStatusForUser( schedules: { select: SCHEDULE_COMPLIANCE_SELECT }, // v1.16.3 — archived schedule eras for era-aware compliance. scheduleRevisions: { orderBy: { validFrom: "asc" } }, + // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. + pauseEras: { select: { pausedAt: true, resumedAt: true } }, }, }); @@ -357,10 +374,6 @@ export async function prepareBloodPressureStatusForUser( }, }); - // v1.7.0 SB-SCHED-2 — resolve the user timezone once so the BP-status - // compliance gate routes its denominator through the canonical engine. - const userTz = await resolveUserTimezone(userId); - const medicationCompliance = bpMedications.map((medication) => { const eventsForMedication = bpMedicationEvents .filter((event) => event.medicationId === medication.id) @@ -406,13 +419,15 @@ export async function prepareBloodPressureStatusForUser( const takenByDay = new Map(); for (const event of bpMedicationEvents) { if (event.skipped || !event.takenAt) continue; - const dayKey = toBerlinDayKey(event.scheduledFor); + // Key on the user's own calendar day so the continuity tally aligns + // with the user-tz day keys the systolic series is bucketed under. + const dayKey = userDayKey(event.scheduledFor, userTz); takenByDay.set(dayKey, (takenByDay.get(dayKey) ?? 0) + 1); } const continuityVsSystolicSeries = sysSeries.daily.map((point) => { // DST-safe: dayOffsetToBerlinDayKey computes calendar days, not 24h ticks. - const dayKey = dayOffsetToBerlinDayKey(now, point.dayOffset); + const dayKey = dayOffsetToBerlinDayKey(now, point.dayOffset, userTz); const taken = takenByDay.get(dayKey) ?? 0; const continuityPct = expectedBpIntakesPerDay > 0 @@ -443,7 +458,7 @@ export async function prepareBloodPressureStatusForUser( measuredAt: entry.moodLoggedAt, value: entry.score, })), - { now }, + { now, tz: userTz }, ); const moodSummary = summarizeSeries( moodSeries.daily.map((bucket) => ({ value: bucket.value })), @@ -456,7 +471,7 @@ export async function prepareBloodPressureStatusForUser( const gradedFromDaily = (buckets: DailyBucket[]) => buildGradedSeriesFromPoints( buckets.map((b) => ({ - measuredAt: new Date(dayOffsetToBerlinDayKey(now, b.dayOffset)), + measuredAt: new Date(dayOffsetToBerlinDayKey(now, b.dayOffset, userTz)), value: b.value, })), now, @@ -483,7 +498,7 @@ export async function prepareBloodPressureStatusForUser( }; }) .filter((entry): entry is PairedPoint => entry !== null); - const continuityVsSystolicCorrelation = pearsonCorrelation( + const continuityVsSystolicCorrelation = significantPearsonCorrelation( continuityVsSystolicPairs, ); @@ -600,8 +615,9 @@ export async function prepareBloodPressureStatusForUser( moodSeries.daily, sysSeries.daily, now, + userTz, ); - return pearsonCorrelation(moodVsSysPairs); + return significantPearsonCorrelation(moodVsSysPairs); })(), } : null, diff --git a/src/lib/insights/bmi-status.ts b/src/lib/insights/bmi-status.ts index 269b87193..ddbb0c5e2 100644 --- a/src/lib/insights/bmi-status.ts +++ b/src/lib/insights/bmi-status.ts @@ -43,7 +43,7 @@ import { type StatusCardResult, } from "@/lib/insights/status-card-generation"; import { annotate } from "@/lib/logging/context"; -import { toBerlinDayKey } from "@/lib/tz/resolver"; +import { DEFAULT_TIMEZONE, toBerlinDayKey } from "@/lib/tz/resolver"; /** * Public entry — unchanged signature. Prepares the card (cache-read, @@ -160,6 +160,7 @@ export async function prepareBmiStatusForUser( where: { id: userId }, select: { heightCm: true, + timezone: true, }, }); @@ -231,6 +232,9 @@ export async function prepareBmiStatusForUser( .then((rows) => rows.reverse()); const now = new Date(); + // v1.2.5 (M-TZ3) — bucket day windows on the user's own calendar so a + // near-midnight reading lands on the user's day for non-Berlin self-hosters. + const userTz = user.timezone ?? DEFAULT_TIMEZONE; const heightFactor = (user.heightCm / 100) ** 2; const bmiPoints = measurements.map((measurement) => ({ @@ -239,7 +243,7 @@ export async function prepareBmiStatusForUser( })); // `applyPayloadBudget` daily buckets drive the latest/previous focus; // the compact graded series is what reaches the prompt. - const weightSeries = applyPayloadBudget(bmiPoints, { now }); + const weightSeries = applyPayloadBudget(bmiPoints, { now, tz: userTz }); // BMI has no rollup tier of its own — it is weight ÷ height², a linear // transform by the per-user height factor. Read the WEIGHT tier (recent // / weekly from a bounded raw read, monthly / yearly from MONTH / YEAR) diff --git a/src/lib/insights/bucket-series.ts b/src/lib/insights/bucket-series.ts index 29744f89b..0b100524b 100644 --- a/src/lib/insights/bucket-series.ts +++ b/src/lib/insights/bucket-series.ts @@ -16,12 +16,12 @@ * does not produce 360 zero-rows of noise. */ -import { toBerlinYmd as toBerlinYmdStrings } from "@/lib/tz/resolver"; +import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format"; const MS_PER_DAY = 24 * 60 * 60 * 1000; export interface DailyBucket { - /** 0 = today (Berlin), 359 = 359 days ago. */ + /** 0 = today (user-local), 359 = 359 days ago. */ dayOffset: number; value: number; n: number; @@ -51,26 +51,27 @@ export interface BucketOptions { monthlyMonths?: number; /** Reference "now". Default new Date(). */ now?: Date; + /** + * IANA timezone the day buckets are keyed in. Defaults to + * `DEFAULT_TIMEZONE` (Europe/Berlin) so legacy / test callers keep their + * behaviour, but every per-user surface MUST pass the resolved + * `User.timezone` so a near-midnight reading lands on the user's own + * calendar day (and the "today / N-days-ago" framing is offset-correct + * for non-Berlin self-hosters). Pre-v1.4.40 these helpers hard-pinned + * Berlin; this threads the user TZ the display surfaces already use. + */ + tz?: string; } -interface BerlinYmd { - year: number; - month: number; - day: number; -} - -function toBerlinYmd(date: Date): BerlinYmd { - const parts = toBerlinYmdStrings(date); - return { - year: Number(parts.year), - month: Number(parts.month), - day: Number(parts.day), - }; -} - -/** UTC midnight of the Berlin calendar day containing `date`. */ -function utcMidnightOfBerlinDay(date: Date): number { - const { year, month, day } = toBerlinYmd(date); +/** + * UTC midnight of the user-local calendar day containing `date`. + * + * Anchors on the `YYYY-MM-DD` day key in `tz` (DST-immune via Intl) and + * re-projects it to a UTC midnight so all downstream calendar arithmetic + * runs in a space where every day is exactly 86_400_000 ms wide. + */ +function utcMidnightOfUserDay(date: Date, tz: string): number { + const [year, month, day] = userDayKey(date, tz).split("-").map(Number); return Date.UTC(year, month - 1, day); } @@ -91,11 +92,18 @@ function utcMidnightOfBerlinDay(date: Date): number { * Exported so the cross-metric `pairDailyBuckets` helpers in * `blood-pressure-status.ts`, `weight-status.ts`, `mood-status.ts`, * etc. all use the same source of truth. + * + * `tz` defaults to Berlin only so legacy callers keep working; per-user + * surfaces pass the resolved `User.timezone`. */ -export function dayOffsetToBerlinDayKey(now: Date, dayOffset: number): string { - const todayMidnight = utcMidnightOfBerlinDay(now); - // Subtraction in UTC-anchored Berlin-day space: every day is 24h wide - // because both endpoints are UTC midnights of consecutive Berlin +export function dayOffsetToBerlinDayKey( + now: Date, + dayOffset: number, + tz: string = DEFAULT_TIMEZONE, +): string { + const todayMidnight = utcMidnightOfUserDay(now, tz); + // Subtraction in UTC-anchored user-day space: every day is 24h wide + // because both endpoints are UTC midnights of consecutive user-local // calendar days, regardless of how long the wall-clock day actually was. const targetUtc = new Date(todayMidnight - dayOffset * MS_PER_DAY); // Read the Y-M-D off the UTC fields directly — we anchored at UTC @@ -111,11 +119,15 @@ function round(value: number): number { } /** - * Day offset between two Berlin-day timestamps, in whole days. + * Day offset between two user-local-day timestamps, in whole days. * Always non-negative when `today >= record`. */ -function dayOffsetBerlin(record: Date, todayMidnight: number): number { - const recordMidnight = utcMidnightOfBerlinDay(record); +function dayOffsetUser( + record: Date, + todayMidnight: number, + tz: string, +): number { + const recordMidnight = utcMidnightOfUserDay(record, tz); return Math.round((todayMidnight - recordMidnight) / MS_PER_DAY); } @@ -126,8 +138,9 @@ export function bucketSeries( const dailyDays = options.dailyDays ?? 360; const monthlyMonths = options.monthlyMonths ?? 24; const monthSize = 30; + const tz = options.tz ?? DEFAULT_TIMEZONE; - const todayMidnight = utcMidnightOfBerlinDay(options.now ?? new Date()); + const todayMidnight = utcMidnightOfUserDay(options.now ?? new Date(), tz); const daily = new Map(); const monthly = new Map(); @@ -139,7 +152,7 @@ export function bucketSeries( const monthlyMaxOffset = monthOffsetBase + monthlyMonths - 1; for (const record of records) { - const offset = dayOffsetBerlin(record.measuredAt, todayMidnight); + const offset = dayOffsetUser(record.measuredAt, todayMidnight, tz); if (offset < 0) continue; // future timestamp — ignore if (offset < dailyDays) { diff --git a/src/lib/insights/correlation-channel-series.ts b/src/lib/insights/correlation-channel-series.ts index 2911716a4..98751db43 100644 --- a/src/lib/insights/correlation-channel-series.ts +++ b/src/lib/insights/correlation-channel-series.ts @@ -56,6 +56,8 @@ export async function fetchComplianceSeries( include: { schedules: { select: SCHEDULE_COMPLIANCE_SELECT }, scheduleRevisions: { orderBy: { validFrom: "asc" } }, + // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. + pauseEras: { select: { pausedAt: true, resumedAt: true } }, }, orderBy: { name: "asc" }, }); diff --git a/src/lib/insights/features.ts b/src/lib/insights/features.ts index 5d4c54872..6f4c09f32 100644 --- a/src/lib/insights/features.ts +++ b/src/lib/insights/features.ts @@ -1623,6 +1623,8 @@ export async function extractFeatures( schedules: { select: SCHEDULE_COMPLIANCE_SELECT }, // v1.16.3 — archived schedule eras for era-aware compliance. scheduleRevisions: { orderBy: { validFrom: "asc" } }, + // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. + pauseEras: { select: { pausedAt: true, resumedAt: true } }, }, }); diff --git a/src/lib/insights/general-status.ts b/src/lib/insights/general-status.ts index 88a95f196..b63164015 100644 --- a/src/lib/insights/general-status.ts +++ b/src/lib/insights/general-status.ts @@ -42,7 +42,11 @@ import { type StatusCardResult, } from "@/lib/insights/status-card-generation"; import { annotate } from "@/lib/logging/context"; -import { toBerlinDayKey } from "@/lib/tz/resolver"; +import { + resolveUserTimezone, + toBerlinDayKey, + userDayKey, +} from "@/lib/tz/resolver"; // Derived from canonical enum so a new measurement type is auto-included // in the AI general-status fetch (V3 audit: enum drift cousins). @@ -185,6 +189,11 @@ export async function prepareGeneralStatusForUser( const now = new Date(); + // v1.2.5 (M-TZ3) — resolve the user's timezone so every day-bucketing + // pass below keys on the user's own calendar (a near-midnight reading + // lands on the user's day for non-Berlin self-hosters). + const userTz = await resolveUserTimezone(userId); + // `dailyByType` keeps the `applyPayloadBudget` daily buckets for the // downstream BP in-target pairing; the prompt embeds the compact // graded series instead of the full daily array. Skip types with no @@ -204,7 +213,7 @@ export async function prepareGeneralStatusForUser( })); if (records.length === 0) return []; - const series = applyPayloadBudget(records, { now }); + const series = applyPayloadBudget(records, { now, tz: userTz }); dailyByType.set(type, series); const graded = buildGradedSeriesFromPoints(records, now); @@ -246,7 +255,7 @@ export async function prepareGeneralStatusForUser( { total: number; taken: number; skipped: number } >(); for (const event of intakeEvents) { - const dayKey = toBerlinDayKey(event.scheduledFor); + const dayKey = userDayKey(event.scheduledFor, userTz); const bucket = adherenceByDay.get(dayKey) ?? { total: 0, taken: 0, @@ -264,14 +273,21 @@ export async function prepareGeneralStatusForUser( // Adherence series — bucket the per-day rate so the model gets the // canonical 360+24 view. We collapse one rate per day first, then feed // it into applyPayloadBudget over a synthesised `{measuredAt, value}` - // record list. + // record list. The day keys are user-local (userDayKey above); anchor + // the synthesised instant at noon UTC so the downstream tz-aware + // re-bucket re-derives the same user-local calendar day across every + // realistic self-hoster offset rather than slipping a day at the + // midnight edge. const adherenceRecords = Array.from(adherenceByDay.entries()) .sort(([a], [b]) => a.localeCompare(b)) .map(([day, value]) => ({ - measuredAt: new Date(`${day}T00:00:00.000Z`), + measuredAt: new Date(`${day}T12:00:00.000Z`), value: value.total > 0 ? round((value.taken / value.total) * 100, 1) : 0, })); - const adherenceSeries = applyPayloadBudget(adherenceRecords, { now }); + const adherenceSeries = applyPayloadBudget(adherenceRecords, { + now, + tz: userTz, + }); const adherenceGraded = buildGradedSeriesFromPoints(adherenceRecords, now); // Fetch mood context (optional — for enrichment only). v1.4.28 @@ -289,7 +305,7 @@ export async function prepareGeneralStatusForUser( measuredAt: entry.moodLoggedAt, value: entry.score, })); - const moodSeries = applyPayloadBudget(moodRecords, { now }); + const moodSeries = applyPayloadBudget(moodRecords, { now, tz: userTz }); const moodGraded = buildGradedSeriesFromPoints(moodRecords, now); const moodSummary = summarizeSeries( moodSeries.daily.map((bucket) => ({ value: bucket.value })), diff --git a/src/lib/insights/medication-compliance-status.ts b/src/lib/insights/medication-compliance-status.ts index 765178053..32f3bc70d 100644 --- a/src/lib/insights/medication-compliance-status.ts +++ b/src/lib/insights/medication-compliance-status.ts @@ -212,6 +212,8 @@ export async function prepareMedicationComplianceStatusForUser( schedules: { select: SCHEDULE_COMPLIANCE_SELECT }, // v1.16.3 — archived schedule eras for era-aware compliance. scheduleRevisions: { orderBy: { validFrom: "asc" } }, + // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. + pauseEras: { select: { pausedAt: true, resumedAt: true } }, }, orderBy: { name: "asc" }, }); @@ -304,7 +306,10 @@ export async function prepareMedicationComplianceStatusForUser( // `applyPayloadBudget` gives the latest-day focus; the compact // graded series is what reaches the prompt. - const dailyBudgeted = applyPayloadBudget(perDayRecords, { now }); + const dailyBudgeted = applyPayloadBudget(perDayRecords, { + now, + tz: userTz, + }); const dailySeries = buildGradedSeriesFromPoints(perDayRecords, now); const latestDay = dailyBudgeted.daily[0] ?? null; diff --git a/src/lib/insights/metric-status.ts b/src/lib/insights/metric-status.ts index 945ad3a6b..b7fcde718 100644 --- a/src/lib/insights/metric-status.ts +++ b/src/lib/insights/metric-status.ts @@ -236,11 +236,14 @@ export async function generateMetricStatus(args: { // to SLEEP_DURATION only so no other metric's status path changes. const isSleep = meta.measurementType === "SLEEP_DURATION"; + // v1.2.5 (M-TZ3) — resolve the user's timezone up front so the day + // bucketing below keys on the user's own calendar regardless of branch. + const userTz = await resolveUserTimezone(args.userId); + const points: Array<{ measuredAt: Date; value: number }> = []; let measurements: Array<{ measuredAt: Date }> = []; if (isSleep) { - const [tz, priorityJson, sleepRows] = await Promise.all([ - resolveUserTimezone(args.userId), + const [priorityJson, sleepRows] = await Promise.all([ loadUserSourcePriority(args.userId), prisma.measurement.findMany({ where: { @@ -263,7 +266,7 @@ export async function generateMetricStatus(args: { ]); const nights = reconstructSleepNights( sleepRows as SleepStageRow[], - tz, + userTz, priorityJson, ).filter((n) => n.asleepMinutes > 0); for (const n of nights) { @@ -288,7 +291,7 @@ export async function generateMetricStatus(args: { } measurements = rows.map((m) => ({ measuredAt: m.measuredAt })); } - const series = applyPayloadBudget(points, { now }); + const series = applyPayloadBudget(points, { now, tz: userTz }); // SLEEP_DURATION must never go through `buildGradedSeriesWithRollups`: that // path reads RAW per-stage rows (and the stage-summed `measurement_rollups` // tier) and folds IN_BED + AWAKE + bare ASLEEP + CORE/DEEP/REM into one diff --git a/src/lib/insights/mood-aggregates.ts b/src/lib/insights/mood-aggregates.ts index 2f86a5a92..dc16fa790 100644 --- a/src/lib/insights/mood-aggregates.ts +++ b/src/lib/insights/mood-aggregates.ts @@ -26,13 +26,14 @@ import { } from "@/lib/insights/bucket-series"; import { round, summarizeSeries } from "@/lib/insights/status-shared"; import { wallClockInTz } from "@/lib/tz/wall-clock"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { computeMoodNarratives, type MoodNarrative, } from "@/lib/insights/mood-narratives"; import { welchTTest } from "@/lib/insights/correlations"; import { benjaminiHochberg as fdrAdjust } from "@/lib/insights/correlation-discovery"; -import { toBerlinYmd } from "@/lib/tz/resolver"; +import { resolveUserTimezone, toBerlinYmd } from "@/lib/tz/resolver"; import { annotate } from "@/lib/logging/context"; import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority"; import { createCustomLabelResolver } from "@/lib/mood/custom-tags"; @@ -218,6 +219,7 @@ export function pairDailyBuckets( seriesA: DailyPoint[], seriesB: DailyPoint[], now: Date, + tz: string = DEFAULT_TIMEZONE, ): Array { const mapB = new Map(seriesB.map((entry) => [entry.dayOffset, entry.value])); @@ -225,7 +227,7 @@ export function pairDailyBuckets( .map((entry) => { const b = mapB.get(entry.dayOffset); if (b == null) return null; - const dayKey = dayOffsetToBerlinDayKey(now, entry.dayOffset); + const dayKey = dayOffsetToBerlinDayKey(now, entry.dayOffset, tz); const [y, m, d] = dayKey.split("-").map(Number); return { a: entry.value, @@ -1512,10 +1514,11 @@ export interface WeekdayRow { export function computeWeekdayAverages( daily: DailyPoint[], now: Date, + tz: string = DEFAULT_TIMEZONE, ): WeekdayRow[] { const sums = new Map(); for (const bucket of daily) { - const dayKey = dayOffsetToBerlinDayKey(now, bucket.dayOffset); + const dayKey = dayOffsetToBerlinDayKey(now, bucket.dayOffset, tz); const d = new Date(dayKey + "T00:00:00Z"); const weekday = (d.getUTCDay() + 6) % 7; // Monday = 0 const cur = sums.get(weekday) ?? { sum: 0, count: 0 }; @@ -1822,8 +1825,9 @@ export function computeMoodMetricCorrelation( moodDaily: DailyPoint[], metricDaily: DailyPoint[], now: Date, + tz: string = DEFAULT_TIMEZONE, ): MoodMetricCorrelation { - const pairs = pairDailyBuckets(moodDaily, metricDaily, now); + const pairs = pairDailyBuckets(moodDaily, metricDaily, now, tz); const result = pearsonCorrelation(pairs); const points = pairs.map((p) => ({ x: p.a, y: p.b })); return { result, points, n: pairs.length }; @@ -1933,15 +1937,22 @@ export function computeMoodAggregates(args: { * the default ladders. */ userPriorityJson?: unknown; + /** + * v1.2.5 (M-TZ3) — the user's IANA timezone. Threaded into every day + * bucketing pass so a near-midnight reading lands on the user's own + * calendar day. Defaults to Berlin for legacy / test callers. + */ + tz?: string; }): MoodAggregates { const { entries, measurements, now } = args; const userPriorityJson = args.userPriorityJson ?? null; + const tz = args.tz ?? DEFAULT_TIMEZONE; const moodPoints = entries.map((entry) => ({ measuredAt: entry.moodLoggedAt, value: entry.score, })); - const moodSeries = applyPayloadBudget(moodPoints, { now }); + const moodSeries = applyPayloadBudget(moodPoints, { now, tz }); const moodDaily = moodSeries.daily; const moodSummary = summarizeSeries( @@ -1968,12 +1979,12 @@ export function computeMoodAggregates(args: { measurements .filter((m) => m.type === type) .map((m) => ({ measuredAt: m.measuredAt, value: m.value })), - { now }, + { now, tz }, ).daily; const windowDays = selectHeatmapWindow(totalSpanDays); - const weekday = computeWeekdayAverages(moodDaily, now); + const weekday = computeWeekdayAverages(moodDaily, now, tz); const timeOfDay = computeTimeOfDayAverages(entries); const stability = computeMoodStability(moodDaily); const tags = computeTagSummary(entries, now); @@ -1984,7 +1995,7 @@ export function computeMoodAggregates(args: { const correlations = Object.fromEntries( Object.entries(CORRELATION_METRICS).map(([key, type]) => [ key, - computeMoodMetricCorrelation(moodDaily, metricDaily(type), now), + computeMoodMetricCorrelation(moodDaily, metricDaily(type), now, tz), ]), ) as MoodAggregates["correlations"]; @@ -2042,6 +2053,7 @@ export function computeMoodAggregates(args: { inTargetPct, loggedDayKeys, now, + tz, }), correlations, }; @@ -2205,12 +2217,16 @@ export async function fetchMoodAggregates( }); } - const userPriorityJson = await loadUserSourcePriority(userId); + const [userPriorityJson, tz] = await Promise.all([ + loadUserSourcePriority(userId), + resolveUserTimezone(userId), + ]); return computeMoodAggregates({ entries, measurements, now, userPriorityJson, + tz, }); } diff --git a/src/lib/insights/mood-narratives.ts b/src/lib/insights/mood-narratives.ts index 243d26e22..ef8107136 100644 --- a/src/lib/insights/mood-narratives.ts +++ b/src/lib/insights/mood-narratives.ts @@ -115,8 +115,12 @@ function mean(values: number[]): number | null { } /** Weekday (0 = Monday … 6 = Sunday) for a daily-bucket offset. */ -function weekdayOfOffset(now: Date, dayOffset: number): number { - const dayKey = dayOffsetToBerlinDayKey(now, dayOffset); +function weekdayOfOffset( + now: Date, + dayOffset: number, + tz: string = DEFAULT_TIMEZONE, +): number { + const dayKey = dayOffsetToBerlinDayKey(now, dayOffset, tz); const d = new Date(dayKey + "T00:00:00Z"); return (d.getUTCDay() + 6) % 7; } @@ -221,7 +225,11 @@ function weekendNarrative(input: MoodNarrativeInput): MoodNarrative | null { const weekend: number[] = []; const weekday: number[] = []; for (const bucket of input.daily) { - const wd = weekdayOfOffset(input.now, bucket.dayOffset); + const wd = weekdayOfOffset( + input.now, + bucket.dayOffset, + input.tz ?? DEFAULT_TIMEZONE, + ); if (wd >= 5) weekend.push(bucket.value); else weekday.push(bucket.value); } diff --git a/src/lib/insights/mood-status.ts b/src/lib/insights/mood-status.ts index fee01ce2f..4a90e6c96 100644 --- a/src/lib/insights/mood-status.ts +++ b/src/lib/insights/mood-status.ts @@ -10,7 +10,7 @@ import { computeSteadyRun, pickVarietyLead, } from "@/lib/insights/assessment-context"; -import { pearsonCorrelation } from "@/lib/analytics/correlations"; +import { significantPearsonCorrelation } from "@/lib/analytics/correlations"; import { applyPayloadBudget } from "@/lib/insights/bucket-series"; import { type TagInfluenceRow, @@ -62,7 +62,7 @@ import { import { annotate } from "@/lib/logging/context"; import { createCustomLabelResolver } from "@/lib/mood/custom-tags"; import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; -import { toBerlinDayKey } from "@/lib/tz/resolver"; +import { resolveUserTimezone, toBerlinDayKey } from "@/lib/tz/resolver"; /** * Drop the ranking-only `pooledSd` before a tag-influence row enters the @@ -289,13 +289,17 @@ export async function prepareMoodStatusForUser( const now = new Date(); + // v1.2.5 (M-TZ3) — resolve the user's timezone so every day-bucketing + // pass below keys on the user's own calendar day. + const userTz = await resolveUserTimezone(userId); + const moodPoints = entries.map((entry) => ({ measuredAt: entry.moodLoggedAt, value: entry.score, })); // `applyPayloadBudget` daily buckets drive the in-target %, latest, // and correlations; the compact graded series reaches the prompt. - const moodSeries = applyPayloadBudget(moodPoints, { now }); + const moodSeries = applyPayloadBudget(moodPoints, { now, tz: userTz }); const moodGraded = buildGradedSeriesFromPoints(moodPoints, now); const moodSummary = summarizeSeries( moodSeries.daily.map((bucket) => ({ value: bucket.value })), @@ -384,21 +388,21 @@ export async function prepareMoodStatusForUser( measurements .filter((m) => m.type === "WEIGHT") .map((m) => ({ measuredAt: m.measuredAt, value: m.value })), - { now }, + { now, tz: userTz }, ); const sysSeries = applyPayloadBudget( measurements .filter((m) => m.type === "BLOOD_PRESSURE_SYS") .map((m) => ({ measuredAt: m.measuredAt, value: m.value })), - { now }, + { now, tz: userTz }, ); const pulseSeries = applyPayloadBudget( measurements .filter((m) => m.type === "PULSE") .map((m) => ({ measuredAt: m.measuredAt, value: m.value })), - { now }, + { now, tz: userTz }, ); // Correlations between mood and other metrics — pair on dayOffset so @@ -407,22 +411,27 @@ export async function prepareMoodStatusForUser( moodSeries.daily, weightSeries.daily, now, + userTz, ); - const moodVsWeightCorrelation = pearsonCorrelation(moodVsWeightPairs); + const moodVsWeightCorrelation = + significantPearsonCorrelation(moodVsWeightPairs); const moodVsSysPairs = pairDailyBuckets( moodSeries.daily, sysSeries.daily, now, + userTz, ); - const moodVsSysCorrelation = pearsonCorrelation(moodVsSysPairs); + const moodVsSysCorrelation = significantPearsonCorrelation(moodVsSysPairs); const moodVsPulsePairs = pairDailyBuckets( moodSeries.daily, pulseSeries.daily, now, + userTz, ); - const moodVsPulseCorrelation = pearsonCorrelation(moodVsPulsePairs); + const moodVsPulseCorrelation = + significantPearsonCorrelation(moodVsPulsePairs); // Extract tag frequencies from recent entries — keep the v1.4.5 // ~90-day window so the model still gets a recency-weighted view of @@ -438,7 +447,7 @@ export async function prepareMoodStatusForUser( // board's metric factors are limited to those three channels (sleep / // steps are absent here by design — the model sees only what it fetched). const tagInfluence = computeTagInfluence(entries, now); - const emptyCorrelation = computeMoodMetricCorrelation([], [], now); + const emptyCorrelation = computeMoodMetricCorrelation([], [], now, userTz); const betterDays = computeBetterDays(tagInfluence, { sleep: emptyCorrelation, steps: emptyCorrelation, @@ -446,16 +455,19 @@ export async function prepareMoodStatusForUser( moodSeries.daily, pulseSeries.daily, now, + userTz, ), weight: computeMoodMetricCorrelation( moodSeries.daily, weightSeries.daily, now, + userTz, ), bloodPressureSystolic: computeMoodMetricCorrelation( moodSeries.daily, sysSeries.daily, now, + userTz, ), }); @@ -488,13 +500,14 @@ export async function prepareMoodStatusForUser( const narratives = computeMoodNarratives({ daily: moodSeries.daily, - weekday: computeWeekdayAverages(moodSeries.daily, now), + weekday: computeWeekdayAverages(moodSeries.daily, now, userTz), timeOfDay, tags: tagSummary, structuredTags: structuredTagSummary, inTargetPct: inTargetPctLast30DailyPoints, loggedDayKeys: Array.from(new Set(entries.map((entry) => entry.date))), now, + tz: userTz, }); // v1.18.10 (HIGH-4) — hand the model the finished recent-vs-baseline diff --git a/src/lib/insights/pulse-status.ts b/src/lib/insights/pulse-status.ts index a9d423bf0..b0862cdfd 100644 --- a/src/lib/insights/pulse-status.ts +++ b/src/lib/insights/pulse-status.ts @@ -227,7 +227,7 @@ export async function preparePulseStatusForUser( // `applyPayloadBudget` daily buckets still drive the derived stats // below (latest, in-target %, delta). They are NOT embedded in the // prompt — the compact graded series replaces the full daily array. - const pulseSeries = applyPayloadBudget(pulsePoints, { now }); + const pulseSeries = applyPayloadBudget(pulsePoints, { now, tz: userTz }); // Primary metric: recent / weekly fold from the bounded raw read, the // monthly / yearly tail comes from the MONTH / YEAR rollup tier (with // a full-history in-memory fallback on a cold-tier coverage miss). @@ -252,7 +252,7 @@ export async function preparePulseStatusForUser( measuredAt: entry.moodLoggedAt, value: entry.score, })); - const moodSeries = applyPayloadBudget(moodPoints, { now }); + const moodSeries = applyPayloadBudget(moodPoints, { now, tz: userTz }); const moodGraded = buildGradedSeriesFromPoints(moodPoints, now); const moodSummary = summarizeSeries( moodSeries.daily.map((bucket) => ({ value: bucket.value })), diff --git a/src/lib/insights/weight-status.ts b/src/lib/insights/weight-status.ts index 795d522af..637abe119 100644 --- a/src/lib/insights/weight-status.ts +++ b/src/lib/insights/weight-status.ts @@ -4,7 +4,7 @@ import { getWeightUserPrompt, } from "@/lib/ai/prompts/weight"; import { - pearsonCorrelation, + significantPearsonCorrelation, type PairedPoint, } from "@/lib/analytics/correlations"; import { getNoKeyWeightStatusText } from "@/lib/insights/no-key-fallbacks"; @@ -54,7 +54,8 @@ import { type StatusCardResult, } from "@/lib/insights/status-card-generation"; import { annotate } from "@/lib/logging/context"; -import { toBerlinDayKey } from "@/lib/tz/resolver"; +import { resolveUserTimezone, toBerlinDayKey } from "@/lib/tz/resolver"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; /** * Cap on the embedded correlation pair arrays. The Pearson coefficient @@ -74,6 +75,7 @@ function pairDailyBuckets( seriesA: DailyBucket[], seriesB: DailyBucket[], now: Date, + tz: string = DEFAULT_TIMEZONE, ): Array { const mapB = new Map(seriesB.map((entry) => [entry.dayOffset, entry.value])); @@ -81,7 +83,7 @@ function pairDailyBuckets( .map((entry) => { const b = mapB.get(entry.dayOffset); if (b == null) return null; - const dayKey = dayOffsetToBerlinDayKey(now, entry.dayOffset); + const dayKey = dayOffsetToBerlinDayKey(now, entry.dayOffset, tz); const [y, m, d] = dayKey.split("-").map(Number); return { a: entry.value, @@ -279,6 +281,10 @@ export async function prepareWeightStatusForUser( const now = new Date(); + // v1.2.5 (M-TZ3) — resolve the user's timezone so every day-bucketing + // pass below keys on the user's own calendar day. + const userTz = await resolveUserTimezone(userId); + const weightSeries = applyPayloadBudget( measurements .filter((measurement) => measurement.type === "WEIGHT") @@ -286,7 +292,7 @@ export async function prepareWeightStatusForUser( measuredAt: measurement.measuredAt, value: measurement.value, })), - { now }, + { now, tz: userTz }, ); const sysSeries = applyPayloadBudget( @@ -296,7 +302,7 @@ export async function prepareWeightStatusForUser( measuredAt: measurement.measuredAt, value: measurement.value, })), - { now }, + { now, tz: userTz }, ); const diaSeries = applyPayloadBudget( @@ -306,7 +312,7 @@ export async function prepareWeightStatusForUser( measuredAt: measurement.measuredAt, value: measurement.value, })), - { now }, + { now, tz: userTz }, ); // Fetch mood context (optional — for enrichment only). v1.4.28 @@ -325,7 +331,7 @@ export async function prepareWeightStatusForUser( measuredAt: entry.moodLoggedAt, value: entry.score, })), - { now }, + { now, tz: userTz }, ); const moodSummary = summarizeSeries( moodSeries.daily.map((bucket) => ({ value: bucket.value })), @@ -348,7 +354,7 @@ export async function prepareWeightStatusForUser( ]); const moodGraded = buildGradedSeriesFromPoints( moodSeries.daily.map((b) => ({ - measuredAt: new Date(dayOffsetToBerlinDayKey(now, b.dayOffset)), + measuredAt: new Date(dayOffsetToBerlinDayKey(now, b.dayOffset, userTz)), value: b.value, })), now, @@ -358,13 +364,17 @@ export async function prepareWeightStatusForUser( weightSeries.daily, sysSeries.daily, now, + userTz, + ); + const weightVsSystolicCorrelation = significantPearsonCorrelation( + weightVsSystolicPairs, ); - const weightVsSystolicCorrelation = pearsonCorrelation(weightVsSystolicPairs); const pairedSystolicDiastolic = pairDailyBuckets( sysSeries.daily, diaSeries.daily, now, + userTz, ).map((entry) => ({ day: entry.dayKey, sys: entry.a, @@ -376,7 +386,7 @@ export async function prepareWeightStatusForUser( // pairDailyBuckets — derive dayOffset from the offsets in sysSeries. const sysOffsetByDay = new Map( sysSeries.daily.map((bucket) => { - const dayKey = dayOffsetToBerlinDayKey(now, bucket.dayOffset); + const dayKey = dayOffsetToBerlinDayKey(now, bucket.dayOffset, userTz); return [dayKey, bucket.dayOffset]; }), ); @@ -392,14 +402,16 @@ export async function prepareWeightStatusForUser( weightSeries.daily, bpMeanDaily, now, + userTz, ); - const weightVsMeanBpCorrelation = pearsonCorrelation(weightVsMeanBpPairs); + const weightVsMeanBpCorrelation = + significantPearsonCorrelation(weightVsMeanBpPairs); // daily[0] = newest bucket (lowest dayOffset). const latestWeight = weightSeries.daily[0] ?? null; const previousWeight = weightSeries.daily[1] ?? null; const latestWeightDay = latestWeight - ? dayOffsetToBerlinDayKey(now, latestWeight.dayOffset) + ? dayOffsetToBerlinDayKey(now, latestWeight.dayOffset, userTz) : null; const sameDayBp = latestWeightDay ? (pairedSystolicDiastolic.find((entry) => entry.day === latestWeightDay) ?? @@ -513,8 +525,9 @@ export async function prepareWeightStatusForUser( moodSeries.daily, weightSeries.daily, now, + userTz, ); - return pearsonCorrelation(moodVsWeightPairs); + return significantPearsonCorrelation(moodVsWeightPairs); })(), } : null, diff --git a/src/lib/medications/compliance-payload.ts b/src/lib/medications/compliance-payload.ts index 927eccdba..a71a94478 100644 --- a/src/lib/medications/compliance-payload.ts +++ b/src/lib/medications/compliance-payload.ts @@ -51,6 +51,10 @@ export interface ComplianceMedicationInput { scheduleRevisions?: Parameters< typeof buildComplianceMedicationContext >[0]["scheduleRevisions"]; + /** v1.25 H-MED1 — pause eras so paused days drop out of the denominator. */ + pauseEras?: Parameters< + typeof buildComplianceMedicationContext + >[0]["pauseEras"]; } /** diff --git a/src/lib/targets/build-response.ts b/src/lib/targets/build-response.ts index 6e2f44885..89b728e66 100644 --- a/src/lib/targets/build-response.ts +++ b/src/lib/targets/build-response.ts @@ -303,6 +303,8 @@ export async function buildTargetsResponse(user: AuthedUser) { schedules: { select: SCHEDULE_COMPLIANCE_SELECT }, // v1.16.3 — archived schedule eras for era-aware compliance. scheduleRevisions: { orderBy: { validFrom: "asc" } }, + // v1.25 H-MED1 — pause eras so paused days drop out of the denominator. + pauseEras: { select: { pausedAt: true, resumedAt: true } }, }, orderBy: { name: "asc" }, }); From 4bf45efcab338e052b3829d68d44e258893d7f8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 11:39:26 +0200 Subject: [PATCH 15/94] revert(insights): drop hydration logging Remove the in-app water-intake logging feature: the WATER_INTAKE measurement type, the per-user daily-goal field, the goal-ring card and its endpoint, and the related query keys, OpenAPI route, and locale strings. The feature is dropped from this release; the baseline-drift, breathing-screening, and labs-changes insight cards are unaffected, and the pre-existing TOTAL_BODY_WATER body-composition type stays intact. --- docs/api/openapi.yaml | 155 ---------- messages/de.json | 18 +- messages/en.json | 18 +- messages/es.json | 18 +- messages/fr.json | 18 +- messages/it.json | 18 +- messages/pl.json | 18 +- .../0209_v125_water_intake/migration.sql | 21 -- prisma/schema.prisma | 12 - src/app/api/hydration/route.ts | 111 ------- src/app/insights/page.tsx | 10 - src/components/insights/hydration-card.tsx | 288 ------------------ src/components/insights/hydration-ring.tsx | 121 -------- .../insights/insights-edit-mode.tsx | 1 - .../measurements/measurement-list-meta.ts | 4 - src/lib/__tests__/insights-layout.test.ts | 1 - .../measurement-type-enum-coverage.test.ts | 7 - src/lib/hydration/__tests__/hydration.test.ts | 72 ----- src/lib/hydration/hydration.ts | 80 ----- src/lib/insights-layout.ts | 1 - src/lib/insights/chart-tokens.ts | 3 - .../__tests__/apple-health-mapping.test.ts | 6 - src/lib/measurements/apple-health-mapping.ts | 5 - src/lib/measurements/categories.ts | 2 - src/lib/measurements/display-transform.ts | 2 - src/lib/openapi/registry.ts | 1 - src/lib/openapi/routes/hydration.ts | 102 ------- src/lib/openapi/routes/index.ts | 2 - src/lib/personal-records/pr-direction.ts | 5 - src/lib/query-keys/hydration.ts | 10 - src/lib/query-keys/index.ts | 4 - src/lib/validations/measurement.ts | 8 - 32 files changed, 6 insertions(+), 1136 deletions(-) delete mode 100644 prisma/migrations/0209_v125_water_intake/migration.sql delete mode 100644 src/app/api/hydration/route.ts delete mode 100644 src/components/insights/hydration-card.tsx delete mode 100644 src/components/insights/hydration-ring.tsx delete mode 100644 src/lib/hydration/__tests__/hydration.test.ts delete mode 100644 src/lib/hydration/hydration.ts delete mode 100644 src/lib/openapi/routes/hydration.ts delete mode 100644 src/lib/query-keys/hydration.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f7be19a22..6645c50c6 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -44,7 +44,6 @@ tags: - name: Illness - name: Admin - name: Meta - - name: Hydration paths: /api/cycle/day-logs: get: @@ -2088,7 +2087,6 @@ paths: - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION - RESILIENCE - - WATER_INTAKE - in: query name: from schema: @@ -2278,7 +2276,6 @@ paths: - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION - RESILIENCE - - WATER_INTAKE value: type: number measuredAt: @@ -5773,7 +5770,6 @@ paths: - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION - RESILIENCE - - WATER_INTAKE description: "The measurement type to read (single metric — no fan-out). Closed enum: an unknown type 422s." required: true description: "The measurement type to read (single metric — no fan-out). Closed enum: an unknown type 422s." @@ -8003,44 +7999,6 @@ paths: "401": *a1 "422": *a3 "429": *a4 - /api/hydration: - get: - tags: - - Hydration - summary: Today's hydration total vs goal - description: Sums the calling user's WATER_INTAKE measurements for the current local day and compares them against the - per-user goal (default 2000 ml). - responses: - "200": - description: Today's hydration summary. - content: - application/json: - schema: - $ref: "#/components/schemas/HydrationTodayEnvelope" - "401": *a1 - "422": *a3 - "429": *a4 - patch: - tags: - - Hydration - summary: Set the daily hydration goal - description: Updates the per-user daily hydration goal in millilitres. - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/HydrationGoalRequest" - responses: - "200": - description: Goal updated. - content: - application/json: - schema: - $ref: "#/components/schemas/HydrationGoalEnvelope" - "401": *a1 - "422": *a3 - "429": *a4 components: schemas: EncryptedExportRequest: @@ -9341,7 +9299,6 @@ components: - cycle-summary - signals - rhythm-events - - hydration - health-status - breathing - labs-changes @@ -9754,17 +9711,6 @@ components: - updatedAt description: Resumable module-tour progress point. `lastStopId` seeds the resume index; `status` is the running/terminal state. - HydrationGoalRequest: - type: object - properties: - goalMl: - type: integer - minimum: 250 - maximum: 8000 - description: Daily hydration goal in millilitres. - required: - - goalMl - description: Set the per-user daily hydration goal (ml). MedicationTreatmentClass: type: string enum: @@ -12366,7 +12312,6 @@ components: - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION - RESILIENCE - - WATER_INTAKE value: type: number unit: @@ -12683,7 +12628,6 @@ components: - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION - RESILIENCE - - WATER_INTAKE additionalProperties: type: array items: @@ -12761,7 +12705,6 @@ components: - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION - RESILIENCE - - WATER_INTAKE value: type: number description: Daily mean (spot metrics) or SUM (cumulative). @@ -12860,7 +12803,6 @@ components: - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION - RESILIENCE - - WATER_INTAKE description: Per-type daily series, keyed by MeasurementType. required: - series @@ -13094,7 +13036,6 @@ components: - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION - RESILIENCE - - WATER_INTAKE value: type: number unit: @@ -23378,101 +23319,6 @@ components: - progress additionalProperties: false description: The persisted completion flag and resume point after the write. - HydrationTodayEnvelope: - type: object - properties: - data: - $ref: "#/components/schemas/HydrationToday" - error: - type: "null" - meta: - type: object - properties: - requestId: - type: string - additionalProperties: false - required: - - data - - error - additionalProperties: false - HydrationToday: - type: object - properties: - date: - type: string - description: ISO-8601 start of the user's local day. - totalMl: - type: number - description: Today's summed intake in ml. - goalMl: - type: number - description: Effective daily goal in ml. - percent: - type: number - description: Progress toward the goal, capped at 100. - rawPercent: - type: number - description: Uncapped progress; can exceed 100. - met: - type: boolean - description: Whether the day's total reached the goal. - remainingMl: - type: number - description: Remaining ml to the goal (0 once met). - entries: - type: array - items: - type: object - properties: - id: - type: string - value: - type: number - description: Logged amount in millilitres. - measuredAt: - type: string - description: ISO-8601 instant the entry was logged. - required: - - id - - value - - measuredAt - additionalProperties: false - required: - - date - - totalMl - - goalMl - - percent - - rawPercent - - met - - remainingMl - - entries - additionalProperties: false - description: Today's water-intake total vs the user's daily goal. - HydrationGoalEnvelope: - type: object - properties: - data: - $ref: "#/components/schemas/HydrationGoalResponse" - error: - type: "null" - meta: - type: object - properties: - requestId: - type: string - additionalProperties: false - required: - - data - - error - additionalProperties: false - HydrationGoalResponse: - type: object - properties: - goalMl: - type: number - required: - - goalMl - additionalProperties: false MedicationScheduleOutput: type: object properties: @@ -23650,7 +23496,6 @@ components: - cycle-summary - signals - rhythm-events - - hydration - health-status - breathing - labs-changes diff --git a/messages/de.json b/messages/de.json index 52333ac05..b5e3eb798 100644 --- a/messages/de.json +++ b/messages/de.json @@ -574,8 +574,7 @@ "bulkDeleteSuccess": "{count} Messungen gelöscht", "bulkDeleteError": "Fehler beim Löschen der ausgewählten Messungen", "bulkDeleteConfirmTitle": "{count} Messungen löschen?", - "bulkDeleteConfirmBody": "Die {count} ausgewählten Messungen werden endgültig gelöscht.", - "typeWaterIntake": "Wasseraufnahme" + "bulkDeleteConfirmBody": "Die {count} ausgewählten Messungen werden endgültig gelöscht." }, "mood": { "title": "Stimmung", @@ -7404,20 +7403,5 @@ "glucoseHighDoctor": "Zwei aktuelle Messungen lagen bei {value} mg/dL oder höher. Miss erneut, trink ausreichend und kontaktiere möglichst bald deine Ärztin oder deinen Arzt, wenn es so hoch bleibt.", "glucoseHighEmergency": "Zwei aktuelle Messungen lagen bei {value} mg/dL oder höher und du hast Beschwerden angegeben. Bei Erbrechen, schneller Atmung oder Verwirrtheit sofort den Notruf wählen." } - }, - "hydration": { - "cardTitle": "Flüssigkeitszufuhr", - "editGoal": "Ziel bearbeiten", - "goalLabel": "Tagesziel (ml)", - "metMessage": "Du hast dein heutiges Ziel erreicht.", - "remainingMessage": "Noch {amount} ml heute.", - "add": "Hinzufügen", - "added": "{amount} ml hinzugefügt.", - "saveError": "Speichern fehlgeschlagen. Bitte erneut versuchen.", - "invalidAmount": "Gib eine Menge zwischen 1 und 5000 ml ein.", - "invalidGoal": "Gib ein Ziel zwischen 250 und 8000 ml ein.", - "goalSaved": "Ziel aktualisiert.", - "customPlaceholder": "Menge", - "customLabel": "Eigene Menge (ml)" } } diff --git a/messages/en.json b/messages/en.json index c47b3d0b7..8902daa12 100644 --- a/messages/en.json +++ b/messages/en.json @@ -574,8 +574,7 @@ "bulkDeleteSuccess": "{count} measurements deleted", "bulkDeleteError": "Error deleting the selected measurements", "bulkDeleteConfirmTitle": "Delete {count} measurements?", - "bulkDeleteConfirmBody": "The {count} selected measurements will be permanently deleted.", - "typeWaterIntake": "Water intake" + "bulkDeleteConfirmBody": "The {count} selected measurements will be permanently deleted." }, "mood": { "title": "Mood", @@ -7404,20 +7403,5 @@ "glucoseHighDoctor": "Two recent readings were {value} mg/dL or higher. Re-check, hydrate, and contact your doctor as soon as possible if it stays this high.", "glucoseHighEmergency": "Two recent readings were {value} mg/dL or higher and you noted symptoms. If you have vomiting, fast breathing, or confusion, call emergency services now." } - }, - "hydration": { - "cardTitle": "Hydration", - "editGoal": "Edit goal", - "goalLabel": "Daily goal (ml)", - "metMessage": "You've reached today's goal.", - "remainingMessage": "{amount} ml to go today.", - "add": "Add", - "added": "Added {amount} ml.", - "saveError": "Could not save. Please try again.", - "invalidAmount": "Enter an amount between 1 and 5000 ml.", - "invalidGoal": "Enter a goal between 250 and 8000 ml.", - "goalSaved": "Goal updated.", - "customPlaceholder": "Amount", - "customLabel": "Custom amount (ml)" } } diff --git a/messages/es.json b/messages/es.json index 6e03917c8..4cdd3886a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -574,8 +574,7 @@ "bulkDeleteSuccess": "{count} mediciones eliminadas", "bulkDeleteError": "Error al eliminar las mediciones seleccionadas", "bulkDeleteConfirmTitle": "¿Eliminar {count} mediciones?", - "bulkDeleteConfirmBody": "Las {count} mediciones seleccionadas se eliminarán de forma permanente.", - "typeWaterIntake": "Ingesta de agua" + "bulkDeleteConfirmBody": "Las {count} mediciones seleccionadas se eliminarán de forma permanente." }, "mood": { "title": "Estado de ánimo", @@ -7404,20 +7403,5 @@ "glucoseHighDoctor": "Dos lecturas recientes fueron de {value} mg/dL o más. Vuelve a medir, hidrátate y contacta a tu médico cuanto antes si sigue así de alta.", "glucoseHighEmergency": "Dos lecturas recientes fueron de {value} mg/dL o más e indicaste síntomas. Si tienes vómitos, respiración rápida o confusión, llama a emergencias ya." } - }, - "hydration": { - "cardTitle": "Hidratación", - "editGoal": "Editar objetivo", - "goalLabel": "Objetivo diario (ml)", - "metMessage": "Has alcanzado el objetivo de hoy.", - "remainingMessage": "Faltan {amount} ml hoy.", - "add": "Añadir", - "added": "Añadidos {amount} ml.", - "saveError": "No se pudo guardar. Inténtalo de nuevo.", - "invalidAmount": "Introduce una cantidad entre 1 y 5000 ml.", - "invalidGoal": "Introduce un objetivo entre 250 y 8000 ml.", - "goalSaved": "Objetivo actualizado.", - "customPlaceholder": "Cantidad", - "customLabel": "Cantidad personalizada (ml)" } } diff --git a/messages/fr.json b/messages/fr.json index 1e40e6df6..8e50b7488 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -574,8 +574,7 @@ "bulkDeleteSuccess": "{count} mesures supprimées", "bulkDeleteError": "Erreur lors de la suppression des mesures sélectionnées", "bulkDeleteConfirmTitle": "Supprimer {count} mesures ?", - "bulkDeleteConfirmBody": "Les {count} mesures sélectionnées seront définitivement supprimées.", - "typeWaterIntake": "Apport en eau" + "bulkDeleteConfirmBody": "Les {count} mesures sélectionnées seront définitivement supprimées." }, "mood": { "title": "Humeur", @@ -7404,20 +7403,5 @@ "glucoseHighDoctor": "Deux mesures récentes étaient à {value} mg/dL ou plus. Remesure, hydrate-toi et contacte ton médecin dès que possible si cela reste aussi élevé.", "glucoseHighEmergency": "Deux mesures récentes étaient à {value} mg/dL ou plus et tu as signalé des symptômes. En cas de vomissements, de respiration rapide ou de confusion, appelle les secours immédiatement." } - }, - "hydration": { - "cardTitle": "Hydratation", - "editGoal": "Modifier l'objectif", - "goalLabel": "Objectif quotidien (ml)", - "metMessage": "Vous avez atteint l'objectif du jour.", - "remainingMessage": "Encore {amount} ml aujourd'hui.", - "add": "Ajouter", - "added": "{amount} ml ajoutés.", - "saveError": "Échec de l'enregistrement. Réessayez.", - "invalidAmount": "Saisissez une quantité entre 1 et 5000 ml.", - "invalidGoal": "Saisissez un objectif entre 250 et 8000 ml.", - "goalSaved": "Objectif mis à jour.", - "customPlaceholder": "Quantité", - "customLabel": "Quantité personnalisée (ml)" } } diff --git a/messages/it.json b/messages/it.json index c8b3d58a2..c22449a44 100644 --- a/messages/it.json +++ b/messages/it.json @@ -574,8 +574,7 @@ "bulkDeleteSuccess": "{count} misurazioni eliminate", "bulkDeleteError": "Errore durante l'eliminazione delle misurazioni selezionate", "bulkDeleteConfirmTitle": "Eliminare {count} misurazioni?", - "bulkDeleteConfirmBody": "Le {count} misurazioni selezionate verranno eliminate definitivamente.", - "typeWaterIntake": "Assunzione di acqua" + "bulkDeleteConfirmBody": "Le {count} misurazioni selezionate verranno eliminate definitivamente." }, "mood": { "title": "Umore", @@ -7404,20 +7403,5 @@ "glucoseHighDoctor": "Due misurazioni recenti erano di {value} mg/dL o superiori. Rimisura, idratati e contatta il medico al più presto se resta così alta.", "glucoseHighEmergency": "Due misurazioni recenti erano di {value} mg/dL o superiori e hai segnalato sintomi. In caso di vomito, respiro affannoso o confusione, chiama subito i soccorsi." } - }, - "hydration": { - "cardTitle": "Idratazione", - "editGoal": "Modifica obiettivo", - "goalLabel": "Obiettivo giornaliero (ml)", - "metMessage": "Hai raggiunto l'obiettivo di oggi.", - "remainingMessage": "Ancora {amount} ml oggi.", - "add": "Aggiungi", - "added": "Aggiunti {amount} ml.", - "saveError": "Salvataggio non riuscito. Riprova.", - "invalidAmount": "Inserisci una quantità tra 1 e 5000 ml.", - "invalidGoal": "Inserisci un obiettivo tra 250 e 8000 ml.", - "goalSaved": "Obiettivo aggiornato.", - "customPlaceholder": "Quantità", - "customLabel": "Quantità personalizzata (ml)" } } diff --git a/messages/pl.json b/messages/pl.json index 603de4300..69f50d7e6 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -574,8 +574,7 @@ "bulkDeleteSuccess": "Usunięto pomiary: {count}", "bulkDeleteError": "Błąd podczas usuwania wybranych pomiarów", "bulkDeleteConfirmTitle": "Usunąć pomiary ({count})?", - "bulkDeleteConfirmBody": "Wybrane pomiary ({count}) zostaną trwale usunięte.", - "typeWaterIntake": "Spożycie wody" + "bulkDeleteConfirmBody": "Wybrane pomiary ({count}) zostaną trwale usunięte." }, "mood": { "title": "Nastrój", @@ -7404,20 +7403,5 @@ "glucoseHighDoctor": "Dwa ostatnie pomiary wyniosły {value} mg/dL lub więcej. Zmierz ponownie, nawodnij się i jak najszybciej skontaktuj się z lekarzem, jeśli pozostaje tak wysoki.", "glucoseHighEmergency": "Dwa ostatnie pomiary wyniosły {value} mg/dL lub więcej i zgłosiłeś objawy. W razie wymiotów, przyspieszonego oddechu lub dezorientacji natychmiast zadzwoń po pomoc." } - }, - "hydration": { - "cardTitle": "Nawodnienie", - "editGoal": "Edytuj cel", - "goalLabel": "Cel dzienny (ml)", - "metMessage": "Osiągnięto dzisiejszy cel.", - "remainingMessage": "Jeszcze {amount} ml dzisiaj.", - "add": "Dodaj", - "added": "Dodano {amount} ml.", - "saveError": "Nie udało się zapisać. Spróbuj ponownie.", - "invalidAmount": "Podaj ilość od 1 do 5000 ml.", - "invalidGoal": "Podaj cel od 250 do 8000 ml.", - "goalSaved": "Cel zaktualizowany.", - "customPlaceholder": "Ilość", - "customLabel": "Własna ilość (ml)" } } diff --git a/prisma/migrations/0209_v125_water_intake/migration.sql b/prisma/migrations/0209_v125_water_intake/migration.sql deleted file mode 100644 index a3212ada1..000000000 --- a/prisma/migrations/0209_v125_water_intake/migration.sql +++ /dev/null @@ -1,21 +0,0 @@ --- v1.25 — hydration logging. --- --- Two additive changes, no backfill, no existing row touched: --- --- 1. `WATER_INTAKE` — a new MeasurementType: a manual water-intake counter in --- millilitres (ml). Cumulative within a day like ACTIVITY_STEPS — each --- entry adds to the day's running total, summed for the goal ring. --- DISTINCT from TOTAL_BODY_WATER (a scale body-composition reading in kg). --- Forward-only: Postgres cannot remove an enum value; idempotent via --- `IF NOT EXISTS`, so reruns are safe. --- --- 2. `users.hydration_goal_ml` — a nullable per-user daily hydration goal in --- millilitres. Null means the in-code default (2000 ml). Edited inline on --- the hydration card. --- --- The enum value is only added, never used in this same statement batch, so the --- Postgres "unsafe use of new enum value" restriction does not apply. - -ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'WATER_INTAKE'; - -ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "hydration_goal_ml" INTEGER; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 85f64b7ff..5bbd4cb75 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -233,10 +233,6 @@ model User { // MDY pins month-day-year, YMD pins ISO yyyy-MM-dd. Display-time only — // canonical storage stays UTC instants. dateFormat DateFormatPreference @default(AUTO) @map("date_format") - // v1.25 — daily water-intake goal in millilitres for the hydration ring. - // Null = the in-code default (DEFAULT_HYDRATION_GOAL_ML, 2000 ml). Surfaced - // and edited inline on the hydration card; canonical storage stays ml. - hydrationGoalMl Int? @map("hydration_goal_ml") // AI provider selection for insights. Null = OPENAI fallback. aiProvider String? @map("ai_provider") // "OPENAI" | "ANTHROPIC" | "LOCAL" | "CHATGPT_OAUTH" aiModel String? @map("ai_model") @@ -1111,14 +1107,6 @@ enum MeasurementType { // `src/lib/oura/client.ts` (RESILIENCE_LEVELS). Stored as `source = OURA`. RESILIENCE // level (ordinal 1–5) — Oura `daily_resilience.level` (limited=1 … exceptional=5). - // ── v1.25 — hydration logging (additive) ── - // Manual water-intake counter in millilitres (ml). Cumulative within a day - // like ACTIVITY_STEPS / ACTIVE_ENERGY_BURNED — each entry adds to the day's - // running total; the daily total is summed for the goal ring. DISTINCT from - // TOTAL_BODY_WATER (a scale body-composition reading in kg). Manual entry - // only today (no device source maps to it), unit "ml", higher = more intake. - WATER_INTAKE // ml — manual hydration intake counter; daily total vs a user goal. - @@map("measurement_type") } diff --git a/src/app/api/hydration/route.ts b/src/app/api/hydration/route.ts deleted file mode 100644 index 0b056c59d..000000000 --- a/src/app/api/hydration/route.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * v1.25 — hydration daily-goal surface. - * - * GET /api/hydration → today's summed water intake vs the user's goal. - * PATCH /api/hydration → set the per-user daily goal (ml). - * - * Logging itself rides the existing measurements create path (POST - * /api/measurements with `type: "WATER_INTAKE"`, unit "ml") — this route only - * reads the day's total and owns the goal. `userId` is always narrowed from - * `requireAuth()`; the goal is built field-by-field, never mass-assigned. - */ -import { NextRequest } from "next/server"; -import { z } from "zod/v4"; -import { - apiError, - apiSuccess, - returnAllZodIssues, - safeJson, -} from "@/lib/api-response"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { annotate } from "@/lib/logging/context"; -import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; -import { prisma } from "@/lib/db"; -import { getUserTodayBounds } from "@/lib/tz/local-day"; -import { - MAX_HYDRATION_GOAL_ML, - MIN_HYDRATION_GOAL_ML, - resolveHydrationGoal, - summariseHydration, -} from "@/lib/hydration/hydration"; - -export const dynamic = "force-dynamic"; - -const goalPatchSchema = z.object({ - goalMl: z - .number() - .int() - .min(MIN_HYDRATION_GOAL_ML) - .max(MAX_HYDRATION_GOAL_ML), -}); - -export const GET = apiHandler(async () => { - const { user } = await requireAuth(); - - const rl = await checkAnalyticsReadRateLimit(user.id); - if (!rl.allowed) { - return apiError("Too many requests. Please retry later.", 429); - } - - const now = new Date(); - const { start, end } = getUserTodayBounds(now, user.timezone); - - const rows = await prisma.measurement.findMany({ - where: { - userId: user.id, - type: "WATER_INTAKE", - measuredAt: { gte: start, lte: end }, - }, - select: { id: true, value: true, measuredAt: true }, - orderBy: { measuredAt: "asc" }, - }); - - const totalMl = rows.reduce((sum, r) => sum + r.value, 0); - const summary = summariseHydration( - totalMl, - resolveHydrationGoal(user.hydrationGoalMl), - ); - - annotate({ - action: { name: "hydration.today.read" }, - meta: { entries: rows.length, met: summary.met }, - }); - - return apiSuccess({ - date: start.toISOString(), - ...summary, - entries: rows.map((r) => ({ - id: r.id, - value: r.value, - measuredAt: r.measuredAt.toISOString(), - })), - }); -}); - -export const PATCH = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); - - const { data: body, error: jsonError } = await safeJson(request, { - maxBytes: 1024, - }); - if (jsonError) return jsonError; - - const parsed = goalPatchSchema.safeParse(body); - if (!parsed.success) { - annotate({ - action: { name: "hydration.goal.validation-failed" }, - meta: { issue_count: parsed.error.issues.length }, - }); - return returnAllZodIssues(parsed.error, 422); - } - - const goalMl = parsed.data.goalMl; - await prisma.user.update({ - where: { id: user.id }, - data: { hydrationGoalMl: goalMl }, - }); - - annotate({ action: { name: "hydration.goal.update" }, meta: { goalMl } }); - - return apiSuccess({ goalMl }); -}); diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index 846068a61..2e90e60bc 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -127,15 +127,6 @@ const RhythmEventsCard = dynamic( })), { ssr: false }, ); -// v1.25 — hydration daily-goal ring. Always present (a goal ring at 0 / goal is -// still useful); it owns its own today-total query and the goal editor. -const HydrationCard = dynamic( - () => - import("@/components/insights/hydration-card").then((mod) => ({ - default: mod.HydrationCard, - })), - { ssr: false }, -); // v1.25 — baseline-drift card. Un-mounts when nothing is drifting; owns its own // read-only `/api/insights/health-status` query. const HealthStatusCard = dynamic( @@ -508,7 +499,6 @@ export default function InsightsPage() { ) : null, signals: , "rhythm-events": , - hydration: , "health-status": , breathing: , "labs-changes": , diff --git a/src/components/insights/hydration-card.tsx b/src/components/insights/hydration-card.tsx deleted file mode 100644 index 45e5c3919..000000000 --- a/src/components/insights/hydration-card.tsx +++ /dev/null @@ -1,288 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { Check, GlassWater, Pencil, Plus } from "lucide-react"; -import { toast } from "sonner"; - -import { useTranslations } from "@/lib/i18n/context"; -import { useAuth } from "@/hooks/use-auth"; -import { cn } from "@/lib/utils"; -import { ApiError, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch"; -import { - queryKeys, - invalidateKeys, - measurementDependentKeys, -} from "@/lib/query-keys"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { SectionHeading } from "@/components/insights/section-heading"; -import { HydrationRing } from "@/components/insights/hydration-ring"; -import { - HYDRATION_QUICK_ADD_ML, - MAX_HYDRATION_ENTRY_ML, - MAX_HYDRATION_GOAL_ML, - MIN_HYDRATION_ENTRY_ML, - MIN_HYDRATION_GOAL_ML, -} from "@/lib/hydration/hydration"; - -/** - * v1.25 — hydration daily-goal card. - * - * A calm goal tracker for the Insights overview: a goal ring (today's summed - * WATER_INTAKE vs the user's goal), quick-add buttons (+250 / +500 ml and a - * custom amount), and an inline goal editor. Logging rides the existing - * measurements create path (POST /api/measurements, type WATER_INTAKE), so a - * new entry busts `measurementDependentKeys`, which carries the hydration key — - * the ring refreshes in lockstep. The goal write goes to PATCH /api/hydration. - * - * Always present (a goal ring at 0 / goal is still useful), unlike the - * insufficient-history read cards which un-mount. - */ - -interface HydrationTodayResponse { - date: string; - totalMl: number; - goalMl: number; - percent: number; - rawPercent: number; - met: boolean; - remainingMl: number; - entries: { id: string; value: number; measuredAt: string }[]; -} - -interface HydrationCardProps { - enabled?: boolean; - className?: string; -} - -export function HydrationCard({ - enabled = true, - className, -}: HydrationCardProps) { - const { t } = useTranslations(); - const { isAuthenticated } = useAuth(); - const queryClient = useQueryClient(); - - const [busy, setBusy] = useState(false); - const [customMl, setCustomMl] = useState(""); - const [editingGoal, setEditingGoal] = useState(false); - const [goalDraft, setGoalDraft] = useState(""); - - const gated = enabled && isAuthenticated; - - const { data } = useQuery({ - queryKey: queryKeys.hydrationToday(), - queryFn: () => apiGet("/api/hydration"), - enabled: gated, - staleTime: 60_000, - }); - - async function logAmount(ml: number) { - if (busy || ml <= 0) return; - setBusy(true); - try { - await apiPost("/api/measurements", { - type: "WATER_INTAKE", - value: ml, - measuredAt: new Date().toISOString(), - }); - await invalidateKeys(queryClient, measurementDependentKeys); - toast.success(t("hydration.added", { amount: ml })); - } catch (err) { - toast.error( - err instanceof ApiError && err.message - ? err.message - : t("hydration.saveError"), - ); - } finally { - setBusy(false); - } - } - - async function logCustom() { - const ml = Math.round(Number(customMl)); - if ( - !Number.isFinite(ml) || - ml < MIN_HYDRATION_ENTRY_ML || - ml > MAX_HYDRATION_ENTRY_ML - ) { - toast.error(t("hydration.invalidAmount")); - return; - } - await logAmount(ml); - setCustomMl(""); - } - - async function saveGoal() { - const ml = Math.round(Number(goalDraft)); - if ( - !Number.isFinite(ml) || - ml < MIN_HYDRATION_GOAL_ML || - ml > MAX_HYDRATION_GOAL_ML - ) { - toast.error(t("hydration.invalidGoal")); - return; - } - setBusy(true); - try { - await apiPatch("/api/hydration", { goalMl: ml }); - await invalidateKeys(queryClient, [queryKeys.hydration()]); - setEditingGoal(false); - toast.success(t("hydration.goalSaved")); - } catch (err) { - toast.error( - err instanceof ApiError && err.message - ? err.message - : t("hydration.saveError"), - ); - } finally { - setBusy(false); - } - } - - const totalMl = data?.totalMl ?? 0; - const goalMl = data?.goalMl ?? 0; - const percent = data?.percent ?? 0; - const met = data?.met ?? false; - const remainingMl = data?.remainingMl ?? 0; - - return ( -
- { - setGoalDraft(String(goalMl || "")); - setEditingGoal(true); - }} - data-slot="hydration-edit-goal" - > -
- ); -} diff --git a/src/components/insights/hydration-ring.tsx b/src/components/insights/hydration-ring.tsx deleted file mode 100644 index 6c73c7c24..000000000 --- a/src/components/insights/hydration-ring.tsx +++ /dev/null @@ -1,121 +0,0 @@ -"use client"; - -import { useEffect, useId, useState } from "react"; - -import { cn } from "@/lib/utils"; -import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; - -/** - * v1.25 — hydration daily-goal ring. - * - * A hand-rolled SVG arc gauge in the same idiom as `score-ring.tsx` (fixed - * `0 0 100 100` viewBox, ~9% round-cap stroke, a faint same-hue track, a - * subtle first-paint sweep gated on `prefers-reduced-motion`). It is purpose- - * built for a goal counter rather than a 0–100 band score: the arc fills to the - * percent of goal (capped at 100), the centre shows the day's total in ml, and - * the hue is a single calm water tone — never the green/yellow/red band - * semantics a low score would otherwise paint, so "not much yet" never reads as - * an alarm. The `` is real SVG JSX, not innerHTML. - */ - -const VIEW = 100; -const STROKE = 9; -const R = (VIEW - STROKE) / 2; -const C = 2 * Math.PI * R; -const CX = VIEW / 2; - -export interface HydrationRingProps { - /** Progress toward the goal, 0..100 (already capped by the caller). */ - percent: number; - /** Today's total in ml — the centred number. */ - totalMl: number; - /** The goal in ml — rendered under the total. */ - goalMl: number; - /** Whether the goal has been met (adds a met affordance). */ - met?: boolean; - /** Render size in px. */ - size?: number; - className?: string; -} - -export function HydrationRing({ - percent, - totalMl, - goalMl, - met = false, - size = 168, - className, -}: HydrationRingProps) { - const gid = useId(); - const clamped = Math.min( - 100, - Math.max(0, Number.isFinite(percent) ? percent : 0), - ); - const dash = (clamped / 100) * C; - - const reduced = prefersReducedMotion(); - const [armed, setArmed] = useState(false); - useEffect(() => { - if (reduced) return; - const raf = requestAnimationFrame(() => setArmed(true)); - return () => cancelAnimationFrame(raf); - }, [reduced]); - const filled = reduced || armed; - - return ( -
- -
- - {totalMl} - - {`/ ${goalMl} ml`} -
-
- ); -} diff --git a/src/components/insights/insights-edit-mode.tsx b/src/components/insights/insights-edit-mode.tsx index bf64ceea6..7d972157d 100644 --- a/src/components/insights/insights-edit-mode.tsx +++ b/src/components/insights/insights-edit-mode.tsx @@ -63,7 +63,6 @@ const SECTION_TITLE_KEYS: Record = { "cycle-summary": "cycle.insightsSummary.title", signals: "insights.derived.coincident.cardTitle", "rhythm-events": "insights.rhythmEvents.sectionTitle", - hydration: "hydration.cardTitle", "health-status": "insights.healthStatus.sectionTitle", breathing: "insights.breathingScreening.sectionTitle", "labs-changes": "insights.labsChanges.sectionTitle", diff --git a/src/components/measurements/measurement-list-meta.ts b/src/components/measurements/measurement-list-meta.ts index 56255d6b0..f27dd5ef2 100644 --- a/src/components/measurements/measurement-list-meta.ts +++ b/src/components/measurements/measurement-list-meta.ts @@ -35,7 +35,6 @@ import { Sun, PersonStanding, Waves, - GlassWater, type LucideIcon, } from "lucide-react"; @@ -55,7 +54,6 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record = { HEART_RATE_VARIABILITY: "measurements.typeHeartRateVariability", RESTING_HEART_RATE: "measurements.typeRestingHeartRate", ACTIVE_ENERGY_BURNED: "measurements.typeActiveEnergyBurned", - WATER_INTAKE: "measurements.typeWaterIntake", FLIGHTS_CLIMBED: "measurements.typeFlightsClimbed", WALKING_RUNNING_DISTANCE: "measurements.typeWalkingRunningDistance", VO2_MAX: "measurements.typeVo2Max", @@ -142,7 +140,6 @@ export const MEASUREMENT_TYPE_ICONS: Record = { HEART_RATE_VARIABILITY: HeartPulse, RESTING_HEART_RATE: Heart, ACTIVE_ENERGY_BURNED: Flame, - WATER_INTAKE: GlassWater, FLIGHTS_CLIMBED: TrendingUp, WALKING_RUNNING_DISTANCE: Footprints, VO2_MAX: Gauge, @@ -266,7 +263,6 @@ export const MEASUREMENT_TYPE_COLORS: Record = { HEART_RATE_VARIABILITY: "bg-chart-5/20 text-chart-5", RESTING_HEART_RATE: "bg-chart-3/20 text-chart-3", ACTIVE_ENERGY_BURNED: "bg-chart-4/20 text-chart-4", - WATER_INTAKE: "bg-chart-2/20 text-chart-2", FLIGHTS_CLIMBED: "bg-chart-2/20 text-chart-2", WALKING_RUNNING_DISTANCE: "bg-chart-2/20 text-chart-2", VO2_MAX: "bg-chart-1/20 text-chart-1", diff --git a/src/lib/__tests__/insights-layout.test.ts b/src/lib/__tests__/insights-layout.test.ts index 0d216bed3..04dff7b0a 100644 --- a/src/lib/__tests__/insights-layout.test.ts +++ b/src/lib/__tests__/insights-layout.test.ts @@ -30,7 +30,6 @@ describe("insights-layout v2 — section id universe", () => { "cycle-summary", "signals", "rhythm-events", - "hydration", "health-status", "breathing", "labs-changes", diff --git a/src/lib/__tests__/measurement-type-enum-coverage.test.ts b/src/lib/__tests__/measurement-type-enum-coverage.test.ts index ceaf09f2c..a8ac5ef1c 100644 --- a/src/lib/__tests__/measurement-type-enum-coverage.test.ts +++ b/src/lib/__tests__/measurement-type-enum-coverage.test.ts @@ -98,8 +98,6 @@ const EXPECTED_TYPES = [ "BODY_TEMPERATURE_DEVIATION", // ── v1.19.0 — Oura resilience (ordinal-encoded level) ── "RESILIENCE", - // ── v1.25 — in-app water-intake logging (hydration goal ring) ── - "WATER_INTAKE", ] as const; describe("measurementTypeEnum coverage", () => { @@ -246,11 +244,6 @@ describe("measurementTypeEnum coverage", () => { // Insights / measurement-list surfaces and never belongs in the clinical // vitals PDF. See doctor-report-pdf-core.ts for the matching rationale. "RESILIENCE", - // v1.25 — water intake is an in-app lifestyle log (the hydration goal - // ring), not a measured clinical vital; it surfaces on the Insights - // overview and never belongs in the clinical vitals PDF. See - // doctor-report-pdf-core.ts for the matching exclusion rationale. - "WATER_INTAKE", ]); it("doctor-report PDF vital types cover the canonical enum minus documented exclusions", () => { diff --git a/src/lib/hydration/__tests__/hydration.test.ts b/src/lib/hydration/__tests__/hydration.test.ts deleted file mode 100644 index 8939919be..000000000 --- a/src/lib/hydration/__tests__/hydration.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, it, expect } from "vitest"; - -import { - DEFAULT_HYDRATION_GOAL_ML, - MAX_HYDRATION_GOAL_ML, - MIN_HYDRATION_GOAL_ML, - resolveHydrationGoal, - summariseHydration, -} from "@/lib/hydration/hydration"; - -describe("resolveHydrationGoal", () => { - it("falls back to the default when unset", () => { - expect(resolveHydrationGoal(null)).toBe(DEFAULT_HYDRATION_GOAL_ML); - expect(resolveHydrationGoal(undefined)).toBe(DEFAULT_HYDRATION_GOAL_ML); - expect(resolveHydrationGoal(Number.NaN)).toBe(DEFAULT_HYDRATION_GOAL_ML); - }); - - it("clamps below the minimum", () => { - expect(resolveHydrationGoal(10)).toBe(MIN_HYDRATION_GOAL_ML); - }); - - it("clamps above the maximum", () => { - expect(resolveHydrationGoal(99999)).toBe(MAX_HYDRATION_GOAL_ML); - }); - - it("keeps an in-band value (rounded)", () => { - expect(resolveHydrationGoal(2500.4)).toBe(2500); - }); -}); - -describe("summariseHydration", () => { - it("empty day", () => { - const s = summariseHydration(0, 2000); - expect(s).toMatchObject({ - totalMl: 0, - goalMl: 2000, - percent: 0, - rawPercent: 0, - met: false, - remainingMl: 2000, - }); - }); - - it("partial day", () => { - const s = summariseHydration(1000, 2000); - expect(s.percent).toBe(50); - expect(s.rawPercent).toBe(50); - expect(s.met).toBe(false); - expect(s.remainingMl).toBe(1000); - }); - - it("goal met exactly", () => { - const s = summariseHydration(2000, 2000); - expect(s.percent).toBe(100); - expect(s.met).toBe(true); - expect(s.remainingMl).toBe(0); - }); - - it("exceeded — percent caps at 100, rawPercent does not", () => { - const s = summariseHydration(2500, 2000); - expect(s.percent).toBe(100); - expect(s.rawPercent).toBe(125); - expect(s.met).toBe(true); - expect(s.remainingMl).toBe(0); - }); - - it("floors a negative total at zero", () => { - const s = summariseHydration(-500, 2000); - expect(s.totalMl).toBe(0); - expect(s.met).toBe(false); - }); -}); diff --git a/src/lib/hydration/hydration.ts b/src/lib/hydration/hydration.ts deleted file mode 100644 index a16bce868..000000000 --- a/src/lib/hydration/hydration.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * Hydration logging — pure helpers for the daily water-intake goal ring. - * - * Water intake is stored as `WATER_INTAKE` Measurement rows in millilitres - * (ml), one row per quick-add entry. The day's running total is the SUM of - * those rows (see `CUMULATIVE_HK_TYPES`); the goal ring compares that total - * against a per-user goal (`User.hydrationGoalMl`, null = the default). - * - * The math lives here, free of Prisma + React, so the ring states (empty / - * partial / met / exceeded) are unit-testable in isolation. - */ - -/** Default daily hydration goal when the user has not set their own (ml). */ -export const DEFAULT_HYDRATION_GOAL_ML = 2000; - -/** Bounds for a user-set goal — a plausible adult range, not a clinical limit. */ -export const MIN_HYDRATION_GOAL_ML = 250; -export const MAX_HYDRATION_GOAL_ML = 8000; - -/** The fixed quick-add amounts the card offers alongside a custom entry (ml). */ -export const HYDRATION_QUICK_ADD_ML = [250, 500] as const; - -/** Bounds for a single logged entry (ml) — mirrors the measurement clamp. */ -export const MIN_HYDRATION_ENTRY_ML = 1; -export const MAX_HYDRATION_ENTRY_ML = 5000; - -/** - * Resolve the effective goal from the (nullable) stored value: fall back to - * the default when unset, and clamp a stored value into the supported band so - * a stale out-of-range row never produces a broken ring. - */ -export function resolveHydrationGoal( - stored: number | null | undefined, -): number { - if (stored == null || !Number.isFinite(stored)) { - return DEFAULT_HYDRATION_GOAL_ML; - } - return Math.min( - MAX_HYDRATION_GOAL_ML, - Math.max(MIN_HYDRATION_GOAL_ML, Math.round(stored)), - ); -} - -export interface HydrationSummary { - /** Today's summed intake in ml. */ - totalMl: number; - /** The effective daily goal in ml. */ - goalMl: number; - /** Progress toward the goal, capped at 100 for the ring fill. */ - percent: number; - /** Uncapped progress (can exceed 100 once the goal is met). */ - rawPercent: number; - /** Whether the day's total has reached the goal. */ - met: boolean; - /** Remaining ml to the goal (0 once met). */ - remainingMl: number; -} - -/** - * Summarise a day's intake against a goal. `totalMl` is the already-summed - * day total; `goalMl` is the effective goal (run it through - * `resolveHydrationGoal` first). Negative totals are floored at 0. - */ -export function summariseHydration( - totalMl: number, - goalMl: number, -): HydrationSummary { - const total = Number.isFinite(totalMl) ? Math.max(0, Math.round(totalMl)) : 0; - const goal = resolveHydrationGoal(goalMl); - const rawPercent = goal > 0 ? Math.round((total / goal) * 100) : 0; - const percent = Math.min(100, rawPercent); - return { - totalMl: total, - goalMl: goal, - percent, - rawPercent, - met: total >= goal, - remainingMl: Math.max(0, goal - total), - }; -} diff --git a/src/lib/insights-layout.ts b/src/lib/insights-layout.ts index f3bff31be..1f7960440 100644 --- a/src/lib/insights-layout.ts +++ b/src/lib/insights-layout.ts @@ -113,7 +113,6 @@ export const INSIGHTS_SECTION_IDS = [ "cycle-summary", "signals", "rhythm-events", - "hydration", "health-status", "breathing", "labs-changes", diff --git a/src/lib/insights/chart-tokens.ts b/src/lib/insights/chart-tokens.ts index 06c669fb0..fca9edb1f 100644 --- a/src/lib/insights/chart-tokens.ts +++ b/src/lib/insights/chart-tokens.ts @@ -38,9 +38,6 @@ export const ALLOWED_CHART_TOKENS = [ // components ship in v1.5 alongside the iOS app surface. "metric:HEART_RATE_VARIABILITY", "metric:RESTING_HEART_RATE", - // v1.25 — daily water intake (cumulative ml), charted alongside the - // hydration goal ring on the overview. - "metric:WATER_INTAKE", "metric:ACTIVE_ENERGY_BURNED", "metric:FLIGHTS_CLIMBED", "metric:WALKING_RUNNING_DISTANCE", diff --git a/src/lib/measurements/__tests__/apple-health-mapping.test.ts b/src/lib/measurements/__tests__/apple-health-mapping.test.ts index ab5dbb6fd..0c0a7ef6b 100644 --- a/src/lib/measurements/__tests__/apple-health-mapping.test.ts +++ b/src/lib/measurements/__tests__/apple-health-mapping.test.ts @@ -77,12 +77,6 @@ const MEASUREMENT_TYPES_WITHOUT_HK_COUNTERPART = new Set([ // the Oura API (source = OURA), never from HealthKit: Apple ships no // resilience metric. No HK mapping by design. "RESILIENCE", - // v1.25 — water intake is logged in-app (the hydration goal ring writes - // WATER_INTAKE rows directly via POST /api/measurements). Apple's - // `HKQuantityTypeIdentifierDietaryWater` stays on the nutrition deferred - // hold (HK_QUANTITY_TYPE_DEFERRED), so the type carries no HK import mapping - // by design. - "WATER_INTAKE", ]); describe("APPLE_HEALTH_TYPE_MAP", () => { diff --git a/src/lib/measurements/apple-health-mapping.ts b/src/lib/measurements/apple-health-mapping.ts index 12a6d34aa..d6be7265a 100644 --- a/src/lib/measurements/apple-health-mapping.ts +++ b/src/lib/measurements/apple-health-mapping.ts @@ -706,11 +706,6 @@ export const CUMULATIVE_HK_TYPES: ReadonlySet = // daily total is the meaningful reduction (SUM), matching the other // cumulative HK counts. "FALL_COUNT", - // v1.25 — manual hydration intake. Not a HealthKit type, but its daily - // reduction is a SUM (running total of the day's entries), so it rides the - // same cumulative daily-sum path as the counts above rather than averaging - // individual sips. - "WATER_INTAKE", ]); /** diff --git a/src/lib/measurements/categories.ts b/src/lib/measurements/categories.ts index aaf1038f6..f9457adf2 100644 --- a/src/lib/measurements/categories.ts +++ b/src/lib/measurements/categories.ts @@ -89,8 +89,6 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap< // ── Activity (cumulative + fitness + mobility) ── ["ACTIVITY_STEPS", "activity"], ["ACTIVE_ENERGY_BURNED", "activity"], - // v1.25 — hydration intake; cumulative daily counter, groups with activity. - ["WATER_INTAKE", "activity"], ["FLIGHTS_CLIMBED", "activity"], ["WALKING_RUNNING_DISTANCE", "activity"], ["VO2_MAX", "activity"], diff --git a/src/lib/measurements/display-transform.ts b/src/lib/measurements/display-transform.ts index 39b058505..1bc059952 100644 --- a/src/lib/measurements/display-transform.ts +++ b/src/lib/measurements/display-transform.ts @@ -105,8 +105,6 @@ const INTEGER_DISPLAY_TYPES: ReadonlySet = new Set([ // grain for these day-cumulative counts. "ACTIVE_ENERGY_BURNED", "TIME_IN_DAYLIGHT", - // ml of water — whole millilitres are the meaningful display grain. - "WATER_INTAKE", ]); /** diff --git a/src/lib/openapi/registry.ts b/src/lib/openapi/registry.ts index afe62485e..147d50b24 100644 --- a/src/lib/openapi/registry.ts +++ b/src/lib/openapi/registry.ts @@ -77,7 +77,6 @@ const openApiBase: Pick< { name: "Illness" }, { name: "Admin" }, { name: "Meta" }, - { name: "Hydration" }, ], }; diff --git a/src/lib/openapi/routes/hydration.ts b/src/lib/openapi/routes/hydration.ts deleted file mode 100644 index 58dccb2f2..000000000 --- a/src/lib/openapi/routes/hydration.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * OpenAPI route module — hydration daily-goal surface (v1.25). - * - * `GET /api/hydration` reads today's summed water intake vs the user's goal; - * `PATCH /api/hydration` sets the per-user daily goal. Logging rides the - * measurements create path (POST /api/measurements, type WATER_INTAKE), so it - * is documented there, not here. - * - * Part of the OpenAPI route table; aggregated in `./index.ts`. - */ -import { z } from "zod/v4"; -import type { ZodOpenApiObject } from "zod-openapi"; - -import { dataEnvelope, stdResponses } from "./shared"; - -const hydrationEntry = z.object({ - id: z.string(), - value: z.number().describe("Logged amount in millilitres."), - measuredAt: z.string().describe("ISO-8601 instant the entry was logged."), -}); - -const hydrationTodayResponse = z - .object({ - date: z.string().describe("ISO-8601 start of the user's local day."), - totalMl: z.number().describe("Today's summed intake in ml."), - goalMl: z.number().describe("Effective daily goal in ml."), - percent: z.number().describe("Progress toward the goal, capped at 100."), - rawPercent: z.number().describe("Uncapped progress; can exceed 100."), - met: z.boolean().describe("Whether the day's total reached the goal."), - remainingMl: z.number().describe("Remaining ml to the goal (0 once met)."), - entries: z.array(hydrationEntry), - }) - .meta({ - id: "HydrationToday", - description: "Today's water-intake total vs the user's daily goal.", - }); - -const hydrationGoalRequest = z - .object({ - goalMl: z - .number() - .int() - .min(250) - .max(8000) - .describe("Daily hydration goal in millilitres."), - }) - .meta({ - id: "HydrationGoalRequest", - description: "Set the per-user daily hydration goal (ml).", - }); - -const hydrationGoalResponse = z - .object({ goalMl: z.number() }) - .meta({ id: "HydrationGoalResponse" }); - -export const hydrationPaths: NonNullable = { - "/api/hydration": { - get: { - tags: ["Hydration"], - summary: "Today's hydration total vs goal", - description: - "Sums the calling user's WATER_INTAKE measurements for the current local day and compares them against the per-user goal (default 2000 ml).", - responses: { - "200": { - description: "Today's hydration summary.", - content: { - "application/json": { - schema: dataEnvelope( - hydrationTodayResponse, - "HydrationTodayEnvelope", - ), - }, - }, - }, - ...stdResponses, - }, - }, - patch: { - tags: ["Hydration"], - summary: "Set the daily hydration goal", - description: "Updates the per-user daily hydration goal in millilitres.", - requestBody: { - required: true, - content: { "application/json": { schema: hydrationGoalRequest } }, - }, - responses: { - "200": { - description: "Goal updated.", - content: { - "application/json": { - schema: dataEnvelope( - hydrationGoalResponse, - "HydrationGoalEnvelope", - ), - }, - }, - }, - ...stdResponses, - }, - }, - }, -}; diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index 0df9ab23c..ae44bef96 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -36,7 +36,6 @@ import { biomarkerPaths } from "./biomarkers"; import { devicePaths } from "./devices"; import { exportPaths } from "./export"; import { healthRecordPaths } from "./health-record"; -import { hydrationPaths } from "./hydration"; import { illnessPaths } from "./illness"; import { importPaths } from "./import"; import { insightsPaths } from "./insights"; @@ -85,7 +84,6 @@ export const openApiPaths: NonNullable = { ...biomarkerPaths, ...illnessPaths, ...onboardingPaths, - ...hydrationPaths, }; export const openApiComponents: NonNullable = { diff --git a/src/lib/personal-records/pr-direction.ts b/src/lib/personal-records/pr-direction.ts index 9671186b8..0058a5131 100644 --- a/src/lib/personal-records/pr-direction.ts +++ b/src/lib/personal-records/pr-direction.ts @@ -194,11 +194,6 @@ export function getPRDirection( // inputs — a derived composite with no clean goal axis, so it stays null // like the other device-derived scores. case "RESILIENCE": - // v1.25 — water intake is a cumulative daily counter surfaced as a - // goal ring + logging streak, not a single-reading achievement. A - // "biggest sip" record would be meaningless and the daily-total record - // is better served by the streak rail, so it stays null. - case "WATER_INTAKE": return null; } } diff --git a/src/lib/query-keys/hydration.ts b/src/lib/query-keys/hydration.ts deleted file mode 100644 index 867dce37a..000000000 --- a/src/lib/query-keys/hydration.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Query keys — hydration tree: today's water-intake total vs the daily goal - * (`/api/hydration`). Part of the centralized factory; aggregated in - * `./index.ts`. A `WATER_INTAKE` measurement write busts this key through the - * `measurementDependentKeys` bundle so the goal ring refreshes in lockstep. - */ -export const hydrationKeys = { - hydration: () => ["hydration"] as const, - hydrationToday: () => ["hydration", "today"] as const, -}; diff --git a/src/lib/query-keys/index.ts b/src/lib/query-keys/index.ts index 762f42536..d84854923 100644 --- a/src/lib/query-keys/index.ts +++ b/src/lib/query-keys/index.ts @@ -19,7 +19,6 @@ import { authKeys } from "./auth"; import { coachKeys } from "./coach"; import { cycleKeys } from "./cycle"; import { dashboardKeys } from "./dashboard"; -import { hydrationKeys } from "./hydration"; import { illnessKeys } from "./illness"; import { insightsKeys } from "./insights"; import { integrationKeys } from "./integrations"; @@ -47,7 +46,6 @@ export const queryKeys = { ...measurementReminderKeys, ...labKeys, ...illnessKeys, - ...hydrationKeys, }; /** @@ -88,8 +86,6 @@ export const measurementDependentKeys = [ ["sleep-night"] as const, // v1.17.0 — refresh the sleep-debt + chronotype read when sleep rows change. ["sleep-rhythm"] as const, - // v1.25 — refresh the hydration goal ring when a WATER_INTAKE row is added. - queryKeys.hydration(), ]; /** diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index 97a4876e0..2c11ba6f0 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -122,10 +122,6 @@ export const measurementTypeEnum = z.enum([ // into the numeric value (limited=1 … exceptional=5), unit `level`. Ingests // server-side as `source = OURA`. See `RESILIENCE_LEVELS` in `src/lib/oura/client`. "RESILIENCE", - // ── v1.25 — hydration logging (additive) ── - // Manual water-intake counter in millilitres. Cumulative within a day like - // ACTIVITY_STEPS; distinct from TOTAL_BODY_WATER (a scale reading in kg). - "WATER_INTAKE", ]); /** @@ -243,7 +239,6 @@ const unitMap: Record = { HEART_RATE_VARIABILITY: "ms", RESTING_HEART_RATE: "bpm", ACTIVE_ENERGY_BURNED: "kcal", - WATER_INTAKE: "ml", FLIGHTS_CLIMBED: "flights", WALKING_RUNNING_DISTANCE: "m", VO2_MAX: "mL/(kg·min)", @@ -399,9 +394,6 @@ export const VALUE_RANGES: Record = { // Active energy kcal/sample — a daily-rollup sample tops out around // 6–8000 kcal even for ultra-endurance days. ACTIVE_ENERGY_BURNED: { min: 0, max: 10000 }, - // Daily cumulative intake in ml; a single quick-add entry is small but the - // running daily total can reach several litres on an active day. - WATER_INTAKE: { min: 0, max: 20000 }, // Flights — vertical-feet/3 ≈ flights; cap generous. FLIGHTS_CLIMBED: { min: 0, max: 1000 }, // Walking/running distance per sample (metres). 200 km covers an From cc41b6680a363409b884706686aba45f1c2bd221 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 11:53:39 +0200 Subject: [PATCH 16/94] refactor(doctor-report): split the type-label keys out of the PDF core Move DOCTOR_REPORT_TYPE_LABEL_KEYS into a jsPDF-free module so leaf consumers can import the map without dragging jspdf + jspdf-autotable into their graph. The PDF core re-exports the map, keeping a single source of truth and its existing import surface intact. Point the clinician share view at the new module so the public share route no longer pulls the PDF renderer into its server graph. --- src/components/clinician/clinician-view.tsx | 2 +- src/lib/doctor-report-pdf-core.ts | 23 ++++++-------------- src/lib/doctor-report/type-label-keys.ts | 24 +++++++++++++++++++++ 3 files changed, 31 insertions(+), 18 deletions(-) create mode 100644 src/lib/doctor-report/type-label-keys.ts diff --git a/src/components/clinician/clinician-view.tsx b/src/components/clinician/clinician-view.tsx index 2b06593a2..1850a2b64 100644 --- a/src/components/clinician/clinician-view.tsx +++ b/src/components/clinician/clinician-view.tsx @@ -10,7 +10,7 @@ * a FENCED, muted wellness card carrying the load-bearing "descriptive, not a * clinical assessment / not a diagnosis" disclaimer. */ -import { DOCTOR_REPORT_TYPE_LABEL_KEYS } from "@/lib/doctor-report-pdf-core"; +import { DOCTOR_REPORT_TYPE_LABEL_KEYS } from "@/lib/doctor-report/type-label-keys"; import type { DoctorReportData } from "@/lib/doctor-report-data"; import type { DoctorReportPrefs } from "@/lib/validations/doctor-report-prefs"; diff --git a/src/lib/doctor-report-pdf-core.ts b/src/lib/doctor-report-pdf-core.ts index 79d3ade8b..54f2895bb 100644 --- a/src/lib/doctor-report-pdf-core.ts +++ b/src/lib/doctor-report-pdf-core.ts @@ -11,6 +11,7 @@ */ import { jsPDF } from "jspdf"; import autoTable from "jspdf-autotable"; +import { DOCTOR_REPORT_TYPE_LABEL_KEYS } from "./doctor-report/type-label-keys"; import { makeFormatters } from "./format-locale"; import type { Locale } from "./i18n/config"; import { convertGlucose, resolveGlucoseUnit } from "./glucose"; @@ -150,23 +151,11 @@ export interface DoctorReportRenderOptions { aiSummary?: string | null; } -/** - * Per-vital label keys. Exported for coverage tests (issue #109 / phase P0) - * so a future enum addition is caught by a unit test rather than reaching - * production as a raw enum string in the PDF. - */ -export const DOCTOR_REPORT_TYPE_LABEL_KEYS: Record = { - WEIGHT: "doctorReport.typeWeight", - BLOOD_PRESSURE_SYS: "doctorReport.typeBpSys", - BLOOD_PRESSURE_DIA: "doctorReport.typeBpDia", - PULSE: "doctorReport.typePulse", - BODY_FAT: "doctorReport.typeBodyFat", - SLEEP_DURATION: "doctorReport.typeSleep", - ACTIVITY_STEPS: "doctorReport.typeSteps", - TOTAL_BODY_WATER: "doctorReport.typeTotalBodyWater", - BONE_MASS: "doctorReport.typeBoneMass", - OXYGEN_SATURATION: "doctorReport.typeOxygenSaturation", -}; +// Per-vital label keys live in a jsPDF-free module so leaf consumers (the +// public clinician share view) can import the map without dragging the PDF +// renderer into their graph. Re-exported here so the PDF core stays the +// single import surface its existing callers already use. +export { DOCTOR_REPORT_TYPE_LABEL_KEYS }; export const DOCTOR_REPORT_TYPE_UNIT_KEYS: Record = { WEIGHT: "kg", diff --git a/src/lib/doctor-report/type-label-keys.ts b/src/lib/doctor-report/type-label-keys.ts new file mode 100644 index 000000000..9123e57b9 --- /dev/null +++ b/src/lib/doctor-report/type-label-keys.ts @@ -0,0 +1,24 @@ +/** + * Per-vital i18n label keys for the doctor report. + * + * Extracted from `doctor-report-pdf-core` so consumers that only need the + * label map (e.g. the public clinician share view, a pure server component) + * do not pull the jsPDF renderer into their module graph. The PDF core + * re-exports this map so there is a single source of truth. + * + * Exported for coverage tests (issue #109 / phase P0) so a future enum + * addition is caught by a unit test rather than reaching production as a raw + * enum string in the PDF. + */ +export const DOCTOR_REPORT_TYPE_LABEL_KEYS: Record = { + WEIGHT: "doctorReport.typeWeight", + BLOOD_PRESSURE_SYS: "doctorReport.typeBpSys", + BLOOD_PRESSURE_DIA: "doctorReport.typeBpDia", + PULSE: "doctorReport.typePulse", + BODY_FAT: "doctorReport.typeBodyFat", + SLEEP_DURATION: "doctorReport.typeSleep", + ACTIVITY_STEPS: "doctorReport.typeSteps", + TOTAL_BODY_WATER: "doctorReport.typeTotalBodyWater", + BONE_MASS: "doctorReport.typeBoneMass", + OXYGEN_SATURATION: "doctorReport.typeOxygenSaturation", +}; From be37a87fa7c3d69ea6460e57a3c254c0342769b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 11:53:46 +0200 Subject: [PATCH 17/94] fix(sharing): resolve gate strings on the server so the share view renders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The passphrase gate is a client component, but the page passed it a `t` translator function. A function cannot cross the server→client boundary, so a fresh (passphrase-protected) link threw a generic server-components render error instead of painting the unlock form. Pre-resolve the gate copy on the server into a plain, serializable `strings` object and pass that instead. Locale resolution stays server-authoritative. The page test mocked the gate away and only checked the token prop, so the regression slipped through. Tighten it to assert the gate receives a serializable contract: no prop value is a function, and the copy arrives as a pre-resolved plain string object. --- src/app/c/[token]/__tests__/page.test.tsx | 26 ++++++++++++++ src/app/c/[token]/page.tsx | 12 ++++++- .../clinician/share-unlock-gate.tsx | 36 ++++++++++--------- 3 files changed, 56 insertions(+), 18 deletions(-) diff --git a/src/app/c/[token]/__tests__/page.test.tsx b/src/app/c/[token]/__tests__/page.test.tsx index 53dacacc1..911a7f956 100644 --- a/src/app/c/[token]/__tests__/page.test.tsx +++ b/src/app/c/[token]/__tests__/page.test.tsx @@ -72,6 +72,15 @@ function isGate(out: unknown): boolean { ); } +/** + * Returns the props the page handed to the gate element. A client component + * boundary can only carry serializable props — issue #374 was a `t` FUNCTION + * crossing it, which throws at render. + */ +function gateProps(out: unknown): Record { + return (out as { props: Record }).props; +} + function resolvedContext() { return { shareLinkId: "link-1", @@ -126,6 +135,23 @@ describe("clinician share page", () => { expect(isGate(out)).toBe(true); expect(resolve).not.toHaveBeenCalled(); expect(loadData).not.toHaveBeenCalled(); + + // Issue #374 contract: every prop crossing the server→client boundary must + // be serializable. NO prop value may be a function (a `t` function here + // throws "Server Components render error"), and the copy must arrive as a + // pre-resolved plain object. + const props = gateProps(out); + for (const value of Object.values(props)) { + expect(typeof value).not.toBe("function"); + } + expect(typeof props.token).toBe("string"); + const strings = props.strings as Record; + expect(strings).toBeTypeOf("object"); + expect(strings).not.toBeNull(); + expect(Object.values(strings).length).toBeGreaterThan(0); + for (const value of Object.values(strings)) { + expect(typeof value).toBe("string"); + } }); it("renders a protected link WITH a valid unlock cookie", async () => { diff --git a/src/app/c/[token]/page.tsx b/src/app/c/[token]/page.tsx index 38633ec14..986836f8a 100644 --- a/src/app/c/[token]/page.tsx +++ b/src/app/c/[token]/page.tsx @@ -82,7 +82,17 @@ export default async function ClinicianSharePage({ if (!verifyUnlockValue(unlockValue, gate.tokenHash)) { const locale = await resolveLocale(); const { t } = getServerTranslator(locale); - return t(key, vars)} token={token} />; + // Resolve the gate copy on the server (it owns the locale) into a plain, + // serializable object. A `t` function cannot cross the RSC boundary into + // the client gate — passing one throws at render (issue #374). + const strings = { + title: t("clinicianView.unlock.title"), + description: t("clinicianView.unlock.description"), + label: t("clinicianView.unlock.label"), + error: t("clinicianView.unlock.error"), + submit: t("clinicianView.unlock.submit"), + }; + return ; } } diff --git a/src/components/clinician/share-unlock-gate.tsx b/src/components/clinician/share-unlock-gate.tsx index a4f613e27..36547d00d 100644 --- a/src/components/clinician/share-unlock-gate.tsx +++ b/src/components/clinician/share-unlock-gate.tsx @@ -25,18 +25,26 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { apiPost } from "@/lib/api/api-fetch"; -type Translate = ( - key: string, - vars?: Record, -) => string; +/** + * Plain, serializable copy for the gate. Resolved on the server (which owns the + * locale) and passed across the RSC boundary as data — never a `t` function, as + * functions cannot be serialized to a client component. + */ +export interface ShareUnlockGateStrings { + title: string; + description: string; + label: string; + error: string; + submit: string; +} interface ShareUnlockGateProps { - t: Translate; + strings: ShareUnlockGateStrings; /** The raw `hls_` token from the path — used only to build the verify URL. */ token: string; } -export function ShareUnlockGate({ t, token }: ShareUnlockGateProps) { +export function ShareUnlockGate({ strings, token }: ShareUnlockGateProps) { const [passphrase, setPassphrase] = useState(""); const [pending, setPending] = useState(false); const [error, setError] = useState(false); @@ -86,18 +94,12 @@ export function ShareUnlockGate({ t, token }: ShareUnlockGateProps) { }} >
-

- {t("clinicianView.unlock.title")} -

-

- {t("clinicianView.unlock.description")} -

+

{strings.title}

+

{strings.description}

- + - {t("clinicianView.unlock.error")} + {strings.error}

)} @@ -122,7 +124,7 @@ export function ShareUnlockGate({ t, token }: ShareUnlockGateProps) { disabled={pending || !passphrase.trim()} className="min-h-11 w-full sm:min-h-9" > - {t("clinicianView.unlock.submit")} + {strings.submit} From ee2e9a356389b83ec63e94f901d480e9338bf328 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 11:53:52 +0200 Subject: [PATCH 18/94] feat(sharing): add an error boundary to the public share route A render failure on `/c/` previously fell through to the full-app error screen, which carries app chrome the public share view never wants. Add a route-scoped, chrome-free error boundary that shows a localised, stack-free message and a single retry. Add the matching copy across all six locales. --- messages/de.json | 5 +++++ messages/en.json | 5 +++++ messages/es.json | 5 +++++ messages/fr.json | 5 +++++ messages/it.json | 5 +++++ messages/pl.json | 5 +++++ src/app/c/error.tsx | 41 +++++++++++++++++++++++++++++++++++++++++ 7 files changed, 71 insertions(+) create mode 100644 src/app/c/error.tsx diff --git a/messages/de.json b/messages/de.json index ce28bb09c..745402814 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6358,6 +6358,11 @@ "label": "Passphrase", "error": "Diese Passphrase ist nicht korrekt.", "submit": "Entsperren" + }, + "loadError": { + "title": "Diese geteilte Ansicht konnte nicht geladen werden", + "description": "Beim Öffnen dieser geteilten Gesundheitsdaten ist ein Fehler aufgetreten. Der Link ist möglicherweise abgelaufen, oder es lag ein vorübergehendes Problem vor. Versuche es erneut oder bitte die Person, die den Link geteilt hat, um einen neuen.", + "retry": "Erneut versuchen" } }, "cycle": { diff --git a/messages/en.json b/messages/en.json index 14fa8b085..2123b06ff 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6358,6 +6358,11 @@ "label": "Passphrase", "error": "That passphrase is not correct.", "submit": "Unlock" + }, + "loadError": { + "title": "This shared view could not be loaded", + "description": "Something went wrong while opening this shared health record. The link may have expired, or there was a temporary problem. Try again, or ask the person who shared it for a new link.", + "retry": "Try again" } }, "cycle": { diff --git a/messages/es.json b/messages/es.json index ce2ee6de9..9c486bcb6 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6358,6 +6358,11 @@ "label": "Frase de acceso", "error": "Esa frase de acceso no es correcta.", "submit": "Desbloquear" + }, + "loadError": { + "title": "No se pudo cargar esta vista compartida", + "description": "Se produjo un error al abrir este registro de salud compartido. Puede que el enlace haya caducado o que haya habido un problema temporal. Inténtalo de nuevo o pide un enlace nuevo a la persona que lo compartió.", + "retry": "Intentar de nuevo" } }, "cycle": { diff --git a/messages/fr.json b/messages/fr.json index 69f085b74..4ac28d1b6 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6358,6 +6358,11 @@ "label": "Phrase secrète", "error": "Cette phrase secrète est incorrecte.", "submit": "Déverrouiller" + }, + "loadError": { + "title": "Impossible de charger cette vue partagée", + "description": "Une erreur s'est produite lors de l'ouverture de ce dossier de santé partagé. Le lien a peut-être expiré, ou un problème temporaire est survenu. Réessayez ou demandez un nouveau lien à la personne qui l'a partagé.", + "retry": "Réessayer" } }, "cycle": { diff --git a/messages/it.json b/messages/it.json index 60e5288d4..abfc66c93 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6358,6 +6358,11 @@ "label": "Passphrase", "error": "La passphrase non è corretta.", "submit": "Sblocca" + }, + "loadError": { + "title": "Impossibile caricare questa vista condivisa", + "description": "Si è verificato un errore durante l'apertura di questo referto sanitario condiviso. Il link potrebbe essere scaduto oppure si è verificato un problema temporaneo. Riprova o chiedi un nuovo link alla persona che lo ha condiviso.", + "retry": "Riprova" } }, "cycle": { diff --git a/messages/pl.json b/messages/pl.json index 5631b6f90..9a72becb6 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6358,6 +6358,11 @@ "label": "Hasło", "error": "To hasło jest nieprawidłowe.", "submit": "Odblokuj" + }, + "loadError": { + "title": "Nie udało się załadować udostępnionego widoku", + "description": "Podczas otwierania udostępnionej dokumentacji zdrowotnej wystąpił błąd. Link mógł wygasnąć lub wystąpił chwilowy problem. Spróbuj ponownie albo poproś osobę, która go udostępniła, o nowy link.", + "retry": "Spróbuj ponownie" } }, "cycle": { diff --git a/src/app/c/error.tsx b/src/app/c/error.tsx new file mode 100644 index 000000000..27700b74f --- /dev/null +++ b/src/app/c/error.tsx @@ -0,0 +1,41 @@ +"use client"; + +/** + * Error boundary for the public clinician share route (`/c/`). + * + * The share view carries no app chrome and no session, so a render failure must + * not fall through to the full-app error screen. This renders a clean, locale- + * aware, stack-free message and offers a single retry. No record data, no + * digest, no stack — a probe learns nothing from a failed render. + */ +import { useTranslations } from "@/lib/i18n/context"; +import { Button } from "@/components/ui/button"; + +export default function ClinicianShareError({ + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + const { t } = useTranslations(); + + return ( +
+
+

+ {t("clinicianView.loadError.title")} +

+

+ {t("clinicianView.loadError.description")} +

+ +
+
+ ); +} From 556f442724d5520fc32cc91da8809c8cf860b656 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 12:42:35 +0200 Subject: [PATCH 19/94] fix(insights): honor the per-user AI timeout and keep the last good narrative MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The daily briefing and the per-metric status assessments ignored the per-user response-timeout setting: they hardcoded their per-surface AI_BUDGETS default, so raising the timeout for a slow self-hosted or local backend had no effect and the surfaces still aborted early. Only the coach threaded the setting through. Resolve the effective timeout from User.aiResponseTimeoutSeconds in one shared helper and thread it onto every non-coach generation call: - runStatusCompletion is the single chokepoint every status / reference surface funnels through (per-metric cards, the batched assessment, the derived assessments, the period narratives), so resolving it there applies the setting to all of them at once — on both the upstream call's own timeout and the outer wall-clock cap that previously clipped a raised value at 60 s. - The comprehensive briefing generator and the user-initiated regenerate route thread it onto the main generation, the JSON retry, the grounding retry, and the paragraph re-roll. On a failed generation the briefing keeps its last good text — no cache row is written, so the previous payload stays intact — and records a dated, append-only failure marker (no migration; reuses the audit-log JSON pattern). The read path compares the marker against the last successful generation so it can tell a held briefing from a current one. --- .../insights/generate/__tests__/route.test.ts | 5 + src/app/api/insights/generate/route.ts | 46 +++++- .../ai/__tests__/effective-timeout.test.ts | 26 ++++ src/lib/ai/effective-timeout.ts | 25 ++++ .../__tests__/briefing-failure-marker.test.ts | 92 ++++++++++++ .../comprehensive-timeout-honored.test.ts | 140 ++++++++++++++++++ src/lib/insights/briefing-failure-marker.ts | 89 +++++++++++ src/lib/insights/comprehensive-generate.ts | 42 +++++- src/lib/insights/status-provider.ts | 28 +++- 9 files changed, 484 insertions(+), 9 deletions(-) create mode 100644 src/lib/ai/__tests__/effective-timeout.test.ts create mode 100644 src/lib/ai/effective-timeout.ts create mode 100644 src/lib/insights/__tests__/briefing-failure-marker.test.ts create mode 100644 src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts create mode 100644 src/lib/insights/briefing-failure-marker.ts diff --git a/src/app/api/insights/generate/__tests__/route.test.ts b/src/app/api/insights/generate/__tests__/route.test.ts index 355250d4a..550817bd4 100644 --- a/src/app/api/insights/generate/__tests__/route.test.ts +++ b/src/app/api/insights/generate/__tests__/route.test.ts @@ -48,6 +48,11 @@ vi.mock("@/lib/db", () => ({ // generation. The test prisma mock has to surface the call so // the cache-invalidation test can assert against it. deleteMany: vi.fn(async () => ({ count: 0 })), + // v1.25 — the GET read path consults the briefing-failure marker + // (`readBriefingFailure`) and the failure paths append one + // (`recordBriefingFailure`). Default to no marker. + findFirst: vi.fn(async () => null), + create: vi.fn(async () => ({})), }, // v1.12.1 — the server-managed consent gate reads the latest active // receipt before egress. These fixtures resolve to an admin-openai diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index 72c5af606..5f15fe2da 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -61,6 +61,11 @@ import { type InsightResult, } from "@/lib/ai/types"; import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; +import { resolveEffectiveTimeoutMs } from "@/lib/ai/effective-timeout"; +import { + recordBriefingFailure, + readBriefingFailure, +} from "@/lib/insights/briefing-failure-marker"; import { resolveProvider, resolveProviderChain } from "@/lib/ai/provider"; import { AllProvidersFailedError, @@ -307,6 +312,15 @@ export const GET = apiHandler(async (request: NextRequest) => { // connect-an-AI-provider hint instead of implying the read is live. const hasProvider = await hasUsableStatusProvider(userId); + // v1.25 — surface whether the most recent generation attempt failed (a + // marker newer than the last successful generation). The briefing keeps its + // last good text on failure, so this is the only honest signal that a shown + // briefing is held rather than freshly refreshed — and, when there is no + // last good text, that the empty state should read "couldn't generate" with + // a retry rather than the generic "no briefing yet". + const generationFailed = + (await readBriefingFailure({ userId, since: cachedAt })) !== null; + // Read-only: never block on the provider. Warm out of band only when the // cached briefing is stale / missing AND a provider is configured (a // provider-less account costs one cheap chain-resolve and shows the @@ -351,6 +365,8 @@ export const GET = apiHandler(async (request: NextRequest) => { // the served briefing can never refresh, so the UI pairs its age // with a connect-provider affordance. hasProvider, + // v1.25 — true when the last generation attempt failed (held text). + generationFailed, }); } catch { // Invalid cache row — fall through to the empty payload below. The @@ -370,6 +386,9 @@ export const GET = apiHandler(async (request: NextRequest) => { revalidating, // v1.18.9 (#4) — see the cached branch above. hasProvider, + // v1.25 — no last-good text AND the last attempt failed: the UI shows a + // "couldn't generate" empty state with a retry instead of the generic one. + generationFailed, }); }); @@ -403,9 +422,17 @@ export const POST = apiHandler((request: NextRequest) => heightCm: true, // v1.22 (W6) — first name for the sparse, hash-gated briefing opener. displayName: true, + // v1.25 — per-user response-timeout (seconds), threaded onto the + // generation calls so a slow self-hosted backend honours the setting. + aiResponseTimeoutSeconds: true, }, }); + const effectiveTimeoutMs = resolveEffectiveTimeoutMs( + dbUser?.aiResponseTimeoutSeconds, + AI_BUDGETS.comprehensive.timeoutMs, + ); + const locale = await resolveServerLocale({ request, userLocale: dbUser?.locale ?? user.locale ?? null, @@ -717,7 +744,8 @@ export const POST = apiHandler((request: NextRequest) => // v1.21.5 — wider upstream budget so the reasoning-heavy briefing // generation is not aborted at the client's 60 s default on large // accounts (which returned an empty briefing + trend narrative). - timeoutMs: AI_BUDGETS.comprehensive.timeoutMs, + // v1.25 — honours the per-user response-timeout setting. + timeoutMs: effectiveTimeoutMs, // The reply is parsed with `JSON.parse` below, so opt the OpenAI / // Codex chains into their strict JSON mode (gated on this flag). responseFormat: "json", @@ -727,6 +755,18 @@ export const POST = apiHandler((request: NextRequest) => workingProviderType = fallback.workingProvider.providerType; fallbackHopCount = fallback.fallbackHops.length; } catch (e) { + // v1.25 — record a dated failure marker (no cache row is written on this + // path, so the last good briefing stays intact). The read path pairs the + // preserved text with a discreet "couldn't refresh" hint, or shows a + // retry empty state when there is no last good text. + void recordBriefingFailure({ + userId, + reason: + e instanceof AllProvidersFailedError + ? "all-providers-failed" + : "provider-error", + locale, + }); if (e instanceof AllProvidersFailedError) { annotate({ meta: { @@ -826,6 +866,7 @@ export const POST = apiHandler((request: NextRequest) => // breaks `await res.json()` on the client side. 422 stays passthrough // so the React Query mutation can read the JSON body and surface a // readable message. Same fix pattern as v1.4.5 ai/test. + void recordBriefingFailure({ userId, reason: "invalid-json", locale }); return apiError("AI response was not valid JSON", 422); } @@ -856,7 +897,8 @@ export const POST = apiHandler((request: NextRequest) => temperature: 0.3, maxTokens: 1500, // v1.21.5 — wider upstream budget; see the first generation call. - timeoutMs: AI_BUDGETS.comprehensive.timeoutMs, + // v1.25 — honours the per-user response-timeout setting. + timeoutMs: effectiveTimeoutMs, // Parsed with `JSON.parse` below — opt OpenAI / Codex into JSON mode. responseFormat: "json", }), diff --git a/src/lib/ai/__tests__/effective-timeout.test.ts b/src/lib/ai/__tests__/effective-timeout.test.ts new file mode 100644 index 000000000..96a95bf13 --- /dev/null +++ b/src/lib/ai/__tests__/effective-timeout.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from "vitest"; +import { resolveEffectiveTimeoutMs } from "../effective-timeout"; + +const BUDGET = 120_000; + +describe("resolveEffectiveTimeoutMs", () => { + it("converts a positive per-user setting from seconds to ms", () => { + expect(resolveEffectiveTimeoutMs(300, BUDGET)).toBe(300_000); + expect(resolveEffectiveTimeoutMs(10, BUDGET)).toBe(10_000); + }); + + it("falls back to the budget default when the setting is unset", () => { + expect(resolveEffectiveTimeoutMs(null, BUDGET)).toBe(BUDGET); + expect(resolveEffectiveTimeoutMs(undefined, BUDGET)).toBe(BUDGET); + }); + + it("falls back to the budget default for a non-positive value", () => { + expect(resolveEffectiveTimeoutMs(0, BUDGET)).toBe(BUDGET); + expect(resolveEffectiveTimeoutMs(-5, BUDGET)).toBe(BUDGET); + }); + + it("honours different budget defaults per surface", () => { + expect(resolveEffectiveTimeoutMs(null, 60_000)).toBe(60_000); + expect(resolveEffectiveTimeoutMs(45, 60_000)).toBe(45_000); + }); +}); diff --git a/src/lib/ai/effective-timeout.ts b/src/lib/ai/effective-timeout.ts new file mode 100644 index 000000000..1055c558e --- /dev/null +++ b/src/lib/ai/effective-timeout.ts @@ -0,0 +1,25 @@ +/** + * Resolve the upstream provider timeout (ms) for a non-chat generation surface. + * + * The per-user `User.aiResponseTimeoutSeconds` setting (Settings → AI) is the + * operator's lever for slow self-hosted / local backends whose first request + * loads the model. Coach already threads it onto its provider call; the + * generated-narrative surfaces (daily briefing, status cards, period + * narratives, …) historically hardcoded their per-surface `AI_BUDGETS.*` + * default and ignored the setting, so raising the timeout had no effect on + * them. This helper is the one place that converts the stored seconds into the + * milliseconds a `CompletionParams.timeoutMs` expects, falling back to the + * surface's budget default when the user has not set a value. + * + * Semantics mirror Coach: a positive stored value wins (seconds → ms), and any + * unset / non-positive value yields the surface default. The setting is bounded + * to 10–600 s at write-time, so no read-time clamp is needed here. + */ +export function resolveEffectiveTimeoutMs( + aiResponseTimeoutSeconds: number | null | undefined, + budgetDefaultMs: number, +): number { + return aiResponseTimeoutSeconds != null && aiResponseTimeoutSeconds > 0 + ? aiResponseTimeoutSeconds * 1000 + : budgetDefaultMs; +} diff --git a/src/lib/insights/__tests__/briefing-failure-marker.test.ts b/src/lib/insights/__tests__/briefing-failure-marker.test.ts new file mode 100644 index 000000000..e50ff58d3 --- /dev/null +++ b/src/lib/insights/__tests__/briefing-failure-marker.test.ts @@ -0,0 +1,92 @@ +/** + * v1.25 — the briefing failure marker drives the read path's "couldn't + * refresh" signal WITHOUT touching the cached briefing text. These tests pin + * that a marker newer than the last successful generation reads as a failure, + * an older one is treated as superseded, and a write failure never throws. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const findFirst = vi.fn(); +const create = vi.fn(); + +vi.mock("@/lib/db", () => ({ + prisma: { + auditLog: { + findFirst: (...a: unknown[]) => findFirst(...a), + create: (...a: unknown[]) => create(...a), + }, + }, +})); + +import { + readBriefingFailure, + recordBriefingFailure, + BRIEFING_FAILURE_ACTION, +} from "../briefing-failure-marker"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("readBriefingFailure", () => { + it("returns the failure when the marker is newer than the last success", async () => { + findFirst.mockResolvedValue({ + createdAt: new Date("2026-06-28T10:00:00Z"), + details: JSON.stringify({ reason: "provider-error" }), + }); + + const result = await readBriefingFailure({ + userId: "u1", + since: new Date("2026-06-28T09:00:00Z"), + }); + + expect(result).toEqual( + expect.objectContaining({ reason: "provider-error" }), + ); + }); + + it("treats a marker older than the last success as superseded", async () => { + findFirst.mockResolvedValue({ + createdAt: new Date("2026-06-28T08:00:00Z"), + details: JSON.stringify({ reason: "provider-error" }), + }); + + const result = await readBriefingFailure({ + userId: "u1", + since: new Date("2026-06-28T09:00:00Z"), + }); + + expect(result).toBeNull(); + }); + + it("returns null when no marker exists", async () => { + findFirst.mockResolvedValue(null); + expect(await readBriefingFailure({ userId: "u1", since: null })).toBeNull(); + }); +}); + +describe("recordBriefingFailure", () => { + it("writes an append-only marker row under the failure action", async () => { + create.mockResolvedValue({}); + await recordBriefingFailure({ + userId: "u1", + reason: "timeout", + locale: "en", + }); + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + userId: "u1", + action: BRIEFING_FAILURE_ACTION, + }), + }), + ); + }); + + it("never throws when the write fails", async () => { + create.mockRejectedValue(new Error("db down")); + await expect( + recordBriefingFailure({ userId: "u1", reason: "timeout" }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts b/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts new file mode 100644 index 000000000..6da5f93a6 --- /dev/null +++ b/src/lib/insights/__tests__/comprehensive-timeout-honored.test.ts @@ -0,0 +1,140 @@ +/** + * v1.25 — the comprehensive briefing generator honours the per-user + * `aiResponseTimeoutSeconds` setting and keeps its last good text on failure. + * + * Before this, every generation call hardcoded `AI_BUDGETS.comprehensive + * .timeoutMs`, so raising the timeout for a slow self-hosted backend had no + * effect on the briefing. These tests pin: a set value is threaded onto the + * provider call (seconds → ms); an unset value falls back to the budget; and a + * terminal failure writes NO cache row (last good preserved) while recording a + * failure marker the read path can surface. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; + +const findUnique = vi.fn(); +const userUpdate = vi.fn(); +const resolveProviderChain = vi.fn(); +const resolveProvider = vi.fn(); +const runRawCompletionWithFallback = vi.fn(); +const extractFeatures = vi.fn(); +const recordBriefingFailure = vi.fn(); + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { + findUnique: (...a: unknown[]) => findUnique(...a), + update: (...a: unknown[]) => userUpdate(...a), + }, + auditLog: { deleteMany: vi.fn(), create: vi.fn() }, + }, +})); +vi.mock("@/lib/ai/provider", () => ({ + resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a), + resolveProvider: (...a: unknown[]) => resolveProvider(...a), +})); +vi.mock("@/lib/ai/provider-runner", () => ({ + AllProvidersFailedError: class extends Error {}, + runRawCompletionWithFallback: (...a: unknown[]) => + runRawCompletionWithFallback(...a), +})); +vi.mock("@/lib/insights/features", () => ({ + FeaturesPayloadTooLargeError: class extends Error { + sizeBytes = 0; + }, + extractFeatures: (...a: unknown[]) => extractFeatures(...a), + BRIEFING_FEATURE_WINDOW_DAYS: 400, +})); +vi.mock("@/lib/insights/illness-cycle-briefing", () => ({ + buildBriefingIllnessCycleContext: vi.fn().mockResolvedValue(null), + buildBriefingIllnessCyclePrompt: vi.fn().mockReturnValue(""), +})); +vi.mock("@/lib/insights/glp1-plateau", () => ({ + detectGlp1Plateau: vi.fn(async () => null), + buildGlp1PlateauPrompt: vi.fn(() => ""), +})); +vi.mock("@/lib/ai/coach/about-me", () => ({ + getSelfContextTextForUser: vi.fn(async () => null), + buildAboutMeInsightBlock: vi.fn(() => ""), +})); +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserInsights: vi.fn(), +})); +vi.mock("@/lib/insights/briefing-failure-marker", () => ({ + recordBriefingFailure: (...a: unknown[]) => recordBriefingFailure(...a), +})); + +import { generateComprehensiveInsight } from "../comprehensive-generate"; + +const FEATURES = { weight: { count: 12, latest: 81.4, mean30: 82.1 } }; +const VALID = JSON.stringify({ dailyBriefing: { paragraph: "ok" } }); + +function mockUser(overrides: Record = {}) { + findUnique.mockResolvedValue({ + insightsPrivacyMode: "aggregated", + insightsCachedAt: null, + insightsCachedText: null, + insightsExcludeMetrics: [], + insightsSnapshotHash: null, + insightsBriefingRerollDate: null, + aiResponseTimeoutSeconds: null, + ...overrides, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + resolveProviderChain.mockResolvedValue([ + { providerType: "openai", instance: {} }, + ]); + resolveProvider.mockResolvedValue({ type: "none" }); + extractFeatures.mockResolvedValue(FEATURES); + userUpdate.mockResolvedValue({}); + mockUser(); +}); + +describe("comprehensive timeout honouring", () => { + it("threads the per-user timeout (seconds → ms) onto the provider call", async () => { + mockUser({ aiResponseTimeoutSeconds: 300 }); + runRawCompletionWithFallback.mockResolvedValue({ + result: { content: VALID, tokensUsed: 10, model: "m" }, + workingProvider: { providerType: "openai" }, + fallbackHops: [], + }); + + await generateComprehensiveInsight("u1", { locale: "de" }); + + const params = runRawCompletionWithFallback.mock.calls[0][0].params; + expect(params.timeoutMs).toBe(300_000); + expect(params.timeoutMs).not.toBe(AI_BUDGETS.comprehensive.timeoutMs); + }); + + it("falls back to the comprehensive budget when the setting is unset", async () => { + mockUser({ aiResponseTimeoutSeconds: null }); + runRawCompletionWithFallback.mockResolvedValue({ + result: { content: VALID, tokensUsed: 10, model: "m" }, + workingProvider: { providerType: "openai" }, + fallbackHops: [], + }); + + await generateComprehensiveInsight("u1", { locale: "de" }); + + const params = runRawCompletionWithFallback.mock.calls[0][0].params; + expect(params.timeoutMs).toBe(AI_BUDGETS.comprehensive.timeoutMs); + }); + + it("keeps the last good text and records a failure marker on a provider error", async () => { + mockUser({ aiResponseTimeoutSeconds: 300 }); + runRawCompletionWithFallback.mockRejectedValue(new Error("boom")); + + const outcome = await generateComprehensiveInsight("u1", { locale: "de" }); + + expect(outcome.status).toBe("failed"); + // No cache row written → the prior `insightsCachedText` (last good) survives. + expect(userUpdate).not.toHaveBeenCalled(); + // A dated failure marker is recorded so the read path can surface a hint. + expect(recordBriefingFailure).toHaveBeenCalledWith( + expect.objectContaining({ userId: "u1", locale: "de" }), + ); + }); +}); diff --git a/src/lib/insights/briefing-failure-marker.ts b/src/lib/insights/briefing-failure-marker.ts new file mode 100644 index 000000000..ad11c863d --- /dev/null +++ b/src/lib/insights/briefing-failure-marker.ts @@ -0,0 +1,89 @@ +import { prisma } from "@/lib/db"; + +/** + * Dated failure marker for the daily-briefing generation. + * + * The briefing keeps its last good payload on a failed refresh: the generator + * writes NO `User.insightsCachedText` row when a provider times out or errors, + * so the previous briefing stays intact and the surface never blanks. What was + * missing is a signal the READ path can use to tell the difference between "no + * refresh was due" and "the last refresh attempt failed" — without that, a + * stale-but-shown briefing reads as current and a never-generated one shows a + * generic empty state instead of an honest "couldn't generate". + * + * This records an append-only `auditLog` row (no migration, no overwrite of the + * cached text) carrying the calendar day, the reason, and the locale. The read + * path compares the latest marker's timestamp against the last successful + * generation (`User.insightsCachedAt`): a marker newer than the last success + * means the most recent attempt failed. + */ +export const BRIEFING_FAILURE_ACTION = "insights.briefing-failure"; + +/** UTC YYYY-MM-DD calendar-day key for the marker payload. */ +function dateKey(at: Date = new Date()): string { + return at.toISOString().slice(0, 10); +} + +/** + * Append a failure marker for `userId`. Best-effort: a write failure here must + * never propagate out of the generation's own failure path. + */ +export async function recordBriefingFailure(args: { + userId: string; + reason: string; + locale?: string; +}): Promise { + try { + await prisma.auditLog.create({ + data: { + userId: args.userId, + action: BRIEFING_FAILURE_ACTION, + details: JSON.stringify({ + dateKey: dateKey(), + reason: args.reason, + locale: args.locale ?? null, + triedAt: new Date().toISOString(), + }), + }, + }); + } catch { + // Marker is best-effort — never throw out of the failure path. + } +} + +export interface BriefingFailureState { + triedAt: string; + reason: string; +} + +/** + * Read the latest briefing-failure marker for `userId`, but only when it is + * newer than the last successful generation (`since`). Returns `null` when the + * most recent attempt succeeded (or there is no marker at all), so the read + * path can render a discreet "couldn't refresh" hint exactly when it applies. + */ +export async function readBriefingFailure(args: { + userId: string; + since: Date | null; +}): Promise { + const latest = await prisma.auditLog.findFirst({ + where: { userId: args.userId, action: BRIEFING_FAILURE_ACTION }, + orderBy: { createdAt: "desc" }, + select: { createdAt: true, details: true }, + }); + if (!latest) return null; + // A failure older than (or equal to) the last successful generation has been + // superseded — the surface is showing fresh text, not a held one. + if (args.since && latest.createdAt <= args.since) return null; + + let reason = "unknown"; + if (latest.details) { + try { + const parsed = JSON.parse(latest.details) as { reason?: unknown }; + if (typeof parsed.reason === "string") reason = parsed.reason; + } catch { + // Malformed marker payload — still report the failure, generic reason. + } + } + return { triedAt: latest.createdAt.toISOString(), reason }; +} diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index eb697390f..60cbca84b 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -93,6 +93,8 @@ import { } from "@/lib/insights/metric-status-registry"; import { annotate } from "@/lib/logging/context"; import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; +import { resolveEffectiveTimeoutMs } from "@/lib/ai/effective-timeout"; +import { recordBriefingFailure } from "@/lib/insights/briefing-failure-marker"; const CACHE_TTL_MS = 24 * 60 * 60 * 1000; @@ -200,6 +202,13 @@ async function rerollBriefingParagraph(args: { * care / workouts) the reroll may now cite. */ features?: AggregatedFeatures | null; + /** + * The upstream provider timeout for this re-roll, resolved from the user's + * response-timeout setting (falling back to the comprehensive budget). The + * caller resolves it once and threads it in so the re-roll honours the same + * slow-backend allowance the main generation does. + */ + effectiveTimeoutMs: number; }): Promise<{ text: string; providerType: string } | null> { let cached: Record; try { @@ -232,7 +241,8 @@ async function rerollBriefingParagraph(args: { maxTokens: AI_BUDGETS.comprehensive.maxTokens, // v1.21.5 — wider upstream budget so the reasoning-heavy briefing // generation is not clipped at the client's 60 s default mid-stream. - timeoutMs: AI_BUDGETS.comprehensive.timeoutMs, + // v1.25 — honours the per-user response-timeout setting (see caller). + timeoutMs: args.effectiveTimeoutMs, responseFormat: "json", }), }); @@ -730,9 +740,22 @@ export async function generateComprehensiveInsight( gender: true, // v1.22 (W6) — first name for the sparse, hash-gated briefing opener. displayName: true, + // v1.25 — per-user response-timeout (seconds). Threaded onto every + // provider call below so a raised value for a slow self-hosted backend + // actually applies to the briefing, not just to Coach. + aiResponseTimeoutSeconds: true, }, }); + // Resolve the upstream provider timeout once from the user's setting, + // falling back to the comprehensive budget. Every generation call in this + // function (main, JSON retry, grounding retry, and the paragraph re-roll) + // uses it so the whole briefing path honours the operator's allowance. + const effectiveTimeoutMs = resolveEffectiveTimeoutMs( + dbUser?.aiResponseTimeoutSeconds, + AI_BUDGETS.comprehensive.timeoutMs, + ); + if ( !force && dbUser?.insightsCachedAt && @@ -930,6 +953,7 @@ export async function generateComprehensiveInsight( locale, signals: features.signalsOfDay ?? null, features, + effectiveTimeoutMs, }); if (rerolled) { await prisma.user.update({ @@ -986,7 +1010,8 @@ export async function generateComprehensiveInsight( maxTokens: AI_BUDGETS.comprehensive.maxTokens, // v1.21.5 — wider upstream budget so the reasoning-heavy briefing // generation is not clipped at the client's 60 s default mid-stream. - timeoutMs: AI_BUDGETS.comprehensive.timeoutMs, + // v1.25 — honours the per-user response-timeout setting. + timeoutMs: effectiveTimeoutMs, // v1.18.7 — structured surface: opt the non-OpenAI chains into their // strongest JSON mode (Ollama `format`, Anthropic `{` prefill) so a // first-pass JSON miss is rarer; stripJsonFences stays the net. @@ -1019,6 +1044,11 @@ export async function generateComprehensiveInsight( provider_message: err.message?.slice(0, 240) ?? null, }, }); + // v1.25 — record a dated failure marker so the read path can surface a + // discreet "couldn't refresh" hint alongside the preserved last-good + // briefing. No cache row is written here, so `insightsCachedText` (the + // last good payload) stays intact and the surface never blanks. + void recordBriefingFailure({ userId, reason, locale }); return { status: "failed", reason }; } @@ -1050,17 +1080,20 @@ export async function generateComprehensiveInsight( temperature: AI_BUDGETS.comprehensive.temperature, maxTokens: AI_BUDGETS.comprehensive.maxTokens, // v1.21.5 — wider upstream budget; see the first generation call. - timeoutMs: AI_BUDGETS.comprehensive.timeoutMs, + // v1.25 — honours the per-user response-timeout setting. + timeoutMs: effectiveTimeoutMs, responseFormat: "json", }), }); result = retry.result; workingProviderType = retry.workingProvider.providerType; } catch { + void recordBriefingFailure({ userId, reason: "invalid-json", locale }); return { status: "failed", reason: "invalid-json" }; } insights = parseComprehensiveResult(result.content); if (insights === null) { + void recordBriefingFailure({ userId, reason: "invalid-json", locale }); return { status: "failed", reason: "invalid-json" }; } } @@ -1092,7 +1125,8 @@ export async function generateComprehensiveInsight( temperature: AI_BUDGETS.comprehensive.temperature, maxTokens: AI_BUDGETS.comprehensive.maxTokens, // v1.21.5 — wider upstream budget; see the first generation call. - timeoutMs: AI_BUDGETS.comprehensive.timeoutMs, + // v1.25 — honours the per-user response-timeout setting. + timeoutMs: effectiveTimeoutMs, responseFormat: "json", }), }); diff --git a/src/lib/insights/status-provider.ts b/src/lib/insights/status-provider.ts index cb1015677..107a48466 100644 --- a/src/lib/insights/status-provider.ts +++ b/src/lib/insights/status-provider.ts @@ -13,6 +13,8 @@ import { annotate } from "@/lib/logging/context"; import { AI_BUDGETS, REFERENCE_AI_SEED } from "@/lib/ai/ai-budgets"; import { singleUserTurn } from "@/lib/ai/types"; import { STATUS_PROVIDER_TIMEOUT_MS, withTimeout } from "./with-timeout"; +import { prisma } from "@/lib/db"; +import { resolveEffectiveTimeoutMs } from "@/lib/ai/effective-timeout"; /** * Shared provider plumbing for the seven `*-status.ts` generators. @@ -134,8 +136,9 @@ export async function statusConsentBlocksGeneration( } /** - * Run a status generation across the user's provider chain, bounded by - * `STATUS_PROVIDER_TIMEOUT_MS`. The result discriminates between + * Run a status generation across the user's provider chain, bounded by the + * per-user response-timeout setting (falling back to `STATUS_PROVIDER_TIMEOUT_MS` + * when unset). The result discriminates between * no-provider / timeout / provider-error / success so the caller can * decide what to persist — only `ok` is ever cached as the day's * assessment. @@ -168,6 +171,24 @@ export async function runStatusCompletion( return { kind: "none" }; } + // Honour the per-user response-timeout setting the operator dials in for a + // slow self-hosted / local backend (Settings → AI). This is the single + // chokepoint every status / reference surface funnels through — per-metric + // cards, the batched assessment, the derived assessments, and the period + // narratives — so resolving it here threads the setting onto all of them at + // once. A positive stored value wins (seconds → ms); unset falls back to the + // status-path budget. Applied to BOTH the upstream call's own `timeoutMs` + // and the outer `withTimeout` cap so a raised value is not silently clipped + // by the 60 s wall-clock that previously bounded the path. + const settingsRow = await prisma.user.findUnique({ + where: { id: userId }, + select: { aiResponseTimeoutSeconds: true }, + }); + const effectiveTimeoutMs = resolveEffectiveTimeoutMs( + settingsRow?.aiResponseTimeoutSeconds, + STATUS_PROVIDER_TIMEOUT_MS, + ); + const raced = await withTimeout( () => runRawCompletionWithFallback({ @@ -184,9 +205,10 @@ export async function runStatusCompletion( // Status cards are JSON by default; the narrative opts out via // `"text"`. responseFormat: args.responseFormat === "text" ? undefined : "json", + timeoutMs: effectiveTimeoutMs, }), }), - STATUS_PROVIDER_TIMEOUT_MS, + effectiveTimeoutMs, null, ); From 423aecc695b2164cc78891377d65400fee37b72b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 12:42:43 +0200 Subject: [PATCH 20/94] feat(insights): add a retry affordance for failed briefing generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface the generation-failure signal in the daily briefing card. When a held briefing is shown but the last refresh failed, the footer states it plainly and offers a retry that runs the existing explicit regenerate — no warm-on-mount. When there is no briefing to fall back on, the empty state reads "couldn't generate" with the same retry instead of the generic "no briefing yet". The hint is suppressed when no AI provider is configured, where that hint already owns the surface and a retry is futile. New copy lands in all six locales. --- messages/de.json | 6 +- messages/en.json | 6 +- messages/es.json | 6 +- messages/fr.json | 6 +- messages/it.json | 6 +- messages/pl.json | 6 +- src/app/insights/page.tsx | 6 ++ .../__tests__/daily-briefing.test.tsx | 51 +++++++++++++++++ src/components/insights/daily-briefing.tsx | 57 +++++++++++++++++-- .../insights/use-insights-advisor.ts | 19 +++++++ 10 files changed, 159 insertions(+), 10 deletions(-) diff --git a/messages/de.json b/messages/de.json index ce28bb09c..7c58dd950 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2037,7 +2037,11 @@ "noProviderTitle": "Kein KI-Anbieter verbunden", "noProviderDescription": "Das Tagesbriefing fasst deine Werte in Klartext zusammen. Dafür braucht HealthLog einen KI-Anbieter — nutze deinen eigenen Schlüssel (OpenAI, Anthropic) oder ein lokales Modell. Deine Daten bleiben dabei bei dir.", "noProviderAction": "Zu den KI-Einstellungen", - "staleNoProviderHint": "Dieses Briefing kann nicht aktualisiert werden — es ist kein KI-Anbieter verbunden." + "staleNoProviderHint": "Dieses Briefing kann nicht aktualisiert werden — es ist kein KI-Anbieter verbunden.", + "refreshFailedHint": "Briefing konnte nicht aktualisiert werden — es wird das letzte angezeigt.", + "retryAction": "Erneut versuchen", + "failedTitle": "Briefing konnte nicht erstellt werden", + "failedDescription": "Der KI-Anbieter hat das Zeitlimit überschritten oder einen Fehler zurückgegeben. Versuche es erneut oder erhöhe in den KI-Einstellungen das Antwort-Zeitlimit für ein langsames lokales Modell." }, "trendsRow": { "title": "Trends", diff --git a/messages/en.json b/messages/en.json index 14fa8b085..05c423d34 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2037,7 +2037,11 @@ "noProviderTitle": "No AI provider connected", "noProviderDescription": "The daily briefing turns your readings into plain-language summary. HealthLog needs an AI provider for that — use your own key (OpenAI, Anthropic) or a local model. Your data stays with you.", "noProviderAction": "Open AI settings", - "staleNoProviderHint": "This briefing can't refresh — no AI provider is connected." + "staleNoProviderHint": "This briefing can't refresh — no AI provider is connected.", + "refreshFailedHint": "Couldn't refresh the briefing — showing the last one.", + "retryAction": "Retry", + "failedTitle": "Couldn't generate the briefing", + "failedDescription": "The AI provider timed out or returned an error. Try again, or raise the response timeout in AI settings for a slow local model." }, "trendsRow": { "title": "Trends", diff --git a/messages/es.json b/messages/es.json index ce2ee6de9..8d631f350 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2037,7 +2037,11 @@ "noProviderTitle": "Ningún proveedor de IA conectado", "noProviderDescription": "El briefing diario resume tus lecturas en lenguaje claro. HealthLog necesita un proveedor de IA para ello: usa tu propia clave (OpenAI, Anthropic) o un modelo local. Tus datos siguen siendo tuyos.", "noProviderAction": "Abrir ajustes de IA", - "staleNoProviderHint": "Este briefing no puede actualizarse: no hay ningún proveedor de IA conectado." + "staleNoProviderHint": "Este briefing no puede actualizarse: no hay ningún proveedor de IA conectado.", + "refreshFailedHint": "No se pudo actualizar el briefing: se muestra el anterior.", + "retryAction": "Reintentar", + "failedTitle": "No se pudo generar el briefing", + "failedDescription": "El proveedor de IA agotó el tiempo de espera o devolvió un error. Inténtalo de nuevo o aumenta el tiempo de espera de respuesta en los ajustes de IA para un modelo local lento." }, "trendsRow": { "title": "Tendencias", diff --git a/messages/fr.json b/messages/fr.json index 69f085b74..f236c24bd 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2037,7 +2037,11 @@ "noProviderTitle": "Aucun fournisseur d'IA connecté", "noProviderDescription": "Le briefing quotidien résume tes mesures en langage clair. HealthLog a besoin d'un fournisseur d'IA pour cela : utilise ta propre clé (OpenAI, Anthropic) ou un modèle local. Tes données restent chez toi.", "noProviderAction": "Ouvrir les réglages IA", - "staleNoProviderHint": "Ce briefing ne peut pas être actualisé — aucun fournisseur d'IA n'est connecté." + "staleNoProviderHint": "Ce briefing ne peut pas être actualisé — aucun fournisseur d'IA n'est connecté.", + "refreshFailedHint": "Impossible d'actualiser le briefing — affichage du précédent.", + "retryAction": "Réessayer", + "failedTitle": "Impossible de générer le briefing", + "failedDescription": "Le fournisseur d'IA a dépassé le délai ou renvoyé une erreur. Réessayez ou augmentez le délai de réponse dans les paramètres d'IA pour un modèle local lent." }, "trendsRow": { "title": "Tendances", diff --git a/messages/it.json b/messages/it.json index 60e5288d4..b5bdb8722 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2037,7 +2037,11 @@ "noProviderTitle": "Nessun provider IA collegato", "noProviderDescription": "Il briefing giornaliero riassume le tue misurazioni in linguaggio chiaro. HealthLog ha bisogno di un provider IA per questo: usa la tua chiave (OpenAI, Anthropic) o un modello locale. I tuoi dati restano tuoi.", "noProviderAction": "Apri impostazioni IA", - "staleNoProviderHint": "Questo briefing non può aggiornarsi: nessun provider IA è collegato." + "staleNoProviderHint": "Questo briefing non può aggiornarsi: nessun provider IA è collegato.", + "refreshFailedHint": "Impossibile aggiornare il briefing: viene mostrato l'ultimo.", + "retryAction": "Riprova", + "failedTitle": "Impossibile generare il briefing", + "failedDescription": "Il provider IA ha superato il tempo limite o ha restituito un errore. Riprova oppure aumenta il timeout di risposta nelle impostazioni IA per un modello locale lento." }, "trendsRow": { "title": "Tendenze", diff --git a/messages/pl.json b/messages/pl.json index 5631b6f90..18f0c7064 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2037,7 +2037,11 @@ "noProviderTitle": "Brak połączonego dostawcy AI", "noProviderDescription": "Codzienny briefing podsumowuje Twoje pomiary prostym językiem. HealthLog potrzebuje do tego dostawcy AI — użyj własnego klucza (OpenAI, Anthropic) lub modelu lokalnego. Twoje dane pozostają u Ciebie.", "noProviderAction": "Otwórz ustawienia AI", - "staleNoProviderHint": "Tego briefingu nie można odświeżyć — nie połączono żadnego dostawcy AI." + "staleNoProviderHint": "Tego briefingu nie można odświeżyć — nie połączono żadnego dostawcy AI.", + "refreshFailedHint": "Nie udało się odświeżyć briefingu — pokazujemy poprzedni.", + "retryAction": "Spróbuj ponownie", + "failedTitle": "Nie udało się wygenerować briefingu", + "failedDescription": "Dostawca AI przekroczył limit czasu lub zwrócił błąd. Spróbuj ponownie albo zwiększ limit czasu odpowiedzi w ustawieniach AI dla wolnego modelu lokalnego." }, "trendsRow": { "title": "Trendy", diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index 000d31c73..cb5ce21ee 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -447,6 +447,12 @@ export default function InsightsPage() { // briefing is actually rendered (the empty-state path owns the // `noProvider` branch above). noProviderStale={briefingPayload !== null && !advisor.hasProvider} + // v1.25 — the last generation attempt failed. On a held briefing this + // adds a discreet "couldn't refresh — retry" footer hint; on an empty + // card it swaps the generic empty state for an honest "couldn't + // generate" one. Suppressed when no provider is configured (that hint + // owns the surface and a retry would be futile). + generationFailed={advisor.generationFailed && advisor.hasProvider} // v1.21.2 (A4) — server-resolved recall + forward-look off the dashboard // snapshot. Both strings are already localised; null leaves the card's // memory block unrendered. diff --git a/src/components/insights/__tests__/daily-briefing.test.tsx b/src/components/insights/__tests__/daily-briefing.test.tsx index a7fa34620..5b1903f7e 100644 --- a/src/components/insights/__tests__/daily-briefing.test.tsx +++ b/src/components/insights/__tests__/daily-briefing.test.tsx @@ -238,4 +238,55 @@ describe("", () => { expect(html).toContain('data-slot="daily-briefing-findings"'); expect(html).not.toContain('data-slot="daily-briefing-no-provider"'); }); + + // v1.25 — generation-failed affordances. The briefing keeps its last good + // text; the card adds an honest "couldn't refresh" footer hint on a held + // briefing and a "couldn't generate — retry" empty state when there is none. + it("renders the refresh-failed footer hint on a held briefing", () => { + const html = render( + {}} + generationFailed + />, + ); + expect(html).toContain('data-slot="daily-briefing-refresh-failed"'); + // The rendered apostrophe is HTML-escaped; match the unambiguous tail. + expect(html).toContain("refresh the briefing"); + expect(html).toContain('data-slot="daily-briefing-refresh-failed-retry"'); + }); + + it("does NOT render the refresh-failed hint when generation did not fail", () => { + const html = render( + {}} />, + ); + expect(html).not.toContain('data-slot="daily-briefing-refresh-failed"'); + }); + + it("suppresses the refresh-failed hint when no provider is connected", () => { + const html = render( + {}} + generationFailed + noProviderStale + />, + ); + expect(html).not.toContain('data-slot="daily-briefing-refresh-failed"'); + expect(html).toContain('data-slot="daily-briefing-stale-no-provider"'); + }); + + it("renders the 'couldn't generate' empty state with a retry CTA when failed and empty", () => { + const html = render( + {}} + generationFailed + />, + ); + // The rendered apostrophe is HTML-escaped; match the unambiguous tail. + expect(html).toContain("generate the briefing"); + expect(html).toMatch(/data-slot="daily-briefing-empty-cta"/); + expect(html).toContain("Retry"); + }); }); diff --git a/src/components/insights/daily-briefing.tsx b/src/components/insights/daily-briefing.tsx index 91b6142bf..311b761de 100644 --- a/src/components/insights/daily-briefing.tsx +++ b/src/components/insights/daily-briefing.tsx @@ -119,6 +119,14 @@ interface DailyBriefingProps { * the staleness reads as intentional, not as a live report. */ noProviderStale?: boolean; + /** + * v1.25 — the most recent generation attempt failed (provider timeout / + * error). The briefing keeps its last good text, so this never blanks a + * shown card; it only adds an honest "couldn't refresh" hint to the footer + * of a held briefing, and — when there is no briefing to show — swaps the + * generic empty state for a "couldn't generate" one whose CTA retries. + */ + generationFailed?: boolean; /** * Optional slot for a meta control mounted in the card header — the * comparison toggle migrates here from the hero in commit 5. @@ -349,6 +357,7 @@ export function DailyBriefing({ regenerating = false, noProvider = false, noProviderStale = false, + generationFailed = false, metaSlot, memory = null, }: DailyBriefingProps) { @@ -468,7 +477,7 @@ export function DailyBriefing({
)} - {(updatedAt || noProviderStale) && ( + {(updatedAt || noProviderStale || generationFailed) && (
{updatedAt && (

)} + {/* v1.25 — the held briefing is shown, but the last refresh + attempt failed (provider timeout / error). State it plainly + and offer a retry so the staleness reads as held, not + current. Suppressed when no provider is configured (that + hint owns the footer) — a retry would be futile there. */} + {generationFailed && !noProviderStale && ( +

+ + {t("insights.dailyBriefing.refreshFailedHint")} + + {onRegenerate && ( + + )} +

+ )}
)} {/* v1.21.0 (C4 H2) — hand off to the Coach for the whole @@ -542,8 +577,20 @@ export function DailyBriefing({ data-slot="daily-briefing-empty" variant="plain" icon={} - title={t("insights.dailyBriefing.emptyTitle")} - description={t("insights.dailyBriefing.emptyDescription")} + // v1.25 — when the last attempt FAILED and there is no last good + // text to fall back on, say so honestly ("couldn't generate") + // rather than the generic "no briefing yet". The CTA below is the + // same explicit regenerate — it retries the failed generation. + title={ + generationFailed + ? t("insights.dailyBriefing.failedTitle") + : t("insights.dailyBriefing.emptyTitle") + } + description={ + generationFailed + ? t("insights.dailyBriefing.failedDescription") + : t("insights.dailyBriefing.emptyDescription") + } action={ onRegenerate ? ( // v1.4.28 BK-M2 — switch the empty-state CTA from @@ -564,7 +611,9 @@ export function DailyBriefing({ {regenerating ? t("insights.heroRegenerating") - : t("insights.dailyBriefing.emptyAction")} + : generationFailed + ? t("insights.dailyBriefing.retryAction") + : t("insights.dailyBriefing.emptyAction")} ) : null diff --git a/src/components/insights/use-insights-advisor.ts b/src/components/insights/use-insights-advisor.ts index 53deb92cd..38594d6f5 100644 --- a/src/components/insights/use-insights-advisor.ts +++ b/src/components/insights/use-insights-advisor.ts @@ -71,6 +71,15 @@ export interface InsightAdvisorPayload { * predates the field — treated as "provider present" (no hint). */ hasProvider?: boolean; + /** + * v1.25 — true when the most recent generation attempt failed (a failure + * marker newer than the last successful generation). The briefing keeps its + * last good text on failure, so this is the honest signal that a shown + * briefing is held, or — with no last good text — that the empty state + * should read "couldn't generate" with a retry rather than the generic one. + * Absent on a pre-field cached payload → treated as "not failed" (no hint). + */ + generationFailed?: boolean; } /** @@ -279,6 +288,13 @@ export interface UseInsightsAdvisorResult { * or an unsettled query. */ hasProvider: boolean; + /** + * v1.25 — true when the GET reported the last generation attempt failed. + * Pairs a shown-but-held briefing with a discreet "couldn't refresh" hint, + * and swaps the generic empty state for a "couldn't generate — retry" one + * when there is no last good text. Defaults false (no hint) when absent. + */ + generationFailed: boolean; } /** @@ -364,5 +380,8 @@ export function useInsightsAdvisorQuery( // assume a provider is present so a transient unknown never flashes a // false "no provider" hint. hasProvider: query.data?.payload?.hasProvider ?? true, + // Absent (pre-field cached payload or unsettled query) → not failed, so a + // transient unknown never flashes a false "couldn't refresh" hint. + generationFailed: query.data?.payload?.generationFailed ?? false, }; } From 9c748382b974ff4fbd0272b8022f9659f7269b31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 13:11:03 +0200 Subject: [PATCH 21/94] feat(tables): share column-sort state across the value lists Lift the byte-identical sortBy/sortDir/toggleSort triple the measurement and mood lists each carried inline into a shared useTableSort hook. Both lists now compose their page-reset and selection-clear side effects around the hook's toggle rather than re-implementing the direction math, and the date columns keep their descending-first default via descColumns. --- .../measurements/measurement-list.tsx | 27 +++++++--- src/components/mood/mood-list.tsx | 26 +++++++--- src/hooks/use-table-sort.ts | 51 +++++++++++++++++++ 3 files changed, 88 insertions(+), 16 deletions(-) create mode 100644 src/hooks/use-table-sort.ts diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index 58a194307..8d7f918eb 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -52,6 +52,7 @@ import { MoreHorizontal, } from "lucide-react"; import { Fragment, useCallback, useId, useState } from "react"; +import { useTableSort } from "@/hooks/use-table-sort"; import { createPortal } from "react-dom"; import { toast } from "sonner"; import { formatDateOrRelative, formatDateTime } from "@/lib/format"; @@ -105,6 +106,10 @@ import { */ const CUMULATIVE_TYPES = new Set(CUMULATIVE_DAY_SUM_TYPES); +// Columns that open descending when first selected. The date column reads +// newest-first by default; every other column opens ascending. +const MEASUREMENT_DESC_COLUMNS: ReadonlySet = new Set(["measuredAt"]); + interface Measurement { id: string; type: string; @@ -313,8 +318,16 @@ export function MeasurementList({ const [valueMinInput, setValueMinInputRaw] = useState(""); const [valueMaxInput, setValueMaxInputRaw] = useState(""); const [page, setPage] = useState(1); - const [sortBy, setSortBy] = useState("measuredAt"); - const [sortDir, setSortDir] = useState<"asc" | "desc">("desc"); + // Shared column-sort state (measuredAt opens descending). + const { + sortBy, + sortDir, + toggleSort: applySort, + } = useTableSort({ + defaultColumn: "measuredAt", + defaultDir: "desc", + descColumns: MEASUREMENT_DESC_COLUMNS, + }); // v1.15.13 — page-scoped multi-select. Holds the ids selected on the // CURRENT page; cleared on any page / filter / sort change (per the @@ -399,13 +412,11 @@ export function MeasurementList({ clearSelection(); }; + // Compose the page-reset + selection-clear side effects around the + // shared sort toggle (the rows the selection referred to are about to + // re-order and the list snaps back to page 1). function toggleSort(column: string) { - if (sortBy === column) { - setSortDir((d) => (d === "asc" ? "desc" : "asc")); - } else { - setSortBy(column); - setSortDir(column === "measuredAt" ? "desc" : "asc"); - } + applySort(column); setPage(1); clearSelection(); } diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index 772f902f6..00868224b 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -51,6 +51,11 @@ import { } from "lucide-react"; import { useCallback, useEffect, useId, useRef, useState } from "react"; import { createPortal } from "react-dom"; +import { useTableSort } from "@/hooks/use-table-sort"; + +// Columns that open descending when first selected. The logged-at column +// reads newest-first by default; every other column opens ascending. +const MOOD_DESC_COLUMNS: ReadonlySet = new Set(["moodLoggedAt"]); import { toast } from "sonner"; import { formatDateTime } from "@/lib/format"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; @@ -128,8 +133,16 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { const [fromDay, setFromDayRaw] = useState(""); const [toDay, setToDayRaw] = useState(""); const [page, setPage] = useState(1); - const [sortBy, setSortBy] = useState("moodLoggedAt"); - const [sortDir, setSortDir] = useState<"asc" | "desc">("desc"); + // Shared column-sort state (moodLoggedAt opens descending). + const { + sortBy, + sortDir, + toggleSort: applySort, + } = useTableSort({ + defaultColumn: "moodLoggedAt", + defaultDir: "desc", + descColumns: MOOD_DESC_COLUMNS, + }); // v1.15.13 — page-scoped multi-select selection (current page ids only). const [selectedIds, setSelectedIds] = useState>(() => new Set()); @@ -201,13 +214,10 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { clearSelection(); }; + // Compose the page-reset + selection-clear side effects around the + // shared sort toggle. function toggleSort(column: string) { - if (sortBy === column) { - setSortDir((d) => (d === "asc" ? "desc" : "asc")); - } else { - setSortBy(column); - setSortDir(column === "moodLoggedAt" ? "desc" : "asc"); - } + applySort(column); setPage(1); clearSelection(); } diff --git a/src/hooks/use-table-sort.ts b/src/hooks/use-table-sort.ts new file mode 100644 index 000000000..776b33b85 --- /dev/null +++ b/src/hooks/use-table-sort.ts @@ -0,0 +1,51 @@ +"use client"; + +import { useCallback, useState } from "react"; + +export type SortDirection = "asc" | "desc"; + +/** + * Shared column-sort state for the data-management tables (measurements, + * mood, lab history). Lifted from the byte-identical `sortBy` / `sortDir` / + * `toggleSort` triple the measurement + mood lists each carried inline. + * + * `toggleSort(column)` flips the direction when the active column is tapped + * again, otherwise switches to the new column with its default direction: + * a column listed in `descColumns` (typically the date column) opens + * descending, every other column ascending. Callers that need extra side + * effects on a sort change (reset the page, clear a selection) compose them + * around the returned `toggleSort` rather than re-implementing the math. + */ +export function useTableSort({ + defaultColumn, + defaultDir = "desc", + descColumns, +}: { + /** Column the table sorts by on first render. */ + defaultColumn: string; + /** Direction for the initial column. */ + defaultDir?: SortDirection; + /** Columns that open descending when first selected (e.g. the date column). */ + descColumns?: ReadonlySet; +}): { + sortBy: string; + sortDir: SortDirection; + toggleSort: (column: string) => void; +} { + const [sortBy, setSortBy] = useState(defaultColumn); + const [sortDir, setSortDir] = useState(defaultDir); + + const toggleSort = useCallback( + (column: string) => { + if (sortBy === column) { + setSortDir((d) => (d === "asc" ? "desc" : "asc")); + } else { + setSortBy(column); + setSortDir(descColumns?.has(column) ? "desc" : "asc"); + } + }, + [sortBy, descColumns], + ); + + return { sortBy, sortDir, toggleSort }; +} From 42b433f2e8068cfa5ec218bbec5bfd01ddae1664 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 13:14:38 +0200 Subject: [PATCH 22/94] feat(tables): make the lab history table sortable Convert the per-biomarker reading history from a static reverse-chronological list to a shadcn Table with clickable sort headers. Reuse the shared useTableSort hook and SortableHead chrome already backing the measurement and mood lists; the sample-date column opens descending, value/range/note open ascending. The sort runs client-side over the in-memory reading feed. --- src/components/labs/lab-history-list.tsx | 191 +++++++++++++++++------ 1 file changed, 145 insertions(+), 46 deletions(-) diff --git a/src/components/labs/lab-history-list.tsx b/src/components/labs/lab-history-list.tsx index 98b072074..17cfb3e50 100644 --- a/src/components/labs/lab-history-list.tsx +++ b/src/components/labs/lab-history-list.tsx @@ -1,17 +1,26 @@ "use client"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Loader2, Pencil } from "lucide-react"; import { toast } from "sonner"; -import { DeleteButton } from "@/components/data-list"; +import { DeleteButton, SortableHead } from "@/components/data-list"; import { Button } from "@/components/ui/button"; import { DateTimeField } from "@/components/ui/date-time-field"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; import { Textarea } from "@/components/ui/textarea"; +import { useTableSort } from "@/hooks/use-table-sort"; import { ApiError, apiDelete, @@ -30,6 +39,19 @@ import type { LabResultDetailDto, LabResultDto } from "./types"; const NOTE_MAX_LENGTH = 2000; +// The sample-date column opens descending (newest first); every other column +// opens ascending. +const LAB_DESC_COLUMNS = new Set(["takenAt"]); + +// Stable ordering for the reference-range column so a sort produces a +// predictable below → in-range → above → unknown progression. +const RANGE_RANK: Record = { + below: 0, + "in-range": 1, + above: 2, + unknown: 3, +}; + function toDateTimeLocal(iso: string): string { const d = new Date(iso); const offset = d.getTimezoneOffset(); @@ -55,6 +77,13 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) { const { t } = useTranslations(); const queryClient = useQueryClient(); + // Client-side column sort over the in-memory reading feed. + const { sortBy, sortDir, toggleSort } = useTableSort({ + defaultColumn: "takenAt", + defaultDir: "desc", + descColumns: LAB_DESC_COLUMNS, + }); + const [editingId, setEditingId] = useState(null); const [editValue, setEditValue] = useState(""); // v1.18.9 — true when the row being edited is a qualitative reading; the @@ -200,54 +229,124 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) { }); } - const ordered = [...readings].sort( - (a, b) => new Date(b.takenAt).getTime() - new Date(a.takenAt).getTime(), - ); + const ordered = useMemo(() => { + const dir = sortDir === "asc" ? 1 : -1; + return [...readings].sort((a, b) => { + let cmp = 0; + switch (sortBy) { + case "value": { + // Numeric rows compare by value; qualitative rows (value null) sort + // after the numeric ones and tie-break on their text. + if (a.value !== null && b.value !== null) { + cmp = a.value - b.value; + } else if (a.value === null && b.value === null) { + cmp = (a.valueText ?? "").localeCompare(b.valueText ?? ""); + } else { + cmp = a.value === null ? 1 : -1; + } + break; + } + case "range": + cmp = + (RANGE_RANK[a.rangeStatus] ?? 99) - + (RANGE_RANK[b.rangeStatus] ?? 99); + break; + case "note": + cmp = Number(a.hasNote) - Number(b.hasNote); + break; + case "takenAt": + default: + cmp = new Date(a.takenAt).getTime() - new Date(b.takenAt).getTime(); + break; + } + // Stable secondary ordering by sample date keeps ties deterministic. + if (cmp === 0) { + cmp = new Date(a.takenAt).getTime() - new Date(b.takenAt).getTime(); + } + return cmp * dir; + }); + }, [readings, sortBy, sortDir]); return ( <> -
    - {ordered.map((r) => ( -
  • -
    -
    - - {formatLabReading(r)} - - -
    -

    + + + + + + + + + {t("labs.editReading")} + + + + + {ordered.map((r) => ( + + + {formatLabReading(r)} + + {formatDateShort(r.takenAt, true)} - {r.hasNote ? ` · ${t("labs.hasNote")}` : ""} -

    - -
    - - deleteMutation.mutate(r.id)} - title={t("labs.deleteConfirmTitle")} - description={t("labs.deleteConfirmDescription")} - confirmLabel={t("labs.deleteReading")} - className="size-11 sm:size-9" - iconClassName="h-4 w-4" - /> -
    - - ))} - +
    + + + + + {r.hasNote ? t("labs.hasNote") : ""} + + +
    + + deleteMutation.mutate(r.id)} + title={t("labs.deleteConfirmTitle")} + description={t("labs.deleteConfirmDescription")} + confirmLabel={t("labs.deleteReading")} + className="size-11 sm:size-9" + iconClassName="h-4 w-4" + /> +
    +
    +
    + ))} +
    +
    Date: Sun, 28 Jun 2026 13:20:24 +0200 Subject: [PATCH 23/94] fix(insights): localize and auto-send the coach assessment prompt Thread an autoSend flag through the coach-launch flow so a card's "ask the coach about this" hand-off dispatches its seeded prompt as the conversation's first turn instead of only seeding the composer. The flag rides the launch context, the layout mount, the drawer, and the shared conversation surface, where a ref-guarded mount effect fires the send exactly once for a fresh thread. Localize the previously hardcoded English assessment opener in the metric status card behind insights.coach.assessmentPrompt with a {metric} placeholder, and localize the resting-pulse coach prefill. Both keys land in all six locales. --- messages/de.json | 6 ++-- messages/en.json | 6 ++-- messages/es.json | 6 ++-- messages/fr.json | 6 ++-- messages/it.json | 6 ++-- messages/pl.json | 6 ++-- src/app/insights/resting-pulse/page.tsx | 4 ++- src/components/insights/ask-coach-action.tsx | 9 ++++- .../coach-panel/coach-conversation.tsx | 35 +++++++++++++++++- .../insights/coach-panel/coach-drawer.tsx | 7 ++++ .../insights/insight-status-card.tsx | 12 ++++++- .../insights/layout-coach-mount.tsx | 3 ++ .../insights/metric-status-card.tsx | 10 +++--- src/lib/insights/coach-launch-context.tsx | 36 ++++++++++++++++--- 14 files changed, 125 insertions(+), 27 deletions(-) diff --git a/messages/de.json b/messages/de.json index ce28bb09c..288729d15 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2372,7 +2372,8 @@ "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?" - } + }, + "assessmentPrompt": "Erkläre mir meine {metric}-Einschätzung — was bedeutet sie und was sollte ich tun?" }, "relativeJustNow": "gerade eben", "relativeMinutesAgoOne": "vor {count} Minute", @@ -2444,7 +2445,8 @@ "emptyState": { "title": "Noch kein Ruhepuls", "description": "Der Ruhepuls erscheint, sobald die Apple Watch oder ein kompatibles Withings-Gerät einen ruhigen Tag durchgetragen hat." - } + }, + "coachPrefill": "Ich habe noch keinen Ruhepuls erfasst — warum unterscheidet sich der tägliche Ruhewert von einem punktuellen Puls, und wie verbessere ich ihn?" }, "oxygenSaturation": { "title": "Sauerstoffsättigung", diff --git a/messages/en.json b/messages/en.json index 14fa8b085..82ab39793 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2372,7 +2372,8 @@ "correlationMoodPulse": "Is there a real link between my mood and my pulse? Help me read this correlation.", "correlationWeightWeekday": "Does my weight follow a weekly pattern? What does this day-of-week correlation tell me?", "recommendation": "Tell me more about this recommendation: \"{text}\" — why does it apply to me, and what should I do about it?" - } + }, + "assessmentPrompt": "Walk me through my {metric} assessment — what does it mean and what should I do about it?" }, "relativeJustNow": "just now", "relativeMinutesAgoOne": "{count} minute ago", @@ -2444,7 +2445,8 @@ "emptyState": { "title": "No resting heart rate yet", "description": "Resting heart rate arrives once Apple Watch or a compatible Withings device has worn through one quiet day." - } + }, + "coachPrefill": "I haven't logged any resting heart rate yet — why is the daily resting value different from a spot pulse, and how do I improve it?" }, "oxygenSaturation": { "title": "Oxygen saturation", diff --git a/messages/es.json b/messages/es.json index ce2ee6de9..a62a65300 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2372,7 +2372,8 @@ "correlationMoodPulse": "¿Existe una relación real entre mi estado de ánimo y mi pulso? Ayúdame a interpretar esta correlación.", "correlationWeightWeekday": "¿Mi peso sigue un patrón semanal? ¿Qué me dice esta correlación por día de la semana?", "recommendation": "Cuéntame más sobre esta recomendación: \"{text}\" — ¿por qué se aplica a mí y qué debería hacer al respecto?" - } + }, + "assessmentPrompt": "Explícame mi evaluación de {metric}: ¿qué significa y qué debería hacer al respecto?" }, "relativeJustNow": "ahora mismo", "relativeMinutesAgoOne": "hace {count} minuto", @@ -2444,7 +2445,8 @@ "emptyState": { "title": "Aún no hay FC en reposo", "description": "La FC en reposo aparece cuando Apple Watch o un Withings compatible ha llevado puesto un día tranquilo." - } + }, + "coachPrefill": "Aún no he registrado ninguna frecuencia cardíaca en reposo: ¿por qué el valor diario en reposo difiere de un pulso puntual y cómo puedo mejorarlo?" }, "oxygenSaturation": { "title": "Saturación de oxígeno", diff --git a/messages/fr.json b/messages/fr.json index 69f085b74..32175a5e3 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2372,7 +2372,8 @@ "correlationMoodPulse": "Y a-t-il un lien réel entre mon humeur et mon pouls ? Aide-moi à interpréter cette corrélation.", "correlationWeightWeekday": "Mon poids suit-il un schéma hebdomadaire ? Que m'indique cette corrélation selon le jour de la semaine ?", "recommendation": "Parle-moi davantage de cette recommandation : \"{text}\" — pourquoi s'applique-t-elle à moi, et que devrais-je faire ?" - } + }, + "assessmentPrompt": "Explique-moi mon évaluation de {metric} : qu'est-ce que cela signifie et que dois-je faire ?" }, "relativeJustNow": "à l’instant", "relativeMinutesAgoOne": "il y a {count} minute", @@ -2444,7 +2445,8 @@ "emptyState": { "title": "Aucune FC au repos", "description": "La FC au repos arrive dès que l'Apple Watch ou un appareil Withings compatible a porté une journée calme." - } + }, + "coachPrefill": "Je n'ai pas encore enregistré de fréquence cardiaque au repos — pourquoi la valeur quotidienne au repos diffère-t-elle d'un pouls ponctuel, et comment l'améliorer ?" }, "oxygenSaturation": { "title": "Saturation en oxygène", diff --git a/messages/it.json b/messages/it.json index 60e5288d4..d90e3a89a 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2372,7 +2372,8 @@ "correlationMoodPulse": "C'è un legame reale tra il mio umore e il mio battito? Aiutami a interpretare questa correlazione.", "correlationWeightWeekday": "Il mio peso segue uno schema settimanale? Cosa mi dice questa correlazione per giorno della settimana?", "recommendation": "Dimmi di più su questa raccomandazione: \"{text}\" — perché si applica a me e cosa dovrei fare?" - } + }, + "assessmentPrompt": "Spiegami la mia valutazione di {metric}: cosa significa e cosa dovrei fare?" }, "relativeJustNow": "proprio ora", "relativeMinutesAgoOne": "{count} minuto fa", @@ -2444,7 +2445,8 @@ "emptyState": { "title": "Ancora nessuna FC a riposo", "description": "La FC a riposo arriva quando Apple Watch o un Withings compatibile ha indossato una giornata tranquilla." - } + }, + "coachPrefill": "Non ho ancora registrato alcuna frequenza cardiaca a riposo: perché il valore giornaliero a riposo è diverso da un battito occasionale e come posso migliorarlo?" }, "oxygenSaturation": { "title": "Saturazione di ossigeno", diff --git a/messages/pl.json b/messages/pl.json index 5631b6f90..2f3ec67fe 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2372,7 +2372,8 @@ "correlationMoodPulse": "Czy istnieje rzeczywisty związek między moim nastrojem a tętnem? Pomóż mi odczytać tę korelację.", "correlationWeightWeekday": "Czy moja waga podlega cotygodniowemu wzorcowi? Co mówi mi ta korelacja według dnia tygodnia?", "recommendation": "Powiedz mi więcej o tej rekomendacji: \"{text}\" — dlaczego mnie dotyczy i co powinienem z nią zrobić?" - } + }, + "assessmentPrompt": "Wyjaśnij mi moją ocenę {metric} — co oznacza i co powinienem z tym zrobić?" }, "relativeJustNow": "przed chwilą", "relativeMinutesAgoOne": "{count} minutę temu", @@ -2444,7 +2445,8 @@ "emptyState": { "title": "Brak tętna spoczynkowego", "description": "Tętno spoczynkowe pojawi się, gdy Apple Watch lub zgodne urządzenie Withings spędzi spokojny dzień na nadgarstku." - } + }, + "coachPrefill": "Nie zarejestrowałem jeszcze tętna spoczynkowego — dlaczego dzienna wartość spoczynkowa różni się od pojedynczego pomiaru tętna i jak ją poprawić?" }, "oxygenSaturation": { "title": "Saturacja tlenu", diff --git a/src/app/insights/resting-pulse/page.tsx b/src/app/insights/resting-pulse/page.tsx index ccf61598a..95a16af3a 100644 --- a/src/app/insights/resting-pulse/page.tsx +++ b/src/app/insights/resting-pulse/page.tsx @@ -3,6 +3,7 @@ import { Heart } from "lucide-react"; import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page"; +import { useTranslations } from "@/lib/i18n/context"; /** * v1.4.32 — `/insights/resting-pulse`. @@ -14,6 +15,7 @@ import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page * trend that's not muddied by mid-walk readings. */ export default function InsightsRestingHrPage() { + const { t } = useTranslations(); return ( } emptyStateCtaType={null} - coachPrefill="I haven't logged any resting heart rate yet — why is the daily resting value different from a spot pulse, and how do I improve it?" + coachPrefill={t("insights.restingHr.coachPrefill")} /> ); } diff --git a/src/components/insights/ask-coach-action.tsx b/src/components/insights/ask-coach-action.tsx index 1200ab309..ffc97490d 100644 --- a/src/components/insights/ask-coach-action.tsx +++ b/src/components/insights/ask-coach-action.tsx @@ -44,6 +44,12 @@ export interface AskCoachActionProps { * briefing), which read better against the default all-source snapshot. */ scope?: CoachLaunchScope; + /** + * Auto-send the seeded question as the conversation's first turn instead + * of only seeding the composer. The assessment hand-off sets this so the + * Coach answers immediately; defaults to false (seed-only). + */ + autoSend?: boolean; /** Optional visible-label override; defaults to the shared CTA copy. */ label?: string; /** className passthrough for per-card alignment. */ @@ -53,6 +59,7 @@ export interface AskCoachActionProps { export function AskCoachAction({ question, scope, + autoSend, label, className, }: AskCoachActionProps) { @@ -76,7 +83,7 @@ export function AskCoachAction({ variant="ghost" size="sm" data-slot="ask-coach-action" - onClick={() => launch.askCoach(question, scope)} + onClick={() => launch.askCoach(question, scope, autoSend)} className={cn( // Discreet: muted text, hover lifts to foreground, no fill. Tight // height so it sits as a quiet footer link, not a primary CTA. diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index 5a9236f6d..c8aaa00df 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useReducer, useState } from "react"; +import { useEffect, useReducer, useRef, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -117,6 +117,13 @@ export interface CoachConversationProps { * established scope. Null → the route's default all-source snapshot. */ launchScope?: CoachLaunchScope | null; + /** + * When true, the `prefill` is dispatched as the conversation's first turn + * automatically, exactly once on mount (ref-guarded). Used by the + * assessment hand-off so the answer lands without a manual send. The send + * only fires for a fresh conversation with a non-empty prefill. + */ + autoSend?: boolean; /** * Renders the conversation title. The surface passes the resolved * title string; the drawer wraps it in ``, the page in an @@ -188,6 +195,7 @@ export interface CoachConversationProps { export function CoachConversation({ prefill, launchScope, + autoSend, renderTitle, renderDescription, leadingHeaderActions, @@ -340,6 +348,31 @@ export function CoachConversation({ } } + // Auto-send the prefill as the conversation's first turn, exactly once. + // A card hand-off (e.g. the assessment "ask about this") opens the Coach + // with `autoSend` so the answer lands without a manual send. Ref-guarded so + // it fires a single time per mount; only for a fresh conversation with a + // non-empty prefill and no stream in flight. + const autoSentRef = useRef(false); + useEffect(() => { + if (!autoSend) return; + if (autoSentRef.current) return; + if (currentConversationId !== null) return; + if (send.isStreaming) return; + const seed = (prefill ?? "").trim(); + if (!seed) return; + autoSentRef.current = true; + void handleSubmit(seed); + // `handleSubmit` is re-created each render but the ref guard ensures a + // single dispatch; the remaining deps gate the one allowed fire. + }, [ + autoSend, + prefill, + currentConversationId, + send.isStreaming, + handleSubmit, + ]); + // v1.22 — "Try again": regenerate an assistant reply by resubmitting the // user turn that produced it as a FRESH turn. The composer value is left // untouched (unlike `handleSubmit`, which clears it) so a half-typed diff --git a/src/components/insights/coach-panel/coach-drawer.tsx b/src/components/insights/coach-panel/coach-drawer.tsx index 490018b21..98e21ef4e 100644 --- a/src/components/insights/coach-panel/coach-drawer.tsx +++ b/src/components/insights/coach-panel/coach-drawer.tsx @@ -54,6 +54,11 @@ export interface CoachDrawerProps { * source(s) + window. Null → the route's default all-source snapshot. */ scope?: CoachLaunchScope | null; + /** + * When true, the conversation auto-sends `prefill` as its first turn + * exactly once on open. Used by the assessment hand-off. + */ + autoSend?: boolean; } export function CoachDrawer({ @@ -61,6 +66,7 @@ export function CoachDrawer({ onOpenChange, prefill, scope, + autoSend, }: CoachDrawerProps) { const { t } = useTranslations(); const router = useRouter(); @@ -139,6 +145,7 @@ export function CoachDrawer({ surface="drawer" prefill={prefill} launchScope={scope} + autoSend={autoSend} autoFocusComposer registerReset={registerReset} // v1.16.1 — the "Conversations" affordance hands off to a diff --git a/src/components/insights/insight-status-card.tsx b/src/components/insights/insight-status-card.tsx index f36c20ca9..03eeca56e 100644 --- a/src/components/insights/insight-status-card.tsx +++ b/src/components/insights/insight-status-card.tsx @@ -69,6 +69,11 @@ interface InsightStatusCardProps { */ coachQuestion?: string; coachScope?: CoachLaunchScope; + /** + * Auto-send the seeded opener as the first turn (assessment hand-off) + * instead of only seeding the composer. Defaults to false. + */ + coachAutoSend?: boolean; } // ─── Main Component ─────────────────────────────────────── @@ -83,6 +88,7 @@ export function InsightStatusCard({ preparing = false, coachQuestion, coachScope, + coachAutoSend, }: InsightStatusCardProps) { const { t } = useTranslations(); const flags = useFeatureFlags(); @@ -248,7 +254,11 @@ export function InsightStatusCard({ caller supplied an opener. */} {coachQuestion ? (

    - +
    ) : null} diff --git a/src/components/insights/layout-coach-mount.tsx b/src/components/insights/layout-coach-mount.tsx index 1b0947430..b8e8be6be 100644 --- a/src/components/insights/layout-coach-mount.tsx +++ b/src/components/insights/layout-coach-mount.tsx @@ -56,6 +56,9 @@ export function LayoutCoachMount() { // opened from a metric surface or insight card is pre-narrowed to // the relevant source(s). scope={launch.scope} + // Auto-send the prefill as the first turn when the launch requested it + // (assessment hand-off), so the answer lands without a manual send. + autoSend={launch.autoSend} /> ); } diff --git a/src/components/insights/metric-status-card.tsx b/src/components/insights/metric-status-card.tsx index 5644db56b..524401a96 100644 --- a/src/components/insights/metric-status-card.tsx +++ b/src/components/insights/metric-status-card.tsx @@ -46,12 +46,9 @@ export function MetricStatusCard({ // carry a snapshot block. Unmapped metrics still get a metric-named // opener, just against the default snapshot. const coachSource = scopeSourceFromMetricKey(metric); - const coachQuestion = `Walk me through my ${metric - .toLowerCase() - .replace( - /_/g, - " ", - )} assessment — what does it mean and what should I do about it?`; + const coachQuestion = t("insights.coach.assessmentPrompt", { + metric: metric.toLowerCase().replace(/_/g, " "), + }); return ( `: the hook is // `enabled: isAuthenticated && enabled`, and both inputs can settle // before a late-hydrating boundary replays its first render — the diff --git a/src/lib/insights/coach-launch-context.tsx b/src/lib/insights/coach-launch-context.tsx index cf7754813..2313f3409 100644 --- a/src/lib/insights/coach-launch-context.tsx +++ b/src/lib/insights/coach-launch-context.tsx @@ -67,6 +67,12 @@ interface CoachLaunchValue { open: boolean; /** Current prefill string (or null when the next open should start blank). */ prefill: string | null; + /** + * When true, the opened conversation auto-sends the prefill as its first + * turn (exactly once), so a card's "ask about this" hand-off lands the + * answer directly instead of only seeding the composer. Cleared on close. + */ + autoSend: boolean; /** * Scope the next/open conversation is narrowed to (or null for the * default all-source snapshot). Set by `askCoach(prefill, scope)`, or @@ -74,8 +80,16 @@ interface CoachLaunchValue { * explicit one. `` forwards it to the drawer. */ scope: CoachLaunchScope | null; - /** Open the drawer with an optional prefill + scope hint. */ - askCoach: (prefill?: string | null, scope?: CoachLaunchScope) => void; + /** + * Open the drawer with an optional prefill + scope hint. When `autoSend` + * is true the prefill is dispatched as the conversation's first turn + * automatically (used by the assessment hand-off). + */ + askCoach: ( + prefill?: string | null, + scope?: CoachLaunchScope, + autoSend?: boolean, + ) => void; /** * v1.21.0 (C4 H1) — register the metric surface the user is currently * looking at so the global FAB (which calls `askCoach()` with no args) @@ -110,6 +124,8 @@ export interface CoachLaunchProviderProps { export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) { const [open, setOpen] = useState(false); const [prefill, setPrefill] = useState(null); + // Whether the next open auto-sends its prefill as the first turn. + const [autoSend, setAutoSend] = useState(false); // Scope the open conversation is narrowed to. `null` → default snapshot. const [scope, setScope] = useState(null); // Ambient scope + seed opener of the metric surface currently on screen. @@ -121,7 +137,11 @@ export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) { const ambientPrefillRef = useRef(null); const askCoach = useCallback( - (nextPrefill?: string | null, nextScope?: CoachLaunchScope) => { + ( + nextPrefill?: string | null, + nextScope?: CoachLaunchScope, + nextAutoSend?: boolean, + ) => { // v1.21.0 (C4 H1/H4) — scope is live. An explicit scope (insight // card, metric-card affordance) wins; otherwise inherit the metric // page's ambient scope so the FAB opens contextual to where the user @@ -135,6 +155,9 @@ export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) { (usingAmbientScope ? ambientPrefillRef.current : null) ?? null, ); + // Auto-send only applies when an explicit prefill is given (a card + // hand-off), never for an ambient/blank open. + setAutoSend(Boolean(nextAutoSend) && Boolean(nextPrefill)); setOpen(true); }, [], @@ -160,9 +183,11 @@ export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) { const handleSetOpen = useCallback((next: boolean) => { setOpen(next); if (!next) { - // Drop the prefill + scope on close so the next open starts clean. + // Drop the prefill + scope + auto-send on close so the next open + // starts clean. setPrefill(null); setScope(null); + setAutoSend(false); } }, []); @@ -170,12 +195,13 @@ export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) { () => ({ open, prefill, + autoSend, scope, askCoach, registerScope, setOpen: handleSetOpen, }), - [open, prefill, scope, askCoach, registerScope, handleSetOpen], + [open, prefill, autoSend, scope, askCoach, registerScope, handleSetOpen], ); return ( From 51aa37f654f409276d0103ddc8671d0ea70a3690 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 13:21:28 +0200 Subject: [PATCH 24/94] feat(labs): delete a biomarker together with its values Deleting a biomarker now removes its recorded values in the same userId-narrowed transaction instead of unlinking them via the SetNull FK. Removing a marker is a deliberate "drop this and its history" action, so leaving orphaned, unlinked readings behind was surprising. Update the delete-confirmation copy in all six locales to state that the values are removed too. --- messages/de.json | 2 +- messages/en.json | 2 +- messages/es.json | 2 +- messages/fr.json | 2 +- messages/it.json | 2 +- messages/pl.json | 2 +- src/app/api/biomarkers/[id]/route.ts | 23 +++++++++++++++-------- 7 files changed, 21 insertions(+), 14 deletions(-) diff --git a/messages/de.json b/messages/de.json index 288729d15..097118112 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6944,7 +6944,7 @@ "deletedToast": "Biomarker gelöscht", "deleteError": "Biomarker konnte nicht gelöscht werden", "deleteConfirmTitle": "Biomarker löschen?", - "deleteConfirmDescription": "Die Definition wird entfernt. Bestehende Werte bleiben erhalten, aber ohne Verknüpfung.", + "deleteConfirmDescription": "Der Biomarker und alle erfassten Werte werden dauerhaft gelöscht. Dies kann nicht rückgängig gemacht werden.", "emptyTitle": "Noch keine Biomarker", "emptyDescription": "Lege Marker wie LDL oder HbA1c an, um Werte zu erfassen.", "loadError": "Biomarker konnten nicht geladen werden", diff --git a/messages/en.json b/messages/en.json index 82ab39793..dd62e0249 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6944,7 +6944,7 @@ "deletedToast": "Biomarker deleted", "deleteError": "Could not delete the biomarker", "deleteConfirmTitle": "Delete biomarker?", - "deleteConfirmDescription": "The definition is removed. Existing readings are kept but unlinked.", + "deleteConfirmDescription": "The biomarker and all of its recorded values are permanently deleted. This cannot be undone.", "emptyTitle": "No biomarkers yet", "emptyDescription": "Define markers like LDL or HbA1c to record values against them.", "loadError": "Could not load biomarkers", diff --git a/messages/es.json b/messages/es.json index a62a65300..fddcd2093 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6944,7 +6944,7 @@ "deletedToast": "Biomarcador eliminado", "deleteError": "No se pudo eliminar el biomarcador", "deleteConfirmTitle": "¿Eliminar biomarcador?", - "deleteConfirmDescription": "Se elimina la definición. Las mediciones existentes se conservan pero se desvinculan.", + "deleteConfirmDescription": "El biomarcador y todos sus valores registrados se eliminan de forma permanente. Esta acción no se puede deshacer.", "emptyTitle": "Aún no hay biomarcadores", "emptyDescription": "Define marcadores como LDL o HbA1c para registrar valores.", "loadError": "No se pudieron cargar los biomarcadores", diff --git a/messages/fr.json b/messages/fr.json index 32175a5e3..f4cb7352d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6944,7 +6944,7 @@ "deletedToast": "Biomarqueur supprimé", "deleteError": "Impossible de supprimer le biomarqueur", "deleteConfirmTitle": "Supprimer le biomarqueur ?", - "deleteConfirmDescription": "La définition est supprimée. Les mesures existantes sont conservées mais dissociées.", + "deleteConfirmDescription": "Le biomarqueur et toutes ses valeurs enregistrées sont définitivement supprimés. Cette action est irréversible.", "emptyTitle": "Aucun biomarqueur pour l'instant", "emptyDescription": "Définissez des marqueurs comme LDL ou HbA1c pour enregistrer des valeurs.", "loadError": "Impossible de charger les biomarqueurs", diff --git a/messages/it.json b/messages/it.json index d90e3a89a..bb20e327f 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6944,7 +6944,7 @@ "deletedToast": "Biomarcatore eliminato", "deleteError": "Impossibile eliminare il biomarcatore", "deleteConfirmTitle": "Eliminare il biomarcatore?", - "deleteConfirmDescription": "La definizione viene rimossa. Le misurazioni esistenti restano ma vengono scollegate.", + "deleteConfirmDescription": "Il biomarcatore e tutti i suoi valori registrati vengono eliminati definitivamente. L'operazione non è reversibile.", "emptyTitle": "Ancora nessun biomarcatore", "emptyDescription": "Definisci marcatori come LDL o HbA1c per registrare i valori.", "loadError": "Impossibile caricare i biomarcatori", diff --git a/messages/pl.json b/messages/pl.json index 2f3ec67fe..4129312fd 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6944,7 +6944,7 @@ "deletedToast": "Biomarker usunięty", "deleteError": "Nie udało się usunąć biomarkera", "deleteConfirmTitle": "Usunąć biomarker?", - "deleteConfirmDescription": "Definicja zostanie usunięta. Istniejące pomiary pozostaną, ale bez powiązania.", + "deleteConfirmDescription": "Biomarker i wszystkie jego zapisane wartości zostaną trwale usunięte. Tej operacji nie można cofnąć.", "emptyTitle": "Brak biomarkerów", "emptyDescription": "Zdefiniuj markery, takie jak LDL lub HbA1c, aby zapisywać wartości.", "loadError": "Nie udało się wczytać biomarkerów", diff --git a/src/app/api/biomarkers/[id]/route.ts b/src/app/api/biomarkers/[id]/route.ts index 3b533241b..89dcce4c6 100644 --- a/src/app/api/biomarkers/[id]/route.ts +++ b/src/app/api/biomarkers/[id]/route.ts @@ -24,10 +24,10 @@ import { effectiveBound, isInvertedRange } from "@/lib/validations/labs"; * * PUT applies a partial edit (`data` built field-by-field; an explicit `null` * clears `context` / `panel` / a bound, an omitted key leaves it untouched). - * DELETE hard-deletes the catalog definition — the `onDelete: SetNull` FK on - * `LabResult.biomarkerId` unlinks every reading without losing it (the row - * keeps its legacy `analyte` / `unit` historical fields). Cross-user rows - * surface as 404 (existence sealed). + * DELETE hard-deletes the catalog definition together with every reading it + * owns, in one userId-narrowed transaction — removing a biomarker is a + * deliberate "drop this and its values" action. Cross-user rows surface as + * 404 (existence sealed). */ type RouteParams = { params: Promise<{ id: string }> }; @@ -187,10 +187,17 @@ export const DELETE = apiHandler( return apiError("Biomarker not found", 404); } - // Hard delete — the `onDelete: SetNull` FK unlinks every reading rather - // than cascading it away. The readings keep their legacy `analyte` / - // `unit` so the history survives the catalog edit. - await prisma.biomarker.delete({ where: { id } }); + // Hard delete the marker AND its readings in one transaction. Deleting a + // biomarker is a deliberate "remove this and its history" action, so the + // readings go with it rather than lingering as orphaned, unlinked rows. + // Both legs are userId-narrowed so a cross-user id can never reach another + // account's data. + await prisma.$transaction([ + prisma.labResult.deleteMany({ + where: { userId: user.id, biomarkerId: id }, + }), + prisma.biomarker.delete({ where: { id } }), + ]); await auditLog("biomarker.delete", { userId: user.id, From d87aac4b4869f047a1b3cd68895a84e267d41151 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 13:24:24 +0200 Subject: [PATCH 25/94] fix(insights): defer the coach auto-send out of the effect body Hold the submit handler in a ref updated from an effect and dispatch the auto-send through a microtask, so the first-turn send no longer calls setState synchronously inside the effect. --- .../coach-panel/coach-conversation.tsx | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index c8aaa00df..0432e6a77 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -354,6 +354,14 @@ export function CoachConversation({ // it fires a single time per mount; only for a fresh conversation with a // non-empty prefill and no stream in flight. const autoSentRef = useRef(false); + // Latest-handler ref so the auto-send effect never lists `handleSubmit` (it + // re-creates each render) and never calls it synchronously in the effect + // body — the dispatch is deferred to a microtask so the composer state it + // touches settles outside the effect. + const handleSubmitRef = useRef(handleSubmit); + useEffect(() => { + handleSubmitRef.current = handleSubmit; + }); useEffect(() => { if (!autoSend) return; if (autoSentRef.current) return; @@ -362,16 +370,10 @@ export function CoachConversation({ const seed = (prefill ?? "").trim(); if (!seed) return; autoSentRef.current = true; - void handleSubmit(seed); - // `handleSubmit` is re-created each render but the ref guard ensures a - // single dispatch; the remaining deps gate the one allowed fire. - }, [ - autoSend, - prefill, - currentConversationId, - send.isStreaming, - handleSubmit, - ]); + queueMicrotask(() => { + void handleSubmitRef.current(seed); + }); + }, [autoSend, prefill, currentConversationId, send.isStreaming]); // v1.22 — "Try again": regenerate an assistant reply by resubmitting the // user turn that produced it as a FRESH turn. The composer value is left From f751f006dea0a743222141a009824e606dfd1a45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 13:28:47 +0200 Subject: [PATCH 26/94] refactor(labs): move the biomarker description to its detail page The per-marker catalog description used to render on every labs overview row, both in the list and the card layout. It reads better as a caption beneath the detail-page heading, mirroring the metric pages' explainer line. Resolve the catalog slug from the marker name and fall back to the user's own context note. --- src/components/labs/lab-biomarker-detail.tsx | 31 ++++++++++++++++--- src/components/labs/lab-list.tsx | 32 ++------------------ 2 files changed, 29 insertions(+), 34 deletions(-) diff --git a/src/components/labs/lab-biomarker-detail.tsx b/src/components/labs/lab-biomarker-detail.tsx index 0db4c7ed4..3353791c2 100644 --- a/src/components/labs/lab-biomarker-detail.tsx +++ b/src/components/labs/lab-biomarker-detail.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useRouter } from "next/navigation"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { FlaskConical, Pencil, Plus } from "lucide-react"; @@ -13,6 +13,7 @@ import { EmptyState } from "@/components/ui/empty-state"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { Skeleton } from "@/components/ui/skeleton"; import { apiDelete, apiGet } from "@/lib/api/api-fetch"; +import { BIOMARKER_CATALOG } from "@/lib/labs/biomarker-catalog"; import { classifyReferenceRange } from "@/lib/labs/reference-range"; import { formatLabValue } from "@/lib/labs/format-value"; import { useTranslations } from "@/lib/i18n/context"; @@ -65,6 +66,18 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { const { t } = useTranslations(); const router = useRouter(); const queryClient = useQueryClient(); + // v1.24 — the per-marker description used to live on the labs overview rows. + // It belongs on the detail page beneath the heading (mirroring the metric + // pages' explainer caption). Resolve the catalog slug from the marker name + // exactly as the overview did, then fall back to the user's own `context`. + const slugByName = useMemo(() => { + const map = new Map(); + for (const seed of BIOMARKER_CATALOG) { + const norm = t(`labs.catalog.${seed.slug}`).trim().toLowerCase(); + if (norm) map.set(norm, seed.slug); + } + return map; + }, [t]); const [addOpen, setAddOpen] = useState(false); // Sticky-footer slot for the add-value sheet (the form portals here). const [addFooterEl, setAddFooterEl] = useState(null); @@ -223,9 +236,19 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
    ) : null} - {marker?.context ? ( -

    {marker.context}

    - ) : null} + {(() => { + const slug = marker?.name + ? slugByName.get(marker.name.trim().toLowerCase()) + : undefined; + const description = slug + ? t(`labs.catalog.desc.${slug}`) + : (marker?.context ?? null); + return description ? ( +

    + {description} +

    + ) : null; + })()} diff --git a/src/components/labs/lab-list.tsx b/src/components/labs/lab-list.tsx index 1f45b1518..0d380b4f9 100644 --- a/src/components/labs/lab-list.tsx +++ b/src/components/labs/lab-list.tsx @@ -11,7 +11,6 @@ import { Card, CardContent } from "@/components/ui/card"; import { EmptyState } from "@/components/ui/empty-state"; import { Skeleton } from "@/components/ui/skeleton"; import { apiGet } from "@/lib/api/api-fetch"; -import { BIOMARKER_CATALOG } from "@/lib/labs/biomarker-catalog"; import { formatDate } from "@/lib/format"; import { formatReferenceRange } from "@/lib/labs/reference-range"; import { formatLabReading, formatLabValue } from "@/lib/labs/format-value"; @@ -81,23 +80,8 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { // v1.22 — a short, factual line under each marker heading describing what the // biomarker measures, sourced from the catalog via i18n. Free-text markers // (no catalog match) carry no subtitle rather than a fabricated one. - // v1.22.1 — build the localized name→slug map once per locale instead of - // re-scanning all catalog seeds (calling `t()` each) per group every render. - const slugByName = useMemo(() => { - const map = new Map(); - for (const seed of BIOMARKER_CATALOG) { - const norm = t(`labs.catalog.${seed.slug}`).trim().toLowerCase(); - if (norm) map.set(norm, seed.slug); - } - return map; - }, [t]); - const describeMarker = (analyte: string): string | null => { - const norm = analyte.trim().toLowerCase(); - if (!norm) return null; - const slug = slugByName.get(norm); - return slug ? t(`labs.catalog.desc.${slug}`) : null; - }; - + // v1.24 — the per-marker description moved to the biomarker detail page + // (beneath the heading); the overview rows no longer carry it. const listKey = queryKeys.labResultsList({ biomarkerId: undefined, analyte: undefined, @@ -204,7 +188,6 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { {groups.map((group) => { - const description = describeMarker(group.analyte); const inner = (
    @@ -216,11 +199,6 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { compact />
    - {description ? ( -

    - {description} -

    - ) : null}
    {formatLabReading(group.latest)} @@ -278,7 +256,6 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
  • ) : null} {groups.map((group) => { - const description = describeMarker(group.analyte); return (
  • @@ -298,11 +275,6 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { linkLabel={group.analyte} /> - {description ? ( -

    - {description} -

    - ) : null}
    From 02d527720a5df6b10936b58c7b695eb2da05c0d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 13:30:19 +0200 Subject: [PATCH 27/94] feat(labs): adjust a biomarker target range from its detail page Add a control beside the marker actions that opens a focused sheet for the reference low and high only, so a user can tune the band without the full marker editor. Persists through the existing biomarker PUT and invalidates the detail, catalog, and result reads. --- messages/de.json | 9 +- messages/en.json | 9 +- messages/es.json | 9 +- messages/fr.json | 9 +- messages/it.json | 9 +- messages/pl.json | 9 +- src/components/labs/lab-biomarker-detail.tsx | 105 ++++++++++++++++++- 7 files changed, 151 insertions(+), 8 deletions(-) diff --git a/messages/de.json b/messages/de.json index 097118112..a5e5f1469 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6979,7 +6979,14 @@ "unhiddenToast": "Biomarker wiederhergestellt", "hideError": "Biomarker konnte nicht aktualisiert werden", "allHidden": "Alle Biomarker sind ausgeblendet.", - "hiddenSection": "Ausgeblendet" + "hiddenSection": "Ausgeblendet", + "targetRange": { + "title": "Zielbereich anpassen", + "description": "Lege die untere und obere Referenz für diesen Marker fest. Lass ein Feld leer für eine offene Grenze.", + "save": "Bereich speichern", + "savedToast": "Zielbereich aktualisiert", + "saveError": "Zielbereich konnte nicht aktualisiert werden" + } }, "catalog": { "total-cholesterol": "Gesamtcholesterin", diff --git a/messages/en.json b/messages/en.json index dd62e0249..c6704f0c4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6979,7 +6979,14 @@ "unhiddenToast": "Biomarker restored", "hideError": "Could not update the biomarker", "allHidden": "Every biomarker is hidden.", - "hiddenSection": "Hidden" + "hiddenSection": "Hidden", + "targetRange": { + "title": "Adjust target range", + "description": "Set the reference low and high for this marker. Leave a field empty for an open-ended bound.", + "save": "Save range", + "savedToast": "Target range updated", + "saveError": "Could not update the target range" + } }, "catalog": { "total-cholesterol": "Total cholesterol", diff --git a/messages/es.json b/messages/es.json index fddcd2093..458ffb5f1 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6979,7 +6979,14 @@ "unhiddenToast": "Biomarcador restaurado", "hideError": "No se pudo actualizar el biomarcador", "allHidden": "Todos los biomarcadores están ocultos.", - "hiddenSection": "Ocultos" + "hiddenSection": "Ocultos", + "targetRange": { + "title": "Ajustar el rango objetivo", + "description": "Define la referencia mínima y máxima de este marcador. Deja un campo vacío para un límite abierto.", + "save": "Guardar rango", + "savedToast": "Rango objetivo actualizado", + "saveError": "No se pudo actualizar el rango objetivo" + } }, "catalog": { "total-cholesterol": "Colesterol total", diff --git a/messages/fr.json b/messages/fr.json index f4cb7352d..48f8b1b65 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6979,7 +6979,14 @@ "unhiddenToast": "Biomarqueur restauré", "hideError": "Impossible de mettre à jour le biomarqueur", "allHidden": "Tous les biomarqueurs sont masqués.", - "hiddenSection": "Masqués" + "hiddenSection": "Masqués", + "targetRange": { + "title": "Ajuster la plage cible", + "description": "Définis la référence basse et haute de ce marqueur. Laisse un champ vide pour une limite ouverte.", + "save": "Enregistrer la plage", + "savedToast": "Plage cible mise à jour", + "saveError": "Impossible de mettre à jour la plage cible" + } }, "catalog": { "total-cholesterol": "Cholestérol total", diff --git a/messages/it.json b/messages/it.json index bb20e327f..acee72e93 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6979,7 +6979,14 @@ "unhiddenToast": "Biomarcatore ripristinato", "hideError": "Impossibile aggiornare il biomarcatore", "allHidden": "Tutti i biomarcatori sono nascosti.", - "hiddenSection": "Nascosti" + "hiddenSection": "Nascosti", + "targetRange": { + "title": "Regola l'intervallo obiettivo", + "description": "Imposta il riferimento minimo e massimo per questo marcatore. Lascia un campo vuoto per un limite aperto.", + "save": "Salva intervallo", + "savedToast": "Intervallo obiettivo aggiornato", + "saveError": "Impossibile aggiornare l'intervallo obiettivo" + } }, "catalog": { "total-cholesterol": "Colesterolo totale", diff --git a/messages/pl.json b/messages/pl.json index 4129312fd..458028f3a 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6979,7 +6979,14 @@ "unhiddenToast": "Biomarker przywrócony", "hideError": "Nie udało się zaktualizować biomarkera", "allHidden": "Wszystkie biomarkery są ukryte.", - "hiddenSection": "Ukryte" + "hiddenSection": "Ukryte", + "targetRange": { + "title": "Dostosuj zakres docelowy", + "description": "Ustaw dolną i górną wartość referencyjną dla tego markera. Pozostaw pole puste, aby granica była otwarta.", + "save": "Zapisz zakres", + "savedToast": "Zaktualizowano zakres docelowy", + "saveError": "Nie udało się zaktualizować zakresu docelowego" + } }, "catalog": { "total-cholesterol": "Cholesterol całkowity", diff --git a/src/components/labs/lab-biomarker-detail.tsx b/src/components/labs/lab-biomarker-detail.tsx index 3353791c2..06994ed7d 100644 --- a/src/components/labs/lab-biomarker-detail.tsx +++ b/src/components/labs/lab-biomarker-detail.tsx @@ -3,16 +3,18 @@ import { useMemo, useState } from "react"; import { useRouter } from "next/navigation"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { FlaskConical, Pencil, Plus } from "lucide-react"; +import { FlaskConical, Pencil, Plus, SlidersHorizontal } from "lucide-react"; import { toast } from "sonner"; import { DeleteButton } from "@/components/data-list"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { EmptyState } from "@/components/ui/empty-state"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { Skeleton } from "@/components/ui/skeleton"; -import { apiDelete, apiGet } from "@/lib/api/api-fetch"; +import { apiDelete, apiGet, apiPut } from "@/lib/api/api-fetch"; import { BIOMARKER_CATALOG } from "@/lib/labs/biomarker-catalog"; import { classifyReferenceRange } from "@/lib/labs/reference-range"; import { formatLabValue } from "@/lib/labs/format-value"; @@ -83,6 +85,12 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { const [addFooterEl, setAddFooterEl] = useState(null); const [editOpen, setEditOpen] = useState(false); const [editFooterEl, setEditFooterEl] = useState(null); + // v1.24 — focused "adjust target range" sheet. Edits only the reference + // bounds (the full marker editor lives behind the pencil); seeded from the + // marker each time it opens. + const [rangeOpen, setRangeOpen] = useState(false); + const [lowerInput, setLowerInput] = useState(""); + const [upperInput, setUpperInput] = useState(""); const { data: marker, isError: markerError } = useQuery({ queryKey: queryKeys.biomarkerDetail(biomarkerId), @@ -151,6 +159,33 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { onError: () => toast.error(t("labs.biomarker.deleteError")), }); + function openRange() { + setLowerInput(marker?.lowerBound != null ? String(marker.lowerBound) : ""); + setUpperInput(marker?.upperBound != null ? String(marker.upperBound) : ""); + setRangeOpen(true); + } + + const saveRange = useMutation({ + mutationFn: () => { + const lower = lowerInput.trim() === "" ? null : Number(lowerInput); + const upper = upperInput.trim() === "" ? null : Number(upperInput); + return apiPut(`/api/biomarkers/${biomarkerId}`, { + lowerBound: lower, + upperBound: upper, + }); + }, + onSuccess: () => { + toast.success(t("labs.biomarker.targetRange.savedToast")); + setRangeOpen(false); + queryClient.invalidateQueries({ + queryKey: queryKeys.biomarkerDetail(biomarkerId), + }); + queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() }); + queryClient.invalidateQueries({ queryKey: queryKeys.labResults() }); + }, + onError: () => toast.error(t("labs.biomarker.targetRange.saveError")), + }); + if (markerError || listError) { return (

    @@ -202,6 +237,16 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9" iconClassName="h-4 w-4" /> + + +

    + } + > +
    +
    + + setLowerInput(e.target.value)} + /> +
    +
    + + setUpperInput(e.target.value)} + /> +
    +
    + {marker?.unit ? ( +

    + {t("labs.biomarker.form.rangeHint")} +

    + ) : null} +
    ); } From 0790a96ca4be3e24bcd9a79d2f3ede61fea4b457 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 13:43:14 +0200 Subject: [PATCH 28/94] feat(labs): add an AI assessment and stat strip to the biomarker detail page Give each biomarker a detail page that matches the metric sub-pages: a numbers-first Min/Max/Median/Mean strip over the numeric readings and a data-grounded AI assessment card. The assessment is served by a new read-only endpoint GET /api/insights/biomarker-assessment?biomarkerId=&locale=, returning the same envelope as /api/insights/metric-status so InsightStatusCard consumes it unchanged. A new generator reads the marker's LabResult history and reuses the shared provider runner, the stale-while-revalidate read-only path, and the persist helper. Regeneration is gated on a fingerprint of the latest reading, so the assessment is rebuilt only when a new reading lands; an idle marker re-stamps the cached text without an LLM call. The biomarker: scope rides the existing on-demand status queue. The stat strip and the assessment card both self-gate on numeric data, so a qualitative-only or brand-new marker paints neither. --- docs/api/openapi.yaml | 102 +++++ .../insights/biomarker-assessment/route.ts | 72 ++++ src/components/labs/lab-biomarker-detail.tsx | 65 +++- src/hooks/use-insight-status.ts | 54 +++ src/lib/insights/biomarker-status.ts | 354 ++++++++++++++++++ .../jobs/insight-status-generate-shared.ts | 11 +- src/lib/jobs/insight-status-generate.ts | 22 ++ src/lib/openapi/routes/insights/paths.ts | 27 ++ src/lib/openapi/routes/insights/schemas.ts | 64 ++++ src/lib/query-keys/insights.ts | 10 + 10 files changed, 779 insertions(+), 2 deletions(-) create mode 100644 src/app/api/insights/biomarker-assessment/route.ts create mode 100644 src/lib/insights/biomarker-status.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index afefa5a85..37c195fc2 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -6052,6 +6052,47 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/insights/biomarker-assessment: + get: + tags: + - Insights + summary: Per-biomarker assessment + description: "Data-driven plain-language assessment for one user-scoped lab biomarker, reading its `LabResult` history. + Identical envelope to the metric-status card so the `InsightStatusCard` consumes it unchanged. Read-only: a + cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate); the + assessment regenerates only when a new reading lands. A marker with no numeric readings returns `insufficient` + without an LLM call. Auth via cookie or Bearer." + parameters: + - in: query + name: biomarkerId + schema: + type: string + minLength: 1 + maxLength: 64 + description: User-scoped biomarker id to assess. A cross-user or unknown id returns an `insufficient` envelope, not a + 404 (existence sealed). + required: true + description: User-scoped biomarker id to assess. A cross-user or unknown id returns an `insufficient` envelope, not a + 404 (existence sealed). + - in: query + name: locale + schema: + description: Optional UI-locale override; defaults to the session locale. + type: string + enum: + - de + - en + description: Optional UI-locale override; defaults to the session locale. + responses: + "200": + description: Assessment envelope (fresh, cached, or preparing). + content: + application/json: + schema: + $ref: "#/components/schemas/BiomarkerAssessmentResponseEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 /api/insights/derived: get: tags: @@ -20229,6 +20270,67 @@ components: description: "Generic per-metric assessment envelope. Identical shape to the seven specialised `*-status` cards so the `InsightStatusCard` consumes it unchanged. Read-only + stale-while-revalidate: a cache miss warms a generation out of band and serves the last-good text meanwhile." + BiomarkerAssessmentResponseEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/BiomarkerAssessmentResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + BiomarkerAssessmentResponse: + type: object + properties: + hasProvider: + type: boolean + description: False when the user has no usable AI provider — `text` then carries the generic no-key guidance. + text: + anyOf: + - type: string + - type: "null" + description: The assessment narrative (plain text, rendered as React text children). Null while a first generation is + preparing, or when the marker has no numeric readings. + cached: + type: boolean + description: True when `text` is served from cache (incl. last-good). + updatedAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + description: When the served assessment was generated; null when none. + preparing: + description: True when a first assessment is being generated out of band and no prior text exists yet — the client polls + until it lands. + type: boolean + revalidating: + description: True when `text` is served from last-good cache (stale-while-revalidate) while a fresh generation is in + flight. The client keeps polling on `preparing || revalidating` (bounded) so the open card upgrades to the + warmed assessment without a remount. + type: boolean + insufficient: + description: True when the marker has no numeric readings; no assessment is generated (no LLM call). The card is not + rendered. + type: boolean + required: + - hasProvider + - text + - cached + - updatedAt + additionalProperties: false + description: "Per-biomarker assessment envelope. Identical shape to the generic metric-status card so the + `InsightStatusCard` consumes it unchanged. Read-only + stale-while-revalidate: a cache miss warms a generation + out of band and serves the last-good text meanwhile; the assessment regenerates only when a new reading lands." DerivedMetricResponseEnvelope: type: object properties: diff --git a/src/app/api/insights/biomarker-assessment/route.ts b/src/app/api/insights/biomarker-assessment/route.ts new file mode 100644 index 000000000..1bc0bd5af --- /dev/null +++ b/src/app/api/insights/biomarker-assessment/route.ts @@ -0,0 +1,72 @@ +/** + * Per-biomarker assessment route. + * + * `GET /api/insights/biomarker-assessment?biomarkerId=` serves the + * data-driven assessment for one user-scoped biomarker. The shape is + * byte-identical to `/api/insights/metric-status` so `InsightStatusCard` + * consumes it unchanged. + * + * Read-only by construction: a cache miss enqueues an out-of-band + * generation (via `resolveReadOnlyStatusMiss` inside the generator) and + * serves the last-good text (stale-while-revalidate), never blocking on the + * provider. The generator regenerates ONLY when the latest reading + * fingerprint changes, so an idle marker re-stamps cached text without an + * LLM round-trip. + */ +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; +import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { resolveServerLocale } from "@/lib/i18n/server-locale"; +import { requireAssistantSurface } from "@/lib/feature-flags"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { generateBiomarkerStatus } from "@/lib/insights/biomarker-status"; +import { resolveMetricStatusLocale } from "@/lib/insights/metric-status"; + +export const dynamic = "force-dynamic"; + +const querySchema = z.object({ + biomarkerId: z.string().min(1).max(64), +}); + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + const m = await requireModuleEnabled(user.id, "insights"); + if (!m.enabled) return m.response; + await requireAssistantSurface("insightStatus"); + + const parsed = querySchema.safeParse({ + biomarkerId: request.nextUrl.searchParams.get("biomarkerId"), + }); + if (!parsed.success) { + annotate({ + action: { name: "insights.biomarker-status.invalid" }, + meta: { issue_count: parsed.error.issues.length }, + }); + return returnAllZodIssues(parsed.error, 422); + } + + const localeParam = request.nextUrl.searchParams.get("locale"); + const resolved = await resolveServerLocale({ + request, + userLocale: user.locale ?? null, + override: localeParam, + }); + const locale = resolveMetricStatusLocale(resolved); + + const result = await generateBiomarkerStatus({ + biomarkerId: parsed.data.biomarkerId, + userId: user.id, + locale, + force: false, + readOnly: true, + }); + + annotate({ + action: { name: "insights.biomarker-status" }, + meta: { biomarkerId: parsed.data.biomarkerId }, + }); + + return apiSuccess(result); +}); diff --git a/src/components/labs/lab-biomarker-detail.tsx b/src/components/labs/lab-biomarker-detail.tsx index 06994ed7d..f4ddab48a 100644 --- a/src/components/labs/lab-biomarker-detail.tsx +++ b/src/components/labs/lab-biomarker-detail.tsx @@ -14,7 +14,12 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { Skeleton } from "@/components/ui/skeleton"; +import { InsightStatusCard } from "@/components/insights/insight-status-card"; +import { MetricStatStrip } from "@/components/insights/metric-stat-strip"; +import { useInsightBiomarkerAssessment } from "@/hooks/use-insight-status"; +import { useMounted } from "@/hooks/use-mounted"; import { apiDelete, apiGet, apiPut } from "@/lib/api/api-fetch"; +import { summarize, type DataSummary } from "@/lib/analytics/trends"; import { BIOMARKER_CATALOG } from "@/lib/labs/biomarker-catalog"; import { classifyReferenceRange } from "@/lib/labs/reference-range"; import { formatLabValue } from "@/lib/labs/format-value"; @@ -117,7 +122,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { ), }); - const readings: LabResultDto[] = list?.results ?? []; + const readings: LabResultDto[] = useMemo(() => list?.results ?? [], [list]); // The reading feed caps at 500 server-side; surface a hint when truncated. const total = list?.meta?.total ?? 0; const truncated = total > readings.length; @@ -129,6 +134,32 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { )[0] : null; + // v1.24 — the numbers-first stat strip + the AI assessment card mirror the + // metric sub-pages. The strip reads a client-side `DataSummary` over the + // numeric readings; the assessment card consumes the read-only biomarker + // route (stale-while-revalidate, regenerated only on a new reading). The + // strip + card both self-gate on data, so a qualitative-only or brand-new + // marker paints neither. + const numericReadings = useMemo( + () => readings.filter((r) => r.value !== null), + [readings], + ); + const summary = useMemo( + () => + numericReadings.length > 0 + ? summarize( + numericReadings.map((r) => ({ + date: new Date(r.takenAt), + value: r.value as number, + })), + ) + : null, + [numericReadings], + ); + const mounted = useMounted(); + const { data: assessment, isLoading: assessmentLoading } = + useInsightBiomarkerAssessment(biomarkerId, numericReadings.length > 0); + function afterAdd() { setAddOpen(false); queryClient.invalidateQueries({ queryKey: queryKeys.labResults() }); @@ -295,6 +326,16 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { ) : null; })()} + {/* Numbers-first stat strip — Min / Max / Median / Mean over the + numeric readings, mirroring the metric sub-pages. Self-gating: + renders nothing for a qualitative-only or empty marker. */} + + {isLoading ? ( @@ -340,6 +381,28 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { ) : null} + {/* AI assessment — the spine's closing block, mirroring the metric + sub-pages (intro → stat strip → chart → history → assessment). The + card self-suppresses when the operator disabled status cards, and + the hook is gated on the marker having numeric readings. */} + {numericReadings.length > 0 ? ( + } + text={assessment?.text ?? null} + hasProvider={assessment?.hasProvider ?? false} + updatedAt={assessment?.updatedAt ?? null} + coachQuestion={ + marker?.name + ? t("insights.coach.assessmentPrompt", { metric: marker.name }) + : undefined + } + coachAutoSend + loading={!mounted || assessmentLoading} + preparing={assessment?.preparing ?? false} + /> + ) : null} + => { + const controller = new AbortController(); + const timeoutHandle = setTimeout( + () => controller.abort(), + STATUS_TIMEOUT_MS, + ); + try { + return await apiGet( + `/api/insights/biomarker-assessment?biomarkerId=${encodeURIComponent( + biomarkerId, + )}&locale=${locale}`, + { signal: controller.signal }, + ); + } finally { + clearTimeout(timeoutHandle); + } + }, + enabled: isAuthenticated && enabled && biomarkerId.length > 0, + staleTime: 60 * 1000, + retry: 0, + refetchInterval: (query) => + nextStatusPollInterval( + query.state.data?.preparing, + query.state.dataUpdateCount, + query.state.data?.revalidating, + ), + }); +} diff --git a/src/lib/insights/biomarker-status.ts b/src/lib/insights/biomarker-status.ts new file mode 100644 index 000000000..e39e547ed --- /dev/null +++ b/src/lib/insights/biomarker-status.ts @@ -0,0 +1,354 @@ +/** + * Per-biomarker assessment generator. + * + * Mirrors `generateMetricStatus` (the generic HealthKit-metric generator) + * but reads `LabResult` rows for one user-scoped `Biomarker` instead of + * `Measurement` rows for a `MeasurementType`. It reuses the exact same + * machinery the metric cards use: + * - the shared provider runner (`runStatusCompletion`), + * - the stale-while-revalidate read-only path (`resolveReadOnlyStatusMiss`), + * - the standard cache-row persist (`persistStatusInsight`), + * - the cheap INPUT gate (`gateUnchangedStatusInput`) keyed on the latest + * reading fingerprint so a fresh assessment is produced ONLY when a new + * reading lands — an idle day re-stamps the cached text without an LLM + * round-trip. + * + * The return shape is byte-identical to the metric generator so + * `InsightStatusCard` consumes it unchanged. The cache scope is + * `biomarker:`, which the on-demand queue worker routes back here. + * + * Empty-data guard: a marker with no numeric readings returns an + * `insufficient` marker WITHOUT touching the provider chain. + */ +import { prisma } from "@/lib/db"; +import { PROMPT_VERSION } from "@/lib/ai/prompts/base-system"; +import { classifyReferenceRange } from "@/lib/labs/reference-range"; +import { getNoKeyGeneralStatusText } from "@/lib/insights/no-key-fallbacks"; +import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; +import { runStatusCompletion } from "@/lib/insights/status-provider"; +import { + type SupportedLocale, + normalizeLocale, + normalizeSummaryText, + parseSummaryFromContent, + persistStatusInsight, + summarizeSeries, +} from "@/lib/insights/status-shared"; +import { + gateUnchangedStatusInput, + readFreshStatusText, + resolveReadOnlyStatusMiss, + statusCacheAction, +} from "@/lib/insights/status-cache"; +import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; +import { annotate } from "@/lib/logging/context"; +import { toBerlinDayKey } from "@/lib/tz/resolver"; + +export interface BiomarkerStatusResult { + hasProvider: boolean; + text: string | null; + cached: boolean; + updatedAt: string | null; + preparing?: boolean; + /** True when last-good text is served while a fresh generation runs. */ + revalidating?: boolean; + /** True when the marker has no numeric readings; no LLM was called. */ + insufficient?: boolean; +} + +/** The `biomarker:` cache/queue scope for one marker. */ +export function biomarkerStatusScope( + biomarkerId: string, +): `biomarker:${string}` { + return `biomarker:${biomarkerId}`; +} + +/** Cap on the recent reading rows folded into the prompt snapshot. */ +const SNAPSHOT_READING_CAP = 60; + +interface BiomarkerReading { + id: string; + value: number; + takenAt: Date; +} + +function insufficient(): BiomarkerStatusResult { + return { + hasProvider: true, + text: null, + cached: false, + updatedAt: null, + insufficient: true, + }; +} + +function getSystemPrompt(locale: SupportedLocale, markerName: string): string { + if (locale === "de") { + return [ + `Du bist ein vorsichtiger, faktenbasierter Gesundheitsbegleiter und beurteilst den Laborwert „${markerName}" einer Person.`, + "Beziehe dich AUSSCHLIESSLICH auf die im Snapshot gelieferten Zahlen. Erfinde keine Werte, keine Diagnosen und keine Medikamente.", + "Schreibe 2 bis 4 ruhige Sätze in klarer Alltagssprache: nenne den aktuellen Wert und seinen Bezug zum Referenzbereich, ordne die Tendenz über die letzten Messungen ein und gib höchstens einen sachlichen Hinweis.", + "Keine Panikmache, keine Marketing-Sprache, keine Befehle. Bei auffälligen Werten verweise neutral auf eine ärztliche Einordnung.", + 'Antworte als JSON-Objekt der Form { "summary": "…" } ohne weiteren Text.', + ].join(" "); + } + return [ + `You are a careful, fact-based health companion assessing a person's lab marker "${markerName}".`, + "Use ONLY the figures provided in the snapshot. Do not invent values, diagnoses, or medications.", + "Write 2 to 4 calm sentences in plain language: state the current value and how it sits against the reference range, place the trend across recent readings, and add at most one factual pointer.", + "No alarmism, no marketing tone, no commands. For notably out-of-range values, refer neutrally to a clinician for interpretation.", + 'Respond as a JSON object of the form { "summary": "…" } with no other text.', + ].join(" "); +} + +function getUserPrompt( + snapshotJson: string, + todayKey: string, + locale: SupportedLocale, +): string { + return locale === "de" + ? `Heutiges Datum: ${todayKey}. Beurteile den folgenden Laborwert-Snapshot:\n${snapshotJson}` + : `Today's date: ${todayKey}. Assess the following lab-marker snapshot:\n${snapshotJson}`; +} + +/** + * Generate (or read from cache) the assessment for one biomarker. Mirrors + * the metric generator's contract: + * - cache hit (today, non-stub) → serve it, + * - `readOnly` miss → enqueue out-of-band + serve last-good, + * - no numeric readings → `insufficient`, no LLM call, + * - unchanged latest reading → re-stamp cached text, no LLM call, + * - otherwise → build snapshot, run completion, persist. + */ +export async function generateBiomarkerStatus(args: { + biomarkerId: string; + userId: string; + locale?: string | null; + force?: boolean; + /** Read-only navigation path — never blocks on the provider. */ + readOnly?: boolean; +}): Promise { + const marker = await prisma.biomarker.findFirst({ + where: { id: args.biomarkerId, userId: args.userId }, + select: { + id: true, + name: true, + unit: true, + lowerBound: true, + upperBound: true, + }, + }); + if (!marker) { + // Unknown / cross-user marker — degrade gracefully rather than throwing. + return insufficient(); + } + + const locale = normalizeLocale(args.locale); + const force = args.force === true; + const readOnly = args.readOnly === true; + const scope = biomarkerStatusScope(marker.id); + const cacheAction = statusCacheAction(scope, locale); + const todayKey = toBerlinDayKey(new Date()); + + const cached = await readFreshStatusText({ + userId: args.userId, + cacheAction, + todayKey, + force, + }); + if (cached) { + return { + hasProvider: true, + text: cached.text, + cached: true, + updatedAt: cached.updatedAt, + }; + } + + // Numeric readings only — a qualitative-only marker carries no range verdict + // or trend, so there is nothing to assess. Done BEFORE any enqueue so an + // empty marker never queues an LLM job nor blocks on the provider. + const rows = await prisma.labResult.findMany({ + where: { + userId: args.userId, + biomarkerId: marker.id, + deletedAt: null, + value: { not: null }, + }, + orderBy: { takenAt: "desc" }, + take: SNAPSHOT_READING_CAP, + select: { id: true, value: true, takenAt: true }, + }); + if (rows.length === 0) { + annotate({ + action: { name: "insights.biomarker-status.insufficient" }, + meta: { biomarkerId: marker.id }, + }); + return insufficient(); + } + + const readings: BiomarkerReading[] = rows.map((r) => ({ + id: r.id, + value: r.value as number, + takenAt: r.takenAt, + })); + const latest = readings[0]; + const totalCount = await prisma.labResult.count({ + where: { + userId: args.userId, + biomarkerId: marker.id, + deletedAt: null, + value: { not: null }, + }, + }); + + if (readOnly) { + const outcome = await resolveReadOnlyStatusMiss({ + userId: args.userId, + metric: scope, + locale, + }); + if (outcome.kind === "no-provider" || outcome.kind === "consent-missing") { + return { + hasProvider: false, + text: getNoKeyGeneralStatusText(locale), + cached: true, + updatedAt: null, + }; + } + return { + hasProvider: true, + text: outcome.lastGood?.text ?? null, + cached: outcome.lastGood !== null, + updatedAt: outcome.lastGood?.updatedAt ?? null, + preparing: outcome.lastGood === null, + revalidating: outcome.revalidating, + }; + } + + // INPUT gate: fingerprint the latest reading + count + bounds + locale + + // prompt version. When the fingerprint matches the cached assessment's + // stored `inputHash`, nothing the prompt could see has changed since the + // last reading, so re-stamp that text under today's day key and skip the + // LLM entirely. A new reading flips the latest id / value / count and + // forces a real regeneration. + const inputHash = hashInsightSnapshot({ + scope, + locale, + promptVersion: PROMPT_VERSION, + count: totalCount, + bounds: { lower: marker.lowerBound, upper: marker.upperBound }, + latest: { + id: latest.id, + takenAt: latest.takenAt.toISOString(), + value: latest.value, + }, + }); + const unchanged = await gateUnchangedStatusInput({ + userId: args.userId, + cacheAction, + todayKey, + inputHash, + force, + }); + if (unchanged) { + return { + hasProvider: true, + text: unchanged.text, + cached: true, + updatedAt: unchanged.updatedAt, + }; + } + + // Ascending series (oldest → newest) for the trend summary the prompt reads. + const ascending = [...readings].reverse(); + const summary = summarizeSeries(ascending.map((r) => ({ value: r.value }))); + const latestStatus = classifyReferenceRange( + latest.value, + marker.lowerBound, + marker.upperBound, + ); + + const snapshot = { + locale, + promptVersion: PROMPT_VERSION, + generatedForDay: todayKey, + marker: { + name: marker.name, + unit: marker.unit, + referenceRange: + marker.lowerBound != null || marker.upperBound != null + ? { lower: marker.lowerBound, upper: marker.upperBound } + : null, + }, + dataCoverage: { + totalReadings: totalCount, + readingsInSnapshot: readings.length, + newestTakenAt: latest.takenAt.toISOString(), + oldestInSnapshot: ascending[0].takenAt.toISOString(), + }, + latest: { + value: latest.value, + takenAt: latest.takenAt.toISOString(), + rangeStatus: latestStatus, + }, + summary, + series: ascending.map((r) => ({ + day: toBerlinDayKey(r.takenAt), + value: r.value, + })), + }; + const snapshotJson = JSON.stringify(snapshot, null, 2); + + annotate({ + action: { name: cacheAction }, + meta: { payload_size_bytes: snapshotJson.length }, + }); + + const outcome = await runStatusCompletion({ + userId: args.userId, + cacheAction, + consentSurface: "insights", + systemPrompt: getSystemPrompt(locale, marker.name), + userPrompt: getUserPrompt(snapshotJson, todayKey, locale), + temperature: 0.45, + maxTokens: 1000, + }); + + if (outcome.kind === "none") { + return { + hasProvider: false, + text: getNoKeyGeneralStatusText(locale), + cached: true, + updatedAt: null, + }; + } + if (outcome.kind === "timeout" || outcome.kind === "error") { + return returnTimeoutFallback({ + cacheAction, + reason: outcome.kind, + userId: args.userId, + todayKey, + stubText: getNoKeyGeneralStatusText(locale), + }); + } + + const text = normalizeSummaryText(parseSummaryFromContent(outcome.content)); + if (!text) { + throw new Error(`Biomarker-status summary was empty for ${marker.id}`); + } + + const updatedAt = await persistStatusInsight({ + userId: args.userId, + cacheAction, + todayKey, + locale, + text, + providerType: outcome.providerType, + model: outcome.model, + tokensUsed: outcome.tokensUsed, + inputHash, + }); + + return { hasProvider: true, text, cached: false, updatedAt }; +} diff --git a/src/lib/jobs/insight-status-generate-shared.ts b/src/lib/jobs/insight-status-generate-shared.ts index 0811e46d2..906847142 100644 --- a/src/lib/jobs/insight-status-generate-shared.ts +++ b/src/lib/jobs/insight-status-generate-shared.ts @@ -67,7 +67,16 @@ export type InsightStatusScope = * always fills the field synchronously — this queue only warms the AI prose * that overrides it on the next read. */ - | `derived-score:${string}`; + | `derived-score:${string}` + /** + * Per-biomarker assessment scopes ride the same on-demand queue. Their + * scope id is `biomarker:`; the worker routes the prefix to the + * biomarker generator, which reads `LabResult` rows for the marker and + * applies its own empty-data + input-hash gates, so a job enqueued for a + * marker with no new reading costs only a cheap fingerprint, not an LLM + * call. + */ + | `biomarker:${string}`; export interface InsightStatusGeneratePayload { userId: string; diff --git a/src/lib/jobs/insight-status-generate.ts b/src/lib/jobs/insight-status-generate.ts index 1ca8d2a28..4b96cd16d 100644 --- a/src/lib/jobs/insight-status-generate.ts +++ b/src/lib/jobs/insight-status-generate.ts @@ -35,6 +35,7 @@ import { generateBmiStatusForUser } from "@/lib/insights/bmi-status"; import { generateMoodStatusForUser } from "@/lib/insights/mood-status"; import { generateMedicationComplianceStatusForUser } from "@/lib/insights/medication-compliance-status"; import { generateMetricStatus } from "@/lib/insights/metric-status"; +import { generateBiomarkerStatus } from "@/lib/insights/biomarker-status"; import { isMetricStatusId } from "@/lib/insights/metric-status-registry"; import { prisma } from "@/lib/db"; import { @@ -115,6 +116,27 @@ export async function runInsightStatusGenerate( return; } + // A `biomarker:` scope routes to the per-biomarker generator, which + // reads `LabResult` rows for the marker. Its own empty-data + input-hash + // gates keep a job for an unchanged marker cheap (no LLM call). + if (payload.metric.startsWith("biomarker:")) { + const biomarkerId = payload.metric.slice("biomarker:".length); + if (!biomarkerId) { + annotate({ + action: { name: "insights.status.generate.unknown_metric" }, + meta: { metric: payload.metric }, + }); + return; + } + await generateBiomarkerStatus({ + biomarkerId, + userId: payload.userId, + locale: payload.locale, + force: true, + }); + return; + } + // v1.13.2 — a `derived-score:` scope warms the per-score assessment AI // prose. The route already filled the deterministic text synchronously; this // recomputes the derived value and persists the warmer prose so the next diff --git a/src/lib/openapi/routes/insights/paths.ts b/src/lib/openapi/routes/insights/paths.ts index a3826a351..59ef64879 100644 --- a/src/lib/openapi/routes/insights/paths.ts +++ b/src/lib/openapi/routes/insights/paths.ts @@ -9,6 +9,8 @@ import { insightsComprehensiveResponse, metricStatusQuery, metricStatusResponse, + biomarkerAssessmentQuery, + biomarkerAssessmentResponse, derivedMetricQuery, derivedMetricResponse, derivedBatchQuery, @@ -300,6 +302,31 @@ export const insightsPaths: NonNullable = { }, }, }, + "/api/insights/biomarker-assessment": { + get: { + tags: ["Insights"], + summary: "Per-biomarker assessment", + description: + "Data-driven plain-language assessment for one user-scoped lab biomarker, reading its `LabResult` history. Identical envelope to the metric-status card so the `InsightStatusCard` consumes it unchanged. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate); the assessment regenerates only when a new reading lands. A marker with no numeric readings returns `insufficient` without an LLM call. Auth via cookie or Bearer.", + requestParams: { + query: biomarkerAssessmentQuery, + }, + responses: { + "200": { + description: "Assessment envelope (fresh, cached, or preparing).", + content: { + "application/json": { + schema: dataEnvelope( + biomarkerAssessmentResponse, + "BiomarkerAssessmentResponseEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, "/api/insights/derived": { get: { tags: ["Insights"], diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index 82971e74f..09f805707 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -473,6 +473,70 @@ export const insightStatusResponse = z "Specialised per-metric assessment envelope (blood-pressure, pulse, weight, bmi, mood). Identical shape to the generic metric-status card so the `InsightStatusCard` consumes it unchanged. Read-only + stale-while-revalidate: a cache miss warms a generation out of band and serves the last-good text meanwhile.", }); +// Per-biomarker assessment. The marker is identified by a user-scoped id +// (the generic metric-status route fixes its metric by a closed registry +// enum; biomarkers are user-defined, so the id is a free-form string). +export const biomarkerAssessmentQuery = z + .object({ + biomarkerId: z + .string() + .min(1) + .max(64) + .describe( + "User-scoped biomarker id to assess. A cross-user or unknown id returns an `insufficient` envelope, not a 404 (existence sealed).", + ), + locale: z + .enum(["de", "en"]) + .optional() + .describe("Optional UI-locale override; defaults to the session locale."), + }) + .meta({ id: "BiomarkerAssessmentQuery" }); + +export const biomarkerAssessmentResponse = z + .object({ + hasProvider: z + .boolean() + .describe( + "False when the user has no usable AI provider — `text` then carries the generic no-key guidance.", + ), + text: z + .string() + .nullable() + .describe( + "The assessment narrative (plain text, rendered as React text children). Null while a first generation is preparing, or when the marker has no numeric readings.", + ), + cached: z + .boolean() + .describe("True when `text` is served from cache (incl. last-good)."), + updatedAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe("When the served assessment was generated; null when none."), + preparing: z + .boolean() + .optional() + .describe( + "True when a first assessment is being generated out of band and no prior text exists yet — the client polls until it lands.", + ), + revalidating: z + .boolean() + .optional() + .describe( + "True when `text` is served from last-good cache (stale-while-revalidate) while a fresh generation is in flight. The client keeps polling on `preparing || revalidating` (bounded) so the open card upgrades to the warmed assessment without a remount.", + ), + insufficient: z + .boolean() + .optional() + .describe( + "True when the marker has no numeric readings; no assessment is generated (no LLM call). The card is not rendered.", + ), + }) + .meta({ + id: "BiomarkerAssessmentResponse", + description: + "Per-biomarker assessment envelope. Identical shape to the generic metric-status card so the `InsightStatusCard` consumes it unchanged. Read-only + stale-while-revalidate: a cache miss warms a generation out of band and serves the last-good text meanwhile; the assessment regenerates only when a new reading lands.", + }); + // The medication-compliance route carries a richer envelope than the // other six: a `summary` narrative plus a per-medication `text` array, // instead of a single `text` field. diff --git a/src/lib/query-keys/insights.ts b/src/lib/query-keys/insights.ts index 0a3607438..ff08733d1 100644 --- a/src/lib/query-keys/insights.ts +++ b/src/lib/query-keys/insights.ts @@ -43,6 +43,16 @@ export const insightsKeys = { */ insightsMetricStatus: (metric: string, locale: string) => ["insights", "metric-status", metric, locale] as const, + /** + * Per-biomarker assessment for the lab-marker detail page. One generic + * route (`/api/insights/biomarker-assessment?biomarkerId=…`) backs every + * marker, so the key is parameterised by the marker id alongside the + * locale. Same read-only + stale-while-revalidate contract as + * `insightsMetricStatus`; the `["insights"]` prefix keeps it in the + * standard invalidation fan-out. + */ + insightsBiomarkerAssessment: (biomarkerId: string, locale: string) => + ["insights", "biomarker-assessment", biomarkerId, locale] as const, /** * v1.8.7.1 — mutationKey for the on-demand full-warm POST * (`/api/insights/pregenerate`). Lives in the factory so the bare-array From f17131dbdf03c57afb9c341fe0e90fd4899b4266 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 13:58:43 +0200 Subject: [PATCH 29/94] test(labs): cover the biomarker assessment, detail page, and marker delete/range Add coverage for the new biomarker-assessment seam and the marker detail surface: - the generator serves today's cache, re-stamps unchanged readings without an LLM call, and regenerates only when the latest-reading fingerprint changes - the read-only endpoint returns the envelope, 422s a missing id, and honours the auth + insightStatus gates - the detail page paints the stat strip, the chart placeholder, and the assessment card - the biomarker resource route drops the marker with its readings in one userId-narrowed transaction and rejects an inverted target range Hold the input gate independent of the worker's forced run so an idle marker re-stamps cached text instead of regenerating, and list the new route on the non-Coach gate allowlist. --- .../biomarkers/[id]/__tests__/route.test.ts | 162 ++++++++++++ .../coach-route-gate-inventory.test.ts | 3 + .../__tests__/route.test.ts | 133 ++++++++++ .../__tests__/lab-biomarker-detail.test.tsx | 142 ++++++++++ .../__tests__/biomarker-status.test.ts | 249 ++++++++++++++++++ src/lib/insights/biomarker-status.ts | 12 +- 6 files changed, 699 insertions(+), 2 deletions(-) create mode 100644 src/app/api/biomarkers/[id]/__tests__/route.test.ts create mode 100644 src/app/api/insights/biomarker-assessment/__tests__/route.test.ts create mode 100644 src/components/labs/__tests__/lab-biomarker-detail.test.tsx create mode 100644 src/lib/insights/__tests__/biomarker-status.test.ts diff --git a/src/app/api/biomarkers/[id]/__tests__/route.test.ts b/src/app/api/biomarkers/[id]/__tests__/route.test.ts new file mode 100644 index 000000000..bb3f0a75d --- /dev/null +++ b/src/app/api/biomarkers/[id]/__tests__/route.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + biomarker: { findFirst: vi.fn(), update: vi.fn(), delete: vi.fn() }, + labResult: { deleteMany: vi.fn() }, + auditLog: { create: vi.fn().mockResolvedValue({}) }, + $transaction: vi.fn(async (ops: Promise[]) => Promise.all(ops)), + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/labs/biomarker-store", () => ({ + encryptContextToBytes: vi.fn(() => new Uint8Array([1])), + decryptContextFromBytes: vi.fn(() => "ctx"), +})); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { PUT, DELETE } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { prisma } from "@/lib/db"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "u", role: "USER" as const, locale: "en" }, +}; + +const EXISTING = { + id: "bm-1", + userId: "user-1", + name: "LDL", + unit: "mg/dL", + lowerBound: 0, + upperBound: 100, + panel: null, + hidden: false, + contextEncrypted: null, + createdAt: new Date("2026-06-01T00:00:00.000Z"), + updatedAt: new Date("2026-06-01T00:00:00.000Z"), +}; + +const params = (id: string) => ({ params: Promise.resolve({ id }) }); + +function putReq(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/biomarkers/bm-1", { + method: "PUT", + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + }); +} + +function delReq(): NextRequest { + return new NextRequest("http://localhost/api/biomarkers/bm-1", { + method: "DELETE", + }); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); + vi.mocked(prisma.$transaction).mockImplementation((async ( + ops: Promise[], + ) => Promise.all(ops)) as never); +}); + +describe("DELETE /api/biomarkers/[id]", () => { + it("drops the marker and its readings in one userId-narrowed transaction", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.biomarker.findFirst).mockResolvedValue(EXISTING as never); + vi.mocked(prisma.labResult.deleteMany).mockResolvedValue({ + count: 3, + } as never); + vi.mocked(prisma.biomarker.delete).mockResolvedValue(EXISTING as never); + + const res = await DELETE(delReq(), params("bm-1")); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { deleted: boolean } | null }; + expect(body.data?.deleted).toBe(true); + + expect(prisma.labResult.deleteMany).toHaveBeenCalledWith({ + where: { userId: "user-1", biomarkerId: "bm-1" }, + }); + expect(prisma.biomarker.delete).toHaveBeenCalledWith({ + where: { id: "bm-1" }, + }); + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + }); + + it("404s a cross-user marker without deleting anything", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.biomarker.findFirst).mockResolvedValue({ + ...EXISTING, + userId: "other-user", + } as never); + + const res = await DELETE(delReq(), params("bm-1")); + expect(res.status).toBe(404); + expect(prisma.labResult.deleteMany).not.toHaveBeenCalled(); + expect(prisma.biomarker.delete).not.toHaveBeenCalled(); + }); +}); + +describe("PUT /api/biomarkers/[id] — target range", () => { + it("persists an updated reference window", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.biomarker.findFirst).mockResolvedValue(EXISTING as never); + vi.mocked(prisma.biomarker.update).mockResolvedValue({ + ...EXISTING, + lowerBound: 10, + upperBound: 50, + } as never); + + const res = await PUT( + putReq({ lowerBound: 10, upperBound: 50 }), + params("bm-1"), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { lowerBound: number; upperBound: number } | null; + }; + expect(body.data?.lowerBound).toBe(10); + expect(body.data?.upperBound).toBe(50); + + const updateCall = vi.mocked(prisma.biomarker.update).mock.calls[0][0] as { + data: Record; + }; + expect(updateCall.data.lowerBound).toBe(10); + expect(updateCall.data.upperBound).toBe(50); + }); + + it("422s an inverted target range without updating", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.biomarker.findFirst).mockResolvedValue(EXISTING as never); + + const res = await PUT( + putReq({ lowerBound: 80, upperBound: 20 }), + params("bm-1"), + ); + expect(res.status).toBe(422); + expect(prisma.biomarker.update).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts b/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts index 939ff22f8..c21fb2bd9 100644 --- a/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts +++ b/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts @@ -35,6 +35,9 @@ import { describe, expect, it } from "vitest"; const NON_COACH_GATED_ROUTES: ReadonlyArray = [ // Non-Coach assistant surfaces — gated on a sibling sub-flag. + // Per-biomarker assessment. Gated on the same `insightStatus` sub-flag as + // the metric-status + specialised status routes (no Coach prose). + "src/app/api/insights/biomarker-assessment/route.ts", "src/app/api/insights/blood-pressure-status/route.ts", "src/app/api/insights/bmi-status/route.ts", "src/app/api/insights/cards/route.ts", diff --git a/src/app/api/insights/biomarker-assessment/__tests__/route.test.ts b/src/app/api/insights/biomarker-assessment/__tests__/route.test.ts new file mode 100644 index 000000000..49f419399 --- /dev/null +++ b/src/app/api/insights/biomarker-assessment/__tests__/route.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + appSettings: { findUnique: vi.fn().mockResolvedValue(null) }, + }, +})); + +// The route resolves the `insights` module gate after `requireAuth()`. Mock +// it default-enabled so the assertions ride through; the off → 403 coverage +// lives in the route-gate inventory test. +vi.mock("@/lib/modules/gate", async (importOriginal) => ({ + ...(await importOriginal()), + requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), + resolveModuleMap: vi.fn().mockResolvedValue({}), +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +// Stub the heavy generator so the route test stays isolated from the real +// status pipeline. +vi.mock("@/lib/insights/biomarker-status", () => ({ + generateBiomarkerStatus: vi.fn(async () => ({ + hasProvider: true, + text: "ok", + cached: true, + updatedAt: new Date().toISOString(), + })), +})); + +vi.mock("@/lib/insights/metric-status", () => ({ + resolveMetricStatusLocale: () => "en", +})); + +import { GET } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { prisma } from "@/lib/db"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { generateBiomarkerStatus } from "@/lib/insights/biomarker-status"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "testuser", + role: "USER" as const, + locale: "en", + }, +}; + +const callGet = GET as unknown as (req: NextRequest) => Promise; +function makeReq(biomarkerId?: string): NextRequest { + const url = new URL("http://localhost/api/insights/biomarker-assessment"); + if (biomarkerId !== undefined) + url.searchParams.set("biomarkerId", biomarkerId); + return new NextRequest(url); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never); + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true }); +}); + +describe("GET /api/insights/biomarker-assessment", () => { + it("returns 200 + envelope and calls the generator read-only", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await callGet(makeReq("bm-1")); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { text: string; hasProvider: boolean } | null; + error: string | null; + }; + expect(body.error).toBeNull(); + expect(body.data?.text).toBe("ok"); + expect(generateBiomarkerStatus).toHaveBeenCalledWith( + expect.objectContaining({ + biomarkerId: "bm-1", + userId: "user-1", + readOnly: true, + }), + ); + }); + + it("422s on a missing biomarkerId without calling the generator", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await callGet(makeReq()); + expect(res.status).toBe(422); + expect(generateBiomarkerStatus).not.toHaveBeenCalled(); + }); + + it("401s when unauthenticated", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await callGet(makeReq("bm-1")); + expect(res.status).toBe(401); + }); + + it("403s + errorCode when insightStatus is disabled", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({ + assistantEnabled: true, + assistantCoachEnabled: true, + assistantBriefingEnabled: true, + assistantInsightStatusEnabled: false, + assistantCorrelationsEnabled: true, + assistantHealthScoreExplainerEnabled: true, + } as never); + const res = await callGet(makeReq("bm-1")); + expect(res.status).toBe(403); + const body = (await res.json()) as { meta?: { errorCode?: string } }; + expect(body.meta?.errorCode).toBe("assistant.disabled.insightStatus"); + }); +}); diff --git a/src/components/labs/__tests__/lab-biomarker-detail.test.tsx b/src/components/labs/__tests__/lab-biomarker-detail.test.tsx new file mode 100644 index 000000000..8c6b6ab0b --- /dev/null +++ b/src/components/labs/__tests__/lab-biomarker-detail.test.tsx @@ -0,0 +1,142 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import type { + BiomarkerDto, + LabResultDto, + LabResultListResponse, +} from "../types"; + +/** + * The per-biomarker detail page mirrors the metric sub-pages: a numbers-first + * stat strip over the numeric readings, the (lazy) chart, the history, and the + * AI assessment card. This test seeds the marker + reading queries and asserts + * the strip, the chart placeholder, and the assessment card all paint. + */ + +vi.mock("@/lib/api/api-fetch", () => ({ + apiGet: () => new Promise(() => {}), + apiDelete: vi.fn(), + apiPut: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn() }), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ isAuthenticated: true, user: { id: "u1" } }), +})); + +vi.mock("@/hooks/use-feature-flags", () => ({ + useFeatureFlags: () => ({ insightStatus: true }), +})); + +import { LabBiomarkerDetail } from "../lab-biomarker-detail"; + +const BIOMARKER_ID = "bm-1"; + +const MARKER: BiomarkerDto = { + id: BIOMARKER_ID, + name: "LDL Cholesterol", + unit: "mg/dL", + lowerBound: 0, + upperBound: 100, + panel: null, + hasContext: false, + context: null, + hidden: false, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", +}; + +function reading(id: string, value: number, takenAt: string): LabResultDto { + return { + id, + biomarkerId: BIOMARKER_ID, + panel: null, + analyte: "LDL Cholesterol", + value, + valueText: null, + unit: "mg/dL", + referenceLow: 0, + referenceHigh: 100, + takenAt, + source: "MANUAL", + hasNote: false, + rangeStatus: "in-range", + createdAt: takenAt, + updatedAt: takenAt, + }; +} + +const LIST: LabResultListResponse = { + results: [ + reading("r3", 95, "2026-06-20T08:00:00.000Z"), + reading("r2", 88, "2026-05-20T08:00:00.000Z"), + reading("r1", 102, "2026-04-20T08:00:00.000Z"), + ], + meta: { total: 3, limit: 500, offset: 0 }, +}; + +function renderWithCache() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + queryClient.setQueryData(queryKeys.biomarkerDetail(BIOMARKER_ID), MARKER); + queryClient.setQueryData( + queryKeys.labResultsList({ + biomarkerId: BIOMARKER_ID, + analyte: undefined, + panel: undefined, + from: undefined, + to: undefined, + page: 0, + sortDir: "desc", + }), + LIST, + ); + queryClient.setQueryData( + queryKeys.insightsBiomarkerAssessment(BIOMARKER_ID, "en"), + { + hasProvider: true, + text: "Your LDL is steady inside the reference range.", + cached: true, + updatedAt: "2026-06-20T09:00:00.000Z", + }, + ); + return renderToStaticMarkup( + + + + + , + ); +} + +describe("", () => { + const html = renderWithCache(); + + it("paints the marker heading", () => { + expect(html).toContain("LDL Cholesterol"); + }); + + it("paints the numbers-first stat strip", () => { + expect(html).toContain('data-slot="metric-stat-strip"'); + }); + + it("paints the chart placeholder (lazy chart shell)", () => { + // The recharts chart is `next/dynamic({ ssr: false })`; the server render + // paints the layout-stable skeleton shell in its place. + expect(html).toContain('data-slot="skeleton"'); + }); + + it("paints the assessment card", () => { + // useMounted() is false during SSR, so the card renders its loading + // skeleton geometry — proof the assessment card mounted on the page. + expect(html).toContain('data-testid="insight-status-card-loading"'); + }); +}); diff --git a/src/lib/insights/__tests__/biomarker-status.test.ts b/src/lib/insights/__tests__/biomarker-status.test.ts new file mode 100644 index 000000000..1c677c606 --- /dev/null +++ b/src/lib/insights/__tests__/biomarker-status.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + biomarker: { findFirst: vi.fn() }, + labResult: { findMany: vi.fn(), count: vi.fn() }, + auditLog: { findFirst: vi.fn(), create: vi.fn() }, + }, +})); + +vi.mock("@/lib/insights/status-provider", () => ({ + runStatusCompletion: vi.fn(), + // Consent never blocks in these fixtures — the gate has its own tests. + statusConsentBlocksGeneration: vi.fn(async () => false), +})); + +import { prisma } from "@/lib/db"; +import { runStatusCompletion } from "@/lib/insights/status-provider"; +import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; +import { PROMPT_VERSION } from "@/lib/ai/prompts/base-system"; +import { toBerlinDayKey } from "@/lib/tz/resolver"; +import { + generateBiomarkerStatus, + biomarkerStatusScope, +} from "../biomarker-status"; + +const MARKER = { + id: "bm-1", + name: "LDL Cholesterol", + unit: "mg/dL", + lowerBound: 0, + upperBound: 100, +}; + +const TAKEN_AT = new Date("2026-06-20T08:00:00.000Z"); + +function stubCompletion(content: string, capture?: { userPrompt: string }) { + vi.mocked(runStatusCompletion).mockImplementation( + async (args: { userPrompt: string }) => { + if (capture) capture.userPrompt = args.userPrompt; + return { + kind: "ok", + content, + providerType: "anthropic", + model: "x", + tokensUsed: 1, + } as never; + }, + ); +} + +/** The fingerprint the generator computes for a single-reading marker. */ +function inputHashFor(reading: { + id: string; + value: number; + takenAt: Date; +}): string { + return hashInsightSnapshot({ + scope: biomarkerStatusScope(MARKER.id), + locale: "en", + promptVersion: PROMPT_VERSION, + count: 1, + bounds: { lower: MARKER.lowerBound, upper: MARKER.upperBound }, + latest: { + id: reading.id, + takenAt: reading.takenAt.toISOString(), + value: reading.value, + }, + }); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(prisma.biomarker.findFirst).mockResolvedValue(MARKER as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: new Date("2026-06-20T09:00:00.000Z"), + } as never); +}); + +describe("generateBiomarkerStatus — empty-data guard", () => { + it("returns insufficient WITHOUT calling the provider for a marker with no numeric readings", async () => { + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never); + vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never); + + const result = await generateBiomarkerStatus({ + biomarkerId: MARKER.id, + userId: "u1", + locale: "en", + readOnly: true, + }); + + expect(result.insufficient).toBe(true); + expect(result.text).toBeNull(); + expect(runStatusCompletion).not.toHaveBeenCalled(); + expect(prisma.labResult.count).not.toHaveBeenCalled(); + }); + + it("returns insufficient for an unknown / cross-user marker", async () => { + vi.mocked(prisma.biomarker.findFirst).mockResolvedValue(null as never); + + const result = await generateBiomarkerStatus({ + biomarkerId: "nope", + userId: "u1", + locale: "en", + readOnly: true, + }); + + expect(result.insufficient).toBe(true); + expect(runStatusCompletion).not.toHaveBeenCalled(); + }); +}); + +describe("generateBiomarkerStatus — cache read", () => { + it("serves today's cached text without calling the provider", async () => { + const todayKey = toBerlinDayKey(new Date()); + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({ + createdAt: new Date(), + details: JSON.stringify({ + dateKey: todayKey, + locale: "en", + text: "Your LDL is steady.", + model: "x", + }), + } as never); + + const result = await generateBiomarkerStatus({ + biomarkerId: MARKER.id, + userId: "u1", + locale: "en", + }); + + expect(result.cached).toBe(true); + expect(result.text).toBe("Your LDL is steady."); + expect(runStatusCompletion).not.toHaveBeenCalled(); + // Cache hit short-circuits before the reading read. + expect(prisma.labResult.findMany).not.toHaveBeenCalled(); + }); +}); + +describe("generateBiomarkerStatus — generation path", () => { + it("builds a snapshot, runs the completion, and persists with an inputHash", async () => { + const reading = { id: "r1", value: 95, takenAt: TAKEN_AT }; + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never); + vi.mocked(prisma.labResult.findMany).mockResolvedValue([reading] as never); + vi.mocked(prisma.labResult.count).mockResolvedValue(1 as never); + + const capture = { userPrompt: "" }; + stubCompletion( + '{"summary":"Your LDL sits inside the reference range."}', + capture, + ); + + const result = await generateBiomarkerStatus({ + biomarkerId: MARKER.id, + userId: "u1", + locale: "en", + }); + + expect(runStatusCompletion).toHaveBeenCalledTimes(1); + expect(result.text).toBe("Your LDL sits inside the reference range."); + expect(result.cached).toBe(false); + expect(result.hasProvider).toBe(true); + + // The snapshot carries the marker metadata + the latest reading. + const match = capture.userPrompt.match(/\{[\s\S]*\}/); + const snapshot = JSON.parse(match![0]); + expect(snapshot.marker.name).toBe("LDL Cholesterol"); + expect(snapshot.marker.unit).toBe("mg/dL"); + expect(snapshot.latest.value).toBe(95); + + // Persisted under the biomarker scope cache action, carrying the inputHash. + const createCall = vi + .mocked(prisma.auditLog.create) + .mock.calls.at(-1)![0] as { + data: { action: string; details: string }; + }; + expect(createCall.data.action).toBe("insights.biomarker:bm-1-status.en"); + const persisted = JSON.parse(createCall.data.details); + expect(persisted.inputHash).toBe(inputHashFor(reading)); + }); +}); + +describe("generateBiomarkerStatus — input gate (regenerate only on a new reading)", () => { + it("re-stamps the cached text WITHOUT calling the provider when the latest reading is unchanged", async () => { + const reading = { id: "r1", value: 95, takenAt: TAKEN_AT }; + // A prior assessment from an earlier day whose inputHash matches the + // current latest reading — readFreshStatusText misses on the day key, but + // the input gate matches the fingerprint. + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({ + createdAt: new Date(), + details: JSON.stringify({ + dateKey: "2000-01-01", + locale: "en", + text: "Stable LDL from a previous reading.", + model: "x", + inputHash: inputHashFor(reading), + }), + } as never); + vi.mocked(prisma.labResult.findMany).mockResolvedValue([reading] as never); + vi.mocked(prisma.labResult.count).mockResolvedValue(1 as never); + + const result = await generateBiomarkerStatus({ + biomarkerId: MARKER.id, + userId: "u1", + locale: "en", + }); + + expect(runStatusCompletion).not.toHaveBeenCalled(); + expect(result.cached).toBe(true); + expect(result.text).toBe("Stable LDL from a previous reading."); + }); + + it("regenerates when a new reading lands (inputHash differs)", async () => { + const oldReading = { id: "r1", value: 95, takenAt: TAKEN_AT }; + const newReading = { + id: "r2", + value: 130, + takenAt: new Date("2026-06-25T08:00:00.000Z"), + }; + // The cached row's inputHash is for the OLD reading; the live latest is the + // new reading, so the fingerprint differs and the gate misses. + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({ + createdAt: new Date(), + details: JSON.stringify({ + dateKey: "2000-01-01", + locale: "en", + text: "Stale assessment.", + model: "x", + inputHash: inputHashFor(oldReading), + }), + } as never); + vi.mocked(prisma.labResult.findMany).mockResolvedValue([ + newReading, + ] as never); + vi.mocked(prisma.labResult.count).mockResolvedValue(1 as never); + + stubCompletion('{"summary":"LDL rose above the reference range."}'); + + const result = await generateBiomarkerStatus({ + biomarkerId: MARKER.id, + userId: "u1", + locale: "en", + }); + + expect(runStatusCompletion).toHaveBeenCalledTimes(1); + expect(result.text).toBe("LDL rose above the reference range."); + expect(result.cached).toBe(false); + }); +}); diff --git a/src/lib/insights/biomarker-status.ts b/src/lib/insights/biomarker-status.ts index e39e547ed..39e867291 100644 --- a/src/lib/insights/biomarker-status.ts +++ b/src/lib/insights/biomarker-status.ts @@ -231,7 +231,15 @@ export async function generateBiomarkerStatus(args: { // stored `inputHash`, nothing the prompt could see has changed since the // last reading, so re-stamp that text under today's day key and skip the // LLM entirely. A new reading flips the latest id / value / count and - // forces a real regeneration. + // forces a real regeneration — so the assessment regenerates ONLY on a new + // reading. + // + // The gate runs with `force: false` even on the worker's forced path: the + // outer `force` exists to bypass the SAME-DAY text cache (so a fresh day + // re-evaluates the fingerprint), not to burn an LLM call on an unchanged + // marker. The worker is re-enqueued whenever today's text cache misses, so + // letting the input gate run keeps an idle marker to one cheap re-stamp per + // day instead of a daily regeneration. const inputHash = hashInsightSnapshot({ scope, locale, @@ -249,7 +257,7 @@ export async function generateBiomarkerStatus(args: { cacheAction, todayKey, inputHash, - force, + force: false, }); if (unchanged) { return { From 1cb7f0933c0a40eb457bf792a24f52a64b09ecdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 14:00:55 +0200 Subject: [PATCH 30/94] test(labs): integration coverage for biomarker target-range and delete Drive the biomarker resource route against a real Postgres: PUT persists a new reference window and rejects an inverted one, and DELETE drops the marker together with its readings while leaving another user's marker and readings intact. --- tests/integration/biomarker-detail.test.ts | 172 +++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 tests/integration/biomarker-detail.test.ts diff --git a/tests/integration/biomarker-detail.test.ts b/tests/integration/biomarker-detail.test.ts new file mode 100644 index 000000000..318fc99cc --- /dev/null +++ b/tests/integration/biomarker-detail.test.ts @@ -0,0 +1,172 @@ +/** + * Integration suite for the biomarker resource route + * (`/api/biomarkers/{id}`) against a real Postgres. + * + * Covers the two detail-page mutations: + * - PUT adjusts the target range (and rejects an inverted window), + * - DELETE drops the marker together with its readings in one + * userId-narrowed transaction, leaving another user's marker untouched. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const USER_ID = "user-biomarker-detail-test"; +const OTHER_USER_ID = "user-biomarker-other-test"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + const prisma = getPrismaClient(); + await prisma.user.createMany({ + data: [ + { id: USER_ID, username: "bm-detail", email: "bm-detail@example.test" }, + { + id: OTHER_USER_ID, + username: "bm-other", + email: "bm-other@example.test", + }, + ], + }); + const session = await prisma.session.create({ + data: { userId: USER_ID, expiresAt: new Date(Date.now() + 60 * 60 * 1000) }, + }); + cookieJar.set("healthlog_session", session.id); +}); + +const params = (id: string) => ({ params: Promise.resolve({ id }) }); + +function putReq(id: string, body: unknown): NextRequest { + return new NextRequest(`http://localhost/api/biomarkers/${id}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +function delReq(id: string): NextRequest { + return new NextRequest(`http://localhost/api/biomarkers/${id}`, { + method: "DELETE", + }); +} + +async function seedMarkerWithReadings(userId: string, name: string) { + const prisma = getPrismaClient(); + const marker = await prisma.biomarker.create({ + data: { userId, name, unit: "mg/dL", lowerBound: 0, upperBound: 100 }, + }); + await prisma.labResult.createMany({ + data: [ + { + userId, + biomarkerId: marker.id, + analyte: name, + unit: "mg/dL", + value: 95, + takenAt: new Date("2026-06-20T08:00:00.000Z"), + }, + { + userId, + biomarkerId: marker.id, + analyte: name, + unit: "mg/dL", + value: 88, + takenAt: new Date("2026-05-20T08:00:00.000Z"), + }, + ], + }); + return marker; +} + +describe("PUT /api/biomarkers/{id} — target range (real Postgres)", () => { + it("persists an updated reference window", async () => { + const { PUT } = await import("@/app/api/biomarkers/[id]/route"); + const marker = await seedMarkerWithReadings(USER_ID, "LDL"); + + const res = await PUT( + putReq(marker.id, { lowerBound: 10, upperBound: 50 }), + params(marker.id), + ); + expect(res.status).toBe(200); + + const row = await getPrismaClient().biomarker.findUnique({ + where: { id: marker.id }, + }); + expect(row?.lowerBound).toBe(10); + expect(row?.upperBound).toBe(50); + }); + + it("422s an inverted target range and leaves the bounds untouched", async () => { + const { PUT } = await import("@/app/api/biomarkers/[id]/route"); + const marker = await seedMarkerWithReadings(USER_ID, "HbA1c"); + + const res = await PUT( + putReq(marker.id, { lowerBound: 80, upperBound: 20 }), + params(marker.id), + ); + expect(res.status).toBe(422); + + const row = await getPrismaClient().biomarker.findUnique({ + where: { id: marker.id }, + }); + expect(row?.lowerBound).toBe(0); + expect(row?.upperBound).toBe(100); + }); +}); + +describe("DELETE /api/biomarkers/{id} (real Postgres)", () => { + it("drops the marker together with its readings", async () => { + const { DELETE } = await import("@/app/api/biomarkers/[id]/route"); + const marker = await seedMarkerWithReadings(USER_ID, "Ferritin"); + + const res = await DELETE(delReq(marker.id), params(marker.id)); + expect(res.status).toBe(200); + + const prisma = getPrismaClient(); + expect(await prisma.biomarker.count({ where: { id: marker.id } })).toBe(0); + expect( + await prisma.labResult.count({ where: { biomarkerId: marker.id } }), + ).toBe(0); + }); + + it("404s another user's marker and leaves it (and its readings) intact", async () => { + const { DELETE } = await import("@/app/api/biomarkers/[id]/route"); + const foreign = await seedMarkerWithReadings(OTHER_USER_ID, "TSH"); + + const res = await DELETE(delReq(foreign.id), params(foreign.id)); + expect(res.status).toBe(404); + + const prisma = getPrismaClient(); + expect(await prisma.biomarker.count({ where: { id: foreign.id } })).toBe(1); + expect( + await prisma.labResult.count({ where: { biomarkerId: foreign.id } }), + ).toBe(2); + }); +}); From 794d598150f824df94fb61279ba003a911f1247b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 15:12:02 +0200 Subject: [PATCH 31/94] feat(signals): add the signal-registry backbone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce one declarative table describing each health signal across the surfaces it touches — DB measurement type, detail page, correlation engine, Coach snapshot, MCP read tool, and FHIR coding — discriminated on kind (measurement / biomarker / score). Promote the proto-registry in metric-status-registry.ts to the canonical definition and add thin adapters that derive the per-surface tables from it. The registry coexists with the enum-driven code: the DB enum stays the source of truth for persistence and the registry sits above it. An invariant test pins every derived table byte-for-byte to the values the hand-maintained tables carry today, so cross-surface drift fails CI. --- .../fixtures/expected-bucketed-types.json | 9 + .../fixtures/expected-measurement-loinc.json | 218 +++ .../__tests__/registry-invariants.test.ts | 99 ++ src/lib/signals/adapters/correlation.ts | 21 + src/lib/signals/adapters/fhir.ts | 29 + src/lib/signals/adapters/metric-status.ts | 44 + src/lib/signals/registry.ts | 1234 +++++++++++++++++ 7 files changed, 1654 insertions(+) create mode 100644 src/lib/signals/__tests__/fixtures/expected-bucketed-types.json create mode 100644 src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json create mode 100644 src/lib/signals/__tests__/registry-invariants.test.ts create mode 100644 src/lib/signals/adapters/correlation.ts create mode 100644 src/lib/signals/adapters/fhir.ts create mode 100644 src/lib/signals/adapters/metric-status.ts create mode 100644 src/lib/signals/registry.ts diff --git a/src/lib/signals/__tests__/fixtures/expected-bucketed-types.json b/src/lib/signals/__tests__/fixtures/expected-bucketed-types.json new file mode 100644 index 000000000..cd5e00bb4 --- /dev/null +++ b/src/lib/signals/__tests__/fixtures/expected-bucketed-types.json @@ -0,0 +1,9 @@ +[ + "WEIGHT", + "BLOOD_PRESSURE_SYS", + "BLOOD_PRESSURE_DIA", + "PULSE", + "BODY_FAT", + "SLEEP_DURATION", + "ACTIVITY_STEPS" +] diff --git a/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json b/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json new file mode 100644 index 000000000..ef811a6a1 --- /dev/null +++ b/src/lib/signals/__tests__/fixtures/expected-measurement-loinc.json @@ -0,0 +1,218 @@ +{ + "WEIGHT": { + "loinc": "29463-7", + "display": "Body weight", + "unit": "kg", + "category": "vital-signs" + }, + "BODY_MASS_INDEX": { + "loinc": "39156-5", + "display": "Body mass index (BMI) [Ratio]", + "unit": "kg/m2", + "category": "vital-signs" + }, + "PULSE": { + "loinc": "8867-4", + "display": "Heart rate", + "unit": "/min", + "category": "vital-signs" + }, + "RESTING_HEART_RATE": { + "loinc": "40443-4", + "display": "Heart rate --resting", + "unit": "/min", + "category": "vital-signs" + }, + "RESPIRATORY_RATE": { + "loinc": "9279-1", + "display": "Respiratory rate", + "unit": "/min", + "category": "vital-signs" + }, + "BODY_TEMPERATURE": { + "loinc": "8310-5", + "display": "Body temperature", + "unit": "Cel", + "category": "vital-signs" + }, + "OXYGEN_SATURATION": { + "loinc": "59408-5", + "display": "Oxygen saturation in Arterial blood by Pulse oximetry", + "unit": "%", + "category": "vital-signs" + }, + "BODY_FAT": { + "loinc": "41982-0", + "display": "Percentage of body fat Measured", + "unit": "%", + "category": "vital-signs" + }, + "VO2_MAX": { + "loinc": "96402-2", + "display": "Oxygen consumption maximum during exercise", + "unit": "mL/min/kg", + "category": "vital-signs" + }, + "HEART_RATE_VARIABILITY": { + "loinc": "80404-7", + "display": "R-R interval.standard deviation (Heart rate variability)", + "unit": "ms", + "category": "vital-signs" + }, + "ACTIVITY_STEPS": { + "loinc": "41950-7", + "display": "Number of steps in 24 hour Measured", + "unit": "{steps}", + "category": "activity" + }, + "SLEEP_DURATION": { + "loinc": "93832-4", + "display": "Sleep duration", + "unit": "h", + "category": "activity" + }, + "ACTIVE_ENERGY_BURNED": { + "loinc": "41981-2", + "display": "Calories burned", + "unit": "kcal", + "category": "activity" + }, + "WALKING_SPEED": { + "loinc": "41957-2", + "display": "Gait speed [Velocity] Measured", + "unit": "m/s", + "category": "vital-signs" + }, + "WALKING_ASYMMETRY": { + "loinc": "91557-1", + "display": "Walking asymmetry percentage", + "unit": "%", + "category": "vital-signs" + }, + "WALKING_STEP_LENGTH": { + "loinc": "41955-6", + "display": "Step length Measured", + "unit": "m", + "category": "vital-signs" + }, + "TOTAL_BODY_WATER": { + "loinc": "73704-9", + "display": "Body water by Bioelectrical impedance analysis", + "unit": "kg", + "category": "vital-signs" + }, + "BONE_MASS": { + "loinc": "73708-0", + "display": "Bone mineral content by DXA", + "unit": "kg", + "category": "vital-signs" + }, + "WALKING_DOUBLE_SUPPORT": { + "loinc": "HKQuantityTypeIdentifierWalkingDoubleSupportPercentage", + "display": "Walking double support percentage", + "unit": "%", + "category": "vital-signs" + }, + "AUDIO_EXPOSURE_ENV": { + "loinc": "HKQuantityTypeIdentifierEnvironmentalAudioExposure", + "display": "Environmental audio exposure", + "unit": "dB[A]", + "category": "vital-signs" + }, + "AUDIO_EXPOSURE_HEADPHONE": { + "loinc": "HKQuantityTypeIdentifierHeadphoneAudioExposure", + "display": "Headphone audio exposure", + "unit": "dB[A]", + "category": "vital-signs" + }, + "FLIGHTS_CLIMBED": { + "loinc": "HKQuantityTypeIdentifierFlightsClimbed", + "display": "Flights climbed", + "unit": "{flights}", + "category": "activity" + }, + "WALKING_RUNNING_DISTANCE": { + "loinc": "HKQuantityTypeIdentifierDistanceWalkingRunning", + "display": "Distance walking/running", + "unit": "m", + "category": "activity" + }, + "TIME_IN_DAYLIGHT": { + "loinc": "HKQuantityTypeIdentifierTimeInDaylight", + "display": "Time in daylight", + "unit": "min", + "category": "activity" + }, + "MUSCLE_MASS": { + "loinc": null, + "display": "Muscle mass", + "unit": "kg", + "category": "vital-signs" + }, + "FAT_MASS": { + "loinc": null, + "display": "Fat mass", + "unit": "kg", + "category": "vital-signs" + }, + "FAT_FREE_MASS": { + "loinc": null, + "display": "Fat-free mass", + "unit": "kg", + "category": "vital-signs" + }, + "LEAN_BODY_MASS": { + "loinc": null, + "display": "Lean body mass", + "unit": "kg", + "category": "vital-signs" + }, + "VISCERAL_FAT": { + "loinc": null, + "display": "Visceral fat", + "unit": "1", + "category": "vital-signs" + }, + "CARDIO_RECOVERY": { + "loinc": "HKQuantityTypeIdentifierHeartRateRecoveryOneMinute", + "display": "Cardio recovery (1-minute heart-rate recovery)", + "unit": "/min", + "category": "vital-signs" + }, + "WRIST_TEMPERATURE": { + "loinc": "HKQuantityTypeIdentifierAppleSleepingWristTemperature", + "display": "Sleeping wrist temperature", + "unit": "Cel", + "category": "vital-signs" + }, + "FALL_COUNT": { + "loinc": "HKQuantityTypeIdentifierNumberOfTimesFallen", + "display": "Number of times fallen", + "unit": "{falls}", + "category": "activity" + }, + "SIX_MINUTE_WALK_DISTANCE": { + "loinc": "HKQuantityTypeIdentifierSixMinuteWalkTestDistance", + "display": "Six-minute walk test distance", + "unit": "m", + "category": "activity" + }, + "STAIR_ASCENT_SPEED": { + "loinc": "HKQuantityTypeIdentifierStairAscentSpeed", + "display": "Stair ascent speed", + "unit": "m/s", + "category": "vital-signs" + }, + "STAIR_DESCENT_SPEED": { + "loinc": "HKQuantityTypeIdentifierStairDescentSpeed", + "display": "Stair descent speed", + "unit": "m/s", + "category": "vital-signs" + }, + "BREATHING_DISTURBANCES": { + "loinc": "HKQuantityTypeIdentifierAppleSleepingBreathingDisturbances", + "display": "Sleeping breathing disturbances", + "unit": "{count}", + "category": "vital-signs" + } +} diff --git a/src/lib/signals/__tests__/registry-invariants.test.ts b/src/lib/signals/__tests__/registry-invariants.test.ts new file mode 100644 index 000000000..d1538ba12 --- /dev/null +++ b/src/lib/signals/__tests__/registry-invariants.test.ts @@ -0,0 +1,99 @@ +/** + * Registry invariants — the drift guard that pins the SIGNAL REGISTRY to the + * values the per-surface tables carry today. Any edit that changes a derived + * table (FHIR coding, correlation eligibility, metric-status metadata) without + * intentionally updating the golden master fails here, so a registry flip stays + * a byte-for-byte no-op and accidental cross-surface drift cannot land. + * + * Golden masters under `./fixtures/*` were extracted verbatim from the + * hand-maintained tables at the v1.25 branch base; update them deliberately + * (and in lock-step with the registry) when a value genuinely changes. + */ +import { describe, expect, it } from "vitest"; + +import { + METRIC_STATUS_IDS, + getMetricStatusMeta, +} from "@/lib/insights/metric-status-registry"; +import { MEASUREMENT_LOINC } from "@/lib/fhir/loinc-map"; +import { + SIGNALS, + allSignals, + getSignal, + signalForMeasurementType, +} from "@/lib/signals/registry"; +import { deriveMetricStatusRegistry } from "@/lib/signals/adapters/metric-status"; +import { deriveMeasurementLoinc } from "@/lib/signals/adapters/fhir"; +import { deriveBucketedTypes } from "@/lib/signals/adapters/correlation"; + +import expectedMeasurementLoinc from "./fixtures/expected-measurement-loinc.json"; +import expectedBucketedTypes from "./fixtures/expected-bucketed-types.json"; + +describe("signal registry — structural sanity", () => { + it("every entry's key matches its record key", () => { + for (const [key, signal] of Object.entries(SIGNALS)) { + expect(signal.key).toBe(key); + } + }); + + it("every signal carries a well-formed source for its kind", () => { + for (const signal of allSignals()) { + if (signal.kind === "biomarker") { + expect(signal.source.biomarkerKey.length).toBeGreaterThan(0); + } else { + expect(signal.source.measurementType.length).toBeGreaterThan(0); + } + } + }); + + it("mcp exposure follows Coach-snapshot inclusion", () => { + // The MCP get_metric_series enum is the Coach scope vocabulary, so a signal + // is MCP-readable iff it carries a Coach scope (and vice-versa). + for (const signal of allSignals()) { + const hasScope = signal.surfaces.coachSnapshot !== false; + expect(signal.surfaces.mcp).toBe(hasScope); + } + }); + + it("signalForMeasurementType round-trips every measurement/score signal", () => { + for (const signal of allSignals()) { + if (signal.kind === "biomarker") continue; + expect(signalForMeasurementType(signal.source.measurementType)).toBe( + signal, + ); + } + }); +}); + +describe("signal registry — metric-status projection equals the live table", () => { + it("registers every generic metric-status id", () => { + for (const id of METRIC_STATUS_IDS) { + expect(getSignal(id)).not.toBeNull(); + } + }); + + it("derives each MetricStatusMeta byte-for-byte from the registry", () => { + const derived = deriveMetricStatusRegistry(); + for (const id of METRIC_STATUS_IDS) { + expect(derived[id]).toEqual(getMetricStatusMeta(id)); + } + }); +}); + +describe("signal registry — FHIR table is byte-identical", () => { + it("derives MEASUREMENT_LOINC equal to the golden master", () => { + expect(deriveMeasurementLoinc()).toEqual(expectedMeasurementLoinc); + }); + + it("the live (flipped) MEASUREMENT_LOINC export equals the golden master", () => { + expect(MEASUREMENT_LOINC).toEqual(expectedMeasurementLoinc); + }); +}); + +describe("signal registry — correlation table is byte-identical", () => { + it("derives BUCKETED_TYPES equal to the golden master (set semantics)", () => { + expect([...deriveBucketedTypes()].sort()).toEqual( + [...(expectedBucketedTypes as string[])].sort(), + ); + }); +}); diff --git a/src/lib/signals/adapters/correlation.ts b/src/lib/signals/adapters/correlation.ts new file mode 100644 index 000000000..777399d0a --- /dev/null +++ b/src/lib/signals/adapters/correlation.ts @@ -0,0 +1,21 @@ +/** + * Adapter: derive the correlation engine's bucketed-type list from the + * registry. Replaces the hand-maintained `BUCKETED_TYPES` literal in + * `features.ts` — every signal flagged `surfaces.correlationEligible` projects + * to its DB `MeasurementType`. The list is consumed as a membership/iteration + * set (each type is read independently), so order is not semantically + * significant; the registry-invariant test compares it as a set. + */ +import type { MeasurementType } from "@/generated/prisma/client"; +import { allSignals } from "@/lib/signals/registry"; + +/** The registry-derived correlation-eligible `MeasurementType` list. */ +export function deriveBucketedTypes(): MeasurementType[] { + const types: MeasurementType[] = []; + for (const signal of allSignals()) { + if (signal.kind === "biomarker") continue; + if (!signal.surfaces.correlationEligible) continue; + types.push(signal.source.measurementType); + } + return types; +} diff --git a/src/lib/signals/adapters/fhir.ts b/src/lib/signals/adapters/fhir.ts new file mode 100644 index 000000000..1daafb530 --- /dev/null +++ b/src/lib/signals/adapters/fhir.ts @@ -0,0 +1,29 @@ +/** + * Adapter: derive the single-value `MeasurementType → FHIR coding` table from + * the registry. Replaces the hand-maintained `MEASUREMENT_LOINC` literal in + * `loinc-map.ts` — every signal that carries a `fhir` facet projects to one + * entry keyed by its DB `MeasurementType`. Signals without a `fhir` facet are + * absent (their value is surfaced as a local text concept elsewhere, or handled + * by a dedicated builder, e.g. BP panel / glucose context). + */ +import type { LoincMapping } from "@/lib/fhir/loinc-map"; +import { allSignals } from "@/lib/signals/registry"; + +/** The registry-derived `MEASUREMENT_LOINC`, keyed by `MeasurementType` string. */ +export function deriveMeasurementLoinc(): Record { + const entries: Array<[string, LoincMapping]> = []; + for (const signal of allSignals()) { + if (signal.kind === "biomarker") continue; + if (!signal.fhir) continue; + entries.push([ + signal.source.measurementType, + { + loinc: signal.fhir.loinc, + display: signal.fhir.display, + unit: signal.fhir.unit, + category: signal.fhir.category, + }, + ]); + } + return Object.fromEntries(entries); +} diff --git a/src/lib/signals/adapters/metric-status.ts b/src/lib/signals/adapters/metric-status.ts new file mode 100644 index 000000000..2470a6733 --- /dev/null +++ b/src/lib/signals/adapters/metric-status.ts @@ -0,0 +1,44 @@ +/** + * Adapter: project the registry back to the `MetricStatusMeta` shape the + * generic assessment path consumes today. The hand-written `REGISTRY` in + * `metric-status-registry.ts` stays the live source for the assessment card + * (untouched in this change); this projection exists so the registry-invariant + * test can assert the two agree field-for-field and flag any future drift. + */ +import { + METRIC_STATUS_IDS, + type MetricStatusMeta, + type MetricStatusMetricId, +} from "@/lib/insights/metric-status-registry"; +import { getSignal } from "@/lib/signals/registry"; + +/** Build the `MetricStatusMeta` for one registered metric-status id. */ +function metaFor(id: MetricStatusMetricId): MetricStatusMeta { + const signal = getSignal(id); + if (!signal || signal.kind === "biomarker") { + throw new Error(`signal registry: no measurement signal for "${id}"`); + } + // Build field-by-field, omitting the optional keys when absent so the + // projection matches the hand-written object shape exactly (deepEqual). + const meta: MetricStatusMeta = { + id, + measurementType: signal.source.measurementType, + displayName: signal.displayName, + unit: signal.unit, + direction: signal.direction, + archetype: signal.archetype, + }; + if (signal.normalRange !== undefined) meta.normalRange = signal.normalRange; + if (signal.feverBandC !== undefined) meta.feverBandC = signal.feverBandC; + return meta; +} + +/** The full registry-derived metric-status table, keyed by metric-status id. */ +export function deriveMetricStatusRegistry(): Record< + MetricStatusMetricId, + MetricStatusMeta +> { + return Object.fromEntries( + METRIC_STATUS_IDS.map((id) => [id, metaFor(id)]), + ) as Record; +} diff --git a/src/lib/signals/registry.ts b/src/lib/signals/registry.ts new file mode 100644 index 000000000..4f26dda81 --- /dev/null +++ b/src/lib/signals/registry.ts @@ -0,0 +1,1234 @@ +/** + * v1.25 — the SIGNAL REGISTRY backbone. + * + * One declarative table that describes a health signal ACROSS every surface + * it touches (DB measurement type, detail page, correlation engine, Coach + * snapshot, MCP read tool, FHIR coding). Today a single signal is described + * 8–16 times in independent tables keyed by slightly different vocabularies, + * and nothing fails the build when they disagree. This registry promotes the + * proto-registry in `metric-status-registry.ts` to the canonical definition + * and lets the per-surface tables DERIVE from it (see `./adapters/*`) instead + * of being hand-maintained beside it. + * + * Coexistence, not replacement: the DB enum stays the source of truth for + * persistence. The registry sits ABOVE it. Surfaces are flipped one at a time + * to read from the registry; the registry-invariant test pins every derived + * table byte-for-byte to the values the hand-written tables carry today, so a + * flip is a no-op diff and future drift fails CI. + * + * Adding a NEW signal (the clinical-signals wave): append ONE + * `SignalDefinition` object below, add its i18n keys, and — for a + * `measurement`/`score` — one Prisma enum member + migration. The derived + * tables (FHIR, correlation, …) light up with no further per-surface edits. + */ +import type { MeasurementType } from "@/generated/prisma/client"; +import type { + MetricArchetype, + MetricDirection, + MetricNormalRange, +} from "@/lib/insights/metric-status-registry"; +import type { FhirObservationCategory } from "@/lib/fhir/loinc-map"; +import { FEVER_BAND_C } from "@/lib/clinical-floors"; + +/** Discriminator: a device/manual reading, a lab biomarker, or a computed score. */ +export type SignalKind = "measurement" | "biomarker" | "score"; + +/** Reuse the assessment registry's direction vocabulary verbatim. */ +export type SignalDirection = MetricDirection; + +/** Coarse population placement band; the user's own baseline still leads. */ +export type SignalBand = MetricNormalRange; + +/** + * FHIR R4 coding facet. Field names align with `loinc-map.ts`'s `LoincMapping` + * (`unit` is the UCUM string, also used as the `code`) so the FHIR adapter is a + * straight projection with no renaming. + */ +export interface SignalFhirCoding { + /** LOINC code, or null when no stable LOINC applies (local text fallback). */ + loinc: string | null; + display: string; + /** UCUM unit string (also used as the FHIR `code`). */ + unit: string; + category: FhirObservationCategory; +} + +/** Chart-overlay facet. Typed loosely (string overlayKey) to keep the backbone free of the dashboard-layout runtime dep. */ +export interface SignalChart { + overlayKey: string; + color?: string; + yAxisUnit?: string; + /** Display rescale (e.g. m/s → km/h); applied at the chart edge only. */ + valueScale?: number; +} + +/** + * Per-surface eligibility — declarative flags, not branches scattered across + * files. `coachSnapshot.scope` is the Coach scope-source token (typed as a + * string here so the backbone does not depend on the coach module; the coach + * flip will tighten it to `CoachScopeSource`). + */ +export interface SignalSurfaces { + /** Mount the generic HealthKit/biomarker detail template. */ + detailPage: boolean; + /** Feed the FDR correlation channels (⇒ `BUCKETED_TYPES`). */ + correlationEligible: boolean; + /** Coach snapshot inclusion + its scope token, or `false` to opt out. */ + coachSnapshot: false | { scope: string }; + /** Exposed to the MCP `get_metric_series` / search inventory. */ + mcp: boolean; +} + +interface SignalCommon { + /** Stable signal key — the ONE vocabulary every surface speaks. */ + key: string; + /** i18n key prefix resolving `.title`/`.description`/`.chartTitle`/`.emptyState.*`. Optional until the clinical-signals wave fills it per signal. */ + i18nPrefix?: string; + /** Stable English display name (the model localises its own prose). */ + displayName: string; + /** Canonical storage unit (matches the DB column semantics). */ + unit: string; + direction: SignalDirection; + archetype: MetricArchetype; + normalRange?: SignalBand; + /** Single-reading fever band line (°C) for temperature signals. */ + feverBandC?: number; + /** Richer clinical bands defer to a `reference-ranges.ts` entry by key. */ + referenceMetric?: string; + /** Cross-source dedup ladder key; omit ⇒ not cross-source-deduped. */ + sourcePriorityKey?: string; + chart?: SignalChart; + surfaces: SignalSurfaces; + fhir?: SignalFhirCoding; +} + +/** How a `measurement`/`score` signal is sourced from the DB enum. */ +export interface MeasurementSignalSource { + measurementType: MeasurementType; + /** Fallback measurement type (e.g. an SDNN→RMSSD HRV proxy). */ + fallbackMeasurementType?: MeasurementType; +} + +/** How a `biomarker` signal is sourced from the labs catalog. */ +export interface BiomarkerSignalSource { + biomarkerKey: string; +} + +/** + * The canonical cross-surface signal definition, discriminated on `kind`. + * `measurement` and `score` both resolve to a `MeasurementType`; `biomarker` + * resolves to a labs-catalog key. + */ +export type SignalDefinition = + | (SignalCommon & { kind: "measurement"; source: MeasurementSignalSource }) + | (SignalCommon & { kind: "score"; source: MeasurementSignalSource }) + | (SignalCommon & { kind: "biomarker"; source: BiomarkerSignalSource }); + +/** Convenience: build a Coach-snapshot facet from a scope token. */ +function coachScope(scope: string): { scope: string } { + return { scope }; +} + +/** + * The registry. Keyed by stable signal key. Generic HealthKit signals key by + * their `metric-status-registry` id (STEPS, RESTING_HEART_RATE, …); the six + * specialised signals (WEIGHT/BMI/PULSE/BODY_FAT/BP_SYS/BP_DIA) key by their + * `MeasurementType` and carry `detailPage:false` so the generic template never + * double-renders them — they still appear in the cross-surface indices (FHIR, + * correlation, Coach, MCP). + * + * Every value here is the value the hand-written per-surface table carries + * today; the registry-invariant test pins them byte-for-byte. + */ +export const SIGNALS: Record = { + // ── physiological-vital ────────────────────────────────────────────── + RESTING_HEART_RATE: { + key: "RESTING_HEART_RATE", + kind: "measurement", + source: { measurementType: "RESTING_HEART_RATE" }, + sourcePriorityKey: "restingHeartRate", + i18nPrefix: "insights.restingHr", + displayName: "Resting heart rate", + unit: "bpm", + direction: "lower-better", + archetype: "physiological-vital", + normalRange: { low: 50, high: 100 }, + referenceMetric: "RESTING_HEART_RATE", + chart: { overlayKey: "restingHr", color: "#ff5555", yAxisUnit: "bpm" }, + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("resting_hr"), + mcp: true, + }, + fhir: { + loinc: "40443-4", + display: "Heart rate --resting", + unit: "/min", + category: "vital-signs", + }, + }, + HEART_RATE_VARIABILITY: { + key: "HEART_RATE_VARIABILITY", + kind: "measurement", + source: { measurementType: "HEART_RATE_VARIABILITY" }, + sourcePriorityKey: "hrv", + displayName: "Heart-rate variability (SDNN)", + unit: "ms", + direction: "higher-better", + archetype: "physiological-vital", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("hrv"), + mcp: true, + }, + fhir: { + loinc: "80404-7", + display: "R-R interval.standard deviation (Heart rate variability)", + unit: "ms", + category: "vital-signs", + }, + }, + OXYGEN_SATURATION: { + key: "OXYGEN_SATURATION", + kind: "measurement", + source: { measurementType: "OXYGEN_SATURATION" }, + sourcePriorityKey: "spo2", + displayName: "Blood oxygen (SpO₂)", + unit: "%", + direction: "higher-better", + archetype: "physiological-vital", + normalRange: { low: 95, high: 100 }, + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("spo2"), + mcp: true, + }, + fhir: { + loinc: "59408-5", + display: "Oxygen saturation in Arterial blood by Pulse oximetry", + unit: "%", + category: "vital-signs", + }, + }, + RESPIRATORY_RATE: { + key: "RESPIRATORY_RATE", + kind: "measurement", + source: { measurementType: "RESPIRATORY_RATE" }, + sourcePriorityKey: "respiratoryRate", + displayName: "Respiratory rate", + unit: "breaths/min", + direction: "target-band", + archetype: "physiological-vital", + normalRange: { low: 12, high: 20 }, + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("respiratory_rate"), + mcp: true, + }, + fhir: { + loinc: "9279-1", + display: "Respiratory rate", + unit: "/min", + category: "vital-signs", + }, + }, + BODY_TEMPERATURE: { + key: "BODY_TEMPERATURE", + kind: "measurement", + source: { measurementType: "BODY_TEMPERATURE" }, + sourcePriorityKey: "bodyTemperature", + displayName: "Body temperature", + unit: "°C", + direction: "target-band", + archetype: "physiological-vital", + normalRange: { low: 36.1, high: 37.2 }, + feverBandC: FEVER_BAND_C, + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("body_temp"), + mcp: true, + }, + fhir: { + loinc: "8310-5", + display: "Body temperature", + unit: "Cel", + category: "vital-signs", + }, + }, + SKIN_TEMPERATURE: { + key: "SKIN_TEMPERATURE", + kind: "measurement", + source: { measurementType: "SKIN_TEMPERATURE" }, + sourcePriorityKey: "skinTemperature", + displayName: "Wrist skin temperature", + unit: "°C", + direction: "target-band", + archetype: "physiological-vital", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("skin_temp"), + mcp: true, + }, + }, + BLOOD_GLUCOSE: { + key: "BLOOD_GLUCOSE", + kind: "measurement", + source: { measurementType: "BLOOD_GLUCOSE" }, + displayName: "Blood glucose", + unit: "mg/dL", + direction: "target-band", + archetype: "physiological-vital", + normalRange: { low: 70, high: 140 }, + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("glucose"), + mcp: true, + }, + }, + WALKING_HEART_RATE_AVERAGE: { + key: "WALKING_HEART_RATE_AVERAGE", + kind: "measurement", + source: { measurementType: "WALKING_HEART_RATE_AVERAGE" }, + displayName: "Walking heart rate", + unit: "bpm", + direction: "lower-better", + archetype: "physiological-vital", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("walking_hr"), + mcp: true, + }, + }, + PULSE_WAVE_VELOCITY: { + key: "PULSE_WAVE_VELOCITY", + kind: "measurement", + source: { measurementType: "PULSE_WAVE_VELOCITY" }, + displayName: "Pulse-wave velocity", + unit: "m/s", + direction: "lower-better", + archetype: "physiological-vital", + normalRange: { low: 0, high: 10 }, + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("pulse_wave_velocity"), + mcp: true, + }, + }, + VASCULAR_AGE: { + key: "VASCULAR_AGE", + kind: "measurement", + source: { measurementType: "VASCULAR_AGE" }, + displayName: "Vascular age", + unit: "years", + direction: "lower-better", + archetype: "physiological-vital", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("vascular_age"), + mcp: true, + }, + }, + CARDIO_RECOVERY: { + key: "CARDIO_RECOVERY", + kind: "measurement", + source: { measurementType: "CARDIO_RECOVERY" }, + displayName: "Cardio recovery", + unit: "bpm", + direction: "higher-better", + archetype: "physiological-vital", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: false, + mcp: false, + }, + fhir: { + loinc: "HKQuantityTypeIdentifierHeartRateRecoveryOneMinute", + display: "Cardio recovery (1-minute heart-rate recovery)", + unit: "/min", + category: "vital-signs", + }, + }, + WRIST_TEMPERATURE: { + key: "WRIST_TEMPERATURE", + kind: "measurement", + source: { measurementType: "WRIST_TEMPERATURE" }, + displayName: "Wrist temperature", + unit: "°C", + direction: "target-band", + archetype: "physiological-vital", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: false, + mcp: false, + }, + fhir: { + loinc: "HKQuantityTypeIdentifierAppleSleepingWristTemperature", + display: "Sleeping wrist temperature", + unit: "Cel", + category: "vital-signs", + }, + }, + ANS_CHARGE: { + key: "ANS_CHARGE", + kind: "measurement", + source: { measurementType: "ANS_CHARGE" }, + displayName: "ANS charge", + unit: "score", + direction: "higher-better", + archetype: "physiological-vital", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: false, + mcp: false, + }, + }, + AVERAGE_HEART_RATE: { + key: "AVERAGE_HEART_RATE", + kind: "measurement", + source: { measurementType: "AVERAGE_HEART_RATE" }, + displayName: "Average heart rate", + unit: "bpm", + direction: "target-band", + archetype: "physiological-vital", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: false, + mcp: false, + }, + }, + MAX_HEART_RATE: { + key: "MAX_HEART_RATE", + kind: "measurement", + source: { measurementType: "MAX_HEART_RATE" }, + displayName: "Max heart rate", + unit: "bpm", + direction: "target-band", + archetype: "physiological-vital", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: false, + mcp: false, + }, + }, + // ── activity-fitness ───────────────────────────────────────────────── + STEPS: { + key: "STEPS", + kind: "measurement", + source: { measurementType: "ACTIVITY_STEPS" }, + sourcePriorityKey: "steps", + displayName: "Steps", + unit: "steps/day", + direction: "higher-better", + archetype: "activity-fitness", + normalRange: { low: 8000, high: 15000 }, + surfaces: { + detailPage: true, + correlationEligible: true, + coachSnapshot: coachScope("steps"), + mcp: true, + }, + fhir: { + loinc: "41950-7", + display: "Number of steps in 24 hour Measured", + unit: "{steps}", + category: "activity", + }, + }, + ACTIVE_ENERGY: { + key: "ACTIVE_ENERGY", + kind: "measurement", + source: { measurementType: "ACTIVE_ENERGY_BURNED" }, + sourcePriorityKey: "activeEnergy", + displayName: "Active energy", + unit: "kcal/day", + direction: "higher-better", + archetype: "activity-fitness", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("active_energy"), + mcp: true, + }, + fhir: { + loinc: "41981-2", + display: "Calories burned", + unit: "kcal", + category: "activity", + }, + }, + FLIGHTS_CLIMBED: { + key: "FLIGHTS_CLIMBED", + kind: "measurement", + source: { measurementType: "FLIGHTS_CLIMBED" }, + sourcePriorityKey: "flightsClimbed", + displayName: "Flights climbed", + unit: "flights/day", + direction: "higher-better", + archetype: "activity-fitness", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("flights"), + mcp: true, + }, + fhir: { + loinc: "HKQuantityTypeIdentifierFlightsClimbed", + display: "Flights climbed", + unit: "{flights}", + category: "activity", + }, + }, + WALKING_RUNNING_DISTANCE: { + key: "WALKING_RUNNING_DISTANCE", + kind: "measurement", + source: { measurementType: "WALKING_RUNNING_DISTANCE" }, + sourcePriorityKey: "walkingRunningDistance", + displayName: "Walking + running distance", + unit: "m/day", + direction: "higher-better", + archetype: "activity-fitness", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("distance"), + mcp: true, + }, + fhir: { + loinc: "HKQuantityTypeIdentifierDistanceWalkingRunning", + display: "Distance walking/running", + unit: "m", + category: "activity", + }, + }, + TIME_IN_DAYLIGHT: { + key: "TIME_IN_DAYLIGHT", + kind: "measurement", + source: { measurementType: "TIME_IN_DAYLIGHT" }, + displayName: "Time in daylight", + unit: "min/day", + direction: "higher-better", + archetype: "activity-fitness", + normalRange: { low: 30, high: 120 }, + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("daylight"), + mcp: true, + }, + fhir: { + loinc: "HKQuantityTypeIdentifierTimeInDaylight", + display: "Time in daylight", + unit: "min", + category: "activity", + }, + }, + VO2_MAX: { + key: "VO2_MAX", + kind: "measurement", + source: { measurementType: "VO2_MAX" }, + sourcePriorityKey: "vo2Max", + displayName: "VO₂ max (cardio fitness)", + unit: "mL/(kg·min)", + direction: "higher-better", + archetype: "activity-fitness", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("vo2_max"), + mcp: true, + }, + fhir: { + loinc: "96402-2", + display: "Oxygen consumption maximum during exercise", + unit: "mL/min/kg", + category: "vital-signs", + }, + }, + DAY_STRAIN: { + key: "DAY_STRAIN", + kind: "measurement", + source: { measurementType: "DAY_STRAIN" }, + displayName: "Day strain", + unit: "score", + direction: "target-band", + archetype: "activity-fitness", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: false, + mcp: false, + }, + }, + WORKOUT_STRAIN: { + key: "WORKOUT_STRAIN", + kind: "measurement", + source: { measurementType: "WORKOUT_STRAIN" }, + displayName: "Workout strain", + unit: "score", + direction: "target-band", + archetype: "activity-fitness", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: false, + mcp: false, + }, + }, + CARDIO_LOAD: { + key: "CARDIO_LOAD", + kind: "measurement", + source: { measurementType: "CARDIO_LOAD" }, + displayName: "Cardio load", + unit: "score", + direction: "target-band", + archetype: "activity-fitness", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: false, + mcp: false, + }, + }, + ENERGY_EXPENDITURE_KJ: { + key: "ENERGY_EXPENDITURE_KJ", + kind: "measurement", + source: { measurementType: "ENERGY_EXPENDITURE_KJ" }, + displayName: "Energy expenditure", + unit: "kJ", + direction: "higher-better", + archetype: "activity-fitness", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: false, + mcp: false, + }, + }, + // ── body-composition ───────────────────────────────────────────────── + TOTAL_BODY_WATER: { + key: "TOTAL_BODY_WATER", + kind: "measurement", + source: { measurementType: "TOTAL_BODY_WATER" }, + displayName: "Total body water", + unit: "kg", + direction: "target-band", + archetype: "body-composition", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("total_body_water"), + mcp: true, + }, + fhir: { + loinc: "73704-9", + display: "Body water by Bioelectrical impedance analysis", + unit: "kg", + category: "vital-signs", + }, + }, + BONE_MASS: { + key: "BONE_MASS", + kind: "measurement", + source: { measurementType: "BONE_MASS" }, + displayName: "Bone mass", + unit: "kg", + direction: "target-band", + archetype: "body-composition", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("bone_mass"), + mcp: true, + }, + fhir: { + loinc: "73708-0", + display: "Bone mineral content by DXA", + unit: "kg", + category: "vital-signs", + }, + }, + FAT_FREE_MASS: { + key: "FAT_FREE_MASS", + kind: "measurement", + source: { measurementType: "FAT_FREE_MASS" }, + displayName: "Fat-free mass", + unit: "kg", + direction: "target-band", + archetype: "body-composition", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("fat_free_mass"), + mcp: true, + }, + fhir: { + loinc: null, + display: "Fat-free mass", + unit: "kg", + category: "vital-signs", + }, + }, + FAT_MASS: { + key: "FAT_MASS", + kind: "measurement", + source: { measurementType: "FAT_MASS" }, + displayName: "Fat mass", + unit: "kg", + direction: "lower-better", + archetype: "body-composition", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("fat_mass"), + mcp: true, + }, + fhir: { + loinc: null, + display: "Fat mass", + unit: "kg", + category: "vital-signs", + }, + }, + MUSCLE_MASS: { + key: "MUSCLE_MASS", + kind: "measurement", + source: { measurementType: "MUSCLE_MASS" }, + displayName: "Muscle mass", + unit: "kg", + direction: "higher-better", + archetype: "body-composition", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("muscle_mass"), + mcp: true, + }, + fhir: { + loinc: null, + display: "Muscle mass", + unit: "kg", + category: "vital-signs", + }, + }, + LEAN_BODY_MASS: { + key: "LEAN_BODY_MASS", + kind: "measurement", + source: { measurementType: "LEAN_BODY_MASS" }, + displayName: "Lean body mass", + unit: "kg", + direction: "target-band", + archetype: "body-composition", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("lean_body_mass"), + mcp: true, + }, + fhir: { + loinc: null, + display: "Lean body mass", + unit: "kg", + category: "vital-signs", + }, + }, + VISCERAL_FAT: { + key: "VISCERAL_FAT", + kind: "measurement", + source: { measurementType: "VISCERAL_FAT" }, + displayName: "Visceral fat rating", + unit: "rating", + direction: "lower-better", + archetype: "body-composition", + normalRange: { low: 1, high: 12 }, + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("visceral_fat"), + mcp: true, + }, + fhir: { + loinc: null, + display: "Visceral fat", + unit: "1", + category: "vital-signs", + }, + }, + // ── mobility-gait ──────────────────────────────────────────────────── + WALKING_STEADINESS: { + key: "WALKING_STEADINESS", + kind: "measurement", + source: { measurementType: "WALKING_STEADINESS" }, + displayName: "Walking steadiness", + unit: "%", + direction: "higher-better", + archetype: "mobility-gait", + normalRange: { low: 50, high: 100 }, + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("walking_steadiness"), + mcp: true, + }, + }, + WALKING_ASYMMETRY: { + key: "WALKING_ASYMMETRY", + kind: "measurement", + source: { measurementType: "WALKING_ASYMMETRY" }, + displayName: "Walking asymmetry", + unit: "%", + direction: "lower-better", + archetype: "mobility-gait", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("walking_asymmetry"), + mcp: true, + }, + fhir: { + loinc: "91557-1", + display: "Walking asymmetry percentage", + unit: "%", + category: "vital-signs", + }, + }, + WALKING_DOUBLE_SUPPORT: { + key: "WALKING_DOUBLE_SUPPORT", + kind: "measurement", + source: { measurementType: "WALKING_DOUBLE_SUPPORT" }, + displayName: "Double-support time", + unit: "%", + direction: "lower-better", + archetype: "mobility-gait", + normalRange: { low: 20, high: 40 }, + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("walking_double_support"), + mcp: true, + }, + fhir: { + loinc: "HKQuantityTypeIdentifierWalkingDoubleSupportPercentage", + display: "Walking double support percentage", + unit: "%", + category: "vital-signs", + }, + }, + WALKING_STEP_LENGTH: { + key: "WALKING_STEP_LENGTH", + kind: "measurement", + source: { measurementType: "WALKING_STEP_LENGTH" }, + displayName: "Step length", + unit: "m", + direction: "higher-better", + archetype: "mobility-gait", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("walking_step_length"), + mcp: true, + }, + fhir: { + loinc: "41955-6", + display: "Step length Measured", + unit: "m", + category: "vital-signs", + }, + }, + WALKING_SPEED: { + key: "WALKING_SPEED", + kind: "measurement", + source: { measurementType: "WALKING_SPEED" }, + displayName: "Walking speed", + unit: "m/s", + direction: "higher-better", + archetype: "mobility-gait", + normalRange: { low: 1.2, high: 1.4 }, + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("walking_speed"), + mcp: true, + }, + fhir: { + loinc: "41957-2", + display: "Gait speed [Velocity] Measured", + unit: "m/s", + category: "vital-signs", + }, + }, + FALL_COUNT: { + key: "FALL_COUNT", + kind: "measurement", + source: { measurementType: "FALL_COUNT" }, + displayName: "Falls", + unit: "falls/day", + direction: "lower-better", + archetype: "mobility-gait", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: false, + mcp: false, + }, + fhir: { + loinc: "HKQuantityTypeIdentifierNumberOfTimesFallen", + display: "Number of times fallen", + unit: "{falls}", + category: "activity", + }, + }, + SIX_MINUTE_WALK_DISTANCE: { + key: "SIX_MINUTE_WALK_DISTANCE", + kind: "measurement", + source: { measurementType: "SIX_MINUTE_WALK_DISTANCE" }, + displayName: "Six-minute walk distance", + unit: "m", + direction: "higher-better", + archetype: "mobility-gait", + normalRange: { low: 400, high: 700 }, + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: false, + mcp: false, + }, + fhir: { + loinc: "HKQuantityTypeIdentifierSixMinuteWalkTestDistance", + display: "Six-minute walk test distance", + unit: "m", + category: "activity", + }, + }, + STAIR_ASCENT_SPEED: { + key: "STAIR_ASCENT_SPEED", + kind: "measurement", + source: { measurementType: "STAIR_ASCENT_SPEED" }, + displayName: "Stair ascent speed", + unit: "m/s", + direction: "higher-better", + archetype: "mobility-gait", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: false, + mcp: false, + }, + fhir: { + loinc: "HKQuantityTypeIdentifierStairAscentSpeed", + display: "Stair ascent speed", + unit: "m/s", + category: "vital-signs", + }, + }, + STAIR_DESCENT_SPEED: { + key: "STAIR_DESCENT_SPEED", + kind: "measurement", + source: { measurementType: "STAIR_DESCENT_SPEED" }, + displayName: "Stair descent speed", + unit: "m/s", + direction: "higher-better", + archetype: "mobility-gait", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: false, + mcp: false, + }, + fhir: { + loinc: "HKQuantityTypeIdentifierStairDescentSpeed", + display: "Stair descent speed", + unit: "m/s", + category: "vital-signs", + }, + }, + // ── environmental-exposure ─────────────────────────────────────────── + AUDIO_EXPOSURE_ENV: { + key: "AUDIO_EXPOSURE_ENV", + kind: "measurement", + source: { measurementType: "AUDIO_EXPOSURE_ENV" }, + displayName: "Environmental sound exposure", + unit: "dBA", + direction: "lower-better", + archetype: "environmental-exposure", + normalRange: { low: 0, high: 80 }, + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("audio_env"), + mcp: true, + }, + fhir: { + loinc: "HKQuantityTypeIdentifierEnvironmentalAudioExposure", + display: "Environmental audio exposure", + unit: "dB[A]", + category: "vital-signs", + }, + }, + AUDIO_EXPOSURE_HEADPHONE: { + key: "AUDIO_EXPOSURE_HEADPHONE", + kind: "measurement", + source: { measurementType: "AUDIO_EXPOSURE_HEADPHONE" }, + displayName: "Headphone audio exposure", + unit: "dBA", + direction: "lower-better", + archetype: "environmental-exposure", + normalRange: { low: 0, high: 80 }, + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("audio_headphone"), + mcp: true, + }, + fhir: { + loinc: "HKQuantityTypeIdentifierHeadphoneAudioExposure", + display: "Headphone audio exposure", + unit: "dB[A]", + category: "vital-signs", + }, + }, + AUDIO_EXPOSURE_EVENT: { + key: "AUDIO_EXPOSURE_EVENT", + kind: "measurement", + source: { measurementType: "AUDIO_EXPOSURE_EVENT" }, + displayName: "Loud-exposure events", + unit: "events", + direction: "lower-better", + archetype: "environmental-exposure", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: coachScope("audio_event"), + mcp: true, + }, + }, + // ── sleep (dedicated template) ─────────────────────────────────────── + SLEEP_DURATION: { + key: "SLEEP_DURATION", + kind: "measurement", + source: { measurementType: "SLEEP_DURATION" }, + sourcePriorityKey: "sleep", + displayName: "Sleep duration", + unit: "min", + direction: "target-band", + archetype: "sleep", + normalRange: { low: 420, high: 540 }, + surfaces: { + detailPage: true, + correlationEligible: true, + coachSnapshot: coachScope("sleep"), + mcp: true, + }, + fhir: { + loinc: "93832-4", + display: "Sleep duration", + unit: "h", + category: "activity", + }, + }, + BREATHING_DISTURBANCES: { + key: "BREATHING_DISTURBANCES", + kind: "measurement", + source: { measurementType: "BREATHING_DISTURBANCES" }, + displayName: "Sleep breathing disturbances", + unit: "count", + direction: "lower-better", + archetype: "sleep", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: false, + mcp: false, + }, + fhir: { + loinc: "HKQuantityTypeIdentifierAppleSleepingBreathingDisturbances", + display: "Sleeping breathing disturbances", + unit: "{count}", + category: "vital-signs", + }, + }, + SLEEP_SCORE: { + key: "SLEEP_SCORE", + kind: "measurement", + source: { measurementType: "SLEEP_SCORE" }, + displayName: "Sleep score", + unit: "score", + direction: "higher-better", + archetype: "sleep", + surfaces: { + detailPage: true, + correlationEligible: false, + coachSnapshot: false, + mcp: false, + }, + }, + // ── specialised metrics (bespoke status files; detailPage:false) ────── + // These keep their dedicated scopes + status generators; they register + // only so the cross-surface indices (FHIR, correlation, Coach, MCP) see + // them without routing them through the generic detail template. + WEIGHT: { + key: "WEIGHT", + kind: "measurement", + source: { measurementType: "WEIGHT" }, + sourcePriorityKey: "weight", + displayName: "Weight", + unit: "kg", + direction: "target-band", + archetype: "body-composition", + surfaces: { + detailPage: false, + correlationEligible: true, + coachSnapshot: coachScope("weight"), + mcp: true, + }, + fhir: { + loinc: "29463-7", + display: "Body weight", + unit: "kg", + category: "vital-signs", + }, + }, + BODY_MASS_INDEX: { + key: "BODY_MASS_INDEX", + kind: "measurement", + source: { measurementType: "BODY_MASS_INDEX" }, + displayName: "Body mass index", + unit: "kg/m²", + direction: "target-band", + archetype: "body-composition", + surfaces: { + detailPage: false, + correlationEligible: false, + coachSnapshot: coachScope("bmi"), + mcp: true, + }, + fhir: { + loinc: "39156-5", + display: "Body mass index (BMI) [Ratio]", + unit: "kg/m2", + category: "vital-signs", + }, + }, + PULSE: { + key: "PULSE", + kind: "measurement", + source: { measurementType: "PULSE" }, + sourcePriorityKey: "pulse", + displayName: "Pulse", + unit: "bpm", + direction: "lower-better", + archetype: "physiological-vital", + surfaces: { + detailPage: false, + correlationEligible: true, + coachSnapshot: coachScope("pulse"), + mcp: true, + }, + fhir: { + loinc: "8867-4", + display: "Heart rate", + unit: "/min", + category: "vital-signs", + }, + }, + BODY_FAT: { + key: "BODY_FAT", + kind: "measurement", + source: { measurementType: "BODY_FAT" }, + sourcePriorityKey: "bodyFat", + displayName: "Body fat", + unit: "%", + direction: "lower-better", + archetype: "body-composition", + surfaces: { + detailPage: false, + correlationEligible: true, + coachSnapshot: coachScope("body_fat"), + mcp: true, + }, + fhir: { + loinc: "41982-0", + display: "Percentage of body fat Measured", + unit: "%", + category: "vital-signs", + }, + }, + BLOOD_PRESSURE_SYS: { + key: "BLOOD_PRESSURE_SYS", + kind: "measurement", + source: { measurementType: "BLOOD_PRESSURE_SYS" }, + sourcePriorityKey: "bloodPressure", + displayName: "Blood pressure (systolic)", + unit: "mmHg", + direction: "lower-better", + archetype: "physiological-vital", + surfaces: { + detailPage: false, + correlationEligible: true, + coachSnapshot: coachScope("bp"), + mcp: true, + }, + }, + BLOOD_PRESSURE_DIA: { + key: "BLOOD_PRESSURE_DIA", + kind: "measurement", + source: { measurementType: "BLOOD_PRESSURE_DIA" }, + sourcePriorityKey: "bloodPressure", + displayName: "Blood pressure (diastolic)", + unit: "mmHg", + direction: "lower-better", + archetype: "physiological-vital", + surfaces: { + detailPage: false, + correlationEligible: true, + coachSnapshot: coachScope("bp"), + mcp: true, + }, + }, +}; + +/** Type guard: narrow an arbitrary string to a registered signal key. */ +export function isSignalKey(key: string): boolean { + return Object.prototype.hasOwnProperty.call(SIGNALS, key); +} + +/** Resolve a signal by its key, or null when unregistered. */ +export function getSignal(key: string): SignalDefinition | null { + return isSignalKey(key) ? SIGNALS[key] : null; +} + +/** Every registered signal, in declaration order. */ +export function allSignals(): SignalDefinition[] { + return Object.values(SIGNALS); +} + +/** Reverse index: DB `MeasurementType` → the signal it backs (built once). */ +const MEASUREMENT_TYPE_TO_SIGNAL = new Map( + allSignals() + .filter( + (s): s is Extract => + s.kind === "measurement" || s.kind === "score", + ) + .map((s) => [s.source.measurementType, s]), +); + +/** Resolve the signal a `MeasurementType` backs, or null when unregistered. */ +export function signalForMeasurementType( + type: MeasurementType, +): SignalDefinition | null { + return MEASUREMENT_TYPE_TO_SIGNAL.get(type) ?? null; +} From dfce3e2710cff307bdb92970d7f8d7b3f7c50a57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 15:12:09 +0200 Subject: [PATCH 32/94] refactor(fhir,insights): derive FHIR and correlation tables from the registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hand-maintained MEASUREMENT_LOINC literal and the BUCKETED_TYPES correlation list with signal-registry projections. Both reads are byte-identical to the previous tables (guarded by the registry-invariant test), so there is no behaviour change — only one source of truth for each. --- src/lib/fhir/loinc-map.ts | 235 +---------------------------------- src/lib/insights/features.ts | 15 +-- 2 files changed, 9 insertions(+), 241 deletions(-) diff --git a/src/lib/fhir/loinc-map.ts b/src/lib/fhir/loinc-map.ts index 9e1fd4cc0..31c9324cb 100644 --- a/src/lib/fhir/loinc-map.ts +++ b/src/lib/fhir/loinc-map.ts @@ -9,6 +9,7 @@ * components 8480-6 / 8462-4), so the two BP component types are NOT in * this single-value map. */ +import { deriveMeasurementLoinc } from "@/lib/signals/adapters/fhir"; export const LOINC_SYSTEM = "http://loinc.org"; export const UCUM_SYSTEM = "http://unitsofmeasure.org"; @@ -39,238 +40,8 @@ export interface LoincMapping { * panel). Glucose is handled per-context by the builder using * `GLUCOSE_LOINC`. */ -export const MEASUREMENT_LOINC: Record = { - WEIGHT: { - loinc: "29463-7", - display: "Body weight", - unit: "kg", - category: "vital-signs", - }, - BODY_MASS_INDEX: { - loinc: "39156-5", - display: "Body mass index (BMI) [Ratio]", - unit: "kg/m2", - category: "vital-signs", - }, - PULSE: { - loinc: "8867-4", - display: "Heart rate", - unit: "/min", - category: "vital-signs", - }, - RESTING_HEART_RATE: { - loinc: "40443-4", - display: "Heart rate --resting", - unit: "/min", - category: "vital-signs", - }, - RESPIRATORY_RATE: { - loinc: "9279-1", - display: "Respiratory rate", - unit: "/min", - category: "vital-signs", - }, - BODY_TEMPERATURE: { - loinc: "8310-5", - display: "Body temperature", - unit: "Cel", - category: "vital-signs", - }, - OXYGEN_SATURATION: { - loinc: "59408-5", - display: "Oxygen saturation in Arterial blood by Pulse oximetry", - unit: "%", - category: "vital-signs", - }, - BODY_FAT: { - loinc: "41982-0", - display: "Percentage of body fat Measured", - unit: "%", - category: "vital-signs", - }, - VO2_MAX: { - loinc: "96402-2", - display: "Oxygen consumption maximum during exercise", - unit: "mL/min/kg", - category: "vital-signs", - }, - HEART_RATE_VARIABILITY: { - loinc: "80404-7", - display: "R-R interval.standard deviation (Heart rate variability)", - unit: "ms", - category: "vital-signs", - }, - ACTIVITY_STEPS: { - loinc: "41950-7", - display: "Number of steps in 24 hour Measured", - unit: "{steps}", - category: "activity", - }, - // Stored canonically in MINUTES; the builder converts the emitted value to - // hours so it matches the UCUM `h` unit (and the iOS table). PDF unaffected. - SLEEP_DURATION: { - loinc: "93832-4", - display: "Sleep duration", - unit: "h", - category: "activity", - }, - ACTIVE_ENERGY_BURNED: { - loinc: "41981-2", - display: "Calories burned", - unit: "kcal", - category: "activity", - }, - WALKING_SPEED: { - loinc: "41957-2", - display: "Gait speed [Velocity] Measured", - unit: "m/s", - category: "vital-signs", - }, - WALKING_ASYMMETRY: { - loinc: "91557-1", - display: "Walking asymmetry percentage", - unit: "%", - category: "vital-signs", - }, - WALKING_STEP_LENGTH: { - loinc: "41955-6", - display: "Step length Measured", - unit: "m", - category: "vital-signs", - }, - // Body-composition family with iOS-locked LOINC codes. - TOTAL_BODY_WATER: { - loinc: "73704-9", - display: "Body water by Bioelectrical impedance analysis", - unit: "kg", - category: "vital-signs", - }, - BONE_MASS: { - loinc: "73708-0", - display: "Bone mineral content by DXA", - unit: "kg", - category: "vital-signs", - }, - // HK-placeholder codes — no published LOINC term. Both iOS and the server - // emit the HealthKit identifier STRING as the `code` under the shared - // `HEALTHKIT_CODESYSTEM` (NOT the LOINC namespace — a non-LOINC code under - // http://loinc.org fails FHIR conformance). The `loinc` field below carries - // the placeholder string verbatim; the builder routes any - // `HKQuantityTypeIdentifier…` code onto the custom system. - WALKING_DOUBLE_SUPPORT: { - loinc: "HKQuantityTypeIdentifierWalkingDoubleSupportPercentage", - display: "Walking double support percentage", - unit: "%", - category: "vital-signs", - }, - AUDIO_EXPOSURE_ENV: { - loinc: "HKQuantityTypeIdentifierEnvironmentalAudioExposure", - display: "Environmental audio exposure", - unit: "dB[A]", - category: "vital-signs", - }, - AUDIO_EXPOSURE_HEADPHONE: { - loinc: "HKQuantityTypeIdentifierHeadphoneAudioExposure", - display: "Headphone audio exposure", - unit: "dB[A]", - category: "vital-signs", - }, - FLIGHTS_CLIMBED: { - loinc: "HKQuantityTypeIdentifierFlightsClimbed", - display: "Flights climbed", - unit: "{flights}", - category: "activity", - }, - WALKING_RUNNING_DISTANCE: { - loinc: "HKQuantityTypeIdentifierDistanceWalkingRunning", - display: "Distance walking/running", - unit: "m", - category: "activity", - }, - TIME_IN_DAYLIGHT: { - loinc: "HKQuantityTypeIdentifierTimeInDaylight", - display: "Time in daylight", - unit: "min", - category: "activity", - }, - MUSCLE_MASS: { - loinc: null, - display: "Muscle mass", - unit: "kg", - category: "vital-signs", - }, - FAT_MASS: { - loinc: null, - display: "Fat mass", - unit: "kg", - category: "vital-signs", - }, - FAT_FREE_MASS: { - loinc: null, - display: "Fat-free mass", - unit: "kg", - category: "vital-signs", - }, - LEAN_BODY_MASS: { - loinc: null, - display: "Lean body mass", - unit: "kg", - category: "vital-signs", - }, - VISCERAL_FAT: { - loinc: null, - display: "Visceral fat", - unit: "1", - category: "vital-signs", - }, - // ── v1.10.0 — additive HealthKit signals (WX-A) ── - // No stable LOINC term for these Apple-specific quantities, so each - // routes through the shared HEALTHKIT_CODESYSTEM with the raw - // HKQuantityTypeIdentifier… string as the code (a non-LOINC code under - // the LOINC namespace would fail FHIR conformance). - CARDIO_RECOVERY: { - loinc: "HKQuantityTypeIdentifierHeartRateRecoveryOneMinute", - display: "Cardio recovery (1-minute heart-rate recovery)", - unit: "/min", - category: "vital-signs", - }, - WRIST_TEMPERATURE: { - loinc: "HKQuantityTypeIdentifierAppleSleepingWristTemperature", - display: "Sleeping wrist temperature", - unit: "Cel", - category: "vital-signs", - }, - FALL_COUNT: { - loinc: "HKQuantityTypeIdentifierNumberOfTimesFallen", - display: "Number of times fallen", - unit: "{falls}", - category: "activity", - }, - SIX_MINUTE_WALK_DISTANCE: { - loinc: "HKQuantityTypeIdentifierSixMinuteWalkTestDistance", - display: "Six-minute walk test distance", - unit: "m", - category: "activity", - }, - STAIR_ASCENT_SPEED: { - loinc: "HKQuantityTypeIdentifierStairAscentSpeed", - display: "Stair ascent speed", - unit: "m/s", - category: "vital-signs", - }, - STAIR_DESCENT_SPEED: { - loinc: "HKQuantityTypeIdentifierStairDescentSpeed", - display: "Stair descent speed", - unit: "m/s", - category: "vital-signs", - }, - BREATHING_DISTURBANCES: { - loinc: "HKQuantityTypeIdentifierAppleSleepingBreathingDisturbances", - display: "Sleeping breathing disturbances", - unit: "{count}", - category: "vital-signs", - }, -}; +export const MEASUREMENT_LOINC: Record = + deriveMeasurementLoinc(); /** BP panel + component LOINC codes. */ export const BP_PANEL_LOINC = "85354-9"; diff --git a/src/lib/insights/features.ts b/src/lib/insights/features.ts index b8de9b84a..76d3e0afe 100644 --- a/src/lib/insights/features.ts +++ b/src/lib/insights/features.ts @@ -29,6 +29,7 @@ import { getMedicationCategories } from "@/lib/medication-category"; import { classifyReferenceRange } from "@/lib/labs/reference-range"; import { resolveLabFields } from "@/lib/labs/serialise"; import { readRollupBuckets } from "@/lib/rollups/measurement-rollups"; +import { deriveBucketedTypes } from "@/lib/signals/adapters/correlation"; import { ensureUserMoodRollupsFresh, readMoodDayRollups, @@ -464,15 +465,11 @@ const BUCKET_WINDOWS: Array<{ * block was suppressed. New `MeasurementType` enum values flow in by * adding one row; the rollup populator already covers every type. */ -const BUCKETED_TYPES: MeasurementType[] = [ - "WEIGHT", - "BLOOD_PRESSURE_SYS", - "BLOOD_PRESSURE_DIA", - "PULSE", - "BODY_FAT", - "SLEEP_DURATION", - "ACTIVITY_STEPS", -]; +// Derived from the signal registry: every signal flagged +// `surfaces.correlationEligible` projects to its DB `MeasurementType`. The list +// is a membership/iteration set (each type is read independently), so order is +// not significant; the registry-invariant test pins the set byte-for-byte. +const BUCKETED_TYPES: MeasurementType[] = deriveBucketedTypes(); function stdDev(values: number[]): number | null { if (values.length < 2) return null; From f599ba9a92410a679819e2ca9c02f247056d0a19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 15:13:23 +0200 Subject: [PATCH 33/94] feat(insights): move the assessment-card Coach hand-off to a shared top-right icon The per-metric assessment card carried the Coach hand-off as a bottom-right text button, present on only some pages. Pin it to the card header's top-right as a single icon button (no text label) with an accessible name and a tooltip, and wire every assessment surface to it: the bespoke slug pages (weight, BMI, pulse, blood pressure, mood), the generic metric scaffold, the medication-compliance card, and the biomarker detail page already on the shared card. Each opens the Coach with a localised, auto-sent opener narrowed to that metric's snapshot source. The button self-gates on the Coach triple (provider mounted, operator flag, per-user opt-out) and the card only mounts it on the populated branch, so it never advertises a control that cannot work. The auto-sent opener now interpolates the localised measurement name rather than a raw token. --- src/app/insights/medications/page.tsx | 5 ++ src/components/insights/ask-coach-action.tsx | 62 +++++++++++++++++++ .../insights/insight-status-card.tsx | 35 ++++++----- .../insights/metric-status-card.tsx | 13 +++- .../insights/slug-insight-status-card.tsx | 33 ++++++++++ 5 files changed, 131 insertions(+), 17 deletions(-) diff --git a/src/app/insights/medications/page.tsx b/src/app/insights/medications/page.tsx index e80f5133e..e95c6a306 100644 --- a/src/app/insights/medications/page.tsx +++ b/src/app/insights/medications/page.tsx @@ -295,6 +295,11 @@ export default function InsightsMedikamentePage() { text={status?.summary ?? null} hasProvider={status?.hasProvider ?? false} updatedAt={status?.updatedAt ?? null} + coachQuestion={t("insights.coach.assessmentPrompt", { + metric: t("insights.medicationCompliance"), + })} + coachScope={{ metric: "compliance" }} + coachAutoSend loading={isStatusLoading} preparing={status?.preparing ?? false} /> diff --git a/src/components/insights/ask-coach-action.tsx b/src/components/insights/ask-coach-action.tsx index ffc97490d..08aa56d33 100644 --- a/src/components/insights/ask-coach-action.tsx +++ b/src/components/insights/ask-coach-action.tsx @@ -3,6 +3,12 @@ import { Sparkles } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; import { @@ -96,3 +102,59 @@ export function AskCoachAction({ ); } + +/** + * v1.25 — icon-only Coach hand-off pinned to the TOP-RIGHT of an assessment + * card header. Same launch + gating contract as ``, but no + * visible text label: the affordance is a single ghost icon button with an + * accessible name and a tooltip carrying the same copy (a11y — an icon button + * must announce its purpose). + * + * Self-gates on the same triple every Coach entry point uses (provider mounted + * in the tree + operator flag + per-user opt-out), so the card never paints a + * dead control. The assessment card only mounts it on the populated branch, + * where the provider is known good — the "no advertised-but-broken control" + * rule holds end-to-end. + */ +export function AskCoachIconButton({ + question, + scope, + autoSend, + label, + className, +}: AskCoachActionProps) { + const { t } = useTranslations(); + const launch = useCoachLaunch(); + const flags = useFeatureFlags(); + const disableCoach = useDisableCoach(); + + if (!launch) return null; + if (!flags.coach) return null; + if (disableCoach) return null; + + const accessibleLabel = label ?? t("insights.coach.askAboutThis"); + + return ( + + + + + + {accessibleLabel} + + + ); +} diff --git a/src/components/insights/insight-status-card.tsx b/src/components/insights/insight-status-card.tsx index 03eeca56e..d3793e74e 100644 --- a/src/components/insights/insight-status-card.tsx +++ b/src/components/insights/insight-status-card.tsx @@ -13,7 +13,7 @@ import { ProseBlocks } from "@/components/insights/prose-blocks"; import { cn } from "@/lib/utils"; import { useAuth } from "@/hooks/use-auth"; import { useFeatureFlags } from "@/hooks/use-feature-flags"; -import { AskCoachAction } from "@/components/insights/ask-coach-action"; +import { AskCoachIconButton } from "@/components/insights/ask-coach-action"; import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context"; // ─── Types ──────────────────────────────────────────────── @@ -236,8 +236,25 @@ export function InsightStatusCard({ {/* v1.11.5 — the top-right "cached" label was removed: it surfaced an implementation detail and devalued the assessment. The card still consumes the warm cache; it just no longer announces it. - v1.12.6 — the icon + title row is the canonical ``. */} - + v1.12.6 — the icon + title row is the canonical ``. + v1.25 — the Coach hand-off lives in the header's right slot as a + single icon button (no text label, tooltip + accessible name), + shared across every assessment card. Rendered only when the + caller supplied an opener; the button self-gates on the Coach + triple, so it never paints a dead control. */} + + ) : undefined + } + /> {/* v1.4.27 — defence-in-depth strip. Cached status text from @@ -249,18 +266,6 @@ export function InsightStatusCard({ they roll forward. */} - {/* v1.21.0 (C4 H2) — discreet hand-off so the user can take the - assessment into a scoped Coach conversation. Only when the - caller supplied an opener. */} - {coachQuestion ? ( -
    - -
    - ) : null}
    ); diff --git a/src/components/insights/metric-status-card.tsx b/src/components/insights/metric-status-card.tsx index 524401a96..7a6a30956 100644 --- a/src/components/insights/metric-status-card.tsx +++ b/src/components/insights/metric-status-card.tsx @@ -7,7 +7,10 @@ import { useMounted } from "@/hooks/use-mounted"; import { useTranslations } from "@/lib/i18n/context"; import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry"; import { InsightStatusCard } from "@/components/insights/insight-status-card"; -import { scopeSourceFromMetricKey } from "@/components/insights/coach-metric-scope"; +import { + scopeSourceFromMetricKey, + scopeSourceMetricLabelKey, +} from "@/components/insights/coach-metric-scope"; interface MetricStatusCardProps { /** The registry metric id the generic assessment route keys on. */ @@ -46,8 +49,14 @@ export function MetricStatusCard({ // carry a snapshot block. Unmapped metrics still get a metric-named // opener, just against the default snapshot. const coachSource = scopeSourceFromMetricKey(metric); + // Prefer the EXISTING localised measurement-name key for the auto-sent + // preset so the opener reads in the user's language; fall back to the + // humanised token only for metrics without a localised name. + const coachLabelKey = scopeSourceMetricLabelKey(coachSource); const coachQuestion = t("insights.coach.assessmentPrompt", { - metric: metric.toLowerCase().replace(/_/g, " "), + metric: coachLabelKey + ? t(coachLabelKey) + : metric.toLowerCase().replace(/_/g, " "), }); return ( diff --git a/src/components/insights/slug-insight-status-card.tsx b/src/components/insights/slug-insight-status-card.tsx index a0d30de28..d73bd2080 100644 --- a/src/components/insights/slug-insight-status-card.tsx +++ b/src/components/insights/slug-insight-status-card.tsx @@ -9,6 +9,7 @@ import { import { useMounted } from "@/hooks/use-mounted"; import { useTranslations } from "@/lib/i18n/context"; import { InsightStatusCard } from "@/components/insights/insight-status-card"; +import type { CoachScopeSource } from "@/lib/ai/coach/types"; interface SlugInsightStatusCardProps { /** The bespoke metric slug the `/api/insights/-status` route keys on. */ @@ -17,6 +18,31 @@ interface SlugInsightStatusCardProps { icon: ReactNode; } +/** + * v1.25 — each bespoke slug maps to the Coach snapshot source it narrows the + * hand-off to, plus the EXISTING localised metric-name key the auto-sent + * preset interpolates. Resolving the label through `t()` keeps the opener in + * the user's language (never a raw enum token), shared with the icon-only + * Coach affordance the assessment card renders. + */ +const SLUG_COACH: Record< + InsightStatusMetric, + { source: CoachScopeSource; labelKey: string } +> = { + "blood-pressure": { + source: "bp", + labelKey: "measurements.typeBloodPressure", + }, + weight: { source: "weight", labelKey: "measurements.typeWeight" }, + pulse: { source: "pulse", labelKey: "measurements.typePulse" }, + bmi: { source: "bmi", labelKey: "measurements.typeBodyMassIndex" }, + mood: { source: "mood", labelKey: "insights.moodSectionTitle" }, + "medication-compliance": { + source: "compliance", + labelKey: "insights.medicationCompliance", + }, +}; + /** * Shared mount for the bespoke per-metric assessment card. * @@ -36,6 +62,8 @@ export function SlugInsightStatusCard({ const mounted = useMounted(); const { data: status, isLoading } = useInsightStatus(slug); + const coach = SLUG_COACH[slug]; + return ( Date: Sun, 28 Jun 2026 15:13:32 +0200 Subject: [PATCH 34/94] fix(insights): merge the sleep page's two assessments into one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sleep page rendered an assessment under the duration/architecture charts and a second one under the sleep-quality scores. The quality assessment mostly never generated — most accounts carry no wearable sleep score — so it sat at the foot of the page as a duplicate empty card or a redundant connect-a-provider prompt. Drop the quality-block assessment so the duration card is the page's single assessment; the quality block keeps its score tiles. Remove the now-unused grounded-note fallback and its deterministic grading helper, and the orphaned quality-assessment message keys across every locale. --- messages/de.json | 23 +-- messages/en.json | 23 +-- messages/es.json | 23 +-- messages/fr.json | 23 +-- messages/it.json | 23 +-- messages/pl.json | 23 +-- src/app/insights/sleep/page.tsx | 19 +-- .../sleep-quality-assessment.test.ts | 68 --------- .../sleep-quality-grounded-note.test.tsx | 67 --------- .../sleep/sleep-quality-assessment.ts | 136 ------------------ .../sleep/sleep-quality-grounded-note.tsx | 116 --------------- .../insights/sleep/sleep-quality-section.tsx | 73 ---------- 12 files changed, 17 insertions(+), 600 deletions(-) delete mode 100644 src/components/insights/sleep/__tests__/sleep-quality-assessment.test.ts delete mode 100644 src/components/insights/sleep/__tests__/sleep-quality-grounded-note.test.tsx delete mode 100644 src/components/insights/sleep/sleep-quality-assessment.ts delete mode 100644 src/components/insights/sleep/sleep-quality-grounded-note.tsx diff --git a/messages/de.json b/messages/de.json index e2576c2c3..146bbed3a 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3606,28 +3606,7 @@ }, "sleepQuality": { "title": "Schlafqualität", - "subtitle": "Nächtliche Werte deines Wearables", - "assessment": { - "metric": { - "SLEEP_SCORE": "dein Gesamt-Schlafscore", - "SLEEP_EFFICIENCY": "die Schlafeffizienz", - "SLEEP_PERFORMANCE": "die Schlafleistung", - "SLEEP_CONSISTENCY": "die Schlafkonstanz" - }, - "grade": { - "excellent": "ausgezeichnet", - "good": "solide", - "fair": "durchschnittlich", - "low": "eher niedrig" - }, - "clause": "Über die letzten 30 Nächte ist {metric} mit {value} {grade}.", - "closing": { - "excellent": "Behalte die Routine bei, die dich hierher bringt.", - "good": "Konstantere Zubett- und Aufstehzeiten sind meist der nächste Gewinn.", - "fair": "Ein konstantes Schlaffenster hebt diesen Wert erfahrungsgemäß am stärksten.", - "low": "Ein festes Herunterfahren am Abend und eine feste Aufstehzeit sind die ersten Hebel." - } - } + "subtitle": "Nächtliche Werte deines Wearables" }, "recovery": { "title": "Erholung", diff --git a/messages/en.json b/messages/en.json index dc3001c27..23ae4f2b7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3606,28 +3606,7 @@ }, "sleepQuality": { "title": "Sleep quality", - "subtitle": "Nightly scores from your wearable", - "assessment": { - "metric": { - "SLEEP_SCORE": "your overall sleep score", - "SLEEP_EFFICIENCY": "sleep efficiency", - "SLEEP_PERFORMANCE": "sleep performance", - "SLEEP_CONSISTENCY": "sleep consistency" - }, - "grade": { - "excellent": "excellent", - "good": "solid", - "fair": "fair", - "low": "on the low side" - }, - "clause": "Over the last 30 nights {metric} is {grade} at {value}.", - "closing": { - "excellent": "Keep the routine that is getting you here.", - "good": "A steadier bed and wake time is the usual next gain.", - "fair": "Protecting a consistent sleep window tends to lift this most.", - "low": "A regular wind-down and a fixed wake time are the first levers to pull." - } - } + "subtitle": "Nightly scores from your wearable" }, "recovery": { "title": "Recovery", diff --git a/messages/es.json b/messages/es.json index 410d5be30..e2f12b186 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3606,28 +3606,7 @@ }, "sleepQuality": { "title": "Calidad del sueño", - "subtitle": "Puntuaciones nocturnas de tu wearable", - "assessment": { - "metric": { - "SLEEP_SCORE": "tu puntuación global de sueño", - "SLEEP_EFFICIENCY": "la eficiencia del sueño", - "SLEEP_PERFORMANCE": "el rendimiento del sueño", - "SLEEP_CONSISTENCY": "la regularidad del sueño" - }, - "grade": { - "excellent": "excelente", - "good": "sólida", - "fair": "aceptable", - "low": "más bien baja" - }, - "clause": "En las últimas 30 noches {metric} es {grade}, en {value}.", - "closing": { - "excellent": "Mantén la rutina que te trae hasta aquí.", - "good": "Acostarte y levantarte a horas más estables suele ser la siguiente mejora.", - "fair": "Proteger una franja de sueño constante es lo que más suele subir esto.", - "low": "Una rutina de relajación regular y una hora fija para despertar son las primeras palancas." - } - } + "subtitle": "Puntuaciones nocturnas de tu wearable" }, "recovery": { "title": "Recuperación", diff --git a/messages/fr.json b/messages/fr.json index 35bccb939..5b953e533 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3606,28 +3606,7 @@ }, "sleepQuality": { "title": "Qualité du sommeil", - "subtitle": "Scores nocturnes de votre wearable", - "assessment": { - "metric": { - "SLEEP_SCORE": "votre score de sommeil global", - "SLEEP_EFFICIENCY": "l’efficacité du sommeil", - "SLEEP_PERFORMANCE": "la performance de sommeil", - "SLEEP_CONSISTENCY": "la régularité du sommeil" - }, - "grade": { - "excellent": "excellente", - "good": "solide", - "fair": "correcte", - "low": "plutôt faible" - }, - "clause": "Sur les 30 dernières nuits, {metric} est {grade}, à {value}.", - "closing": { - "excellent": "Gardez la routine qui vous mène jusque-là.", - "good": "Des heures de coucher et de lever plus stables sont souvent le prochain gain.", - "fair": "Protéger une fenêtre de sommeil constante est ce qui fait le plus progresser ce point.", - "low": "Un rituel d’endormissement régulier et une heure de réveil fixe sont les premiers leviers." - } - } + "subtitle": "Scores nocturnes de votre wearable" }, "recovery": { "title": "Récupération", diff --git a/messages/it.json b/messages/it.json index a79038358..1c446bda3 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3606,28 +3606,7 @@ }, "sleepQuality": { "title": "Qualità del sonno", - "subtitle": "Punteggi notturni del tuo wearable", - "assessment": { - "metric": { - "SLEEP_SCORE": "il tuo punteggio di sonno complessivo", - "SLEEP_EFFICIENCY": "l’efficienza del sonno", - "SLEEP_PERFORMANCE": "la prestazione del sonno", - "SLEEP_CONSISTENCY": "la regolarità del sonno" - }, - "grade": { - "excellent": "eccellente", - "good": "solida", - "fair": "discreta", - "low": "piuttosto bassa" - }, - "clause": "Nelle ultime 30 notti {metric} è {grade}, a {value}.", - "closing": { - "excellent": "Mantieni la routine che ti porta fin qui.", - "good": "Orari di sonno e risveglio più stabili sono di solito il prossimo guadagno.", - "fair": "Proteggere una finestra di sonno costante è ciò che alza di più questo valore.", - "low": "Una routine serale regolare e un orario di sveglia fisso sono le prime leve." - } - } + "subtitle": "Punteggi notturni del tuo wearable" }, "recovery": { "title": "Recupero", diff --git a/messages/pl.json b/messages/pl.json index dc40c714a..cb6fd0d43 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3606,28 +3606,7 @@ }, "sleepQuality": { "title": "Jakość snu", - "subtitle": "Nocne wyniki z Twojego urządzenia", - "assessment": { - "metric": { - "SLEEP_SCORE": "Twój ogólny wynik snu", - "SLEEP_EFFICIENCY": "efektywność snu", - "SLEEP_PERFORMANCE": "wydajność snu", - "SLEEP_CONSISTENCY": "regularność snu" - }, - "grade": { - "excellent": "doskonała", - "good": "solidna", - "fair": "przeciętna", - "low": "raczej niska" - }, - "clause": "W ciągu ostatnich 30 nocy {metric} jest {grade} i wynosi {value}.", - "closing": { - "excellent": "Trzymaj się rutyny, która Cię tu doprowadziła.", - "good": "Bardziej stałe pory zasypiania i wstawania to zwykle następny zysk.", - "fair": "Utrzymanie stałego okna snu zwykle podnosi ten wynik najbardziej.", - "low": "Regularne wyciszenie wieczorem i stała pora pobudki to pierwsze dźwignie." - } - } + "subtitle": "Nocne wyniki z Twojego urządzenia" }, "recovery": { "title": "Regeneracja", diff --git a/src/app/insights/sleep/page.tsx b/src/app/insights/sleep/page.tsx index fda0a981f..cb261e133 100644 --- a/src/app/insights/sleep/page.tsx +++ b/src/app/insights/sleep/page.tsx @@ -104,12 +104,15 @@ export default function InsightsSchlafPage() { the HealthKit scaffold (Moon vs Sparkles). v1.18.1 — moved up to sit DIRECTLY with the duration/architecture chart - group it describes (``), rather than trailing the whole - page. The sleep-quality block below now carries its OWN assessment, so - each distinct chart group reads its own matching text — the canonical - "multiple charts ⇒ multiple texts" rule the recovery page meets. The - card mounts only on this data-bearing branch, so a source-less account - never fetches. + group it describes (``). + + v1.25 — this is now the page's SINGLE assessment. The sleep-quality + block below used to carry a second SLEEP_SCORE assessment that mostly + never generated (no wearable score), leaving a duplicate "connect an AI + provider" / empty card at the foot of the page. That bottom assessment + is gone; the quality block keeps its score tiles only. The card mounts + only on this data-bearing branch, so a source-less account never + fetches. */} ` assessment internally. + — the tiles render stored values. v1.25 — score tiles only; the page's + single assessment is the SLEEP_DURATION card above. */} diff --git a/src/components/insights/sleep/__tests__/sleep-quality-assessment.test.ts b/src/components/insights/sleep/__tests__/sleep-quality-assessment.test.ts deleted file mode 100644 index ec27b8be4..000000000 --- a/src/components/insights/sleep/__tests__/sleep-quality-assessment.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { describe, it, expect } from "vitest"; - -import { buildSleepQualityAssessment } from "../sleep-quality-assessment"; - -/** - * v1.18.6 — the deterministic sleep-quality grading that backs the grounded - * "Einschätzung" when no AI narrative exists. Pins the band thresholds and the - * worst-grade-wins overall, and the "skip ungradable metrics" contract. - */ -describe("buildSleepQualityAssessment", () => { - it("grades efficiency on the clinical 90 / 85 / 75 floors", () => { - expect( - buildSleepQualityAssessment([{ type: "SLEEP_EFFICIENCY", value: 92 }]) - ?.lead.grade, - ).toBe("excellent"); - expect( - buildSleepQualityAssessment([{ type: "SLEEP_EFFICIENCY", value: 86 }]) - ?.lead.grade, - ).toBe("good"); - expect( - buildSleepQualityAssessment([{ type: "SLEEP_EFFICIENCY", value: 78 }]) - ?.lead.grade, - ).toBe("fair"); - expect( - buildSleepQualityAssessment([{ type: "SLEEP_EFFICIENCY", value: 60 }]) - ?.lead.grade, - ).toBe("low"); - }); - - it("grades 0–100 scores on the 90 / 85 / 70 split", () => { - expect( - buildSleepQualityAssessment([{ type: "SLEEP_SCORE", value: 95 }])?.lead - .grade, - ).toBe("excellent"); - expect( - buildSleepQualityAssessment([{ type: "SLEEP_SCORE", value: 72 }])?.lead - .grade, - ).toBe("fair"); - }); - - it("opens on the headline score and keeps worst-grade-wins overall", () => { - const a = buildSleepQualityAssessment([ - { type: "SLEEP_CONSISTENCY", value: 60 }, - { type: "SLEEP_SCORE", value: 95 }, - { type: "SLEEP_EFFICIENCY", value: 92 }, - ]); - expect(a?.lead.type).toBe("SLEEP_SCORE"); - // The low consistency pulls the overall down even though the score is high. - expect(a?.overall).toBe("low"); - expect(a?.rest.length).toBe(2); - }); - - it("skips metrics it has no recognised band for, and returns null on none", () => { - // SLEEP_NEED (minutes) + disturbance count are not gradable here. - expect( - buildSleepQualityAssessment([ - { type: "SLEEP_NEED", value: 480 }, - { type: "SLEEP_DISTURBANCE_COUNT", value: 3 }, - ]), - ).toBeNull(); - }); - - it("ignores non-finite values", () => { - expect( - buildSleepQualityAssessment([{ type: "SLEEP_SCORE", value: Number.NaN }]), - ).toBeNull(); - }); -}); diff --git a/src/components/insights/sleep/__tests__/sleep-quality-grounded-note.test.tsx b/src/components/insights/sleep/__tests__/sleep-quality-grounded-note.test.tsx deleted file mode 100644 index f02cfc52d..000000000 --- a/src/components/insights/sleep/__tests__/sleep-quality-grounded-note.test.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -import { I18nProvider } from "@/lib/i18n/context"; -import { SleepQualityGroundedNote } from "../sleep-quality-grounded-note"; -import type { DataSummary } from "@/lib/analytics/trends"; - -/** - * v1.18.6 — the grounded sleep-quality note that fills the assessment slot - * when no AI narrative is available. Pins that it builds substantive copy from - * the user's own averages and that it self-suppresses when the AI is present. - */ -function summary(avg30: number): DataSummary { - return { - count: 30, - latest: avg30, - avg7: avg30, - avg30, - } as unknown as DataSummary; -} - -function render(node: React.ReactNode, locale: "en" | "de" = "en") { - return renderToStaticMarkup( - {node}, - ); -} - -describe("", () => { - const summaries = { - SLEEP_SCORE: summary(82), - SLEEP_EFFICIENCY: summary(88), - }; - - it("renders a substantive, grounded assessment when the AI is absent", () => { - const html = render( - , - ); - expect(html).toContain('data-slot="sleep-quality-grounded-note"'); - // The headline score (82 → fair on the 90/85/70 split) leads the read. - expect(html).toContain("overall sleep score"); - expect(html).toContain("fair"); - expect(html).toContain("82"); - }); - - it("renders nothing when the AI assessment is present", () => { - const html = render( - , - ); - expect(html).toBe(""); - }); - - it("renders nothing when no quality metric is gradable", () => { - const html = render( - , - ); - expect(html).toBe(""); - }); -}); diff --git a/src/components/insights/sleep/sleep-quality-assessment.ts b/src/components/insights/sleep/sleep-quality-assessment.ts deleted file mode 100644 index b2799a3d3..000000000 --- a/src/components/insights/sleep/sleep-quality-assessment.ts +++ /dev/null @@ -1,136 +0,0 @@ -/** - * v1.18.6 — deterministic, data-grounded sleep-quality assessment. - * - * The AI "Einschätzung" under the sleep-quality block reads "no assessment - * yet / unavailable" until a provider has run (and never at all on an account - * with no AI provider). That left the most universally useful read — the one - * the user's OWN numbers already support — blank. This pure helper turns the - * sleep-quality summaries that the section already has in hand into a short, - * grounded sentence: it names the night-over-night averages and grades them - * against recognised reference ranges, with NO provider call and NO recompute - * of a stored value (it only reads the server-authoritative averages). - * - * Grading thresholds are the conventional consumer-sleep-platform bands: - * - Sleep efficiency: ≥ 85 % is the textbook "good sleeper" floor (AASM); - * ≥ 90 % is excellent, < 75 % is poor. - * - Performance / consistency / headline score: 0–100, graded on the - * standard 85 / 70 split most rings use (good / fair / low). - * The copy is intentionally cautious — it describes, it never diagnoses. - * - * Kept free of React + i18n so the grading is unit-testable in isolation; the - * component maps the returned shape onto localised strings. - */ - -export type QualityGrade = "excellent" | "good" | "fair" | "low"; - -export interface SleepQualityMetricRead { - /** Registry/measurement type, e.g. `SLEEP_EFFICIENCY`. */ - type: string; - /** The averaged value the section already holds (avg30 ?? avg7 ?? latest). */ - value: number; -} - -export interface SleepQualityFinding { - type: string; - value: number; - grade: QualityGrade; -} - -export interface SleepQualityAssessment { - /** The lead metric the assessment opens on (best-known quality signal). */ - lead: SleepQualityFinding; - /** The remaining graded metrics, most-informative first. */ - rest: SleepQualityFinding[]; - /** Worst grade across the present metrics — drives the closing nudge. */ - overall: QualityGrade; -} - -/** Percent-scaled 0–100 metrics graded on the 90 / 85 / 70 ring split. */ -const PERCENT_METRICS = new Set([ - "SLEEP_SCORE", - "SLEEP_PERFORMANCE", - "SLEEP_CONSISTENCY", -]); - -/** Efficiency uses the clinical 90 / 85 / 75 floors rather than the ring split. */ -function gradeEfficiency(pct: number): QualityGrade { - if (pct >= 90) return "excellent"; - if (pct >= 85) return "good"; - if (pct >= 75) return "fair"; - return "low"; -} - -function gradeScore(value: number): QualityGrade { - if (value >= 90) return "excellent"; - if (value >= 85) return "good"; - if (value >= 70) return "fair"; - return "low"; -} - -/** - * Grade one quality metric. Returns null for a metric the helper has no - * recognised band for (e.g. raw disturbance counts or sleep-need minutes), - * so the assessment never grades a value against a scale it does not fit. - */ -function gradeMetric(type: string, value: number): QualityGrade | null { - if (type === "SLEEP_EFFICIENCY") return gradeEfficiency(value); - if (PERCENT_METRICS.has(type)) return gradeScore(value); - return null; -} - -/** Worst grade wins — a single low signal pulls the overall read down. */ -const GRADE_RANK: Record = { - excellent: 3, - good: 2, - fair: 1, - low: 0, -}; - -/** - * Lead-metric preference: the headline score reads first when present, then - * efficiency, then performance, then consistency. Keeps the opening sentence - * anchored on the most holistic signal the night carries. - */ -const LEAD_ORDER = [ - "SLEEP_SCORE", - "SLEEP_EFFICIENCY", - "SLEEP_PERFORMANCE", - "SLEEP_CONSISTENCY", -] as const; - -/** - * Build a deterministic assessment from the present quality metrics. Returns - * null when none of the supplied metrics is gradable, so the caller falls back - * to its existing copy rather than printing an empty read. - */ -export function buildSleepQualityAssessment( - reads: readonly SleepQualityMetricRead[], -): SleepQualityAssessment | null { - const findings: SleepQualityFinding[] = []; - for (const r of reads) { - if (!Number.isFinite(r.value)) continue; - const grade = gradeMetric(r.type, r.value); - if (grade == null) continue; - findings.push({ type: r.type, value: r.value, grade }); - } - if (findings.length === 0) return null; - - const leadIndex = (type: string): number => { - const i = LEAD_ORDER.indexOf(type as (typeof LEAD_ORDER)[number]); - return i === -1 ? Number.MAX_SAFE_INTEGER : i; - }; - const sorted = [...findings].sort( - (a, b) => leadIndex(a.type) - leadIndex(b.type), - ); - - const overall = sorted.reduce( - (worst, f) => (GRADE_RANK[f.grade] < GRADE_RANK[worst] ? f.grade : worst), - "excellent", - ); - - return { - lead: sorted[0], - rest: sorted.slice(1), - overall, - }; -} diff --git a/src/components/insights/sleep/sleep-quality-grounded-note.tsx b/src/components/insights/sleep/sleep-quality-grounded-note.tsx deleted file mode 100644 index 0c8311e0b..000000000 --- a/src/components/insights/sleep/sleep-quality-grounded-note.tsx +++ /dev/null @@ -1,116 +0,0 @@ -"use client"; - -import { Moon } from "lucide-react"; - -import { useTranslations } from "@/lib/i18n/context"; -import { Card, CardContent, CardHeader } from "@/components/ui/card"; -import { TileHeader } from "@/components/insights/tile-header"; -import type { DataSummary } from "@/lib/analytics/trends"; -import { - buildSleepQualityAssessment, - type QualityGrade, - type SleepQualityFinding, -} from "./sleep-quality-assessment"; - -/** - * v1.18.6 — the grounded sleep-quality "Einschätzung". - * - * The AI assessment under the sleep-quality block reads "no assessment yet" - * until a provider has run, and stays blank forever on an account with no AI - * provider. This card fills that gap with a substantive read built ENTIRELY - * from the user's own quality averages (efficiency / performance / consistency - * / headline score) graded against recognised reference bands — no provider, - * no recompute of a stored value. It describes; it does not diagnose. - * - * Mounted only when the AI assessment is NOT present (`showWhenAiAbsent`), so - * the richer AI narrative still wins when it exists and the user never sees - * two assessments stacked. - */ - -const QUALITY_TYPES = [ - "SLEEP_SCORE", - "SLEEP_EFFICIENCY", - "SLEEP_PERFORMANCE", - "SLEEP_CONSISTENCY", -] as const; - -/** Map a finding to its localised "{metric} is {grade} at {value}" clause. */ -function clauseFor( - finding: SleepQualityFinding, - t: (key: string, vars?: Record) => string, -): string { - const metricLabel = t( - `insights.sleepQuality.assessment.metric.${finding.type}`, - ); - const gradeLabel = t( - `insights.sleepQuality.assessment.grade.${finding.grade}`, - ); - // Efficiency / performance / consistency are percentages; the headline score - // is a 0–100 index. Both read naturally as a rounded integer. - const value = Math.round(finding.value); - return t("insights.sleepQuality.assessment.clause", { - metric: metricLabel, - grade: gradeLabel, - value, - }); -} - -export interface SleepQualityGroundedNoteProps { - /** The analytics summaries the section already holds (no extra fetch). */ - summaries: Record | undefined; - /** - * Whether the AI assessment is absent. The grounded note only renders when - * the AI narrative is not there, so the two never stack. - */ - showWhenAiAbsent: boolean; -} - -export function SleepQualityGroundedNote({ - summaries, - showWhenAiAbsent, -}: SleepQualityGroundedNoteProps) { - const { t } = useTranslations(); - - if (!showWhenAiAbsent || !summaries) return null; - - const reads = QUALITY_TYPES.map((type) => { - const s = summaries[type]; - const value = s?.avg30 ?? s?.avg7 ?? s?.latest ?? null; - return value == null - ? null - : ({ type, value } as { type: string; value: number }); - }).filter((r): r is { type: string; value: number } => r !== null); - - const assessment = buildSleepQualityAssessment(reads); - if (!assessment) return null; - - const clauses = [assessment.lead, ...assessment.rest].map((f) => - clauseFor(f, t), - ); - const closing = t( - `insights.sleepQuality.assessment.closing.${assessment.overall as QualityGrade}`, - ); - - return ( - - - - - -

    - {clauses.join(" ")} {closing} -

    -
    -
    - ); -} - -function QualityNoteIcon({ className }: { className?: string }) { - return