feat(cycle): cycle-phase correlation insight + Coach phase dimension

Add the menstrual-cycle phase as a channel in the existing FDR-guarded
correlation machinery, the same way rated mood factors were folded in —
no new statistics, the same Welch + Benjamini-Hochberg pipeline, the same
day floors and q = 0.10 gate.

Two complementary mechanisms, both confined to a gender-gated surface:
- a categorical luteal-vs-follicular contrast per outcome metric (resting
  heart rate, HRV, sleep, steps, weight, basal/wrist/skin temperature),
  surfacing one headline finding (resting-heart-rate-by-phase, falling
  back to HRV); and
- the continuous phase ordinal folded into the lagged-Pearson matrix.

Phase never reaches the general correlations route — the temperature
channels held out of the general matrix are un-blocked only here, behind
the cycle gate, so a reproductive inference can never leak onto a
non-gated surface. Every row shows n, effect size, p and q; nothing is
fabricated on thin data and nothing is framed as causal.

Surface it through a new gated GET /api/cycle/insights and a drop-in
card that mirrors the mood-factor crosstab.

Add a cycle dimension to the Coach snapshot: current phase + day-of-cycle,
the next predicted event (period range + confidence + method, fertile
window goal-gated), and the headline phase finding — present only for a
cycle-enabled account, so a non-cycle snapshot is unchanged. A new ground
rule keeps the Coach descriptive: never contraception-grade, never a
"safe day" claim.

OpenAPI regenerated; phase-correlation + snapshot-inclusion tests added;
copy localised across all six locales.

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 09:59:02 +02:00
1 parent 5f485ca780
commit 77a652a47c
18 files changed
+2057 -4

No files matched your search

