From eec355713756172a4552e896ea5d8ca0876298ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 6 Jun 2026 13:47:04 +0200 Subject: [PATCH] feat(cycle): symptom-by-phase patterns on the insights tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/api/openapi.yaml | 61 +++++++++ messages/de.json | 5 +- messages/en.json | 5 +- messages/es.json | 5 +- messages/fr.json | 5 +- messages/it.json | 5 +- messages/pl.json | 5 +- src/app/api/cycle/insights/route.ts | 14 ++ .../cycle/cycle-symptom-patterns.tsx | 128 ++++++++++++++++++ src/components/cycle/cycle-view.tsx | 4 + src/components/cycle/use-cycle.ts | 15 ++ src/lib/cycle/__tests__/symptom-phase.test.ts | 67 +++++++++ src/lib/cycle/symptom-phase.ts | 95 +++++++++++++ src/lib/openapi/routes.ts | 40 +++++- 14 files changed, 447 insertions(+), 7 deletions(-) create mode 100644 src/components/cycle/cycle-symptom-patterns.tsx create mode 100644 src/lib/cycle/__tests__/symptom-phase.test.ts create mode 100644 src/lib/cycle/symptom-phase.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 69ee6184d..664e3e24d 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -5442,6 +5442,10 @@ components: - fdrQ - minPairs additionalProperties: false + symptomPatterns: + type: array + items: + $ref: "#/components/schemas/CycleSymptomPhaseRow" contrast: type: object properties: @@ -5467,6 +5471,7 @@ components: - rows - headline - lagged + - symptomPatterns - contrast - windowDays - cyclesObserved @@ -5497,6 +5502,8 @@ components: - basalBodyTemp - wristTemperature - skinTemperature + - bloodGlucose + - mood display: type: string enum: @@ -5506,6 +5513,8 @@ components: - ms - kg - celsius + - glucose + - mood lutealDays: type: integer minimum: -9007199254740991 @@ -5579,6 +5588,58 @@ components: additionalProperties: false description: One FDR-surviving lagged-Pearson pair from the continuous CYCLE_PHASE ordinal × outcome matrix (mechanism B). Descriptive, never causal. + CycleSymptomPhaseRow: + type: object + properties: + symptomKey: + type: string + counts: + type: object + properties: + MENSTRUAL: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + FOLLICULAR: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + OVULATORY: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + LUTEAL: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - MENSTRUAL + - FOLLICULAR + - OVULATORY + - LUTEAL + additionalProperties: false + total: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + topPhase: + type: string + enum: + - MENSTRUAL + - FOLLICULAR + - OVULATORY + - LUTEAL + topShare: + type: number + required: + - symptomKey + - counts + - total + - topPhase + - topShare + additionalProperties: false + description: Where a logged symptom clusters across the cycle phases. Surfaced only once logged on ≥3 phase-labelled + days. Observational, never causal. CyclePurgeEnvelope: type: object properties: diff --git a/messages/de.json b/messages/de.json index 7402b80c2..c7a8058ec 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4960,7 +4960,10 @@ "empty": "Phasen-Erkenntnisse erscheinen hier, sobald du genug Zyklen erfasst hast. Sie vergleichen deine Werte über die Zyklusphasen hinweg, mit offen gezeigter Statistik.", "sampleSize": "n = {n}", "effectSize": "Effektstärke {value}", - "qValue": "q = {value}" + "qValue": "q = {value}", + "symptomPatternsTitle": "Symptommuster nach Phase", + "symptomPatternsEmpty": "Erfasse Symptome über einen Zyklus, um zu sehen, in welcher Phase sie sich häufen.", + "symptomMostlyIn": "Meist in {phase} ({count}/{total} Tage)" }, "disclaimer": "Diese Vorhersagen sind beschreibende Schätzungen aus deinen erfassten Daten, keine Verhütungsmethode und keine medizinische Beratung. Sie zeigen nie an, dass ungeschützter Geschlechtsverkehr sicher ist. Wende dich für Verhütung oder medizinische Entscheidungen an eine medizinische Fachperson.", "title": "Dein Zyklus", diff --git a/messages/en.json b/messages/en.json index 11fbafbae..7fd9b439b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4960,7 +4960,10 @@ "empty": "Phase insights appear here once you have enough logged cycles. They compare your metrics across cycle phases, with the statistics shown openly.", "sampleSize": "n = {n}", "effectSize": "Effect size {value}", - "qValue": "q = {value}" + "qValue": "q = {value}", + "symptomPatternsTitle": "Symptom patterns by phase", + "symptomPatternsEmpty": "Log symptoms across a cycle to see which phase they cluster in.", + "symptomMostlyIn": "Mostly in {phase} ({count}/{total} days)" }, "disclaimer": "These predictions are descriptive estimates from your logged data, not a contraceptive method and not medical advice. They never indicate that it is safe to have unprotected sex. For contraception or any medical decision, consult a healthcare professional.", "title": "Your cycle", diff --git a/messages/es.json b/messages/es.json index 2ba83b30d..66ad23a33 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4960,7 +4960,10 @@ "empty": "Los análisis por fase aparecen aquí cuando tienes suficientes ciclos registrados. Comparan tus métricas entre fases del ciclo, mostrando la estadística abiertamente.", "sampleSize": "n = {n}", "effectSize": "Tamaño del efecto {value}", - "qValue": "q = {value}" + "qValue": "q = {value}", + "symptomPatternsTitle": "Patrones de síntomas por fase", + "symptomPatternsEmpty": "Registra síntomas a lo largo de un ciclo para ver en qué fase se concentran.", + "symptomMostlyIn": "Sobre todo en {phase} ({count}/{total} días)" }, "disclaimer": "Estas predicciones son estimaciones descriptivas a partir de tus datos registrados, no un método anticonceptivo ni un consejo médico. Nunca indican que sea seguro tener relaciones sin protección. Para anticoncepción o cualquier decisión médica, consulta a un profesional sanitario.", "title": "Tu ciclo", diff --git a/messages/fr.json b/messages/fr.json index 74e6719d9..cb6b6825a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4960,7 +4960,10 @@ "empty": "Les analyses par phase apparaissent ici une fois assez de cycles enregistrés. Elles comparent tes mesures entre les phases du cycle, avec les statistiques affichées ouvertement.", "sampleSize": "n = {n}", "effectSize": "Taille d'effet {value}", - "qValue": "q = {value}" + "qValue": "q = {value}", + "symptomPatternsTitle": "Tendances des symptômes par phase", + "symptomPatternsEmpty": "Notez vos symptômes sur un cycle pour voir dans quelle phase ils se regroupent.", + "symptomMostlyIn": "Surtout en {phase} ({count}/{total} jours)" }, "disclaimer": "Ces prévisions sont des estimations descriptives issues de vos données enregistrées, ni une méthode de contraception ni un avis médical. Elles n'indiquent jamais qu'il est sûr d'avoir des rapports non protégés. Pour la contraception ou toute décision médicale, consultez un professionnel de santé.", "title": "Ton cycle", diff --git a/messages/it.json b/messages/it.json index 86513e04e..7b560241f 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4960,7 +4960,10 @@ "empty": "Le analisi per fase compaiono qui quando hai registrato abbastanza cicli. Confrontano le tue misure tra le fasi del ciclo, con la statistica mostrata apertamente.", "sampleSize": "n = {n}", "effectSize": "Dimensione dell'effetto {value}", - "qValue": "q = {value}" + "qValue": "q = {value}", + "symptomPatternsTitle": "Pattern dei sintomi per fase", + "symptomPatternsEmpty": "Registra i sintomi durante un ciclo per vedere in quale fase si concentrano.", + "symptomMostlyIn": "Soprattutto in {phase} ({count}/{total} giorni)" }, "disclaimer": "Queste previsioni sono stime descrittive basate sui tuoi dati registrati, non un metodo contraccettivo né un parere medico. Non indicano mai che sia sicuro avere rapporti non protetti. Per la contraccezione o qualsiasi decisione medica, consulta un professionista sanitario.", "title": "Il tuo ciclo", diff --git a/messages/pl.json b/messages/pl.json index 8f4104949..27b5b0a66 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4960,7 +4960,10 @@ "empty": "Analizy faz pojawią się tutaj, gdy zapiszesz wystarczająco wiele cykli. Porównują twoje wskaźniki między fazami cyklu, z otwarcie pokazaną statystyką.", "sampleSize": "n = {n}", "effectSize": "Wielkość efektu {value}", - "qValue": "q = {value}" + "qValue": "q = {value}", + "symptomPatternsTitle": "Wzorce objawów wg fazy", + "symptomPatternsEmpty": "Zapisuj objawy przez cały cykl, aby zobaczyć, w której fazie się grupują.", + "symptomMostlyIn": "Głównie w fazie {phase} ({count}/{total} dni)" }, "disclaimer": "Te prognozy to opisowe szacunki na podstawie zapisanych danych, a nie metoda antykoncepcji ani porada medyczna. Nigdy nie oznaczają, że współżycie bez zabezpieczenia jest bezpieczne. W sprawach antykoncepcji lub decyzji medycznych skonsultuj się z pracownikiem ochrony zdrowia.", "title": "Twój cykl", diff --git a/src/app/api/cycle/insights/route.ts b/src/app/api/cycle/insights/route.ts index 74ab80fba..48275d773 100644 --- a/src/app/api/cycle/insights/route.ts +++ b/src/app/api/cycle/insights/route.ts @@ -35,6 +35,10 @@ import { MOOD_CHANNEL_KEY, } from "@/lib/cycle/phase-crosstab"; import type { CrossMetricMeasurement } from "@/lib/insights/mood-aggregates"; +import { + computeSymptomPhasePatterns, + type SymptomDay, +} from "@/lib/cycle/symptom-phase"; import { addDays } from "@/lib/cycle/day-math"; import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key"; @@ -79,6 +83,7 @@ export const GET = apiHandler(async () => { basalBodyTempC: true, ovulationTest: true, cervicalMucus: true, + symptomLinks: { select: { symptom: { select: { key: true } } } }, }, }), // Passive wrist temperature feeds the temperature-trend ovulation layer @@ -196,6 +201,13 @@ export const GET = apiHandler(async () => { userPriorityJson, }); + // Cycle-NATIVE insight: where each logged symptom clusters across the phases. + const symptomDays: SymptomDay[] = dayLogRows.map((d) => ({ + date: d.date, + keys: d.symptomLinks.map((l) => l.symptom.key), + })); + const symptomPatterns = computeSymptomPhasePatterns(symptomDays, phaseByDay); + annotate({ action: { name: "cycle.insights.read" }, meta: { @@ -205,6 +217,7 @@ export const GET = apiHandler(async () => { has_headline: headline !== null, lagged_discovered: lagged.discovered.length, lagged_pairs_tested: lagged.pairsTested, + symptom_patterns: symptomPatterns.length, }, }); @@ -217,6 +230,7 @@ export const GET = apiHandler(async () => { fdrQ: lagged.fdrQ, minPairs: lagged.minPairs, }, + symptomPatterns, contrast: { high: "LUTEAL", low: "FOLLICULAR" }, windowDays: WINDOW_DAYS, cyclesObserved: cycles.length, diff --git a/src/components/cycle/cycle-symptom-patterns.tsx b/src/components/cycle/cycle-symptom-patterns.tsx new file mode 100644 index 000000000..84a4e2a2a --- /dev/null +++ b/src/components/cycle/cycle-symptom-patterns.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { useMemo } from "react"; +import { Activity } from "lucide-react"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { useTranslations } from "@/lib/i18n/context"; +import { CYCLE_SYMPTOM_CATALOG } from "./symptom-catalog"; +import { PHASE_HUE } from "./phase-tokens"; +import type { CyclePhase } from "./types"; +import type { SymptomPhaseRow } from "./use-cycle"; + +/** + * v1.15.0 — symptom patterns by phase (the cycle-NATIVE insight). + * + * The phase×vitals crosstab correlates the cycle against your measurements; + * this card answers the complementary, cycle-own question: which of your logged + * symptoms cluster in which phase. Each row shows the symptom's per-phase + * distribution as a proportional 4-segment bar (phase-hued, never colour-only — + * the dominant phase is named in words) plus "mostly in your luteal phase + * (7 of 9 days)". Observational, never causal; only symptoms logged on ≥3 + * phase-labelled days surface (the server floor). + */ + +const PHASE_ORDER: CyclePhase[] = [ + "MENSTRUAL", + "FOLLICULAR", + "OVULATORY", + "LUTEAL", +]; + +export interface CycleSymptomPatternsProps { + rows: SymptomPhaseRow[]; +} + +export function CycleSymptomPatterns({ rows }: CycleSymptomPatternsProps) { + const { t } = useTranslations(); + + // Flatten the catalog once: symptom key → { labelKey, icon }. + const byKey = useMemo(() => { + const map = new Map< + string, + { + labelKey: string; + icon: (typeof CYCLE_SYMPTOM_CATALOG)[number]["symptoms"][number]["icon"]; + } + >(); + for (const cat of CYCLE_SYMPTOM_CATALOG) { + for (const s of cat.symptoms) { + map.set(s.key, { labelKey: s.labelKey, icon: s.icon }); + } + } + return map; + }, []); + + return ( + + + + + + + {rows.length === 0 ? ( +

