From 1229d54d308bcab47d798e0050cb6ef00488715b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 9 Aug 2026 04:13:46 +0200 Subject: [PATCH] fix(insights): recoverable read failures on the ECG detail and list page The ECG detail rendered a read failure as a muted, non-recoverable line; the routed list page had no error branch at all, leaving the shell heading over an empty body that read the same as loading. The detail now paints a QueryErrorCard with retry (an alert is content, not muted meta), and the page shows the same instead of an empty shell. The overview teaser still unmounts silently, which is right for the overview. Tests watched each fail first. --- .../ecg/__tests__/page-error-state.test.tsx | 42 +++++++++++++++++++ src/app/insights/ecg/page.tsx | 18 +++++++- .../insights/__tests__/ecg-detail.test.tsx | 7 +++- src/components/insights/ecg-detail.tsx | 17 ++++---- 4 files changed, 72 insertions(+), 12 deletions(-) create mode 100644 src/app/insights/ecg/__tests__/page-error-state.test.tsx diff --git a/src/app/insights/ecg/__tests__/page-error-state.test.tsx b/src/app/insights/ecg/__tests__/page-error-state.test.tsx new file mode 100644 index 000000000..489551177 --- /dev/null +++ b/src/app/insights/ecg/__tests__/page-error-state.test.tsx @@ -0,0 +1,42 @@ +/** + * A failed ECG-list read on the routed `/insights/ecg` page must not leave the + * shell heading over an empty body — indistinguishable from "still loading". + * The shared list query is forced into its error state; the page has to render + * a `query-error-card` with retry inside the shell. + */ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ isAuthenticated: true, user: null }), +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ + data: undefined, + isLoading: false, + isError: true, + refetch: vi.fn(), + }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import InsightsEcgPage from "../page"; + +function render() { + return renderToStaticMarkup( + + + , + ); +} + +describe("/insights/ecg page — a read failure is honest, not an empty shell", () => { + it("renders the query-error card with retry, not an empty section body", () => { + const html = render(); + expect(html).toContain('data-slot="query-error-card"'); + expect(html).toContain('data-slot="query-error-retry"'); + // The heading shell is still there, but not over a silently empty body. + expect(html).toContain('id="insights-subpage-title"'); + }); +}); diff --git a/src/app/insights/ecg/page.tsx b/src/app/insights/ecg/page.tsx index eaec88fa0..110be6dbe 100644 --- a/src/app/insights/ecg/page.tsx +++ b/src/app/insights/ecg/page.tsx @@ -9,6 +9,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; import { Button } from "@/components/ui/button"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; import { SubPageShell } from "@/components/insights/sub-page-shell"; import { MetricEmptyState } from "@/components/insights/metric-empty-state"; import { EcgSection } from "@/components/insights/ecg-section"; @@ -38,12 +39,27 @@ export default function InsightsEcgPage() { // Reuse the SAME query cell `` reads (`insightsEcgList`) so the // page and the section share one cache entry. The page only needs the // `hasRecordings` flag to choose between the empty state and the section. - const { data, isLoading } = useQuery({ + const { data, isLoading, isError, refetch } = useQuery({ queryKey: queryKeys.insightsEcgList(), queryFn: () => apiGet<{ hasRecordings: boolean }>("/api/insights/ecg"), enabled: isAuthenticated, }); + // A read failure must be visible, not a heading over an empty body that + // reads the same as "still loading" (§6). The section teaser below unmounts + // silently on error, which is right for the overview but wrong for the page + // the user opened to see this list. + if (isError) { + return ( + + refetch()} /> + + ); + } + // Data-availability empty state — a direct URL hit (or deleted recordings) // lands here without a section to show. Mirrors the sub-page convention: // a calm one-line scaffold + a connect-a-device CTA, never a blank surface. diff --git a/src/components/insights/__tests__/ecg-detail.test.tsx b/src/components/insights/__tests__/ecg-detail.test.tsx index 9c7c9b80e..b39ccc35a 100644 --- a/src/components/insights/__tests__/ecg-detail.test.tsx +++ b/src/components/insights/__tests__/ecg-detail.test.tsx @@ -78,18 +78,21 @@ describe(" — non-diagnostic framing", () => { expect(html).toContain("as reported by the recording device"); }); - it("says a failed read failed instead of painting an empty recording", () => { + it("shows a recoverable read failure instead of painting an empty recording", () => { useQueryMock.mockReturnValue({ data: undefined, isLoading: false, isError: true, + refetch: vi.fn(), }); const html = renderToStaticMarkup( , ); - expect(html).toContain('data-slot="ecg-detail-error"'); + // The failure is a recoverable card, not a muted "no data" line. + expect(html).toContain('data-slot="query-error-card"'); + expect(html).toContain('data-slot="query-error-retry"'); expect(html).not.toContain('data-slot="ecg-trace"'); }); }); diff --git a/src/components/insights/ecg-detail.tsx b/src/components/insights/ecg-detail.tsx index 8c2f5fc11..c60c2abee 100644 --- a/src/components/insights/ecg-detail.tsx +++ b/src/components/insights/ecg-detail.tsx @@ -7,6 +7,7 @@ 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 { QueryErrorCard } from "@/components/ui/query-error-card"; import { ecgResultLabel, isNonNormalEcg, @@ -41,7 +42,7 @@ export function EcgDetail({ recordingId }: { recordingId: string }) { const { t } = useTranslations(); const fmt = useFormatters(); - const { data, isLoading, isError } = useQuery({ + const { data, isLoading, isError, refetch } = useQuery({ queryKey: queryKeys.insightsEcgDetail(recordingId, false), queryFn: async () => { try { @@ -55,15 +56,13 @@ export function EcgDetail({ recordingId }: { recordingId: string }) { }); if (isError) { - // A failed read must never read as "no data" (UI-STANDARDS §6). + // A failed read must never read as "no data" (UI-STANDARDS §6), and the + // alert is content, not muted meta — it also has to be recoverable. return ( -

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

+ refetch()} + /> ); }