feat(cycle): symptom-by-phase patterns on the insights tab

Add the cycle-native counterpart to the phase×vitals crosstab: for each
logged symptom, where in the cycle it clusters. A pure aggregator tallies
each symptom's occurrences across the phase-labelled days (the same phase
map the calendar uses), surfaces only symptoms logged on at least three
phased days, and reports per-phase counts + the dominant phase + its
share. The insights tab renders it as a proportional phase-hued bar with
the dominant phase named in words (never colour-only), observational and
never causal.

Also correct the phase-crosstab OpenAPI row enum, which had drifted
behind the runtime (mood + glucose channels were missing).

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 13:47:04 +02:00
1 parent d2da8d2657
commit eec3557137
14 files changed
+447 -7

No files matched your search

+39 -1
View File
@@ -2426,8 +2426,19 @@ const cyclePhaseCrosstabRow = z
"basalBodyTemp",
"wristTemperature",
"skinTemperature",
"bloodGlucose",
"mood",
]),
display: z.enum([
"hours",
"steps",
"bpm",
"ms",
"kg",
"celsius",
"glucose",
"mood",
]),
display: z.enum(["hours", "steps", "bpm", "ms", "kg", "celsius"]),
lutealDays: z.number().int(),
follicularDays: z.number().int(),
lutealAvg: z.number(),
@@ -2460,6 +2471,32 @@ const cyclePhaseLaggedPair = z
"One FDR-surviving lagged-Pearson pair from the continuous CYCLE_PHASE ordinal × outcome matrix (mechanism B). Descriptive, never causal.",
});
const cyclePhaseEnumForCount = z.enum([
"MENSTRUAL",
"FOLLICULAR",
"OVULATORY",
"LUTEAL",
]);
const cycleSymptomPhaseRow = z
.object({
symptomKey: z.string(),
counts: z.object({
MENSTRUAL: z.number().int(),
FOLLICULAR: z.number().int(),
OVULATORY: z.number().int(),
LUTEAL: z.number().int(),
}),
total: z.number().int(),
topPhase: cyclePhaseEnumForCount,
topShare: z.number(),
})
.meta({
id: "CycleSymptomPhaseRow",
description:
"Where a logged symptom clusters across the cycle phases. Surfaced only once logged on ≥3 phase-labelled days. Observational, never causal.",
});
const cycleInsightsResponse = z.object({
rows: z.array(cyclePhaseCrosstabRow),
headline: cyclePhaseCrosstabRow.nullable(),
@@ -2469,6 +2506,7 @@ const cycleInsightsResponse = z.object({
fdrQ: z.number(),
minPairs: z.number().int(),
}),
symptomPatterns: z.array(cycleSymptomPhaseRow),
contrast: z.object({
high: z.literal("LUTEAL"),
low: z.literal("FOLLICULAR"),