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()} + /> ); }