mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
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:
1 parent
323b111a24
commit
59ffa02b0c
5 files changed
+93
-64
No files matched your search
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user