mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
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:
1 parent
5f485ca780
commit
77a652a47c
18 files changed
+2057
-4
No files matched your search
@@ -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,
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user