Merge pull request #508 from MBombeck/release/v1.28.50

v1.28.50 — View your ECG recordings in Insights
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-16 10:06:06 +02:00
commit 57539283c8
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