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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 12:22:52 +02:00
1 parent e49997bf97
commit a15a148667
3 files changed
+61 -24

No files matched your search

+6 -5
View File
@@ -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": (
<WellnessScores
read={dashboardDerived.read}
isLoading={dashboardDerived.isLoading}
isLoading={dashboardDerived.isLoading || cycleRing.pending}
isError={dashboardDerived.isError}
refetch={dashboardDerived.refetch}
// v1.15.3 — the cycle ring rides the scores strip as a gated sibling
@@ -2,14 +2,20 @@
* The scores strip gives the cycle ring a cell only when the ring would draw
* a dial. The ring renders nothing without an active cycle, and a cell
* reserved for it (with Strain dropped to make room) sat empty in the row.
*
* The decision waits on the calendar read, so the hook also says when it is
* still pending: the strip holds its skeleton until then instead of painting
* Strain and swapping it for the ring a moment later.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import { queryKeys } from "@/lib/query-keys";
import { localYmd, useCycleRingHasDial } from "../use-cycle";
import { localYmd, useCycleRingDial } from "../use-cycle";
function window(): [string, string] {
const from = new Date();
@@ -20,34 +26,56 @@ function window(): [string, string] {
}
function Probe({ enabled }: { enabled: boolean }) {
return <span>{String(useCycleRingHasDial(enabled))}</span>;
const { dial, pending } = useCycleRingDial(enabled);
return <span>{`${dial}:${pending}`}</span>;
}
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(
<QueryClientProvider client={client}>
<Probe enabled={enabled} />
</QueryClientProvider>,
);
return html === "<span>true</span>";
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*\}/,
);
});
});
+14 -6
View File
@@ -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) {