feat(insights): view ECG recordings

ECG recordings synced from a compatible watch were stored encrypted but
shown nowhere. They now have a surface in Insights: a list of recordings,
each opening the full single-lead waveform on a familiar ECG grid, with the
date, duration, average heart rate, and the result the device recorded.

The read routes narrow ownership in the query and decrypt the waveform
through the existing fail-closed codec; the overview is min/max-decimated so
the R-wave peaks survive the reduction, with a raw path for a calibrated
view. The result shown is the recording device's own classification,
attributed to the device — HealthLog does not read or interpret the trace
and produces no verdict of its own. A permanent note states this, and a
non-normal result carries a line to discuss the recording with a clinician.
This commit is contained in:
Marc-André Bombeck committed 2026-07-16 09:55:35 +02:00
1 parent 7a0a271c70
commit 49ed0f41df
28 files changed
+2124 -4

No files matched your search

+13 -1
View File
@@ -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
+266 -1
View File
@@ -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:
+30
View File
@@ -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": {
+30
View File
@@ -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": {
+30
View File
@@ -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": {
+30
View File
@@ -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": {
+30
View File
@@ -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": {
+30
View File
@@ -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": {
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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/*`
@@ -67,6 +67,12 @@ const NON_COACH_GATED_ROUTES: ReadonlyArray<string> = [
// 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",
];
@@ -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<typeof import("@/lib/modules/gate")>()),
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<Response>;
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);
});
});
+101
View File
@@ -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;
},
);
@@ -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<typeof import("@/lib/modules/gate")>()),
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<Response>;
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<string, boolean>;
};
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");
});
});
+87
View File
@@ -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,
});
});
+12
View File
@@ -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": <HealthStatusCard enabled={isAuthenticated} />,
breathing: <BreathingScreeningCard enabled={isAuthenticated} />,
"labs-changes": <LabsChangesCard enabled={isAuthenticated} />,
ecg: <EcgSection enabled={isAuthenticated} />,
};
const orderedSectionIds = orderedVisibleSectionIds(layout);
@@ -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 — `<EcgSection>` / `<EcgDetail>` 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(
<I18nProvider initialLocale="en">
<EcgSection />
</I18nProvider>,
);
}
function renderDetail(
recording: EcgRecordingListItem,
resultLabel: string | null,
) {
useQueryMock.mockReturnValue({ data: DETAIL, isLoading: false });
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<EcgDetail
recording={recording}
resultLabel={resultLabel}
onBack={() => {}}
/>
</I18nProvider>,
);
}
describe("<EcgSection>", () => {
it("renders nothing before the payload resolves", () => {
useQueryMock.mockReturnValue({ data: undefined, isLoading: true });
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<EcgSection />
</I18nProvider>,
);
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("<EcgDetail> — 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");
});
});
@@ -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 — `<EcgWaveform>` SVG unit tests.
*
* The component draws the raw trace and nothing else: a single `<path>`
* over a grid `<pattern>`, labelled as a whole image whose accessible name
* carries the DEVICE's result verbatim. No interpretation, no annotation.
*/
function render(props: Partial<Parameters<typeof EcgWaveform>[0]> = {}) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<EcgWaveform
samples={[0, 10, -5, 40, -20, 5, 0]}
recordedAt="2026-06-01T09:15:00.000Z"
durationSeconds={30}
averageHeartRate={72}
resultLabel="Atrial fibrillation detected"
{...props}
/>
</I18nProvider>,
);
}
describe("<EcgWaveform>", () => {
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(/<path[^>]*d="M0/);
expect(html).toContain("<pattern");
});
it("exposes role=img with an accessible label carrying the device result", () => {
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"');
});
});
+356
View File
@@ -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<string, string> = {
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<string | null>(null);
const { data, isLoading } = useQuery({
queryKey: queryKeys.insightsEcgList(),
queryFn: async () => {
try {
return await apiGet<EcgListResponse>("/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 (
<section
data-slot="ecg-section"
aria-label={t("insights.ecg.sectionTitle")}
className={cn("space-y-3", className)}
>
<SectionHeading icon={Activity} title={t("insights.ecg.sectionTitle")} />
<div
data-slot="ecg-card"
className="bg-card border-border space-y-4 rounded-xl border p-4 md:p-6"
>
{selected ? (
<EcgDetail
recording={selected}
resultLabel={resultLabel(selected.classification)}
onBack={() => setSelectedId(null)}
/>
) : (
<>
<p className="text-muted-foreground text-sm">
{t("insights.ecg.sectionIntro")}
</p>
<ol data-slot="ecg-list" className="space-y-3">
{data.recordings.map((rec) => {
const label = resultLabel(rec.classification);
const rowInner = (
<>
<span className="bg-muted text-muted-foreground mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full">
<HeartPulse className="size-4" />
</span>
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-foreground text-sm font-medium">
{fmt.dateTime(new Date(rec.recordedAt))}
</p>
{label && (
<p
data-slot="ecg-row-result"
className="text-muted-foreground text-sm"
>
{label}
</p>
)}
{rec.averageHeartRate != null && (
<p className="text-muted-foreground text-xs">
{t("insights.ecg.meta.bpmValue", {
bpm: rec.averageHeartRate,
})}
</p>
)}
</div>
</>
);
return (
<li
key={rec.id}
data-slot="ecg-row"
data-classification={rec.classification ?? "NONE"}
className="border-border/60 border-b pb-3 last:border-b-0 last:pb-0"
>
{rec.hasWaveform ? (
<button
type="button"
onClick={() => setSelectedId(rec.id)}
className="hover:bg-muted/40 -m-1 flex w-full items-start gap-3 rounded-md p-1 text-left transition-colors"
>
{rowInner}
</button>
) : (
<div className="flex items-start gap-3">{rowInner}</div>
)}
</li>
);
})}
</ol>
{/* Load-bearing regulatory disclaimer — permanent,
non-dismissible. Plain React text children (no markdown). */}
<EcgDisclaimer />
</>
)}
</div>
</section>
);
}
/**
* 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<EcgDetailResponse>(
`/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 (
<div data-slot="ecg-detail" className="space-y-4">
<button
type="button"
onClick={onBack}
className="text-muted-foreground hover:text-foreground -ml-1 flex items-center gap-1 text-sm transition-colors"
>
<ChevronLeft className="size-4" />
{t("insights.ecg.backToList")}
</button>
{/* The DEVICE's result, attributed to the device. This is the ONLY
verdict shown — HealthLog produces none. */}
{resultLabel && (
<p
data-slot="ecg-result"
className="text-foreground text-sm font-medium"
>
{t("insights.ecg.resultAttribution", { result: resultLabel })}
</p>
)}
{isLoading || !detail ? (
<div
data-slot="ecg-waveform-skeleton"
className="bg-muted/40 h-40 w-full animate-pulse rounded-lg motion-reduce:animate-none"
/>
) : (
<EcgWaveform
samples={detail.samples}
recordedAt={detail.recordedAt}
durationSeconds={detail.durationSeconds}
averageHeartRate={detail.averageHeartRate}
resultLabel={resultLabel}
/>
)}
<dl
data-slot="ecg-meta"
className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm sm:grid-cols-3"
>
<MetaItem
label={t("insights.ecg.meta.recorded")}
value={fmt.dateTime(new Date(recording.recordedAt))}
/>
<MetaItem
label={t("insights.ecg.meta.duration")}
value={durationLabel}
/>
<MetaItem label={t("insights.ecg.meta.lead")} value={leadLabel} />
<MetaItem
label={t("insights.ecg.meta.averageHeartRate")}
value={
recording.averageHeartRate != null
? t("insights.ecg.meta.bpmValue", {
bpm: recording.averageHeartRate,
})
: t("insights.ecg.meta.unknown")
}
/>
<MetaItem
label={t("insights.ecg.meta.samplingRate")}
value={
recording.samplingFrequency > 0
? t("insights.ecg.meta.hzValue", {
hz: recording.samplingFrequency,
})
: t("insights.ecg.meta.unknown")
}
/>
</dl>
{/* "Discuss with a clinician" — only on a non-normal device result. */}
{nonNormal && (
<p
data-slot="ecg-clinician-note"
className="text-foreground text-sm font-medium"
>
{t("insights.ecg.clinicianNote")}
</p>
)}
<EcgDisclaimer />
</div>
);
}
function MetaItem({ label, value }: { label: string; value: string }) {
return (
<div className="space-y-0.5">
<dt className="text-muted-foreground text-xs">{label}</dt>
<dd className="text-foreground">{value}</dd>
</div>
);
}
/**
* 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 (
<div
data-slot="ecg-disclaimer"
role="note"
className="bg-muted/50 text-muted-foreground flex items-start gap-2 rounded-lg p-3 text-xs"
>
<Info className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
<p>{t("insights.ecg.disclaimer")}</p>
</div>
);
}
+188
View File
@@ -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 `<path>` over a classic ECG grid `<pattern>` — 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 (
<figure
data-slot="ecg-waveform"
role="img"
aria-label={ariaLabel}
className={cn(
"bg-card border-border overflow-hidden rounded-lg border",
className,
)}
>
<svg
viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
className="h-auto w-full"
preserveAspectRatio="xMidYMid meet"
aria-hidden="true"
>
<defs>
{/* Small 1 mm boxes. */}
<pattern
id={smallGridId}
width={SMALL_BOX}
height={SMALL_BOX}
patternUnits="userSpaceOnUse"
>
<path
d={`M ${SMALL_BOX} 0 L 0 0 0 ${SMALL_BOX}`}
fill="none"
className="stroke-brand-pink/20"
strokeWidth={0.5}
/>
</pattern>
{/* Bold 5 mm boxes. */}
<pattern
id={bigGridId}
width={BIG_BOX}
height={BIG_BOX}
patternUnits="userSpaceOnUse"
>
<path
d={`M ${BIG_BOX} 0 L 0 0 0 ${BIG_BOX}`}
fill="none"
className="stroke-brand-pink/40"
strokeWidth={1}
/>
</pattern>
</defs>
<rect width={VIEW_W} height={VIEW_H} fill={`url(#${smallGridId})`} />
<rect width={VIEW_W} height={VIEW_H} fill={`url(#${bigGridId})`} />
{d && (
<path
data-slot="ecg-trace"
d={d}
fill="none"
className="stroke-foreground"
strokeWidth={1.5}
strokeLinejoin="round"
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
)}
</svg>
</figure>
);
}
@@ -66,6 +66,7 @@ const SECTION_TITLE_KEYS: Record<InsightsSectionId, string> = {
"health-status": "insights.healthStatus.sectionTitle",
breathing: "insights.breathingScreening.sectionTitle",
"labs-changes": "insights.labsChanges.sectionTitle",
ecg: "insights.ecg.sectionTitle",
};
interface InsightsEditModeProps {
@@ -33,6 +33,7 @@ describe("insights-layout v2 — section id universe", () => {
"health-status",
"breathing",
"labs-changes",
"ecg",
]);
});
+5
View File
@@ -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];
@@ -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<number>(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);
});
});
+83
View File
@@ -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;
}
+60
View File
@@ -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<ZodOpenApiObject["paths"]> = {
@@ -429,6 +434,61 @@ export const insightsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/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"],
+132
View File
@@ -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.",
});
+13
View File
@@ -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