diff --git a/src/app/api/cycle/calendar/__tests__/verdict-round-trip.test.ts b/src/app/api/cycle/calendar/__tests__/verdict-round-trip.test.ts index eb19019b7..4e10ce533 100644 --- a/src/app/api/cycle/calendar/__tests__/verdict-round-trip.test.ts +++ b/src/app/api/cycle/calendar/__tests__/verdict-round-trip.test.ts @@ -103,6 +103,16 @@ const LONG_CYCLE_STARTS = [ "2026-04-30", ]; +/** + * Four period starts exactly 28 days apart — the textbook regular record. The + * last start is open, so where "today" sits relative to `lastStart + 28` + * decides what the verdict may claim. Built from a supplied last start so a + * test can place today a chosen number of days past the predicted one. + */ +function regularStarts(lastStart: string): string[] { + return [-84, -56, -28, 0].map((offset) => addDays(lastStart, offset)); +} + function cycleRows(starts: readonly string[]) { return starts.map((startDate, i) => ({ id: `cyc-${i}`, @@ -249,6 +259,7 @@ describe("GET /api/cycle/calendar carries the resolved verdict", () => { cycleRows(LONG_CYCLE_STARTS) as never, prediction?.nextPeriodStart ?? null, resolveLuteal(profile() as never), + "2026-05-20", ); expect(verdict.dayOfCycle).toBe(fromEngine.dayOfCycle); expect(verdict.phase).toBe(fromEngine.phase); @@ -276,6 +287,120 @@ describe("GET /api/cycle/calendar carries the resolved verdict", () => { expect(prediction!.nextPeriodStart).toBe(addDays("2026-05-20", expected)); }); + /** + * D-list edge 1 — the verdict at and after the predicted start day, driven + * through the real route. + * + * A regular 28-day record whose period has not arrived is the ORDINARY way + * to be late, and it is the case the ring was silent about: the engine ends + * the open cycle's phase window at the predicted start, so the day after it + * carried no phase, and the verdict resolver read "no phase today" as "no + * data at all". The person saw an empty ring and the words insufficient data + * on the third day of a late period. `OVERDUE` was only ever reachable when + * the engine itself forecast a cycle longer than the grace window. + */ + describe("a period that has not arrived", () => { + /** Place today N days past `lastStart + 28`, the predicted next start. */ + function overdueBy(days: number): { today: string; lastStart: string } { + const today = "2026-06-11"; + return { today, lastStart: addDays(today, -(28 + days)) }; + } + + function seedRegular(lastStart: string): void { + vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue( + cycleRows(regularStarts(lastStart)) as never, + ); + } + + it("keeps counting the open cycle past the predicted start", async () => { + const { today, lastStart } = overdueBy(3); + seedRegular(lastStart); + vi.mocked(getSession).mockResolvedValue(session("UTC") as never); + vi.useFakeTimers(); + vi.setSystemTime(new Date(`${today}T12:00:00Z`)); + + const { body } = await callCalendar(); + const data = body.data as Record; + const verdict = data.verdict as Record; + const prediction = data.prediction as { nextPeriodStart: string } | null; + + // The predicted start really is behind us — otherwise this asserts nothing. + expect(prediction?.nextPeriodStart).toBe(addDays(today, -3)); + + expect(verdict.state).toBe("IN_CYCLE"); + expect(verdict.dayOfCycle).toBe(32); + expect(verdict.phase).toBe("LUTEAL"); + expect(verdict.cycleStartDate).toBe(lastStart); + // There is no "until" once the predicted start is behind us. + expect(verdict.daysUntilNext).toBeNull(); + }); + + it("says OVERDUE, with the day count, once the grace window is spent", async () => { + // Typical 28 + 14 days of grace = 42. Day 44 is past it. + const { today, lastStart } = overdueBy(15); + seedRegular(lastStart); + vi.mocked(getSession).mockResolvedValue(session("UTC") as never); + vi.useFakeTimers(); + vi.setSystemTime(new Date(`${today}T12:00:00Z`)); + + const { body } = await callCalendar(); + const verdict = (body.data as Record).verdict as Record< + string, + unknown + >; + + expect(verdict.state).toBe("OVERDUE"); + expect(verdict.dayOfCycle).toBeNull(); + expect(verdict.phase).toBeNull(); + expect(verdict.overdueDays).toBe(16); + expect(verdict.cycleStartDate).toBe(lastStart); + expect(verdict.spans).toHaveLength(4); + }); + + it("holds the count on the predicted start day itself", async () => { + const { today, lastStart } = overdueBy(0); + seedRegular(lastStart); + vi.mocked(getSession).mockResolvedValue(session("UTC") as never); + vi.useFakeTimers(); + vi.setSystemTime(new Date(`${today}T12:00:00Z`)); + + const { body } = await callCalendar(); + const verdict = (body.data as Record).verdict as Record< + string, + unknown + >; + + // Day 29 of a 28-day cycle: the period is due today and has not been + // logged, which is a cycle day, not an absence of data. + expect(verdict.state).toBe("IN_CYCLE"); + expect(verdict.dayOfCycle).toBe(29); + expect(verdict.daysUntilNext).toBe(0); + }); + + it("stops extending the open window once the next period is logged", async () => { + // The same record with one more start logged four days ago: the earlier + // cycle closes at that start and today is day 5 of the new one. + const { today, lastStart } = overdueBy(3); + const withNewStart = [...regularStarts(lastStart), addDays(today, -4)]; + vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue( + cycleRows(withNewStart) as never, + ); + vi.mocked(getSession).mockResolvedValue(session("UTC") as never); + vi.useFakeTimers(); + vi.setSystemTime(new Date(`${today}T12:00:00Z`)); + + const { body } = await callCalendar(); + const verdict = (body.data as Record).verdict as Record< + string, + unknown + >; + + expect(verdict.state).toBe("IN_CYCLE"); + expect(verdict.dayOfCycle).toBe(5); + expect(verdict.cycleStartDate).toBe(addDays(today, -4)); + }); + }); + it("says INSUFFICIENT_DATA when nothing has been logged", async () => { vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue([] as never); vi.mocked(getSession).mockResolvedValue(session("UTC") as never); diff --git a/src/app/api/cycle/insights/route.ts b/src/app/api/cycle/insights/route.ts index ba23e7992..d23d5c1da 100644 --- a/src/app/api/cycle/insights/route.ts +++ b/src/app/api/cycle/insights/route.ts @@ -176,6 +176,7 @@ export const GET = apiHandler(async () => { profile.lutealPhaseLength ?? LUTEAL_DEFAULT, from, today, + today, ); const measurements: CrossMetricMeasurement[] = measurementRows.map((m) => ({ diff --git a/src/lib/ai/coach/cycle-snapshot.ts b/src/lib/ai/coach/cycle-snapshot.ts index b3ee48a32..c4f0b4cad 100644 --- a/src/lib/ai/coach/cycle-snapshot.ts +++ b/src/lib/ai/coach/cycle-snapshot.ts @@ -242,6 +242,7 @@ export async function buildCycleSnapshotBlock( cycles, prediction?.nextPeriodStart ?? null, lutealLength, + today, ); // Next predicted event — period range + confidence + method, fertile window @@ -280,6 +281,7 @@ export async function buildCycleSnapshotBlock( lutealLength, from, today, + today, ); const measurements: CrossMetricMeasurement[] = measurementRows.map((m) => ({ type: m.type, diff --git a/src/lib/cycle/engine-adapter.ts b/src/lib/cycle/engine-adapter.ts index a6c3e1901..b2add008d 100644 --- a/src/lib/cycle/engine-adapter.ts +++ b/src/lib/cycle/engine-adapter.ts @@ -18,6 +18,8 @@ import { type CyclePhase, type PhaseCycle, resolveLuteal, + clampLuteal, + POPULATION_DEFAULT_CYCLE, } from "@/lib/cycle"; import type { CycleProfile, @@ -129,9 +131,30 @@ function periodLengthOf( } /** - * Build a contiguous `PhaseCycle[]` from the observed cycles (each cycle's - * span ends where the next begins; the latest open cycle runs to the - * predicted next-period start). Used to label every calendar day's phase. + * Build a contiguous `PhaseCycle[]` from the observed cycles: each closed + * cycle's span ends where the next begins, and the latest open cycle runs to + * the predicted next-period start — or, when that day has come and gone with + * no period logged, on through `today`. + * + * That trailing extension is the difference between a late period and no + * record at all. A cycle stays open until the next one starts, and a forecast + * is a forecast: the day after a missed prediction is still a day of the cycle + * the person is in. Ending the span at the predicted start instead made every + * day past it carry no phase, which the verdict resolver could only read as + * "today falls outside every known cycle window" — so an ordinary three-days- + * late period reported INSUFFICIENT_DATA, and the honest OVERDUE state was + * unreachable unless the engine itself forecast a cycle longer than the grace + * window. The count is bounded on the other side: `resolveCycleVerdict` stops + * claiming a cycle day once the run passes the profile's typical length plus + * its grace window. + * + * The extension deliberately does NOT move the ovulation estimate. Left to + * `phaseForDay`, an ovulation with no confirmed date is back-calculated from + * the span's own length, so a window that grows a day per day of delay would + * walk the estimate — and every FOLLICULAR/OVULATORY/LUTEAL boundary already + * drawn for that cycle — forward with it. The open cycle therefore carries an + * explicit anchor derived from the PREDICTED length, which is the same date + * `phaseForDay` computes when the span ends where the forecast said it would. */ export function buildPhaseCycles( cycles: readonly Pick< @@ -140,18 +163,37 @@ export function buildPhaseCycles( >[], predictedNextStart: string | null, lutealLength: number, + /** Today in the USER's timezone. The open cycle runs at least this far. */ + today: string, ): PhaseCycle[] { const sorted = [...cycles].sort((a, b) => dayDiff(a.startDate, b.startDate)); const out: PhaseCycle[] = []; for (let i = 0; i < sorted.length; i++) { const c = sorted[i]; const next = sorted[i + 1]; + if (next) { + out.push({ + startDate: c.startDate, + nextStart: next.startDate, + ovulationDate: c.ovulationDate, + periodLength: periodLengthOf(c), + lutealLength, + }); + continue; + } + + // The open cycle. `phaseForDay` treats `nextStart` as exclusive, so the + // window has to reach the day AFTER today for today to carry a phase. + const forecastEnd = + predictedNextStart ?? addDays(c.startDate, POPULATION_DEFAULT_CYCLE); + const throughToday = addDays(today, 1); const nextStart = - next?.startDate ?? predictedNextStart ?? addDays(c.startDate, 28); + dayDiff(throughToday, forecastEnd) > 0 ? throughToday : forecastEnd; out.push({ startDate: c.startDate, nextStart, - ovulationDate: c.ovulationDate, + ovulationDate: + c.ovulationDate ?? addDays(forecastEnd, -clampLuteal(lutealLength)), periodLength: periodLengthOf(c), lutealLength, }); @@ -186,11 +228,14 @@ export function phaseForDate( >[], predictedNextStart: string | null, lutealLength: number, + /** Today in the USER's timezone — how far a still-open cycle runs. */ + today: string, ): { phase: CyclePhase | null; dayOfCycle: number | null } { const phaseCycles = buildPhaseCycles( cycles, predictedNextStart, lutealLength, + today, ); for (const pc of phaseCycles) { const r = phaseForDay(date, pc); @@ -219,11 +264,14 @@ export function buildPhaseDayMap( lutealLength: number, from: string, to: string, + /** Today in the USER's timezone — how far a still-open cycle runs. */ + today: string, ): Map { const phaseCycles = buildPhaseCycles( cycles, predictedNextStart, lutealLength, + today, ); const out = new Map(); const span = dayDiff(to, from); @@ -304,6 +352,7 @@ export function buildCalendar( cycles, prediction?.nextPeriodStart ?? null, lutealLength, + today, ); // Forward predicted period bar: [nextPeriodStart, +predictedPeriodLength).