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; } /**