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 ( +

+ {t("insights.ecg.loadError")} +

+ ); + } + + if (isLoading || !data) { + return ( +
+ ); + } + + 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 ( + + {/* The DEVICE's result, attributed to the device. This is the ONLY + verdict shown — HealthLog produces none. */} + {resultLabel && ( +

+ {t("insights.ecg.resultAttribution", { result: resultLabel })} +

+ )} + + + +
+ + + + + 0 + ? t("insights.ecg.meta.hzValue", { hz: data.samplingFrequency }) + : t("insights.ecg.meta.unknown") + } + /> +
+ + {/* "Discuss with a clinician" — only on a non-normal device result. */} + {isNonNormalEcg(data.classification) && ( +

+ {t("insights.ecg.clinicianNote")} +

+ )} +
+ ); +} + +function MetaItem({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} 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)} - /> - ) : ( - <> -

- {t("insights.ecg.sectionIntro")} -

-
    - {data.recordings.map((rec) => { - const label = resultLabel(rec.classification); - const rowInner = ( - <> - - - -
    -

    - {fmt.dateTime(new Date(rec.recordedAt))} -

    - {label && ( -

    - {label} -

    - )} - {rec.averageHeartRate != null && ( -

    - {t("insights.ecg.meta.bpmValue", { - bpm: rec.averageHeartRate, - })} -

    - )} -
    - - ); - return ( -
  1. - {rec.hasWaveform ? ( - - ) : ( -
    {rowInner}
    + {label} + )} -
  2. - ); - })} -
- - {/* Load-bearing regulatory disclaimer — permanent, - non-dismissible. Plain React text children (no markdown). */} - - - )} +

+ {rec.averageHeartRate != null && ( +

+ {t("insights.ecg.meta.bpmValue", { + bpm: rec.averageHeartRate, + })} +

+ )} + + + ); + return ( +
  • + {rec.hasWaveform ? ( + + {rowInner} +
  • + ); + })} + ); } - -/** - * 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( - `/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 ( -
    - - - {/* The DEVICE's result, attributed to the device. This is the ONLY - verdict shown — HealthLog produces none. */} - {resultLabel && ( -

    - {t("insights.ecg.resultAttribution", { result: resultLabel })} -

    - )} - - {isLoading || !detail ? ( -
    - ) : ( - - )} - -
    - - - - - 0 - ? t("insights.ecg.meta.hzValue", { - hz: recording.samplingFrequency, - }) - : t("insights.ecg.meta.unknown") - } - /> -
    - - {/* "Discuss with a clinician" — only on a non-normal device result. */} - {nonNormal && ( -

    - {t("insights.ecg.clinicianNote")} -

    - )} - - -
    - ); -} - -function MetaItem({ label, value }: { label: string; value: string }) { - return ( -
    -
    {label}
    -
    {value}
    -
    - ); -} - -/** - * 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 ( -
    -
    - ); -} 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"; +}