fix(insights): give the cycle ring a score cell only when it has a dial

The scores strip reserved a cell for the cycle ring, and dropped Strain to
make room, whenever the cycle module was on. Without an active cycle the
ring renders nothing, so the row showed a gap where Strain used to be. The
overview now asks the same calendar read whether there is a dial today.
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 11:10:48 +02:00
1 parent 5f6a0e42b4
commit a6c65bd37e
5 files changed
+103 -17

No files matched your search

+16 -3
View File
@@ -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 `<Suspense>` 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 ? <CycleRingTile /> : undefined}
// v1.39 — and only once the ring has a dial to draw (see above).
extraTile={cycleRingDial ? <CycleRingTile /> : 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
@@ -48,7 +48,7 @@ vi.mock("../use-cycle", async () => {
await vi.importActual<typeof import("../use-cycle")>("../use-cycle");
return {
...actual,
useCycleCalendar: () => calendarState.current,
useCycleRingCalendar: () => calendarState.current,
};
});
@@ -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 <span>{String(useCycleRingHasDial(enabled))}</span>;
}
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(
<QueryClientProvider client={client}>
<Probe enabled={enabled} />
</QueryClientProvider>,
);
return html === "<span>true</span>";
}
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);
});
});
+3 -13
View File
@@ -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.
+30
View File
@@ -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),