diff --git a/CHANGELOG.md b/CHANGELOG.md index acd329079..daf0b6c2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ ## [Unreleased] +## [1.28.50] — 2026-07-16 — View your ECG recordings in Insights + +ECG recordings synced from a compatible watch were stored but never shown. They +now have a place in Insights: a list of your recordings, each opening the full +waveform on a familiar ECG grid, with the date, duration, average heart rate, +and the result your device recorded. + +The result shown is the one your recording device produced — HealthLog does not +read or interpret the trace and does not provide a diagnosis. A recording is +single-lead and for personal awareness only; if a result concerns you, a note +alongside it points you to discuss the recording with a clinician. + ## [1.28.49] — 2026-07-16 — Apple Health re-import no longer stops on a duplicate reading Re-importing a cumulative export.zip could run for several minutes and then fail @@ -14,7 +26,7 @@ carries on. A re-import completes cleanly. ## [1.28.48] — 2026-07-16 — Security follow-ups -Two fixes from the campaign's security re-verification: +Two security fixes: - On a brand-new instance with single-sign-on configured, two people signing in for the very first time at the same moment could both be made admin. The diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index ba918fbbd..402e6b32b 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.28.49 + version: 1.28.50 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -6688,6 +6688,76 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/insights/ecg: + get: + tags: + - Insights + summary: ECG recording list (metadata only) + description: "v1.28.50 — the authenticated user's ECG recordings as a cheap, index-covered metadata list (recorded time, + duration, sampling rate, sample count, average heart rate, lead, and the DEVICE's own rhythm classification). + NEVER decrypts or returns the waveform — the per-recording strip is fetched on demand from GET + /api/insights/ecg/{id}. Reflects only the recording device's certified on-device classification, verbatim; + HealthLog never re-classifies an ECG or produces a diagnosis. Data-availability-gated: an empty account returns + `hasRecordings: false`. Module-gated on `insights` and the operator `insightStatus` assistant surface; no LLM + call. Auth via cookie or Bearer." + responses: + "200": + description: The ECG recording list (possibly empty). + content: + application/json: + schema: + $ref: "#/components/schemas/EcgListResponseEnvelope" + "401": *a1 + "403": *a5 + "422": *a3 + "429": *a4 + /api/insights/ecg/{id}: + get: + tags: + - Insights + summary: One ECG recording with waveform + description: v1.28.50 — one recording's decrypted waveform plus metadata and the DEVICE's verbatim classification. + Ownership is narrowed in the query where (`{ id, userId }`) so a cross-user read is structurally impossible; a + foreign or unknown id 404s (existence sealed). The waveform is AES-256-GCM at rest, decrypted through the + fail-closed codec. By default the ~9000-sample strip is min/max-decimated to ~2500 display points so R-wave + peaks survive; `?full=1` returns the raw array. HealthLog does not interpret the trace, measure intervals, + annotate beats, or emit a verdict of its own. Module-gated on `insights` and the operator `insightStatus` + assistant surface; no LLM call. `no-store`. Auth via cookie or Bearer. + parameters: + - in: path + name: id + schema: + type: string + description: The ECG recording id (cuid). + required: true + description: The ECG recording id (cuid). + - in: query + name: full + schema: + description: "`1` returns the RAW, un-decimated sample array (the true-calibration 25 mm/s · 10 mm/mV zoom view). + Omitted returns the min/max-decimated ~2500-point fit-to-width overview (`decimated: true`)." + type: string + const: "1" + description: "`1` returns the RAW, un-decimated sample array (the true-calibration 25 mm/s · 10 mm/mV zoom view). + Omitted returns the min/max-decimated ~2500-point fit-to-width overview (`decimated: true`)." + responses: + "200": + description: The recording's waveform + metadata + device classification. + content: + application/json: + schema: + $ref: "#/components/schemas/EcgDetailResponseEnvelope" + "401": *a1 + "403": *a5 + "404": + description: No ECG recording with that id for the authenticated user (existence sealed — a foreign id is + indistinguishable from a missing one). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 /api/insights/correlations: get: tags: @@ -12049,6 +12119,7 @@ components: - health-status - breathing - labs-changes + - ecg visible: type: boolean order: @@ -24709,6 +24780,199 @@ components: additionalProperties: false description: "Deterministic weight-plateau detection for users on an active GLP-1 medication: stable dose for ≥ the window with no weight loss beyond the threshold. Association only — carries no verdict or advice." + EcgListResponseEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/EcgListResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + EcgListResponse: + type: object + properties: + recordings: + type: array + items: + $ref: "#/components/schemas/EcgRecordingListItem" + description: The user's ECG recordings, newest first (capped at 200). + hasRecordings: + type: boolean + description: False for an account with no recordings — the client un-mounts the whole ECG surface (data-availability + floor). + required: + - recordings + - hasRecordings + additionalProperties: false + description: Metadata-only ECG recording list. Never carries the waveform — the per-recording strip is fetched on demand + from GET /api/insights/ecg/{id}. Read-only; no LLM call. Reflects only the device's own classification, never a + HealthLog interpretation. + EcgRecordingListItem: + type: object + properties: + id: + type: string + description: Recording id (cuid). + recordedAt: + 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)))$ + description: On-device recording time (the display timestamp). + durationSeconds: + anyOf: + - type: number + - type: "null" + description: "`sampleCount / samplingFrequency` in seconds; null when the source omitted the sampling frequency." + samplingFrequency: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Signal sampling rate in Hz (Withings ScanWatch = 300); 0 when the source omitted it. + sampleCount: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Number of samples in the (encrypted) waveform array. + averageHeartRate: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: Source-reported average heart rate (BPM) for the strip, when present. + lead: + anyOf: + - type: string + - type: "null" + description: Recording lead label when the source reports it; null for the single-lead ScanWatch (Lead I). + classification: + anyOf: + - type: string + enum: + - IRREGULAR + - NOT_DETECTED + - INCONCLUSIVE + - type: "null" + description: "The RECORDING DEVICE's own rhythm classification, surfaced verbatim: IRREGULAR (device flagged possible + atrial fibrillation), NOT_DETECTED (algorithm ran, nothing flagged), INCONCLUSIVE (poor signal / + out-of-range HR). Null when the source reported none. HealthLog never re-classifies an ECG — this is the + device's certified on-device result, not a HealthLog verdict." + source: + type: string + description: Measurement source that wrote the recording (e.g. WITHINGS). + hasWaveform: + type: boolean + description: True when a waveform is stored (`sampleCount > 0`); false for a verdict-only fallback event with no signal + to fetch. + required: + - id + - recordedAt + - durationSeconds + - samplingFrequency + - sampleCount + - averageHeartRate + - lead + - classification + - source + - hasWaveform + additionalProperties: false + EcgDetailResponseEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/EcgDetailResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + EcgDetailResponse: + type: object + properties: + recordedAt: + 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)))$ + description: On-device recording time (the display timestamp). + durationSeconds: + anyOf: + - type: number + - type: "null" + description: Strip duration in seconds; null when the sampling frequency was 0. + samplingFrequency: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Signal sampling rate in Hz (300 for a ScanWatch). + averageHeartRate: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: Source-reported average heart rate (BPM) for the strip, when present. + lead: + anyOf: + - type: string + - type: "null" + description: Recording lead label when reported; null for the single-lead ScanWatch (Lead I). + classification: + anyOf: + - type: string + enum: + - IRREGULAR + - NOT_DETECTED + - INCONCLUSIVE + - type: "null" + description: "The RECORDING DEVICE's own rhythm classification, surfaced verbatim: IRREGULAR (device flagged possible + atrial fibrillation), NOT_DETECTED (algorithm ran, nothing flagged), INCONCLUSIVE (poor signal / + out-of-range HR). Null when the source reported none. HealthLog never re-classifies an ECG — this is the + device's certified on-device result, not a HealthLog verdict." + source: + type: string + description: Measurement source that wrote the recording. + samples: + type: array + items: + type: number + description: Micro-volt waveform samples. Min/max-decimated to ~2500 points by default so R-wave peaks survive the + fit-to-width compression; the raw array when `?full=1`. The x-position of each sample is implied by its + index (an overview trace, not a calibrated-time axis) unless `decimated` is false. + decimated: + type: boolean + description: True when `samples` was min/max-decimated for display; false when the raw array is returned (`?full=1`, or + a strip already at or below the display budget). + required: + - recordedAt + - durationSeconds + - samplingFrequency + - averageHeartRate + - lead + - classification + - source + - samples + - decimated + additionalProperties: false + description: One ECG recording's decrypted waveform plus metadata and the DEVICE's verbatim classification. Ownership is + narrowed in the query where — a foreign / unknown id 404s. The waveform is AES-256-GCM at rest, decrypted + through the fail-closed codec. HealthLog does not interpret the trace, measure intervals, annotate beats, or + emit a verdict of its own. no-store. CorrelationDiscoveryResponseEnvelope: type: object properties: @@ -29665,6 +29929,7 @@ components: - health-status - breathing - labs-changes + - ecg visible: type: boolean order: diff --git a/messages/de.json b/messages/de.json index 85b1f7bf8..ba9a11a80 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3834,6 +3834,36 @@ "cta": "Taille-zu-Größe-Verhältnis erfassen" }, "coachPrefill": "Ich habe noch kein Taille-zu-Größe-Verhältnis erfasst — warum ist es eine nützliche Zahl, und wie halte ich es unter 0,5?" + }, + "ecg": { + "sectionTitle": "EKG-Aufzeichnungen", + "sectionIntro": "Einkanal-EKG-Streifen, die dein Gerät aufgezeichnet hat. HealthLog zeigt nur die Aufzeichnung und das von deinem Gerät erstellte Ergebnis – die Kurve wird nicht gelesen oder ausgewertet.", + "loadError": "Deine EKG-Aufzeichnungen konnten nicht geladen werden.", + "backToList": "Zurück zu den Aufzeichnungen", + "disclaimer": "Dies ist das Ergebnis, das dein Aufzeichnungsgerät erstellt hat. HealthLog liest oder interpretiert EKG-Aufzeichnungen nicht und stellt keine Diagnose.", + "clinicianNote": "Wenn dich dieses Ergebnis beunruhigt, besprich diese Aufzeichnung mit einer Ärztin oder einem Arzt.", + "resultAttribution": "Aufgezeichnetes Ergebnis: {result}, wie vom Aufzeichnungsgerät gemeldet.", + "result": { + "irregular": "Vorhofflimmern erkannt", + "notDetected": "Keine Anzeichen von Vorhofflimmern", + "inconclusive": "Nicht eindeutige Aufzeichnung", + "none": "Kein Ergebnis gemeldet" + }, + "meta": { + "recorded": "Aufgezeichnet", + "duration": "Dauer", + "lead": "Ableitung", + "averageHeartRate": "Durchschnittliche Herzfrequenz", + "samplingRate": "Abtastrate", + "leadSingle": "Einkanal (Ableitung I)", + "durationValue": "{seconds} s", + "bpmValue": "{bpm} bpm", + "hzValue": "{hz} Hz", + "unknown": "—" + }, + "waveform": { + "ariaLabel": "EKG-Streifen aufgezeichnet {date}, Dauer {duration}, durchschnittliche Herzfrequenz {heartRate}, aufgezeichnetes Ergebnis: {result}." + } } }, "thresholds": { diff --git a/messages/en.json b/messages/en.json index f7f780413..a335675f8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3834,6 +3834,36 @@ "cta": "Log waist-to-height ratio" }, "coachPrefill": "I haven't logged a waist-to-height ratio yet — why is it a useful number, and how do I keep it under 0.5?" + }, + "ecg": { + "sectionTitle": "ECG recordings", + "sectionIntro": "Single-lead ECG strips recorded by your device. HealthLog only shows the recording and the result your device produced — it does not read or interpret the waveform.", + "loadError": "Could not load your ECG recordings.", + "backToList": "Back to recordings", + "disclaimer": "This is the result your recording device produced. HealthLog does not read or interpret ECG recordings and does not provide a diagnosis.", + "clinicianNote": "If this result concerns you, discuss this recording with a clinician.", + "resultAttribution": "Recorded result: {result}, as reported by the recording device.", + "result": { + "irregular": "Atrial fibrillation detected", + "notDetected": "No signs of atrial fibrillation", + "inconclusive": "Inconclusive recording", + "none": "No result reported" + }, + "meta": { + "recorded": "Recorded", + "duration": "Duration", + "lead": "Lead", + "averageHeartRate": "Average heart rate", + "samplingRate": "Sampling rate", + "leadSingle": "Single lead (Lead I)", + "durationValue": "{seconds} s", + "bpmValue": "{bpm} bpm", + "hzValue": "{hz} Hz", + "unknown": "—" + }, + "waveform": { + "ariaLabel": "ECG strip recorded {date}, duration {duration}, average heart rate {heartRate}, recorded result: {result}." + } } }, "thresholds": { diff --git a/messages/es.json b/messages/es.json index 40507a47b..ad6da1321 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3834,6 +3834,36 @@ "cta": "Registrar índice cintura-altura" }, "coachPrefill": "Aún no he registrado un índice cintura-altura: ¿por qué es un número útil y cómo lo mantengo por debajo de 0,5?" + }, + "ecg": { + "sectionTitle": "Registros de ECG", + "sectionIntro": "Tiras de ECG de una sola derivación registradas por tu dispositivo. HealthLog solo muestra el registro y el resultado que produjo tu dispositivo; no lee ni interpreta la onda.", + "loadError": "No se pudieron cargar tus registros de ECG.", + "backToList": "Volver a los registros", + "disclaimer": "Este es el resultado que produjo tu dispositivo de registro. HealthLog no lee ni interpreta los registros de ECG y no proporciona un diagnóstico.", + "clinicianNote": "Si este resultado te preocupa, comenta este registro con un profesional médico.", + "resultAttribution": "Resultado registrado: {result}, según lo informado por el dispositivo de registro.", + "result": { + "irregular": "Fibrilación auricular detectada", + "notDetected": "Sin signos de fibrilación auricular", + "inconclusive": "Registro no concluyente", + "none": "Sin resultado informado" + }, + "meta": { + "recorded": "Registrado", + "duration": "Duración", + "lead": "Derivación", + "averageHeartRate": "Frecuencia cardíaca media", + "samplingRate": "Frecuencia de muestreo", + "leadSingle": "Una sola derivación (derivación I)", + "durationValue": "{seconds} s", + "bpmValue": "{bpm} bpm", + "hzValue": "{hz} Hz", + "unknown": "—" + }, + "waveform": { + "ariaLabel": "Tira de ECG registrada el {date}, duración {duration}, frecuencia cardíaca media {heartRate}, resultado registrado: {result}." + } } }, "thresholds": { diff --git a/messages/fr.json b/messages/fr.json index 4191e0ab3..7171c27d5 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3834,6 +3834,36 @@ "cta": "Enregistrer le rapport taille-hauteur" }, "coachPrefill": "Je n'ai pas encore enregistré de rapport taille-hauteur — pourquoi est-ce un chiffre utile, et comment le maintenir sous 0,5 ?" + }, + "ecg": { + "sectionTitle": "Enregistrements ECG", + "sectionIntro": "Tracés ECG à une seule dérivation enregistrés par votre appareil. HealthLog affiche uniquement l'enregistrement et le résultat produit par votre appareil ; il ne lit ni n'interprète le tracé.", + "loadError": "Impossible de charger vos enregistrements ECG.", + "backToList": "Retour aux enregistrements", + "disclaimer": "Il s'agit du résultat produit par votre appareil d'enregistrement. HealthLog ne lit ni n'interprète les enregistrements ECG et ne fournit aucun diagnostic.", + "clinicianNote": "Si ce résultat vous préoccupe, parlez de cet enregistrement à un professionnel de santé.", + "resultAttribution": "Résultat enregistré : {result}, tel que signalé par l'appareil d'enregistrement.", + "result": { + "irregular": "Fibrillation auriculaire détectée", + "notDetected": "Aucun signe de fibrillation auriculaire", + "inconclusive": "Enregistrement non concluant", + "none": "Aucun résultat signalé" + }, + "meta": { + "recorded": "Enregistré", + "duration": "Durée", + "lead": "Dérivation", + "averageHeartRate": "Fréquence cardiaque moyenne", + "samplingRate": "Fréquence d'échantillonnage", + "leadSingle": "Une seule dérivation (dérivation I)", + "durationValue": "{seconds} s", + "bpmValue": "{bpm} bpm", + "hzValue": "{hz} Hz", + "unknown": "—" + }, + "waveform": { + "ariaLabel": "Tracé ECG enregistré le {date}, durée {duration}, fréquence cardiaque moyenne {heartRate}, résultat enregistré : {result}." + } } }, "thresholds": { diff --git a/messages/it.json b/messages/it.json index 3cea4cbb7..7d2f4f058 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3834,6 +3834,36 @@ "cta": "Registra il rapporto vita-altezza" }, "coachPrefill": "Non ho ancora registrato un rapporto vita-altezza — perché è un numero utile e come lo tengo sotto 0,5?" + }, + "ecg": { + "sectionTitle": "Registrazioni ECG", + "sectionIntro": "Tracciati ECG a derivazione singola registrati dal tuo dispositivo. HealthLog mostra solo la registrazione e il risultato prodotto dal dispositivo; non legge né interpreta il tracciato.", + "loadError": "Impossibile caricare le tue registrazioni ECG.", + "backToList": "Torna alle registrazioni", + "disclaimer": "Questo è il risultato prodotto dal tuo dispositivo di registrazione. HealthLog non legge né interpreta le registrazioni ECG e non fornisce una diagnosi.", + "clinicianNote": "Se questo risultato ti preoccupa, parla di questa registrazione con un medico.", + "resultAttribution": "Risultato registrato: {result}, come riportato dal dispositivo di registrazione.", + "result": { + "irregular": "Fibrillazione atriale rilevata", + "notDetected": "Nessun segno di fibrillazione atriale", + "inconclusive": "Registrazione non conclusiva", + "none": "Nessun risultato riportato" + }, + "meta": { + "recorded": "Registrato", + "duration": "Durata", + "lead": "Derivazione", + "averageHeartRate": "Frequenza cardiaca media", + "samplingRate": "Frequenza di campionamento", + "leadSingle": "Derivazione singola (derivazione I)", + "durationValue": "{seconds} s", + "bpmValue": "{bpm} bpm", + "hzValue": "{hz} Hz", + "unknown": "—" + }, + "waveform": { + "ariaLabel": "Tracciato ECG registrato il {date}, durata {duration}, frequenza cardiaca media {heartRate}, risultato registrato: {result}." + } } }, "thresholds": { diff --git a/messages/pl.json b/messages/pl.json index c0fe16053..febb2845d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3834,6 +3834,36 @@ "cta": "Zapisz stosunek talii do wzrostu" }, "coachPrefill": "Nie zapisałem jeszcze stosunku talii do wzrostu — dlaczego to przydatna liczba i jak utrzymać ją poniżej 0,5?" + }, + "ecg": { + "sectionTitle": "Zapisy EKG", + "sectionIntro": "Jednoodprowadzeniowe zapisy EKG zarejestrowane przez Twoje urządzenie. HealthLog pokazuje tylko zapis i wynik wygenerowany przez urządzenie — nie odczytuje ani nie interpretuje przebiegu.", + "loadError": "Nie udało się wczytać Twoich zapisów EKG.", + "backToList": "Powrót do zapisów", + "disclaimer": "To jest wynik wygenerowany przez Twoje urządzenie rejestrujące. HealthLog nie odczytuje ani nie interpretuje zapisów EKG i nie stawia diagnozy.", + "clinicianNote": "Jeśli ten wynik Cię niepokoi, omów ten zapis z lekarzem.", + "resultAttribution": "Zarejestrowany wynik: {result}, zgodnie z informacją z urządzenia rejestrującego.", + "result": { + "irregular": "Wykryto migotanie przedsionków", + "notDetected": "Brak oznak migotania przedsionków", + "inconclusive": "Zapis niejednoznaczny", + "none": "Nie zgłoszono wyniku" + }, + "meta": { + "recorded": "Zarejestrowano", + "duration": "Czas trwania", + "lead": "Odprowadzenie", + "averageHeartRate": "Średnie tętno", + "samplingRate": "Częstotliwość próbkowania", + "leadSingle": "Jedno odprowadzenie (odprowadzenie I)", + "durationValue": "{seconds} s", + "bpmValue": "{bpm} bpm", + "hzValue": "{hz} Hz", + "unknown": "—" + }, + "waveform": { + "ariaLabel": "Zapis EKG zarejestrowany {date}, czas trwania {duration}, średnie tętno {heartRate}, zarejestrowany wynik: {result}." + } } }, "thresholds": { diff --git a/package.json b/package.json index 5d0224cd0..d3bceb52b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.28.49", + "version": "1.28.50", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 0ceaa7399..4e485b61e 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.28.49"; + /* @sw-version-fallback */ "v1.28.50"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` 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 99ba58ba5..17dd78f64 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 @@ -67,6 +67,12 @@ const NON_COACH_GATED_ROUTES: ReadonlyArray = [ // the same `insightStatus` sub-flag as the assessment routes (no Coach // prose). "src/app/api/insights/rhythm-events/route.ts", + // v1.28.50 — ECG recording surface (list + per-recording waveform). Pure + // DB read of the device's own recordings + verdicts; gates on the same + // `insightStatus` sub-flag as the assessment routes (no Coach prose — the + // waveform is never interpreted). + "src/app/api/insights/ecg/route.ts", + "src/app/api/insights/ecg/[id]/route.ts", "src/app/api/insights/weight-status/route.ts", ]; diff --git a/src/app/api/insights/ecg/[id]/__tests__/route.test.ts b/src/app/api/insights/ecg/[id]/__tests__/route.test.ts new file mode 100644 index 000000000..125e93b9c --- /dev/null +++ b/src/app/api/insights/ecg/[id]/__tests__/route.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { NextRequest } from "next/server"; + +/** + * v1.28.50 — `GET /api/insights/ecg/[id]` (waveform detail). + * + * Load-bearing behaviour under test: + * - ownership is narrowed IN THE where ({ id, userId }) so a foreign id + * is null and 404s (cross-user leak structurally impossible); + * - the waveform is decrypted through the real fail-closed codec + * (encrypt → Bytes → route → decrypt round-trip); + * - the ~9000-sample strip is min/max-decimated to ~2500 by default and + * `?full=1` returns the raw array; + * - the response is `no-store`. + */ + +vi.mock("@/lib/db", () => ({ + prisma: { + appSettings: { findUnique: vi.fn().mockResolvedValue(null) }, + ecgRecording: { findFirst: vi.fn() }, + }, +})); + +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: () => {}, + })), +})); + +import { GET } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { prisma } from "@/lib/db"; +import { _resetCryptoCacheForTests } from "@/lib/crypto"; +import { encryptWaveformToBytes } from "@/lib/withings/ecg-waveform-codec"; + +const KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +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", + }, +}; + +type Ctx = { params: Promise<{ id: string }> }; +const callGet = GET as unknown as ( + req: NextRequest, + ctx: Ctx, +) => Promise; + +function makeReq(query = ""): NextRequest { + return new NextRequest( + new URL(`http://localhost/api/insights/ecg/ecg_1${query}`), + ); +} +function ctx(id = "ecg_1"): Ctx { + return { params: Promise.resolve({ id }) }; +} + +function rowWith(samples: number[]) { + return { + recordedAt: new Date("2026-06-01T09:15:00.000Z"), + durationSeconds: 30, + samplingFrequency: 300, + averageHeartRate: 72, + lead: null, + rhythmClassification: "IRREGULAR", + source: "WITHINGS", + waveformEncrypted: encryptWaveformToBytes(samples), + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); + vi.stubEnv("ENCRYPTION_KEYS", ""); + vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", ""); + vi.stubEnv("ENCRYPTION_KEY", KEY); + _resetCryptoCacheForTests(); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + _resetCryptoCacheForTests(); +}); + +describe("GET /api/insights/ecg/[id]", () => { + it("narrows ownership in the where and 404s an unknown / foreign id", async () => { + vi.mocked(prisma.ecgRecording.findFirst).mockResolvedValue(null as never); + const res = await callGet(makeReq(), ctx("ecg_x")); + expect(res.status).toBe(404); + const where = vi.mocked(prisma.ecgRecording.findFirst).mock.calls[0][0] + ?.where as { id: string; userId: string }; + expect(where).toEqual({ id: "ecg_x", userId: "user-1" }); + }); + + it("decrypts the waveform and min/max-decimates the ~9000-sample strip", async () => { + const raw = Array.from({ length: 9000 }, (_, i) => + Math.round(Math.sin(i / 8) * 500), + ); + raw[4500] = 9999; // an R-wave peak that must survive + vi.mocked(prisma.ecgRecording.findFirst).mockResolvedValue( + rowWith(raw) as never, + ); + + const res = await callGet(makeReq(), ctx()); + expect(res.status).toBe(200); + expect(res.headers.get("Cache-Control")).toBe("no-store"); + const body = (await res.json()) as { + data: { + samples: number[]; + decimated: boolean; + classification: string; + }; + }; + expect(body.data.decimated).toBe(true); + expect(body.data.samples.length).toBeLessThanOrEqual(2500); + expect(body.data.samples.length).toBeLessThan(9000); + // The device verdict is surfaced verbatim. + expect(body.data.classification).toBe("IRREGULAR"); + // The peak survives the decimation (min/max, never a naive stride). + expect(Math.max(...body.data.samples)).toBe(9999); + }); + + it("returns the raw array unchanged when ?full=1", async () => { + const raw = Array.from({ length: 9000 }, (_, i) => i % 17); + vi.mocked(prisma.ecgRecording.findFirst).mockResolvedValue( + rowWith(raw) as never, + ); + + const res = await callGet(makeReq("?full=1"), ctx()); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { samples: number[]; decimated: boolean }; + }; + expect(body.data.decimated).toBe(false); + expect(body.data.samples).toEqual(raw); + }); +}); diff --git a/src/app/api/insights/ecg/[id]/route.ts b/src/app/api/insights/ecg/[id]/route.ts new file mode 100644 index 000000000..ac0d0c31b --- /dev/null +++ b/src/app/api/insights/ecg/[id]/route.ts @@ -0,0 +1,101 @@ +/** + * v1.28.50 — single ECG recording WITH waveform. + * + * `GET /api/insights/ecg/[id]` returns one recording's decrypted waveform + * plus its metadata and the DEVICE's own rhythm classification. Ownership + * is narrowed IN THE `where` (`{ id, userId }`) so a cross-user read is + * structurally impossible — a foreign or unknown id resolves to null and + * 404s, sealing existence exactly like the documents cache-leak fix. + * + * The waveform is AES-256-GCM at rest; it is decrypted through the + * existing fail-closed `decryptWaveformFromBytes` (a bad key id or a + * non-array payload throws rather than leaking plaintext). By default the + * ~9000-sample strip is min/max-decimated to ~2500 display points so the + * R-wave peaks survive the reduction (see `decimateMinMax`); `?full=1` + * returns the raw array for the true-calibration zoom view. + * + * Regulatory framing (load-bearing): the response carries the waveform, + * the metadata, and the device's verbatim `classification` ONLY. HealthLog + * does not interpret the trace, measure intervals, annotate beats, or emit + * a verdict of its own. + * + * Mirrors the `rhythm-events` gating: `apiHandler`, cookie OR Bearer auth, + * `userId` from the session, the `insights` module gate, and the + * `insightStatus` assistant-surface gate. No AI provider call. + */ +import { NextRequest } from "next/server"; + +import { apiError, apiSuccess } from "@/lib/api-response"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { requireAssistantSurface } from "@/lib/feature-flags"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { prisma } from "@/lib/db"; +import { decryptWaveformFromBytes } from "@/lib/withings/ecg-waveform-codec"; +import { + ECG_DISPLAY_TARGET_POINTS, + decimateMinMax, +} from "@/lib/insights/ecg-decimate"; + +export const dynamic = "force-dynamic"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const GET = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const m = await requireModuleEnabled(user.id, "insights"); + if (!m.enabled) return m.response; + await requireAssistantSurface("insightStatus"); + + const { id } = await params; + const full = request.nextUrl.searchParams.get("full") === "1"; + + // Ownership narrowed in the where — a foreign / unknown id is null. + const row = await prisma.ecgRecording.findFirst({ + where: { id, userId: user.id }, + select: { + recordedAt: true, + durationSeconds: true, + samplingFrequency: true, + averageHeartRate: true, + lead: true, + rhythmClassification: true, + source: true, + waveformEncrypted: true, + }, + }); + + if (!row) { + return apiError("ECG recording not found", 404); + } + + // Fail-closed decrypt (throws on a bad key id / non-array payload). + const raw = decryptWaveformFromBytes(row.waveformEncrypted); + + const decimated = !full && raw.length > ECG_DISPLAY_TARGET_POINTS; + const samples = decimated + ? decimateMinMax(raw, ECG_DISPLAY_TARGET_POINTS) + : raw; + + annotate({ + action: { name: "insights.ecg.detail" }, + meta: { sampleCount: raw.length, decimated }, + }); + + const res = apiSuccess({ + recordedAt: row.recordedAt.toISOString(), + durationSeconds: row.durationSeconds, + samplingFrequency: row.samplingFrequency, + averageHeartRate: row.averageHeartRate, + lead: row.lead, + classification: row.rhythmClassification, + source: row.source, + samples, + decimated, + }); + // Never cache a decrypted health-data waveform at any hop. + res.headers.set("Cache-Control", "no-store"); + return res; + }, +); diff --git a/src/app/api/insights/ecg/__tests__/route.test.ts b/src/app/api/insights/ecg/__tests__/route.test.ts new file mode 100644 index 000000000..413465864 --- /dev/null +++ b/src/app/api/insights/ecg/__tests__/route.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +/** + * v1.28.50 — `GET /api/insights/ecg` (metadata-only recording list). + * + * Load-bearing behaviour under test: it narrows the query to the session + * user, NEVER selects the encrypted waveform, returns the device's own + * classification per row, and reports `hasRecordings: false` (the + * data-availability gate the UI keys off) when the user has none. + */ + +vi.mock("@/lib/db", () => ({ + prisma: { + appSettings: { findUnique: vi.fn().mockResolvedValue(null) }, + ecgRecording: { findMany: vi.fn() }, + }, +})); + +// Module gate mocked default-enabled; the off → 403 coverage lives in the +// module-route-gate inventory test (the route calls requireModuleEnabled). +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: () => {}, + })), +})); + +import { GET } 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: "testuser", + role: "USER" as const, + locale: "en", + }, +}; + +const callGet = GET as unknown as (req: NextRequest) => Promise; +function makeReq(): NextRequest { + return new NextRequest(new URL("http://localhost/api/insights/ecg")); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); +}); + +describe("GET /api/insights/ecg", () => { + it("returns hasRecordings:false with an empty list when the user has none", async () => { + vi.mocked(prisma.ecgRecording.findMany).mockResolvedValue([] as never); + const res = await callGet(makeReq()); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { recordings: unknown[]; hasRecordings: boolean }; + }; + expect(body.data.hasRecordings).toBe(false); + expect(body.data.recordings).toEqual([]); + }); + + it("narrows the query to the session user and never selects the waveform", async () => { + vi.mocked(prisma.ecgRecording.findMany).mockResolvedValue([] as never); + await callGet(makeReq()); + const arg = vi.mocked(prisma.ecgRecording.findMany).mock.calls[0][0] as { + where: { userId: string }; + select: Record; + }; + expect(arg.where.userId).toBe("user-1"); + // The encrypted blob must never be pulled into the list read. + expect(arg.select.waveformEncrypted).toBeUndefined(); + expect(arg.select.id).toBe(true); + expect(arg.select.rhythmClassification).toBe(true); + }); + + it("maps each row to metadata + the device verdict, flagging hasWaveform", async () => { + vi.mocked(prisma.ecgRecording.findMany).mockResolvedValue([ + { + id: "ecg_1", + recordedAt: new Date("2026-06-01T09:15:00.000Z"), + durationSeconds: 30, + samplingFrequency: 300, + sampleCount: 9000, + averageHeartRate: 72, + lead: null, + rhythmClassification: "IRREGULAR", + source: "WITHINGS", + }, + { + id: "ecg_2", + recordedAt: new Date("2026-05-01T09:15:00.000Z"), + durationSeconds: null, + samplingFrequency: 0, + sampleCount: 0, + averageHeartRate: null, + lead: null, + rhythmClassification: "NOT_DETECTED", + source: "WITHINGS", + }, + ] as never); + const res = await callGet(makeReq()); + const body = (await res.json()) as { + data: { + hasRecordings: boolean; + recordings: Array<{ + id: string; + classification: string | null; + hasWaveform: boolean; + recordedAt: string; + }>; + }; + }; + expect(body.data.hasRecordings).toBe(true); + expect(body.data.recordings[0]).toMatchObject({ + id: "ecg_1", + classification: "IRREGULAR", + hasWaveform: true, + recordedAt: "2026-06-01T09:15:00.000Z", + }); + // A verdict-only fallback (sampleCount 0) reports hasWaveform:false. + expect(body.data.recordings[1].hasWaveform).toBe(false); + expect(body.data.recordings[1].classification).toBe("NOT_DETECTED"); + }); +}); diff --git a/src/app/api/insights/ecg/route.ts b/src/app/api/insights/ecg/route.ts new file mode 100644 index 000000000..f997721b1 --- /dev/null +++ b/src/app/api/insights/ecg/route.ts @@ -0,0 +1,87 @@ +/** + * v1.28.50 — ECG recording list route (metadata only, no waveform). + * + * `GET /api/insights/ecg` returns the authenticated user's ECG recordings + * as a cheap, index-covered metadata list — recorded time, duration, + * sampling rate, sample count, average heart rate, lead, and the DEVICE's + * own rhythm classification. It NEVER decrypts or returns the waveform; + * the per-recording strip is fetched on demand via + * `GET /api/insights/ecg/[id]`. + * + * Regulatory framing (load-bearing): this surface reflects ONLY the + * classification RESULT the recording device's certified on-device + * algorithm produced. HealthLog never re-classifies an ECG, never reads + * the waveform to form a verdict, and never produces a diagnosis. The + * `classification` field the client renders is the device's, verbatim. + * + * Data-availability-gated by construction: an account with no recordings + * gets `{ recordings: [], hasRecordings: false }`, and the client un-mounts + * the whole surface rather than painting an empty card. + * + * Mirrors the `rhythm-events` route gating exactly: `apiHandler` wrapper, + * cookie OR Bearer auth, `userId` narrowed from the session (never a query + * field), the `insights` module gate, and the `insightStatus` + * assistant-surface gate. No AI provider call — this is a pure DB read. + */ +import { apiSuccess } from "@/lib/api-response"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { requireAssistantSurface } from "@/lib/feature-flags"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { prisma } from "@/lib/db"; + +export const dynamic = "force-dynamic"; + +// Defensive cap. A ScanWatch user records a handful to a few dozen strips +// over time; this is a bound, not an expected ceiling. +const MAX_RECORDINGS = 200; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + const m = await requireModuleEnabled(user.id, "insights"); + if (!m.enabled) return m.response; + await requireAssistantSurface("insightStatus"); + + const rows = await prisma.ecgRecording.findMany({ + where: { userId: user.id }, + // Everything EXCEPT `waveformEncrypted` — the list never touches the + // encrypted blob, so no decrypt happens on this path. + select: { + id: true, + recordedAt: true, + durationSeconds: true, + samplingFrequency: true, + sampleCount: true, + averageHeartRate: true, + lead: true, + rhythmClassification: true, + source: true, + }, + orderBy: { recordedAt: "desc" }, + take: MAX_RECORDINGS, + }); + + const recordings = rows.map((r) => ({ + id: r.id, + recordedAt: r.recordedAt.toISOString(), + durationSeconds: r.durationSeconds, + samplingFrequency: r.samplingFrequency, + sampleCount: r.sampleCount, + averageHeartRate: r.averageHeartRate, + lead: r.lead, + classification: r.rhythmClassification, + source: r.source, + // A `ts-` fallback event carries a verdict but no signal to fetch. + hasWaveform: r.sampleCount > 0, + })); + + annotate({ + action: { name: "insights.ecg.list" }, + meta: { count: recordings.length }, + }); + + return apiSuccess({ + recordings, + hasRecordings: recordings.length > 0, + }); +}); diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index f80c4d125..473b782df 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -128,6 +128,17 @@ const RhythmEventsCard = dynamic( })), { ssr: false }, ); +// v1.28.50 — ECG recording surface (waveform-backed). Deferred like the +// other below-the-fold blocks; the section un-mounts itself when the user +// has no recordings (`ssr: false` + its own data gate), so it carries no +// loading placeholder of its own. +const EcgSection = dynamic( + () => + import("@/components/insights/ecg-section").then((mod) => ({ + default: mod.EcgSection, + })), + { 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( @@ -499,6 +510,7 @@ export default function InsightsPage() { "health-status": , breathing: , "labs-changes": , + ecg: , }; const orderedSectionIds = orderedVisibleSectionIds(layout); diff --git a/src/components/insights/__tests__/ecg-section.test.tsx b/src/components/insights/__tests__/ecg-section.test.tsx new file mode 100644 index 000000000..6497af8fd --- /dev/null +++ b/src/components/insights/__tests__/ecg-section.test.tsx @@ -0,0 +1,183 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; + +/** + * v1.28.50 — `` / `` unit tests. + * + * The load-bearing behaviour under test: + * - data-availability gating: the section un-mounts entirely when the + * user has no recordings or while the payload is in flight; + * - the NON-DIAGNOSTIC framing (mirrors `RhythmEventsCard`): the device's + * result is shown attributed to the device, a permanent disclaimer + * states HealthLog does not interpret / diagnose, and any non-normal + * device result adds the "discuss with a clinician" note — while a + * normal result does NOT. + * + * `useAuth` + TanStack Query are mocked and the assertions run through SSR + * (the suite's node environment has no DOM), exactly like the sibling + * `rhythm-events-card` test. + */ + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: vi.fn(() => ({ isAuthenticated: true, user: null })), +})); + +const useQueryMock = vi.fn(); +vi.mock("@tanstack/react-query", () => ({ + useQuery: (opts: unknown) => useQueryMock(opts), +})); + +const { EcgSection, EcgDetail } = await import("../ecg-section"); + +interface EcgRecordingListItem { + id: string; + recordedAt: string; + durationSeconds: number | null; + samplingFrequency: number; + sampleCount: number; + averageHeartRate: number | null; + lead: string | null; + classification: "IRREGULAR" | "NOT_DETECTED" | "INCONCLUSIVE" | null; + source: string; + hasWaveform: boolean; +} + +const IRREGULAR_REC: EcgRecordingListItem = { + id: "ecg_1", + recordedAt: "2026-06-01T09:15:00.000Z", + durationSeconds: 30, + samplingFrequency: 300, + sampleCount: 9000, + averageHeartRate: 72, + lead: null, + classification: "IRREGULAR", + source: "WITHINGS", + hasWaveform: true, +}; + +const NORMAL_REC: EcgRecordingListItem = { + ...IRREGULAR_REC, + id: "ecg_2", + classification: "NOT_DETECTED", +}; + +const DETAIL = { + recordedAt: "2026-06-01T09:15:00.000Z", + durationSeconds: 30, + samplingFrequency: 300, + averageHeartRate: 72, + lead: null, + classification: "IRREGULAR" as const, + source: "WITHINGS", + samples: [0, 10, -5, 40, -20, 5, 0], + decimated: true, +}; + +function renderSection( + data: + { recordings: EcgRecordingListItem[]; hasRecordings: boolean } | undefined, +) { + useQueryMock.mockReturnValue({ data, isLoading: false }); + return renderToStaticMarkup( + + + , + ); +} + +function renderDetail( + recording: EcgRecordingListItem, + resultLabel: string | null, +) { + useQueryMock.mockReturnValue({ data: DETAIL, isLoading: false }); + return renderToStaticMarkup( + + {}} + /> + , + ); +} + +describe("", () => { + it("renders nothing before the payload resolves", () => { + useQueryMock.mockReturnValue({ data: undefined, isLoading: true }); + const html = renderToStaticMarkup( + + + , + ); + expect(html).toBe(""); + }); + + it("renders nothing when the user has no recordings (data-availability gate)", () => { + const html = renderSection({ recordings: [], hasRecordings: false }); + expect(html).toBe(""); + }); + + it("renders the recording list with a device-result label per row", () => { + const html = renderSection({ + recordings: [IRREGULAR_REC, NORMAL_REC], + hasRecordings: true, + }); + expect(html).toContain('data-slot="ecg-card"'); + expect(html).toContain('data-slot="ecg-list"'); + const rows = (html.match(/data-slot="ecg-row"/g) ?? []).length; + expect(rows).toBe(2); + expect(html).toContain("Atrial fibrillation detected"); + expect(html).toContain("No signs of atrial fibrillation"); + }); + + it("shows the permanent non-diagnostic disclaimer on the list", () => { + const html = renderSection({ + recordings: [IRREGULAR_REC], + hasRecordings: true, + }); + expect(html).toContain('data-slot="ecg-disclaimer"'); + expect(html).toContain( + "HealthLog does not read or interpret ECG recordings", + ); + expect(html).toContain("does not provide a diagnosis"); + }); +}); + +describe(" — non-diagnostic framing", () => { + it("attributes the result to the recording device and draws the waveform", () => { + const html = renderDetail(IRREGULAR_REC, "Atrial fibrillation detected"); + expect(html).toContain('data-slot="ecg-detail"'); + expect(html).toContain('data-slot="ecg-result"'); + // Load-bearing: the verdict is the device's, attributed to the device. + expect(html).toContain("as reported by the recording device"); + expect(html).toContain("Atrial fibrillation detected"); + // The waveform strip is rendered. + expect(html).toContain('data-slot="ecg-trace"'); + // And the permanent disclaimer is present here too. + expect(html).toContain('data-slot="ecg-disclaimer"'); + }); + + it("adds the 'discuss with a clinician' note on a non-normal result", () => { + const html = renderDetail(IRREGULAR_REC, "Atrial fibrillation detected"); + expect(html).toContain('data-slot="ecg-clinician-note"'); + expect(html).toContain("discuss this recording with a clinician"); + }); + + it("adds the clinician note for an INCONCLUSIVE result", () => { + const html = renderDetail( + { ...IRREGULAR_REC, classification: "INCONCLUSIVE" }, + "Inconclusive recording", + ); + expect(html).toContain('data-slot="ecg-clinician-note"'); + }); + + it("does NOT add the clinician note for a normal (not-detected) result", () => { + const html = renderDetail(NORMAL_REC, "No signs of atrial fibrillation"); + expect(html).not.toContain('data-slot="ecg-clinician-note"'); + // But the disclaimer + device attribution are still present. + expect(html).toContain('data-slot="ecg-disclaimer"'); + expect(html).toContain("as reported by the recording device"); + }); +}); diff --git a/src/components/insights/__tests__/ecg-waveform.test.tsx b/src/components/insights/__tests__/ecg-waveform.test.tsx new file mode 100644 index 000000000..968c7188f --- /dev/null +++ b/src/components/insights/__tests__/ecg-waveform.test.tsx @@ -0,0 +1,54 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { EcgWaveform } from "../ecg-waveform"; + +/** + * v1.28.50 — `` SVG unit tests. + * + * The component draws the raw trace and nothing else: a single `` + * over a grid ``, labelled as a whole image whose accessible name + * carries the DEVICE's result verbatim. No interpretation, no annotation. + */ + +function render(props: Partial[0]> = {}) { + return renderToStaticMarkup( + + + , + ); +} + +describe("", () => { + it("renders a single trace path and the ECG grid pattern", () => { + const html = render(); + expect(html).toContain('data-slot="ecg-waveform"'); + expect(html).toContain('data-slot="ecg-trace"'); + // A polyline d-string, not per-point elements. + expect(html).toMatch(/]*d="M0/); + expect(html).toContain(" { + const html = render(); + expect(html).toContain('role="img"'); + expect(html).toContain("aria-label"); + // The device's verdict is folded into the whole-image label verbatim. + expect(html).toContain("Atrial fibrillation detected"); + expect(html).toContain("72 bpm"); + }); + + it("renders no trace path for an empty sample array", () => { + const html = render({ samples: [] }); + expect(html).toContain('data-slot="ecg-waveform"'); + expect(html).not.toContain('data-slot="ecg-trace"'); + }); +}); diff --git a/src/components/insights/ecg-section.tsx b/src/components/insights/ecg-section.tsx new file mode 100644 index 000000000..324846d49 --- /dev/null +++ b/src/components/insights/ecg-section.tsx @@ -0,0 +1,356 @@ +"use client"; + +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Activity, ChevronLeft, HeartPulse, Info } from "lucide-react"; + +import { useAuth } from "@/hooks/use-auth"; +import { queryKeys } from "@/lib/query-keys"; +import { apiGet } from "@/lib/api/api-fetch"; +import { useTranslations, useFormatters } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; +import { SectionHeading } from "@/components/insights/section-heading"; +import { EcgWaveform } from "@/components/insights/ecg-waveform"; + +/** + * v1.28.50 — ECG recording surface (list → detail). + * + * Renders the user's ECG recordings synced from a single-lead device + * (Withings ScanWatch today): a list of strips, each opening a detail view + * with the `EcgWaveform` trace, the recording metadata, and the DEVICE's + * own classification. + * + * NON-DIAGNOSTIC (load-bearing, do not soften — mirrors `RhythmEventsCard`): + * the surface shows only the waveform (raw data), the metadata, and the + * recording device's own result, attributed unambiguously to the device + * ("Recorded result: …, as reported by the recording device"). HealthLog + * generates NO interpretation of the waveform — no measured intervals, no + * beat/P/QRS/T annotation, no risk score, no verdict of its own. A + * permanent, non-dismissible disclaimer states this, and any non-normal + * device result additionally carries a "discuss with a clinician" note. + * All copy renders as plain React text children (no markdown library — the + * standing XSS rule). + * + * Data-availability-gated: the section un-mounts entirely (`return null`) + * when the user has no recordings — never an empty / alarming card. + */ + +type EcgClassification = "IRREGULAR" | "NOT_DETECTED" | "INCONCLUSIVE" | null; + +interface EcgRecordingListItem { + id: string; + recordedAt: string; + durationSeconds: number | null; + samplingFrequency: number; + sampleCount: number; + averageHeartRate: number | null; + lead: string | null; + classification: EcgClassification; + source: string; + hasWaveform: boolean; +} + +interface EcgListResponse { + recordings: EcgRecordingListItem[]; + hasRecordings: boolean; +} + +interface EcgDetailResponse { + recordedAt: string; + durationSeconds: number | null; + samplingFrequency: number; + averageHeartRate: number | null; + lead: string | null; + classification: EcgClassification; + source: string; + samples: number[]; + decimated: boolean; +} + +/** The device verdict, surfaced verbatim and attributed to the device. */ +const RESULT_LABEL_KEYS: Record = { + IRREGULAR: "insights.ecg.result.irregular", + NOT_DETECTED: "insights.ecg.result.notDetected", + INCONCLUSIVE: "insights.ecg.result.inconclusive", +}; + +/** Non-normal device results trigger the "discuss with a clinician" note. */ +function isNonNormal(classification: EcgClassification): boolean { + return classification === "IRREGULAR" || classification === "INCONCLUSIVE"; +} + +interface EcgSectionProps { + enabled?: boolean; + className?: string; +} + +export function EcgSection({ enabled = true, className }: EcgSectionProps) { + const { isAuthenticated } = useAuth(); + const { t } = useTranslations(); + const fmt = useFormatters(); + const [selectedId, setSelectedId] = useState(null); + + const { data, isLoading } = useQuery({ + queryKey: queryKeys.insightsEcgList(), + queryFn: async () => { + try { + return await apiGet("/api/insights/ecg"); + } catch { + throw new Error(t("insights.ecg.loadError")); + } + }, + enabled: enabled && isAuthenticated, + }); + + // Data-availability gate — never paint an empty card. + if (isLoading || !data || !data.hasRecordings) return null; + + const selected = + selectedId != null + ? (data.recordings.find((r) => r.id === selectedId) ?? null) + : null; + + function resultLabel(classification: EcgClassification): string | null { + if (!classification) return null; + const key = RESULT_LABEL_KEYS[classification]; + return key ? t(key) : null; + } + + return ( +
+ +
+ {selected ? ( + setSelectedId(null)} + /> + ) : ( + <> +

+ {t("insights.ecg.sectionIntro")} +

+
    + {data.recordings.map((rec) => { + const label = resultLabel(rec.classification); + const rowInner = ( + <> + + + +
    +

    + {fmt.dateTime(new Date(rec.recordedAt))} +

    + {label && ( +

    + {label} +

    + )} + {rec.averageHeartRate != null && ( +

    + {t("insights.ecg.meta.bpmValue", { + bpm: rec.averageHeartRate, + })} +

    + )} +
    + + ); + return ( +
  1. + {rec.hasWaveform ? ( + + ) : ( +
    {rowInner}
    + )} +
  2. + ); + })} +
+ + {/* Load-bearing regulatory disclaimer — permanent, + non-dismissible. Plain React text children (no markdown). */} + + + )} +
+
+ ); +} + +/** + * The detail view: waveform + metadata + device-attributed result + + * permanent disclaimer + clinician note on a non-normal device result. + * Exported so the non-diagnostic framing can be unit-tested directly. + */ +export function EcgDetail({ + recording, + resultLabel, + onBack, +}: { + recording: EcgRecordingListItem; + resultLabel: string | null; + onBack: () => void; +}) { + const { t } = useTranslations(); + const fmt = useFormatters(); + + const { data: detail, isLoading } = useQuery({ + queryKey: queryKeys.insightsEcgDetail(recording.id, false), + queryFn: async () => { + try { + return await apiGet( + `/api/insights/ecg/${recording.id}`, + ); + } catch { + throw new Error(t("insights.ecg.loadError")); + } + }, + }); + + const nonNormal = isNonNormal(recording.classification); + const leadLabel = recording.lead ?? t("insights.ecg.meta.leadSingle"); + const durationLabel = + recording.durationSeconds != null + ? t("insights.ecg.meta.durationValue", { + seconds: Math.round(recording.durationSeconds), + }) + : t("insights.ecg.meta.unknown"); + + return ( +
+ + + {/* The DEVICE's result, attributed to the device. This is the ONLY + verdict shown — HealthLog produces none. */} + {resultLabel && ( +

+ {t("insights.ecg.resultAttribution", { result: resultLabel })} +

+ )} + + {isLoading || !detail ? ( +
+ ) : ( + + )} + +
+ + + + + 0 + ? t("insights.ecg.meta.hzValue", { + hz: recording.samplingFrequency, + }) + : t("insights.ecg.meta.unknown") + } + /> +
+ + {/* "Discuss with a clinician" — only on a non-normal device result. */} + {nonNormal && ( +

+ {t("insights.ecg.clinicianNote")} +

+ )} + + +
+ ); +} + +function MetaItem({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +/** + * The permanent, non-dismissible non-diagnostic disclaimer. States that the + * displayed result is the device's and that HealthLog does not read, + * interpret, or diagnose ECG recordings. + */ +function EcgDisclaimer() { + const { t } = useTranslations(); + return ( +
+
+ ); +} diff --git a/src/components/insights/ecg-waveform.tsx b/src/components/insights/ecg-waveform.tsx new file mode 100644 index 000000000..3779c7642 --- /dev/null +++ b/src/components/insights/ecg-waveform.tsx @@ -0,0 +1,188 @@ +"use client"; + +import { useId, useMemo } from "react"; + +import { useTranslations, useFormatters } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; + +/** + * v1.28.50 — purpose-built SVG ECG waveform strip. + * + * A raw single-lead ECG strip is thousands of micro-volt samples. This is + * rendered as ONE `` over a classic ECG grid `` — NOT + * Recharts, which builds a per-point React element tree and janks at this + * point count. A hand-computed polyline `d`-string is one DOM node, + * renders instantly, re-scales cheaply, is crisp at any zoom, prints + * cleanly (doctor report / browser print), and can be labelled as a whole + * image. (This is a new primitive, not a Recharts chart being swapped — + * the "Recharts stays" rule protects the existing trend charts.) + * + * NON-DIAGNOSTIC: this component draws the raw data and nothing else. It + * places no beat/interval annotation on the trace, no P/QRS/T markers, no + * grid-derived measurement — the classic 25 mm/s · 10 mm/mV grid here is + * DECORATIVE calibration chrome for the fit-to-width overview, not a + * measurement axis. The accessible label reports the device's own result + * verbatim; it never asserts a HealthLog verdict. + * + * Theme-aware via semantic tokens (the tokenised `--brand-pink` grid and + * `--foreground` trace resolve per light/dark), and `motion-reduce`-safe + * by construction (no draw-on animation — the path is static). + */ + +// Fixed display viewBox. The overview compresses the whole strip to this +// width, so the grid is decorative chrome at a fixed box size rather than a +// time-calibrated axis; the trace maps evenly across the full width. +const VIEW_W = 1000; +const VIEW_H = 320; +// Small ECG box (1 mm) and bold box (5 mm) in viewBox units. +const SMALL_BOX = 8; +const BIG_BOX = SMALL_BOX * 5; +// Vertical inset so the tallest R-wave never clips the top/bottom edge. +const Y_PAD = VIEW_H * 0.12; + +export interface EcgWaveformProps { + /** + * Micro-volt samples. The x-position of each is implied by its index + * (evenly mapped across the strip); this is a shape overview, not a + * calibrated-time axis. + */ + samples: number[]; + /** ISO recording timestamp — read into the accessible label. */ + recordedAt: string; + /** Strip duration in seconds (null when the source omitted the rate). */ + durationSeconds: number | null; + /** Source-reported average heart rate (BPM), when present. */ + averageHeartRate: number | null; + /** + * The recording DEVICE's own result, already localised and attributed to + * the device by the caller (e.g. "Atrial fibrillation detected"). Folded + * into the accessible label so a screen-reader user hears the device's + * verdict, never a HealthLog one. Null when the source reported none. + */ + resultLabel: string | null; + className?: string; +} + +/** Build the polyline `d`-string, mapping samples evenly across the width. */ +function buildPath(samples: number[]): string { + const n = samples.length; + if (n === 0) return ""; + + let min = samples[0]; + let max = samples[0]; + for (let i = 1; i < n; i++) { + if (samples[i] < min) min = samples[i]; + if (samples[i] > max) max = samples[i]; + } + const span = max - min || 1; // flat trace → centre it + const usableH = VIEW_H - 2 * Y_PAD; + const xStep = n > 1 ? VIEW_W / (n - 1) : 0; + + let d = ""; + for (let i = 0; i < n; i++) { + const x = i * xStep; + // Higher voltage → smaller y (up). + const y = Y_PAD + ((max - samples[i]) / span) * usableH; + d += `${i === 0 ? "M" : "L"}${x.toFixed(2)} ${y.toFixed(2)}`; + if (i < n - 1) d += " "; + } + return d; +} + +export function EcgWaveform({ + samples, + recordedAt, + durationSeconds, + averageHeartRate, + resultLabel, + className, +}: EcgWaveformProps) { + const { t } = useTranslations(); + const fmt = useFormatters(); + const uid = useId(); + const smallGridId = `ecg-grid-sm-${uid}`; + const bigGridId = `ecg-grid-lg-${uid}`; + + const d = useMemo(() => buildPath(samples), [samples]); + + // Accessible whole-image label: when + how long + average HR + the + // DEVICE's result. Non-normal or normal, it is the device's verdict. + const ariaLabel = t("insights.ecg.waveform.ariaLabel", { + date: fmt.dateTime(new Date(recordedAt)), + duration: + durationSeconds != null + ? t("insights.ecg.meta.durationValue", { + seconds: Math.round(durationSeconds), + }) + : t("insights.ecg.meta.unknown"), + heartRate: + averageHeartRate != null + ? t("insights.ecg.meta.bpmValue", { bpm: averageHeartRate }) + : t("insights.ecg.meta.unknown"), + result: resultLabel ?? t("insights.ecg.result.none"), + }); + + return ( +
+ +
+ ); +} diff --git a/src/components/insights/insights-edit-mode.tsx b/src/components/insights/insights-edit-mode.tsx index 0f6bff60d..fd323f27d 100644 --- a/src/components/insights/insights-edit-mode.tsx +++ b/src/components/insights/insights-edit-mode.tsx @@ -66,6 +66,7 @@ const SECTION_TITLE_KEYS: Record = { "health-status": "insights.healthStatus.sectionTitle", breathing: "insights.breathingScreening.sectionTitle", "labs-changes": "insights.labsChanges.sectionTitle", + ecg: "insights.ecg.sectionTitle", }; interface InsightsEditModeProps { diff --git a/src/lib/__tests__/insights-layout.test.ts b/src/lib/__tests__/insights-layout.test.ts index 04dff7b0a..61bce8989 100644 --- a/src/lib/__tests__/insights-layout.test.ts +++ b/src/lib/__tests__/insights-layout.test.ts @@ -33,6 +33,7 @@ describe("insights-layout v2 — section id universe", () => { "health-status", "breathing", "labs-changes", + "ecg", ]); }); diff --git a/src/lib/insights-layout.ts b/src/lib/insights-layout.ts index 1f7960440..538830917 100644 --- a/src/lib/insights-layout.ts +++ b/src/lib/insights-layout.ts @@ -116,6 +116,11 @@ export const INSIGHTS_SECTION_IDS = [ "health-status", "breathing", "labs-changes", + // v1.28.50 — ECG recording surface (waveform-backed, single-lead device). + // Kept separate from the waveform-less `rhythm-events` timeline. Auto-merges + // default-invisible onto existing saved layouts (per the section resolver), + // default-visible for new users; data-availability-gated on top. + "ecg", ] as const; export type InsightsSectionId = (typeof INSIGHTS_SECTION_IDS)[number]; diff --git a/src/lib/insights/__tests__/ecg-decimate.test.ts b/src/lib/insights/__tests__/ecg-decimate.test.ts new file mode 100644 index 000000000..9f2212990 --- /dev/null +++ b/src/lib/insights/__tests__/ecg-decimate.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; + +import { ECG_DISPLAY_TARGET_POINTS, decimateMinMax } from "../ecg-decimate"; + +/** + * v1.28.50 — ECG min/max decimation. + * + * The load-bearing property: reducing ~9000 samples to ~2500 display points + * must PRESERVE the R-wave peaks (the global min and max of every bucket + * survive), unlike a naive stride-decimation which would drop the spikes + * that sit between kept indices and misrepresent the trace. + */ + +describe("decimateMinMax", () => { + it("returns the input unchanged when already at or below the target", () => { + const samples = [1, 2, 3, 4, 5]; + expect(decimateMinMax(samples, 10)).toEqual(samples); + expect(decimateMinMax(samples, 5)).toEqual(samples); + // A copy, not the same reference (callers must not mutate the source). + expect(decimateMinMax(samples, 10)).not.toBe(samples); + }); + + it("returns an empty array for empty input", () => { + expect(decimateMinMax([], 2500)).toEqual([]); + }); + + it("returns the raw array for a non-positive target", () => { + expect(decimateMinMax([1, 2, 3, 4], 0)).toEqual([1, 2, 3, 4]); + expect(decimateMinMax([1, 2, 3, 4], -5)).toEqual([1, 2, 3, 4]); + }); + + it("reduces a large array to roughly the point budget", () => { + const n = 9000; + const samples = Array.from({ length: n }, (_, i) => Math.sin(i / 10)); + const out = decimateMinMax(samples, ECG_DISPLAY_TARGET_POINTS); + expect(out.length).toBeLessThanOrEqual(ECG_DISPLAY_TARGET_POINTS); + // Two points per bucket ⇒ within a small factor of the budget. + expect(out.length).toBeGreaterThan(ECG_DISPLAY_TARGET_POINTS / 2); + expect(out.length).toBeLessThan(n); + }); + + it("preserves the global maximum (R-wave peak) hidden between strides", () => { + // A flat baseline with a single tall spike that a stride-decimation at + // this ratio would step right over. + const n = 6000; + const samples = new Array(n).fill(0); + const spikeIdx = 1234; + samples[spikeIdx] = 5000; // the R-wave + const trough = 3777; + samples[trough] = -3200; + + const out = decimateMinMax(samples, 1000); + expect(Math.max(...out)).toBe(5000); + expect(Math.min(...out)).toBe(-3200); + }); + + it("keeps the global peak even at a heavy reduction ratio", () => { + const n = 9000; + const samples = Array.from({ length: n }, (_, i) => + Math.round(Math.sin(i / 7) * 100), + ); + samples[4500] = 999999; // single dominant peak + const out = decimateMinMax(samples, 500); + expect(Math.max(...out)).toBe(999999); + }); + + it("emits bucket extremes in index order so the trace shape is preserved", () => { + // Rising-then-falling within the first bucket ⇒ min before max is wrong; + // here index 0 is the min and index 2 the max, so min must come first. + const samples = [0, 3, 9, 2, 8, 1, 7, 4]; + const out = decimateMinMax(samples, 4); // 2 buckets + // First bucket [0..4): min=0 (idx0), max=9 (idx2) ⇒ 0 then 9. + expect(out[0]).toBe(0); + expect(out[1]).toBe(9); + }); +}); diff --git a/src/lib/insights/ecg-decimate.ts b/src/lib/insights/ecg-decimate.ts new file mode 100644 index 000000000..b4a7953f7 --- /dev/null +++ b/src/lib/insights/ecg-decimate.ts @@ -0,0 +1,83 @@ +/** + * v1.28.50 — min/max decimation for the ECG display waveform. + * + * A Withings ScanWatch strip is ~9000 micro-volt samples (30 s at 300 Hz). + * A fit-to-width overview does not need every raw sample to reach the + * client, but a NAIVE stride-decimation (keep every Nth sample) silently + * drops the R-wave peaks that sit between the kept indices and would + * misrepresent the trace — the one thing an ECG must never do. + * + * Min/max decimation avoids that: the sample range is split into buckets, + * and each bucket emits BOTH its minimum and its maximum sample, in the + * order the two extremes actually occur within the bucket. The global + * extreme of every bucket therefore always survives, so the tall, narrow + * QRS spikes are preserved even at a heavy reduction ratio. The output is + * ~`targetPoints` values (2 per bucket); the x-position of each point is + * implied by its index, so the renderer maps it evenly across the strip + * width — the display is an overview, not a calibrated-time axis (the + * `?full=1` raw path serves the true-calibration view). + * + * Pure + side-effect-free so the reduction ratio and peak-preservation are + * unit-testable without a route. + */ + +/** + * Default output-point budget for the decimated overview waveform + * (~2 points per bucket, so ~1250 buckets). Chosen per the ECG display + * design: dense enough that the compressed strip reads as a continuous + * trace, small enough that the JSON payload stays ~15–25 KB. + */ +export const ECG_DISPLAY_TARGET_POINTS = 2500; + +/** + * Reduce a raw sample array to at most ~`targetPoints` values using + * min/max decimation. Returns a copy of the input unchanged when it is + * already at or below the target (or the target is non-positive) — the + * caller then reports `decimated: false`. + */ +export function decimateMinMax( + samples: number[], + targetPoints: number, +): number[] { + const n = samples.length; + if (n === 0) return []; + // Nothing to gain from bucketing — hand back the raw trace. + if (targetPoints <= 0 || targetPoints >= n) return samples.slice(); + + // Two output points per bucket (a min and a max), so the bucket count is + // half the point budget. Guard the degenerate case where bucketing would + // not actually shrink the array. + const buckets = Math.max(1, Math.floor(targetPoints / 2)); + if (buckets * 2 >= n) return samples.slice(); + + const out: number[] = []; + const bucketSize = n / buckets; + + for (let b = 0; b < buckets; b++) { + const start = Math.floor(b * bucketSize); + // The last bucket always runs to the end so no tail sample is dropped. + const end = b === buckets - 1 ? n : Math.floor((b + 1) * bucketSize); + + let minIdx = start; + let maxIdx = start; + for (let i = start + 1; i < end; i++) { + if (samples[i] < samples[minIdx]) minIdx = i; + if (samples[i] > samples[maxIdx]) maxIdx = i; + } + + // Emit the two extremes in the order they occur so the trace keeps its + // shape (an up-then-down bucket reads up-then-down). A flat bucket + // where min === max emits a single point. + if (minIdx === maxIdx) { + out.push(samples[minIdx]); + } else if (minIdx < maxIdx) { + out.push(samples[minIdx]); + out.push(samples[maxIdx]); + } else { + out.push(samples[maxIdx]); + out.push(samples[minIdx]); + } + } + + return out; +} diff --git a/src/lib/openapi/routes/insights/paths.ts b/src/lib/openapi/routes/insights/paths.ts index b25d3e044..097c14092 100644 --- a/src/lib/openapi/routes/insights/paths.ts +++ b/src/lib/openapi/routes/insights/paths.ts @@ -4,9 +4,11 @@ * Schema declarations live in `./schemas`; this module is the path orchestrator. */ import type { ZodOpenApiObject } from "zod-openapi"; +import { z } from "zod/v4"; import { consentRequiredResponse, dataEnvelope, + errorEnvelope, moduleDisabledResponse, stdResponses, } from "../shared"; @@ -31,6 +33,9 @@ import { insightsPregenerateRequest, insightsPregenerateResponse, dashboardSnapshotResponse, + ecgListResponse, + ecgDetailQuery, + ecgDetailResponse, } from "./schemas"; export const insightsPaths: NonNullable = { @@ -429,6 +434,61 @@ export const insightsPaths: NonNullable = { }, }, }, + "/api/insights/ecg": { + get: { + tags: ["Insights"], + summary: "ECG recording list (metadata only)", + description: + "v1.28.50 — the authenticated user's ECG recordings as a cheap, index-covered metadata list (recorded time, duration, sampling rate, sample count, average heart rate, lead, and the DEVICE's own rhythm classification). NEVER decrypts or returns the waveform — the per-recording strip is fetched on demand from GET /api/insights/ecg/{id}. Reflects only the recording device's certified on-device classification, verbatim; HealthLog never re-classifies an ECG or produces a diagnosis. Data-availability-gated: an empty account returns `hasRecordings: false`. Module-gated on `insights` and the operator `insightStatus` assistant surface; no LLM call. Auth via cookie or Bearer.", + responses: { + "200": { + description: "The ECG recording list (possibly empty).", + content: { + "application/json": { + schema: dataEnvelope(ecgListResponse, "EcgListResponseEnvelope"), + }, + }, + }, + ...moduleDisabledResponse, + ...stdResponses, + }, + }, + }, + "/api/insights/ecg/{id}": { + get: { + tags: ["Insights"], + summary: "One ECG recording with waveform", + description: + "v1.28.50 — one recording's decrypted waveform plus metadata and the DEVICE's verbatim classification. Ownership is narrowed in the query where (`{ id, userId }`) so a cross-user read is structurally impossible; a foreign or unknown id 404s (existence sealed). The waveform is AES-256-GCM at rest, decrypted through the fail-closed codec. By default the ~9000-sample strip is min/max-decimated to ~2500 display points so R-wave peaks survive; `?full=1` returns the raw array. HealthLog does not interpret the trace, measure intervals, annotate beats, or emit a verdict of its own. Module-gated on `insights` and the operator `insightStatus` assistant surface; no LLM call. `no-store`. Auth via cookie or Bearer.", + requestParams: { + path: z.object({ + id: z.string().describe("The ECG recording id (cuid)."), + }), + query: ecgDetailQuery, + }, + responses: { + "200": { + description: + "The recording's waveform + metadata + device classification.", + content: { + "application/json": { + schema: dataEnvelope( + ecgDetailResponse, + "EcgDetailResponseEnvelope", + ), + }, + }, + }, + "404": { + description: + "No ECG recording with that id for the authenticated user (existence sealed — a foreign id is indistinguishable from a missing one).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...moduleDisabledResponse, + ...stdResponses, + }, + }, + }, "/api/insights/correlations": { get: { tags: ["Insights"], diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index 8451451ad..98b90427f 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -1003,3 +1003,135 @@ const insightCard = z export const insightsCardsResponse = z .array(insightCard) .meta({ id: "InsightsCardsResponse" }); + +// v1.28.50 — ECG recording surface. The device's own AFib verdict, verbatim. +// Only three RhythmClassification values occur for an ECG strip; the field is +// nullable when the source omitted a classification. +const ecgClassification = z + .enum(["IRREGULAR", "NOT_DETECTED", "INCONCLUSIVE"]) + .nullable() + .describe( + "The RECORDING DEVICE's own rhythm classification, surfaced verbatim: IRREGULAR (device flagged possible atrial fibrillation), NOT_DETECTED (algorithm ran, nothing flagged), INCONCLUSIVE (poor signal / out-of-range HR). Null when the source reported none. HealthLog never re-classifies an ECG — this is the device's certified on-device result, not a HealthLog verdict.", + ); + +const ecgRecordingListItem = z + .object({ + id: z.string().describe("Recording id (cuid)."), + recordedAt: z.iso + .datetime({ offset: true }) + .describe("On-device recording time (the display timestamp)."), + durationSeconds: z + .number() + .nullable() + .describe( + "`sampleCount / samplingFrequency` in seconds; null when the source omitted the sampling frequency.", + ), + samplingFrequency: z + .number() + .int() + .describe( + "Signal sampling rate in Hz (Withings ScanWatch = 300); 0 when the source omitted it.", + ), + sampleCount: z + .number() + .int() + .describe("Number of samples in the (encrypted) waveform array."), + averageHeartRate: z + .number() + .int() + .nullable() + .describe( + "Source-reported average heart rate (BPM) for the strip, when present.", + ), + lead: z + .string() + .nullable() + .describe( + "Recording lead label when the source reports it; null for the single-lead ScanWatch (Lead I).", + ), + classification: ecgClassification, + source: z + .string() + .describe("Measurement source that wrote the recording (e.g. WITHINGS)."), + hasWaveform: z + .boolean() + .describe( + "True when a waveform is stored (`sampleCount > 0`); false for a verdict-only fallback event with no signal to fetch.", + ), + }) + .meta({ id: "EcgRecordingListItem" }); + +export const ecgListResponse = z + .object({ + recordings: z + .array(ecgRecordingListItem) + .describe("The user's ECG recordings, newest first (capped at 200)."), + hasRecordings: z + .boolean() + .describe( + "False for an account with no recordings — the client un-mounts the whole ECG surface (data-availability floor).", + ), + }) + .meta({ + id: "EcgListResponse", + description: + "Metadata-only ECG recording list. Never carries the waveform — the per-recording strip is fetched on demand from GET /api/insights/ecg/{id}. Read-only; no LLM call. Reflects only the device's own classification, never a HealthLog interpretation.", + }); + +export const ecgDetailQuery = z + .object({ + full: z + .literal("1") + .optional() + .describe( + "`1` returns the RAW, un-decimated sample array (the true-calibration 25 mm/s · 10 mm/mV zoom view). Omitted returns the min/max-decimated ~2500-point fit-to-width overview (`decimated: true`).", + ), + }) + .meta({ id: "EcgDetailQuery" }); + +export const ecgDetailResponse = z + .object({ + recordedAt: z.iso + .datetime({ offset: true }) + .describe("On-device recording time (the display timestamp)."), + durationSeconds: z + .number() + .nullable() + .describe( + "Strip duration in seconds; null when the sampling frequency was 0.", + ), + samplingFrequency: z + .number() + .int() + .describe("Signal sampling rate in Hz (300 for a ScanWatch)."), + averageHeartRate: z + .number() + .int() + .nullable() + .describe( + "Source-reported average heart rate (BPM) for the strip, when present.", + ), + lead: z + .string() + .nullable() + .describe( + "Recording lead label when reported; null for the single-lead ScanWatch (Lead I).", + ), + classification: ecgClassification, + source: z.string().describe("Measurement source that wrote the recording."), + samples: z + .array(z.number()) + .describe( + "Micro-volt waveform samples. Min/max-decimated to ~2500 points by default so R-wave peaks survive the fit-to-width compression; the raw array when `?full=1`. The x-position of each sample is implied by its index (an overview trace, not a calibrated-time axis) unless `decimated` is false.", + ), + decimated: z + .boolean() + .describe( + "True when `samples` was min/max-decimated for display; false when the raw array is returned (`?full=1`, or a strip already at or below the display budget).", + ), + }) + .meta({ + id: "EcgDetailResponse", + description: + "One ECG recording's decrypted waveform plus metadata and the DEVICE's verbatim classification. Ownership is narrowed in the query where — a foreign / unknown id 404s. The waveform is AES-256-GCM at rest, decrypted through the fail-closed codec. HealthLog does not interpret the trace, measure intervals, annotate beats, or emit a verdict of its own. no-store.", + }); diff --git a/src/lib/query-keys/insights.ts b/src/lib/query-keys/insights.ts index 7d5a517fb..d2d015f3a 100644 --- a/src/lib/query-keys/insights.ts +++ b/src/lib/query-keys/insights.ts @@ -71,6 +71,19 @@ export const insightsKeys = { * row busts it via the prefix). */ insightsRhythmEvents: () => ["insights", "rhythm-events"] as const, + /** + * v1.28.50 — ECG recording surface. The metadata list + * (`/api/insights/ecg`) and the per-recording waveform detail + * (`/api/insights/ecg/[id]`). The detail key carries the recording id + * plus a `full` discriminant so the fit-to-width overview waveform and + * the raw `?full=1` true-calibration array cache independently and never + * poison each other (same key + different sample density would). Both + * sit under the `["insights"]` prefix so a sync that lands a new + * recording busts them via the standard insights invalidation fan-out. + */ + insightsEcgList: () => ["insights", "ecg"] as const, + insightsEcgDetail: (id: string, full: boolean) => + ["insights", "ecg", id, full ? "full" : "overview"] as const, /** * v1.10.0 — derived-wellness metrics. One generic route * (`/api/insights/derived?metric=…[&type=…]`) backs the vitals