From f63fdb373efe9573686f1be8ba2f4779b2c60f75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 9 Aug 2026 04:20:55 +0200 Subject: [PATCH] fix(cycle): use the shared query-error primitives on failed cycle reads The cycle tabs rendered a bespoke error row in muted text, wrapped in a bare card. Replace it with the shared primitives: QueryErrorCard on the standalone prediction/insights/settings tabs, and a borderless QueryErrorRow inside the calendar card so the failure reads as foreground content with retry rather than muted meta. Test watched the muted row first. --- .../__tests__/cycle-view-error-state.test.tsx | 69 +++++++++++++++++++ src/components/cycle/cycle-view.tsx | 59 ++++++---------- 2 files changed, 90 insertions(+), 38 deletions(-) create mode 100644 src/components/cycle/__tests__/cycle-view-error-state.test.tsx diff --git a/src/components/cycle/__tests__/cycle-view-error-state.test.tsx b/src/components/cycle/__tests__/cycle-view-error-state.test.tsx new file mode 100644 index 000000000..13ef5cae2 --- /dev/null +++ b/src/components/cycle/__tests__/cycle-view-error-state.test.tsx @@ -0,0 +1,69 @@ +/** + * A failed cycle read must surface as a recoverable, foreground error, not a + * hand-rolled muted line. The calendar read (which drives the default tab) is + * forced into its error state; the tab has to render the shared query-error + * primitive with retry, and no longer the muted `cycle-tab-error` row. + */ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("next/navigation", () => ({ + useSearchParams: () => new URLSearchParams(), +})); + +vi.mock("@/hooks/use-record-capabilities", () => ({ + useRecordCapabilities: () => ({ canManage: true, canAdd: true }), +})); + +const errorQuery = { + data: undefined, + isError: true, + isLoading: false, + refetch: vi.fn(), +}; +const idleQuery = { + data: undefined, + isError: false, + isLoading: false, + refetch: vi.fn(), +}; +const mutationStub = { mutate: vi.fn(), isPending: false, isError: false }; + +vi.mock("../use-cycle", () => ({ + localYmd: () => "2026-07-01", + useCycleCalendar: () => errorQuery, + useCycleHistory: () => idleQuery, + useCycleInsights: () => idleQuery, + useCycleProfile: () => idleQuery, + useCycleDayLog: () => idleQuery, + useCustomSymptoms: () => idleQuery, + useLogDay: () => mutationStub, + usePatchDayLog: () => mutationStub, + useStartPeriod: () => mutationStub, + useEndPeriod: () => mutationStub, + useDeleteDayLog: () => mutationStub, + useCreateCustomSymptom: () => mutationStub, + useDeleteCustomSymptom: () => mutationStub, + CUSTOM_SYMPTOM_LIMIT_ERROR_CODE: "cycle.symptom.custom.limit", + CustomSymptomError: class extends Error {}, +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { CycleView } from "../cycle-view"; + +function render() { + return renderToStaticMarkup( + + + , + ); +} + +describe(" — a read failure is honest, not a muted line", () => { + it("renders the shared query-error primitive with retry, not the muted row", () => { + const html = render(); + expect(html).toContain('data-slot="query-error-row"'); + expect(html).toContain('data-slot="query-error-row-retry"'); + expect(html).not.toContain('data-slot="cycle-tab-error"'); + }); +}); diff --git a/src/components/cycle/cycle-view.tsx b/src/components/cycle/cycle-view.tsx index ddccf448f..c268f4a20 100644 --- a/src/components/cycle/cycle-view.tsx +++ b/src/components/cycle/cycle-view.tsx @@ -8,6 +8,8 @@ import { Loader2, Plus, RefreshCw, Sparkles } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { PageHeader } from "@/components/ui/page-header"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { Skeleton } from "@/components/ui/skeleton"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; @@ -349,7 +351,13 @@ export function CycleView() { {t("cycle.calendar.loading")}

) : calendarError ? ( - void calendar.refetch()} /> + // Inside the calendar card already — a borderless row keeps + // it in line with the plain loading line above it (§3/§6). + void calendar.refetch()} + className="border-0 bg-transparent p-0" + /> ) : ( <> {/* Cold start: the grid carries no fertile/ovulation/phase @@ -388,11 +396,10 @@ export function CycleView() { {calendarError ? ( - - - void calendar.refetch()} /> - - + void calendar.refetch()} + /> ) : ( <> {insights.isError ? ( - - - void insights.refetch()} /> - - + void insights.refetch()} + /> ) : ( <> {profileQuery.isError ? ( - - - void profileQuery.refetch()} /> - - + void profileQuery.refetch()} + /> ) : profileQuery.isLoading ? (
@@ -480,25 +485,3 @@ export function CycleView() { } /** A compact in-place error + Retry for a failed tab read. */ -function TabError({ onRetry }: { onRetry: () => void }) { - const { t } = useTranslations(); - return ( -
- {t("cycle.loadError")} - -
- ); -}