mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
Merge pull request #508 from MBombeck/release/v1.28.50
v1.28.50 — View your ECG recordings in Insights
This commit is contained in:
28 files changed
+2124
-4
No files matched your search
+13
-1
@@ -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
@@ -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:
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"],
|
||||
|
||||
@@ -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.",
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user