From 47d41a13f45357d214d00b12c9eb0ddc0e7b81e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 7 Aug 2026 15:53:55 +0200 Subject: [PATCH] docs(cycle): say why the fertile window is seven days, not six The constants make ovulation minus five through ovulation plus one, and the comment above them claimed a six-day window. Wilcox's six days are the five before ovulation plus the day itself, measured against a KNOWN ovulation day. Ours is estimated, usually by back-calculating a luteal length from a median cycle, so a window drawn tight to the estimate loses its last day whenever the estimate lands early, and the ovum stays viable for most of a day after release besides. The direction is decided by who reads the window. It is surfaced to people trying to conceive and to people avoiding it, and for the second group a window one day short is a window that misses. The constants keep the conservative reading and the comment now states it, with the note that changing either one is a coordination matter because the iOS client reimplements the same spec. The width is pinned by a test rather than left to be re-read off the numbers the code already uses. --- src/lib/cycle/__tests__/prediction.test.ts | 31 ++++++++++++++++++++++ src/lib/cycle/types.ts | 21 ++++++++++++++- 2 files changed, 51 insertions(+), 1 deletion(-) 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;