feat(cycle): surface a gated cycle summary card on the insights page

Add a compact cycle teaser to the main Insights overview for accounts with
cycle tracking enabled. The card shows the current phase, cycle day, and the
single headline phase finding, and deep-links into the full cycle insights tab
so the cycle tab stays the single source of truth.

The card mounts only when cycleTrackingEnabled resolves true from
/api/auth/me — the same gate the sidebar cycle entry uses — and renders nothing
for every other account. Phase and cycle day derive from the same calendar read
plus deriveWheelState the cycle wheel uses, and the headline reuses the shared
CyclePhaseHeadline, including its honest empty line when nothing has cleared the
significance check.

The cycle view now opens the insights tab when the URL carries ?tab=insights,
validated against the known tab values; CycleView is wrapped in a Suspense
boundary for the search-params read.
This commit is contained in:
Marc-André Bombeck committed 2026-06-06 19:44:01 +02:00
1 parent 9d68b07b9e
commit c1d8ff9db9
11 files changed
+410 -3

No files matched your search

+15 -2
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect } from "react";
import { Suspense, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Loader2 } from "lucide-react";
@@ -40,5 +40,18 @@ export default function CyclePage() {
);
}
return <CycleView />;
// `<CycleView>` reads `useSearchParams` for the `?tab=` deep-link; wrap it in
// a Suspense boundary so the client-search-params bailout never de-opts the
// build (Next's `missing-suspense-with-csr-bailout`).
return (
<Suspense
fallback={
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
</div>
}
>
<CycleView />
</Suspense>
);
}
+19
View File
@@ -153,6 +153,20 @@ const PeriodNarrativeCard = dynamic(
},
);
// v1.15.2 — the gated cycle-insights summary teaser. Mounted ONLY when
// `user.cycleTrackingEnabled` is true (the same /api/auth/me signal the
// sidebar nav entry gates on), so the cycle reads never fire for an account
// without the feature. Deferred behind `next/dynamic` like the other
// below-the-hero blocks; it owns its own calendar + insights reads and renders
// nothing while resolving / on error, so it carries no loading placeholder.
const CycleInsightSummaryCard = dynamic(
() =>
import("@/components/cycle/cycle-insight-summary-card").then((mod) => ({
default: mod.CycleInsightSummaryCard,
})),
{ ssr: false },
);
/**
* v1.4.25 W4d — Insights mother page.
*
@@ -384,6 +398,11 @@ export default function InsightsPage() {
{flags.briefing && <PeriodNarrativeCard enabled={isAuthenticated} />}
{/* v1.15.2 — gated cycle teaser. Render only for a cycle-tracking
account; for everyone else this is nothing (no card, no layout gap).
The card itself stays silent until its reads resolve. */}
{user?.cycleTrackingEnabled ? <CycleInsightSummaryCard /> : null}
<CoincidentDeviationCard enabled={isAuthenticated} />
<RhythmEventsCard enabled={isAuthenticated} />
@@ -0,0 +1,180 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import type { CalendarResponse } from "../types";
import type { CycleInsightsResponse } from "../use-cycle";
import type { CyclePhaseCrosstabRow } from "../cycle-phase-crosstab";
/**
* v1.15.2 — the gated cycle-insights summary teaser on the main Insights page.
*
* The card itself is mounted ONLY when `user.cycleTrackingEnabled` is true (a
* page-level gate); these tests cover the card's own behaviour given that gate:
* it sources the phase + cycle day from the SAME calendar read the wheel uses,
* renders the shared FDR-gated headline, deep-links to the cycle insights tab,
* and stays silent while resolving / on a hard error so the overview never
* shows a broken cycle teaser.
*
* The cycle reads are mocked at the hook boundary so the SSR snapshot is
* deterministic without a TanStack-Query client / network round-trip.
*/
// `next/link` → a plain anchor in the static snapshot.
vi.mock("next/link", () => ({
default: ({
href,
children,
...rest
}: {
href: string;
children: React.ReactNode;
}) => (
<a href={href} {...rest}>
{children}
</a>
),
}));
const calendarState = vi.hoisted(() => ({
current: {
data: undefined as CalendarResponse | undefined,
isLoading: false,
isError: false,
},
}));
const insightsState = vi.hoisted(() => ({
current: {
data: undefined as CycleInsightsResponse | undefined,
isError: false,
},
}));
vi.mock("../use-cycle", async () => {
const actual =
await vi.importActual<typeof import("../use-cycle")>("../use-cycle");
return {
...actual,
useCycleCalendar: () => calendarState.current,
useCycleInsights: () => insightsState.current,
};
});
import { CycleInsightSummaryCard } from "../cycle-insight-summary-card";
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
/** Build a calendar read where `today` sits on a LUTEAL day at day-of-cycle 3
* (a short MENSTRUAL run → LUTEAL today, so `deriveWheelState` resolves a
* phase + day). */
function lutealCalendar(today: string): CalendarResponse {
// Three consecutive days ending today: MENSTRUAL, FOLLICULAR, LUTEAL.
const days = [
{ date: shift(today, -2), phase: "MENSTRUAL" as const },
{ date: shift(today, -1), phase: "FOLLICULAR" as const },
{ date: today, phase: "LUTEAL" as const },
];
return {
days: days as unknown as CalendarResponse["days"],
profile: {} as CalendarResponse["profile"],
prediction: null,
} as CalendarResponse;
}
function shift(ymd: string, delta: number): string {
const [y, m, d] = ymd.split("-").map(Number);
const dt = new Date(y, m - 1, d);
dt.setDate(dt.getDate() + delta);
return `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, "0")}-${String(dt.getDate()).padStart(2, "0")}`;
}
function todayYmd(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
const headlineRow: CyclePhaseCrosstabRow = {
metricKey: "restingHeartRate",
display: "bpm",
lutealDays: 12,
follicularDays: 11,
lutealAvg: 64,
follicularAvg: 60,
delta: 4,
pValue: 0.01,
qValue: 0.03,
confidence: "high",
};
beforeEach(() => {
calendarState.current = {
data: undefined,
isLoading: false,
isError: false,
};
insightsState.current = { data: undefined, isError: false };
});
describe("<CycleInsightSummaryCard>", () => {
it("renders the current phase, cycle day, headline, and the deep-link", () => {
calendarState.current = {
data: lutealCalendar(todayYmd()),
isLoading: false,
isError: false,
};
insightsState.current = {
data: { rows: [headlineRow], headline: headlineRow, symptomPatterns: [] },
isError: false,
};
const html = render(<CycleInsightSummaryCard />);
// Phase name + cycle-day read (day 3 of the 3-day run).
expect(html).toContain("Luteal");
expect(html).toContain("Day 3 of your cycle");
// The shared FDR-gated headline (resting-heart-rate phrasing).
expect(html).toContain("resting heart rate");
// Deep-link into the single source of truth (cycle insights tab).
expect(html).toContain('href="/cycle?tab=insights"');
expect(html).toContain("View cycle insights");
expect(html).toContain('data-phase="LUTEAL"');
});
it("shows the honest empty headline line when nothing cleared the FDR gate", () => {
calendarState.current = {
data: lutealCalendar(todayYmd()),
isLoading: false,
isError: false,
};
insightsState.current = {
data: { rows: [], headline: null, symptomPatterns: [] },
isError: false,
};
const html = render(<CycleInsightSummaryCard />);
// Calm "keep logging" line from the shared <CyclePhaseHeadline>.
expect(html).toContain("Not enough cycles");
// Still a teaser with a working deep-link.
expect(html).toContain('href="/cycle?tab=insights"');
});
it("renders nothing while the calendar read is still resolving", () => {
calendarState.current = { data: undefined, isLoading: true, isError: false };
const html = render(<CycleInsightSummaryCard />);
expect(html).toBe("");
});
it("renders nothing on a hard calendar read error", () => {
calendarState.current = {
data: undefined,
isLoading: false,
isError: true,
};
const html = render(<CycleInsightSummaryCard />);
expect(html).toBe("");
});
});
@@ -0,0 +1,141 @@
"use client";
import { useMemo } from "react";
import Link from "next/link";
import { ArrowRight, Sparkles } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { CyclePhaseHeadline } from "./cycle-phase-crosstab";
import { PHASE_HUE } from "./phase-tokens";
import { deriveWheelState } from "./wheel-state";
import { localYmd, useCycleCalendar, useCycleInsights } from "./use-cycle";
/**
* v1.15.2 — the gated cycle-insights summary card for the main Insights page.
*
* A compact TEASER + entry point, never a duplicate of the full cycle insights
* tab. It surfaces three things and links into the source of truth:
* 1. the current phase (name + hue dot) and cycle day, derived from the
* SAME calendar read + `deriveWheelState` the cycle wheel uses, so the
* two surfaces can never disagree on the day-of-cycle or phase;
* 2. the one headline phase finding, rendered through the SHARED
* `<CyclePhaseHeadline>` (which already owns the FDR-gated copy + the
* honest "not enough cycles yet" empty line) — no re-implementation;
* 3. a "view details" deep-link into `/cycle?tab=insights`.
*
* Visual idiom mirrors `phase-education-card.tsx`: a hue-tinted `wellness-tile`
* keyed to the ACTIVE phase via `--tile-hue`, with one calm entrance that the
* global `prefers-reduced-motion` block already zeroes. The phase semantic is
* never colour-only — the phase word + an aria-label always accompany the hue.
*
* GATING IS THE CALLER'S JOB: the page only mounts this when
* `user.cycleTrackingEnabled` is true (the same `/api/auth/me` signal the
* sidebar nav entry uses), so the two cycle reads never fire for an account
* without the feature. The component additionally renders nothing while the
* calendar read is still resolving and on a hard read error, so it never shows
* a broken or empty-framed state on the overview.
*/
/** 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 CycleInsightSummaryCard() {
const { t } = useTranslations();
// Mirror the cycle-view window so the cache key is shared (no extra request
// when the user later opens the cycle page in the same session).
const today = useMemo(() => shiftToday(0), []);
const from = useMemo(() => shiftToday(-90), []);
const to = useMemo(() => shiftToday(180), []);
const calendar = useCycleCalendar(from, to);
const insights = useCycleInsights();
const wheel = useMemo(
() => deriveWheelState(calendar.data?.days ?? [], today),
[calendar.data, today],
);
// Stay silent until the calendar read resolves, and on a hard error: the
// overview must never show a half-painted or error-framed cycle teaser. The
// full cycle page owns the retry affordance.
if (calendar.isLoading && !calendar.data) return null;
if (calendar.isError && !calendar.data) return null;
const phase = wheel.phase;
const hue = phase ? PHASE_HUE[phase] : PHASE_HUE.LUTEAL;
const phaseName = phase ? t(`cycle.phase.${phase}`) : t("cycle.phase.none");
const dayOfCycle = wheel.dayOfCycle;
const ariaLabel =
phase && dayOfCycle != null
? t("cycle.ring.ariaPhase", { day: dayOfCycle, phase: phaseName })
: t("cycle.ring.ariaUnknown");
return (
<section
data-slot="cycle-insight-summary"
data-revealed="true"
data-phase={phase ?? "none"}
aria-label={t("cycle.insightsSummary.title")}
style={{ "--tile-hue": hue } as React.CSSProperties}
className="wellness-tile wellness-tile-rise rounded-xl px-5 py-5"
>
<div className="flex items-start justify-between gap-3">
{/* Zone 1 — eyebrow + the current phase / cycle-day read. */}
<div className="min-w-0">
<div className="flex items-center gap-2">
<Sparkles
className="h-4 w-4 shrink-0"
style={{ color: hue }}
aria-hidden="true"
/>
<span className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("cycle.insightsSummary.title")}
</span>
</div>
<h3
className="text-foreground mt-2 flex items-center gap-2 text-base font-semibold"
aria-label={ariaLabel}
>
<span
aria-hidden="true"
className="inline-block h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: hue }}
/>
<span>{phaseName}</span>
</h3>
<p className="text-muted-foreground mt-1 text-sm" aria-hidden="true">
{dayOfCycle != null
? t("cycle.insightsSummary.currentDay", { day: dayOfCycle })
: t("cycle.insightsSummary.noActiveCycle")}
</p>
</div>
</div>
{/* Zone 2 — the one headline phase finding (shared component owns the
FDR-gated copy AND the honest "not enough cycles yet" empty line, so
this can never show a broken state). */}
<div className="mt-3">
<CyclePhaseHeadline headline={insights.data?.headline ?? null} />
</div>
{/* Zone 3 — the deep-link into the single source of truth: the cycle
insights tab. The cycle page reads `?tab=insights` and opens that tab. */}
<Link
href="/cycle?tab=insights"
data-slot="cycle-insight-summary-link"
className="text-foreground bg-background/55 border-foreground/15 hover:bg-background/80 focus-visible:ring-ring/50 mt-4 inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm font-medium transition-colors focus:outline-none focus-visible:ring-[3px]"
>
{t("cycle.insightsSummary.viewDetails")}
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
</Link>
</section>
);
}
+19 -1
View File
@@ -1,6 +1,7 @@
"use client";
import { useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { Loader2, Plus, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -51,9 +52,26 @@ function shiftToday(days: number): string {
return localYmd(d);
}
/** The four cycle tabs, in render order. The deep-link value is validated
* against this set so a junk `?tab=` falls back to the calendar tab. */
const CYCLE_TABS = ["calendar", "predictions", "insights", "settings"] as const;
type CycleTab = (typeof CYCLE_TABS)[number];
export function CycleView() {
const { t } = useTranslations();
// Deep-link support: `/cycle?tab=insights` opens the insights tab. The value
// is read ONCE for the initial `defaultValue` (uncontrolled tabs — so normal
// clicking still works), validated against the known set, and falls back to
// the calendar tab on anything else.
const searchParams = useSearchParams();
const initialTab: CycleTab = useMemo(() => {
const raw = searchParams?.get("tab");
return raw && (CYCLE_TABS as readonly string[]).includes(raw)
? (raw as CycleTab)
: "calendar";
}, [searchParams]);
const today = useMemo(() => shiftToday(0), []);
const from = useMemo(() => shiftToday(-90), []);
const to = useMemo(() => shiftToday(180), []);
@@ -206,7 +224,7 @@ export function CycleView() {
) : null}
</div>
<Tabs defaultValue="calendar">
<Tabs defaultValue={initialTab}>
<TabsList className="w-full">
<TabsTrigger value="calendar" className="flex-1">
{t("cycle.tabs.calendar")}