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] 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,