From 55f40df0ac5be9cc89d586df9696161b8f5bcbc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 6 Jun 2026 10:26:13 +0200 Subject: [PATCH] refactor(cycle): wire the phase-correlation card into the insights tab The cycle insights tab rendered an empty placeholder while the phase-contrast card + its `/api/cycle/insights` feed sat unused. Wire them together: a `useCycleInsights` read drives the headline finding + the luteal-vs-follicular crosstab on the insights tab, matching the mood-factor crosstab styling. Drops the placeholder card + a duplicate disclaimer component in favour of the ones already in use, and removes the unused recency/phase-result/label exports. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../cycle/__tests__/cycle-insights.test.tsx | 43 ---------- src/components/cycle/cycle-insights.tsx | 86 ------------------- .../cycle/cycle-prediction-disclaimer.tsx | 41 --------- src/components/cycle/cycle-view.tsx | 17 ++-- src/components/cycle/symptom-catalog.ts | 7 -- src/components/cycle/types.ts | 11 --- src/components/cycle/use-cycle.ts | 23 +++++ src/lib/cycle/types.ts | 17 ---- src/lib/query-keys.ts | 1 + 9 files changed, 33 insertions(+), 213 deletions(-) delete mode 100644 src/components/cycle/__tests__/cycle-insights.test.tsx delete mode 100644 src/components/cycle/cycle-insights.tsx delete mode 100644 src/components/cycle/cycle-prediction-disclaimer.tsx diff --git a/src/components/cycle/__tests__/cycle-insights.test.tsx b/src/components/cycle/__tests__/cycle-insights.test.tsx deleted file mode 100644 index 2430d347e..000000000 --- a/src/components/cycle/__tests__/cycle-insights.test.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { renderToStaticMarkup } from "react-dom/server"; - -import { CycleInsights } from "../cycle-insights"; -import { I18nProvider } from "@/lib/i18n/context"; - -function render(node: React.ReactNode) { - return renderToStaticMarkup( - {node}, - ); -} - -describe("", () => { - it("renders the empty-state when no cards are surfaced", () => { - const html = render(); - expect(html).toContain("Phase insights appear here"); - expect(html).not.toContain('data-slot="cycle-insights"'); - }); - - it("renders the card list with the open statistics when cards are present", () => { - const html = render( - , - ); - expect(html).toContain('data-slot="cycle-insights"'); - expect(html).toContain("Resting heart rate runs higher"); - expect(html).toContain("n = 42"); - expect(html).toContain("0.61"); - expect(html).toContain("0.040"); - expect(html).toContain("Descriptive only."); - }); -}); diff --git a/src/components/cycle/cycle-insights.tsx b/src/components/cycle/cycle-insights.tsx deleted file mode 100644 index e18954671..000000000 --- a/src/components/cycle/cycle-insights.tsx +++ /dev/null @@ -1,86 +0,0 @@ -"use client"; - -import { Activity } from "lucide-react"; - -import { - Card, - CardContent, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { useTranslations } from "@/lib/i18n/context"; -import type { CycleInsightCard } from "./types"; - -/** - * v1.15.0 — phase-correlation insight cards. - * - * Renders whatever phase-correlation cards the API surfaces (another wave - * adds the data feed; this UI renders the list gracefully and shows the - * empty-state when none). Each card carries the open statistics — sample - * size, effect size, q-value — alongside the prose, matching the - * FDR-guarded honest-reporting posture of the rest of Insights. - */ -export function CycleInsights({ - cards, -}: { - cards: CycleInsightCard[]; -}) { - const { t } = useTranslations(); - - if (cards.length === 0) { - return ( - - - - - - -

- {t("cycle.insights.empty")} -

-
-
- ); - } - - return ( -
- {cards.map((card) => ( - - - {card.title} - - -

{card.body}

-
- {card.n != null ? ( - {t("cycle.insights.sampleSize", { n: card.n })} - ) : null} - {card.effectSize != null ? ( - - {t("cycle.insights.effectSize", { - value: card.effectSize.toFixed(2), - })} - - ) : null} - {card.qValue != null ? ( - - {t("cycle.insights.qValue", { - value: card.qValue.toFixed(3), - })} - - ) : null} -
- {card.caveat ? ( -

- {card.caveat} -

- ) : null} -
-
- ))} -
- ); -} diff --git a/src/components/cycle/cycle-prediction-disclaimer.tsx b/src/components/cycle/cycle-prediction-disclaimer.tsx deleted file mode 100644 index 90c3e7793..000000000 --- a/src/components/cycle/cycle-prediction-disclaimer.tsx +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Fixed non-medical disclaimer for every fertility / prediction surface. - * - * Mirrors the GLP-1 drug-level-estimate disclaimer pattern - * (`DrugLevelChart`): a styled, always-attached note rendered as plain - * React text children (no markdown), so it cannot be dismissed or styled - * away and carries no XSS surface. - * - * The copy is deliberate and load-bearing: predictions are descriptive - * estimates from the user's own logged data — never a contraceptive - * method, never medical advice, and never a "safe to have unprotected - * sex" signal. Drop this under any calendar, wheel, fertile-window, or - * predicted-period surface. The copy lives in `messages/.json` - * under `cycle.disclaimer` and is translated for all six locales. - */ -"use client"; - -import { useTranslations } from "@/lib/i18n/context"; -import { cn } from "@/lib/utils"; - -export interface CyclePredictionDisclaimerProps { - /** Optional extra classes for layout (margins, width) at the call site. */ - className?: string; -} - -export function CyclePredictionDisclaimer({ - className, -}: CyclePredictionDisclaimerProps) { - const { t } = useTranslations(); - return ( -

- {t("cycle.disclaimer")} -

- ); -} diff --git a/src/components/cycle/cycle-view.tsx b/src/components/cycle/cycle-view.tsx index b9d4c57b6..aee7a43d2 100644 --- a/src/components/cycle/cycle-view.tsx +++ b/src/components/cycle/cycle-view.tsx @@ -14,15 +14,18 @@ import { CycleRing } from "./cycle-ring"; import { CycleCalendar } from "./cycle-calendar"; import { LogDaySheet } from "./log-day-sheet"; import { PredictionsPanel } from "./predictions-panel"; -import { CycleInsights } from "./cycle-insights"; +import { + CyclePhaseHeadline, + CyclePhaseCrosstab, +} from "./cycle-phase-crosstab"; import { CycleSettings } from "./cycle-settings"; import { deriveWheelState } from "./wheel-state"; import { useCycleCalendar, useCycleHistory, + useCycleInsights, useCycleProfile, } from "./use-cycle"; -import type { CycleInsightCard } from "./types"; /** * v1.15.0 — the cycle vertical's client orchestrator. @@ -53,14 +56,11 @@ export function CycleView() { const calendar = useCycleCalendar(from, to); const history = useCycleHistory(); const profileQuery = useCycleProfile(); + const insights = useCycleInsights(); const [sheetOpen, setSheetOpen] = useState(false); const [selectedDate, setSelectedDate] = useState(today); - // Phase-correlation cards arrive from another wave; render the graceful - // empty-state until the feed lands. - const insightCards: CycleInsightCard[] = []; - const wheel = useMemo( () => deriveWheelState(calendar.data?.days ?? [], today), [calendar.data, today], @@ -153,8 +153,9 @@ export function CycleView() { /> - - + + + diff --git a/src/components/cycle/symptom-catalog.ts b/src/components/cycle/symptom-catalog.ts index bf53d2298..5351cfccc 100644 --- a/src/components/cycle/symptom-catalog.ts +++ b/src/components/cycle/symptom-catalog.ts @@ -105,10 +105,3 @@ export const CYCLE_SYMPTOM_CATALOG: CycleSymptomCategory[] = [ ], }, ]; - -/** Flat lookup: symptom key → its i18n labelKey (for rendering dots/labels). */ -export const SYMPTOM_LABEL_BY_KEY: Record = Object.fromEntries( - CYCLE_SYMPTOM_CATALOG.flatMap((c) => - c.symptoms.map((s) => [s.key, s.labelKey]), - ), -); diff --git a/src/components/cycle/types.ts b/src/components/cycle/types.ts index 79ecceaff..d9d91ac25 100644 --- a/src/components/cycle/types.ts +++ b/src/components/cycle/types.ts @@ -107,17 +107,6 @@ export interface CycleProfileDTO { updatedAt: string; } -/** A phase-correlation insight card the API surfaces (another wave fills these). */ -export interface CycleInsightCard { - id: string; - title: string; - body: string; - n?: number; - effectSize?: number; - qValue?: number; - caveat?: string; -} - /** The day-log capture payload (subset of the API input we send from web). */ export interface CycleDayLogInput { date: string; diff --git a/src/components/cycle/use-cycle.ts b/src/components/cycle/use-cycle.ts index d3a1d7073..2d4e3319a 100644 --- a/src/components/cycle/use-cycle.ts +++ b/src/components/cycle/use-cycle.ts @@ -22,6 +22,13 @@ import type { CycleHistoryResponse, CycleProfileDTO, } from "./types"; +import type { CyclePhaseCrosstabRow } from "./cycle-phase-crosstab"; + +/** The `/api/cycle/insights` read: the phase-contrast rows + the headline. */ +export interface CycleInsightsResponse { + rows: CyclePhaseCrosstabRow[]; + headline: CyclePhaseCrosstabRow | null; +} async function unwrap(res: Response): Promise { if (!res.ok) throw new Error(`Request failed: ${res.status}`); @@ -60,6 +67,22 @@ export function useCycleProfile() { }); } +/** + * The FDR-guarded phase-correlation surface: the luteal-vs-follicular contrast + * rows + the one headline finding. Gated server-side; reads only on the cycle + * insights tab. + */ +export function useCycleInsights() { + return useQuery({ + queryKey: queryKeys.cycleInsights(), + queryFn: () => + fetch("/api/cycle/insights").then((r) => + unwrap(r), + ), + staleTime: 5 * 60_000, + }); +} + /** A fresh idempotency key per write attempt (matches the iOS Outbox shape). */ function idempotencyKey(): string { return crypto.randomUUID(); diff --git a/src/lib/cycle/types.ts b/src/lib/cycle/types.ts index 87d64aa13..b23a5dbb3 100644 --- a/src/lib/cycle/types.ts +++ b/src/lib/cycle/types.ts @@ -57,15 +57,6 @@ export const HISTORY_WINDOW_N = 12; /** §1 — need ≥2 completed cycles for a real (non-priors) prediction. */ export const MIN_CYCLES_TO_PREDICT = 2; -/** §1 — optional recency half-life (cycles). OFF in MVP (see below). */ -export const RECENCY_HALF_LIFE = 6; - -/** - * §1 — recency weighting is OFF in the v1.15 MVP (plain median) so the TS and - * Swift engines stay trivially identical. Flip in lockstep later. - */ -export const RECENCY_WEIGHTING_ENABLED = false; - /** §1 — floor on the robust SD so the band is never zero-width (false precision). */ export const SIGMA_FLOOR = 1.0; @@ -238,11 +229,3 @@ export interface CyclePredictionResult { /** Robust SD of cycle length (days), >= SIGMA_FLOOR. */ estimatedCycleSd: number; } - -/** The phase of a single calendar date relative to its containing cycle. */ -export interface PhaseResult { - date: string; - phase: CyclePhase | null; - /** 1-based day-of-cycle (1 = startDate), or null if the date is outside any cycle. */ - dayOfCycle: number | null; -} diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index 01f01ce67..67837cdfd 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -514,6 +514,7 @@ export const queryKeys = { ["cycle", "calendar", from, to] as const, cycleHistory: (limit: number) => ["cycle", "history", limit] as const, cycleProfile: () => ["cycle", "profile"] as const, + cycleInsights: () => ["cycle", "insights"] as const, }; /**