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")}
-
-
- );
-}