From a15a148667d2650ef4778bc3560e554bf5f8b38a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 24 Sep 2026 12:22:52 +0200 Subject: [PATCH] fix(insights): hold the scores strip until the cycle ring is decided For a cycle-tracking account the strip painted Strain first and swapped it for the cycle ring once the calendar read landed, moving the row under the reader. The ring's decision now reports while it is pending, and the strip keeps its skeleton until then. --- src/app/insights/page-client.tsx | 11 ++-- .../use-cycle-ring-has-dial.test.tsx | 54 ++++++++++++++----- src/components/cycle/use-cycle.ts | 20 ++++--- 3 files changed, 61 insertions(+), 24 deletions(-) diff --git a/src/app/insights/page-client.tsx b/src/app/insights/page-client.tsx index 78288637f..44f946c30 100644 --- a/src/app/insights/page-client.tsx +++ b/src/app/insights/page-client.tsx @@ -32,7 +32,7 @@ import { HeroStrip } from "@/components/insights/hero-strip"; import { AiSetupHint } from "@/components/insights/ai-setup-hint"; import { OverviewSectionBoundary } from "@/components/insights/overview-section-boundary"; import { isSurfaceVisible } from "@/lib/modules/surface"; -import { useCycleRingHasDial } from "@/components/cycle/use-cycle"; +import { useCycleRingDial } from "@/components/cycle/use-cycle"; import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor"; import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; import { useDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot"; @@ -373,9 +373,10 @@ export default function InsightsPageClient() { "overview:cycle-ring", user?.modules, ); - const cycleRingDial = useCycleRingHasDial( - isAuthenticated && cycleRingVisible, - ); + // Until that read answers the strip keeps its skeleton, so it never paints + // Strain first and swaps it for the ring when the calendar lands. + const cycleRing = useCycleRingDial(isAuthenticated && cycleRingVisible); + const cycleRingDial = cycleRing.dial; // A failed comprehensive read no longer replaces the page. The payload only // decides the "no data yet" empty state below; every section on the page @@ -467,7 +468,7 @@ export default function InsightsPageClient() { "wellness-scores": ( {String(useCycleRingHasDial(enabled))}; + const { dial, pending } = useCycleRingDial(enabled); + return {`${dial}:${pending}`}; } -function hasDial(verdict: unknown, enabled = true): boolean { +function probe(verdict: unknown | undefined, enabled = true): string { const client = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: 0 } }, }); const [from, to] = window(); - client.setQueryData(queryKeys.cycleCalendar(from, to), { verdict }); + if (verdict !== undefined) { + client.setQueryData(queryKeys.cycleCalendar(from, to), { verdict }); + } const html = renderToStaticMarkup( , ); - return html === "true"; + return html.replace(/<\/?span>/g, ""); } -describe("useCycleRingHasDial", () => { - it("is true while a cycle is active today", () => { - expect(hasDial({ phase: "follicular", dayOfCycle: 6 })).toBe(true); +describe("useCycleRingDial", () => { + it("has a dial while a cycle is active today", () => { + expect(probe({ phase: "follicular", dayOfCycle: 6 })).toBe("true:false"); }); - it("is false without an active cycle, so no empty cell is reserved", () => { - expect(hasDial({ phase: null, dayOfCycle: null })).toBe(false); - expect(hasDial(null)).toBe(false); + it("has none without an active cycle, so no empty cell is reserved", () => { + expect(probe({ phase: null, dayOfCycle: null })).toBe("false:false"); + expect(probe(null)).toBe("false:false"); }); - it("is false while the cycle surface is hidden", () => { - expect(hasDial({ phase: "luteal", dayOfCycle: 20 }, false)).toBe(false); + it("has none, and is not pending, while the cycle surface is hidden", () => { + expect(probe({ phase: "luteal", dayOfCycle: 20 }, false)).toBe( + "false:false", + ); + expect(probe(undefined, false)).toBe("false:false"); + }); + + it("is pending until the calendar read lands", () => { + expect(probe(undefined)).toBe("false:true"); + }); +}); + +describe("the scores strip waits for the ring's decision", () => { + it("holds its skeleton while the decision is pending", () => { + const source = readFileSync( + join(process.cwd(), "src/app/insights/page-client.tsx"), + "utf8", + ); + expect(source).toMatch( + /isLoading=\{\s*dashboardDerived\.isLoading\s*\|\|\s*cycleRing\.pending\s*\}/, + ); }); }); diff --git a/src/components/cycle/use-cycle.ts b/src/components/cycle/use-cycle.ts index 39b2d0684..1377d3ffd 100644 --- a/src/components/cycle/use-cycle.ts +++ b/src/components/cycle/use-cycle.ts @@ -130,15 +130,23 @@ export function useCycleRingCalendar(enabled = true) { } /** - * Whether the cycle ring would draw a dial today. The wellness strip asks - * this before it gives the ring a cell (and takes Strain's place for it): the - * ring renders nothing without an active cycle, and a cell reserved for it - * would sit empty in the row. + * Whether the cycle ring would draw a dial today, and whether that is still + * unknown. The wellness strip asks this before it gives the ring a cell (and + * takes Strain's place for it): the ring renders nothing without an active + * cycle, and a cell reserved for it would sit empty in the row. While the + * calendar read is `pending` the strip holds its skeleton, so it never paints + * Strain and then swaps it for the ring. A failed read settles to no dial. */ -export function useCycleRingHasDial(enabled: boolean): boolean { +export function useCycleRingDial(enabled: boolean): { + dial: boolean; + pending: boolean; +} { const calendar = useCycleRingCalendar(enabled); const verdict = calendar.data?.verdict; - return enabled && verdict?.phase != null && verdict.dayOfCycle != null; + return { + dial: enabled && verdict?.phase != null && verdict.dayOfCycle != null, + pending: enabled && calendar.isPending, + }; } export function useCycleHistory(limit = 24) {