+ {t("cycle.insights.symptomPatternsEmpty")} +

+ ) : ( +
    + {rows.map((row) => { + const meta = byKey.get(row.symptomKey); + const Icon = meta?.icon; + const name = meta ? t(meta.labelKey) : row.symptomKey; + const phaseName = t(`cycle.phase.${row.topPhase}`); + return ( +
  • +
    + + {Icon ? ( + + + {t("cycle.insights.symptomMostlyIn", { + phase: phaseName, + count: row.counts[row.topPhase], + total: row.total, + })} + +
    + {/* Proportional phase-distribution bar — phase-hued, with the + dominant phase named above so it is never colour-only. */} +
    + {PHASE_ORDER.map((phase) => { + const share = + row.total > 0 ? row.counts[phase] / row.total : 0; + if (share <= 0) return null; + return ( + + ); + })} +
    +
  • + ); + })} +
+ )} +
+
+ ); +} diff --git a/src/components/cycle/cycle-view.tsx b/src/components/cycle/cycle-view.tsx index 0febea984..1b29562da 100644 --- a/src/components/cycle/cycle-view.tsx +++ b/src/components/cycle/cycle-view.tsx @@ -15,6 +15,7 @@ import { CycleDisclaimer } from "./cycle-disclaimer"; import { LogDaySheet } from "./log-day-sheet"; import { PredictionsPanel } from "./predictions-panel"; import { CyclePhaseHeadline, CyclePhaseCrosstab } from "./cycle-phase-crosstab"; +import { CycleSymptomPatterns } from "./cycle-symptom-patterns"; import { CycleSettings } from "./cycle-settings"; import { deriveWheelState } from "./wheel-state"; import { PHASE_HUE } from "./phase-tokens"; @@ -263,6 +264,9 @@ export function CycleView() { headline={insights.data?.headline ?? null} /> + )} diff --git a/src/components/cycle/use-cycle.ts b/src/components/cycle/use-cycle.ts index fd83a0746..d4ee8bf4a 100644 --- a/src/components/cycle/use-cycle.ts +++ b/src/components/cycle/use-cycle.ts @@ -31,10 +31,25 @@ import type { } from "./types"; import type { CyclePhaseCrosstabRow } from "./cycle-phase-crosstab"; +/** One symptom's per-phase clustering (mirrors `SymptomPhasePatternRow`). */ +export interface SymptomPhaseRow { + symptomKey: string; + counts: { + MENSTRUAL: number; + FOLLICULAR: number; + OVULATORY: number; + LUTEAL: number; + }; + total: number; + topPhase: "MENSTRUAL" | "FOLLICULAR" | "OVULATORY" | "LUTEAL"; + topShare: number; +} + /** The `/api/cycle/insights` read: the phase-contrast rows + the headline. */ export interface CycleInsightsResponse { rows: CyclePhaseCrosstabRow[]; headline: CyclePhaseCrosstabRow | null; + symptomPatterns: SymptomPhaseRow[]; } async function unwrap(res: Response): Promise { diff --git a/src/lib/cycle/__tests__/symptom-phase.test.ts b/src/lib/cycle/__tests__/symptom-phase.test.ts new file mode 100644 index 000000000..b979be48f --- /dev/null +++ b/src/lib/cycle/__tests__/symptom-phase.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from "vitest"; + +import { + computeSymptomPhasePatterns, + SYMPTOM_PHASE_MIN_DAYS, + type SymptomDay, +} from "../symptom-phase"; +import type { CyclePhase } from "../types"; + +function phaseMap(entries: [string, CyclePhase][]): Map { + return new Map(entries); +} + +describe("computeSymptomPhasePatterns", () => { + it("clusters a symptom into its dominant phase", () => { + const days: SymptomDay[] = [ + { date: "2026-06-01", keys: ["cramps"] }, + { date: "2026-06-02", keys: ["cramps"] }, + { date: "2026-06-03", keys: ["cramps"] }, + { date: "2026-06-20", keys: ["cramps"] }, + ]; + const phases = phaseMap([ + ["2026-06-01", "MENSTRUAL"], + ["2026-06-02", "MENSTRUAL"], + ["2026-06-03", "MENSTRUAL"], + ["2026-06-20", "LUTEAL"], + ]); + const [row] = computeSymptomPhasePatterns(days, phases); + expect(row.symptomKey).toBe("cramps"); + expect(row.total).toBe(4); + expect(row.counts.MENSTRUAL).toBe(3); + expect(row.counts.LUTEAL).toBe(1); + expect(row.topPhase).toBe("MENSTRUAL"); + expect(row.topShare).toBeCloseTo(0.75, 5); + }); + + it("drops symptoms below the min-day floor", () => { + const days: SymptomDay[] = [ + { date: "2026-06-01", keys: ["rare"] }, + { date: "2026-06-02", keys: ["rare"] }, + ]; + const phases = phaseMap([ + ["2026-06-01", "MENSTRUAL"], + ["2026-06-02", "FOLLICULAR"], + ]); + // 2 < SYMPTOM_PHASE_MIN_DAYS (3) → no row. + expect(SYMPTOM_PHASE_MIN_DAYS).toBe(3); + expect(computeSymptomPhasePatterns(days, phases)).toHaveLength(0); + }); + + it("ignores symptom days that fall outside any phase window", () => { + const days: SymptomDay[] = [ + { date: "2026-06-01", keys: ["cramps"] }, + { date: "2026-06-02", keys: ["cramps"] }, + { date: "2026-06-03", keys: ["cramps"] }, + { date: "2026-06-30", keys: ["cramps"] }, // unphased → ignored + ]; + const phases = phaseMap([ + ["2026-06-01", "LUTEAL"], + ["2026-06-02", "LUTEAL"], + ["2026-06-03", "LUTEAL"], + ]); + const [row] = computeSymptomPhasePatterns(days, phases); + expect(row.total).toBe(3); + expect(row.counts.LUTEAL).toBe(3); + }); +}); diff --git a/src/lib/cycle/symptom-phase.ts b/src/lib/cycle/symptom-phase.ts new file mode 100644 index 000000000..1dbd29393 --- /dev/null +++ b/src/lib/cycle/symptom-phase.ts @@ -0,0 +1,95 @@ +/** + * v1.15.0 — symptom-by-phase pattern aggregation. + * + * The cycle-NATIVE counterpart to the phase×vitals crosstab: for each logged + * symptom, where in the cycle does it cluster? Pure + DB-free — the gated + * `/api/cycle/insights` route does the reads and the gender gate, then hands the + * already-fetched per-day symptom keys + the phase-day map (built identically to + * the calendar grid) to this function. + * + * Honest, never causal: a symptom is surfaced only once it has been logged on at + * least `SYMPTOM_PHASE_MIN_DAYS` phase-labelled days, so a one-off entry never + * produces a "pattern". The output reports the raw per-phase counts and the + * dominant phase + its share, so the UI states "logged mostly in your luteal + * phase (7 of 9 days)" — an observation, not a diagnosis. + */ +import type { CyclePhase } from "@/lib/cycle/types"; + +/** A symptom must appear on at least this many phased days to surface a pattern. */ +export const SYMPTOM_PHASE_MIN_DAYS = 3; + +/** Cap the surfaced rows so the card stays scannable (most-logged first). */ +export const SYMPTOM_PHASE_MAX_ROWS = 10; + +const PHASES: CyclePhase[] = ["MENSTRUAL", "FOLLICULAR", "OVULATORY", "LUTEAL"]; + +/** One day's logged symptom keys (presence only — severity is not needed here). */ +export interface SymptomDay { + date: string; + keys: readonly string[]; +} + +export interface SymptomPhasePatternRow { + symptomKey: string; + /** Per-phase count of phase-labelled days this symptom was logged. */ + counts: Record; + /** Total phase-labelled days the symptom was logged (sum of `counts`). */ + total: number; + /** The phase the symptom clusters in most. */ + topPhase: CyclePhase; + /** topPhase count / total, in [0,1] — how concentrated the pattern is. */ + topShare: number; +} + +function emptyCounts(): Record { + return { MENSTRUAL: 0, FOLLICULAR: 0, OVULATORY: 0, LUTEAL: 0 }; +} + +/** + * Tally each symptom's occurrences by cycle phase across the window. Only days + * that carry a phase label count (a symptom logged outside any cycle window is + * ignored). Rows below the min-day floor are dropped; the rest sort by total + * desc (ties broken by key for determinism) and cap at `SYMPTOM_PHASE_MAX_ROWS`. + */ +export function computeSymptomPhasePatterns( + symptomDays: readonly SymptomDay[], + phaseByDay: ReadonlyMap, +): SymptomPhasePatternRow[] { + const byKey = new Map>(); + + for (const day of symptomDays) { + const phase = phaseByDay.get(day.date); + if (!phase) continue; + for (const key of day.keys) { + let counts = byKey.get(key); + if (!counts) { + counts = emptyCounts(); + byKey.set(key, counts); + } + counts[phase] += 1; + } + } + + const rows: SymptomPhasePatternRow[] = []; + for (const [symptomKey, counts] of byKey) { + const total = PHASES.reduce((s, p) => s + counts[p], 0); + if (total < SYMPTOM_PHASE_MIN_DAYS) continue; + // Dominant phase — deterministic tie-break by the fixed phase order. + let topPhase: CyclePhase = PHASES[0]; + for (const p of PHASES) { + if (counts[p] > counts[topPhase]) topPhase = p; + } + rows.push({ + symptomKey, + counts, + total, + topPhase, + topShare: counts[topPhase] / total, + }); + } + + rows.sort( + (a, b) => b.total - a.total || a.symptomKey.localeCompare(b.symptomKey), + ); + return rows.slice(0, SYMPTOM_PHASE_MAX_ROWS); +} diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index e8dfda118..fae3fca3b 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -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"),