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

+192
View File
@@ -0,0 +1,192 @@
/**
* `GET /api/cycle/insights` — cycle-phase × vital correlation insights
* (integration-audit §1 + §3 "INSIGHTS").
*
* The gender-gated home for the CYCLE_PHASE correlation. The general
* `/api/insights/correlations` route is NOT gender-gated, so phase — and the
* temperature channels a phase relation rides on — must never surface there;
* this route is the deliberate, gated place they do.
*
* Surfaces the FDR-guarded LUTEAL-vs-FOLLICULAR phase contrast per outcome
* metric (the same Welch + Benjamini-Hochberg machinery the mood-factor
* crosstab runs) plus the ONE headline insight (resting-heart-rate-by-phase,
* falling back to HRV) for the v1.15.0 card. Honest: n / effect size / q are
* shown, every row is FDR-gated, nothing is fabricated on thin data, nothing
* is causal.
*/
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { requireCycleEnabled } from "@/lib/cycle/gate";
import {
predictCycle,
type NightlyTempInput,
} from "@/lib/cycle";
import { LUTEAL_DEFAULT } from "@/lib/cycle/types";
import {
buildPhaseDayMap,
toCycleInputs,
toDayLogInputs,
toProfileInput,
} from "@/lib/cycle/engine-adapter";
import {
computePhaseMetricCrosstab,
discoverPhaseCorrelations,
selectHeadlinePhaseRow,
PHASE_CROSSTAB_METRIC_TYPES,
} from "@/lib/cycle/phase-crosstab";
import type { CrossMetricMeasurement } from "@/lib/insights/mood-aggregates";
import { addDays } from "@/lib/cycle/day-math";
import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key";
/** Trailing window the phase-contrast walks (days). */
const WINDOW_DAYS = 365;
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
const profile = gate.profile;
const tz = user.timezone ?? DEFAULT_TIMEZONE;
const today = moodDateKey(new Date(), tz);
const from = addDays(today, -WINDOW_DAYS);
const [cycles, dayLogRows, nightlyTempRows, measurementRows, prefsRow] =
await Promise.all([
prisma.menstrualCycle.findMany({
where: { userId: user.id, deletedAt: null },
orderBy: { startDate: "asc" },
}),
prisma.cycleDayLog.findMany({
where: { userId: user.id, deletedAt: null },
orderBy: { date: "asc" },
select: {
date: true,
flow: true,
basalBodyTempC: true,
ovulationTest: true,
cervicalMucus: true,
},
}),
// Passive wrist temperature feeds the temperature-trend ovulation layer
// (used only to sharpen the prediction's next-start the phase-day map
// extends to). Not a crosstab input.
prisma.measurement.findMany({
where: {
userId: user.id,
deletedAt: null,
type: "WRIST_TEMPERATURE",
measuredAt: {
gte: new Date(Date.parse(`${addDays(today, -90)}T00:00:00Z`)),
},
},
orderBy: { measuredAt: "asc" },
select: { measuredAt: true, value: true },
}),
// The outcome metrics the phase contrast compares — soft-delete-scoped,
// canonical-source deduped per day inside `metricDayMap`.
prisma.measurement.findMany({
where: {
userId: user.id,
deletedAt: null,
type: { in: PHASE_CROSSTAB_METRIC_TYPES },
measuredAt: {
gte: new Date(Date.parse(`${from}T00:00:00Z`)),
},
},
orderBy: { measuredAt: "asc" },
select: {
type: true,
value: true,
measuredAt: true,
source: true,
deviceType: true,
},
}),
prisma.user.findUnique({
where: { id: user.id },
select: { sourcePriorityJson: true },
}),
]);
// The latest open cycle runs to the predicted next-period start so the
// trailing window is phase-labelled even before the next period is logged.
const nights: NightlyTempInput[] = nightlyTempRows.map((m) => ({
date: moodDateKey(m.measuredAt, tz),
valueC: m.value,
}));
const prediction =
profile.predictionEnabled && !profile.rawChartMode
? predictCycle(
toCycleInputs(cycles),
toDayLogInputs(dayLogRows),
toProfileInput(profile),
today,
nights,
)
: null;
const phaseByDay = buildPhaseDayMap(
cycles,
prediction?.nextPeriodStart ?? null,
profile.lutealPhaseLength ?? LUTEAL_DEFAULT,
from,
today,
);
const measurements: CrossMetricMeasurement[] = measurementRows.map((m) => ({
type: m.type,
value: m.value,
measuredAt: m.measuredAt,
source: m.source,
deviceType: m.deviceType,
}));
const userPriorityJson = prefsRow?.sourcePriorityJson ?? null;
// Mechanism A — the categorical luteal-vs-follicular contrast (the headline
// surface). Mechanism B — the continuous CYCLE_PHASE ordinal folded into the
// lagged-Pearson FDR matrix. Both are FDR-gated; both live only here.
const rows = computePhaseMetricCrosstab({
phaseByDay,
measurements,
userPriorityJson,
});
const headline = selectHeadlinePhaseRow(rows);
const lagged = discoverPhaseCorrelations({
phaseByDay,
measurements,
userPriorityJson,
});
annotate({
action: { name: "cycle.insights.read" },
meta: {
labelled_days: phaseByDay.size,
cycles_observed: cycles.length,
rows: rows.length,
has_headline: headline !== null,
lagged_discovered: lagged.discovered.length,
lagged_pairs_tested: lagged.pairsTested,
},
});
return apiSuccess({
rows,
headline,
lagged: {
discovered: lagged.discovered,
pairsTested: lagged.pairsTested,
fdrQ: lagged.fdrQ,
minPairs: lagged.minPairs,
},
contrast: { high: "LUTEAL", low: "FOLLICULAR" },
windowDays: WINDOW_DAYS,
cyclesObserved: cycles.length,
});
});