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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-06 10:26:13 +02:00
1 parent e984ada5f1
commit 55f40df0ac
9 files changed
+33 -213

No files matched your search

@@ -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(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
describe("<CycleInsights>", () => {
it("renders the empty-state when no cards are surfaced", () => {
const html = render(<CycleInsights cards={[]} />);
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(
<CycleInsights
cards={[
{
id: "rhr-luteal",
title: "Resting heart rate runs higher in your luteal phase",
body: "Across your logged cycles RHR is ~3 bpm higher in luteal vs follicular days.",
n: 42,
effectSize: 0.61,
qValue: 0.04,
caveat: "Descriptive only.",
},
]}
/>,
);
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.");
});
});
-86
View File
@@ -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 (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Activity className="text-primary h-4 w-4" aria-hidden="true" />
{t("cycle.insights.title")}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-sm">
{t("cycle.insights.empty")}
</p>
</CardContent>
</Card>
);
}
return (
<div className="space-y-3" data-slot="cycle-insights">
{cards.map((card) => (
<Card key={card.id}>
<CardHeader>
<CardTitle className="text-base">{card.title}</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<p className="text-foreground/90 text-sm">{card.body}</p>
<div className="text-muted-foreground flex flex-wrap gap-x-3 gap-y-1 text-xs tabular-nums">
{card.n != null ? (
<span>{t("cycle.insights.sampleSize", { n: card.n })}</span>
) : null}
{card.effectSize != null ? (
<span>
{t("cycle.insights.effectSize", {
value: card.effectSize.toFixed(2),
})}
</span>
) : null}
{card.qValue != null ? (
<span>
{t("cycle.insights.qValue", {
value: card.qValue.toFixed(3),
})}
</span>
) : null}
</div>
{card.caveat ? (
<p className="text-muted-foreground text-xs italic">
{card.caveat}
</p>
) : null}
</CardContent>
</Card>
))}
</div>
);
}
@@ -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/<locale>.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 (
<p
className={cn(
"text-foreground/80 border-border bg-muted/40 rounded-md border-l-2 px-3 py-2 text-xs font-medium",
className,
)}
data-slot="cycle-prediction-disclaimer"
>
{t("cycle.disclaimer")}
</p>
);
}
+9 -8
View File
@@ -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() {
/>
</TabsContent>
<TabsContent value="insights" className="mt-4">
<CycleInsights cards={insightCards} />
<TabsContent value="insights" className="mt-4 space-y-4">
<CyclePhaseHeadline headline={insights.data?.headline ?? null} />
<CyclePhaseCrosstab rows={insights.data?.rows ?? []} />
</TabsContent>
<TabsContent value="settings" className="mt-4">
-7
View File
@@ -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<string, string> = Object.fromEntries(
CYCLE_SYMPTOM_CATALOG.flatMap((c) =>
c.symptoms.map((s) => [s.key, s.labelKey]),
),
);
-11
View File
@@ -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;
+23
View File
@@ -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<T>(res: Response): Promise<T> {
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<CycleInsightsResponse>(r),
),
staleTime: 5 * 60_000,
});
}
/** A fresh idempotency key per write attempt (matches the iOS Outbox shape). */
function idempotencyKey(): string {
return crypto.randomUUID();
-17
View File
@@ -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;
}
+1
View File
@@ -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,
};
/**