fix(ui): draw every failed read through a read-failure primitive

Around thirty surfaces drew a failed read their own way: a bare
destructive paragraph with no retry, a muted line saying the data was
still being learned, a skeleton that never ended, an error card nested
in a tile, or nothing at all. None of them told an ended session from a
refusal. They now use QueryErrorRow or QueryErrorCard with the query's
error and a retry, inset where a card already frames them, and keep
their card and title where the page would otherwise jump. Hydration and
caffeine now fail the same way.

The recovery page no longer points to Integrations while Oura's first
resilience level is simply on its way.

A guard reads every read hook's isError branch and refuses hand-rolled
markup there, and refuses a framed error row inside a card.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 10:10:49 +02:00
1 parent a1b56f0b75
commit 923249dbf6
60 files changed
+579 -175

No files matched your search

@@ -61,9 +61,15 @@ vi.mock("next/link", () => ({
const calendarState = vi.hoisted(() => ({
current: {
data: undefined as CalendarResponse | undefined,
data: undefined,
isLoading: false,
isError: false,
} as {
data: CalendarResponse | undefined;
isLoading: boolean;
isError: boolean;
error?: unknown;
refetch?: () => unknown;
},
}));
const insightsState = vi.hoisted(() => ({
@@ -223,13 +229,17 @@ describe("<CycleInsightSummaryCard>", () => {
expect(html).toBe("");
});
it("renders nothing on a hard calendar read error", () => {
it("says a hard calendar read error, with a retry", () => {
// Drawing nothing would read as "no cycle tracked".
calendarState.current = {
data: undefined,
isLoading: false,
isError: true,
error: new Error("boom"),
refetch: () => undefined,
};
const html = render(<CycleInsightSummaryCard />);
expect(html).toBe("");
expect(html).toContain('data-slot="cycle-insight-summary-error"');
expect(html).toContain('data-slot="query-error-row-retry"');
});
});
@@ -9,6 +9,7 @@ import { SectionHeading } from "@/components/ui/section-heading";
import { CyclePhaseHeadline } from "./cycle-phase-crosstab";
import { PHASE_HUE } from "./phase-tokens";
import { localYmd, useCycleCalendar, useCycleInsights } from "./use-cycle";
import { QueryErrorRow } from "@/components/ui/query-error-row";
/**
* v1.15.2 — the gated cycle-insights summary card for the main Insights page.
@@ -56,11 +57,29 @@ export function CycleInsightSummaryCard() {
// The server's resolved verdict — the teaser reports it, never recomputes it.
const verdict = calendar.data?.verdict;
// Stay silent until the calendar read resolves, and on a hard error: the
// overview must never show a half-painted or error-framed cycle teaser. The
// full cycle page owns the retry affordance.
// Stay silent until the calendar read resolves: the overview must never
// show a half-painted teaser. A failed read is said with a retry, since
// drawing nothing would read as "no cycle tracked".
if (calendar.isLoading && !calendar.data) return null;
if (calendar.isError && !calendar.data) return null;
if (calendar.isError && !calendar.data) {
return (
<section
data-slot="cycle-insight-summary-section"
aria-label={t("cycle.insightsSummary.title")}
className="space-y-3"
>
<SectionHeading
icon={Droplets}
title={t("cycle.insightsSummary.title")}
/>
<QueryErrorRow
error={calendar.error}
slot="cycle-insight-summary-error"
onRetry={() => void calendar.refetch()}
/>
</section>
);
}
const phase = verdict?.phase ?? null;
const hue = phase ? PHASE_HUE[phase] : PHASE_HUE.LUTEAL;
+1 -1
View File
@@ -362,10 +362,10 @@ export function CycleView() {
// Inside the calendar card already — a borderless row keeps
// it in line with the plain loading line above it (§3/§6).
<QueryErrorRow
inset
error={calendar.error}
message={t("cycle.loadError")}
onRetry={() => void calendar.refetch()}
className="border-0 bg-transparent p-0"
/>
) : (
<>