diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 77494704f..575890eda 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -703,8 +703,8 @@ paths: lutealPhaseLength: anyOf: - type: integer - minimum: 8 - maximum: 20 + minimum: 10 + maximum: 16 - type: "null" responses: "200": diff --git a/src/app/api/cycle/calendar/route.ts b/src/app/api/cycle/calendar/route.ts index ee72d97c5..fdeb41e54 100644 --- a/src/app/api/cycle/calendar/route.ts +++ b/src/app/api/cycle/calendar/route.ts @@ -28,7 +28,8 @@ import { goalAllowsFertileWindow, } from "@/lib/cycle/dto"; import { persistPredictionCache } from "@/lib/cycle/prediction-cache"; -import { addDays } from "@/lib/cycle/day-math"; +import { addDays, dayDiff } from "@/lib/cycle/day-math"; +import { BBT_WINDOW } from "@/lib/cycle/types"; import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key"; import { getServerTranslator } from "@/lib/i18n/server-translator"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; @@ -75,8 +76,21 @@ export const GET = apiHandler(async (request: NextRequest) => { where: { userId: user.id, deletedAt: null }, orderBy: { startDate: "asc" }, }), + // Bound the day-log read to the rendered span plus the symptothermal + // lookback — the earlier of `from` and (today − BBT_WINDOW). Cycle-length + // stats run off MenstrualCycle rows, so day-logs can be windowed (QA: perf + // — unbounded full-history read on a hot, per-navigation route). prisma.cycleDayLog.findMany({ - where: { userId: user.id, deletedAt: null }, + where: { + userId: user.id, + deletedAt: null, + date: { + gte: + dayDiff(from, addDays(today, -BBT_WINDOW)) <= 0 + ? from + : addDays(today, -BBT_WINDOW), + }, + }, orderBy: { date: "asc" }, select: { date: true, diff --git a/src/app/api/cycle/insights/route.ts b/src/app/api/cycle/insights/route.ts index e6bd24db4..dc6acbc0d 100644 --- a/src/app/api/cycle/insights/route.ts +++ b/src/app/api/cycle/insights/route.ts @@ -35,6 +35,7 @@ import { discoverPhaseCorrelations, selectHeadlinePhaseRow, PHASE_CROSSTAB_METRIC_TYPES, + MOOD_CHANNEL_KEY, } from "@/lib/cycle/phase-crosstab"; import type { CrossMetricMeasurement } from "@/lib/insights/mood-aggregates"; import { addDays } from "@/lib/cycle/day-math"; @@ -56,14 +57,24 @@ export const GET = apiHandler(async () => { const today = moodDateKey(new Date(), tz); const from = addDays(today, -WINDOW_DAYS); - const [cycles, dayLogRows, nightlyTempRows, measurementRows, prefsRow] = - await Promise.all([ + const [ + cycles, + dayLogRows, + nightlyTempRows, + measurementRows, + prefsRow, + moodRows, + ] = await Promise.all([ prisma.menstrualCycle.findMany({ where: { userId: user.id, deletedAt: null }, orderBy: { startDate: "asc" }, }), + // Bound the day-log read to the rendered window (`from` = today − 365d), + // which is already earlier than the symptothermal lookback. Cycle-length + // stats run off MenstrualCycle rows so the day-logs can be windowed (QA: + // perf — unbounded full-history read). prisma.cycleDayLog.findMany({ - where: { userId: user.id, deletedAt: null }, + where: { userId: user.id, deletedAt: null, date: { gte: from } }, orderBy: { date: "asc" }, select: { date: true, @@ -112,6 +123,18 @@ export const GET = apiHandler(async () => { where: { id: user.id }, select: { sourcePriorityJson: true }, }), + // MOOD outcome (QA HIGH): mood lives in MoodEntry (1–5 score), not a + // Measurement row, so read it here and inject it into the crosstab as a + // synthetic MOOD_CHANNEL_KEY measurement (same FDR / day-floor guards). + prisma.moodEntry.findMany({ + where: { + userId: user.id, + deletedAt: null, + moodLoggedAt: { gte: new Date(Date.parse(`${from}T00:00:00Z`)) }, + }, + orderBy: { moodLoggedAt: "asc" }, + select: { score: true, moodLoggedAt: true }, + }), ]); // The latest open cycle runs to the predicted next-period start so the @@ -146,6 +169,18 @@ export const GET = apiHandler(async () => { source: m.source, deviceType: m.deviceType, })); + // Inject mood as a synthetic MOOD_CHANNEL_KEY series (no source ladder — the + // metricDayMap pass-through keeps every row, averaging multiple same-day + // entries like every other channel). + for (const m of moodRows) { + measurements.push({ + type: MOOD_CHANNEL_KEY, + value: m.score, + measuredAt: m.moodLoggedAt, + source: null, + deviceType: null, + }); + } const userPriorityJson = prefsRow?.sourcePriorityJson ?? null; diff --git a/src/lib/cycle/__tests__/phase-crosstab.test.ts b/src/lib/cycle/__tests__/phase-crosstab.test.ts index 98142de80..a6c412830 100644 --- a/src/lib/cycle/__tests__/phase-crosstab.test.ts +++ b/src/lib/cycle/__tests__/phase-crosstab.test.ts @@ -7,7 +7,9 @@ import { selectHeadlinePhaseRow, PHASE_ORDINAL, PHASE_CROSSTAB_METRIC_TYPES, + PHASE_CROSSTAB_METRICS, CYCLE_PHASE_CHANNEL_KEY, + MOOD_CHANNEL_KEY, type PhaseMetricCrosstabRow, } from "../phase-crosstab"; import type { CrossMetricMeasurement } from "@/lib/insights/mood-aggregates"; @@ -81,6 +83,76 @@ describe("PHASE_CROSSTAB_METRIC_TYPES", () => { expect(PHASE_CROSSTAB_METRIC_TYPES).toContain("BODY_TEMPERATURE"); expect(PHASE_CROSSTAB_METRIC_TYPES).toContain("RESTING_HEART_RATE"); }); + + it("includes GLUCOSE but NOT mood (mood is not a Measurement row)", () => { + expect(PHASE_CROSSTAB_METRIC_TYPES).toContain("BLOOD_GLUCOSE"); + expect(PHASE_CROSSTAB_METRIC_TYPES).not.toContain(MOOD_CHANNEL_KEY); + }); + + it("declares mood + glucose as outcome metrics (QA HIGH)", () => { + expect(PHASE_CROSSTAB_METRICS.mood.type).toBe(MOOD_CHANNEL_KEY); + expect(PHASE_CROSSTAB_METRICS.bloodGlucose.type).toBe("BLOOD_GLUCOSE"); + }); +}); + +describe("computePhaseMetricCrosstab — MOOD + GLUCOSE outcomes (QA HIGH)", () => { + /** Build a luteal-vs-follicular contrast for an arbitrary outcome `type`. */ + function contrastFor(type: string, lutealVal: number, follicularVal: number) { + const phaseByDay = new Map(); + const measurements: CrossMetricMeasurement[] = []; + let idx = 0; + const dayKey = (i: number) => { + const d = new Date(Date.UTC(2026, 2, 1)); + d.setUTCDate(d.getUTCDate() + i); + return d.toISOString().slice(0, 10); + }; + for (let i = 0; i < 14; i++) { + const k = dayKey(idx++); + phaseByDay.set(k, "LUTEAL"); + measurements.push(m(type, k, lutealVal + (i % 3) * 0.5)); + } + for (let i = 0; i < 14; i++) { + const k = dayKey(idx++); + phaseByDay.set(k, "FOLLICULAR"); + measurements.push(m(type, k, follicularVal + (i % 3) * 0.5)); + } + return { phaseByDay, measurements }; + } + + it("surfaces a luteal-vs-follicular GLUCOSE contrast", () => { + const { phaseByDay, measurements } = contrastFor("BLOOD_GLUCOSE", 105, 92); + const rows = computePhaseMetricCrosstab({ phaseByDay, measurements }); + const glucose = rows.find((r) => r.metricKey === "bloodGlucose"); + expect(glucose).toBeDefined(); + expect(glucose!.display).toBe("glucose"); + expect(glucose!.delta).toBeGreaterThan(0); + expect(glucose!.pValue).toBeLessThan(0.05); + }); + + it("surfaces a luteal-vs-follicular MOOD contrast (injected synthetic channel)", () => { + const { phaseByDay, measurements } = contrastFor(MOOD_CHANNEL_KEY, 4.4, 2.6); + const rows = computePhaseMetricCrosstab({ phaseByDay, measurements }); + const mood = rows.find((r) => r.metricKey === "mood"); + expect(mood).toBeDefined(); + expect(mood!.display).toBe("mood"); + expect(mood!.delta).toBeGreaterThan(0); + expect(mood!.pValue).toBeLessThan(0.05); + }); +}); + +describe("goalAllowsFertileWindow (QA MEDIUM)", () => { + it("shows the fertile window for TRYING_TO_CONCEIVE and AVOID_PREGNANCY", async () => { + const { goalAllowsFertileWindow } = await import("../dto"); + expect(goalAllowsFertileWindow("TRYING_TO_CONCEIVE")).toBe(true); + expect(goalAllowsFertileWindow("AVOID_PREGNANCY")).toBe(true); + }); + + it("hides it for GENERAL_HEALTH / PERIMENOPAUSE / OFF", async () => { + const { goalAllowsFertileWindow } = await import("../dto"); + expect(goalAllowsFertileWindow("GENERAL_HEALTH")).toBe(false); + expect(goalAllowsFertileWindow("PERIMENOPAUSE")).toBe(false); + expect(goalAllowsFertileWindow("OFF")).toBe(false); + }); }); describe("computePhaseMetricCrosstab", () => { diff --git a/src/lib/cycle/__tests__/prediction.test.ts b/src/lib/cycle/__tests__/prediction.test.ts index 4791bdd9d..019daff4d 100644 --- a/src/lib/cycle/__tests__/prediction.test.ts +++ b/src/lib/cycle/__tests__/prediction.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { addDays } from "../day-math"; import { + clampLuteal, confirmSymptothermal, detectTempShift, detectTemperatureTrend, @@ -10,6 +11,7 @@ import { median, observedPeriodLength, predictCycle, + resolveLuteal, } from "../prediction"; import type { CycleInput, @@ -401,3 +403,114 @@ describe("cycle/prediction — temperature-trend retrospective ovulation (§4.3) expect(result.predictedOvulation).toBe(addDays(start, 5)); }); }); + +describe("cycle/prediction — multi-cycle window scoping (QA HIGH)", () => { + function bbtDay(date: string, t: number, mucus: DayLogInput["cervicalMucus"] = null): DayLogInput { + return { date, flow: null, basalBodyTempC: t, ovulationTest: null, cervicalMucus: mucus }; + } + + /** Build a confirmable symptothermal shift (temp + agreeing mucus) on `ovDay`. */ + function symptothermalAround(ovDay: string): DayLogInput[] { + const logs: DayLogInput[] = []; + 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)); + logs.push({ + date: ovDay, + flow: null, + basalBodyTempC: null, + ovulationTest: null, + cervicalMucus: "EGG_WHITE", + }); + return logs; + } + + it("does not confirm a STALE prior-cycle ovulation for a multi-cycle user", () => { + // Three confirmed cycles; last start 2024-03-25, today well into the + // current cycle. The ONLY BBT/mucus data is a complete shift back in the + // FIRST cycle (months ago). Pre-fix, detectTempShift returned that stale + // shift and nextPeriodStart landed in the past. + const cycles = cyclesFromGaps("2024-01-01", [28, 28, 28]); + const lastStart = cycles[cycles.length - 1].startDate; + expect(lastStart).toBe("2024-03-25"); + + const staleOv = addDays("2024-01-01", 13); // 2024-01-14, two cycles back + const logs = symptothermalAround(staleOv); + + const today = addDays(lastStart, 16); + const result = predictCycle(cycles, logs, BASE_PROFILE, today); + + // Stale signal is outside [lastStart − BBT_WINDOW, today] → ignored. + expect(result.ovulationConfirmed).toBe(false); + expect(result.method).toBe("CALENDAR"); + // Calendar next-start is in the future, never in the past. + expect(result.nextPeriodStart >= today).toBe(true); + }); + + it("confirms the CURRENT-cycle shift even when a prior-cycle shift also exists", () => { + const cycles = cyclesFromGaps("2024-01-01", [28, 28, 28]); + const lastStart = cycles[cycles.length - 1].startDate; // 2024-03-25 + + const staleOv = addDays("2024-01-01", 13); // months ago + const currentOv = addDays(lastStart, 13); // current cycle + const logs = [...symptothermalAround(staleOv), ...symptothermalAround(currentOv)]; + + const result = predictCycle(cycles, logs, BASE_PROFILE, addDays(lastStart, 16)); + expect(result.ovulationConfirmed).toBe(true); + expect(result.predictedOvulation).toBe(currentOv); + expect(result.nextPeriodStart).toBe(addDays(currentOv, 14)); + }); + + it("detectTempShift returns the LATEST qualifying shift, not the earliest", () => { + const logs: DayLogInput[] = []; + // First shift around 2024-01-07. + 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.6)); + logs.push(bbtDay(addDays(start, 7), 36.61)); + logs.push(bbtDay(addDays(start, 8), 36.62)); + // Drop back to baseline, then a SECOND shift a month later. + const start2 = "2024-02-01"; + [36.4, 36.42, 36.38, 36.41, 36.4, 36.39].forEach((t, i) => + logs.push(bbtDay(addDays(start2, i), t)), + ); + logs.push(bbtDay(addDays(start2, 6), 36.6)); + logs.push(bbtDay(addDays(start2, 7), 36.61)); + logs.push(bbtDay(addDays(start2, 8), 36.62)); + + const shift = detectTempShift(logs, 0.2); + // Latest match → day before the second rise onset (2024-02-07 → 2024-02-06). + expect(shift?.ovulationDate).toBe(addDays(start2, 5)); + }); +}); + +describe("cycle/prediction — luteal clamp single source of truth (QA HIGH)", () => { + it("clampLuteal pins a raw value to [10, 16]", () => { + expect(clampLuteal(8)).toBe(10); + expect(clampLuteal(20)).toBe(16); + expect(clampLuteal(13)).toBe(13); + }); + + it("resolveLuteal applies the default then the clamp", () => { + expect(resolveLuteal({ lutealPhaseLength: null })).toBe(14); + expect(resolveLuteal({ lutealPhaseLength: 8 })).toBe(10); + expect(resolveLuteal({ lutealPhaseLength: 19 })).toBe(16); + }); + + it("predictCycle ovulation uses the CLAMPED luteal for an out-of-clamp profile", () => { + const cycles = cyclesFromGaps("2024-01-01", [28, 28, 28]); + const lastStart = cycles[cycles.length - 1].startDate; + // Profile asks for luteal 8 → engine clamps to 10. + const profile: CycleProfileInput = { ...BASE_PROFILE, lutealPhaseLength: 8 }; + const result = predictCycle(cycles, [], profile, addDays(lastStart, 5)); + // predictedOvulation = nextStart − clampedLuteal(10). + expect(result.predictedOvulation).toBe(addDays(result.nextPeriodStart, -10)); + }); +}); diff --git a/src/lib/cycle/dto.ts b/src/lib/cycle/dto.ts index 83b08d12c..067e718fb 100644 --- a/src/lib/cycle/dto.ts +++ b/src/lib/cycle/dto.ts @@ -236,11 +236,15 @@ type MenstrualCycleProfileRow = { }; /** - * Whether a goal surfaces the fertile window. Per the v1.15 contract the - * window is gated to the conception goal only — GENERAL_HEALTH / - * AVOID_PREGNANCY / PERIMENOPAUSE / OFF suppress it (the suppression is - * always server-side, never iOS). + * Whether a goal surfaces the fertile window. Per algorithm.md §4 the window is + * shown for TRYING_TO_CONCEIVE and AVOID_PREGNANCY (AVOID_PREGNANCY carries a + * stronger "estimate, not a contraceptive method" caveat in the copy), and + * suppressed for GENERAL_HEALTH / PERIMENOPAUSE / OFF (the suppression is always + * server-side, never iOS). */ export function goalAllowsFertileWindow(goal: string): boolean { - return goal === "TRYING_TO_CONCEIVE"; + // algorithm.md §4: shown for TRYING_TO_CONCEIVE AND AVOID_PREGNANCY (the + // latter carries a stronger "not a contraceptive method" caveat in copy), + // hidden for GENERAL_HEALTH / PERIMENOPAUSE / OFF. + return goal === "TRYING_TO_CONCEIVE" || goal === "AVOID_PREGNANCY"; } diff --git a/src/lib/cycle/engine-adapter.ts b/src/lib/cycle/engine-adapter.ts index 1474d0fdb..814596b34 100644 --- a/src/lib/cycle/engine-adapter.ts +++ b/src/lib/cycle/engine-adapter.ts @@ -17,7 +17,7 @@ import { type CyclePredictionResult, type CyclePhase, type PhaseCycle, - LUTEAL_DEFAULT, + resolveLuteal, } from "@/lib/cycle"; import type { CycleProfile, @@ -219,7 +219,10 @@ export function buildCalendar( today: string, goalAllowsFertile: boolean, ): CalendarBuildResult { - const lutealLength = profile.lutealPhaseLength ?? LUTEAL_DEFAULT; + // Clamp identically to the engine's resolveLuteal so the predicted-ovulation + // dot (engine-clamped) and the OVULATORY phase band (this value) never + // diverge for an out-of-clamp stored luteal length (QA HIGH). + const lutealLength = resolveLuteal(profile); // Forecast (only when prediction is on + not raw-chart mode). let prediction: CyclePredictionResult | null = null; diff --git a/src/lib/cycle/index.ts b/src/lib/cycle/index.ts index 13ff49741..a0368f830 100644 --- a/src/lib/cycle/index.ts +++ b/src/lib/cycle/index.ts @@ -19,5 +19,7 @@ export { detectTemperatureTrend, median, mad, + resolveLuteal, + clampLuteal, } from "./prediction"; export { phaseForDay, phaseSeries, type PhaseCycle } from "./phase"; diff --git a/src/lib/cycle/phase-crosstab.ts b/src/lib/cycle/phase-crosstab.ts index 7732cd790..096a9aa3e 100644 --- a/src/lib/cycle/phase-crosstab.ts +++ b/src/lib/cycle/phase-crosstab.ts @@ -75,7 +75,20 @@ export type PhaseCrosstabDisplay = | "bpm" | "ms" | "kg" - | "celsius"; + | "celsius" + | "glucose" + | "mood"; + +/** + * Synthetic non-MeasurementType channel key for the MOOD outcome. Mood lives in + * the separate `MoodEntry` model (1–5 score), not a Measurement row, so the + * caller injects it into the crosstab `measurements` array tagged with this key + * and `metricDayMap` groups it like any other type (its pass-through fallback + * keeps every row — there is no source ladder for mood). It is deliberately NOT + * a member of `PHASE_CROSSTAB_METRIC_TYPES`, so it never enters the Measurement + * read query. + */ +export const MOOD_CHANNEL_KEY = "MOOD"; /** * The outcome metric grid the phase contrast walks. Mirrors @@ -86,7 +99,7 @@ export type PhaseCrosstabDisplay = */ export const PHASE_CROSSTAB_METRICS: Record< string, - { type: MeasurementType; display: PhaseCrosstabDisplay } + { type: MeasurementType | typeof MOOD_CHANNEL_KEY; display: PhaseCrosstabDisplay } > = { restingHeartRate: { type: "RESTING_HEART_RATE", display: "bpm" }, heartRateVariability: { type: "HEART_RATE_VARIABILITY", display: "ms" }, @@ -96,13 +109,26 @@ export const PHASE_CROSSTAB_METRICS: Record< basalBodyTemp: { type: "BODY_TEMPERATURE", display: "celsius" }, wristTemperature: { type: "WRIST_TEMPERATURE", display: "celsius" }, skinTemperature: { type: "SKIN_TEMPERATURE", display: "celsius" }, + // QA HIGH (features): the crosstab must cover MOOD + GLUCOSE as outcomes, + // under the identical FDR / day-floor guards. Glucose is a MeasurementType; + // mood is the synthetic MOOD_CHANNEL_KEY the route injects (see above). + bloodGlucose: { type: "BLOOD_GLUCOSE", display: "glucose" }, + mood: { type: MOOD_CHANNEL_KEY, display: "mood" }, } as const; export type PhaseCrosstabMetricKey = keyof typeof PHASE_CROSSTAB_METRICS; -/** Distinct measurement types the phase crosstab reads — single-sourced. */ +/** + * Distinct MeasurementType values the phase crosstab reads — single-sourced. + * MOOD_CHANNEL_KEY is excluded: it is not a Measurement row (the route reads it + * from MoodEntry and injects it), so it must never enter the Measurement query. + */ export const PHASE_CROSSTAB_METRIC_TYPES: MeasurementType[] = Array.from( - new Set(Object.values(PHASE_CROSSTAB_METRICS).map((m) => m.type)), + new Set( + Object.values(PHASE_CROSSTAB_METRICS) + .map((m) => m.type) + .filter((t): t is MeasurementType => t !== MOOD_CHANNEL_KEY), + ), ); /** The two phases the categorical contrast compares (largest contrast). */ diff --git a/src/lib/cycle/phase.ts b/src/lib/cycle/phase.ts index 211f6bc1a..c351db77a 100644 --- a/src/lib/cycle/phase.ts +++ b/src/lib/cycle/phase.ts @@ -18,6 +18,7 @@ */ import { addDays, dayDiff } from "./day-math"; +import { clampLuteal } from "./prediction"; import { LUTEAL_DEFAULT, POPULATION_DEFAULT_PERIOD, type CyclePhase } from "./types"; /** A resolved cycle window the phase mapper needs. */ @@ -54,7 +55,8 @@ export function phaseForDay( const dayOfCycle = offsetFromStart + 1; // 1-based const periodLength = cycle.periodLength ?? POPULATION_DEFAULT_PERIOD; - const lutealLength = cycle.lutealLength ?? LUTEAL_DEFAULT; + // Clamp identically to the prediction engine (QA HIGH: one source of truth). + const lutealLength = clampLuteal(cycle.lutealLength ?? LUTEAL_DEFAULT); // Ovulation: confirmed/estimated date, or back-calculated from length. const ovulationDate = diff --git a/src/lib/cycle/prediction.ts b/src/lib/cycle/prediction.ts index 94ac6b64e..736396cac 100644 --- a/src/lib/cycle/prediction.ts +++ b/src/lib/cycle/prediction.ts @@ -16,6 +16,7 @@ import { addDays, dayDiff, roundHalf } from "./day-math"; import { ADHERENCE_FLOOR, ADHERENCE_SLOPE, + BBT_WINDOW, COLD_START_BAND_BONUS, CONFIDENCE_LABEL_HIGH_MIN, CONFIDENCE_LABEL_LOW_MAX, @@ -272,8 +273,11 @@ export function detectTempShift( .map((l) => ({ date: l.date, t: l.basalBodyTempC as number })) .sort((a, b) => dayDiff(a.date, b.date)); - // Need 6 baseline + 3 elevated = 9 readings minimum. - for (let i = 6; i + 2 < temps.length; i++) { + // 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). + 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]]; @@ -413,11 +417,21 @@ function clamp(x: number, lo: number, hi: number): number { return Math.min(hi, Math.max(lo, x)); } -function resolveLuteal(profile: CycleProfileInput): number { - const raw = profile.lutealPhaseLength ?? LUTEAL_DEFAULT; +/** + * Clamp a raw luteal length to the physiological range [LUTEAL_MIN, LUTEAL_MAX] + * (§4). The ONE source of truth for resolving luteal length — the engine, + * calendar adapter, and phase mapper all route through this so the + * predicted-ovulation dot and the OVULATORY band can never diverge for a user + * whose stored value is out of clamp (QA HIGH: luteal clamp divergence). + */ +export function clampLuteal(raw: number): number { return clamp(raw, LUTEAL_MIN, LUTEAL_MAX); } +export function resolveLuteal(profile: Pick): number { + return clampLuteal(profile.lutealPhaseLength ?? LUTEAL_DEFAULT); +} + /* ------------------------------------------------------------------ */ /* main entry */ /* ------------------------------------------------------------------ */ @@ -490,21 +504,43 @@ export function predictCycle( // Precedence: confirmed-symptothermal > temperature-trend > calendar. A // confirmed signal overrides the CURRENT cycle's ovulation/next-start; the // calendar predictor still fills all FUTURE cycles (BLENDED records that mix). - const symptoOvulation = confirmSymptothermal(dayLogs); - const trendOvulation = symptoOvulation ? null : detectTemperatureTrend(nights); + // + // Window scoping (QA HIGH): the symptothermal/temperature-trend detectors + // must only see the CURRENT cycle's signal, otherwise a multi-cycle user's + // stale prior-cycle BBT/mucus confirms a months-old ovulation and lands + // nextPeriodStart in the past. Scope to [lastConfirmedStart − BBT_WINDOW, + // today]; the BBT_WINDOW reach-back keeps the 6-reading baseline intact for a + // young current cycle. detectTempShift returns the LATEST qualifying shift. + const windowStart = addDays(lastConfirmedStart, -BBT_WINDOW); + const isInCurrentWindow = (date: string) => + 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 trendOvulation = symptoOvulation + ? null + : detectTemperatureTrend(currentNights); if (symptoOvulation) { - predictedOvulation = symptoOvulation; - nextStart = addDays(symptoOvulation, lutealLength); - ovulationConfirmed = true; - confirmMultiplier = HALF_WIDTH_MULT_SYMPTOTHERMAL; - method = lengths.length > 0 ? "BLENDED" : "SYMPTOTHERMAL"; + const confirmedNextStart = addDays(symptoOvulation, lutealLength); + // Reject a confirmation whose next-start is already in the past (a stale + // signal that slipped through the window) — fall back to the calendar. + if (dayDiff(confirmedNextStart, today) >= 0) { + predictedOvulation = symptoOvulation; + nextStart = confirmedNextStart; + ovulationConfirmed = true; + confirmMultiplier = HALF_WIDTH_MULT_SYMPTOTHERMAL; + method = lengths.length > 0 ? "BLENDED" : "SYMPTOTHERMAL"; + } } else if (trendOvulation) { - predictedOvulation = trendOvulation; - nextStart = addDays(trendOvulation, lutealLength); - ovulationConfirmed = true; - confirmMultiplier = HALF_WIDTH_MULT_TEMP_TREND; - method = lengths.length > 0 ? "BLENDED" : "TEMPERATURE_TREND"; + const confirmedNextStart = addDays(trendOvulation, lutealLength); + if (dayDiff(confirmedNextStart, today) >= 0) { + predictedOvulation = trendOvulation; + nextStart = confirmedNextStart; + ovulationConfirmed = true; + confirmMultiplier = HALF_WIDTH_MULT_TEMP_TREND; + method = lengths.length > 0 ? "BLENDED" : "TEMPERATURE_TREND"; + } } // -------- §3 band half-width -------- diff --git a/src/lib/cycle/types.ts b/src/lib/cycle/types.ts index b23a5dbb3..fdb3926ea 100644 --- a/src/lib/cycle/types.ts +++ b/src/lib/cycle/types.ts @@ -120,6 +120,15 @@ 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 — trailing lookback (days) for the symptothermal / temperature-trend + * detectors. The 3-over-6 BBT rule needs 6 baseline readings before the rise, + * so a current-cycle scan starting at lastConfirmedStart must reach back this + * far to keep the baseline window intact when the cycle is still young. Also + * the floor for windowing the day-log read on the hot calendar/insights routes. + */ +export const BBT_WINDOW = 40; + /** §3 — confidence scalar clamp (never certain, never zero). */ export const CONFIDENCE_MIN = 0.05; export const CONFIDENCE_MAX = 0.98; diff --git a/src/lib/validations/cycle.ts b/src/lib/validations/cycle.ts index e4f490f1b..ce0bda8bb 100644 --- a/src/lib/validations/cycle.ts +++ b/src/lib/validations/cycle.ts @@ -176,7 +176,9 @@ export const cyclePrefsSchema = z sensitiveCategoryEncryption: z.boolean().optional(), typicalCycleLength: z.number().int().min(15).max(60).nullable().optional(), typicalPeriodLength: z.number().int().min(1).max(15).nullable().optional(), - lutealPhaseLength: z.number().int().min(8).max(20).nullable().optional(), + // Bound matches the engine clamp [LUTEAL_MIN, LUTEAL_MAX] (§4) so the + // stored value never silently snaps in the predictor (QA HIGH). + lutealPhaseLength: z.number().int().min(10).max(16).nullable().optional(), }) .refine((v) => Object.keys(v).length > 0, { message: "At least one preference field is required",