mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
fix(insights): give an ECG recording its own page and quiet the list
The ECG surface opened with three lines of muted text saying what HealthLog does not do, before a single strip. Two of them said the same thing — one from the page's explainer slot, one hand-rolled beneath it — and the third repeated it under the list. The device attribution on the recording itself already carries that claim where it means something, so the three go and the page opens on the recordings. - the device's verdict rides the timestamp as a tag instead of taking a line of its own beneath it. It qualifies the recording; it is not a second fact - a recording is a route now, `/insights/ecg/[id]`, with the back link ABOVE the heading exactly like a workout detail. The detail used to swap itself into the list card with its back button under the page title, and the recording had no address at all: nothing to deep link, nothing for the browser's own Back to return to. It reads by id, so a cold link works - the list paints the five most recent strips. A watch that records every morning turns an unbounded list into a scroll, not a history `ecg-classification.ts` holds the verdict label map the list, the detail and the cross-link card each used to carry a copy of.
This commit is contained in:
1 parent
0160052289
commit
dc56f70961
14 files changed
+512
-405
No files matched your search
+1
-4
@@ -3090,8 +3090,7 @@
|
||||
"valuesBack": "Zurück zu Insights",
|
||||
"valuesBackToMetric": "Zurück zu {metric}",
|
||||
"scoresBack": "Zurück zu Insights",
|
||||
"ecgDescription": "Einkanal-EKG-Aufzeichnungen deines Geräts, jeweils mit dem Ergebnis des Geräts.",
|
||||
"ecgDisclaimer": "HealthLog zeigt die Kurve und die Aufzeichnung – die Kurve wird nicht interpretiert."
|
||||
"ecgDescription": "Einkanal-EKG-Aufzeichnungen deines Geräts, jeweils mit dem Ergebnis des Geräts."
|
||||
},
|
||||
"units": {
|
||||
"steps": "Schritte",
|
||||
@@ -4235,10 +4234,8 @@
|
||||
},
|
||||
"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": {
|
||||
|
||||
+1
-4
@@ -3090,8 +3090,7 @@
|
||||
"valuesBack": "Back to insights",
|
||||
"valuesBackToMetric": "Back to {metric}",
|
||||
"scoresBack": "Back to insights",
|
||||
"ecgDescription": "Single-lead ECG recordings from your device, each with the device's own result.",
|
||||
"ecgDisclaimer": "HealthLog shows the trace and the recording — it does not interpret the waveform."
|
||||
"ecgDescription": "Single-lead ECG recordings from your device, each with the device's own result."
|
||||
},
|
||||
"units": {
|
||||
"steps": "steps",
|
||||
@@ -4235,10 +4234,8 @@
|
||||
},
|
||||
"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": {
|
||||
|
||||
+1
-4
@@ -3090,8 +3090,7 @@
|
||||
"valuesBack": "Volver a Insights",
|
||||
"valuesBackToMetric": "Volver a {metric}",
|
||||
"scoresBack": "Volver a Insights",
|
||||
"ecgDescription": "Registros de ECG de una derivación de tu dispositivo, cada uno con el resultado del propio dispositivo.",
|
||||
"ecgDisclaimer": "HealthLog muestra el trazado y el registro, pero no interpreta la onda."
|
||||
"ecgDescription": "Registros de ECG de una derivación de tu dispositivo, cada uno con el resultado del propio dispositivo."
|
||||
},
|
||||
"units": {
|
||||
"steps": "pasos",
|
||||
@@ -4235,10 +4234,8 @@
|
||||
},
|
||||
"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": {
|
||||
|
||||
+1
-4
@@ -3090,8 +3090,7 @@
|
||||
"valuesBack": "Retour aux Insights",
|
||||
"valuesBackToMetric": "Retour à {metric}",
|
||||
"scoresBack": "Retour aux Insights",
|
||||
"ecgDescription": "Enregistrements ECG à dérivation unique de votre appareil, chacun accompagné du résultat de l'appareil.",
|
||||
"ecgDisclaimer": "HealthLog affiche le tracé et l'enregistrement, mais n'interprète pas la courbe."
|
||||
"ecgDescription": "Enregistrements ECG à dérivation unique de votre appareil, chacun accompagné du résultat de l'appareil."
|
||||
},
|
||||
"units": {
|
||||
"steps": "pas",
|
||||
@@ -4235,10 +4234,8 @@
|
||||
},
|
||||
"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": {
|
||||
|
||||
+1
-4
@@ -3090,8 +3090,7 @@
|
||||
"valuesBack": "Torna a Insights",
|
||||
"valuesBackToMetric": "Torna a {metric}",
|
||||
"scoresBack": "Torna a Insights",
|
||||
"ecgDescription": "Registrazioni ECG a singola derivazione dal tuo dispositivo, ciascuna con il risultato del dispositivo.",
|
||||
"ecgDisclaimer": "HealthLog mostra il tracciato e la registrazione, ma non interpreta l'onda."
|
||||
"ecgDescription": "Registrazioni ECG a singola derivazione dal tuo dispositivo, ciascuna con il risultato del dispositivo."
|
||||
},
|
||||
"units": {
|
||||
"steps": "passi",
|
||||
@@ -4235,10 +4234,8 @@
|
||||
},
|
||||
"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": {
|
||||
|
||||
+1
-4
@@ -3090,8 +3090,7 @@
|
||||
"valuesBack": "Powrót do Insights",
|
||||
"valuesBackToMetric": "Powrót do {metric}",
|
||||
"scoresBack": "Powrót do Insights",
|
||||
"ecgDescription": "Zapisy EKG jednoodprowadzeniowe z Twojego urządzenia, każdy z wynikiem samego urządzenia.",
|
||||
"ecgDisclaimer": "HealthLog pokazuje przebieg i zapis — nie interpretuje krzywej."
|
||||
"ecgDescription": "Zapisy EKG jednoodprowadzeniowe z Twojego urządzenia, każdy z wynikiem samego urządzenia."
|
||||
},
|
||||
"units": {
|
||||
"steps": "kroki",
|
||||
@@ -4235,10 +4234,8 @@
|
||||
},
|
||||
"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": {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { EcgDetail } from "@/components/insights/ecg-detail";
|
||||
|
||||
/**
|
||||
* `/insights/ecg/[id]` — one ECG recording.
|
||||
*
|
||||
* The detail used to swap itself into the list card, with its own back
|
||||
* button sitting inside the card above the trace. That put the way back
|
||||
* BELOW the page heading, which is the one place a back control never
|
||||
* belongs, and it left the recording without an address: nothing to deep
|
||||
* link, nothing for the browser's own Back to return to.
|
||||
*
|
||||
* It is a route now, shaped exactly like `/insights/workouts/[id]`: a
|
||||
* `BackLink` above the `<SubPageShell>` title, and a body that fetches by
|
||||
* id so a cold deep link works without the list.
|
||||
*/
|
||||
export default function InsightsEcgDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { id } = use(params);
|
||||
|
||||
return (
|
||||
<SubPageShell
|
||||
title={t("insights.ecg.sectionTitle")}
|
||||
// No description. The one the list page carries describes the list,
|
||||
// and a detail page must not claim to be the list.
|
||||
backLink={
|
||||
<BackLink
|
||||
href="/insights/ecg"
|
||||
label={t("insights.ecg.backToList")}
|
||||
dataSlot="ecg-detail-back"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EcgDetail recordingId={id} />
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
@@ -63,21 +63,31 @@ function render(data: { recordings: EcgRecord[]; hasRecordings: boolean }) {
|
||||
}
|
||||
|
||||
describe("/insights/ecg page (H1)", () => {
|
||||
it("renders the EcgSection with its disclaimer when recordings exist", () => {
|
||||
it("renders the EcgSection when recordings exist", () => {
|
||||
const html = render({
|
||||
recordings: [seededRecording()],
|
||||
hasRecordings: true,
|
||||
});
|
||||
// The reused section + its load-bearing non-diagnostic disclaimer render.
|
||||
expect(html).toContain('data-slot="ecg-section"');
|
||||
expect(html).toContain('data-slot="ecg-card"');
|
||||
expect(html).toContain('data-slot="ecg-disclaimer"');
|
||||
// The shell owns the page heading (`<h1>`); the section's own
|
||||
// `<SectionHeading>` (`<h2>`) is suppressed via `hideHeading`.
|
||||
expect(html).toContain('id="insights-subpage-title"');
|
||||
expect(html).not.toContain("<h2");
|
||||
});
|
||||
|
||||
it("carries no disclaimer paragraph above the list", () => {
|
||||
// Two muted lines used to lead this page — one from the shell's own
|
||||
// explainer slot and one hand-rolled beneath it, both saying HealthLog
|
||||
// does not read the curve. The page opens on the recordings now.
|
||||
const html = render({
|
||||
recordings: [seededRecording()],
|
||||
hasRecordings: true,
|
||||
});
|
||||
expect(html).not.toContain('data-slot="ecg-disclaimer"');
|
||||
expect(html).not.toContain("is not interpreted");
|
||||
});
|
||||
|
||||
it("shows the empty state (no section) when the account has no recordings", () => {
|
||||
const html = render({ recordings: [], hasRecordings: false });
|
||||
expect(html).not.toContain('data-slot="ecg-section"');
|
||||
|
||||
@@ -22,13 +22,14 @@ import { EcgSection } from "@/components/insights/ecg-section";
|
||||
* all. This page gives ECG its own address, reached from the Heart-group
|
||||
* pill in the tab strip (gated on `hasRecordings`) and the metric catalog.
|
||||
*
|
||||
* The surface itself is the existing `<EcgSection>`, reused verbatim — its
|
||||
* non-diagnostic disclaimer + waveform logic are load-bearing and untouched.
|
||||
* We only suppress its internal `<SectionHeading>` (via `hideHeading`) because
|
||||
* The surface itself is `<EcgSection>` — the list of strips. Its internal
|
||||
* `<SectionHeading>` is suppressed (via `hideHeading`) because
|
||||
* `<SubPageShell>` already renders the page heading, mirroring how every other
|
||||
* routed sub-page lets the shell own the `<h1>`.
|
||||
*
|
||||
* The overview `<EcgSection>` teaser on `/insights` stays as-is.
|
||||
* A row opens `/insights/ecg/[id]`, which is where the trace, the metadata and
|
||||
* the device's own result live. The overview `<EcgSection>` teaser on
|
||||
* `/insights` links to the same place.
|
||||
*/
|
||||
export default function InsightsEcgPage() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
@@ -76,11 +77,6 @@ export default function InsightsEcgPage() {
|
||||
title={t("insights.ecg.sectionTitle")}
|
||||
description={t("insights.subPage.ecgDescription")}
|
||||
>
|
||||
{/* What the app does and does not claim about a trace. Its own
|
||||
paragraph, so the page explainer above stays one sentence. */}
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("insights.subPage.ecgDisclaimer")}
|
||||
</p>
|
||||
<EcgSection enabled={isAuthenticated} hideHeading />
|
||||
</SubPageShell>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* `<EcgDetail>` — one recording, addressed by id.
|
||||
*
|
||||
* The non-diagnostic framing is the load-bearing part and it is unchanged by
|
||||
* the move to a route: the result shown is the DEVICE's, attributed to the
|
||||
* device, and a non-normal device result adds the "discuss with a clinician"
|
||||
* note while a normal one does not.
|
||||
*
|
||||
* What did change: the component reads by id rather than being handed a row
|
||||
* from the list, so a cold deep link works — the positive control below is
|
||||
* that a render with only an id in hand still paints the trace.
|
||||
*/
|
||||
|
||||
const useQueryMock = vi.fn();
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: (opts: unknown) => useQueryMock(opts),
|
||||
}));
|
||||
|
||||
const { EcgDetail } = await import("../ecg-detail");
|
||||
type EcgDetailResponse = import("../ecg-detail").EcgDetailResponse;
|
||||
|
||||
const DETAIL: EcgDetailResponse = {
|
||||
recordedAt: "2026-06-01T09:15:00.000Z",
|
||||
durationSeconds: 30,
|
||||
samplingFrequency: 300,
|
||||
averageHeartRate: 72,
|
||||
lead: null,
|
||||
classification: "IRREGULAR",
|
||||
source: "WITHINGS",
|
||||
samples: [0, 10, -5, 40, -20, 5, 0],
|
||||
decimated: true,
|
||||
};
|
||||
|
||||
function renderDetail(detail: Partial<EcgDetailResponse> = {}) {
|
||||
useQueryMock.mockReturnValue({
|
||||
data: { ...DETAIL, ...detail },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<EcgDetail recordingId="ecg_1" />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<EcgDetail> — non-diagnostic framing", () => {
|
||||
it("attributes the result to the recording device and draws the waveform", () => {
|
||||
const html = renderDetail();
|
||||
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");
|
||||
expect(html).toContain('data-slot="ecg-trace"');
|
||||
});
|
||||
|
||||
it("adds the 'discuss with a clinician' note on a non-normal result", () => {
|
||||
const html = renderDetail();
|
||||
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({ classification: "INCONCLUSIVE" });
|
||||
expect(html).toContain('data-slot="ecg-clinician-note"');
|
||||
});
|
||||
|
||||
it("does NOT add the clinician note for a normal (not-detected) result", () => {
|
||||
const html = renderDetail({ classification: "NOT_DETECTED" });
|
||||
expect(html).not.toContain('data-slot="ecg-clinician-note"');
|
||||
// The device attribution is still there.
|
||||
expect(html).toContain("as reported by the recording device");
|
||||
});
|
||||
|
||||
it("says a failed read failed instead of painting an empty recording", () => {
|
||||
useQueryMock.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
});
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<EcgDetail recordingId="ecg_1" />
|
||||
</I18nProvider>,
|
||||
);
|
||||
expect(html).toContain('data-slot="ecg-detail-error"');
|
||||
expect(html).not.toContain('data-slot="ecg-trace"');
|
||||
});
|
||||
});
|
||||
@@ -4,20 +4,19 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.28.50 — `<EcgSection>` / `<EcgDetail>` unit tests.
|
||||
* `<EcgSection>` — the list of recorded strips.
|
||||
*
|
||||
* 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.
|
||||
* - data-availability gating: the section un-mounts entirely when the user
|
||||
* has no recordings or while the payload is in flight;
|
||||
* - the reading cap: a device that records every morning accumulates
|
||||
* hundreds of strips, and the list paints the most recent handful;
|
||||
* - the device's verdict rides the timestamp as a tag, and a row that has
|
||||
* a waveform opens the recording's own address.
|
||||
*
|
||||
* `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.
|
||||
* `rhythm-events-card` test. `<EcgDetail>` has its own spec.
|
||||
*/
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
@@ -29,20 +28,8 @@ 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 { EcgSection, ECG_LIST_LIMIT } = await import("../ecg-section");
|
||||
type EcgRecordingListItem = import("../ecg-section").EcgRecordingListItem;
|
||||
|
||||
const IRREGULAR_REC: EcgRecordingListItem = {
|
||||
id: "ecg_1",
|
||||
@@ -63,18 +50,6 @@ const NORMAL_REC: EcgRecordingListItem = {
|
||||
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,
|
||||
@@ -87,20 +62,8 @@ function renderSection(
|
||||
);
|
||||
}
|
||||
|
||||
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>,
|
||||
);
|
||||
function countRows(html: string): number {
|
||||
return (html.match(/data-slot="ecg-row"/g) ?? []).length;
|
||||
}
|
||||
|
||||
describe("<EcgSection>", () => {
|
||||
@@ -119,65 +82,73 @@ describe("<EcgSection>", () => {
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("renders the recording list with a device-result label per row", () => {
|
||||
it("renders one row per recording with the device result as a tag", () => {
|
||||
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(countRows(html)).toBe(2);
|
||||
expect(html).toContain("Atrial fibrillation detected");
|
||||
expect(html).toContain("No signs of atrial fibrillation");
|
||||
// The verdict sits beside the timestamp inside the row's own header
|
||||
// line, not on a line of its own beneath it.
|
||||
expect(html).toMatch(
|
||||
/<span[^>]*data-slot="ecg-row-result"[^>]*class="[^"]*rounded-full/,
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the permanent non-diagnostic disclaimer on the list", () => {
|
||||
it("caps the list at the most recent handful", () => {
|
||||
// Eight strips in, five out — and the five are the ones the route
|
||||
// handed back first, which it orders `recordedAt desc`.
|
||||
const recordings = Array.from({ length: 8 }, (_, index) => ({
|
||||
...NORMAL_REC,
|
||||
id: `ecg_${index}`,
|
||||
recordedAt: new Date(Date.UTC(2026, 5, 20 - index, 9, 15)).toISOString(),
|
||||
}));
|
||||
const html = renderSection({ recordings, hasRecordings: true });
|
||||
|
||||
expect(ECG_LIST_LIMIT).toBe(5);
|
||||
expect(countRows(html)).toBe(ECG_LIST_LIMIT);
|
||||
for (const kept of recordings.slice(0, ECG_LIST_LIMIT)) {
|
||||
expect(html, `${kept.id} should be listed`).toContain(
|
||||
`/insights/ecg/${kept.id}`,
|
||||
);
|
||||
}
|
||||
for (const dropped of recordings.slice(ECG_LIST_LIMIT)) {
|
||||
expect(html, `${dropped.id} is past the cap`).not.toContain(
|
||||
`/insights/ecg/${dropped.id}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("opens the recording's own address when it has a waveform", () => {
|
||||
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");
|
||||
expect(html).toContain('href="/insights/ecg/ecg_1"');
|
||||
});
|
||||
});
|
||||
|
||||
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("leaves a waveform-less recording unlinked", () => {
|
||||
const html = renderSection({
|
||||
recordings: [{ ...IRREGULAR_REC, hasWaveform: false }],
|
||||
hasRecordings: true,
|
||||
});
|
||||
expect(countRows(html)).toBe(1);
|
||||
expect(html).not.toContain('href="/insights/ecg/ecg_1"');
|
||||
});
|
||||
|
||||
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");
|
||||
it("carries no intro paragraph and no disclaimer block", () => {
|
||||
// Both were removed: the page heading already says what the surface is,
|
||||
// and the device attribution on the detail page is where the "this is
|
||||
// the device's result" statement belongs.
|
||||
const html = renderSection({
|
||||
recordings: [IRREGULAR_REC],
|
||||
hasRecordings: true,
|
||||
});
|
||||
expect(html).not.toContain('data-slot="ecg-disclaimer"');
|
||||
expect(html).not.toContain("Single-lead ECG strips");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { EcgWaveform } from "@/components/insights/ecg-waveform";
|
||||
import { InsightSectionCard } from "@/components/insights/insight-section-card";
|
||||
import {
|
||||
ecgResultLabel,
|
||||
isNonNormalEcg,
|
||||
type EcgClassification,
|
||||
} from "@/lib/insights/ecg-classification";
|
||||
|
||||
/**
|
||||
* One ECG recording: the trace, the metadata, and the DEVICE's own result.
|
||||
*
|
||||
* NON-DIAGNOSTIC: the result shown is the recording device's, attributed to
|
||||
* the device ("Recorded result: …, as reported by the recording device").
|
||||
* HealthLog reads nothing off the curve. A non-normal device result adds the
|
||||
* "discuss with a clinician" note. Plain React text children throughout.
|
||||
*
|
||||
* The component owns its own read, addressed by id, so the detail page needs
|
||||
* nothing from the list it was opened from — a deep link works cold.
|
||||
*/
|
||||
|
||||
export interface EcgDetailResponse {
|
||||
recordedAt: string;
|
||||
durationSeconds: number | null;
|
||||
samplingFrequency: number;
|
||||
averageHeartRate: number | null;
|
||||
lead: string | null;
|
||||
classification: EcgClassification;
|
||||
source: string;
|
||||
samples: number[];
|
||||
decimated: boolean;
|
||||
}
|
||||
|
||||
export function EcgDetail({ recordingId }: { recordingId: string }) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: queryKeys.insightsEcgDetail(recordingId, false),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await apiGet<EcgDetailResponse>(
|
||||
`/api/insights/ecg/${recordingId}`,
|
||||
);
|
||||
} catch {
|
||||
throw new Error(t("insights.ecg.loadError"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (isError) {
|
||||
// A failed read must never read as "no data" (UI-STANDARDS §6).
|
||||
return (
|
||||
<p
|
||||
data-slot="ecg-detail-error"
|
||||
role="alert"
|
||||
className="text-muted-foreground text-sm"
|
||||
>
|
||||
{t("insights.ecg.loadError")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<div
|
||||
data-slot="ecg-waveform-skeleton"
|
||||
className="bg-muted/40 h-40 w-full animate-pulse rounded-lg motion-reduce:animate-none"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const resultLabel = ecgResultLabel(data.classification, t);
|
||||
const leadLabel = data.lead ?? t("insights.ecg.meta.leadSingle");
|
||||
const durationLabel =
|
||||
data.durationSeconds != null
|
||||
? t("insights.ecg.meta.durationValue", {
|
||||
seconds: Math.round(data.durationSeconds),
|
||||
})
|
||||
: t("insights.ecg.meta.unknown");
|
||||
|
||||
return (
|
||||
<InsightSectionCard slot="ecg-detail" className="border-border">
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
<EcgWaveform
|
||||
samples={data.samples}
|
||||
recordedAt={data.recordedAt}
|
||||
durationSeconds={data.durationSeconds}
|
||||
averageHeartRate={data.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(data.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={
|
||||
data.averageHeartRate != null
|
||||
? t("insights.ecg.meta.bpmValue", { bpm: data.averageHeartRate })
|
||||
: t("insights.ecg.meta.unknown")
|
||||
}
|
||||
/>
|
||||
<MetaItem
|
||||
label={t("insights.ecg.meta.samplingRate")}
|
||||
value={
|
||||
data.samplingFrequency > 0
|
||||
? t("insights.ecg.meta.hzValue", { hz: data.samplingFrequency })
|
||||
: t("insights.ecg.meta.unknown")
|
||||
}
|
||||
/>
|
||||
</dl>
|
||||
|
||||
{/* "Discuss with a clinician" — only on a non-normal device result. */}
|
||||
{isNonNormalEcg(data.classification) && (
|
||||
<p
|
||||
data-slot="ecg-clinician-note"
|
||||
className="text-foreground text-sm font-medium"
|
||||
>
|
||||
{t("insights.ecg.clinicianNote")}
|
||||
</p>
|
||||
)}
|
||||
</InsightSectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Activity, ChevronLeft, HeartPulse, Info } from "lucide-react";
|
||||
import { Activity, ChevronRight, HeartPulse } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -10,35 +10,41 @@ import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SectionHeading } from "@/components/ui/section-heading";
|
||||
import { EcgWaveform } from "@/components/insights/ecg-waveform";
|
||||
import {
|
||||
ecgResultLabel,
|
||||
type EcgClassification,
|
||||
} from "@/lib/insights/ecg-classification";
|
||||
import { InsightSectionCard } from "./insight-section-card";
|
||||
|
||||
/**
|
||||
* v1.28.50 — ECG recording surface (list → detail).
|
||||
* v1.28.50 — ECG recording list.
|
||||
*
|
||||
* 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.
|
||||
* The user's ECG recordings synced from a single-lead device (Withings
|
||||
* ScanWatch today): one row per strip, each opening `/insights/ecg/<id>`
|
||||
* with the trace, the 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).
|
||||
* NON-DIAGNOSTIC: the surface shows the waveform (raw data), the metadata
|
||||
* and the recording device's own result, attributed to the device on the
|
||||
* detail page. HealthLog generates NO interpretation of the waveform — no
|
||||
* measured intervals, no beat/P/QRS/T annotation, no risk score, no verdict
|
||||
* of its own. All copy renders as plain React text children (no markdown
|
||||
* library — the standing XSS rule).
|
||||
*
|
||||
* The list is capped: a device that records every morning accumulates
|
||||
* hundreds of strips, and a page that paints all of them is a scroll, not a
|
||||
* history. The most recent handful is what the surface is for.
|
||||
*
|
||||
* 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;
|
||||
/**
|
||||
* How many strips the list paints. The API already caps its own read at 200;
|
||||
* this is the reading limit, not the fetch limit.
|
||||
*/
|
||||
export const ECG_LIST_LIMIT = 5;
|
||||
|
||||
interface EcgRecordingListItem {
|
||||
export interface EcgRecordingListItem {
|
||||
id: string;
|
||||
recordedAt: string;
|
||||
durationSeconds: number | null;
|
||||
@@ -56,30 +62,6 @@ interface EcgListResponse {
|
||||
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;
|
||||
@@ -87,8 +69,7 @@ interface EcgSectionProps {
|
||||
* v1.30 — suppress the internal `<SectionHeading>` when the section is
|
||||
* hosted on the routed `/insights/ecg` sub-page, whose `<SubPageShell>`
|
||||
* already renders the page `<h1>`. Defaults to `false` so the overview
|
||||
* teaser keeps its own heading unchanged. Purely presentational — the
|
||||
* non-diagnostic disclaimer + waveform logic are untouched.
|
||||
* teaser keeps its own heading unchanged.
|
||||
*/
|
||||
hideHeading?: boolean;
|
||||
}
|
||||
@@ -101,7 +82,6 @@ export function EcgSection({
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: queryKeys.insightsEcgList(),
|
||||
@@ -118,16 +98,9 @@ export function EcgSection({
|
||||
// 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;
|
||||
}
|
||||
// The route hands them back most-recent-first; the slice is the reading
|
||||
// limit on top of that, so the newest strips are the ones on screen.
|
||||
const recordings = data.recordings.slice(0, ECG_LIST_LIMIT);
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -143,230 +116,67 @@ export function EcgSection({
|
||||
/>
|
||||
)}
|
||||
<InsightSectionCard slot="ecg-card" className="border-border">
|
||||
{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"
|
||||
<ol data-slot="ecg-list" className="space-y-3">
|
||||
{recordings.map((rec) => {
|
||||
const label = ecgResultLabel(rec.classification, t);
|
||||
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">
|
||||
{/* The device's verdict rides the timestamp as a tag
|
||||
rather than claiming a line of its own — it qualifies
|
||||
the recording, it is not a second fact about it. */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{fmt.dateTime(new Date(rec.recordedAt))}
|
||||
</p>
|
||||
{label && (
|
||||
<span
|
||||
data-slot="ecg-row-result"
|
||||
className="border-border text-muted-foreground rounded-full border px-2 py-0.5 text-xs"
|
||||
>
|
||||
{rowInner}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-start gap-3">{rowInner}</div>
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
|
||||
{/* Load-bearing regulatory disclaimer — permanent,
|
||||
non-dismissible. Plain React text children (no markdown). */}
|
||||
<EcgDisclaimer />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{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 ? (
|
||||
<Link
|
||||
href={`/insights/ecg/${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}
|
||||
<ChevronRight
|
||||
className="text-muted-foreground mt-1.5 size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="flex items-start gap-3">{rowInner}</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</InsightSectionCard>
|
||||
</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,36 @@
|
||||
/**
|
||||
* The recording device's own rhythm verdict, and the two things every ECG
|
||||
* surface needs to say about it.
|
||||
*
|
||||
* The list, the detail page and the cross-link card all render this value and
|
||||
* all three used to carry their own copy of the label map. It is one map now,
|
||||
* so a fourth surface cannot introduce a fifth wording.
|
||||
*
|
||||
* HealthLog produces NO verdict of its own — no measured intervals, no
|
||||
* beat/P/QRS/T annotation, no risk score. Everything here is the device's
|
||||
* result, passed through.
|
||||
*/
|
||||
export type EcgClassification =
|
||||
"IRREGULAR" | "NOT_DETECTED" | "INCONCLUSIVE" | null;
|
||||
|
||||
/** i18n key per device verdict, under `insights.ecg.result.*`. */
|
||||
export const ECG_RESULT_LABEL_KEYS: Record<string, string> = {
|
||||
IRREGULAR: "insights.ecg.result.irregular",
|
||||
NOT_DETECTED: "insights.ecg.result.notDetected",
|
||||
INCONCLUSIVE: "insights.ecg.result.inconclusive",
|
||||
};
|
||||
|
||||
/** Resolve the device verdict to its localised label, or null when unset. */
|
||||
export function ecgResultLabel(
|
||||
classification: EcgClassification,
|
||||
t: (key: string) => string,
|
||||
): string | null {
|
||||
if (!classification) return null;
|
||||
const key = ECG_RESULT_LABEL_KEYS[classification];
|
||||
return key ? t(key) : null;
|
||||
}
|
||||
|
||||
/** A non-normal device result carries the "discuss with a clinician" note. */
|
||||
export function isNonNormalEcg(classification: EcgClassification): boolean {
|
||||
return classification === "IRREGULAR" || classification === "INCONCLUSIVE";
|
||||
}
|
||||
Reference in new issue
Block a user