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] 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, };