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.
This commit is contained in:
Marc-André Bombeck committed 2026-08-09 04:20:55 +02:00
1 parent 6485aae787
commit f63fdb373e
2 files changed
+90 -38

No files matched your search

@@ -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(
<I18nProvider initialLocale="en">
<CycleView />
</I18nProvider>,
);
}
describe("<CycleView> — 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"');
});
});
+21 -38
View File
@@ -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")}
</p>
) : calendarError ? (
<TabError onRetry={() => void calendar.refetch()} />
// Inside the calendar card already — a borderless row keeps
// it in line with the plain loading line above it (§3/§6).
<QueryErrorRow
message={t("cycle.loadError")}
onRetry={() => 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() {
<TabsContent value="predictions" className="mt-4 space-y-4">
{calendarError ? (
<Card>
<CardContent className="py-4">
<TabError onRetry={() => void calendar.refetch()} />
</CardContent>
</Card>
<QueryErrorCard
title={t("cycle.loadError")}
onRetry={() => void calendar.refetch()}
/>
) : (
<>
<PredictionsPanel
@@ -419,11 +426,10 @@ export function CycleView() {
<TabsContent value="insights" className="mt-4 space-y-4">
{insights.isError ? (
<Card>
<CardContent className="py-4">
<TabError onRetry={() => void insights.refetch()} />
</CardContent>
</Card>
<QueryErrorCard
title={t("cycle.loadError")}
onRetry={() => void insights.refetch()}
/>
) : (
<>
<CyclePhaseHeadline
@@ -439,11 +445,10 @@ export function CycleView() {
<TabsContent value="settings" className="mt-4">
{profileQuery.isError ? (
<Card>
<CardContent className="py-4">
<TabError onRetry={() => void profileQuery.refetch()} />
</CardContent>
</Card>
<QueryErrorCard
title={t("cycle.loadError")}
onRetry={() => void profileQuery.refetch()}
/>
) : profileQuery.isLoading ? (
<div className="flex h-32 items-center justify-center">
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin motion-reduce:animate-none" />
@@ -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 (
<div
role="alert"
data-slot="cycle-tab-error"
className="text-muted-foreground flex flex-col items-start gap-3 text-sm sm:flex-row sm:items-center sm:justify-between"
>
<span>{t("cycle.loadError")}</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={onRetry}
className="gap-1.5"
>
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("common.retry")}</span>
</Button>
</div>
);
}