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 4e10ce533..259329ac8 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 @@ -401,6 +401,72 @@ describe("GET /api/cycle/calendar carries the resolved verdict", () => { }); }); + /** + * The cold-start gate suppresses the PHASE band while the engine is still + * learning — a population-framed phase word off one or two cycles is not + * earned. The day count is not a guess: it is the number of days since a + * start the person logged themselves. Reading it off the phase labels meant + * the gate took both, so a record with three logged periods rendered an + * empty ring and offered to log a first period. + */ + describe("while the engine is still learning", () => { + it("still counts the cycle day off the logged start", async () => { + const today = "2026-06-11"; + const lastStart = addDays(today, -10); + // Three starts → two observed lengths → under the three-cycle gate. + vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue( + cycleRows([ + addDays(lastStart, -56), + addDays(lastStart, -28), + lastStart, + ]) 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 data = body.data as Record; + // The gate really is on, so the phase below is suppressed rather than absent. + expect(data.stillLearning).toBe(true); + + const verdict = data.verdict as Record; + expect(verdict.state).toBe("IN_CYCLE"); + expect(verdict.dayOfCycle).toBe(11); + expect(verdict.cycleStartDate).toBe(lastStart); + // No phase claim — that part of the gate stands. + expect(verdict.phase).toBeNull(); + // The ring still draws a full wheel rather than an empty circle. + expect(verdict.spans).toHaveLength(4); + }); + + it("still says how late a period is", async () => { + const today = "2026-06-11"; + const lastStart = addDays(today, -50); + vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue( + cycleRows([ + addDays(lastStart, -56), + addDays(lastStart, -28), + lastStart, + ]) 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 + >; + + // Day 51 against a stated 28-day typical plus 14 days of grace. + expect(verdict.state).toBe("OVERDUE"); + expect(verdict.overdueDays).toBe(23); + expect(verdict.cycleStartDate).toBe(lastStart); + }); + }); + 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/calendar/route.ts b/src/app/api/cycle/calendar/route.ts index f5f4d1f51..2c69eb869 100644 --- a/src/app/api/cycle/calendar/route.ts +++ b/src/app/api/cycle/calendar/route.ts @@ -195,6 +195,12 @@ export const GET = apiHandler(async (request: NextRequest) => { nextPeriodStart: predictionDto?.nextPeriodStart ?? null, fertileWindowStart: predictionDto?.fertileWindowStart ?? null, fertileWindowEnd: predictionDto?.fertileWindowEnd ?? null, + // The latest start the person logged, read off the cycle rows rather than + // the grid. The grid's phase band is withheld while the engine is still + // learning, and the day count must not go with it — `cycles` is ordered by + // startDate ascending, so the last one at or before today is the anchor. + lastPeriodStart: + cycles.filter((c) => c.startDate <= today).at(-1)?.startDate ?? null, }); annotate({ diff --git a/src/lib/cycle/__tests__/verdict.test.ts b/src/lib/cycle/__tests__/verdict.test.ts index a7be69428..117c61efc 100644 --- a/src/lib/cycle/__tests__/verdict.test.ts +++ b/src/lib/cycle/__tests__/verdict.test.ts @@ -163,6 +163,60 @@ describe("resolveCycleVerdict — what the record supports", () => { expect(v.cycleLength).toBe(8); }); + it("counts off the logged start when the grid withholds its phase labels", () => { + // What the calendar hands over while the engine is still learning: a grid + // with every phase suppressed. The count is not the suppressed claim. + const days = [day("2026-06-01", null), day("2026-06-11", null)]; + const v = resolveCycleVerdict({ + days, + today: "2026-06-11", + profile: { typicalCycleLength: 28 }, + lastPeriodStart: "2026-06-01", + }); + expect(v.state).toBe("IN_CYCLE"); + expect(v.dayOfCycle).toBe(11); + expect(v.cycleStartDate).toBe("2026-06-01"); + expect(v.phase).toBeNull(); + expect(v.spans).toHaveLength(4); + }); + + it("says how late a period is with no phase labels to read", () => { + const days = [day("2026-06-11", null)]; + const v = resolveCycleVerdict({ + days, + today: "2026-06-11", + profile: { typicalCycleLength: 28 }, + lastPeriodStart: "2026-04-22", + }); + expect(v.state).toBe("OVERDUE"); + expect(v.dayOfCycle).toBeNull(); + expect(v.overdueDays).toBe(23); + expect(v.cycleStartDate).toBe("2026-04-22"); + }); + + it("counts off the logged start when today is absent from the grid", () => { + const days = [day("2026-06-01", "MENSTRUAL")]; + const v = resolveCycleVerdict({ + days, + today: "2026-06-09", + lastPeriodStart: "2026-06-01", + }); + expect(v.state).toBe("IN_CYCLE"); + expect(v.dayOfCycle).toBe(9); + }); + + it("keeps INSUFFICIENT_DATA when the anchor is ahead of today", () => { + // A start dated in the future is not a cycle the person is in. + const days = [day("2026-06-02", null)]; + const v = resolveCycleVerdict({ + days, + today: "2026-06-02", + lastPeriodStart: "2026-06-20", + }); + expect(v.state).toBe("INSUFFICIENT_DATA"); + expect(v.cycleStartDate).toBeNull(); + }); + it("draws the canonical four arcs for a low-data tracker", () => { // A two-day first-ever run: the observed-share math would let MENSTRUAL // fill nearly the whole circle, so the ring falls back to the profile's diff --git a/src/lib/cycle/verdict.ts b/src/lib/cycle/verdict.ts index 5fb5ecadd..ae7a83c91 100644 --- a/src/lib/cycle/verdict.ts +++ b/src/lib/cycle/verdict.ts @@ -116,6 +116,21 @@ export interface ResolveCycleVerdictInput { /** Already goal- and cold-start-gated fertile window bounds. */ fertileWindowStart?: string | null; fertileWindowEnd?: string | null; + /** + * The latest LOGGED period start at or before today, or null when the record + * holds none. Read straight off the cycle rows, never off the grid. + * + * The grid's phase labels are gated: the calendar suppresses the whole phase + * band while the engine is still learning (fewer than three observed cycles), + * because a population-framed phase word off one or two cycles is not earned. + * The day count is not that kind of claim — it is the number of days since a + * start the person entered themselves — but deriving it from the labelled run + * meant the gate took both, and a record with three logged periods rendered an + * empty ring under an offer to log a first period. This anchor keeps the count + * and the honest overdue judgement available when the labels are withheld; + * the phase stays null, which is the part the gate is actually about. + */ + lastPeriodStart?: string | null; } /** Below this many labelled days in the current cycle run, the ring would @@ -234,6 +249,41 @@ function resolveFertileWindow( }; } +/** + * The verdict for a record whose grid carries no phase for today but whose + * cycle rows carry a logged period start: count from that start, and apply the + * same overdue ceiling. No phase is claimed — that is exactly the claim the + * missing labels were withholding. Null when no start precedes today, which is + * the one case that really is INSUFFICIENT_DATA. + */ +function verdictFromLoggedStart( + input: ResolveCycleVerdictInput, + fertileWindow: CycleVerdict["fertileWindow"], + daysUntilNext: number | null, +): CycleVerdict | null { + const { today, profile, lastPeriodStart } = input; + if (!lastPeriodStart) return null; + const elapsed = dayDiff(today, lastPeriodStart); + if (elapsed < 0) return null; + + const dayOfCycle = elapsed + 1; + const typicalLength = typicalCycleLength(profile); + const ideal = idealizedSpans(profile); + const overdue = dayOfCycle > typicalLength + OVERDUE_GRACE_DAYS; + + return { + state: overdue ? "OVERDUE" : "IN_CYCLE", + dayOfCycle: overdue ? null : dayOfCycle, + cycleLength: ideal.cycleLength, + phase: null, + spans: ideal.spans, + cycleStartDate: lastPeriodStart, + overdueDays: overdue ? dayOfCycle - typicalLength : null, + daysUntilNext, + fertileWindow, + }; +} + /** * Resolve everything a client needs to render the cycle ring and its caption. * The only place in the product that decides any of it. @@ -259,12 +309,22 @@ export function resolveCycleVerdict( fertileWindow, }; - if (!todayDay || todayDay.phase == null) return nothingToSay; + if (!todayDay || todayDay.phase == null) { + return ( + verdictFromLoggedStart(input, fertileWindow, daysUntilNext) ?? + nothingToSay + ); + } // Sort dates ascending and find today's index. const sorted = [...days].sort((a, b) => a.date.localeCompare(b.date)); const todayIdx = sorted.findIndex((d) => d.date === today); - if (todayIdx < 0) return nothingToSay; + if (todayIdx < 0) { + return ( + verdictFromLoggedStart(input, fertileWindow, daysUntilNext) ?? + nothingToSay + ); + } const startIdx = findCycleStartIndex(sorted, todayIdx); const cycleStartDate = sorted[startIdx].date;