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) {