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.
This commit is contained in:
Marc-André Bombeck committed 2026-08-09 04:13:46 +02:00
1 parent 22d5c01a68
commit 1229d54d30
4 files changed
+72 -12

No files matched your search

@@ -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(
<I18nProvider initialLocale="en">
<InsightsEcgPage />
</I18nProvider>,
);
}
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"');
});
});
+17 -1
View File
@@ -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 `<EcgSection>` 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 (
<SubPageShell
title={t("insights.ecg.sectionTitle")}
description={t("insights.subPage.ecgDescription")}
>
<QueryErrorCard onRetry={() => refetch()} />
</SubPageShell>
);
}
// 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.
@@ -78,18 +78,21 @@ describe("<EcgDetail> — 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(
<I18nProvider initialLocale="en">
<EcgDetail recordingId="ecg_1" />
</I18nProvider>,
);
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"');
});
});
+8 -9
View File
@@ -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 (
<p
data-slot="ecg-detail-error"
role="alert"
className="text-muted-foreground text-sm"
>
{t("insights.ecg.loadError")}
</p>
<QueryErrorCard
title={t("insights.ecg.loadError")}
onRetry={() => refetch()}
/>
);
}