mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
5f6a0e42b4
commit
a6c65bd37e
5 files changed
+103
-17
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user