From f5798e20d8a143c9412fab18aa48e8868c3562c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 14:43:50 +0200 Subject: [PATCH 01/12] fix(cycle): gate the calendar grid to a learning state during cold start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The predictions panel suppressed its fertile-window and ovulation rows while fewer than three cycles were observed, but the month calendar did not — so a brand-new user who logged a single period saw the grid paint a confident fertile-window shading, a predicted-ovulation dot, and full phase bands off a population 28/14 prior at ~0.20 confidence. Calendar-based fertility estimation is unreliable below several recorded cycles, so the grid was asserting days it could not know. Thread the same still-learning gate into the calendar build: while fewer than three cycles are observed, suppress the fertile window, the ovulation dot, and the phase band (the predicted-period bar stays, mirroring the panel). Surface a top-level stillLearning flag on the calendar response so the client renders a calm "learning your cycle" banner over the grid instead of a bare, falsely confident calendar. The flag is additive and back-compatible; older clients ignore it and degrade to the previous render. Also pin the observed-cycle count to a single source — the engine's post-exclusion value — so the response never carries two divergent counts. --- docs/api/openapi.yaml | 3 + messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/pl.json | 1 + src/app/api/cycle/calendar/route.ts | 12 ++- src/components/cycle/cycle-view.tsx | 41 ++++++++--- src/components/cycle/types.ts | 6 ++ .../cycle/__tests__/engine-adapter.test.ts | 73 +++++++++++++++++-- src/lib/cycle/engine-adapter.ts | 31 +++++++- src/lib/openapi/routes/cycle.ts | 6 ++ 13 files changed, 159 insertions(+), 19 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index d32fa3b1e..c5d57b9ca 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -6608,6 +6608,8 @@ components: anyOf: - $ref: "#/components/schemas/CyclePredictionDTO" - type: "null" + stillLearning: + type: boolean days: type: array items: @@ -6691,6 +6693,7 @@ components: required: - profile - prediction + - stillLearning - days - meta additionalProperties: false diff --git a/messages/de.json b/messages/de.json index 72d0e546e..4090d9ad3 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5824,6 +5824,7 @@ "nextMonth": "Nächster Monat", "loading": "Kalender wird geladen…", "empty": "Erfasse Daten, damit sich dein Kalender füllt.", + "learning": "Wir lernen deinen Zyklus – erfasse mindestens drei volle Zyklen, dann füllen sich fruchtbares Fenster, Eisprung und Phasen. Bis dahin zeigt der Kalender nur, was du erfasst hast.", "today": "Heute", "dayAria": "{date}", "legendSymptom": "Symptome", diff --git a/messages/en.json b/messages/en.json index 9b689ad2b..22b529a5b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5824,6 +5824,7 @@ "nextMonth": "Next month", "loading": "Loading your calendar…", "empty": "Start logging to see your calendar fill in.", + "learning": "Learning your cycle — log at least three full cycles and the fertile window, ovulation and phases fill in. Until then the calendar shows only what you've logged.", "today": "Today", "dayAria": "{date}", "legendSymptom": "Symptoms", diff --git a/messages/es.json b/messages/es.json index f020d3cd4..c9aa4fbdf 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5824,6 +5824,7 @@ "nextMonth": "Mes siguiente", "loading": "Cargando tu calendario…", "empty": "Empieza a registrar para ver tu calendario.", + "learning": "Estamos aprendiendo tu ciclo: registra al menos tres ciclos completos y se completarán la ventana fértil, la ovulación y las fases. Hasta entonces, el calendario solo muestra lo que has registrado.", "today": "Hoy", "dayAria": "{date}", "legendSymptom": "Síntomas", diff --git a/messages/fr.json b/messages/fr.json index 9f90ace3a..739e1af82 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5824,6 +5824,7 @@ "nextMonth": "Mois suivant", "loading": "Chargement de ton calendrier…", "empty": "Commence à enregistrer pour remplir ton calendrier.", + "learning": "Nous apprenons ton cycle — enregistre au moins trois cycles complets et la fenêtre fertile, l'ovulation et les phases se compléteront. D'ici là, le calendrier n'affiche que ce que tu as enregistré.", "today": "Aujourd'hui", "dayAria": "{date}", "legendSymptom": "Symptômes", diff --git a/messages/it.json b/messages/it.json index ebadb9ffe..183042f2e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5824,6 +5824,7 @@ "nextMonth": "Mese successivo", "loading": "Caricamento del calendario…", "empty": "Inizia a registrare per riempire il calendario.", + "learning": "Stiamo imparando il tuo ciclo: registra almeno tre cicli completi e si completeranno la finestra fertile, l'ovulazione e le fasi. Fino ad allora il calendario mostra solo ciò che hai registrato.", "today": "Oggi", "dayAria": "{date}", "legendSymptom": "Sintomi", diff --git a/messages/pl.json b/messages/pl.json index 92f351f3b..ed11af429 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5824,6 +5824,7 @@ "nextMonth": "Następny miesiąc", "loading": "Ładowanie kalendarza…", "empty": "Zacznij zapisywać, aby wypełnić kalendarz.", + "learning": "Uczymy się Twojego cyklu — zapisz co najmniej trzy pełne cykle, a okno płodne, owulacja i fazy się uzupełnią. Do tego czasu kalendarz pokazuje tylko to, co zapisałaś.", "today": "Dziś", "dayAria": "{date}", "legendSymptom": "Objawy", diff --git a/src/app/api/cycle/calendar/route.ts b/src/app/api/cycle/calendar/route.ts index e4bfa86c2..40b1b6f1c 100644 --- a/src/app/api/cycle/calendar/route.ts +++ b/src/app/api/cycle/calendar/route.ts @@ -137,7 +137,7 @@ export const GET = apiHandler(async (request: NextRequest) => { const goalAllowsFertile = goalAllowsFertileWindow(profile.goal); - const { prediction, days } = buildCalendar( + const { prediction, stillLearning, days } = buildCalendar( profile, cycles, dayLogs, @@ -179,11 +179,21 @@ export const GET = apiHandler(async (request: NextRequest) => { goal: profile.goal, rawChartMode: profile.rawChartMode, predictionEnabled: profile.predictionEnabled, + // Single source of truth for the observed-cycle count: the engine's + // post-exclusion `cyclesObserved` (the same number the prediction and the + // `stillLearning` gate key off). The raw `cycles.length` fallback only + // applies when no prediction ran (raw-chart mode / prediction disabled), + // so a consumer never sees two divergent "cycles observed" values. cyclesObserved: prediction?.cyclesObserved ?? cycles.length, }, prediction: prediction ? toCyclePredictionDTO(prediction, goalAllowsFertile, disclaimer) : null, + // Top-level cold-start flag (mirrors `prediction.stillLearning`): the + // calendar grid has suppressed fertile/ovulation/phase output, so the + // client renders a calm "learning your cycle" banner over the grid. Additive + // + back-compatible — older iOS builds ignore the field. + stillLearning, days, meta: { generatedAt }, }); diff --git a/src/components/cycle/cycle-view.tsx b/src/components/cycle/cycle-view.tsx index 26bf834e7..a13c8fac2 100644 --- a/src/components/cycle/cycle-view.tsx +++ b/src/components/cycle/cycle-view.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from "react"; import { useSearchParams } from "next/navigation"; -import { Loader2, Plus, RefreshCw } from "lucide-react"; +import { Loader2, Plus, RefreshCw, Sparkles } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; @@ -274,16 +274,35 @@ export function CycleView() { ) : calendarError ? ( void calendar.refetch()} /> ) : ( - + <> + {/* Cold start: the grid carries no fertile/ovulation/phase + overlays yet (the server suppresses them while learning), + so name the calm state rather than letting a bare grid + imply a confident forecast. */} + {calendar.data?.stillLearning ? ( +
+
+ ) : null} + + )} diff --git a/src/components/cycle/types.ts b/src/components/cycle/types.ts index 25fbf63f7..be791ffe5 100644 --- a/src/components/cycle/types.ts +++ b/src/components/cycle/types.ts @@ -68,6 +68,12 @@ export interface CalendarResponse { cyclesObserved: number; }; prediction: CyclePrediction | null; + /** + * Cold-start gate (mirrors `prediction.stillLearning`): true while < 3 cycles + * are observed. When set, `days` carries no fertile window, ovulation dot, or + * phase band — render the calm "learning your cycle" state over the grid. + */ + stillLearning: boolean; days: CalendarDay[]; meta: { generatedAt: string }; } diff --git a/src/lib/cycle/__tests__/engine-adapter.test.ts b/src/lib/cycle/__tests__/engine-adapter.test.ts index 900a3031d..6e2db3080 100644 --- a/src/lib/cycle/__tests__/engine-adapter.test.ts +++ b/src/lib/cycle/__tests__/engine-adapter.test.ts @@ -96,15 +96,22 @@ describe("buildCalendar", () => { expect(days.every((d) => !d.isPredictedOvulation)).toBe(true); }); - it("surfaces the fertile window for the conception goal", () => { + it("surfaces the fertile window for the conception goal once past cold start", () => { + // Four starts → three completed cycles → not still-learning. + const fourCycles = [ + cycle("2026-01-01", { endDate: "2026-01-29", periodEndDate: "2026-01-05" }), + cycle("2026-01-29", { endDate: "2026-02-26", periodEndDate: "2026-02-02" }), + cycle("2026-02-26", { endDate: "2026-03-26", periodEndDate: "2026-03-02" }), + cycle("2026-03-26", { periodEndDate: "2026-03-30" }), + ]; const { days } = buildCalendar( profile({ goal: "TRYING_TO_CONCEIVE" }), - cycles, + fourCycles, [], [], - "2026-02-20", - "2026-04-15", - "2026-03-10", + "2026-03-20", + "2026-05-15", + "2026-04-10", true, ); expect(days.some((d) => d.isFertileWindow)).toBe(true); @@ -123,4 +130,60 @@ describe("buildCalendar", () => { ); expect(prediction).toBeNull(); }); + + describe("still-learning gate (cold start)", () => { + // One logged period — zero completed cycles → priors-only forecast. + const oneCycle = [cycle("2026-02-26", { periodEndDate: "2026-03-02" })]; + + it("flags stillLearning and asserts no fertile/ovulation grid for ≤1 cycle (TTC)", () => { + const { prediction, stillLearning, days } = buildCalendar( + profile({ goal: "TRYING_TO_CONCEIVE" }), + oneCycle, + [], + [], + "2026-02-20", + "2026-04-15", + "2026-03-10", + true, // goal allows fertile — only the learning gate must suppress it + ); + expect(prediction).not.toBeNull(); + expect(prediction!.cyclesObserved).toBeLessThan(3); + expect(prediction!.stillLearning).toBe(true); + expect(stillLearning).toBe(true); + + // No confident fertile shading or ovulation dot from a population guess. + expect(days.every((d) => !d.isFertileWindow)).toBe(true); + expect(days.every((d) => !d.isPredictedOvulation)).toBe(true); + // No asserted phase band either (population-28 frame is not yet earned). + expect(days.every((d) => d.phase === null)).toBe(true); + // The predicted next-period bar still shows (the panel shows it too). + expect(days.some((d) => d.isPredictedPeriod)).toBe(true); + }); + + it("emits normal fertile/ovulation/phase output once ≥3 cycles are observed", () => { + // Four ~28-day starts → three COMPLETED cycle lengths → cyclesObserved=3. + const fourCycles = [ + cycle("2026-01-01", { endDate: "2026-01-29", periodEndDate: "2026-01-05" }), + cycle("2026-01-29", { endDate: "2026-02-26", periodEndDate: "2026-02-02" }), + cycle("2026-02-26", { endDate: "2026-03-26", periodEndDate: "2026-03-02" }), + cycle("2026-03-26", { periodEndDate: "2026-03-30" }), + ]; + const { prediction, stillLearning, days } = buildCalendar( + profile({ goal: "TRYING_TO_CONCEIVE" }), + fourCycles, + [], + [], + "2026-03-20", + "2026-05-15", + "2026-04-10", + true, + ); + expect(prediction!.cyclesObserved).toBeGreaterThanOrEqual(3); + expect(prediction!.stillLearning).toBe(false); + expect(stillLearning).toBe(false); + expect(days.some((d) => d.isFertileWindow)).toBe(true); + expect(days.some((d) => d.isPredictedOvulation)).toBe(true); + expect(days.some((d) => d.phase !== null)).toBe(true); + }); + }); }); diff --git a/src/lib/cycle/engine-adapter.ts b/src/lib/cycle/engine-adapter.ts index cc7f33e7b..20c11f2d2 100644 --- a/src/lib/cycle/engine-adapter.ts +++ b/src/lib/cycle/engine-adapter.ts @@ -214,6 +214,15 @@ export function buildPhaseDayMap( export interface CalendarBuildResult { prediction: CyclePredictionResult | null; + /** + * True while the engine is "still learning" the user's cycle (< 3 observed + * cycles, mirrors `prediction.stillLearning`). When set, the calendar grid + * does NOT assert a fertile window, an ovulation dot, or a population-framed + * phase band — those are population guesses the app has not yet earned the + * confidence to show as fact. The client renders a calm "learning your cycle" + * state instead. False when no prediction ran (raw-chart mode / disabled). + */ + stillLearning: boolean; days: CalendarDayDTO[]; } @@ -226,6 +235,15 @@ export interface CalendarBuildResult { * (GENERAL_HEALTH / PERIMENOPAUSE) to suppress `isFertileWindow` + * `isPredictedOvulation` at the grid level (the prediction's own window * fields are nulled upstream by the engine for those goals). + * + * Cold-start honesty (C1): while the engine reports `stillLearning` (< 3 + * observed cycles), the grid suppresses the fertile window, the predicted- + * ovulation dot, AND the phase band — all of which would otherwise be painted + * from a population 28/14 prior at ~0.20 confidence. The predicted-period bar + * is kept (the predictions panel shows it too while learning). This matches + * the `stillLearning` gate the predictions panel already applies, so the + * calendar grid never asserts "these are your fertile days" off a single + * logged cycle. */ export function buildCalendar( profile: CycleProfile, @@ -254,6 +272,11 @@ export function buildCalendar( ); } + // Cold-start gate: until ≥3 cycles are observed the engine's fertile/ + // ovulation/phase output rests on a population prior, so the calendar must + // present it as a calm "learning" state rather than asserting it as fact. + const stillLearning = prediction?.stillLearning ?? false; + const phaseCycles = buildPhaseCycles( cycles, prediction?.nextPeriodStart ?? null, @@ -285,6 +308,7 @@ export function buildCalendar( isWithin(date, predictedPeriodStart, predictedPeriodEnd); const isFertileWindow = + !stillLearning && goalAllowsFertile && prediction?.fertileWindowStart != null && prediction.fertileWindowEnd != null && @@ -295,13 +319,16 @@ export function buildCalendar( ); const isPredictedOvulation = + !stillLearning && goalAllowsFertile && prediction?.predictedOvulation != null && prediction.predictedOvulation === date; days.push({ date, - phase: phaseAcross(date, phaseCycles), + // No asserted phase band while learning — a population-28 frame off a + // single cycle is not yet earned (Lower: single-cycle phase band). + phase: stillLearning ? null : phaseAcross(date, phaseCycles), isPredictedPeriod, isFertileWindow, isPredictedOvulation, @@ -315,5 +342,5 @@ export function buildCalendar( }); } - return { prediction, days }; + return { prediction, stillLearning, days }; } diff --git a/src/lib/openapi/routes/cycle.ts b/src/lib/openapi/routes/cycle.ts index 418424c93..a2663c885 100644 --- a/src/lib/openapi/routes/cycle.ts +++ b/src/lib/openapi/routes/cycle.ts @@ -205,6 +205,12 @@ const cycleCalendarResponse = z.object({ cyclesObserved: z.number().int(), }), prediction: cyclePredictionDto.nullable(), + // Cold-start gate (mirrors `prediction.stillLearning`): true while < 3 cycles + // are observed. When set, the `days` grid carries no fertile window, no + // predicted-ovulation dot, and no phase band (those would rest on a + // population prior) — the client shows a calm "learning your cycle" state. + // Additive + back-compatible. + stillLearning: z.boolean(), days: z.array(cycleCalendarDayDto), meta: z.object({ generatedAt: z.iso.datetime({ offset: true }) }), }); From df67b604202c42ab49ae246e080512b8b5dfd643 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 14:43:57 +0200 Subject: [PATCH 02/12] fix(cycle): raise the hard cycle-length ceiling so long cycles survive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hard upper bound clamped cycle lengths at 45 days, force-excluding any longer cycle as an outlier before the robust statistics ran. Cycles over 35 days are oligomenorrhea but still real, and adolescent and perimenopausal users routinely run to fifty days or more — exactly the irregular users for whom an honest estimate matters most. Clamping their genuine cycles biased the estimate toward the population middle. Raise the ceiling to 60 days. It stays a coarse gross-error backstop; the median-plus-MAD fence and the missed-log heuristic still do the real outlier work, and because the fence is computed before exclusion, raising the ceiling does not move it — it only stops the hard rule from pre-empting the fence on long but consistent cycles. --- src/lib/cycle/__tests__/prediction.test.ts | 11 +++++++++++ src/lib/cycle/types.ts | 18 ++++++++++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/lib/cycle/__tests__/prediction.test.ts b/src/lib/cycle/__tests__/prediction.test.ts index 019daff4d..acd39024d 100644 --- a/src/lib/cycle/__tests__/prediction.test.ts +++ b/src/lib/cycle/__tests__/prediction.test.ts @@ -88,6 +88,17 @@ describe("cycle/prediction — cycle-length estimator (§1, §5)", () => { expect(est.lengthRounded).toBe(28); }); + it("keeps a legitimate long (oligomenorrhea) cycle inside the hard bound", () => { + // A run of long-but-real cycles (ACOG: > 35 d is oligomenorrhea, still a + // real cycle; perimenopause routinely runs longer). A 52-day length must + // not be force-excluded by the hard ceiling — only the MAD fence / missed- + // log heuristic should ever drop it. Here the whole run is long, so the + // robust median sits near 50 and 52 is well inside the fence. + const est = estimateCycleLength([48, 50, 52, 49, 51], "PERIMENOPAUSE"); + expect(est.cyclesObserved).toBe(5); // none clipped by HARD_CYCLE_MAX + expect(est.lengthRounded).toBe(50); + }); + it("perimenopause widens the fence (OUTLIER_K 4 keeps more)", () => { const lengths = [28, 30, 26, 40, 24, 32]; const normal = estimateCycleLength(lengths, "TRYING_TO_CONCEIVE"); diff --git a/src/lib/cycle/types.ts b/src/lib/cycle/types.ts index cab9114ee..425cd6e53 100644 --- a/src/lib/cycle/types.ts +++ b/src/lib/cycle/types.ts @@ -105,9 +105,23 @@ export const FERTILE_POST = 1; export const OUTLIER_K = 3.0; export const OUTLIER_K_PERIMENOPAUSE = 4.0; -/** §5 — hard physiological cycle-length bounds (always outlier candidates). */ +/** + * §5 — hard physiological cycle-length bounds (always outlier candidates). + * + * The ceiling is a deliberately generous outlier *backstop*, not a clinical + * "normal" cap — the robust MAD fence + missed-log heuristic do the real + * outlier work. ACOG flags cycles > 35 d as oligomenorrhea, but such cycles are + * still genuine, and adolescents / perimenopausal users routinely run to ~50+ d. + * A hard cap of 45 force-excluded those legitimate long cycles, biasing the + * estimate toward the population middle for exactly the irregular users who most + * need an honest estimate. 60 d keeps the rare gross-error guard (a length this + * long is almost always a missed log, which the missed-log rule also catches) + * while letting real long cycles reach the MAD fence. The MAD fence is + * computed pre-exclusion, so raising the ceiling does not move the fence — it + * only stops the hard rule from pre-empting it on long but consistent cycles. + */ export const HARD_CYCLE_MIN = 21; -export const HARD_CYCLE_MAX = 45; +export const HARD_CYCLE_MAX = 60; /** §5 — a single length ≥ this × median is treated as a probable missed-log. */ export const MISSED_LOG_FACTOR = 1.75; From d98711a2460910b9b70eaed99f696bf03420b6fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 14:51:03 +0200 Subject: [PATCH 03/12] feat(cycle): add both Sensiplan temperature exception rules The BBT shift detector only applied the strict 3-over-6 rule (3 readings above the cover line, the 3rd >=0.2C above), so it under-detected ovulation on common ovulatory patterns. Add the two published Ausnahmeregeln: - 1. Ausnahmeregel (slow/staircase rise): when the 3rd high reading is above the cover line but not >=0.2C above, a 4th reading that need only be above the line confirms the shift. - 2. Ausnahmeregel (one value falls back): when exactly one of the three readings falls back to/below the cover line, it is discounted and a 4th reading that again clears 0.2C above the line confirms. The result now carries a `rule` (0|1|2) provenance field and the `evaluationCompleteDate` (evening of the 3rd or 4th high reading). The rules follow the Arbeitsgruppe NFP / Raith-Paula & Frank-Herrmann standard and are mutually exclusive by construction. --- src/lib/cycle/__tests__/prediction.test.ts | 63 +++++++++++++++ src/lib/cycle/prediction.ts | 91 ++++++++++++++++++---- 2 files changed, 141 insertions(+), 13 deletions(-) diff --git a/src/lib/cycle/__tests__/prediction.test.ts b/src/lib/cycle/__tests__/prediction.test.ts index acd39024d..af0ca883e 100644 --- a/src/lib/cycle/__tests__/prediction.test.ts +++ b/src/lib/cycle/__tests__/prediction.test.ts @@ -325,6 +325,69 @@ describe("cycle/prediction — symptothermal confirmation (§4.2)", () => { expect(detectTempShift(logs, 0.2)).toBeNull(); }); + it("detectTempShift rule 0: the regular rule reports rule:0 + evaluation on the 3rd day", () => { + const logs: DayLogInput[] = []; + const start = "2024-01-01"; + [36.4, 36.42, 36.38, 36.41, 36.4, 36.39].forEach((t, i) => + logs.push(bbtDay(addDays(start, i), t)), + ); + logs.push(bbtDay(addDays(start, 6), 36.55)); + logs.push(bbtDay(addDays(start, 7), 36.6)); + logs.push(bbtDay(addDays(start, 8), 36.62)); // 3rd clears 0.2 over 36.42 + + const shift = detectTempShift(logs, 0.2); + expect(shift?.rule).toBe(0); + expect(shift?.ovulationDate).toBe(addDays(start, 5)); + expect(shift?.evaluationCompleteDate).toBe(addDays(start, 8)); + }); + + it("detectTempShift 1. Ausnahmeregel: slow/staircase rise confirms on the 4th reading", () => { + // 1. Ausnahmeregel: 3rd high reading is above the line but NOT 0.2°C above; + // a 4th reading above the line confirms (need only be above the line). + const logs: DayLogInput[] = []; + const start = "2024-01-01"; + [36.4, 36.42, 36.38, 36.41, 36.4, 36.39].forEach((t, i) => + logs.push(bbtDay(addDays(start, i), t)), + ); + // cover line = 36.42. Staircase: all above the line, 3rd only +0.1. + logs.push(bbtDay(addDays(start, 6), 36.45)); + logs.push(bbtDay(addDays(start, 7), 36.49)); + logs.push(bbtDay(addDays(start, 8), 36.52)); // +0.1 → regular rule fails + // Without a 4th reading the strict + 1. rule cannot confirm. + expect(detectTempShift([...logs], 0.2)).toBeNull(); + // 4th reading merely above the line (no 0.2 requirement) → confirmed by rule 1. + logs.push(bbtDay(addDays(start, 9), 36.5)); + const shift = detectTempShift(logs, 0.2); + expect(shift?.rule).toBe(1); + expect(shift?.ovulationDate).toBe(addDays(start, 5)); + expect(shift?.evaluationCompleteDate).toBe(addDays(start, 9)); + }); + + it("detectTempShift 2. Ausnahmeregel: a single fall-back day confirms on a 4th ≥0.2°C reading", () => { + // 2. Ausnahmeregel: one of the 3 readings falls back to/below the line; it + // is discounted and a 4th reading is required that again clears 0.2°C. + const logs: DayLogInput[] = []; + const start = "2024-01-01"; + [36.4, 36.42, 36.38, 36.41, 36.4, 36.39].forEach((t, i) => + logs.push(bbtDay(addDays(start, i), t)), + ); + // cover line = 36.42. 1st + 2nd above, 3rd falls back below the line. + logs.push(bbtDay(addDays(start, 6), 36.6)); + logs.push(bbtDay(addDays(start, 7), 36.62)); + logs.push(bbtDay(addDays(start, 8), 36.4)); // falls back below 36.42 + // Without a 4th reading nothing confirms. + expect(detectTempShift([...logs], 0.2)).toBeNull(); + // 4th reading that does NOT clear 0.2 must not confirm. + const tooLow = [...logs, bbtDay(addDays(start, 9), 36.5)]; // +0.08 only + expect(detectTempShift(tooLow, 0.2)).toBeNull(); + // 4th reading ≥0.2°C above the line → confirmed by rule 2. + logs.push(bbtDay(addDays(start, 9), 36.63)); // +0.21 over 36.42 + const shift = detectTempShift(logs, 0.2); + expect(shift?.rule).toBe(2); + expect(shift?.ovulationDate).toBe(addDays(start, 5)); + expect(shift?.evaluationCompleteDate).toBe(addDays(start, 9)); + }); + it("confirmSymptothermal requires temp-shift and mucus-peak within ±2 days", () => { const logs: DayLogInput[] = []; const start = "2024-01-01"; diff --git a/src/lib/cycle/prediction.ts b/src/lib/cycle/prediction.ts index 3727c2914..c6ae70c05 100644 --- a/src/lib/cycle/prediction.ts +++ b/src/lib/cycle/prediction.ts @@ -250,17 +250,41 @@ export function estimatePeriodLength( /* §4 — symptothermal + temperature-trend ovulation detection */ /* ------------------------------------------------------------------ */ +/** Which sensiplan temperature rule confirmed the shift (provenance). */ +export type TempShiftRule = 0 | 1 | 2; + interface TempShiftResult { /** Confirmed ovulation day = day before the first of the 3 elevated readings. */ ovulationDate: string; + /** + * Which rule confirmed the rise: 0 = the regular 3-over-6 rule (3rd reading + * ≥0.2°C above the cover line), 1 = the 1. Ausnahmeregel (slow/staircase rise, + * 4th reading required), 2 = the 2. Ausnahmeregel (one of the 3 falls back to + * the line, 4th reading required ≥0.2°C above). + */ + rule: TempShiftRule; + /** Day the evaluation completed (evening of the 3rd or 4th high reading). */ + evaluationCompleteDate: string; } /** - * §4.2(a) — sensiplan 3-over-6 BBT rule on manual basal temperatures. - * The first day of 3 consecutive temps each strictly above the highest of the - * immediately preceding 6 temps, AND the 3rd is >= 0.2°C above that 6-day max. - * Ovulation = the last low-temp day before the rise (the day BEFORE the first - * elevated reading). + * §4.2(a) — sensiplan 3-over-6 BBT rule on manual basal temperatures, with both + * published Ausnahmeregeln (exception rules). + * + * Cover line (Hüllkurve) = the highest of the 6 measured low values immediately + * preceding the rise. Ovulation = the last low day before the rise (the day + * BEFORE the first elevated reading). + * + * Rules (Arbeitsgruppe NFP / Raith-Paula & Frank-Herrmann; myNFP "Temperatur­kurve + * auswerten"; Generation-Pille "Die wichtigsten NFP-Regeln"): + * - **Regular rule (0):** 3 consecutive readings strictly above the cover line, + * the 3rd ≥0.2°C above it. Evaluation completes on the 3rd high day. + * - **1. Ausnahmeregel (1):** if the 3rd reading is not ≥0.2°C above the line, + * await a 4th reading which need only be above the line. Completes on the 4th. + * - **2. Ausnahmeregel (2):** if exactly one of the 3 readings falls back to/below + * the line, it is discounted and a 4th reading is required which must again be + * ≥0.2°C above the line. Completes on the 4th. + * The two exception rules are mutually exclusive by construction. */ export function detectTempShift( dayLogs: readonly DayLogInput[], @@ -274,17 +298,58 @@ export function detectTempShift( // Need 6 baseline + 3 elevated = 9 readings minimum. Scan from the END and // return the LATEST qualifying shift — for a multi-cycle BBT series the most // recent rise is the one that belongs to the current cycle; the earliest - // match would confirm a months-old ovulation (QA: window scoping). + // match would confirm a months-old ovulation (QA: window scoping). When a 4th + // reading is needed (exception rules) the candidate's start index may run to + // temps.length - 4; the loop starts there and skips candidates without enough + // following readings for the rule that fires. for (let i = temps.length - 3; i >= 6; i--) { const baseline = temps.slice(i - 6, i); const sixMax = Math.max(...baseline.map((x) => x.t)); - const rise = [temps[i], temps[i + 1], temps[i + 2]]; - const allAbove = rise.every((r) => r.t > sixMax); - // The 3rd elevated reading must clear the 6-day max by the threshold. - const thirdClears = roundHalf(rise[2].t - sixMax, 2) >= thresholdC; - if (allAbove && thirdClears) { - // Ovulation = day before the first elevated reading. - return { ovulationDate: addDays(rise[0].date, -1) }; + const r1 = temps[i]; + const r2 = temps[i + 1]; + const r3 = temps[i + 2]; + const r4 = temps[i + 3]; // may be undefined (no 4th reading available) + + const above = (t: number) => t > sixMax; + const clears = (t: number) => roundHalf(t - sixMax, 2) >= thresholdC; + + // Regular rule (0): 3 above, 3rd clears the threshold. + if (above(r1.t) && above(r2.t) && above(r3.t) && clears(r3.t)) { + return { + ovulationDate: addDays(r1.date, -1), + rule: 0, + evaluationCompleteDate: r3.date, + }; + } + + // 1. Ausnahmeregel (1): all 3 above the line but the 3rd does not clear + // 0.2°C → require a 4th reading that is merely above the line. + if ( + above(r1.t) && + above(r2.t) && + above(r3.t) && + !clears(r3.t) && + r4 != null && + above(r4.t) + ) { + return { + ovulationDate: addDays(r1.date, -1), + rule: 1, + evaluationCompleteDate: r4.date, + }; + } + + // 2. Ausnahmeregel (2): the 1st is above the line and EXACTLY ONE of the + // 2nd/3rd falls back to/below the line → that value is discounted and a 4th + // reading is required which must again clear 0.2°C above the line. (The 1st + // high measurement itself must stay above the line — it anchors the rise.) + const oneFellBack = above(r1.t) && above(r2.t) !== above(r3.t); + if (oneFellBack && r4 != null && above(r4.t) && clears(r4.t)) { + return { + ovulationDate: addDays(r1.date, -1), + rule: 2, + evaluationCompleteDate: r4.date, + }; } } return null; From cd55c60faecf2b9c91fa3092ca2246e8a11c6cdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 14:53:24 +0200 Subject: [PATCH 04/12] feat(cycle): confirm the mucus peak with the Sensiplan post-peak count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mucus peak was taken as the last egg-white/watery day with no confirmation, so a stray late fertile-mucus entry corrupted the peak. Implement the Sensiplan Höhepunkt rule: the peak day is the LAST best-quality (egg-white/watery) day that is followed by at least 3 consecutive drier observed days, each strictly lower quality. The peak is only confirmable retrospectively, after those 3 days, so a stray late egg-white entry not yet followed by 3 drier days cannot move an already-confirmed peak. Quality is scored on the t/f/S/+S ladder; unobserved days are skipped so a logging gap does not break the count. Citation: Arbeitsgruppe NFP / Raith-Paula & Frank-Herrmann; myNFP "Zervixschleim beobachten". --- src/lib/cycle/__tests__/prediction.test.ts | 98 +++++++++++++++++++--- src/lib/cycle/prediction.ts | 70 +++++++++++++--- 2 files changed, 147 insertions(+), 21 deletions(-) diff --git a/src/lib/cycle/__tests__/prediction.test.ts b/src/lib/cycle/__tests__/prediction.test.ts index af0ca883e..47768d805 100644 --- a/src/lib/cycle/__tests__/prediction.test.ts +++ b/src/lib/cycle/__tests__/prediction.test.ts @@ -4,6 +4,7 @@ import { addDays } from "../day-math"; import { clampLuteal, confirmSymptothermal, + detectMucusPeak, detectTempShift, detectTemperatureTrend, estimateCycleLength, @@ -388,6 +389,67 @@ describe("cycle/prediction — symptothermal confirmation (§4.2)", () => { expect(shift?.evaluationCompleteDate).toBe(addDays(start, 9)); }); + it("detectMucusPeak: confirms the last best-quality day followed by 3 drier days", () => { + const m = (date: string, q: DayLogInput["cervicalMucus"]): DayLogInput => ({ + date, + flow: null, + basalBodyTempC: null, + ovulationTest: null, + cervicalMucus: q, + }); + const start = "2024-01-01"; + const logs = [ + m(addDays(start, 0), "STICKY"), + m(addDays(start, 1), "CREAMY"), + m(addDays(start, 2), "EGG_WHITE"), // peak candidate + m(addDays(start, 3), "CREAMY"), + m(addDays(start, 4), "STICKY"), + m(addDays(start, 5), "DRY"), + ]; + expect(detectMucusPeak(logs)).toBe(addDays(start, 2)); + }); + + it("detectMucusPeak: returns null until 3 drier days follow the best-quality day", () => { + const m = (date: string, q: DayLogInput["cervicalMucus"]): DayLogInput => ({ + date, + flow: null, + basalBodyTempC: null, + ovulationTest: null, + cervicalMucus: q, + }); + const start = "2024-01-01"; + // egg-white then only 2 drier observed days → not yet confirmable. + const logs = [ + m(addDays(start, 0), "EGG_WHITE"), + m(addDays(start, 1), "CREAMY"), + m(addDays(start, 2), "DRY"), + ]; + expect(detectMucusPeak(logs)).toBeNull(); + }); + + it("detectMucusPeak: a stray late egg-white entry no longer moves a confirmed peak", () => { + const m = (date: string, q: DayLogInput["cervicalMucus"]): DayLogInput => ({ + date, + flow: null, + basalBodyTempC: null, + ovulationTest: null, + cervicalMucus: q, + }); + const start = "2024-01-01"; + const logs = [ + m(addDays(start, 0), "EGG_WHITE"), // true peak + m(addDays(start, 1), "CREAMY"), + m(addDays(start, 2), "STICKY"), + m(addDays(start, 3), "DRY"), + // a stray late egg-white with NO 3 drier days after it (only 1 logged + // day follows) — must not be confirmed as a new peak. + m(addDays(start, 8), "EGG_WHITE"), + m(addDays(start, 9), "DRY"), + ]; + // The confirmed peak stays on the true (early) egg-white day, not the stray. + expect(detectMucusPeak(logs)).toBe(addDays(start, 0)); + }); + it("confirmSymptothermal requires temp-shift and mucus-peak within ±2 days", () => { const logs: DayLogInput[] = []; const start = "2024-01-01"; @@ -397,14 +459,19 @@ describe("cycle/prediction — symptothermal confirmation (§4.2)", () => { logs.push(bbtDay(addDays(start, 6), 36.55)); logs.push(bbtDay(addDays(start, 7), 36.6)); logs.push(bbtDay(addDays(start, 8), 36.62)); - // temp ovulation = 2024-01-06. Add a mucus peak (EGG_WHITE) on 2024-01-06. - logs.push({ - date: "2024-01-06", + // temp ovulation = 2024-01-06. Add a mucus peak (EGG_WHITE) on 2024-01-06, + // confirmed by 3 drier days after it (Sensiplan post-peak count). + const mucusDay = (date: string, m: DayLogInput["cervicalMucus"]): DayLogInput => ({ + date, flow: null, basalBodyTempC: null, ovulationTest: null, - cervicalMucus: "EGG_WHITE", + cervicalMucus: m, }); + logs.push(mucusDay("2024-01-06", "EGG_WHITE")); + logs.push(mucusDay("2024-01-07", "STICKY")); + logs.push(mucusDay("2024-01-08", "DRY")); + logs.push(mucusDay("2024-01-09", "DRY")); expect(confirmSymptothermal(logs)).toBe("2024-01-06"); }); @@ -425,14 +492,18 @@ describe("cycle/prediction — symptothermal confirmation (§4.2)", () => { logs.push(bbtDay(riseStart, 36.6)); logs.push(bbtDay(addDays(riseStart, 1), 36.62)); logs.push(bbtDay(addDays(riseStart, 2), 36.63)); - // mucus peak agreeing with the temp ovulation. - logs.push({ - date: ovDay, + // mucus peak agreeing with the temp ovulation, confirmed by 3 drier days. + const mucusDay = (date: string, m: DayLogInput["cervicalMucus"]): DayLogInput => ({ + date, flow: null, basalBodyTempC: null, ovulationTest: null, - cervicalMucus: "EGG_WHITE", + cervicalMucus: m, }); + logs.push(mucusDay(ovDay, "EGG_WHITE")); + logs.push(mucusDay(addDays(ovDay, 1), "STICKY")); + logs.push(mucusDay(addDays(ovDay, 2), "DRY")); + logs.push(mucusDay(addDays(ovDay, 3), "DRY")); const result = predictCycle(cycles, logs, BASE_PROFILE, addDays(lastStart, 16)); expect(result.ovulationConfirmed).toBe(true); @@ -494,13 +565,18 @@ describe("cycle/prediction — multi-cycle window scoping (QA HIGH)", () => { logs.push(bbtDay(riseStart, 36.6)); logs.push(bbtDay(addDays(riseStart, 1), 36.62)); logs.push(bbtDay(addDays(riseStart, 2), 36.63)); - logs.push({ - date: ovDay, + const mucusDay = (date: string, m: DayLogInput["cervicalMucus"]): DayLogInput => ({ + date, flow: null, basalBodyTempC: null, ovulationTest: null, - cervicalMucus: "EGG_WHITE", + cervicalMucus: m, }); + // Peak on ovDay confirmed by 3 drier observed days (Sensiplan post-peak count). + logs.push(mucusDay(ovDay, "EGG_WHITE")); + logs.push(mucusDay(addDays(ovDay, 1), "STICKY")); + logs.push(mucusDay(addDays(ovDay, 2), "DRY")); + logs.push(mucusDay(addDays(ovDay, 3), "DRY")); return logs; } diff --git a/src/lib/cycle/prediction.ts b/src/lib/cycle/prediction.ts index c6ae70c05..abc908b32 100644 --- a/src/lib/cycle/prediction.ts +++ b/src/lib/cycle/prediction.ts @@ -356,20 +356,70 @@ export function detectTempShift( } /** - * §4.2(b) — cervical-mucus peak: the LAST day of egg-white/watery mucus. - * Infertile phase confirmed on peak+3; ovulation is taken at the peak day for - * the agreement check. + * Mucus quality magnitude on the Sensiplan t/f/S/+S ladder (0 = driest, + * 4 = best/peak quality). EGG_WHITE/WATERY are the peak-quality (+S) classes; + * STICKY/CREAMY are lower fertile-mucus, DRY is t. A null reading is not a + * "drier day" — it is an unobserved day and does not count toward the post-peak + * run (the run is over observed mucus days only). + */ +function mucusQuality(m: DayLogInput["cervicalMucus"]): number { + switch (m) { + case "EGG_WHITE": + return 4; + case "WATERY": + return 3; + case "CREAMY": + return 2; + case "STICKY": + return 1; + case "DRY": + return 0; + default: + return -1; // not observed + } +} + +/** Peak-quality mucus = the best (+S) classes: egg-white / watery. */ +const MUCUS_PEAK_QUALITY = 3; + +/** + * §4.2(b) — sensiplan mucus peak (Höhepunkt). The peak day is the LAST day of + * best-quality (egg-white / watery / spinnbar) mucus that is FOLLOWED by at + * least 3 consecutive drier observed days (each strictly lower quality than the + * peak). The peak is only confirmable retrospectively, after those 3 days — so a + * stray late egg-white entry that is NOT yet followed by 3 drier days cannot + * move an already-confirmed peak. + * + * Returns the confirmed peak day, or null when no best-quality day has yet been + * followed by 3 drier observed days. + * + * Citation: "Höhepunkt = the last day of best-quality (S+) mucus; evaluation + * completes on the evening of the 3rd day after the change to poorer quality." + * Arbeitsgruppe NFP / Raith-Paula & Frank-Herrmann; myNFP "Zervixschleim + * beobachten". */ export function detectMucusPeak( dayLogs: readonly DayLogInput[], ): string | null { - const peakDays = dayLogs - .filter( - (l) => l.cervicalMucus === "EGG_WHITE" || l.cervicalMucus === "WATERY", - ) - .map((l) => l.date) - .sort((a, b) => dayDiff(a, b)); - return peakDays.length > 0 ? peakDays[peakDays.length - 1] : null; + // Observed mucus days only, oldest→newest. Unobserved days are skipped so a + // gap in logging doesn't break the 3-drier-day post-peak run. + const observed = dayLogs + .filter((l) => mucusQuality(l.cervicalMucus) >= 0) + .map((l) => ({ date: l.date, q: mucusQuality(l.cervicalMucus) })) + .sort((a, b) => dayDiff(a.date, b.date)); + + // Walk every best-quality day; a candidate peak is confirmed iff the next 3 + // observed days are all strictly lower quality. Scan forward and keep the + // LATEST confirmed peak (a true later peak supersedes an earlier one). + let confirmedPeak: string | null = null; + for (let i = 0; i < observed.length; i++) { + if (observed[i].q < MUCUS_PEAK_QUALITY) continue; + const following = observed.slice(i + 1, i + 4); + if (following.length < 3) continue; // not yet evaluable + const allDrier = following.every((d) => d.q < observed[i].q); + if (allDrier) confirmedPeak = observed[i].date; + } + return confirmedPeak; } /** From 4bfe9f11d7490c878d1d6aa99e6188a866364df2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 15:04:25 +0200 Subject: [PATCH 05/12] feat(cycle): exclude disturbed temperatures from the symptothermal engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fever, illness, alcohol or a late/short-sleep reading can fake or mask a temperature rise. Add a per-day `temperatureExcluded` flag on CycleDayLog (migration 0156): when set, the engine drops the reading from the cover-line baseline AND the shift evaluation, so a spike neither fabricates nor masks a rise (Sensiplan: the cover line is drawn over the last six unbracketed values). Wired end to end — schema + Prisma, the engine's `detectTempShift` filter, the Zod input/patch schemas, the day-log upsert (partial merge + change detection), the read DTO, the calendar/insights/coach selects, the backup round-trip, and the OpenAPI contract (a new boolean on the day-log and calendar-day DTOs for the iOS client). The log-day sheet gains a "mark reading as disturbed" toggle that appears once a temperature is entered, translated across all six locales. Citation: de.wikipedia "Symptothermale Methode"; myNFP "Temperaturkurve auswerten" — Störfaktor bracketing. --- docs/api/openapi.yaml | 12 +++++ messages/de.json | 2 + messages/en.json | 2 + messages/es.json | 2 + messages/fr.json | 2 + messages/it.json | 2 + messages/pl.json | 2 + .../migration.sql | 10 ++++ prisma/schema.prisma | 7 +++ src/app/api/cycle/calendar/route.ts | 2 + src/app/api/cycle/insights/route.ts | 1 + .../cycle/__tests__/log-day-sheet.test.ts | 24 ++++++++++ src/components/cycle/log-day-sheet.tsx | 28 ++++++++++- src/components/cycle/types.ts | 2 + src/components/cycle/use-cycle.ts | 1 + src/lib/ai/coach/cycle-snapshot.ts | 1 + .../cycle/__tests__/engine-adapter.test.ts | 2 +- src/lib/cycle/__tests__/prediction.test.ts | 46 +++++++++++++++++++ src/lib/cycle/backup.ts | 3 ++ src/lib/cycle/day-log-write.ts | 9 ++++ src/lib/cycle/dto.ts | 3 ++ src/lib/cycle/engine-adapter.ts | 18 +++++++- src/lib/cycle/prediction.ts | 9 ++-- src/lib/cycle/types.ts | 9 ++++ src/lib/openapi/routes/cycle.ts | 2 + src/lib/validations/backup.ts | 1 + src/lib/validations/cycle.ts | 4 ++ 27 files changed, 199 insertions(+), 7 deletions(-) create mode 100644 prisma/migrations/0156_v1170_cycle_temp_excluded/migration.sql diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index c5d57b9ca..1b8f0c632 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -117,6 +117,8 @@ paths: type: number minimum: 30 maximum: 45 + temperatureExcluded: + type: boolean ovulationTest: type: string enum: @@ -252,6 +254,8 @@ paths: minimum: 30 maximum: 45 - type: "null" + temperatureExcluded: + type: boolean ovulationTest: anyOf: - type: string @@ -412,6 +416,8 @@ paths: type: number minimum: 30 maximum: 45 + temperatureExcluded: + type: boolean ovulationTest: type: string enum: @@ -6240,6 +6246,8 @@ components: anyOf: - type: number - type: "null" + temperatureExcluded: + type: boolean ovulationTest: anyOf: - type: string @@ -6333,6 +6341,7 @@ components: - flow - intermenstrualBleeding - basalBodyTempC + - temperatureExcluded - ovulationTest - cervicalMucus - sexualActivity @@ -6647,6 +6656,8 @@ components: anyOf: - type: number - type: "null" + temperatureExcluded: + type: boolean ovulationTest: anyOf: - type: string @@ -6677,6 +6688,7 @@ components: - hasSymptoms - confidence - basalBodyTempC + - temperatureExcluded - ovulationTest - cervicalMucus additionalProperties: false diff --git a/messages/de.json b/messages/de.json index 4090d9ad3..86bdf4591 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5882,6 +5882,8 @@ "symptoms": "Symptome", "temperature": "Basaltemperatur", "temperaturePlaceholder": "°C", + "temperatureDisturbed": "Messwert als gestört markieren", + "temperatureDisturbedHint": "Fieber, Krankheit, Alkohol oder eine späte Messung — wird von der Temperaturauswertung ausgeschlossen.", "ovulationTest": "Ovulationstest", "mucus": "Zervixschleim", "intercourse": "Geschlechtsverkehr", diff --git a/messages/en.json b/messages/en.json index 22b529a5b..3ee745a08 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5882,6 +5882,8 @@ "symptoms": "Symptoms", "temperature": "Basal body temperature", "temperaturePlaceholder": "°C", + "temperatureDisturbed": "Mark reading as disturbed", + "temperatureDisturbedHint": "Fever, illness, alcohol or a late measurement — excluded from the temperature evaluation.", "ovulationTest": "Ovulation test", "mucus": "Cervical mucus", "intercourse": "Intercourse", diff --git a/messages/es.json b/messages/es.json index c9aa4fbdf..6ff2de793 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5882,6 +5882,8 @@ "symptoms": "Síntomas", "temperature": "Temperatura basal", "temperaturePlaceholder": "°C", + "temperatureDisturbed": "Marcar la medición como alterada", + "temperatureDisturbedHint": "Fiebre, enfermedad, alcohol o una medición tardía: se excluye de la evaluación de temperatura.", "ovulationTest": "Test de ovulación", "mucus": "Moco cervical", "intercourse": "Relaciones", diff --git a/messages/fr.json b/messages/fr.json index 739e1af82..1ac723285 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5882,6 +5882,8 @@ "symptoms": "Symptômes", "temperature": "Température basale", "temperaturePlaceholder": "°C", + "temperatureDisturbed": "Marquer la mesure comme perturbée", + "temperatureDisturbedHint": "Fièvre, maladie, alcool ou mesure tardive — exclue de l'évaluation de la température.", "ovulationTest": "Test d'ovulation", "mucus": "Glaire cervicale", "intercourse": "Rapports", diff --git a/messages/it.json b/messages/it.json index 183042f2e..b9f2be8b7 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5882,6 +5882,8 @@ "symptoms": "Sintomi", "temperature": "Temperatura basale", "temperaturePlaceholder": "°C", + "temperatureDisturbed": "Segna la misurazione come disturbata", + "temperatureDisturbedHint": "Febbre, malattia, alcol o una misurazione tardiva: esclusa dalla valutazione della temperatura.", "ovulationTest": "Test di ovulazione", "mucus": "Muco cervicale", "intercourse": "Rapporti", diff --git a/messages/pl.json b/messages/pl.json index ed11af429..e60e33682 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5882,6 +5882,8 @@ "symptoms": "Objawy", "temperature": "Podstawowa temperatura ciała", "temperaturePlaceholder": "°C", + "temperatureDisturbed": "Oznacz pomiar jako zakłócony", + "temperatureDisturbedHint": "Gorączka, choroba, alkohol lub spóźniony pomiar — wykluczony z oceny temperatury.", "ovulationTest": "Test owulacyjny", "mucus": "Śluz szyjkowy", "intercourse": "Współżycie", diff --git a/prisma/migrations/0156_v1170_cycle_temp_excluded/migration.sql b/prisma/migrations/0156_v1170_cycle_temp_excluded/migration.sql new file mode 100644 index 000000000..5556992de --- /dev/null +++ b/prisma/migrations/0156_v1170_cycle_temp_excluded/migration.sql @@ -0,0 +1,10 @@ +-- v1.17.0 — disturbed-temperature exclusion for the symptothermal engine. +-- +-- A fever / illness / alcohol / late or short-sleep BBT reading can fake or +-- mask a temperature rise. `temperature_excluded = true` marks a day's basal +-- body temperature as a disturbed value: the Sensiplan cover-line baseline and +-- the shift evaluation both skip it, so a spike neither fabricates nor masks a +-- rise (the cover line is drawn over the last six *unbracketed* values). +-- Additive: every existing row backfills `false`. + +ALTER TABLE "cycle_day_logs" ADD COLUMN "temperature_excluded" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3a1ebb4dd..8214510e4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -3749,6 +3749,13 @@ model CycleDayLog { /// symptothermal confirmation. BBT also lands in Measurement /// (BODY_TEMPERATURE) for charting; this column is the cycle-local copy. basalBodyTempC Float? @map("basal_body_temp_c") + /// Whether this day's BBT is a disturbed/unreliable reading (fever, + /// illness, alcohol, a late or short-sleep measurement) the user marked + /// excluded. When true the symptothermal engine drops the reading from the + /// cover-line baseline AND the shift evaluation, so a spike neither + /// fabricates nor masks a temperature rise (Sensiplan: the cover line is + /// drawn over the last six *unbracketed* values). + temperatureExcluded Boolean @default(false) @map("temperature_excluded") /// Ovulation predictor-kit (OPK) reading. ovulationTest OvulationTest? @map("ovulation_test") /// Cervical-mucus quality. diff --git a/src/app/api/cycle/calendar/route.ts b/src/app/api/cycle/calendar/route.ts index 40b1b6f1c..cd03590f9 100644 --- a/src/app/api/cycle/calendar/route.ts +++ b/src/app/api/cycle/calendar/route.ts @@ -99,6 +99,7 @@ export const GET = apiHandler(async (request: NextRequest) => { date: true, flow: true, basalBodyTempC: true, + temperatureExcluded: true, ovulationTest: true, cervicalMucus: true, _count: { select: { symptomLinks: true } }, @@ -125,6 +126,7 @@ export const GET = apiHandler(async (request: NextRequest) => { date: l.date, flow: l.flow, basalBodyTempC: l.basalBodyTempC, + temperatureExcluded: l.temperatureExcluded, ovulationTest: l.ovulationTest, cervicalMucus: l.cervicalMucus, hasSymptoms: l._count.symptomLinks > 0, diff --git a/src/app/api/cycle/insights/route.ts b/src/app/api/cycle/insights/route.ts index 9ce67b0e9..0bff32a89 100644 --- a/src/app/api/cycle/insights/route.ts +++ b/src/app/api/cycle/insights/route.ts @@ -90,6 +90,7 @@ export const GET = apiHandler(async () => { date: true, flow: true, basalBodyTempC: true, + temperatureExcluded: true, ovulationTest: true, cervicalMucus: true, symptomLinks: { select: { symptom: { select: { key: true } } } }, diff --git a/src/components/cycle/__tests__/log-day-sheet.test.ts b/src/components/cycle/__tests__/log-day-sheet.test.ts index a7a130f29..00e6a5933 100644 --- a/src/components/cycle/__tests__/log-day-sheet.test.ts +++ b/src/components/cycle/__tests__/log-day-sheet.test.ts @@ -11,6 +11,7 @@ function blank(): DayLogFormState { flow: null, intermenstrual: false, bbt: "", + bbtDisturbed: false, opk: null, mucus: null, intercourse: false, @@ -90,4 +91,27 @@ describe("buildDayLogInput (new row → omit empties)", () => { expect(input.flow).toBe("LIGHT"); expect(input.basalBodyTempC).toBe(36.5); }); + + it("carries the disturbed flag alongside a BBT reading", () => { + const input = buildDayLogInput( + { ...blank(), bbt: "36.5", bbtDisturbed: true }, + "2026-06-06", + ); + expect(input.basalBodyTempC).toBe(36.5); + expect(input.temperatureExcluded).toBe(true); + const patch = buildDayLogPatch({ ...blank(), bbt: "36.5", bbtDisturbed: true }); + expect(patch.temperatureExcluded).toBe(true); + }); + + it("never marks an absent BBT reading disturbed", () => { + // A disturbed flag with no temperature is meaningless — it must reset. + const patch = buildDayLogPatch({ ...blank(), bbt: "", bbtDisturbed: true }); + expect(patch.basalBodyTempC).toBeNull(); + expect(patch.temperatureExcluded).toBe(false); + const input = buildDayLogInput( + { ...blank(), bbt: "", bbtDisturbed: true }, + "2026-06-06", + ); + expect("temperatureExcluded" in input).toBe(false); + }); }); diff --git a/src/components/cycle/log-day-sheet.tsx b/src/components/cycle/log-day-sheet.tsx index c80170167..b21c4173d 100644 --- a/src/components/cycle/log-day-sheet.tsx +++ b/src/components/cycle/log-day-sheet.tsx @@ -154,6 +154,8 @@ export interface DayLogFormState { intermenstrual: boolean; /** Raw text from the BBT input; "" / non-finite resolves to null. */ bbt: string; + /** Whether the BBT reading is marked disturbed (fever/late) — engine skips it. */ + bbtDisturbed: boolean; opk: OvulationTest | null; mucus: CervicalMucus | null; intercourse: boolean; @@ -189,6 +191,8 @@ export function buildDayLogPatch(s: DayLogFormState): CycleDayLogPatch { flow: s.flow ?? null, intermenstrualBleeding: s.intermenstrual, basalBodyTempC: resolveBbt(s.bbt), + // Only meaningful with a BBT present; a cleared reading resets the flag. + temperatureExcluded: resolveBbt(s.bbt) != null ? s.bbtDisturbed : false, ovulationTest: s.opk ?? null, cervicalMucus: s.mucus ?? null, sexualActivity: s.intercourse, @@ -214,7 +218,9 @@ export function buildDayLogInput( source: "MANUAL", ...(s.flow ? { flow: s.flow } : {}), intermenstrualBleeding: s.intermenstrual, - ...(bbtVal != null ? { basalBodyTempC: bbtVal } : {}), + ...(bbtVal != null + ? { basalBodyTempC: bbtVal, temperatureExcluded: s.bbtDisturbed } + : {}), ...(s.opk ? { ovulationTest: s.opk } : {}), ...(s.mucus ? { cervicalMucus: s.mucus } : {}), sexualActivity: s.intercourse, @@ -292,6 +298,7 @@ export function LogDaySheet({ new Map(), ); const [bbt, setBbt] = useState(""); + const [bbtDisturbed, setBbtDisturbed] = useState(false); const [opk, setOpk] = useState(null); const [mucus, setMucus] = useState(null); const [intercourse, setIntercourse] = useState(false); @@ -324,6 +331,7 @@ export function LogDaySheet({ setIntermenstrual(dto?.intermenstrualBleeding ?? false); setSymptoms(new Map((dto?.symptoms ?? []).map((s) => [s.key, s.severity]))); setBbt(dto?.basalBodyTempC != null ? String(dto.basalBodyTempC) : ""); + setBbtDisturbed(dto?.temperatureExcluded ?? false); setOpk((dto?.ovulationTest ?? null) as OvulationTest | null); setMucus((dto?.cervicalMucus ?? null) as CervicalMucus | null); setIntercourse(dto?.sexualActivity ?? false); @@ -359,6 +367,7 @@ export function LogDaySheet({ flow, intermenstrual, bbt, + bbtDisturbed, opk, mucus, intercourse, @@ -601,6 +610,23 @@ export function LogDaySheet({ className="border-input bg-background focus-visible:ring-ring/50 w-32 rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none" aria-label={t("cycle.sheet.temperature")} /> + {resolveBbt(bbt) != null && ( +
+
+ + {t("cycle.sheet.temperatureDisturbed")} + + + {t("cycle.sheet.temperatureDisturbedHint")} + +
+ +
+ )} {/* Ovulation test */} diff --git a/src/components/cycle/types.ts b/src/components/cycle/types.ts index be791ffe5..064495b48 100644 --- a/src/components/cycle/types.ts +++ b/src/components/cycle/types.ts @@ -138,6 +138,7 @@ export interface CycleDayLogInput { flow?: FlowLevel; intermenstrualBleeding?: boolean; basalBodyTempC?: number; + temperatureExcluded?: boolean; ovulationTest?: OvulationTest; cervicalMucus?: CervicalMucus; sexualActivity?: boolean; @@ -159,6 +160,7 @@ export interface CycleDayLogDTO { flow: FlowLevel | null; intermenstrualBleeding: boolean; basalBodyTempC: number | null; + temperatureExcluded: boolean; ovulationTest: OvulationTest | null; cervicalMucus: CervicalMucus | null; sexualActivity: boolean; diff --git a/src/components/cycle/use-cycle.ts b/src/components/cycle/use-cycle.ts index dc5d94a48..1f7fca8ee 100644 --- a/src/components/cycle/use-cycle.ts +++ b/src/components/cycle/use-cycle.ts @@ -237,6 +237,7 @@ export interface CycleDayLogPatch { flow?: FlowLevel | null; intermenstrualBleeding?: boolean; basalBodyTempC?: number | null; + temperatureExcluded?: boolean; ovulationTest?: OvulationTest | null; cervicalMucus?: CervicalMucus | null; sexualActivity?: boolean; diff --git a/src/lib/ai/coach/cycle-snapshot.ts b/src/lib/ai/coach/cycle-snapshot.ts index c3c3e24f2..91369c3f3 100644 --- a/src/lib/ai/coach/cycle-snapshot.ts +++ b/src/lib/ai/coach/cycle-snapshot.ts @@ -176,6 +176,7 @@ export async function buildCycleSnapshotBlock( date: true, flow: true, basalBodyTempC: true, + temperatureExcluded: true, ovulationTest: true, cervicalMucus: true, }, diff --git a/src/lib/cycle/__tests__/engine-adapter.test.ts b/src/lib/cycle/__tests__/engine-adapter.test.ts index 6e2db3080..4630c2088 100644 --- a/src/lib/cycle/__tests__/engine-adapter.test.ts +++ b/src/lib/cycle/__tests__/engine-adapter.test.ts @@ -55,7 +55,7 @@ describe("buildCalendar", () => { it("labels logged-flow days and emits a forecast for ≥2 cycles", () => { const dayLogs: CalendarDayLogRow[] = [ - { date: "2026-02-26", flow: "MEDIUM", basalBodyTempC: null, ovulationTest: null, cervicalMucus: null, hasSymptoms: true }, + { date: "2026-02-26", flow: "MEDIUM", basalBodyTempC: null, temperatureExcluded: false, ovulationTest: null, cervicalMucus: null, hasSymptoms: true }, ]; const { prediction, days } = buildCalendar( profile(), diff --git a/src/lib/cycle/__tests__/prediction.test.ts b/src/lib/cycle/__tests__/prediction.test.ts index 47768d805..01d6e1d34 100644 --- a/src/lib/cycle/__tests__/prediction.test.ts +++ b/src/lib/cycle/__tests__/prediction.test.ts @@ -389,6 +389,52 @@ describe("cycle/prediction — symptothermal confirmation (§4.2)", () => { expect(shift?.evaluationCompleteDate).toBe(addDays(start, 9)); }); + it("detectTempShift: an excluded (disturbed) baseline spike no longer masks a true rise", () => { + const start = "2024-01-01"; + // 6 baseline days; the LAST (immediately before the rise) is a fever spike, + // so it sits in the cover-line window. Unexcluded it raises the cover line + // and contaminates detection; excluded it is dropped and the remaining lows + // form the real (lower) cover line. + const baseline = [36.4, 36.42, 36.38, 36.41, 36.4, 37.5]; + const extraLow = 36.39; // a 6th valid low so exclusion still leaves 6 + const rise = [36.55, 36.6, 36.62]; + const build = (excludeFever: boolean): DayLogInput[] => { + const logs: DayLogInput[] = []; + logs.push(bbtDay(addDays(start, 0), extraLow)); + baseline.forEach((t, i) => { + const day = bbtDay(addDays(start, 1 + i), t); + if (i === 5 && excludeFever) day.temperatureExcluded = true; + logs.push(day); + }); + rise.forEach((t, i) => logs.push(bbtDay(addDays(start, 7 + i), t))); + return logs; + }; + // Fever NOT excluded: the 37.5 spike contaminates detection — it does NOT + // yield the correct shift (ovulation = day before the real rise). + const corrupted = detectTempShift(build(false), 0.2); + expect(corrupted?.ovulationDate).not.toBe(addDays(start, 6)); + // Fever excluded: cover line falls to the real baseline max and the genuine + // rise confirms with ovulation the day before it (2024-01-07). + const shift = detectTempShift(build(true), 0.2); + expect(shift?.rule).toBe(0); + expect(shift?.ovulationDate).toBe(addDays(start, 6)); + }); + + it("detectTempShift: an excluded fever spike inside the rise no longer fabricates a shift", () => { + const start = "2024-01-01"; + const logs: DayLogInput[] = []; + [36.4, 36.4, 36.4, 36.4, 36.4, 36.4].forEach((t, i) => + logs.push(bbtDay(addDays(start, i), t)), + ); + const fever = bbtDay(addDays(start, 6), 38.0); + fever.temperatureExcluded = true; + logs.push(fever); + logs.push(bbtDay(addDays(start, 7), 36.41)); + logs.push(bbtDay(addDays(start, 8), 36.4)); + // The excluded fever is dropped; the remaining readings show no rise. + expect(detectTempShift(logs, 0.2)).toBeNull(); + }); + it("detectMucusPeak: confirms the last best-quality day followed by 3 drier days", () => { const m = (date: string, q: DayLogInput["cervicalMucus"]): DayLogInput => ({ date, diff --git a/src/lib/cycle/backup.ts b/src/lib/cycle/backup.ts index 34ef32e7d..87a58fb52 100644 --- a/src/lib/cycle/backup.ts +++ b/src/lib/cycle/backup.ts @@ -40,6 +40,7 @@ export interface CycleBackupSection { flow: string | null; intermenstrualBleeding: boolean; basalBodyTempC: number | null; + temperatureExcluded: boolean; ovulationTest: string | null; cervicalMucus: string | null; sexualActivity: boolean; @@ -108,6 +109,7 @@ export async function buildCycleBackupSection( flow: d.flow, intermenstrualBleeding: d.intermenstrualBleeding, basalBodyTempC: d.basalBodyTempC, + temperatureExcluded: d.temperatureExcluded, ovulationTest: d.ovulationTest, cervicalMucus: d.cervicalMucus, sexualActivity: d.sexualActivity, @@ -289,6 +291,7 @@ export async function restoreCycleData( flow: enumOrNull(d.flow, FLOW_LEVELS) as never, intermenstrualBleeding: d.intermenstrualBleeding ?? false, basalBodyTempC: d.basalBodyTempC ?? null, + temperatureExcluded: d.temperatureExcluded ?? false, ovulationTest: enumOrNull(d.ovulationTest, OVULATION_TESTS) as never, cervicalMucus: enumOrNull(d.cervicalMucus, CERVICAL_MUCUS) as never, sexualActivity: d.sexualActivity ?? false, diff --git a/src/lib/cycle/day-log-write.ts b/src/lib/cycle/day-log-write.ts index 16b9c2234..9d70d599e 100644 --- a/src/lib/cycle/day-log-write.ts +++ b/src/lib/cycle/day-log-write.ts @@ -140,6 +140,7 @@ type ExistingRow = { flow: string | null; intermenstrualBleeding: boolean; basalBodyTempC: number | null; + temperatureExcluded: boolean; ovulationTest: string | null; cervicalMucus: string | null; sexualActivity: boolean; @@ -158,6 +159,7 @@ const EXISTING_SELECT = { flow: true, intermenstrualBleeding: true, basalBodyTempC: true, + temperatureExcluded: true, ovulationTest: true, cervicalMucus: true, sexualActivity: true, @@ -309,6 +311,10 @@ async function writeDayLog(args: WriteArgs): Promise { entry.basalBodyTempC !== undefined ? (entry.basalBodyTempC ?? null) : (existing?.basalBodyTempC ?? null); + const temperatureExcluded = + entry.temperatureExcluded !== undefined + ? entry.temperatureExcluded + : (existing?.temperatureExcluded ?? false); const ovulationTest = entry.ovulationTest !== undefined ? (entry.ovulationTest ?? null) @@ -338,6 +344,7 @@ async function writeDayLog(args: WriteArgs): Promise { flow: flow as never, intermenstrualBleeding, basalBodyTempC, + temperatureExcluded, ovulationTest: ovulationTest as never, cervicalMucus: cervicalMucus as never, sexualActivity: sensitivePlaintext.sexualActivity, @@ -357,6 +364,7 @@ async function writeDayLog(args: WriteArgs): Promise { (existing.flow ?? null) !== flow || existing.intermenstrualBleeding !== intermenstrualBleeding || (existing.basalBodyTempC ?? null) !== basalBodyTempC || + existing.temperatureExcluded !== temperatureExcluded || (existing.ovulationTest ?? null) !== ovulationTest || (existing.cervicalMucus ?? null) !== cervicalMucus || storedSensitive.sexualActivity !== resolvedSensitive.sexualActivity || @@ -395,6 +403,7 @@ async function writeDayLog(args: WriteArgs): Promise { flow: baseData.flow as never, intermenstrualBleeding: baseData.intermenstrualBleeding as never, basalBodyTempC: baseData.basalBodyTempC as never, + temperatureExcluded: baseData.temperatureExcluded as never, ovulationTest: baseData.ovulationTest as never, cervicalMucus: baseData.cervicalMucus as never, sexualActivity: sensitivePlaintext.sexualActivity, diff --git a/src/lib/cycle/dto.ts b/src/lib/cycle/dto.ts index b9a3ca7b5..7b43456a4 100644 --- a/src/lib/cycle/dto.ts +++ b/src/lib/cycle/dto.ts @@ -29,6 +29,8 @@ export interface CycleDayLogDTO { flow: string | null; intermenstrualBleeding: boolean; basalBodyTempC: number | null; + /** Whether the day's BBT is marked disturbed (excluded from the engine). */ + temperatureExcluded: boolean; ovulationTest: string | null; cervicalMucus: string | null; sexualActivity: boolean; @@ -107,6 +109,7 @@ export function toCycleDayLogDTO(row: DayLogWithLinks): CycleDayLogDTO { flow: row.flow, intermenstrualBleeding: row.intermenstrualBleeding, basalBodyTempC: row.basalBodyTempC, + temperatureExcluded: row.temperatureExcluded, ovulationTest: row.ovulationTest, cervicalMucus: row.cervicalMucus, sexualActivity: hasEnvelope diff --git a/src/lib/cycle/engine-adapter.ts b/src/lib/cycle/engine-adapter.ts index 20c11f2d2..5afc60d09 100644 --- a/src/lib/cycle/engine-adapter.ts +++ b/src/lib/cycle/engine-adapter.ts @@ -37,6 +37,8 @@ export interface CalendarDayDTO { confidence: number; /** Logged basal body temperature (°C), or null. Feeds the web BBT chart. */ basalBodyTempC: number | null; + /** Whether the day's BBT is marked disturbed (excluded from the engine). */ + temperatureExcluded: boolean; /** Logged ovulation-test result, or null. */ ovulationTest: string | null; /** Logged cervical-mucus quality, or null. */ @@ -46,7 +48,12 @@ export interface CalendarDayDTO { /** Rows the calendar needs from each day-log. */ export type CalendarDayLogRow = Pick< CycleDayLog, - "date" | "flow" | "basalBodyTempC" | "ovulationTest" | "cervicalMucus" + | "date" + | "flow" + | "basalBodyTempC" + | "temperatureExcluded" + | "ovulationTest" + | "cervicalMucus" > & { hasSymptoms: boolean }; /** Map MenstrualCycle rows (oldest→newest) to engine `CycleInput`. */ @@ -73,13 +80,19 @@ export function toCycleInputs( export function toDayLogInputs( logs: readonly Pick< CycleDayLog, - "date" | "flow" | "basalBodyTempC" | "ovulationTest" | "cervicalMucus" + | "date" + | "flow" + | "basalBodyTempC" + | "temperatureExcluded" + | "ovulationTest" + | "cervicalMucus" >[], ): DayLogInput[] { return logs.map((l) => ({ date: l.date, flow: l.flow, basalBodyTempC: l.basalBodyTempC, + temperatureExcluded: l.temperatureExcluded, ovulationTest: l.ovulationTest, cervicalMucus: l.cervicalMucus, })); @@ -337,6 +350,7 @@ export function buildCalendar( hasSymptoms: log?.hasSymptoms ?? false, confidence: prediction?.confidence ?? 0, basalBodyTempC: log?.basalBodyTempC ?? null, + temperatureExcluded: log?.temperatureExcluded ?? false, ovulationTest: log?.ovulationTest ?? null, cervicalMucus: log?.cervicalMucus ?? null, }); diff --git a/src/lib/cycle/prediction.ts b/src/lib/cycle/prediction.ts index abc908b32..735b65af8 100644 --- a/src/lib/cycle/prediction.ts +++ b/src/lib/cycle/prediction.ts @@ -272,8 +272,11 @@ interface TempShiftResult { * published Ausnahmeregeln (exception rules). * * Cover line (Hüllkurve) = the highest of the 6 measured low values immediately - * preceding the rise. Ovulation = the last low day before the rise (the day - * BEFORE the first elevated reading). + * preceding the rise. Excluded (disturbed) readings are dropped before the cover + * line and the rise are evaluated, so a fever / late reading neither raises the + * cover line nor masks a true shift (Sensiplan: the line is drawn over the last + * six *unbracketed* values). Ovulation = the last low day before the rise (the + * day BEFORE the first elevated reading). * * Rules (Arbeitsgruppe NFP / Raith-Paula & Frank-Herrmann; myNFP "Temperatur­kurve * auswerten"; Generation-Pille "Die wichtigsten NFP-Regeln"): @@ -291,7 +294,7 @@ export function detectTempShift( thresholdC: number, ): TempShiftResult | null { const temps = dayLogs - .filter((l) => l.basalBodyTempC != null) + .filter((l) => l.basalBodyTempC != null && l.temperatureExcluded !== true) .map((l) => ({ date: l.date, t: l.basalBodyTempC as number })) .sort((a, b) => dayDiff(a.date, b.date)); diff --git a/src/lib/cycle/types.ts b/src/lib/cycle/types.ts index 425cd6e53..993bfb4ad 100644 --- a/src/lib/cycle/types.ts +++ b/src/lib/cycle/types.ts @@ -192,6 +192,15 @@ export interface DayLogInput { flow: FlowLevel | null; /** Basal body temperature in °C, already rounded to 2 dp before input. */ basalBodyTempC: number | null; + /** + * Whether this day's BBT is a disturbed/unreliable reading (fever, illness, + * alcohol, a late or short-sleep measurement) the user marked excluded. When + * true the engine drops the reading from the cover-line baseline AND the shift + * evaluation, so a spike neither fabricates nor masks a temperature rise + * (Sensiplan: the cover line is drawn over the last six *unbracketed* values). + * Optional for back-compat; treated as `false` when absent. + */ + temperatureExcluded?: boolean | null; ovulationTest: OvulationTest | null; cervicalMucus: CervicalMucus | null; } diff --git a/src/lib/openapi/routes/cycle.ts b/src/lib/openapi/routes/cycle.ts index a2663c885..fe6ef6391 100644 --- a/src/lib/openapi/routes/cycle.ts +++ b/src/lib/openapi/routes/cycle.ts @@ -101,6 +101,7 @@ const cycleDayLogDto = z flow: flowLevelEnum.nullable(), intermenstrualBleeding: z.boolean(), basalBodyTempC: z.number().nullable(), + temperatureExcluded: z.boolean(), ovulationTest: ovulationTestEnum.nullable(), cervicalMucus: cervicalMucusEnum.nullable(), sexualActivity: z.boolean(), @@ -175,6 +176,7 @@ const cycleCalendarDayDto = z.object({ // so the web BBT chart renders from the calendar read (the values are already // loaded server-side for the symptothermal layer; no extra query). basalBodyTempC: z.number().nullable(), + temperatureExcluded: z.boolean(), ovulationTest: ovulationTestEnum.nullable(), cervicalMucus: cervicalMucusEnum.nullable(), }); diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index ea483d352..283c5c306 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -141,6 +141,7 @@ const cycleDayLogSchema = z flow: z.string().nullable().optional(), intermenstrualBleeding: z.boolean().optional(), basalBodyTempC: z.number().nullable().optional(), + temperatureExcluded: z.boolean().optional(), ovulationTest: z.string().nullable().optional(), cervicalMucus: z.string().nullable().optional(), sexualActivity: z.boolean().optional(), diff --git a/src/lib/validations/cycle.ts b/src/lib/validations/cycle.ts index 6b3353cc1..c19ac0bb4 100644 --- a/src/lib/validations/cycle.ts +++ b/src/lib/validations/cycle.ts @@ -103,6 +103,9 @@ export const cycleDayLogInputSchema = z.object({ flow: flowLevelEnum.optional(), intermenstrualBleeding: z.boolean().optional(), basalBodyTempC: z.number().min(30).max(45).optional(), + // Marks the day's BBT as disturbed (fever / illness / late reading): the + // symptothermal engine drops it from the cover line + shift evaluation. + temperatureExcluded: z.boolean().optional(), ovulationTest: ovulationTestEnum.optional(), cervicalMucus: cervicalMucusEnum.optional(), sexualActivity: z.boolean().optional(), @@ -132,6 +135,7 @@ export const cycleDayLogPatchSchema = z.object({ flow: flowLevelEnum.nullable().optional(), intermenstrualBleeding: z.boolean().optional(), basalBodyTempC: z.number().min(30).max(45).nullable().optional(), + temperatureExcluded: z.boolean().optional(), ovulationTest: ovulationTestEnum.nullable().optional(), cervicalMucus: cervicalMucusEnum.nullable().optional(), sexualActivity: z.boolean().optional(), From 2c225ce094e9656a247b0ce22ea1c9499159ac08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 15:06:55 +0200 Subject: [PATCH 06/12] feat(cycle): fold a positive LH/OPK surge into the ovulation estimate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A stored positive ovulation test (LH surge) was ignored by the predictor. Wire it in as a Marquette/Fehring anchor: a positive surge predicts ovulation ~24-36h later, so the estimated ovulation is set one day after the last positive LH test in the current window, sharpening the estimate over the calendar back-calculation — most valuable in the thin-data (<3 cycle) case where the median/luteal back-calc is weak. The LH surge is a SIGNAL that refines, never a sole assertion: it re-anchors the still-estimated ovulation but never flips `ovulationConfirmed` (a single indicator is not the symptothermal double-check), and a confirmed symptothermal/temperature-trend ovulation always wins over it. The still-learning gate and the un-confirmed status are preserved. A stale prior-cycle surge whose next-start lands in the past is rejected. Citation: Fehring R.J. — Marquette Method; urinary LH peaks ~1 day before ovulation. --- src/lib/cycle/__tests__/prediction.test.ts | 98 ++++++++++++++++++++++ src/lib/cycle/index.ts | 1 + src/lib/cycle/prediction.ts | 56 ++++++++++++- src/lib/cycle/types.ts | 12 +++ 4 files changed, 166 insertions(+), 1 deletion(-) diff --git a/src/lib/cycle/__tests__/prediction.test.ts b/src/lib/cycle/__tests__/prediction.test.ts index 01d6e1d34..28928b6ce 100644 --- a/src/lib/cycle/__tests__/prediction.test.ts +++ b/src/lib/cycle/__tests__/prediction.test.ts @@ -4,6 +4,7 @@ import { addDays } from "../day-math"; import { clampLuteal, confirmSymptothermal, + detectLhSurgeOvulation, detectMucusPeak, detectTempShift, detectTemperatureTrend, @@ -560,6 +561,103 @@ describe("cycle/prediction — symptothermal confirmation (§4.2)", () => { }); }); +describe("cycle/prediction — Marquette LH/OPK ovulation anchor (§4, C2)", () => { + function lhDay(date: string): DayLogInput { + return { + date, + flow: null, + basalBodyTempC: null, + ovulationTest: "POSITIVE_LH_SURGE", + cervicalMucus: null, + }; + } + + it("detectLhSurgeOvulation: anchors ovulation one day after the LAST positive LH", () => { + const logs = [lhDay("2024-01-10"), lhDay("2024-01-11")]; + expect(detectLhSurgeOvulation(logs)).toBe("2024-01-12"); + expect(detectLhSurgeOvulation([])).toBeNull(); + }); + + it("predictCycle: a positive LH surge anchors ovulation on the surge, not nextStart−luteal", () => { + const cycles = cyclesFromGaps("2024-01-01", [28, 28, 28]); + const lastStart = cycles[cycles.length - 1].startDate; // 2024-03-25 + // The calendar back-calc would place ovulation at nextStart−14. A positive + // LH on lastStart+16 should anchor ovulation to lastStart+17. + const lhDate = addDays(lastStart, 16); + const logs = [lhDay(lhDate)]; + const today = addDays(lastStart, 18); + + const calendarOnly = predictCycle(cycles, [], BASE_PROFILE, today); + const withLh = predictCycle(cycles, logs, BASE_PROFILE, today); + + // The LH anchor moves the ovulation estimate off the calendar back-calc. + expect(withLh.predictedOvulation).toBe(addDays(lhDate, 1)); + expect(withLh.predictedOvulation).not.toBe(calendarOnly.predictedOvulation); + // next-start = anchored ovulation + luteal(14). + expect(withLh.nextPeriodStart).toBe(addDays(lhDate, 1 + 14)); + expect(withLh.method).toBe("BLENDED"); + // A single LH indicator never asserts CONFIRMED ovulation. + expect(withLh.ovulationConfirmed).toBe(false); + }); + + it("predictCycle: LH sharpens the estimate in the thin-data (1-cycle) case", () => { + const cycles = cyclesFromGaps("2024-01-01", [28]); // one completed length + const lastStart = cycles[cycles.length - 1].startDate; // 2024-01-29 + const lhDate = addDays(lastStart, 15); + const today = addDays(lastStart, 17); + const result = predictCycle(cycles, [lhDay(lhDate)], BASE_PROFILE, today); + expect(result.predictedOvulation).toBe(addDays(lhDate, 1)); + expect(result.ovulationConfirmed).toBe(false); + // The still-learning gate is preserved (1 cycle < 3). + expect(result.stillLearning).toBe(true); + }); + + it("predictCycle: a confirmed symptothermal ovulation wins over the LH anchor", () => { + const cycles = cyclesFromGaps("2024-01-01", [28, 28, 28]); + const lastStart = cycles[cycles.length - 1].startDate; + const ovDay = addDays(lastStart, 13); + const riseStart = addDays(ovDay, 1); + const baselineStart = addDays(riseStart, -6); + const logs: DayLogInput[] = []; + [36.4, 36.41, 36.39, 36.4, 36.42, 36.4].forEach((t, i) => + logs.push({ + date: addDays(baselineStart, i), + flow: null, + basalBodyTempC: t, + ovulationTest: null, + cervicalMucus: null, + }), + ); + const bbt = (date: string, t: number): DayLogInput => ({ + date, + flow: null, + basalBodyTempC: t, + ovulationTest: null, + cervicalMucus: null, + }); + logs.push(bbt(riseStart, 36.6)); + logs.push(bbt(addDays(riseStart, 1), 36.62)); + logs.push(bbt(addDays(riseStart, 2), 36.63)); + const mucus = (date: string, m: DayLogInput["cervicalMucus"]): DayLogInput => ({ + date, + flow: null, + basalBodyTempC: null, + ovulationTest: null, + cervicalMucus: m, + }); + logs.push(mucus(ovDay, "EGG_WHITE")); + logs.push(mucus(addDays(ovDay, 1), "STICKY")); + logs.push(mucus(addDays(ovDay, 2), "DRY")); + logs.push(mucus(addDays(ovDay, 3), "DRY")); + // A contradicting LH surge far from the temp shift must NOT override it. + logs.push(lhDay(addDays(ovDay, 5))); + + const result = predictCycle(cycles, logs, BASE_PROFILE, addDays(lastStart, 20)); + expect(result.ovulationConfirmed).toBe(true); + expect(result.predictedOvulation).toBe(ovDay); // symptothermal wins + }); +}); + describe("cycle/prediction — temperature-trend retrospective ovulation (§4.3)", () => { it("detectTemperatureTrend: 3-of-4 nights >= +0.15°C over the trailing 6-night mean", () => { const start = "2024-02-01"; diff --git a/src/lib/cycle/index.ts b/src/lib/cycle/index.ts index a0368f830..d8e2a2297 100644 --- a/src/lib/cycle/index.ts +++ b/src/lib/cycle/index.ts @@ -17,6 +17,7 @@ export { detectMucusPeak, confirmSymptothermal, detectTemperatureTrend, + detectLhSurgeOvulation, median, mad, resolveLuteal, diff --git a/src/lib/cycle/prediction.ts b/src/lib/cycle/prediction.ts index 735b65af8..716f6b3fe 100644 --- a/src/lib/cycle/prediction.ts +++ b/src/lib/cycle/prediction.ts @@ -31,6 +31,7 @@ import { HARD_CYCLE_MAX, HARD_CYCLE_MIN, HISTORY_WINDOW_N, + LH_SURGE_TO_OVULATION_DAYS, LOG_SPARSITY_SCALE, LUTEAL_DEFAULT, LUTEAL_MAX, @@ -471,6 +472,33 @@ export function detectTemperatureTrend( return null; } +/** + * §4 — Marquette (Fehring) LH/OPK ovulation anchor. A positive urinary-LH surge + * precedes ovulation by ~24–36 h, so the estimated ovulation day is one day + * after the LAST positive LH test in the supplied logs (the surge can read + * positive across consecutive days; the last positive is closest to ovulation). + * + * This ANCHORS the ovulation estimate — it never confirms it. A single LH + * indicator is not the symptothermal double-check, so the caller folds this in + * as a refining signal that sharpens the estimate without flipping + * `ovulationConfirmed`. Returns the estimated ovulation day, or null when no + * positive LH test is present. + * + * Citation: Fehring R.J. — Marquette Method; urinary LH peaks ~1 day before + * ovulation. + */ +export function detectLhSurgeOvulation( + dayLogs: readonly DayLogInput[], +): string | null { + const positives = dayLogs + .filter((l) => l.ovulationTest === "POSITIVE_LH_SURGE") + .map((l) => l.date) + .sort((a, b) => dayDiff(a, b)); + if (positives.length === 0) return null; + const lastPositive = positives[positives.length - 1]; + return addDays(lastPositive, LH_SURGE_TO_OVULATION_DAYS); +} + /* ------------------------------------------------------------------ */ /* §3 — confidence + band */ /* ------------------------------------------------------------------ */ @@ -571,7 +599,7 @@ export function resolveLuteal( * engine returns the window and the caller hides it). * * @param cycles confirmed (non-predicted) cycles; order-independent. - * @param dayLogs all day logs the device holds (for period, symptothermal). + * @param dayLogs all day logs the device holds (period, symptothermal, LH/OPK). * @param profile user priors + mode flags. * @param today `YYYY-MM-DD` reference day (for adherence density). * @param nights optional nightly passive-temperature series (Apple Watch). @@ -670,6 +698,32 @@ export function predictCycle( } } + // -------- §4 Marquette LH/OPK ANCHOR (refines, never confirms) -------- + // A positive LH surge predicts ovulation ~24–36 h later (Marquette/Fehring). + // It is a single indicator, NOT the symptothermal double-check, so it only + // RE-ANCHORS the ovulation estimate — it never flips `ovulationConfirmed`. + // When the symptothermal or temperature-trend layer already confirmed, that + // stronger signal wins and the LH anchor is ignored. Otherwise the LH surge + // sharpens the still-estimated ovulation (and the derived next-start) over + // the calendar back-calculation — most valuable in the thin-data (<3 cycle) + // case where the median/luteal back-calc is weak. The still-learning gate and + // the un-confirmed status are both preserved (honesty principle). + if (!ovulationConfirmed) { + const lhOvulation = detectLhSurgeOvulation(currentDayLogs); + if (lhOvulation) { + const lhNextStart = addDays(lhOvulation, lutealLength); + // Only adopt a future-dated anchor (a positive-LH next-start in the past + // is a stale prior-cycle signal that slipped the window). + if (dayDiff(lhNextStart, today) >= 0) { + predictedOvulation = lhOvulation; + nextStart = lhNextStart; + // method records the mix but ovulationConfirmed stays FALSE — a single + // LH indicator never asserts confirmed ovulation. + method = "BLENDED"; + } + } + } + // -------- §3 band half-width -------- const { cAdherence, logSparsity } = adherenceFactors( lastConfirmedStart, diff --git a/src/lib/cycle/types.ts b/src/lib/cycle/types.ts index 993bfb4ad..062cdc5fd 100644 --- a/src/lib/cycle/types.ts +++ b/src/lib/cycle/types.ts @@ -139,6 +139,18 @@ export const HALF_WIDTH_MULT_TEMP_TREND = 0.75; /** §4 — symptothermal agreement tolerance between temp-shift and mucus peak (days). */ export const SYMPTOTHERMAL_AGREE_DAYS = 2; +/** + * §4 — Marquette (Fehring) LH/OPK ovulation lag. A positive urinary-LH surge + * precedes ovulation by ~24–36 h, so ovulation is estimated one day after the + * (last) positive LH test. Used to ANCHOR — never to confirm — the ovulation + * estimate: a single LH indicator is not the symptothermal double-check, so it + * sharpens the estimate without asserting confirmed ovulation. + * + * Citation: Fehring R.J. — Marquette Method; urinary LH peaks ~1 day before + * ovulation. ESHRE/Wilcox fertile-window literature. + */ +export const LH_SURGE_TO_OVULATION_DAYS = 1; + /** * §4 — trailing lookback (days) for the symptothermal / temperature-trend * detectors. The 3-over-6 BBT rule needs 6 baseline readings before the rise, From 609d248252343600dc6c284146ffcc68d956cf4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 15:23:26 +0200 Subject: [PATCH 07/12] feat(cycle): choose cervix as the symptothermal secondary symptom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Let a user pick the Sensiplan double-check's second indicator — cervical mucus (default) or cervix observation — mirroring the published NFP standard where the temperature primary sign pairs with one chosen secondary sign. - `CycleProfile.secondarySymptom` (MUCUS default | CERVIX) drives which second sign the engine confirms against. Casual users keep temperature + mucus untouched; the choice lives behind the advanced cycle settings. - `CycleDayLog` gains the three standard cervix signs — position (LOW/HIGH), firmness (FIRM/SOFT), opening/os (CLOSED/OPEN) — as nullable columns (migration 0157), wired through Zod, the day-log upsert, the DTO, the sync feed, backup/restore and OpenAPI. Additive and back-compatible: existing profiles backfill MUCUS, existing day-logs keep NULL cervix signs, iOS inherits the fields. - The engine's `confirmSymptothermal` takes the secondary-symptom choice; on CERVIX it confirms against `detectCervixPeak` (last fertile-cervix day followed by three closed days — the cervix-closure rule) instead of the mucus peak. The double-check still requires BOTH the temperature shift AND the secondary peak: a single cervix sign never confirms, and the still-learning gate and conservative posture are unchanged. Engine derived clean-room from the Arbeitsgruppe-NFP / Raith-Paula & Frank-Herrmann standard. --- docs/api/openapi.yaml | 110 +++++++++++++++ .../migration.sql | 29 ++++ prisma/schema.prisma | 57 ++++++++ src/app/api/auth/me/cycle-prefs/route.ts | 3 + src/app/api/cycle/calendar/route.ts | 6 + src/app/api/cycle/insights/route.ts | 3 + src/lib/ai/coach/cycle-snapshot.ts | 5 + .../cycle/__tests__/engine-adapter.test.ts | 2 +- src/lib/cycle/__tests__/prediction.test.ts | 128 ++++++++++++++++++ src/lib/cycle/backup.ts | 23 ++++ src/lib/cycle/day-log-write.ts | 27 ++++ src/lib/cycle/dto.ts | 11 ++ src/lib/cycle/engine-adapter.ts | 10 ++ src/lib/cycle/prediction.ts | 87 +++++++++++- src/lib/cycle/types.ts | 32 +++++ src/lib/openapi/routes/cycle.ts | 11 ++ src/lib/validations/backup.ts | 4 + src/lib/validations/cycle.ts | 19 +++ 18 files changed, 561 insertions(+), 6 deletions(-) create mode 100644 prisma/migrations/0157_v1170_cycle_secondary_symptom/migration.sql diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 1b8f0c632..d6be35587 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -134,6 +134,21 @@ paths: - CREAMY - WATERY - EGG_WHITE + cervixPosition: + type: string + enum: + - LOW + - HIGH + cervixFirmness: + type: string + enum: + - FIRM + - SOFT + cervixOpening: + type: string + enum: + - CLOSED + - OPEN sexualActivity: type: boolean protectedSex: @@ -275,6 +290,27 @@ paths: - WATERY - EGG_WHITE - type: "null" + cervixPosition: + anyOf: + - type: string + enum: + - LOW + - HIGH + - type: "null" + cervixFirmness: + anyOf: + - type: string + enum: + - FIRM + - SOFT + - type: "null" + cervixOpening: + anyOf: + - type: string + enum: + - CLOSED + - OPEN + - type: "null" sexualActivity: type: boolean protectedSex: @@ -433,6 +469,21 @@ paths: - CREAMY - WATERY - EGG_WHITE + cervixPosition: + type: string + enum: + - LOW + - HIGH + cervixFirmness: + type: string + enum: + - FIRM + - SOFT + cervixOpening: + type: string + enum: + - CLOSED + - OPEN sexualActivity: type: boolean protectedSex: @@ -730,6 +781,11 @@ paths: - TRYING_TO_CONCEIVE - PERIMENOPAUSE - OFF + secondarySymptom: + type: string + enum: + - MUCUS + - CERVIX rawChartMode: type: boolean predictionEnabled: @@ -6267,6 +6323,27 @@ components: - WATERY - EGG_WHITE - type: "null" + cervixPosition: + anyOf: + - type: string + enum: + - LOW + - HIGH + - type: "null" + cervixFirmness: + anyOf: + - type: string + enum: + - FIRM + - SOFT + - type: "null" + cervixOpening: + anyOf: + - type: string + enum: + - CLOSED + - OPEN + - type: "null" sexualActivity: type: boolean protectedSex: @@ -6344,6 +6421,9 @@ components: - temperatureExcluded - ovulationTest - cervicalMucus + - cervixPosition + - cervixFirmness + - cervixOpening - sexualActivity - protectedSex - pregnancyTest @@ -6677,6 +6757,27 @@ components: - WATERY - EGG_WHITE - type: "null" + cervixPosition: + anyOf: + - type: string + enum: + - LOW + - HIGH + - type: "null" + cervixFirmness: + anyOf: + - type: string + enum: + - FIRM + - SOFT + - type: "null" + cervixOpening: + anyOf: + - type: string + enum: + - CLOSED + - OPEN + - type: "null" required: - date - phase @@ -6691,6 +6792,9 @@ components: - temperatureExcluded - ovulationTest - cervicalMucus + - cervixPosition + - cervixFirmness + - cervixOpening additionalProperties: false meta: type: object @@ -7156,6 +7260,11 @@ components: - OFF cycleTrackingEnabled: type: boolean + secondarySymptom: + type: string + enum: + - MUCUS + - CERVIX rawChartMode: type: boolean predictionEnabled: @@ -7189,6 +7298,7 @@ components: required: - goal - cycleTrackingEnabled + - secondarySymptom - rawChartMode - predictionEnabled - discreetNotifications diff --git a/prisma/migrations/0157_v1170_cycle_secondary_symptom/migration.sql b/prisma/migrations/0157_v1170_cycle_secondary_symptom/migration.sql new file mode 100644 index 000000000..cf3d7762a --- /dev/null +++ b/prisma/migrations/0157_v1170_cycle_secondary_symptom/migration.sql @@ -0,0 +1,29 @@ +-- v1.17.0 — symptothermal SECONDARY-symptom choice: cervical mucus (default) +-- OR cervix observation, mirroring the Sensiplan/NFP double-check where the +-- temperature primary sign pairs with one chosen second indicator. +-- +-- `cycle_profiles.secondary_symptom` picks which second sign the engine uses +-- (MUCUS default | CERVIX); the casual user keeps temperature + mucus and never +-- sees the choice (it lives behind the advanced cycle settings). +-- +-- `cycle_day_logs` gains the three standard Sensiplan cervix signs — position +-- (LOW/HIGH), firmness (FIRM/SOFT), opening/os (CLOSED/OPEN) — used as the +-- secondary indicator when secondary_symptom = CERVIX. Fertile = HIGH+SOFT+OPEN; +-- infertile (closure) = LOW+FIRM+CLOSED. +-- +-- Additive + back-compatible: every existing profile backfills MUCUS (the prior +-- behaviour) and every existing day-log row keeps NULL cervix signs. iOS +-- inherits the fields without a migration of its own. + +CREATE TYPE "secondary_symptom" AS ENUM ('MUCUS', 'CERVIX'); +CREATE TYPE "cervix_position" AS ENUM ('LOW', 'HIGH'); +CREATE TYPE "cervix_firmness" AS ENUM ('FIRM', 'SOFT'); +CREATE TYPE "cervix_opening" AS ENUM ('CLOSED', 'OPEN'); + +ALTER TABLE "cycle_profiles" + ADD COLUMN "secondary_symptom" "secondary_symptom" NOT NULL DEFAULT 'MUCUS'; + +ALTER TABLE "cycle_day_logs" + ADD COLUMN "cervix_position" "cervix_position", + ADD COLUMN "cervix_firmness" "cervix_firmness", + ADD COLUMN "cervix_opening" "cervix_opening"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8214510e4..6a09c701b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -3615,6 +3615,47 @@ enum CyclePhase { @@map("cycle_phase") } +/// Which symptothermal SECONDARY sign the user observes alongside the +/// temperature primary sign for the Sensiplan double-check. `MUCUS` +/// (cervical-mucus quality) is the default; `CERVIX` swaps in the cervix +/// observation (position / firmness / opening). The temperature primary +/// sign is unchanged either way — only the second indicator of the +/// double-check differs. Mirrors the `drip` app's Settings → +/// Customization → Secondary symptom choice. +enum SecondarySymptom { + MUCUS + CERVIX + + @@map("secondary_symptom") +} + +/// Cervix vertical position, a Sensiplan cervix sign. Fertile = `HIGH` +/// (the cervix rises around ovulation); infertile = `LOW`. +enum CervixPosition { + LOW + HIGH + + @@map("cervix_position") +} + +/// Cervix firmness, a Sensiplan cervix sign. Fertile = `SOFT` (the cervix +/// softens around ovulation); infertile = `FIRM`. +enum CervixFirmness { + FIRM + SOFT + + @@map("cervix_firmness") +} + +/// Cervix os / opening, a Sensiplan cervix sign. Fertile = `OPEN`; +/// infertile = `CLOSED`. +enum CervixOpening { + CLOSED + OPEN + + @@map("cervix_opening") +} + /// Which method produced a cached CyclePrediction. `CALENDAR` is the /// median+MAD variability predictor; `SYMPTOTHERMAL` folds the /// sensiplan BBT + mucus confirmation; `TEMPERATURE_TREND` is the @@ -3654,6 +3695,12 @@ model CycleProfile { typicalPeriodLength Int? @map("typical_period_length") lutealPhaseLength Int? @map("luteal_phase_length") + /// Which symptothermal SECONDARY sign the user observes for the Sensiplan + /// double-check: `MUCUS` (default) or `CERVIX`. Lives behind the advanced + /// cycle settings — the casual user keeps temperature + mucus and never + /// sees the choice. The temperature primary sign is unchanged either way. + secondarySymptom SecondarySymptom @default(MUCUS) @map("secondary_symptom") + /// When false, the app computes / stores NO predictions — pure logging /// (the Read-Your-Body posture for users who distrust algorithmic /// interpretation). @@ -3761,6 +3808,16 @@ model CycleDayLog { /// Cervical-mucus quality. cervicalMucus CervicalMucus? @map("cervical_mucus") + /// Cervix observation — the three standard Sensiplan cervix signs, used + /// as the symptothermal secondary indicator when + /// CycleProfile.secondarySymptom is CERVIX. All NULL = not observed. + /// The fertile combination is HIGH + SOFT + OPEN; the infertile + /// (closure) combination is LOW + FIRM + CLOSED. Queryable-plaintext like + /// the other physiology signs (mucus / BBT / OPK). + cervixPosition CervixPosition? @map("cervix_position") + cervixFirmness CervixFirmness? @map("cervix_firmness") + cervixOpening CervixOpening? @map("cervix_opening") + /// Intent-revealing fields. When CycleProfile.sensitiveCategoryEncryption /// is ON these are encrypted at the application layer (the dispatch /// envelope), default-ON for this category. diff --git a/src/app/api/auth/me/cycle-prefs/route.ts b/src/app/api/auth/me/cycle-prefs/route.ts index 3de329d1c..ec6d97132 100644 --- a/src/app/api/auth/me/cycle-prefs/route.ts +++ b/src/app/api/auth/me/cycle-prefs/route.ts @@ -92,6 +92,9 @@ export const PATCH = apiHandler(async (req: Request) => { data: { ...(p.enabled !== undefined && { cycleTrackingEnabled: p.enabled }), ...(p.goal !== undefined && { goal: p.goal }), + ...(p.secondarySymptom !== undefined && { + secondarySymptom: p.secondarySymptom, + }), ...(p.rawChartMode !== undefined && { rawChartMode: p.rawChartMode }), ...(p.predictionEnabled !== undefined && { predictionEnabled: p.predictionEnabled, diff --git a/src/app/api/cycle/calendar/route.ts b/src/app/api/cycle/calendar/route.ts index cd03590f9..0f4c4f38b 100644 --- a/src/app/api/cycle/calendar/route.ts +++ b/src/app/api/cycle/calendar/route.ts @@ -102,6 +102,9 @@ export const GET = apiHandler(async (request: NextRequest) => { temperatureExcluded: true, ovulationTest: true, cervicalMucus: true, + cervixPosition: true, + cervixFirmness: true, + cervixOpening: true, _count: { select: { symptomLinks: true } }, }, }), @@ -129,6 +132,9 @@ export const GET = apiHandler(async (request: NextRequest) => { temperatureExcluded: l.temperatureExcluded, ovulationTest: l.ovulationTest, cervicalMucus: l.cervicalMucus, + cervixPosition: l.cervixPosition, + cervixFirmness: l.cervixFirmness, + cervixOpening: l.cervixOpening, hasSymptoms: l._count.symptomLinks > 0, })); diff --git a/src/app/api/cycle/insights/route.ts b/src/app/api/cycle/insights/route.ts index 0bff32a89..3f16a02ed 100644 --- a/src/app/api/cycle/insights/route.ts +++ b/src/app/api/cycle/insights/route.ts @@ -93,6 +93,9 @@ export const GET = apiHandler(async () => { temperatureExcluded: true, ovulationTest: true, cervicalMucus: true, + cervixPosition: true, + cervixFirmness: true, + cervixOpening: true, symptomLinks: { select: { symptom: { select: { key: true } } } }, }, }), diff --git a/src/lib/ai/coach/cycle-snapshot.ts b/src/lib/ai/coach/cycle-snapshot.ts index 91369c3f3..1e36b1333 100644 --- a/src/lib/ai/coach/cycle-snapshot.ts +++ b/src/lib/ai/coach/cycle-snapshot.ts @@ -142,6 +142,7 @@ export async function buildCycleSnapshotBlock( lutealPhaseLength: true, typicalCycleLength: true, typicalPeriodLength: true, + secondarySymptom: true, }, }); if (!profile) return null; @@ -153,6 +154,7 @@ export async function buildCycleSnapshotBlock( lutealPhaseLength: profile.lutealPhaseLength, predictionEnabled: profile.predictionEnabled, rawChartMode: profile.rawChartMode, + secondarySymptom: profile.secondarySymptom, }; // Derive "today" from the user's timezone — matches how the calendar @@ -179,6 +181,9 @@ export async function buildCycleSnapshotBlock( temperatureExcluded: true, ovulationTest: true, cervicalMucus: true, + cervixPosition: true, + cervixFirmness: true, + cervixOpening: true, }, }), prisma.measurement.findMany({ diff --git a/src/lib/cycle/__tests__/engine-adapter.test.ts b/src/lib/cycle/__tests__/engine-adapter.test.ts index 4630c2088..b7169ade6 100644 --- a/src/lib/cycle/__tests__/engine-adapter.test.ts +++ b/src/lib/cycle/__tests__/engine-adapter.test.ts @@ -55,7 +55,7 @@ describe("buildCalendar", () => { it("labels logged-flow days and emits a forecast for ≥2 cycles", () => { const dayLogs: CalendarDayLogRow[] = [ - { date: "2026-02-26", flow: "MEDIUM", basalBodyTempC: null, temperatureExcluded: false, ovulationTest: null, cervicalMucus: null, hasSymptoms: true }, + { date: "2026-02-26", flow: "MEDIUM", basalBodyTempC: null, temperatureExcluded: false, ovulationTest: null, cervicalMucus: null, cervixPosition: null, cervixFirmness: null, cervixOpening: null, hasSymptoms: true }, ]; const { prediction, days } = buildCalendar( profile(), diff --git a/src/lib/cycle/__tests__/prediction.test.ts b/src/lib/cycle/__tests__/prediction.test.ts index 28928b6ce..418f9f6b2 100644 --- a/src/lib/cycle/__tests__/prediction.test.ts +++ b/src/lib/cycle/__tests__/prediction.test.ts @@ -4,6 +4,7 @@ import { addDays } from "../day-math"; import { clampLuteal, confirmSymptothermal, + detectCervixPeak, detectLhSurgeOvulation, detectMucusPeak, detectTempShift, @@ -297,6 +298,24 @@ describe("cycle/prediction — symptothermal confirmation (§4.2)", () => { return { date, flow: null, basalBodyTempC: t, ovulationTest: null, cervicalMucus: mucus }; } + function cervixDay( + date: string, + cervixPosition: DayLogInput["cervixPosition"], + cervixFirmness: DayLogInput["cervixFirmness"], + cervixOpening: DayLogInput["cervixOpening"], + ): DayLogInput { + return { + date, + flow: null, + basalBodyTempC: null, + ovulationTest: null, + cervicalMucus: null, + cervixPosition, + cervixFirmness, + cervixOpening, + }; + } + it("detectTempShift: 3-over-6 with the 3rd reading >= 0.2°C over the 6-day max", () => { // 6 low baseline ~36.40, then 3 elevated clearing the max by 0.2. const logs: DayLogInput[] = []; @@ -522,6 +541,115 @@ describe("cycle/prediction — symptothermal confirmation (§4.2)", () => { expect(confirmSymptothermal(logs)).toBe("2024-01-06"); }); + it("confirmSymptothermal defaults to the mucus secondary symptom (cervix ignored)", () => { + // A confirmed temp shift + a confirmed CERVIX peak but NO mucus must NOT + // confirm on the default (mucus) path — the default behaviour is unchanged. + const logs: DayLogInput[] = []; + const start = "2024-01-01"; + [36.4, 36.42, 36.38, 36.41, 36.4, 36.39].forEach((t, i) => + logs.push(bbtDay(addDays(start, i), t)), + ); + logs.push(bbtDay(addDays(start, 6), 36.55)); + logs.push(bbtDay(addDays(start, 7), 36.6)); + logs.push(bbtDay(addDays(start, 8), 36.62)); + // cervix peak on 2024-01-06, confirmed by 3 closed days — but no mucus. + logs.push(cervixDay("2024-01-06", "HIGH", "SOFT", "OPEN")); + logs.push(cervixDay("2024-01-07", "LOW", "FIRM", "CLOSED")); + logs.push(cervixDay("2024-01-08", "LOW", "FIRM", "CLOSED")); + logs.push(cervixDay("2024-01-09", "LOW", "FIRM", "CLOSED")); + // Default secondary = mucus → no mucus peak → no confirmation. + expect(confirmSymptothermal(logs)).toBeNull(); + }); + + it("detectCervixPeak: confirms the last fertile-cervix day followed by 3 closed days", () => { + const start = "2024-01-01"; + const logs = [ + cervixDay(addDays(start, 0), "LOW", "FIRM", "CLOSED"), + cervixDay(addDays(start, 1), "HIGH", "SOFT", "OPEN"), // fertile peak + cervixDay(addDays(start, 2), "LOW", "FIRM", "CLOSED"), + cervixDay(addDays(start, 3), "LOW", "FIRM", "CLOSED"), + cervixDay(addDays(start, 4), "LOW", "FIRM", "CLOSED"), + ]; + expect(detectCervixPeak(logs)).toBe(addDays(start, 1)); + }); + + it("detectCervixPeak: returns null until 3 closed days follow the fertile peak", () => { + const start = "2024-01-01"; + const logs = [ + cervixDay(addDays(start, 0), "HIGH", "SOFT", "OPEN"), + cervixDay(addDays(start, 1), "LOW", "FIRM", "CLOSED"), + cervixDay(addDays(start, 2), "LOW", "FIRM", "CLOSED"), + ]; + expect(detectCervixPeak(logs)).toBeNull(); + }); + + it("confirmSymptothermal(CERVIX): a cervix peak + temp shift confirms like mucus does", () => { + const logs: DayLogInput[] = []; + const start = "2024-01-01"; + [36.4, 36.42, 36.38, 36.41, 36.4, 36.39].forEach((t, i) => + logs.push(bbtDay(addDays(start, i), t)), + ); + logs.push(bbtDay(addDays(start, 6), 36.55)); + logs.push(bbtDay(addDays(start, 7), 36.6)); + logs.push(bbtDay(addDays(start, 8), 36.62)); + // temp ovulation = 2024-01-06. Cervix peak on 2024-01-06, confirmed by 3 + // closed days after it (cervix-closure rule). + logs.push(cervixDay("2024-01-06", "HIGH", "SOFT", "OPEN")); + logs.push(cervixDay("2024-01-07", "LOW", "FIRM", "CLOSED")); + logs.push(cervixDay("2024-01-08", "LOW", "FIRM", "CLOSED")); + logs.push(cervixDay("2024-01-09", "LOW", "FIRM", "CLOSED")); + expect(confirmSymptothermal(logs, "CERVIX")).toBe("2024-01-06"); + }); + + it("confirmSymptothermal(CERVIX): a cervix sign alone (no temp shift) never confirms", () => { + const start = "2024-01-01"; + // A confirmed cervix peak but a FLAT temperature series (no rise). + const logs: DayLogInput[] = []; + [36.4, 36.41, 36.39, 36.4, 36.42, 36.4, 36.41, 36.4, 36.39].forEach( + (t, i) => logs.push(bbtDay(addDays(start, i), t)), + ); + logs.push(cervixDay(addDays(start, 4), "HIGH", "SOFT", "OPEN")); + logs.push(cervixDay(addDays(start, 5), "LOW", "FIRM", "CLOSED")); + logs.push(cervixDay(addDays(start, 6), "LOW", "FIRM", "CLOSED")); + logs.push(cervixDay(addDays(start, 7), "LOW", "FIRM", "CLOSED")); + // Single sign (cervix) without the temperature double-check → no confirmation. + expect(confirmSymptothermal(logs, "CERVIX")).toBeNull(); + }); + + it("predictCycle(CERVIX): a cervix-peak + temp shift confirms the same boundary as mucus", () => { + const cervixProfile: CycleProfileInput = { + ...BASE_PROFILE, + secondarySymptom: "CERVIX", + }; + const cycles = cyclesFromGaps("2024-01-01", [28, 28, 28]); + const lastStart = cycles[cycles.length - 1].startDate; + const logs: DayLogInput[] = []; + const ovDay = addDays(lastStart, 13); + const riseStart = addDays(ovDay, 1); + const baselineStart = addDays(riseStart, -6); + [36.4, 36.41, 36.39, 36.4, 36.42, 36.4].forEach((t, i) => + logs.push(bbtDay(addDays(baselineStart, i), t)), + ); + logs.push(bbtDay(riseStart, 36.6)); + logs.push(bbtDay(addDays(riseStart, 1), 36.62)); + logs.push(bbtDay(addDays(riseStart, 2), 36.63)); + // cervix peak agreeing with the temp ovulation, confirmed by 3 closed days. + logs.push(cervixDay(ovDay, "HIGH", "SOFT", "OPEN")); + logs.push(cervixDay(addDays(ovDay, 1), "LOW", "FIRM", "CLOSED")); + logs.push(cervixDay(addDays(ovDay, 2), "LOW", "FIRM", "CLOSED")); + logs.push(cervixDay(addDays(ovDay, 3), "LOW", "FIRM", "CLOSED")); + + const result = predictCycle( + cycles, + logs, + cervixProfile, + addDays(lastStart, 16), + ); + expect(result.ovulationConfirmed).toBe(true); + expect(result.method).toBe("BLENDED"); + expect(result.predictedOvulation).toBe(ovDay); + }); + it("predictCycle: a confirmed symptothermal ovulation overrides next-start and tightens the band", () => { const cycles = cyclesFromGaps("2024-01-01", [28, 28, 28]); // 3 lengths, last start 2024-03-25 const lastStart = cycles[cycles.length - 1].startDate; diff --git a/src/lib/cycle/backup.ts b/src/lib/cycle/backup.ts index 87a58fb52..a4bda6804 100644 --- a/src/lib/cycle/backup.ts +++ b/src/lib/cycle/backup.ts @@ -21,6 +21,7 @@ export interface CycleBackupSection { typicalCycleLength: number | null; typicalPeriodLength: number | null; lutealPhaseLength: number | null; + secondarySymptom: string; predictionEnabled: boolean; rawChartMode: boolean; discreetNotifications: boolean; @@ -43,6 +44,9 @@ export interface CycleBackupSection { temperatureExcluded: boolean; ovulationTest: string | null; cervicalMucus: string | null; + cervixPosition: string | null; + cervixFirmness: string | null; + cervixOpening: string | null; sexualActivity: boolean; protectedSex: boolean | null; pregnancyTest: string | null; @@ -89,6 +93,7 @@ export async function buildCycleBackupSection( typicalCycleLength: profile.typicalCycleLength, typicalPeriodLength: profile.typicalPeriodLength, lutealPhaseLength: profile.lutealPhaseLength, + secondarySymptom: profile.secondarySymptom, predictionEnabled: profile.predictionEnabled, rawChartMode: profile.rawChartMode, discreetNotifications: profile.discreetNotifications, @@ -112,6 +117,9 @@ export async function buildCycleBackupSection( temperatureExcluded: d.temperatureExcluded, ovulationTest: d.ovulationTest, cervicalMucus: d.cervicalMucus, + cervixPosition: d.cervixPosition, + cervixFirmness: d.cervixFirmness, + cervixOpening: d.cervixOpening, sexualActivity: d.sexualActivity, protectedSex: d.protectedSex, pregnancyTest: d.pregnancyTest, @@ -156,6 +164,10 @@ const CERVICAL_MUCUS = new Set([ "WATERY", "EGG_WHITE", ]); +const SECONDARY_SYMPTOMS = new Set(["MUCUS", "CERVIX"]); +const CERVIX_POSITIONS = new Set(["LOW", "HIGH"]); +const CERVIX_FIRMNESSES = new Set(["FIRM", "SOFT"]); +const CERVIX_OPENINGS = new Set(["CLOSED", "OPEN"]); const HOME_TESTS = new Set(["NEGATIVE", "POSITIVE", "INDETERMINATE"]); const CONTRACEPTIVES = new Set([ "NONE", @@ -221,6 +233,8 @@ export async function restoreCycleData( typicalCycleLength: p.typicalCycleLength ?? null, typicalPeriodLength: p.typicalPeriodLength ?? null, lutealPhaseLength: p.lutealPhaseLength ?? null, + secondarySymptom: (enumOrNull(p.secondarySymptom, SECONDARY_SYMPTOMS) ?? + "MUCUS") as never, predictionEnabled: p.predictionEnabled ?? true, rawChartMode: p.rawChartMode ?? false, discreetNotifications: p.discreetNotifications ?? false, @@ -294,6 +308,15 @@ export async function restoreCycleData( temperatureExcluded: d.temperatureExcluded ?? false, ovulationTest: enumOrNull(d.ovulationTest, OVULATION_TESTS) as never, cervicalMucus: enumOrNull(d.cervicalMucus, CERVICAL_MUCUS) as never, + cervixPosition: enumOrNull( + d.cervixPosition, + CERVIX_POSITIONS, + ) as never, + cervixFirmness: enumOrNull( + d.cervixFirmness, + CERVIX_FIRMNESSES, + ) as never, + cervixOpening: enumOrNull(d.cervixOpening, CERVIX_OPENINGS) as never, sexualActivity: d.sexualActivity ?? false, protectedSex: d.protectedSex ?? null, pregnancyTest: enumOrNull(d.pregnancyTest, HOME_TESTS) as never, diff --git a/src/lib/cycle/day-log-write.ts b/src/lib/cycle/day-log-write.ts index 9d70d599e..1113c506f 100644 --- a/src/lib/cycle/day-log-write.ts +++ b/src/lib/cycle/day-log-write.ts @@ -143,6 +143,9 @@ type ExistingRow = { temperatureExcluded: boolean; ovulationTest: string | null; cervicalMucus: string | null; + cervixPosition: string | null; + cervixFirmness: string | null; + cervixOpening: string | null; sexualActivity: boolean; protectedSex: boolean | null; pregnancyTest: string | null; @@ -162,6 +165,9 @@ const EXISTING_SELECT = { temperatureExcluded: true, ovulationTest: true, cervicalMucus: true, + cervixPosition: true, + cervixFirmness: true, + cervixOpening: true, sexualActivity: true, protectedSex: true, pregnancyTest: true, @@ -323,6 +329,18 @@ async function writeDayLog(args: WriteArgs): Promise { entry.cervicalMucus !== undefined ? (entry.cervicalMucus ?? null) : (existing?.cervicalMucus ?? null); + const cervixPosition = + entry.cervixPosition !== undefined + ? (entry.cervixPosition ?? null) + : (existing?.cervixPosition ?? null); + const cervixFirmness = + entry.cervixFirmness !== undefined + ? (entry.cervixFirmness ?? null) + : (existing?.cervixFirmness ?? null); + const cervixOpening = + entry.cervixOpening !== undefined + ? (entry.cervixOpening ?? null) + : (existing?.cervixOpening ?? null); // Split the sensitive fields between plaintext columns and the envelope // depending on the flag. When encrypting, the plaintext columns are NULL @@ -347,6 +365,9 @@ async function writeDayLog(args: WriteArgs): Promise { temperatureExcluded, ovulationTest: ovulationTest as never, cervicalMucus: cervicalMucus as never, + cervixPosition: cervixPosition as never, + cervixFirmness: cervixFirmness as never, + cervixOpening: cervixOpening as never, sexualActivity: sensitivePlaintext.sexualActivity, protectedSex: sensitivePlaintext.protectedSex, pregnancyTest: sensitivePlaintext.pregnancyTest as never, @@ -367,6 +388,9 @@ async function writeDayLog(args: WriteArgs): Promise { existing.temperatureExcluded !== temperatureExcluded || (existing.ovulationTest ?? null) !== ovulationTest || (existing.cervicalMucus ?? null) !== cervicalMucus || + (existing.cervixPosition ?? null) !== cervixPosition || + (existing.cervixFirmness ?? null) !== cervixFirmness || + (existing.cervixOpening ?? null) !== cervixOpening || storedSensitive.sexualActivity !== resolvedSensitive.sexualActivity || storedSensitive.protectedSex !== resolvedSensitive.protectedSex || storedSensitive.pregnancyTest !== resolvedSensitive.pregnancyTest || @@ -406,6 +430,9 @@ async function writeDayLog(args: WriteArgs): Promise { temperatureExcluded: baseData.temperatureExcluded as never, ovulationTest: baseData.ovulationTest as never, cervicalMucus: baseData.cervicalMucus as never, + cervixPosition: baseData.cervixPosition as never, + cervixFirmness: baseData.cervixFirmness as never, + cervixOpening: baseData.cervixOpening as never, sexualActivity: sensitivePlaintext.sexualActivity, protectedSex: sensitivePlaintext.protectedSex, pregnancyTest: sensitivePlaintext.pregnancyTest as never, diff --git a/src/lib/cycle/dto.ts b/src/lib/cycle/dto.ts index 7b43456a4..3908ac9fa 100644 --- a/src/lib/cycle/dto.ts +++ b/src/lib/cycle/dto.ts @@ -33,6 +33,10 @@ export interface CycleDayLogDTO { temperatureExcluded: boolean; ovulationTest: string | null; cervicalMucus: string | null; + /** Cervix observation signs (symptothermal secondary indicator). */ + cervixPosition: string | null; + cervixFirmness: string | null; + cervixOpening: string | null; sexualActivity: boolean; protectedSex: boolean | null; pregnancyTest: string | null; @@ -112,6 +116,9 @@ export function toCycleDayLogDTO(row: DayLogWithLinks): CycleDayLogDTO { temperatureExcluded: row.temperatureExcluded, ovulationTest: row.ovulationTest, cervicalMucus: row.cervicalMucus, + cervixPosition: row.cervixPosition, + cervixFirmness: row.cervixFirmness, + cervixOpening: row.cervixOpening, sexualActivity: hasEnvelope ? (enc.sexualActivity ?? false) : row.sexualActivity, @@ -206,6 +213,8 @@ export function toCyclePredictionDTO( export interface CycleProfileDTO { goal: string; cycleTrackingEnabled: boolean; + /** Symptothermal secondary symptom — MUCUS (default) or CERVIX. */ + secondarySymptom: string; rawChartMode: boolean; predictionEnabled: boolean; discreetNotifications: boolean; @@ -223,6 +232,7 @@ export function toCycleProfileDTO( return { goal: row.goal, cycleTrackingEnabled, + secondarySymptom: row.secondarySymptom, rawChartMode: row.rawChartMode, predictionEnabled: row.predictionEnabled, discreetNotifications: row.discreetNotifications, @@ -236,6 +246,7 @@ export function toCycleProfileDTO( type MenstrualCycleProfileRow = { goal: string; + secondarySymptom: string; rawChartMode: boolean; predictionEnabled: boolean; discreetNotifications: boolean; diff --git a/src/lib/cycle/engine-adapter.ts b/src/lib/cycle/engine-adapter.ts index 5afc60d09..a6c3e1901 100644 --- a/src/lib/cycle/engine-adapter.ts +++ b/src/lib/cycle/engine-adapter.ts @@ -54,6 +54,9 @@ export type CalendarDayLogRow = Pick< | "temperatureExcluded" | "ovulationTest" | "cervicalMucus" + | "cervixPosition" + | "cervixFirmness" + | "cervixOpening" > & { hasSymptoms: boolean }; /** Map MenstrualCycle rows (oldest→newest) to engine `CycleInput`. */ @@ -86,6 +89,9 @@ export function toDayLogInputs( | "temperatureExcluded" | "ovulationTest" | "cervicalMucus" + | "cervixPosition" + | "cervixFirmness" + | "cervixOpening" >[], ): DayLogInput[] { return logs.map((l) => ({ @@ -95,6 +101,9 @@ export function toDayLogInputs( temperatureExcluded: l.temperatureExcluded, ovulationTest: l.ovulationTest, cervicalMucus: l.cervicalMucus, + cervixPosition: l.cervixPosition, + cervixFirmness: l.cervixFirmness, + cervixOpening: l.cervixOpening, })); } @@ -107,6 +116,7 @@ export function toProfileInput(profile: CycleProfile): CycleProfileInput { lutealPhaseLength: profile.lutealPhaseLength, predictionEnabled: profile.predictionEnabled, rawChartMode: profile.rawChartMode, + secondarySymptom: profile.secondarySymptom, }; } diff --git a/src/lib/cycle/prediction.ts b/src/lib/cycle/prediction.ts index 716f6b3fe..4569f3933 100644 --- a/src/lib/cycle/prediction.ts +++ b/src/lib/cycle/prediction.ts @@ -57,6 +57,7 @@ import { type CycleProfileInput, type DayLogInput, type NightlyTempInput, + type SecondarySymptom, type PredictionMethod, } from "./types"; @@ -427,16 +428,89 @@ export function detectMucusPeak( } /** - * §4.2 — symptothermal CONFIRMATION: ovulation is confirmed only when the - * temp-shift day and the mucus-peak agree within ±2 days. Returns the - * temp-derived ovulation day when confirmed, else null. + * §4.2(b') — sensiplan cervix peak (Höhepunkt), the CERVIX-secondary-symptom + * analogue of `detectMucusPeak`. The cervix is a fertile sign while it is HIGH, + * SOFT, or OPEN (any of the three Sensiplan cervix signs in its fertile state); + * it is infertile only when fully closed — LOW + FIRM + CLOSED. The peak day is + * the LAST fertile-cervix day FOLLOWED by at least 3 consecutive closed + * (infertile) observed days (the cervix-closure rule). Like the mucus peak it is + * only confirmable retrospectively, so a stray late fertile-cervix entry not yet + * followed by 3 closed days cannot move an already-confirmed peak. + * + * A cervix "day" counts as observed when at least one of the three signs is + * logged (an unlogged sign is treated as its fertile/uncertain state so a + * partial entry never fabricates a closure). Returns the confirmed peak day, or + * null when no fertile-cervix day has yet been followed by 3 closed days. + * + * Citation: cervix observation as a Sensiplan secondary sign — the cervix opens, + * softens and rises around ovulation and closes, firms and lowers afterward; + * evaluation follows the same "last fertile day + 3 confirming days" shape as + * mucus. Arbeitsgruppe NFP / Raith-Paula & Frank-Herrmann; myNFP "Den + * Gebärmutterhals beobachten". + */ +export function detectCervixPeak( + dayLogs: readonly DayLogInput[], +): string | null { + // Observed cervix days only (at least one sign logged), oldest→newest. + const observed = dayLogs + .filter( + (l) => + l.cervixPosition != null || + l.cervixFirmness != null || + l.cervixOpening != null, + ) + .map((l) => ({ date: l.date, closed: isCervixClosed(l) })) + .sort((a, b) => dayDiff(a.date, b.date)); + + // Walk every fertile-cervix day; a candidate peak is confirmed iff the next 3 + // observed cervix days are all closed (infertile). Scan forward and keep the + // LATEST confirmed peak (a true later peak supersedes an earlier one). + let confirmedPeak: string | null = null; + for (let i = 0; i < observed.length; i++) { + if (observed[i].closed) continue; // only a fertile-cervix day can be a peak + const following = observed.slice(i + 1, i + 4); + if (following.length < 3) continue; // not yet evaluable + if (following.every((d) => d.closed)) confirmedPeak = observed[i].date; + } + return confirmedPeak; +} + +/** + * A cervix is in its infertile (closed) state only when ALL three signs read + * infertile: LOW + FIRM + CLOSED. Any fertile sign (HIGH / SOFT / OPEN) — or an + * unlogged sign, treated conservatively as not-yet-closed — keeps the day + * fertile, so a partial entry never fabricates a closure. + */ +function isCervixClosed(log: DayLogInput): boolean { + return ( + log.cervixPosition === "LOW" && + log.cervixFirmness === "FIRM" && + log.cervixOpening === "CLOSED" + ); +} + +/** + * §4.2 — symptothermal CONFIRMATION (the Sensiplan double-check): ovulation is + * confirmed only when the temperature primary sign AND the chosen SECONDARY sign + * both confirm and agree within ±2 days. The secondary sign is the cervical + * mucus peak by default, or the cervix peak when `secondarySymptom` is `CERVIX`. + * Returns the temp-derived ovulation day when confirmed, else null. + * + * A single sign never confirms — no temperature shift returns null regardless of + * the secondary sign, and no secondary peak returns null regardless of the + * temperature. This is the honesty principle: the double-check requires two + * indicators, never one. */ export function confirmSymptothermal( dayLogs: readonly DayLogInput[], + secondarySymptom: SecondarySymptom = "MUCUS", ): string | null { const shift = detectTempShift(dayLogs, TEMP_SHIFT_C_MANUAL); if (!shift) return null; - const peak = detectMucusPeak(dayLogs); + const peak = + secondarySymptom === "CERVIX" + ? detectCervixPeak(dayLogs) + : detectMucusPeak(dayLogs); if (!peak) return null; if ( Math.abs(dayDiff(shift.ovulationDate, peak)) <= SYMPTOTHERMAL_AGREE_DAYS @@ -671,7 +745,10 @@ export function predictCycle( dayDiff(date, windowStart) >= 0 && dayDiff(today, date) >= 0; const currentDayLogs = dayLogs.filter((l) => isInCurrentWindow(l.date)); const currentNights = nights.filter((n) => isInCurrentWindow(n.date)); - const symptoOvulation = confirmSymptothermal(currentDayLogs); + const symptoOvulation = confirmSymptothermal( + currentDayLogs, + profile.secondarySymptom, + ); const trendOvulation = symptoOvulation ? null : detectTemperatureTrend(currentNights); diff --git a/src/lib/cycle/types.ts b/src/lib/cycle/types.ts index 062cdc5fd..29c86a7dd 100644 --- a/src/lib/cycle/types.ts +++ b/src/lib/cycle/types.ts @@ -34,6 +34,22 @@ export type CervicalMucus = | "WATERY" | "EGG_WHITE"; +/** + * Which symptothermal SECONDARY sign pairs with the temperature primary sign + * for the Sensiplan double-check. `MUCUS` (cervical-mucus quality) is the + * default; `CERVIX` swaps in the cervix observation. + */ +export type SecondarySymptom = "MUCUS" | "CERVIX"; + +/** Cervix vertical position — fertile = HIGH, infertile = LOW. */ +export type CervixPosition = "LOW" | "HIGH"; + +/** Cervix firmness — fertile = SOFT, infertile = FIRM. */ +export type CervixFirmness = "FIRM" | "SOFT"; + +/** Cervix os / opening — fertile = OPEN, infertile = CLOSED. */ +export type CervixOpening = "CLOSED" | "OPEN"; + export type CyclePhase = "MENSTRUAL" | "FOLLICULAR" | "OVULATORY" | "LUTEAL"; export type PredictionMethod = @@ -215,6 +231,15 @@ export interface DayLogInput { temperatureExcluded?: boolean | null; ovulationTest: OvulationTest | null; cervicalMucus: CervicalMucus | null; + /** + * Cervix observation — the three Sensiplan cervix signs. Used as the + * symptothermal SECONDARY indicator when the profile's `secondarySymptom` + * is `CERVIX`; ignored on the default (mucus) path. All optional for + * back-compat; an unobserved sign is `null`/absent. + */ + cervixPosition?: CervixPosition | null; + cervixFirmness?: CervixFirmness | null; + cervixOpening?: CervixOpening | null; } /** @@ -240,6 +265,13 @@ export interface CycleProfileInput { predictionEnabled: boolean; /** Read-Your-Body mode: suppress all interpretation. */ rawChartMode: boolean; + /** + * Which symptothermal SECONDARY sign the double-check uses — `MUCUS` + * (default) or `CERVIX`. Optional for back-compat; the engine treats an + * absent value as `MUCUS`, so existing callers keep the mucus path + * unchanged. + */ + secondarySymptom?: SecondarySymptom; } /* ------------------------------------------------------------------ */ diff --git a/src/lib/openapi/routes/cycle.ts b/src/lib/openapi/routes/cycle.ts index fe6ef6391..b9880a8d6 100644 --- a/src/lib/openapi/routes/cycle.ts +++ b/src/lib/openapi/routes/cycle.ts @@ -11,6 +11,10 @@ import { flowLevelEnum, ovulationTestEnum, cervicalMucusEnum, + secondarySymptomEnum, + cervixPositionEnum, + cervixFirmnessEnum, + cervixOpeningEnum, homeTestResultEnum, cycleTrackingGoalEnum, cycleDayLogInputSchema, @@ -104,6 +108,9 @@ const cycleDayLogDto = z temperatureExcluded: z.boolean(), ovulationTest: ovulationTestEnum.nullable(), cervicalMucus: cervicalMucusEnum.nullable(), + cervixPosition: cervixPositionEnum.nullable(), + cervixFirmness: cervixFirmnessEnum.nullable(), + cervixOpening: cervixOpeningEnum.nullable(), sexualActivity: z.boolean(), protectedSex: z.boolean().nullable(), pregnancyTest: homeTestResultEnum.nullable(), @@ -179,12 +186,16 @@ const cycleCalendarDayDto = z.object({ temperatureExcluded: z.boolean(), ovulationTest: ovulationTestEnum.nullable(), cervicalMucus: cervicalMucusEnum.nullable(), + cervixPosition: cervixPositionEnum.nullable(), + cervixFirmness: cervixFirmnessEnum.nullable(), + cervixOpening: cervixOpeningEnum.nullable(), }); const cycleProfileDto = z .object({ goal: cycleTrackingGoalEnum, cycleTrackingEnabled: z.boolean(), + secondarySymptom: secondarySymptomEnum, rawChartMode: z.boolean(), predictionEnabled: z.boolean(), discreetNotifications: z.boolean(), diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index 283c5c306..f3c2923aa 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -144,6 +144,9 @@ const cycleDayLogSchema = z temperatureExcluded: z.boolean().optional(), ovulationTest: z.string().nullable().optional(), cervicalMucus: z.string().nullable().optional(), + cervixPosition: z.string().nullable().optional(), + cervixFirmness: z.string().nullable().optional(), + cervixOpening: z.string().nullable().optional(), sexualActivity: z.boolean().optional(), protectedSex: z.boolean().nullable().optional(), pregnancyTest: z.string().nullable().optional(), @@ -166,6 +169,7 @@ const cycleProfileSchema = z typicalCycleLength: z.number().int().nullable().optional(), typicalPeriodLength: z.number().int().nullable().optional(), lutealPhaseLength: z.number().int().nullable().optional(), + secondarySymptom: z.string().nullable().optional(), predictionEnabled: z.boolean().optional(), rawChartMode: z.boolean().optional(), discreetNotifications: z.boolean().optional(), diff --git a/src/lib/validations/cycle.ts b/src/lib/validations/cycle.ts index c19ac0bb4..ced6da11d 100644 --- a/src/lib/validations/cycle.ts +++ b/src/lib/validations/cycle.ts @@ -51,6 +51,14 @@ export const cervicalMucusEnum = z.enum([ "EGG_WHITE", ]); +/** Symptothermal secondary-symptom choice (mirrors the Prisma enum). */ +export const secondarySymptomEnum = z.enum(["MUCUS", "CERVIX"]); + +/** The three Sensiplan cervix signs (mirror the Prisma enums). */ +export const cervixPositionEnum = z.enum(["LOW", "HIGH"]); +export const cervixFirmnessEnum = z.enum(["FIRM", "SOFT"]); +export const cervixOpeningEnum = z.enum(["CLOSED", "OPEN"]); + export const homeTestResultEnum = z.enum([ "NEGATIVE", "POSITIVE", @@ -108,6 +116,11 @@ export const cycleDayLogInputSchema = z.object({ temperatureExcluded: z.boolean().optional(), ovulationTest: ovulationTestEnum.optional(), cervicalMucus: cervicalMucusEnum.optional(), + // Cervix observation — the symptothermal secondary indicator when the + // profile's secondarySymptom is CERVIX. + cervixPosition: cervixPositionEnum.optional(), + cervixFirmness: cervixFirmnessEnum.optional(), + cervixOpening: cervixOpeningEnum.optional(), sexualActivity: z.boolean().optional(), protectedSex: z.boolean().nullable().optional(), pregnancyTest: homeTestResultEnum.optional(), @@ -138,6 +151,9 @@ export const cycleDayLogPatchSchema = z.object({ temperatureExcluded: z.boolean().optional(), ovulationTest: ovulationTestEnum.nullable().optional(), cervicalMucus: cervicalMucusEnum.nullable().optional(), + cervixPosition: cervixPositionEnum.nullable().optional(), + cervixFirmness: cervixFirmnessEnum.nullable().optional(), + cervixOpening: cervixOpeningEnum.nullable().optional(), sexualActivity: z.boolean().optional(), protectedSex: z.boolean().nullable().optional(), pregnancyTest: homeTestResultEnum.nullable().optional(), @@ -192,6 +208,9 @@ export const cyclePrefsSchema = z .object({ enabled: z.boolean().optional(), goal: cycleTrackingGoalEnum.optional(), + // Symptothermal secondary symptom — advanced cycle setting (MUCUS default + // | CERVIX). Drives which second sign the double-check uses. + secondarySymptom: secondarySymptomEnum.optional(), rawChartMode: z.boolean().optional(), predictionEnabled: z.boolean().optional(), discreetNotifications: z.boolean().optional(), From e83222240279df04807fef6217b6984d39995729 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 15:33:11 +0200 Subject: [PATCH 08/12] feat(cycle): surface the cervix secondary-symptom choice in the UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Advanced cycle settings gains a "secondary fertility sign" selector (cervical mucus default | cervix observation). It sits below the priors in the advanced settings so it never forces itself on a casual user. - The log-day sheet shows the cervix entry — position / firmness / opening chips — only when the user picked cervix observation; otherwise the cervical-mucus picker stays as before. Progressive disclosure: exactly one secondary sign is offered at a time so the sheet never overwhelms. - Web client types, the cyclePrefs patch, and the day-log payload builders carry the new fields; copy added across all six locales. --- messages/de.json | 22 +++ messages/en.json | 22 +++ messages/es.json | 22 +++ messages/fr.json | 22 +++ messages/it.json | 22 +++ messages/pl.json | 22 +++ .../cycle/__tests__/log-day-sheet.test.ts | 21 +++ src/components/cycle/cycle-settings.tsx | 39 +++++- src/components/cycle/log-day-sheet.tsx | 126 ++++++++++++++++-- src/components/cycle/types.ts | 15 +++ src/components/cycle/use-cycle.ts | 7 + 11 files changed, 325 insertions(+), 15 deletions(-) diff --git a/messages/de.json b/messages/de.json index 86bdf4591..4b0b74011 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5797,6 +5797,18 @@ "EGG_WHITE": "Eiweißartig", "label": "Zervixschleim" }, + "cervixPosition": { + "LOW": "Tief", + "HIGH": "Hoch" + }, + "cervixFirmness": { + "FIRM": "Fest", + "SOFT": "Weich" + }, + "cervixOpening": { + "CLOSED": "Geschlossen", + "OPEN": "Geöffnet" + }, "goal": { "GENERAL_HEALTH": "Allgemeine Gesundheit", "AVOID_PREGNANCY": "Verhütung", @@ -5886,6 +5898,10 @@ "temperatureDisturbedHint": "Fieber, Krankheit, Alkohol oder eine späte Messung — wird von der Temperaturauswertung ausgeschlossen.", "ovulationTest": "Ovulationstest", "mucus": "Zervixschleim", + "cervix": "Gebärmutterhals", + "cervixPosition": "Position", + "cervixFirmness": "Festigkeit", + "cervixOpening": "Öffnung", "intercourse": "Geschlechtsverkehr", "protected": "Geschützt", "note": "Notiz", @@ -5915,6 +5931,12 @@ "priorsDescription": "Diese Vorgaben helfen deinen Prognosen, bevor du viele Zyklen erfasst hast. Leer lassen, um die Standardwerte zu nutzen.", "rawChartMode": "Roh-Diagramm-Modus", "rawChartModeDescription": "Zeige nur deine erfassten Daten, ohne Prognosen oder Deutung.", + "secondarySymptomLabel": "Zweites Fruchtbarkeitszeichen", + "secondarySymptomDescription": "Das zweite Zeichen, das für die symptothermale Doppelkontrolle mit deiner Temperatur kombiniert wird. Die meisten beobachten den Zervixschleim; wechsle zur Gebärmutterhals-Beobachtung, wenn du das bevorzugst.", + "secondarySymptom": { + "MUCUS": "Zervixschleim", + "CERVIX": "Gebärmutterhals" + }, "prediction": "Prognosen", "predictionDescription": "Prognostiziere deine nächste Periode und, wenn relevant, dein fruchtbares Fenster.", "encryption": "Sensible Einträge verschlüsseln", diff --git a/messages/en.json b/messages/en.json index 3ee745a08..ac02d4119 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5797,6 +5797,18 @@ "EGG_WHITE": "Egg white", "label": "Cervical mucus" }, + "cervixPosition": { + "LOW": "Low", + "HIGH": "High" + }, + "cervixFirmness": { + "FIRM": "Firm", + "SOFT": "Soft" + }, + "cervixOpening": { + "CLOSED": "Closed", + "OPEN": "Open" + }, "goal": { "GENERAL_HEALTH": "General health", "AVOID_PREGNANCY": "Avoiding pregnancy", @@ -5886,6 +5898,10 @@ "temperatureDisturbedHint": "Fever, illness, alcohol or a late measurement — excluded from the temperature evaluation.", "ovulationTest": "Ovulation test", "mucus": "Cervical mucus", + "cervix": "Cervix", + "cervixPosition": "Position", + "cervixFirmness": "Firmness", + "cervixOpening": "Opening", "intercourse": "Intercourse", "protected": "Protected", "intermenstrualBleeding": "Spotting between periods", @@ -5920,6 +5936,12 @@ "priorsDescription": "These priors help your predictions before you have logged many cycles. Leave blank to use the defaults.", "rawChartMode": "Raw chart mode", "rawChartModeDescription": "Show only your logged data with no predictions or interpretation.", + "secondarySymptomLabel": "Secondary fertility sign", + "secondarySymptomDescription": "The second sign paired with your temperature for the symptothermal double-check. Most people track cervical mucus; switch to cervix observation if you prefer.", + "secondarySymptom": { + "MUCUS": "Cervical mucus", + "CERVIX": "Cervix observation" + }, "prediction": "Predictions", "predictionDescription": "Forecast your next period and, when relevant, your fertile window.", "encryption": "Encrypt sensitive entries", diff --git a/messages/es.json b/messages/es.json index 6ff2de793..76da38cee 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5797,6 +5797,18 @@ "EGG_WHITE": "Clara de huevo", "label": "Moco cervical" }, + "cervixPosition": { + "LOW": "Bajo", + "HIGH": "Alto" + }, + "cervixFirmness": { + "FIRM": "Firme", + "SOFT": "Blando" + }, + "cervixOpening": { + "CLOSED": "Cerrado", + "OPEN": "Abierto" + }, "goal": { "GENERAL_HEALTH": "Salud general", "AVOID_PREGNANCY": "Evitar el embarazo", @@ -5886,6 +5898,10 @@ "temperatureDisturbedHint": "Fiebre, enfermedad, alcohol o una medición tardía: se excluye de la evaluación de temperatura.", "ovulationTest": "Test de ovulación", "mucus": "Moco cervical", + "cervix": "Cuello uterino", + "cervixPosition": "Posición", + "cervixFirmness": "Firmeza", + "cervixOpening": "Apertura", "intercourse": "Relaciones", "protected": "Protegidas", "note": "Nota", @@ -5915,6 +5931,12 @@ "priorsDescription": "Estos valores ayudan a tus predicciones antes de registrar muchos ciclos. Déjalos en blanco para usar los predeterminados.", "rawChartMode": "Modo de gráfico en bruto", "rawChartModeDescription": "Muestra solo tus datos registrados, sin predicciones ni interpretación.", + "secondarySymptomLabel": "Segundo signo de fertilidad", + "secondarySymptomDescription": "El segundo signo que se combina con tu temperatura para la doble comprobación sintotérmica. La mayoría observa el moco cervical; cambia a la observación del cuello uterino si lo prefieres.", + "secondarySymptom": { + "MUCUS": "Moco cervical", + "CERVIX": "Cuello uterino" + }, "prediction": "Predicciones", "predictionDescription": "Pronostica tu próximo periodo y, cuando corresponda, tu ventana fértil.", "encryption": "Cifrar entradas sensibles", diff --git a/messages/fr.json b/messages/fr.json index 1ac723285..fa3dabdd2 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5797,6 +5797,18 @@ "EGG_WHITE": "Blanc d'œuf", "label": "Glaire cervicale" }, + "cervixPosition": { + "LOW": "Bas", + "HIGH": "Haut" + }, + "cervixFirmness": { + "FIRM": "Ferme", + "SOFT": "Souple" + }, + "cervixOpening": { + "CLOSED": "Fermé", + "OPEN": "Ouvert" + }, "goal": { "GENERAL_HEALTH": "Santé générale", "AVOID_PREGNANCY": "Éviter une grossesse", @@ -5886,6 +5898,10 @@ "temperatureDisturbedHint": "Fièvre, maladie, alcool ou mesure tardive — exclue de l'évaluation de la température.", "ovulationTest": "Test d'ovulation", "mucus": "Glaire cervicale", + "cervix": "Col de l'utérus", + "cervixPosition": "Position", + "cervixFirmness": "Fermeté", + "cervixOpening": "Ouverture", "intercourse": "Rapports", "protected": "Protégés", "note": "Note", @@ -5915,6 +5931,12 @@ "priorsDescription": "Ces valeurs aident tes prévisions avant d'avoir enregistré beaucoup de cycles. Laisse vide pour utiliser les valeurs par défaut.", "rawChartMode": "Mode graphique brut", "rawChartModeDescription": "Affiche uniquement tes données enregistrées, sans prévision ni interprétation.", + "secondarySymptomLabel": "Deuxième signe de fertilité", + "secondarySymptomDescription": "Le deuxième signe associé à ta température pour la double vérification sympto-thermique. La plupart des personnes observent la glaire cervicale ; passe à l'observation du col de l'utérus si tu préfères.", + "secondarySymptom": { + "MUCUS": "Glaire cervicale", + "CERVIX": "Col de l'utérus" + }, "prediction": "Prévisions", "predictionDescription": "Prévois tes prochaines règles et, le cas échéant, ta fenêtre fertile.", "encryption": "Chiffrer les entrées sensibles", diff --git a/messages/it.json b/messages/it.json index b9f2be8b7..58232fc1b 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5797,6 +5797,18 @@ "EGG_WHITE": "Albume", "label": "Muco cervicale" }, + "cervixPosition": { + "LOW": "Basso", + "HIGH": "Alto" + }, + "cervixFirmness": { + "FIRM": "Duro", + "SOFT": "Morbido" + }, + "cervixOpening": { + "CLOSED": "Chiuso", + "OPEN": "Aperto" + }, "goal": { "GENERAL_HEALTH": "Salute generale", "AVOID_PREGNANCY": "Evitare la gravidanza", @@ -5886,6 +5898,10 @@ "temperatureDisturbedHint": "Febbre, malattia, alcol o una misurazione tardiva: esclusa dalla valutazione della temperatura.", "ovulationTest": "Test di ovulazione", "mucus": "Muco cervicale", + "cervix": "Cervice", + "cervixPosition": "Posizione", + "cervixFirmness": "Consistenza", + "cervixOpening": "Apertura", "intercourse": "Rapporti", "protected": "Protetti", "note": "Nota", @@ -5915,6 +5931,12 @@ "priorsDescription": "Questi valori aiutano le previsioni prima di aver registrato molti cicli. Lascia vuoto per usare i valori predefiniti.", "rawChartMode": "Modalità grafico grezzo", "rawChartModeDescription": "Mostra solo i tuoi dati registrati, senza previsioni né interpretazioni.", + "secondarySymptomLabel": "Secondo segno di fertilità", + "secondarySymptomDescription": "Il secondo segno abbinato alla tua temperatura per il doppio controllo sintotermico. La maggior parte osserva il muco cervicale; passa all'osservazione della cervice se preferisci.", + "secondarySymptom": { + "MUCUS": "Muco cervicale", + "CERVIX": "Cervice" + }, "prediction": "Previsioni", "predictionDescription": "Prevedi il prossimo ciclo e, quando rilevante, la tua finestra fertile.", "encryption": "Cifra le voci sensibili", diff --git a/messages/pl.json b/messages/pl.json index e60e33682..00c80f0b9 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5797,6 +5797,18 @@ "EGG_WHITE": "Jak białko jaja", "label": "Śluz szyjkowy" }, + "cervixPosition": { + "LOW": "Nisko", + "HIGH": "Wysoko" + }, + "cervixFirmness": { + "FIRM": "Twarda", + "SOFT": "Miękka" + }, + "cervixOpening": { + "CLOSED": "Zamknięta", + "OPEN": "Otwarta" + }, "goal": { "GENERAL_HEALTH": "Ogólne zdrowie", "AVOID_PREGNANCY": "Unikanie ciąży", @@ -5886,6 +5898,10 @@ "temperatureDisturbedHint": "Gorączka, choroba, alkohol lub spóźniony pomiar — wykluczony z oceny temperatury.", "ovulationTest": "Test owulacyjny", "mucus": "Śluz szyjkowy", + "cervix": "Szyjka macicy", + "cervixPosition": "Położenie", + "cervixFirmness": "Twardość", + "cervixOpening": "Rozwarcie", "intercourse": "Współżycie", "protected": "Zabezpieczone", "note": "Notatka", @@ -5915,6 +5931,12 @@ "priorsDescription": "Te wartości pomagają prognozom, zanim zapiszesz wiele cykli. Zostaw puste, aby użyć wartości domyślnych.", "rawChartMode": "Tryb surowego wykresu", "rawChartModeDescription": "Pokazuj tylko zapisane dane, bez prognoz i interpretacji.", + "secondarySymptomLabel": "Drugi objaw płodności", + "secondarySymptomDescription": "Drugi objaw łączony z temperaturą w podwójnej kontroli objawowo-termicznej. Większość obserwuje śluz szyjkowy; przełącz na obserwację szyjki macicy, jeśli wolisz.", + "secondarySymptom": { + "MUCUS": "Śluz szyjkowy", + "CERVIX": "Szyjka macicy" + }, "prediction": "Prognozy", "predictionDescription": "Prognozuj następną miesiączkę oraz, gdy to istotne, okno płodne.", "encryption": "Szyfruj wrażliwe wpisy", diff --git a/src/components/cycle/__tests__/log-day-sheet.test.ts b/src/components/cycle/__tests__/log-day-sheet.test.ts index 00e6a5933..8c9671e01 100644 --- a/src/components/cycle/__tests__/log-day-sheet.test.ts +++ b/src/components/cycle/__tests__/log-day-sheet.test.ts @@ -14,6 +14,9 @@ function blank(): DayLogFormState { bbtDisturbed: false, opk: null, mucus: null, + cervixPosition: null, + cervixFirmness: null, + cervixOpening: null, intercourse: false, protectedSex: false, pregnancyTest: null, @@ -103,6 +106,24 @@ describe("buildDayLogInput (new row → omit empties)", () => { expect(patch.temperatureExcluded).toBe(true); }); + it("carries the cervix signs through both payload builders", () => { + const state = { + ...blank(), + cervixPosition: "HIGH" as const, + cervixFirmness: "SOFT" as const, + cervixOpening: "OPEN" as const, + }; + const input = buildDayLogInput(state, "2026-06-06"); + expect(input.cervixPosition).toBe("HIGH"); + expect(input.cervixFirmness).toBe("SOFT"); + expect(input.cervixOpening).toBe("OPEN"); + // A blank edit nulls every cervix sign (clear semantics). + const patch = buildDayLogPatch(blank()); + expect(patch.cervixPosition).toBeNull(); + expect(patch.cervixFirmness).toBeNull(); + expect(patch.cervixOpening).toBeNull(); + }); + it("never marks an absent BBT reading disturbed", () => { // A disturbed flag with no temperature is meaningless — it must reset. const patch = buildDayLogPatch({ ...blank(), bbt: "", bbtDisturbed: true }); diff --git a/src/components/cycle/cycle-settings.tsx b/src/components/cycle/cycle-settings.tsx index aa273765d..68b323cca 100644 --- a/src/components/cycle/cycle-settings.tsx +++ b/src/components/cycle/cycle-settings.tsx @@ -17,7 +17,11 @@ import { Switch } from "@/components/ui/switch"; import { NativeSelect } from "@/components/ui/native-select"; import { Separator } from "@/components/ui/separator"; import { useTranslations } from "@/lib/i18n/context"; -import type { CycleGoal, CycleProfileDTO } from "./types"; +import type { + CycleGoal, + CycleProfileDTO, + SecondarySymptom, +} from "./types"; import { useUpdateCyclePrefs } from "./use-cycle"; /** @@ -43,6 +47,8 @@ const GOALS: CycleGoal[] = [ "OFF", ]; +const SECONDARY_SYMPTOMS: SecondarySymptom[] = ["MUCUS", "CERVIX"]; + function numOrEmpty(v: number | null): string { return v == null ? "" : String(v); } @@ -60,6 +66,9 @@ export function CycleSettings({ profile }: { profile: CycleProfileDTO }) { profile.sensitiveCategoryEncryption, ); const [discreet, setDiscreet] = useState(profile.discreetNotifications); + const [secondarySymptom, setSecondarySymptom] = useState( + profile.secondarySymptom, + ); const [cycleLen, setCycleLen] = useState( numOrEmpty(profile.typicalCycleLength), ); @@ -78,6 +87,7 @@ export function CycleSettings({ profile }: { profile: CycleProfileDTO }) { async function handleSave() { await update.mutateAsync({ goal, + secondarySymptom, rawChartMode, predictionEnabled, sensitiveCategoryEncryption: encryption, @@ -167,6 +177,33 @@ export function CycleSettings({ profile }: { profile: CycleProfileDTO }) { + {/* Advanced — symptothermal secondary symptom. The default is mucus; + the choice never forces itself on a casual user (progressive + disclosure: it sits here in the advanced cycle settings). */} +
+ + + setSecondarySymptom(e.target.value as SecondarySymptom) + } + > + {SECONDARY_SYMPTOMS.map((s) => ( + + ))} + +

+ {t("cycle.settings.secondarySymptomDescription")} +

+
+ + + {/* Privacy toggles */} ({ + label, + values, + current, + labelFor, + onToggle, +}: { + label: string; + values: readonly T[]; + current: T | null; + labelFor: (v: T) => string; + onToggle: (v: T) => void; +}) { + return ( +
+

{label}

+
+ {values.map((v) => ( + onToggle(v)}> + {labelFor(v)} + + ))} +
+
+ ); +} + export function LogDaySheet({ open, onOpenChange, @@ -291,6 +335,11 @@ export function LogDaySheet({ const dayLog = useCycleDayLog(open ? date : null); // The caller's custom symptoms, merged into the seeded chip grid. const customSymptoms = useCustomSymptoms(); + // Drives which symptothermal secondary sign the sheet offers — mucus (default) + // or the cervix pickers (progressive disclosure: cervix entry only appears + // when the user chose CERVIX in the advanced cycle settings). + const cycleProfile = useCycleProfile(); + const showCervix = cycleProfile.data?.secondarySymptom === "CERVIX"; const [flow, setFlow] = useState(null); const [intermenstrual, setIntermenstrual] = useState(false); @@ -301,6 +350,13 @@ export function LogDaySheet({ const [bbtDisturbed, setBbtDisturbed] = useState(false); const [opk, setOpk] = useState(null); const [mucus, setMucus] = useState(null); + const [cervixPosition, setCervixPosition] = useState( + null, + ); + const [cervixFirmness, setCervixFirmness] = useState( + null, + ); + const [cervixOpening, setCervixOpening] = useState(null); const [intercourse, setIntercourse] = useState(false); const [protectedSex, setProtectedSex] = useState(false); const [pregnancyTest, setPregnancyTest] = useState( @@ -334,6 +390,9 @@ export function LogDaySheet({ setBbtDisturbed(dto?.temperatureExcluded ?? false); setOpk((dto?.ovulationTest ?? null) as OvulationTest | null); setMucus((dto?.cervicalMucus ?? null) as CervicalMucus | null); + setCervixPosition((dto?.cervixPosition ?? null) as CervixPosition | null); + setCervixFirmness((dto?.cervixFirmness ?? null) as CervixFirmness | null); + setCervixOpening((dto?.cervixOpening ?? null) as CervixOpening | null); setIntercourse(dto?.sexualActivity ?? false); setProtectedSex(dto?.protectedSex ?? false); setPregnancyTest(dto?.pregnancyTest ?? null); @@ -370,6 +429,9 @@ export function LogDaySheet({ bbtDisturbed, opk, mucus, + cervixPosition, + cervixFirmness, + cervixOpening, intercourse, protectedSex, pregnancyTest, @@ -644,20 +706,56 @@ export function LogDaySheet({ - {/* Cervical mucus */} - -
- {MUCUS_VALUES.map((v) => ( - setMucus((cur) => (cur === v ? null : v))} - > - {t(`cycle.mucus.${v}`)} - - ))} -
-
+ {/* Symptothermal secondary symptom: cervical mucus (default) OR the + cervix observation, per the user's advanced-settings choice. Only one + of the two is offered so the log sheet never overwhelms. */} + {showCervix ? ( + +
+ t(`cycle.cervixPosition.${v}`)} + onToggle={(v) => + setCervixPosition((cur) => (cur === v ? null : v)) + } + /> + t(`cycle.cervixFirmness.${v}`)} + onToggle={(v) => + setCervixFirmness((cur) => (cur === v ? null : v)) + } + /> + t(`cycle.cervixOpening.${v}`)} + onToggle={(v) => + setCervixOpening((cur) => (cur === v ? null : v)) + } + /> +
+
+ ) : ( + +
+ {MUCUS_VALUES.map((v) => ( + setMucus((cur) => (cur === v ? null : v))} + > + {t(`cycle.mucus.${v}`)} + + ))} +
+
+ )} {/* Intercourse + protection */} diff --git a/src/components/cycle/types.ts b/src/components/cycle/types.ts index 064495b48..bbc799610 100644 --- a/src/components/cycle/types.ts +++ b/src/components/cycle/types.ts @@ -101,9 +101,18 @@ export interface CycleHistoryResponse { }; } +/** Symptothermal secondary symptom — mucus (default) or cervix observation. */ +export type SecondarySymptom = "MUCUS" | "CERVIX"; + +/** The three Sensiplan cervix signs. */ +export type CervixPosition = "LOW" | "HIGH"; +export type CervixFirmness = "FIRM" | "SOFT"; +export type CervixOpening = "CLOSED" | "OPEN"; + export interface CycleProfileDTO { goal: CycleGoal; cycleTrackingEnabled: boolean; + secondarySymptom: SecondarySymptom; rawChartMode: boolean; predictionEnabled: boolean; discreetNotifications: boolean; @@ -141,6 +150,9 @@ export interface CycleDayLogInput { temperatureExcluded?: boolean; ovulationTest?: OvulationTest; cervicalMucus?: CervicalMucus; + cervixPosition?: CervixPosition | null; + cervixFirmness?: CervixFirmness | null; + cervixOpening?: CervixOpening | null; sexualActivity?: boolean; protectedSex?: boolean | null; pregnancyTest?: HomeTestResult; @@ -163,6 +175,9 @@ export interface CycleDayLogDTO { temperatureExcluded: boolean; ovulationTest: OvulationTest | null; cervicalMucus: CervicalMucus | null; + cervixPosition: CervixPosition | null; + cervixFirmness: CervixFirmness | null; + cervixOpening: CervixOpening | null; sexualActivity: boolean; protectedSex: boolean | null; pregnancyTest: HomeTestResult | null; diff --git a/src/components/cycle/use-cycle.ts b/src/components/cycle/use-cycle.ts index 1f7fca8ee..d6f1ef287 100644 --- a/src/components/cycle/use-cycle.ts +++ b/src/components/cycle/use-cycle.ts @@ -29,6 +29,9 @@ import { import type { CalendarResponse, CervicalMucus, + CervixFirmness, + CervixOpening, + CervixPosition, ContraceptiveKind, CycleDayLogDTO, CycleDayLogInput, @@ -240,6 +243,9 @@ export interface CycleDayLogPatch { temperatureExcluded?: boolean; ovulationTest?: OvulationTest | null; cervicalMucus?: CervicalMucus | null; + cervixPosition?: CervixPosition | null; + cervixFirmness?: CervixFirmness | null; + cervixOpening?: CervixOpening | null; sexualActivity?: boolean; protectedSex?: boolean | null; pregnancyTest?: HomeTestResult | null; @@ -317,6 +323,7 @@ export function useDeleteDayLog() { export interface CyclePrefsPatch { enabled?: boolean; goal?: CycleProfileDTO["goal"]; + secondarySymptom?: CycleProfileDTO["secondarySymptom"]; rawChartMode?: boolean; predictionEnabled?: boolean; discreetNotifications?: boolean; From fd00eb48d0f86ccbef22491c58e5f08264790ebd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 15:35:32 +0200 Subject: [PATCH 09/12] test(cycle): cover cervix signs + secondary-symptom across the wire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integration coverage for the new fields: a cervix observation round-trips POST → GET on the day-log route; the cycle-prefs GET defaults secondarySymptom to MUCUS and a PATCH to CERVIX persists; and the backup/restore path preserves both the cervix day-log signs and the profile's secondary-symptom choice. --- tests/integration/cycle-import-export.test.ts | 14 ++++++- tests/integration/cycle-routes.test.ts | 42 +++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/tests/integration/cycle-import-export.test.ts b/tests/integration/cycle-import-export.test.ts index 288ea873a..a85dab1d3 100644 --- a/tests/integration/cycle-import-export.test.ts +++ b/tests/integration/cycle-import-export.test.ts @@ -176,6 +176,9 @@ describe("cycle backup — round-trips through build + restore", () => { date: "2026-03-02", flow: "MEDIUM", cycleId: cycle.id, + cervixPosition: "HIGH", + cervixFirmness: "SOFT", + cervixOpening: "OPEN", notesEncrypted: "v1:abc123:cipher-envelope", source: "MANUAL", }, @@ -187,7 +190,12 @@ describe("cycle backup — round-trips through build + restore", () => { data: { dayLogId: dayLog.id, symptomId: symptom.id }, }); await prisma.cycleProfile.create({ - data: { userId: USER_ID, goal: "TRYING_TO_CONCEIVE", cycleTrackingEnabled: true }, + data: { + userId: USER_ID, + goal: "TRYING_TO_CONCEIVE", + cycleTrackingEnabled: true, + secondarySymptom: "CERVIX", + }, }); // Build the backup section, then assert it round-trips through the schema. @@ -217,6 +225,9 @@ describe("cycle backup — round-trips through build + restore", () => { }); expect(restored).toHaveLength(1); expect(restored[0].notesEncrypted).toBe("v1:abc123:cipher-envelope"); + expect(restored[0].cervixPosition).toBe("HIGH"); + expect(restored[0].cervixFirmness).toBe("SOFT"); + expect(restored[0].cervixOpening).toBe("OPEN"); expect(restored[0].cycleId).not.toBeNull(); expect(restored[0].symptomLinks.map((l) => l.symptom.key)).toEqual([ "cramps", @@ -226,5 +237,6 @@ describe("cycle backup — round-trips through build + restore", () => { where: { userId: USER_ID }, }); expect(profile.goal).toBe("TRYING_TO_CONCEIVE"); + expect(profile.secondarySymptom).toBe("CERVIX"); }); }); diff --git a/tests/integration/cycle-routes.test.ts b/tests/integration/cycle-routes.test.ts index 75cbc6964..cfd7009bc 100644 --- a/tests/integration/cycle-routes.test.ts +++ b/tests/integration/cycle-routes.test.ts @@ -202,6 +202,30 @@ describe("cycle day-logs — single read (GET)", () => { const res = await GET(jsonRequest("/api/cycle/day-logs?date=nope", "GET")); expect(res.status).toBe(422); }); + + it("round-trips the cervix observation signs through POST → GET", async () => { + await loginAs(FEMALE_USER_ID); + const route = await import("@/app/api/cycle/day-logs/route"); + + await route.POST( + jsonRequest("/api/cycle/day-logs", "POST", { + date: "2026-05-02", + cervixPosition: "HIGH", + cervixFirmness: "SOFT", + cervixOpening: "OPEN", + loggedAt: "2026-05-02T08:00:00.000Z", + }), + ); + + const hit = await route.GET( + jsonRequest("/api/cycle/day-logs?date=2026-05-02", "GET"), + ); + expect(hit.status).toBe(200); + const { data } = await hit.json(); + expect(data.cervixPosition).toBe("HIGH"); + expect(data.cervixFirmness).toBe("SOFT"); + expect(data.cervixOpening).toBe("OPEN"); + }); }); describe("cycle day-logs — symptom severity", () => { @@ -488,6 +512,24 @@ describe("cycle-prefs gate flip", () => { ); expect(res.status).toBe(201); }); + + it("defaults secondarySymptom to MUCUS and persists a switch to CERVIX", async () => { + await loginAs(FEMALE_USER_ID); + const { GET, PATCH } = await import( + "@/app/api/auth/me/cycle-prefs/route" + ); + + const initial = await GET(); + expect((await initial.json()).data.secondarySymptom).toBe("MUCUS"); + + const patched = await PATCH( + jsonRequest("/api/auth/me/cycle-prefs", "PATCH", { + secondarySymptom: "CERVIX", + }), + ); + expect(patched.status).toBe(200); + expect((await patched.json()).data.secondarySymptom).toBe("CERVIX"); + }); }); describe("sync/changes — cycle domains", () => { From a614ca78c6f8078047963b0706a6fd9b10f99925 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 15:42:58 +0200 Subject: [PATCH 10/12] feat(cycle): deepen phase-education copy with a second context line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a per-phase "context" line under cycle.phaseEducation.context.* that expands the existing descriptive-only education with what someone might notice — energy, sleep, basal temperature, mood — and a gentle framing of normal variation. The original whatsHappening lines are kept verbatim; this sits below them as a second, deeper paragraph in the same warm, medically-grounded, non-prescriptive register. Wire the new leaf through phase-copy.ts (PHASE_CONTEXT_KEY) and render it in PhaseEducationCard, and add the copy across all six locales. --- messages/de.json | 6 ++++++ messages/en.json | 6 ++++++ messages/es.json | 6 ++++++ messages/fr.json | 6 ++++++ messages/it.json | 6 ++++++ messages/pl.json | 6 ++++++ src/components/cycle/phase-education-card.tsx | 13 +++++++++++-- src/lib/cycle/phase-copy.ts | 14 ++++++++++++++ 8 files changed, 61 insertions(+), 2 deletions(-) diff --git a/messages/de.json b/messages/de.json index 4b0b74011..103d43183 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5741,6 +5741,12 @@ "OVULATORY": "Etwa zur Zyklusmitte wird eine Eizelle freigesetzt und die Fruchtbarkeit erreicht für ein kurzes Fenster ihren Höhepunkt. Diese Phase ist die kürzeste der vier.", "LUTEAL": "Nach dem Eisprung steigt das Progesteron und der Körper kommt zur Ruhe. In den Tagen vor der nächsten Periode bemerken manche prämenstruelle Veränderungen." }, + "context": { + "MENSTRUAL": "Die Hormonspiegel sind jetzt am niedrigsten, weshalb sich diese Tage wie ein ruhiger Neustart anfühlen können. Manche bemerken Krämpfe, leichteren Schlaf oder den Wunsch, einen Gang zurückzuschalten; andere kommen fast unverändert durch. All das liegt im normalen Rahmen, und wie es sich anfühlt, kann von Zyklus zu Zyklus wechseln.", + "FOLLICULAR": "Mit steigendem Östrogen beschreiben viele diese Tage als hell und offen – der Schlaf fällt oft leichter und der Kopf fühlt sich klarer an. Die Basaltemperatur bleibt meist im niedrigeren Bereich, bis kurz vor dem Eisprung. Die Länge dieser Phase schwankt am stärksten – von Person zu Person und von Zyklus zu Zyklus.", + "OVULATORY": "Das Östrogen erreicht kurz vor dem Eisprung seinen Höhepunkt, oft begleitet von einem kurzen Stimmungshoch und einem leichten Anstieg der Basaltemperatur in den ein, zwei Tagen danach. Der Zervixschleim wird in diesem Fenster meist klarer und dehnbarer. Ein kurzes Ziehen auf einer Seite bemerken manche; viele spüren gar nichts, und beides ist völlig normal.", + "LUTEAL": "Das Progesteron führt durch die zweite Zyklushälfte, und die Basaltemperatur liegt meist etwas höher als zuvor im Zyklus. Manche bemerken im Lauf der Tage Veränderungen bei Schlaf, Appetit, Stimmung oder Energie, während andere durchgehend ausgeglichen bleiben. Das Muster ist persönlich und sieht von Monat zu Monat oft anders aus." + }, "commonHere": "Das bemerkst du hier oft", "trackNudge": "Heute eintragen", "stillLearning": "Lerne deinen Zyklus noch kennen. Trage weiter ein, dann füllt sich das mit Mustern aus deinen eigenen Tagen." diff --git a/messages/en.json b/messages/en.json index ac02d4119..0519da330 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5741,6 +5741,12 @@ "OVULATORY": "Around mid-cycle an egg is released and fertility peaks for a short window. This phase is the briefest of the four.", "LUTEAL": "After ovulation, progesterone rises and the body settles in. In the days before your next period some people notice premenstrual changes." }, + "context": { + "MENSTRUAL": "Hormone levels are at their lowest, which is why these days can feel like a quiet reset. Some people notice cramps, lighter sleep or a wish to slow down; others sail through with little change. All of that sits within the normal range, and how it feels can shift from one cycle to the next.", + "FOLLICULAR": "With oestrogen on the rise, many people describe these days as bright and open — sleep often comes more easily and the mind feels clearer. Basal body temperature usually stays in its lower band until just before ovulation. The length of this phase varies most from person to person, and from cycle to cycle.", + "OVULATORY": "Oestrogen peaks just before the egg is released, often alongside a brief lift in mood and a small rise in basal body temperature in the day or two that follow. Cervical fluid tends to become clearer and more stretchy around this window. A short twinge on one side is something some people notice; many feel nothing at all, and both are perfectly normal.", + "LUTEAL": "Progesterone leads the second half and basal body temperature usually settles a little higher than earlier in the cycle. Some people notice shifts in sleep, appetite, mood or energy as the days go on, while others feel steady throughout. The pattern is personal, and it often looks different from one month to the next." + }, "commonHere": "You often notice this here", "trackNudge": "Log today", "stillLearning": "Still learning your cycle. Keep logging and this fills in with patterns from your own days." diff --git a/messages/es.json b/messages/es.json index 76da38cee..49339cb48 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5741,6 +5741,12 @@ "OVULATORY": "Hacia la mitad del ciclo se libera un óvulo y la fertilidad alcanza su punto máximo durante una ventana corta. Esta fase es la más breve de las cuatro.", "LUTEAL": "Después de la ovulación, la progesterona sube y el cuerpo se asienta. En los días previos a la siguiente menstruación, algunas personas notan cambios premenstruales." }, + "context": { + "MENSTRUAL": "Los niveles hormonales están en su punto más bajo, por eso estos días pueden sentirse como un reinicio tranquilo. Algunas personas notan cólicos, un sueño más ligero o ganas de bajar el ritmo; otras los atraviesan casi sin cambios. Todo ello entra dentro de lo normal, y cómo se siente puede variar de un ciclo a otro.", + "FOLLICULAR": "Con el estrógeno en aumento, muchas personas describen estos días como luminosos y abiertos: el sueño suele llegar con más facilidad y la mente se siente más despejada. La temperatura basal suele mantenerse en su franja más baja hasta justo antes de la ovulación. La duración de esta fase es la que más varía de una persona a otra y de un ciclo a otro.", + "OVULATORY": "El estrógeno alcanza su máximo justo antes de liberarse el óvulo, a menudo junto con un breve ánimo más alto y una ligera subida de la temperatura basal en el día o los dos días siguientes. El flujo cervical tiende a volverse más transparente y elástico en torno a esta ventana. Una breve punzada en un lado es algo que algunas personas notan; muchas no sienten nada, y ambas cosas son del todo normales.", + "LUTEAL": "La progesterona guía la segunda mitad y la temperatura basal suele asentarse algo más alta que al inicio del ciclo. Algunas personas notan cambios en el sueño, el apetito, el ánimo o la energía a medida que pasan los días, mientras que otras se sienten estables todo el tiempo. El patrón es personal y a menudo se ve distinto de un mes a otro." + }, "commonHere": "Esto lo notas a menudo aquí", "trackNudge": "Registrar hoy", "stillLearning": "Aún estamos conociendo tu ciclo. Sigue registrando y esto se irá completando con patrones de tus propios días." diff --git a/messages/fr.json b/messages/fr.json index fa3dabdd2..335cf77b2 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5741,6 +5741,12 @@ "OVULATORY": "Vers le milieu du cycle, un ovule est libéré et la fertilité culmine sur une courte fenêtre. Cette phase est la plus brève des quatre.", "LUTEAL": "Après l'ovulation, la progestérone augmente et le corps se pose. Dans les jours précédant les prochaines règles, certaines personnes remarquent des changements prémenstruels." }, + "context": { + "MENSTRUAL": "Les taux hormonaux sont au plus bas, ce qui peut donner à ces jours des airs de remise à zéro tranquille. Certaines personnes remarquent des crampes, un sommeil plus léger ou l'envie de lever le pied ; d'autres les traversent presque sans changement. Tout cela reste dans la normale, et le ressenti peut varier d'un cycle à l'autre.", + "FOLLICULAR": "Avec l'œstrogène qui monte, beaucoup décrivent ces jours comme lumineux et ouverts : le sommeil vient souvent plus facilement et l'esprit semble plus clair. La température basale reste en général dans sa plage basse jusqu'à juste avant l'ovulation. La durée de cette phase est ce qui varie le plus d'une personne à l'autre, et d'un cycle à l'autre.", + "OVULATORY": "L'œstrogène culmine juste avant la libération de l'ovule, souvent accompagné d'un bref regain d'humeur et d'une légère hausse de la température basale dans le jour ou les deux qui suivent. La glaire cervicale tend à devenir plus claire et plus filante autour de cette fenêtre. Un bref tiraillement d'un côté, certaines personnes le remarquent ; beaucoup ne ressentent rien, et les deux sont tout à fait normaux.", + "LUTEAL": "La progestérone mène la seconde moitié et la température basale se stabilise en général un peu plus haut qu'au début du cycle. Certaines personnes remarquent des variations du sommeil, de l'appétit, de l'humeur ou de l'énergie au fil des jours, tandis que d'autres se sentent stables tout du long. Le schéma est personnel, et il se présente souvent différemment d'un mois à l'autre." + }, "commonHere": "Tu le remarques souvent ici", "trackNudge": "Noter aujourd'hui", "stillLearning": "On apprend encore ton cycle. Continue à noter et cela se remplira avec les tendances de tes propres journées." diff --git a/messages/it.json b/messages/it.json index 58232fc1b..227575089 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5741,6 +5741,12 @@ "OVULATORY": "Verso metà ciclo viene rilasciato un ovulo e la fertilità raggiunge il picco per una breve finestra. Questa fase è la più breve delle quattro.", "LUTEAL": "Dopo l'ovulazione il progesterone sale e il corpo si assesta. Nei giorni prima del ciclo successivo alcune persone notano cambiamenti premestruali." }, + "context": { + "MENSTRUAL": "I livelli ormonali sono al minimo, ed è per questo che questi giorni possono sembrare un tranquillo ripartire da capo. Alcune persone notano crampi, un sonno più leggero o il desiderio di rallentare; altre li attraversano quasi senza cambiamenti. Tutto questo rientra nella norma, e come ci si sente può cambiare da un ciclo all'altro.", + "FOLLICULAR": "Con gli estrogeni in salita, molte persone descrivono questi giorni come luminosi e aperti: il sonno spesso arriva più facilmente e la mente sembra più limpida. La temperatura basale di solito resta nella sua fascia più bassa fino a poco prima dell'ovulazione. La durata di questa fase è ciò che varia di più da persona a persona e da ciclo a ciclo.", + "OVULATORY": "Gli estrogeni raggiungono il picco poco prima del rilascio dell'ovulo, spesso insieme a un breve rialzo dell'umore e a un leggero aumento della temperatura basale nel giorno o nei due successivi. Il muco cervicale tende a diventare più trasparente ed elastico intorno a questa finestra. Una breve fitta su un lato è qualcosa che alcune persone notano; molte non sentono nulla, ed entrambe le cose sono del tutto normali.", + "LUTEAL": "Il progesterone guida la seconda metà e la temperatura basale di solito si assesta un po' più in alto rispetto all'inizio del ciclo. Alcune persone notano cambiamenti nel sonno, nell'appetito, nell'umore o nell'energia con il passare dei giorni, mentre altre si sentono stabili per tutto il tempo. Lo schema è personale e spesso appare diverso da un mese all'altro." + }, "commonHere": "Questo lo noti spesso qui", "trackNudge": "Registra oggi", "stillLearning": "Stiamo ancora imparando il tuo ciclo. Continua a registrare e questo si completerà con gli schemi delle tue giornate." diff --git a/messages/pl.json b/messages/pl.json index 00c80f0b9..e9eeffd0b 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5741,6 +5741,12 @@ "OVULATORY": "W połowie cyklu uwalniana jest komórka jajowa, a płodność osiąga szczyt przez krótkie okno czasu. Ta faza jest najkrótsza z czterech.", "LUTEAL": "Po owulacji rośnie poziom progesteronu i organizm się wycisza. W dniach przed kolejną miesiączką niektóre osoby zauważają zmiany przedmiesiączkowe." }, + "context": { + "MENSTRUAL": "Poziom hormonów jest teraz najniższy, dlatego te dni mogą sprawiać wrażenie spokojnego resetu. Niektóre osoby zauważają skurcze, lżejszy sen albo chęć zwolnienia tempa; inne przechodzą przez nie niemal bez zmian. To wszystko mieści się w normie, a odczucia potrafią zmieniać się z cyklu na cykl.", + "FOLLICULAR": "Wraz z rosnącym estrogenem wiele osób opisuje te dni jako jasne i pełne otwartości – sen często przychodzi łatwiej, a umysł wydaje się bardziej klarowny. Podstawowa temperatura ciała zwykle utrzymuje się w niższym zakresie aż do chwili tuż przed owulacją. Długość tej fazy różni się najbardziej – od osoby do osoby i od cyklu do cyklu.", + "OVULATORY": "Estrogen osiąga szczyt tuż przed uwolnieniem komórki jajowej, często z krótkim wzrostem nastroju i niewielkim podniesieniem podstawowej temperatury ciała w ciągu kolejnego dnia lub dwóch. Śluz szyjkowy w tym oknie zwykle staje się bardziej przejrzysty i ciągliwy. Krótkie ukłucie po jednej stronie niektóre osoby zauważają; wiele nie czuje nic, a oba warianty są zupełnie normalne.", + "LUTEAL": "Progesteron prowadzi przez drugą połowę cyklu, a podstawowa temperatura ciała zwykle ustala się nieco wyżej niż wcześniej. Niektóre osoby zauważają z dnia na dzień zmiany w śnie, apetycie, nastroju czy energii, podczas gdy inne czują się stabilnie przez cały czas. Ten wzorzec jest indywidualny i często wygląda inaczej z miesiąca na miesiąc." + }, "commonHere": "To często zauważasz tutaj", "trackNudge": "Zapisz dziś", "stillLearning": "Wciąż poznajemy Twój cykl. Zapisuj dalej, a to wypełni się wzorcami z Twoich własnych dni." diff --git a/src/components/cycle/phase-education-card.tsx b/src/components/cycle/phase-education-card.tsx index 8ca2b10a0..681e2d9ed 100644 --- a/src/components/cycle/phase-education-card.tsx +++ b/src/components/cycle/phase-education-card.tsx @@ -5,7 +5,10 @@ import { Sparkles } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; -import { PHASE_WHATS_HAPPENING_KEY } from "@/lib/cycle/phase-copy"; +import { + PHASE_WHATS_HAPPENING_KEY, + PHASE_CONTEXT_KEY, +} from "@/lib/cycle/phase-copy"; import { PHASE_HUE } from "./phase-tokens"; import { CYCLE_SYMPTOM_CATALOG } from "./symptom-catalog"; import type { CyclePhase } from "./types"; @@ -146,10 +149,16 @@ export function PhaseEducationCard({ {phaseName} - {/* Zone 2 — the curated, descriptive-only "what's happening" line. */} + {/* Zone 2 — the curated, descriptive-only "what's happening" line, + followed by a deeper "context" line (what someone might notice + + gentle framing of normal variation). Both stay descriptive, never + clinical advice / a medical claim. */}

{t(PHASE_WHATS_HAPPENING_KEY[phase as CyclePhase])}

+

+ {t(PHASE_CONTEXT_KEY[phase as CyclePhase])} +

{/* Zone 3 — the user's OWN clustered symptoms for this phase ("you often notice this here"). */} diff --git a/src/lib/cycle/phase-copy.ts b/src/lib/cycle/phase-copy.ts index c24bb6cd6..87d1c8fb6 100644 --- a/src/lib/cycle/phase-copy.ts +++ b/src/lib/cycle/phase-copy.ts @@ -21,3 +21,17 @@ export const PHASE_WHATS_HAPPENING_KEY: Record = { OVULATORY: "cycle.phaseEducation.whatsHappening.OVULATORY", LUTEAL: "cycle.phaseEducation.whatsHappening.LUTEAL", }; + +/** + * The phase → i18n leaf for the second, deeper "context" line — what someone + * might notice (energy, sleep, basal temperature, mood) and a gentle framing of + * normal variation. Same descriptive-only constraint as above: no advice, no + * clinical claim, never prescriptive. Spelled out as literals for the same + * call-site-coverage reason. + */ +export const PHASE_CONTEXT_KEY: Record = { + MENSTRUAL: "cycle.phaseEducation.context.MENSTRUAL", + FOLLICULAR: "cycle.phaseEducation.context.FOLLICULAR", + OVULATORY: "cycle.phaseEducation.context.OVULATORY", + LUTEAL: "cycle.phaseEducation.context.LUTEAL", +}; From ab3153008e70289dd4fa210341b78355f2b47455 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 15:50:36 +0200 Subject: [PATCH 11/12] fix(cycle): suppress the predicted fertile window on the wire while still learning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Below three observed cycles the prediction is a population-prior guess, not a data-grounded estimate. The web calendar already hid the fertile window and predicted ovulation in that state; make the gate structural in the DTO mapper so iOS — and any client reading prediction.* directly — cannot paint a window the rest of the app refuses to show. ovulationConfirmed stays goal-gated only: a confirmed shift is observed data, not a prior. --- src/lib/cycle/__tests__/prediction.test.ts | 70 ++++++++++++++++++++++ src/lib/cycle/dto.ts | 16 ++++- 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/src/lib/cycle/__tests__/prediction.test.ts b/src/lib/cycle/__tests__/prediction.test.ts index 418f9f6b2..158d9784c 100644 --- a/src/lib/cycle/__tests__/prediction.test.ts +++ b/src/lib/cycle/__tests__/prediction.test.ts @@ -16,9 +16,11 @@ import { predictCycle, resolveLuteal, } from "../prediction"; +import { toCyclePredictionDTO } from "../dto"; import type { CycleInput, CycleProfileInput, + CyclePredictionResult, DayLogInput, NightlyTempInput, } from "../types"; @@ -936,3 +938,71 @@ describe("cycle/prediction — luteal clamp single source of truth (QA HIGH)", ( expect(result.predictedOvulation).toBe(addDays(result.nextPeriodStart, -10)); }); }); + +describe("cycle/dto — toCyclePredictionDTO still-learning honesty gate (M-1)", () => { + /** A fully-populated engine result; `stillLearning` is overridden per case. */ + function makeResult( + overrides: Partial = {}, + ): CyclePredictionResult { + return { + method: "CALENDAR", + nextPeriodStart: "2024-02-01", + nextPeriodStartLow: "2024-01-30", + nextPeriodStartHigh: "2024-02-03", + predictedPeriodLength: 5, + fertileWindowStart: "2024-01-14", + fertileWindowEnd: "2024-01-20", + predictedOvulation: "2024-01-18", + ovulationConfirmed: true, + confidence: 0.6, + confidenceLabel: "medium", + cyclesObserved: 5, + stillLearning: false, + estimatedCycleLength: 28, + estimatedCycleSd: 1.5, + ...overrides, + }; + } + + it("suppresses the fertile window + predicted ovulation on the wire while still learning (<3 cycles), even when the goal allows it", () => { + const result = makeResult({ stillLearning: true, cyclesObserved: 2 }); + const dto = toCyclePredictionDTO(result, /* goalAllowsFertile */ true, "d"); + + expect(dto.fertileWindowStart).toBeNull(); + expect(dto.fertileWindowEnd).toBeNull(); + expect(dto.predictedOvulation).toBeNull(); + // The honesty flag still reaches the client so it can paint the calm state. + expect(dto.stillLearning).toBe(true); + }); + + it("populates the fertile window + predicted ovulation once learning completes (>=3 cycles) and the goal allows it", () => { + const result = makeResult({ stillLearning: false, cyclesObserved: 3 }); + const dto = toCyclePredictionDTO(result, /* goalAllowsFertile */ true, "d"); + + expect(dto.fertileWindowStart).toBe("2024-01-14"); + expect(dto.fertileWindowEnd).toBe("2024-01-20"); + expect(dto.predictedOvulation).toBe("2024-01-18"); + expect(dto.stillLearning).toBe(false); + }); + + it("keeps ovulationConfirmed goal-gated only — unchanged by the still-learning gate", () => { + // Still learning but goal allows: a confirmed shift is observed data, not a + // prior, so it stays surfaced even while the predicted window is suppressed. + const learning = toCyclePredictionDTO( + makeResult({ stillLearning: true, ovulationConfirmed: true }), + true, + "d", + ); + expect(learning.ovulationConfirmed).toBe(true); + + // Goal forbids the fertile window → confirmation drops regardless of learning. + const goalGated = toCyclePredictionDTO( + makeResult({ stillLearning: false, ovulationConfirmed: true }), + false, + "d", + ); + expect(goalGated.ovulationConfirmed).toBe(false); + expect(goalGated.fertileWindowStart).toBeNull(); + expect(goalGated.predictedOvulation).toBeNull(); + }); +}); diff --git a/src/lib/cycle/dto.ts b/src/lib/cycle/dto.ts index 3908ac9fa..09dedd8f7 100644 --- a/src/lib/cycle/dto.ts +++ b/src/lib/cycle/dto.ts @@ -198,9 +198,19 @@ export function toCyclePredictionDTO( nextPeriodStart: result.nextPeriodStart, nextPeriodStartLow: result.nextPeriodStartLow, nextPeriodStartHigh: result.nextPeriodStartHigh, - fertileWindowStart: goalAllowsFertile ? result.fertileWindowStart : null, - fertileWindowEnd: goalAllowsFertile ? result.fertileWindowEnd : null, - predictedOvulation: goalAllowsFertile ? result.predictedOvulation : null, + // Fertile-window + predicted ovulation are suppressed both when the goal + // forbids them AND while still learning (<3 cycles): below that we would be + // emitting a population-prior guess, not a data-grounded estimate. Making + // the gate structural here (not just in the web panels / calendar grid) + // means iOS — and any client reading `prediction.*` directly — cannot paint + // a fertile window the rest of the app refuses to show. `ovulationConfirmed` + // stays goal-gated only: a confirmed shift is observed data, not a prior. + fertileWindowStart: + goalAllowsFertile && !result.stillLearning ? result.fertileWindowStart : null, + fertileWindowEnd: + goalAllowsFertile && !result.stillLearning ? result.fertileWindowEnd : null, + predictedOvulation: + goalAllowsFertile && !result.stillLearning ? result.predictedOvulation : null, ovulationConfirmed: goalAllowsFertile ? result.ovulationConfirmed : false, confidence: result.confidence, cyclesObserved: result.cyclesObserved, From 8511b0bed7e2edd74281710eaa814df757da87d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 15:53:01 +0200 Subject: [PATCH 12/12] =?UTF-8?q?chore(release):=20v1.16.15=20=E2=80=94=20?= =?UTF-8?q?an=20honest=20calendar=20that=20learns=20before=20it=20predicts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 23 +++++++++++++++++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- 4 files changed, 26 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01bcfd7f2..f1fce99ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,29 @@ ## [Unreleased] +## [1.16.15] — 2026-06-14 — an honest calendar that learns before it predicts + +A focused release on the cycle calendar: it leans on observed data, names its +uncertainty plainly, and reads your charts the way the Sensiplan method does. +No breaking changes. + +### Added + +- **Mark a reading as disturbed.** A temperature taken after a fever, a late night, or any off day can be flagged "disturbed" and is dropped from the evaluation, so a single skewed point no longer drags the curve. +- **A positive ovulation test anchors the prediction.** When you log a positive test, it refines the predicted ovulation day — it sharpens the estimate but never overrides a temperature shift you have already confirmed. +- **Choose your secondary fertility sign.** Advanced cycle settings now let you track either cervical mucus (the default) or cervix observation alongside temperature, so the symptothermal evaluation follows the sign you actually record. +- **Warmer phase descriptions.** Each cycle phase gained a second paragraph of plain-language context, so the calendar explains what is happening, not just which phase you are in. + +### Changed + +- **The calendar learns before it predicts.** Below three logged cycles it shows a calm "still learning your cycle" state instead of a confident fertile-window or ovulation guess — and the same gate now applies on the API, so every client reads the same honest signal rather than painting a window the rest of the app holds back. +- **Temperature evaluation no longer under-detects ovulation.** The symptothermal reading gained the two Sensiplan exception rules — the slow rise and the single fall-back day, each confirmed on a fourth reading — so a real shift is recognised in the patterns that the simple rule used to miss. +- **The mucus peak settles before it is trusted.** A mucus peak is now confirmed only after three drier days follow it, so a stray late entry no longer shifts the peak backward. + +### Fixed + +- **Longer cycles are no longer clipped.** Cycles up to roughly sixty days are evaluated in full instead of being cut short, so a naturally long cycle reads correctly. + ## [1.16.14] — 2026-06-14 — one number everywhere: the score, the coach, and the doctor-report agree ### Changed diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index d6be35587..4ca8aebd8 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.16.14 + version: 1.16.15 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index 51a0d82bc..3f8ce4958 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.16.14", + "version": "1.16.15", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index f7fb41909..5ff76596b 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.16.14"; + /* @sw-version-fallback */ "v1.16.15"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; const MAX_STATIC_ENTRIES = 150;