Show one sleep-rhythm error, not three

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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-19 06:49:28 +02:00
1 parent 323b111a24
commit 59ffa02b0c
5 files changed
+93 -64

No files matched your search

+21 -5
View File
@@ -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).
*/}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<SleepRhythmSection enabled={!isEmpty} />
<AverageSleepSection enabled={!isEmpty} />
<ChronotypeSection enabled={!isEmpty} />
</div>
{rhythmError ? (
<QueryErrorCard
description={t("insights.sleep.rhythm.loadError")}
onRetry={() => void refetchRhythm()}
/>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<SleepRhythmSection enabled={!isEmpty} />
<AverageSleepSection enabled={!isEmpty} />
<ChronotypeSection enabled={!isEmpty} />
</div>
)}
{/*
v1.17.1 — "Sleep quality" block. Surfaces the WHOOP / Oura nightly
@@ -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(<SleepPage />, 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"');
});
});
@@ -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 (
<div
data-slot="average-sleep-error"
role="status"
className="bg-card border-border text-muted-foreground rounded-xl border p-4 text-sm"
>
{t("insights.sleep.rhythm.loadError")}
</div>
);
}
if (isLoading || !data) {
return (
<Skeleton
@@ -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 { ChronotypeCard } from "./chronotype-card";
@@ -26,25 +25,10 @@ import { ChronotypeCard } from "./chronotype-card";
*/
export function ChronotypeSection({ 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 (
<div
data-slot="chronotype-error"
role="status"
className="bg-card border-border text-muted-foreground rounded-xl border p-4 text-sm"
>
{t("insights.sleep.rhythm.loadError")}
</div>
);
}
if (isLoading || !data) {
return (
<Skeleton
@@ -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 { SleepDebtCard } from "./sleep-debt-card";
@@ -27,25 +26,10 @@ import { SleepDebtCard } from "./sleep-debt-card";
*/
export function SleepRhythmSection({ 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 (
<div
data-slot="sleep-rhythm-error"
role="status"
className="bg-card border-border text-muted-foreground rounded-xl border p-4 text-sm"
>
{t("insights.sleep.rhythm.loadError")}
</div>
);
}
if (isLoading || !data) {
return (
<div className="space-y-4" data-slot="sleep-rhythm-loading">