mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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"');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user