@@ -0,0 +1,202 @@
"use client";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
/**
* v1.15 — CYCLE_PHASE × vital crosstab card.
*
* Mirrors the mood-factor × vital crosstab (`mood-factor-metric-crosstab.tsx`):
* for each outcome metric, the vital's mean on LUTEAL-phase days vs
* FOLLICULAR-phase days, the signed delta, n per group, q, and a confidence
* chip. The math is pre-computed in `src/lib/cycle/phase-crosstab.ts`
* (`computePhaseMetricCrosstab`) — the same Welch t-test + day floors +
* Benjamini-Hochberg FDR the rest of the relations surface uses; only
* FDR-surviving rows reach here. Observational only — the page-level
* "associations, not causes" footer carries the caveat once. This surface is
* gender-gated by its `/api/cycle/insights` route; phase never renders on the
* general correlations surface.
*/
export type CyclePhaseCrosstabDisplay =
| "hours"
| "steps"
| "bpm"
| "ms"
| "kg"
| "celsius";
export type CyclePhaseCrosstabConfidence = "low" | "medium" | "high";
export interface CyclePhaseCrosstabRow {
metricKey: string;
display: CyclePhaseCrosstabDisplay;
lutealDays: number;
follicularDays: number;
lutealAvg: number;
follicularAvg: number;
delta: number;
pValue: number;
qValue: number;
confidence: CyclePhaseCrosstabConfidence;
}
const METRIC_LABEL_KEY: Record<string, string> = {
restingHeartRate: "cycle.insights.crosstab.metricRestingHeartRate",
heartRateVariability: "cycle.insights.crosstab.metricHrv",
sleepDuration: "cycle.insights.crosstab.metricSleepDuration",
steps: "cycle.insights.crosstab.metricSteps",
weight: "cycle.insights.crosstab.metricWeight",
basalBodyTemp: "cycle.insights.crosstab.metricBasalBodyTemp",
wristTemperature: "cycle.insights.crosstab.metricWristTemperature",
skinTemperature: "cycle.insights.crosstab.metricSkinTemperature",
};
const UNIT_KEY: Record<CyclePhaseCrosstabDisplay, string> = {
hours: "cycle.insights.crosstab.unitHours",
steps: "cycle.insights.crosstab.unitSteps",
bpm: "cycle.insights.crosstab.unitBpm",
ms: "cycle.insights.crosstab.unitMs",
kg: "cycle.insights.crosstab.unitKg",
celsius: "cycle.insights.crosstab.unitCelsius",
};
const CONFIDENCE_KEY: Record<CyclePhaseCrosstabConfidence, string> = {
low: "cycle.insights.crosstab.confidenceLow",
medium: "cycle.insights.crosstab.confidenceMedium",
high: "cycle.insights.crosstab.confidenceHigh",
};
const CONFIDENCE_CLASS: Record<CyclePhaseCrosstabConfidence, string> = {
low: "bg-secondary text-muted-foreground",
medium: "bg-info/15 text-info",
high: "bg-success/15 text-success",
};
/** Whole numbers for steps; one decimal otherwise. */
function fmt(value: number, display: CyclePhaseCrosstabDisplay): string {
return display === "steps" ? Math.round(value).toString() : value.toFixed(1);
}
const HEADLINE_KEY: Record<string, string> = {
restingHeartRate: "cycle.insights.headline.restingHeartRate",
heartRateVariability: "cycle.insights.headline.heartRateVariability",
};
/**
* The single headline finding rendered prominently above the grid. Resting
* heart rate / HRV get bespoke phrasing; every other metric uses the generic
* line. Returns null when nothing cleared the FDR gate — an honest empty state.
*/
export function CyclePhaseHeadline({
headline,
}: {
headline: CyclePhaseCrosstabRow | null;
}) {
const { t } = useTranslations();
if (!headline) {
return (
<p
data-slot="cycle-phase-headline-empty"
className="text-muted-foreground text-sm"
>
{t("cycle.insights.headline.empty")}
</p>
);
}
const metricLabel = t(METRIC_LABEL_KEY[headline.metricKey] ?? headline.metricKey);
const unit = t(UNIT_KEY[headline.display]);
const up = headline.delta >= 0;
const dir = t(
up ? "cycle.insights.headline.dirHigher" : "cycle.insights.headline.dirLower",
);
const params = {
metric: metricLabel,
delta: fmt(Math.abs(headline.delta), headline.display),
unit,
dir,
};
const key = HEADLINE_KEY[headline.metricKey] ?? "cycle.insights.headline.generic";
return (
<p
data-slot="cycle-phase-headline"
data-metric={headline.metricKey}
className="text-foreground text-sm font-medium"
>
{t(key, params)}
</p>
);
}
export function CyclePhaseCrosstab({
rows,
}: {
rows: CyclePhaseCrosstabRow[];
}) {
const { t } = useTranslations();
if (rows.length === 0) return null;
return (
<div data-slot="cycle-phase-crosstab">
<p className="text-muted-foreground mb-2 text-sm">
{t("cycle.insights.crosstab.description")}
</p>
<ul className="divide-border divide-y">
{rows.map((row) => {
const metricLabel = t(METRIC_LABEL_KEY[row.metricKey] ?? row.metricKey);
const unit = t(UNIT_KEY[row.display]);
// `delta` = lutealAvg − follicularAvg: positive = the vital runs
// higher in the luteal phase. The number stays NEUTRAL — higher is
// good for steps but bad for resting HR, and this board is
// observational, so colouring it would imply a verdict the data
// doesn't support. The sign prefix + `data-direction` carry it.
const up = row.delta >= 0;
const deltaText = `${up ? "+" : ""}${fmt(row.delta, row.display)} ${unit}`;
return (
<li
key={row.metricKey}
className="flex flex-col gap-1.5 py-2"
data-slot="cycle-phase-crosstab-row"
data-metric={row.metricKey}
data-direction={up ? "up" : "down"}
data-confidence={row.confidence}
>
<div className="flex items-center gap-2 text-sm">
<span
className="text-foreground min-w-0 flex-1 truncate"
title={metricLabel}
>
{t("cycle.insights.crosstab.pairLabel", { metric: metricLabel })}
</span>
<span className="text-foreground shrink-0 text-sm font-semibold tabular-nums">
{deltaText}
</span>
<span
className={cn(
"shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium",
CONFIDENCE_CLASS[row.confidence],
)}
>
{t(CONFIDENCE_KEY[row.confidence])}
</span>
</div>
<p className="text-muted-foreground text-xs">
{t("cycle.insights.crosstab.detail", {
metric: metricLabel,
lutealAvg: fmt(row.lutealAvg, row.display),
follicularAvg: fmt(row.follicularAvg, row.display),
unit,
lutealDays: row.lutealDays,
follicularDays: row.follicularDays,
})}
</p>
</li>
);
})}
</ul>
<p className="text-muted-foreground mt-3 text-xs">
{t("cycle.insights.crosstab.footer")}
</p>
</div>
);
}