diff --git a/src/lib/cycle/__tests__/prediction.test.ts b/src/lib/cycle/__tests__/prediction.test.ts index 4e97d3963..08b994f4a 100644 --- a/src/lib/cycle/__tests__/prediction.test.ts +++ b/src/lib/cycle/__tests__/prediction.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { addDays } from "../day-math"; +import { FERTILE_POST, FERTILE_PRE } from "../types"; import { clampLuteal, confirmSymptothermal, @@ -1091,3 +1092,33 @@ describe("cycle/dto — toCyclePredictionDTO still-learning honesty gate (M-1)", expect(goalGated.predictedOvulation).toBeNull(); }); }); + +describe("cycle/prediction — the fertile window's width (A10)", () => { + it("spans seven days, ending the day after the ovulation estimate", () => { + // The comment on the constants used to say six days and the constants made + // seven. The width is now a decision with a reason written at the constant, + // so it is pinned here rather than left to be re-read off the same numbers + // the code uses: five days before the estimate, the estimate, and one after. + const cycles = cyclesFromGaps("2026-01-01", [28, 28, 28]); + const p = predictCycle(cycles, [], BASE_PROFILE, "2026-03-20", []); + + expect(p.predictedOvulation).not.toBeNull(); + expect(p.fertileWindowStart).toBe(addDays(p.predictedOvulation!, -5)); + expect(p.fertileWindowEnd).toBe(addDays(p.predictedOvulation!, 1)); + + const span = + (Date.parse(`${p.fertileWindowEnd}T12:00:00Z`) - + Date.parse(`${p.fertileWindowStart}T12:00:00Z`)) / + 86_400_000; + // Inclusive of both ends. + expect(span + 1).toBe(7); + }); + + it("keeps the extra day on the side that costs least to be wrong about", () => { + // The trailing day is estimator slack plus ovum viability. Dropping it + // would shorten the window for somebody avoiding pregnancy, which is the + // direction that misses. + expect(FERTILE_PRE).toBe(5); + expect(FERTILE_POST).toBe(1); + }); +}); diff --git a/src/lib/cycle/types.ts b/src/lib/cycle/types.ts index 16eada87f..33983bc14 100644 --- a/src/lib/cycle/types.ts +++ b/src/lib/cycle/types.ts @@ -103,7 +103,26 @@ export const LUTEAL_DEFAULT = 14; export const LUTEAL_MIN = 10; export const LUTEAL_MAX = 16; -/** §4 — six-day fertile window: ovulation − 5 … ovulation + 1. */ +/** + * §4 — the fertile window: ovulation − 5 … ovulation + 1, seven days. + * + * Wilcox's six-day window is the five days BEFORE ovulation plus the day + * itself, and the trailing day here is a deliberate departure from it. The + * six-day figure is measured against a KNOWN ovulation day; ours is estimated, + * usually by back-calculating a luteal length from a median cycle, and an + * estimate that lands a day early would put the last fertile day outside a + * window drawn tight to it. The ovum also stays viable for the better part of a + * day after release, so the extra day is not only estimator slack. + * + * The asymmetry of the cost decides the direction. This window is surfaced to + * people trying to conceive and to people avoiding it, and for the second group + * a window one day too short is a window that misses. The constants are the + * conservative reading; the comment used to claim six days and describe seven, + * which is the only part of this that was wrong. + * + * Changing either constant changes what the iOS client reimplements from the + * same spec, so it needs a coordination note, not just an edit here. + */ export const FERTILE_PRE = 5; export const FERTILE_POST = 1;