diff --git a/src/app/insights/page-client.tsx b/src/app/insights/page-client.tsx index f1b3c0ec4..78288637f 100644 --- a/src/app/insights/page-client.tsx +++ b/src/app/insights/page-client.tsx @@ -32,6 +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 { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor"; import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; import { useDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot"; @@ -364,6 +365,18 @@ export default function InsightsPageClient() { // Settings → Insights, reached via the top-right cog on the tab strip. const { layout } = useInsightsLayoutQuery(isAuthenticated); + // The cycle ring takes a cell in the scores strip (and Strain's place) only + // when it would draw a dial today. With the cycle module on but no active + // cycle the ring renders nothing, and a cell reserved for it sat empty in + // the row. Same calendar read the ring makes, so no extra request. + const cycleRingVisible = isSurfaceVisible( + "overview:cycle-ring", + user?.modules, + ); + const cycleRingDial = useCycleRingHasDial( + isAuthenticated && cycleRingVisible, + ); + // 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 // owns its own read and its own error row, so one failing read (#975: the @@ -407,7 +420,6 @@ export default function InsightsPageClient() { ? (advisor.payload?.dailyBriefing ?? null) : null; const modules = user?.modules; - const cycleRingVisible = isSurfaceVisible("overview:cycle-ring", modules); const heroStripUpdatedAt = advisor.payload?.cachedAt ?? null; // v1.4.36 QA C2 — no `` wrappers below. The mother page is @@ -464,11 +476,12 @@ export default function InsightsPageClient() { // v1.18.0 — gate on the resolved `modules.cycle` flag (per-user toggle // AND the operator server-wide kill-switch) so an operator-off // instance never renders the ring nor fires its calendar read. - extraTile={cycleRingVisible ? : undefined} + // v1.39 — and only once the ring has a dial to draw (see above). + extraTile={cycleRingDial ? : undefined} // v1.15.5 — when the cycle ring is shown it TAKES the Strain slot: // hide Strain so the strip stays compact instead of growing a sixth // tile. Strain stays visible for non-cycle accounts. - hideStrain={cycleRingVisible} + hideStrain={cycleRingDial} /> ), // Hidden, never an error card, while the briefing is unavailable for any diff --git a/src/components/cycle/__tests__/cycle-ring-tile.test.tsx b/src/components/cycle/__tests__/cycle-ring-tile.test.tsx index 192383f05..2085f7904 100644 --- a/src/components/cycle/__tests__/cycle-ring-tile.test.tsx +++ b/src/components/cycle/__tests__/cycle-ring-tile.test.tsx @@ -48,7 +48,7 @@ vi.mock("../use-cycle", async () => { await vi.importActual("../use-cycle"); return { ...actual, - useCycleCalendar: () => calendarState.current, + useCycleRingCalendar: () => calendarState.current, }; }); diff --git a/src/components/cycle/__tests__/use-cycle-ring-has-dial.test.tsx b/src/components/cycle/__tests__/use-cycle-ring-has-dial.test.tsx new file mode 100644 index 000000000..b0b036a09 --- /dev/null +++ b/src/components/cycle/__tests__/use-cycle-ring-has-dial.test.tsx @@ -0,0 +1,53 @@ +/** + * 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. + */ +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"; + +function window(): [string, string] { + const from = new Date(); + from.setDate(from.getDate() - 90); + const to = new Date(); + to.setDate(to.getDate() + 180); + return [localYmd(from), localYmd(to)]; +} + +function Probe({ enabled }: { enabled: boolean }) { + return {String(useCycleRingHasDial(enabled))}; +} + +function hasDial(verdict: unknown, enabled = true): boolean { + const client = new QueryClient({ + defaultOptions: { queries: { staleTime: Infinity, retry: 0 } }, + }); + const [from, to] = window(); + client.setQueryData(queryKeys.cycleCalendar(from, to), { verdict }); + const html = renderToStaticMarkup( + + + , + ); + return html === "true"; +} + +describe("useCycleRingHasDial", () => { + it("is true while a cycle is active today", () => { + expect(hasDial({ phase: "follicular", dayOfCycle: 6 })).toBe(true); + }); + + 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("is false while the cycle surface is hidden", () => { + expect(hasDial({ phase: "luteal", dayOfCycle: 20 }, false)).toBe(false); + }); +}); diff --git a/src/components/cycle/cycle-ring-tile.tsx b/src/components/cycle/cycle-ring-tile.tsx index 9b0342ab2..486f06578 100644 --- a/src/components/cycle/cycle-ring-tile.tsx +++ b/src/components/cycle/cycle-ring-tile.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo, useState } from "react"; +import { useState } from "react"; import Link from "next/link"; import { Droplets } from "lucide-react"; @@ -8,7 +8,7 @@ import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; import { CycleRing } from "./cycle-ring"; import { PHASE_HUE } from "./phase-tokens"; -import { localYmd, useCycleCalendar } from "./use-cycle"; +import { useCycleRingCalendar } from "./use-cycle"; /** * v1.15.3 — the compact cycle ring as a WELLNESS-STRIP element. @@ -36,22 +36,12 @@ import { localYmd, useCycleCalendar } from "./use-cycle"; * strip (no gap, no placeholder). */ -/** YYYY-MM-DD for `n` days from now in the local tz (shares `localYmd`). */ -function shiftToday(days: number): string { - const d = new Date(); - d.setDate(d.getDate() + days); - return localYmd(d); -} - export function CycleRingTile({ className }: { className?: string }) { const { t } = useTranslations(); // Mirror the cycle-view + summary-card window so the cache key is shared — // no extra request when the user opens the cycle page in the same session. - const from = useMemo(() => shiftToday(-90), []); - const to = useMemo(() => shiftToday(180), []); - - const calendar = useCycleCalendar(from, to); + const calendar = useCycleRingCalendar(); // The server already resolved the ring: cycle day, phase, and the arc // proportions (including the profile-derived idealized ring a low-data // tracker gets). The tile renders it. diff --git a/src/components/cycle/use-cycle.ts b/src/components/cycle/use-cycle.ts index d041dabec..39b2d0684 100644 --- a/src/components/cycle/use-cycle.ts +++ b/src/components/cycle/use-cycle.ts @@ -9,6 +9,7 @@ * wheel, predictions panel, and history repaint in lockstep after a quick * log. */ +import { useMemo } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; @@ -111,6 +112,35 @@ export function useCycleCalendar(from: string, to: string, enabled = true) { }); } +/** YYYY-MM-DD for `days` from today in the local zone. */ +function localYmdFromToday(days: number): string { + const d = new Date(); + d.setDate(d.getDate() + days); + return localYmd(d); +} + +/** + * The calendar window the cycle ring reads: the cycle page's own window, so + * the ring, the page and the summary card share one cache entry. + */ +export function useCycleRingCalendar(enabled = true) { + const from = useMemo(() => localYmdFromToday(-90), []); + const to = useMemo(() => localYmdFromToday(180), []); + return useCycleCalendar(from, to, enabled); +} + +/** + * 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. + */ +export function useCycleRingHasDial(enabled: boolean): boolean { + const calendar = useCycleRingCalendar(enabled); + const verdict = calendar.data?.verdict; + return enabled && verdict?.phase != null && verdict.dayOfCycle != null; +} + export function useCycleHistory(limit = 24) { return useQuery({ queryKey: queryKeys.cycleHistory(limit),