mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
e49997bf97
commit
a15a148667
3 files changed
+61
-24
No files matched your search
@@ -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*\}/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user