diff --git a/messages/de.json b/messages/de.json
index 4b2738597..2ca8e44a9 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -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": {
diff --git a/messages/en.json b/messages/en.json
index de26039d7..cce8b2ea2 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -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": {
diff --git a/messages/es.json b/messages/es.json
index ab4264cb7..14df01c07 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -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": {
diff --git a/messages/fr.json b/messages/fr.json
index 364b9a907..1510ead0c 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -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": {
diff --git a/messages/it.json b/messages/it.json
index c377574f4..c0d97c4ef 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -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": {
diff --git a/messages/pl.json b/messages/pl.json
index c3c69948c..90eb7eda8 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -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": {
diff --git a/src/app/insights/ecg/[id]/page.tsx b/src/app/insights/ecg/[id]/page.tsx
new file mode 100644
index 000000000..e9ba67545
--- /dev/null
+++ b/src/app/insights/ecg/[id]/page.tsx
@@ -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 `` 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 (
+
+ }
+ >
+
+
+ );
+}
diff --git a/src/app/insights/ecg/__tests__/page.test.tsx b/src/app/insights/ecg/__tests__/page.test.tsx
index 8ddba695e..fec827e45 100644
--- a/src/app/insights/ecg/__tests__/page.test.tsx
+++ b/src/app/insights/ecg/__tests__/page.test.tsx
@@ -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 (`
`); the section's own
// `` (`
`) is suppressed via `hideHeading`.
expect(html).toContain('id="insights-subpage-title"');
expect(html).not.toContain("
{
+ // 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"');
diff --git a/src/app/insights/ecg/page.tsx b/src/app/insights/ecg/page.tsx
index 30abbea93..eaec88fa0 100644
--- a/src/app/insights/ecg/page.tsx
+++ b/src/app/insights/ecg/page.tsx
@@ -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 ``, reused verbatim — its
- * non-diagnostic disclaimer + waveform logic are load-bearing and untouched.
- * We only suppress its internal `` (via `hideHeading`) because
+ * The surface itself is `` — the list of strips. Its internal
+ * `` is suppressed (via `hideHeading`) because
* `` already renders the page heading, mirroring how every other
* routed sub-page lets the shell own the `
`.
*
- * The overview `` 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 `` 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. */}
-
- {t("insights.subPage.ecgDisclaimer")}
-
);
diff --git a/src/components/insights/__tests__/ecg-detail.test.tsx b/src/components/insights/__tests__/ecg-detail.test.tsx
new file mode 100644
index 000000000..9c7c9b80e
--- /dev/null
+++ b/src/components/insights/__tests__/ecg-detail.test.tsx
@@ -0,0 +1,95 @@
+import { describe, it, expect, vi } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { I18nProvider } from "@/lib/i18n/context";
+
+/**
+ * `` — 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 = {}) {
+ useQueryMock.mockReturnValue({
+ data: { ...DETAIL, ...detail },
+ isLoading: false,
+ isError: false,
+ });
+ return renderToStaticMarkup(
+
+
+ ,
+ );
+}
+
+describe(" — 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(
+
+
+ ,
+ );
+ expect(html).toContain('data-slot="ecg-detail-error"');
+ expect(html).not.toContain('data-slot="ecg-trace"');
+ });
+});
diff --git a/src/components/insights/__tests__/ecg-section.test.tsx b/src/components/insights/__tests__/ecg-section.test.tsx
index 6497af8fd..eb663f15d 100644
--- a/src/components/insights/__tests__/ecg-section.test.tsx
+++ b/src/components/insights/__tests__/ecg-section.test.tsx
@@ -4,20 +4,19 @@ import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
/**
- * v1.28.50 — `` / `` unit tests.
+ * `` — 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. `` 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(
-
- {}}
- />
- ,
- );
+function countRows(html: string): number {
+ return (html.match(/data-slot="ecg-row"/g) ?? []).length;
}
describe("", () => {
@@ -119,65 +82,73 @@ describe("", () => {
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(
+ /]*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(" — 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");
});
});
diff --git a/src/components/insights/ecg-detail.tsx b/src/components/insights/ecg-detail.tsx
new file mode 100644
index 000000000..8c2f5fc11
--- /dev/null
+++ b/src/components/insights/ecg-detail.tsx
@@ -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(
+ `/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 (
+
+ );
+}
diff --git a/src/components/insights/ecg-section.tsx b/src/components/insights/ecg-section.tsx
index a5fe8a473..ab6ed1669 100644
--- a/src/components/insights/ecg-section.tsx
+++ b/src/components/insights/ecg-section.tsx
@@ -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/`
+ * 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 = {
- 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 `` when the section is
* hosted on the routed `/insights/ecg` sub-page, whose ``
* already renders the page `
`. 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(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 (
)}
- {selected ? (
- setSelectedId(null)}
- />
- ) : (
- <>
-
- );
-}
-
-/**
- * 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 (
-
-
-
{t("insights.ecg.disclaimer")}
-
- );
-}
diff --git a/src/lib/insights/ecg-classification.ts b/src/lib/insights/ecg-classification.ts
new file mode 100644
index 000000000..42883c274
--- /dev/null
+++ b/src/lib/insights/ecg-classification.ts
@@ -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 = {
+ 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";
+}