mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +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
@@ -2349,6 +2349,68 @@ const cyclePeriodResponse = z.object({
|
||||
dayLog: cycleDayLogDto.nullable(),
|
||||
});
|
||||
|
||||
const cyclePhaseCrosstabRow = z
|
||||
.object({
|
||||
metricKey: z.enum([
|
||||
"restingHeartRate",
|
||||
"heartRateVariability",
|
||||
"sleepDuration",
|
||||
"steps",
|
||||
"weight",
|
||||
"basalBodyTemp",
|
||||
"wristTemperature",
|
||||
"skinTemperature",
|
||||
]),
|
||||
display: z.enum(["hours", "steps", "bpm", "ms", "kg", "celsius"]),
|
||||
lutealDays: z.number().int(),
|
||||
follicularDays: z.number().int(),
|
||||
lutealAvg: z.number(),
|
||||
follicularAvg: z.number(),
|
||||
delta: z.number(),
|
||||
pValue: z.number(),
|
||||
qValue: z.number(),
|
||||
confidence: z.enum(["low", "medium", "high"]),
|
||||
})
|
||||
.meta({
|
||||
id: "CyclePhaseCrosstabRow",
|
||||
description:
|
||||
"One FDR-surviving luteal-vs-follicular contrast for an outcome metric. `delta = lutealAvg − follicularAvg`. Observational, never causal.",
|
||||
});
|
||||
|
||||
const cyclePhaseLaggedPair = z
|
||||
.object({
|
||||
behaviour: z.string(),
|
||||
outcome: z.string(),
|
||||
n: z.number().int(),
|
||||
r: z.number(),
|
||||
pValue: z.number(),
|
||||
qValue: z.number(),
|
||||
interpretation: z.string(),
|
||||
lagDays: z.number().int(),
|
||||
})
|
||||
.meta({
|
||||
id: "CyclePhaseLaggedPair",
|
||||
description:
|
||||
"One FDR-surviving lagged-Pearson pair from the continuous CYCLE_PHASE ordinal × outcome matrix (mechanism B). Descriptive, never causal.",
|
||||
});
|
||||
|
||||
const cycleInsightsResponse = z.object({
|
||||
rows: z.array(cyclePhaseCrosstabRow),
|
||||
headline: cyclePhaseCrosstabRow.nullable(),
|
||||
lagged: z.object({
|
||||
discovered: z.array(cyclePhaseLaggedPair),
|
||||
pairsTested: z.number().int(),
|
||||
fdrQ: z.number(),
|
||||
minPairs: z.number().int(),
|
||||
}),
|
||||
contrast: z.object({
|
||||
high: z.literal("LUTEAL"),
|
||||
low: z.literal("FOLLICULAR"),
|
||||
}),
|
||||
windowDays: z.number().int(),
|
||||
cyclesObserved: z.number().int(),
|
||||
});
|
||||
|
||||
const cycleBulkEntryResult = z.object({
|
||||
index: z.number().int(),
|
||||
status: z.enum(["inserted", "duplicate", "updated", "skipped"]),
|
||||
@@ -2554,6 +2616,26 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/cycle/insights": {
|
||||
get: {
|
||||
tags: ["Cycle"],
|
||||
summary: "Cycle-phase correlation insights (v1.15.0)",
|
||||
description:
|
||||
"FDR-guarded luteal-vs-follicular phase contrast per outcome metric (RHR / HRV / sleep / steps / weight / temperatures), plus the single headline finding (resting-heart-rate-by-phase, falling back to HRV). The same Welch t-test + Benjamini-Hochberg machinery the mood-factor crosstab runs; only rows with p < 0.05 AND q ≤ 0.10 surface. Strictly gender-gated — phase never appears on the general `/api/insights/correlations` route. Observational only, never causal. Gated: `cycle.disabled` 403 when the feature is off.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Phase-correlation rows + headline.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(cycleInsightsResponse, "CycleInsightsEnvelope"),
|
||||
},
|
||||
},
|
||||
},
|
||||
...cycleDisabledResponse,
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/cycle/cycles/{id}": {
|
||||
delete: {
|
||||
tags: ["Cycle"],
|
||||
|
||||
Reference in new issue
Block a user