From 59ffa02b0c331d02a02c18c6102c82368bbfea15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 06:49:28 +0200 Subject: [PATCH] Show one sleep-rhythm error, not three MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rhythm, average-sleep and chronotype cards on /insights/sleep share a single ["sleep-rhythm"] read, and each rendered its own copy of the same failure sentence — so one failed read painted the identical notice three times, none of them offering a way to try again. The page owns the shared state now: one notice with a retry in place of the card row. The cards keep their loading and empty handling and drop the duplicated error branch. --- src/app/insights/sleep/page.tsx | 26 +++++-- .../failed-read-never-empty.test.tsx | 77 +++++++++++++++++-- .../insights/sleep/average-sleep-section.tsx | 18 +---- .../insights/sleep/chronotype-section.tsx | 18 +---- .../insights/sleep/sleep-rhythm-section.tsx | 18 +---- 5 files changed, 93 insertions(+), 64 deletions(-) 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 (