diff --git a/src/app/insights/sleep/page.tsx b/src/app/insights/sleep/page.tsx
index cb261e133..1803be910 100644
--- a/src/app/insights/sleep/page.tsx
+++ b/src/app/insights/sleep/page.tsx
@@ -18,6 +18,8 @@ import { ChronotypeSection } from "@/components/insights/sleep/chronotype-sectio
import { AverageSleepSection } from "@/components/insights/sleep/average-sleep-section";
import { SleepQualitySection } from "@/components/insights/sleep/sleep-quality-section";
import { SubPageShell } from "@/components/insights/sub-page-shell";
+import { QueryErrorCard } from "@/components/ui/query-error-card";
+import { useSleepRhythm } from "@/components/insights/sleep/use-sleep-rhythm";
/**
* v1.4.25 W4c — `/insights/sleep`.
@@ -41,6 +43,13 @@ export default function InsightsSchlafPage() {
const { ready } = useModulePageGuard("sleep");
const { isEmpty } = useInsightsAnalytics("SLEEP_DURATION");
+ // The three rhythm cards share one `["sleep-rhythm"]` read, so a failure used
+ // to paint the same sentence three times, none of them offering a way out.
+ // The page owns the shared state and renders one notice with a retry; the
+ // cards no longer carry their own copy of it.
+ const { isError: rhythmError, refetch: refetchRhythm } = useSleepRhythm(
+ Boolean(user) && !isEmpty,
+ );
// v1.18.0 B1 — bounce a direct URL hit on a disabled-sleep account to
// /insights instead of half-rendering the sleep surface.
@@ -127,11 +136,18 @@ export default function InsightsSchlafPage() {
viewports and sit side-by-side from `sm` up; each card keeps its own
internals (the debt headline and the chronotype band treatment).
*/}
-
+ {rhythmError ? (
+ void refetchRhythm()}
+ />
+ ) : (
+
+ )}
{/*
v1.17.1 — "Sleep quality" block. Surfaces the WHOOP / Oura nightly
diff --git a/src/components/__tests__/failed-read-never-empty.test.tsx b/src/components/__tests__/failed-read-never-empty.test.tsx
index 9a109e71a..ef60997ce 100644
--- a/src/components/__tests__/failed-read-never-empty.test.tsx
+++ b/src/components/__tests__/failed-read-never-empty.test.tsx
@@ -155,20 +155,81 @@ describe("read-failure branch is wired on every swept surface", () => {
"src/components/custom-metrics/custom-metric-values-list.tsx",
"src/app/achievements/page.tsx",
"src/components/insights/nutrients/micronutrients-card.tsx",
- "src/components/insights/sleep/chronotype-section.tsx",
+ // The three sleep-rhythm cards share one read, so the PAGE owns the single
+ // error notice rather than each card painting its own copy of it.
+ "src/app/insights/sleep/page.tsx",
] as const;
for (const rel of surfaces) {
it(`${rel} reads isError and offers a retry`, () => {
const source = readFileSync(join(process.cwd(), rel), "utf8");
expect(source).toMatch(/isError/);
- // chronotype-section is the one surface that legitimately renders an
- // inline notice rather than the card (it sits inside a chart section),
- // so it is exempt from the retry affordance but not from the branch.
- if (!rel.endsWith("chronotype-section.tsx")) {
- expect(source).toMatch(/onRetry=/);
- expect(source).toMatch(/refetch/);
- }
+ expect(source).toMatch(/onRetry=/);
+ expect(source).toMatch(/refetch/);
});
}
});
+
+describe("the shared sleep-rhythm read has exactly one error notice", () => {
+ it("no rhythm card renders the load-error string itself", () => {
+ // One failed read used to paint the same sentence three times on
+ // /insights/sleep, none of them offering a retry.
+ for (const rel of [
+ "src/components/insights/sleep/chronotype-section.tsx",
+ "src/components/insights/sleep/average-sleep-section.tsx",
+ "src/components/insights/sleep/sleep-rhythm-section.tsx",
+ ]) {
+ const source = readFileSync(join(process.cwd(), rel), "utf8");
+ expect(source).not.toContain("rhythm.loadError");
+ }
+ const page = readFileSync(
+ join(process.cwd(), "src/app/insights/sleep/page.tsx"),
+ "utf8",
+ );
+ expect(page).toContain("rhythm.loadError");
+ expect(page).toContain("QueryErrorCard");
+ });
+});
+
+/**
+ * The /insights/sleep page renders the shared rhythm error itself, so the
+ * behaviour is observable — a structural check would still pass if the gate
+ * were short-circuited, which the mutation check proved.
+ */
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }),
+ usePathname: () => "/insights/sleep",
+ useSearchParams: () => new URLSearchParams(),
+}));
+
+vi.mock("@/hooks/use-insights-analytics", () => ({
+ useInsightsAnalytics: () => ({ isEmpty: false }),
+}));
+
+// The shared rhythm read is mocked at the hook rather than seeded in the cache:
+// the hook sets its own staleTime, which makes a cache-seeded failure look like
+// a pending refetch during a server render and hides the branch under test.
+vi.mock("@/components/insights/sleep/use-sleep-rhythm", () => ({
+ useSleepRhythm: () => ({
+ data: undefined,
+ isLoading: false,
+ isError: true,
+ refetch: () => {},
+ }),
+}));
+
+const { default: SleepPage } = await import("@/app/insights/sleep/page");
+
+describe("/insights/sleep — shared rhythm read", () => {
+ it("renders exactly one error notice with a retry when the read fails", () => {
+ const html = render(, makeClient());
+
+ // React escapes the apostrophe in the copy, so count the rendered cards
+ // rather than the raw bundle string.
+ const notices = html.split('data-slot="query-error-card"').length - 1;
+ expect(notices).toBe(1);
+ expect(html).toContain(en.common.retry);
+ // The three sibling cards must not render alongside the notice.
+ expect(html).not.toContain('data-slot="chronotype-error"');
+ });
+});
diff --git a/src/components/insights/sleep/average-sleep-section.tsx b/src/components/insights/sleep/average-sleep-section.tsx
index 8e848fd18..f708ac0cf 100644
--- a/src/components/insights/sleep/average-sleep-section.tsx
+++ b/src/components/insights/sleep/average-sleep-section.tsx
@@ -2,7 +2,6 @@
import { useAuth } from "@/hooks/use-auth";
import { Skeleton } from "@/components/ui/skeleton";
-import { useTranslations } from "@/lib/i18n/context";
import { useSleepRhythm } from "./use-sleep-rhythm";
import { AverageSleepCard } from "./average-sleep-card";
@@ -21,25 +20,10 @@ import { AverageSleepCard } from "./average-sleep-card";
*/
export function AverageSleepSection({ enabled }: { enabled: boolean }) {
const { isAuthenticated } = useAuth();
- const { t } = useTranslations();
- const { data, isLoading, isError } = useSleepRhythm(
- isAuthenticated && enabled,
- );
+ const { data, isLoading } = useSleepRhythm(isAuthenticated && enabled);
if (!enabled) return null;
- if (isError) {
- return (
-
- {t("insights.sleep.rhythm.loadError")}
-
- );
- }
-
if (isLoading || !data) {
return (
- {t("insights.sleep.rhythm.loadError")}
-
- );
- }
-
if (isLoading || !data) {
return (
- {t("insights.sleep.rhythm.loadError")}
-
- );
- }
-
if (isLoading || !data) {
return (