mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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.");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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]),
|
||||
),
|
||||
);
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user