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 ed1dced44..85427a947 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 @@ -167,6 +167,52 @@ describe("GET /api/cycle/calendar is a read", () => { }); }); +describe("GET /api/cycle/calendar carries the projection (#1350)", () => { + it("publishes the projection whose first row is the prediction", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-10T12:00:00Z")); + + const { status, body } = await callCalendar(); + expect(status).toBe(200); + const data = body.data as { + prediction: { nextPeriodStart: string; nextPeriodStartLow: string }; + projection: Array>; + projectionState: string; + }; + expect(data.projectionState).not.toBe("OFF"); + expect(data.projection.length).toBeGreaterThan(0); + { + expect(data.projection[0]).toMatchObject({ + k: 1, + start: data.prediction.nextPeriodStart, + bandStart: data.prediction.nextPeriodStartLow, + }); + // Dates and bands only: the fertile window stays in `prediction`. + expect(Object.keys(data.projection[0]).sort()).toEqual([ + "bandEnd", + "bandStart", + "k", + "periodLength", + "start", + ]); + } + }); + + it("publishes an empty projection, state OFF, with prediction switched off", async () => { + // The mock answers with one object; switching it off there switches it + // off for the route's own call. + const gate = await requireCycleEnabled("user-1", null); + if (gate.enabled) gate.profile.predictionEnabled = false; + const { body } = await callCalendar(); + const data = body.data as { + projection: unknown[]; + projectionState: string; + }; + expect(data.projection).toEqual([]); + expect(data.projectionState).toBe("OFF"); + }); +}); + describe("GET /api/cycle/calendar carries the resolved verdict", () => { it("puts the whole verdict on the wire", async () => { // 2026-06-10 12:00 UTC is already 2026-06-11 for a user at UTC+14. diff --git a/src/app/api/cycle/calendar/route.ts b/src/app/api/cycle/calendar/route.ts index 247f0341a..17213bab1 100644 --- a/src/app/api/cycle/calendar/route.ts +++ b/src/app/api/cycle/calendar/route.ts @@ -15,7 +15,6 @@ */ import { NextRequest } from "next/server"; -import { prisma } from "@/lib/db"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { @@ -28,24 +27,27 @@ import { requireCycleEnabled } from "@/lib/cycle/gate"; import { cycleCalendarQuerySchema } from "@/lib/validations/cycle"; import { buildCalendar, - type CalendarDayLogRow, + toCycleInputs, + toDayLogInputs, } from "@/lib/cycle/engine-adapter"; +import { + CALENDAR_DEFAULT_PAST_DAYS, + dayLogWindowStart, + loadCalendarInputs, +} from "@/lib/cycle/load-inputs"; +import { PROJECTION_MAX_HORIZON, projectCycles } from "@/lib/cycle/projection"; import { toCyclePredictionDTO, goalAllowsFertileWindow, cycleDisclaimerKey, - resolveSensitiveFields, } from "@/lib/cycle/dto"; import { resolveCycleVerdict } from "@/lib/cycle/verdict"; -import { addDays, dayDiff } from "@/lib/cycle/day-math"; -import { BBT_WINDOW } from "@/lib/cycle/types"; +import { addDays } from "@/lib/cycle/day-math"; import { moodDateKey } from "@/lib/mood/date-key"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { getServerTranslator } from "@/lib/i18n/server-translator"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; -import { startOfLocalDayKey } from "@/lib/tz/local-day"; -const DEFAULT_PAST_DAYS = 90; const DEFAULT_FORWARD_DAYS = 180; /** Hard cap on the rendered span (days) to bound the day-grid build. */ const MAX_SPAN_DAYS = 400; @@ -75,7 +77,7 @@ export const GET = apiHandler(async (request: NextRequest) => { const tz = user.timezone ?? DEFAULT_TIMEZONE; const today = moodDateKey(new Date(), tz); - const from = parsed.data.from ?? addDays(today, -DEFAULT_PAST_DAYS); + const from = parsed.data.from ?? addDays(today, -CALENDAR_DEFAULT_PAST_DAYS); const to = parsed.data.to ?? addDays(today, DEFAULT_FORWARD_DAYS); // Reject an inverted or absurdly wide range up front. @@ -87,103 +89,19 @@ export const GET = apiHandler(async (request: NextRequest) => { }); } - const [cycles, dayLogRows, nightlyTemps] = await Promise.all([ - prisma.menstrualCycle.findMany({ - 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, - date: { - gte: - dayDiff(from, addDays(today, -BBT_WINDOW)) <= 0 - ? from - : addDays(today, -BBT_WINDOW), - }, - }, - orderBy: { date: "asc" }, - select: { - date: true, - flow: true, - basalBodyTempC: true, - temperatureExcluded: true, - ovulationTest: true, - cervicalMucus: true, - cervixPosition: true, - cervixFirmness: true, - cervixOpening: true, - intermenstrualBleeding: true, - // The intent fields and their encrypted envelope, resolved below - // through the same reader the day-log route uses. Same `cycle` grant - // as that route (see sharing-surface-guard), so the grid shows a - // delegate nothing the day log would not. - sexualActivity: true, - protectedSex: true, - pregnancyTest: true, - progesteroneTest: true, - contraceptive: true, - sensitiveEncrypted: true, - // Selected for presence only; the text is never decrypted here. - notesEncrypted: true, - _count: { select: { symptomLinks: true } }, - }, - }), - // Apple Watch wrist/skin temperature feeds the temperature-trend - // ovulation layer. Read the WRIST_TEMPERATURE measurements as nightly - // values; the engine derives the trailing-mean deviation itself. - // - // This is a measurements read on a route declared under `cycle`, and it - // needs no section predicate for one reason: no value from it reaches the - // response. It goes into `predictCycle` and what comes out the other side - // is a shifted date. The crosstab next door reads the same channel and IS - // fenced, because there the values are the answer. - prisma.measurement.findMany({ - where: { - userId: user.id, - deletedAt: null, - type: "WRIST_TEMPERATURE", - measuredAt: { - gte: startOfLocalDayKey(addDays(today, -90), tz), - }, - }, - orderBy: { measuredAt: "asc" }, - select: { measuredAt: true, value: true }, - }), - ]); - - const dayLogs: CalendarDayLogRow[] = dayLogRows.map((l) => { - const sensitive = resolveSensitiveFields(l); - return { - date: l.date, - flow: l.flow, - basalBodyTempC: l.basalBodyTempC, - temperatureExcluded: l.temperatureExcluded, - ovulationTest: l.ovulationTest, - cervicalMucus: l.cervicalMucus, - cervixPosition: l.cervixPosition, - cervixFirmness: l.cervixFirmness, - cervixOpening: l.cervixOpening, - intermenstrualBleeding: l.intermenstrualBleeding, - sexualActivity: sensitive.sexualActivity, - pregnancyTest: sensitive.pregnancyTest, - progesteroneTest: sensitive.progesteroneTest, - contraceptive: sensitive.contraceptive, - hasSymptoms: l._count.symptomLinks > 0, - hasNote: l.notesEncrypted != null && l.notesEncrypted !== "", - }; + // One loader for the forecast's inputs, shared with the Coach block and the + // calendar feed (`load-inputs.ts`). The day-log read is bounded to the + // rendered span plus the symptothermal lookback. Cycle lengths come from the + // MenstrualCycle rows; the window only reaches the period-length fallback, + // which is why the readers that promise to agree with this page read the + // default view's window (`defaultCalendarDayLogStart`). + const { cycles, dayLogs, nights } = await loadCalendarInputs({ + userId: user.id, + today, + tz, + dayLogsFrom: dayLogWindowStart(from, today), }); - const nights = nightlyTemps.map((m) => ({ - date: moodDateKey(m.measuredAt, tz), - valueC: m.value, - })); - const goalAllowsFertile = goalAllowsFertileWindow(profile.goal); const { prediction, stillLearning, days } = buildCalendar( @@ -244,6 +162,19 @@ export const GET = apiHandler(async (request: NextRequest) => { cycles.filter((c) => c.startDate <= today).at(-1)?.startDate ?? null, }); + // The cycles beyond the next one, from the same engine run. Read by the + // predictions panel; the calendar feed serves the same projection, so a + // client never extrapolates further dates itself. Fertile fields stay in + // `prediction.*`, where the goal and cold-start gates already apply. + const projection = projectCycles({ + prediction, + cycles: toCycleInputs(cycles), + dayLogs: toDayLogInputs(dayLogs), + goal: profile.goal, + today, + horizon: PROJECTION_MAX_HORIZON, + }); + annotate({ action: { name: "cycle.calendar.read" }, meta: { @@ -276,6 +207,10 @@ export const GET = apiHandler(async (request: NextRequest) => { // client renders a calm "learning your cycle" banner over the grid. Additive // + back-compatible — older iOS builds ignore the field. stillLearning, + // Additive (#1350): the next cycles with their widening bands, and why + // the list holds what it holds (OK / LEARNING / LATE / OFF). + projection: projection.cycles, + projectionState: projection.state, days, meta: { generatedAt }, }); diff --git a/src/lib/ai/coach/__tests__/cycle-snapshot.test.ts b/src/lib/ai/coach/__tests__/cycle-snapshot.test.ts index 9b4aa84fc..e6acb859d 100644 --- a/src/lib/ai/coach/__tests__/cycle-snapshot.test.ts +++ b/src/lib/ai/coach/__tests__/cycle-snapshot.test.ts @@ -190,6 +190,59 @@ describe("buildCycleSnapshotBlock", () => { /descriptive pattern, not a cause/, ); }); + + it("carries the projected later periods as ranges (#1350)", async () => { + prismaMock.cycleProfile.findUnique.mockResolvedValue(profile()); + // Thirteen regular cycles: past the cold start, so the projection reaches + // beyond the next period. + const many = Array.from({ length: 13 }, (_, i) => { + const start = new Date(Date.UTC(2025, 3, 7 + i * 28)); + return { + startDate: start.toISOString().slice(0, 10), + endDate: null, + periodEndDate: new Date(start.getTime() + 4 * 86_400_000) + .toISOString() + .slice(0, 10), + ovulationDate: null, + ovulationConfirmed: false, + }; + }); + prismaMock.menstrualCycle.findMany.mockResolvedValue(many); + const block = await buildCycleSnapshotBlock("u1", "FEMALE", NOW); + expect(block!.upcoming.length).toBeGreaterThan(1); + expect(block!.upcoming[0].start).toBe(block!.nextEvent!.nextPeriodStart); + expect(block!.upcoming[0].earliestStart).toBe( + block!.nextEvent!.nextPeriodStartLow, + ); + for (const u of block!.upcoming) { + expect(Object.keys(u).sort()).toEqual([ + "earliestStart", + "latestStart", + "start", + ]); + } + }); + + it("carries no upcoming periods when prediction is off", async () => { + prismaMock.cycleProfile.findUnique.mockResolvedValue( + profile({ predictionEnabled: false }), + ); + prismaMock.menstrualCycle.findMany.mockResolvedValue(cycles()); + const block = await buildCycleSnapshotBlock("u1", "FEMALE", NOW); + expect(block!.upcoming).toEqual([]); + expect(block!.nextEvent).toBeNull(); + }); + + it("reads day logs from the calendar's default window, not the whole history", async () => { + prismaMock.cycleProfile.findUnique.mockResolvedValue(profile()); + prismaMock.menstrualCycle.findMany.mockResolvedValue(cycles()); + await buildCycleSnapshotBlock("u1", "FEMALE", NOW); + expect(prismaMock.cycleDayLog.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ date: { gte: "2025-12-22" } }), + }), + ); + }); }); describe("buildCycleSnapshotBlock — the reader's units", () => { diff --git a/src/lib/ai/coach/cycle-snapshot.ts b/src/lib/ai/coach/cycle-snapshot.ts index fe6ee6202..d26ab67da 100644 --- a/src/lib/ai/coach/cycle-snapshot.ts +++ b/src/lib/ai/coach/cycle-snapshot.ts @@ -29,14 +29,19 @@ import { resolveUnitPreferences, type UnitPreferences, } from "@/lib/measurements/display-transform"; -import { predictCycle, type NightlyTempInput } from "@/lib/cycle"; -import { LUTEAL_DEFAULT, type CyclePhase } from "@/lib/cycle/types"; +import { resolveLuteal } from "@/lib/cycle"; +import type { CyclePhase } from "@/lib/cycle/types"; import { phaseForDate, buildPhaseDayMap, + gatedPrediction, toCycleInputs, - toDayLogInputs, } from "@/lib/cycle/engine-adapter"; +import { + defaultCalendarDayLogStart, + loadCycleEngineInputs, +} from "@/lib/cycle/load-inputs"; +import { PROJECTION_MAX_HORIZON, projectCycles } from "@/lib/cycle/projection"; import type { CycleProfileInput } from "@/lib/cycle/types"; import { goalAllowsFertileWindow } from "@/lib/cycle/dto"; import { @@ -110,12 +115,25 @@ interface NextEventBlock { fertileWindowEnd?: string; } +/** One projected period start with its band (#1350). */ +interface UpcomingCycleBlock { + start: string; + earliestStart: string; + latestStart: string; +} + export interface CycleSnapshotBlock { phase: CyclePhase | null; dayOfCycle: number | null; goal: string; cyclesObserved: number; nextEvent: NextEventBlock | null; + /** + * The next period and the ones after it, widening, self-capped where the + * band passes ±10 days or half a cycle. Empty while prediction is off, while + * the next period is later than its band, or with no logged cycle. + */ + upcoming: UpcomingCycleBlock[]; phaseInsight: PhaseInsightBlock | null; } @@ -206,80 +224,47 @@ export async function buildCycleSnapshotBlock( const today = moodDateKey(now, tz); const from = addDays(today, -WINDOW_DAYS); - const [cycles, dayLogRows, nightlyTempRows, measurementRows, userRow] = - await Promise.all([ - prisma.menstrualCycle.findMany({ - where: { userId, deletedAt: null }, - orderBy: { startDate: "asc" }, - }), - prisma.cycleDayLog.findMany({ - where: { userId, deletedAt: null }, - orderBy: { date: "asc" }, - select: { - date: true, - flow: true, - basalBodyTempC: true, - temperatureExcluded: true, - ovulationTest: true, - cervicalMucus: true, - cervixPosition: true, - cervixFirmness: true, - cervixOpening: true, - }, - }), - prisma.measurement.findMany({ - where: { - userId, - deletedAt: null, - type: "WRIST_TEMPERATURE", - measuredAt: { - gte: startOfLocalDayKey(addDays(today, -90), tz), - }, - }, - orderBy: { measuredAt: "asc" }, - select: { measuredAt: true, value: true }, - }), - // A year of steps, heart-rate variability and CGM glucose is far too - // many readings to hold as objects (#1023). The crosstab only needs - // each day's sum and count per source and device, which is what the - // per-source picker decides on, so the fold runs in SQL. Days are the - // user's days, the ones `metricDayMap` keys on and the phase days are - // dated in. - readSourceDayAggregates({ - userId, - types: PHASE_CROSSTAB_METRIC_TYPES, - since: startOfLocalDayKey(from, tz), - timeZone: tz, - }), - prisma.user.findUnique({ - where: { id: userId }, - select: { - sourcePriorityJson: true, - unitPreference: true, - glucoseUnit: true, - }, - }), - ]); + const [inputs, measurementRows, userRow] = await Promise.all([ + // The forecast's inputs through the calendar's own loader and the + // calendar's default day-log window, so "next period" here is the date the + // calendar page shows (`load-inputs.ts`). + loadCycleEngineInputs({ + userId, + today, + tz, + dayLogsFrom: defaultCalendarDayLogStart(today), + }), + // A year of steps, heart-rate variability and CGM glucose is far too + // many readings to hold as objects (#1023). The crosstab only needs + // each day's sum and count per source and device, which is what the + // per-source picker decides on, so the fold runs in SQL. Days are the + // user's days, the ones `metricDayMap` keys on and the phase days are + // dated in. + readSourceDayAggregates({ + userId, + types: PHASE_CROSSTAB_METRIC_TYPES, + since: startOfLocalDayKey(from, tz), + timeZone: tz, + }), + prisma.user.findUnique({ + where: { id: userId }, + select: { + sourcePriorityJson: true, + unitPreference: true, + glucoseUnit: true, + }, + }), + ]); + const { cycles } = inputs; if (cycles.length === 0) return null; - const lutealLength = profile.lutealPhaseLength ?? LUTEAL_DEFAULT; + // The engine's own clamp, so the phase this block names agrees with the + // calendar's band for an out-of-range stored luteal length. + const lutealLength = resolveLuteal(profileInput); const goalAllowsFertile = goalAllowsFertileWindow(profile.goal); - const nights: NightlyTempInput[] = nightlyTempRows.map((m) => ({ - date: moodDateKey(m.measuredAt, tz), - valueC: m.value, - })); - const prediction = - profile.predictionEnabled && !profile.rawChartMode - ? predictCycle( - toCycleInputs(cycles), - toDayLogInputs(dayLogRows), - profileInput, - today, - nights, - ) - : null; + const prediction = gatedPrediction(profileInput, inputs, today); const { phase, dayOfCycle } = phaseForDate( today, @@ -318,6 +303,22 @@ export async function buildCycleSnapshotBlock( }; } + // The cycles after the next one, from the same projection the calendar + // publishes and the calendar feed serves. Dates and bands only. + const projection = projectCycles({ + prediction, + cycles: toCycleInputs(cycles), + dayLogs: inputs.dayLogs, + goal: profile.goal, + today, + horizon: PROJECTION_MAX_HORIZON, + }); + const upcoming: UpcomingCycleBlock[] = projection.cycles.map((c) => ({ + start: c.start, + earliestStart: c.bandStart, + latestStart: c.bandEnd, + })); + // Headline phase insight — the FDR-surviving luteal-vs-follicular contrast. const phaseByDay = buildPhaseDayMap( cycles, @@ -378,6 +379,7 @@ export async function buildCycleSnapshotBlock( goal: profile.goal, cyclesObserved: cycles.length, nextEvent, + upcoming, phaseInsight, }; } diff --git a/src/lib/ai/coach/system-prompt.ts b/src/lib/ai/coach/system-prompt.ts index 5f099c264..0e70dcdaf 100644 --- a/src/lib/ai/coach/system-prompt.ts +++ b/src/lib/ai/coach/system-prompt.ts @@ -186,7 +186,8 @@ GROUND RULES 12. Cycle data is DESCRIPTIVE ONLY. When a "cycle" block is present in the SNAPSHOT you may state the user's current phase and day-of-cycle, the next predicted period as a RANGE (reading - "nextEvent" straight from the block, always conditional), and + "nextEvent" straight from the block, always conditional), the + periods after it only as the ranges "upcoming" carries, and the headline phase finding ("phaseInsight") in the block's own observational wording — never as a cause. NEVER frame any prediction as contraception-grade, NEVER name a "safe day" or @@ -756,7 +757,8 @@ GRUNDREGELN "cycle"-Block im SNAPSHOT vorhanden ist, darfst du die aktuelle Phase und den Zyklustag nennen, die nächste vorhergesagte Periode als BEREICH ("nextEvent" direkt aus dem Block, immer - konditional) und das Phasen-Hauptmuster ("phaseInsight") in der + konditional), die Perioden danach nur als die Bereiche, die + "upcoming" enthält, und das Phasen-Hauptmuster ("phaseInsight") in der beobachtenden Formulierung des Blocks — nie als Ursache. Stelle eine Vorhersage NIE als verhütungstauglich dar, nenne NIE einen "sicheren" oder "unsicheren" Tag, berate NIE zum diff --git a/src/lib/cycle/__tests__/projection.test.ts b/src/lib/cycle/__tests__/projection.test.ts new file mode 100644 index 000000000..e0ee9a5ea --- /dev/null +++ b/src/lib/cycle/__tests__/projection.test.ts @@ -0,0 +1,262 @@ +/** + * #1350 — the multi-cycle projection: widening, self-capping, honest about + * a late period, and the engine's own forecast as its first row. + */ +import { describe, expect, it } from "vitest"; + +import { addDays, dayDiff } from "../day-math"; +import { predictCycle } from "../prediction"; +import { + PROJECTION_BAND_CAP_DAYS, + PROJECTION_MAX_HORIZON, + projectCycles, + type ProjectionInput, +} from "../projection"; +import type { + CycleInput, + CycleProfileInput, + CycleTrackingGoal, + DayLogInput, +} from "../types"; + +/** Cycles whose consecutive starts are `lengths` apart, the last one open. */ +function cyclesFrom(first: string, lengths: number[]): CycleInput[] { + const starts = [first]; + for (const L of lengths) starts.push(addDays(starts.at(-1)!, L)); + return starts.map((startDate, i) => ({ + startDate, + endDate: starts[i + 1] ?? null, + periodEndDate: addDays(startDate, 4), + ovulationDate: null, + ovulationConfirmed: false, + })); +} + +/** A flow or "dry" log every day from `from` through `to`: dense logging. */ +function denseLogs(from: string, to: string): DayLogInput[] { + const out: DayLogInput[] = []; + for (let d = from; dayDiff(to, d) >= 0; d = addDays(d, 1)) { + out.push({ + date: d, + flow: "NONE", + basalBodyTempC: null, + ovulationTest: null, + cervicalMucus: null, + }); + } + return out; +} + +function profile( + goal: CycleTrackingGoal = "GENERAL_HEALTH", +): CycleProfileInput { + return { + goal, + typicalCycleLength: null, + typicalPeriodLength: null, + lutealPhaseLength: null, + predictionEnabled: true, + rawChartMode: false, + }; +} + +function run( + cycles: CycleInput[], + today: string, + options: { + dayLogs?: DayLogInput[]; + goal?: CycleTrackingGoal; + horizon?: number; + } = {}, +) { + const dayLogs = options.dayLogs ?? []; + const goal = options.goal ?? "GENERAL_HEALTH"; + const input: ProjectionInput = { + prediction: predictCycle(cycles, dayLogs, profile(goal), today), + cycles, + dayLogs, + goal, + today, + horizon: options.horizon ?? PROJECTION_MAX_HORIZON, + }; + return { input, out: projectCycles(input) }; +} + +const REGULAR = Array.from({ length: 12 }, () => 28); + +describe("projectCycles", () => { + it("is OFF with no forecast and nothing projected", () => { + const cycles = cyclesFrom("2026-01-01", REGULAR); + expect( + projectCycles({ + prediction: null, + cycles, + dayLogs: [], + goal: "GENERAL_HEALTH", + today: "2026-11-06", + horizon: 3, + }), + ).toEqual({ state: "OFF", cycles: [] }); + }); + + it("projects nothing for a record with no logged cycle", () => { + // The engine would anchor a population prior on today; that is not + // this record's forecast. + const { out } = run([], "2026-11-06"); + expect(out).toEqual({ state: "LEARNING", cycles: [] }); + }); + + it("gives the next period only while still learning", () => { + const cycles = cyclesFrom("2026-09-01", [28, 28]); + const last = cycles.at(-1)!.startDate; + const { input, out } = run(cycles, addDays(last, 5)); + expect(out.state).toBe("LEARNING"); + expect(out.cycles).toHaveLength(1); + expect(out.cycles[0].start).toBe(input.prediction!.nextPeriodStart); + }); + + it("gives the next period only for PERIMENOPAUSE", () => { + const cycles = cyclesFrom("2025-11-01", REGULAR); + const last = cycles.at(-1)!.startDate; + const { out } = run(cycles, addDays(last, 5), { goal: "PERIMENOPAUSE" }); + expect(out.state).toBe("OK"); + expect(out.cycles).toHaveLength(1); + }); + + it("projects nothing once the next period's band has passed (the late rule)", () => { + const cycles = cyclesFrom("2025-11-01", REGULAR); + const last = cycles.at(-1)!.startDate; + const { input } = run(cycles, addDays(last, 5)); + const bandEnd = input.prediction!.nextPeriodStartHigh; + + // On the band's last day the forecast still stands... + expect(run(cycles, bandEnd).out.state).toBe("OK"); + // ...and the day after, it is withdrawn rather than re-anchored. + expect(run(cycles, addDays(bandEnd, 1)).out).toEqual({ + state: "LATE", + cycles: [], + }); + }); + + it("uses the engine's forecast, unchanged, as cycle 1", () => { + const cycles = cyclesFrom("2025-11-01", REGULAR); + const last = cycles.at(-1)!.startDate; + const { input, out } = run(cycles, addDays(last, 5)); + const p = input.prediction!; + expect(out.cycles[0]).toEqual({ + k: 1, + start: p.nextPeriodStart, + periodLength: p.predictedPeriodLength, + bandStart: p.nextPeriodStartLow, + bandEnd: p.nextPeriodStartHigh, + }); + }); + + it("widens the band strictly from one cycle to the next", () => { + const cycles = cyclesFrom("2025-11-01", REGULAR); + const last = cycles.at(-1)!.startDate; + const today = addDays(last, 10); + const { out } = run(cycles, today, { + dayLogs: denseLogs(last, today), + }); + expect(out.cycles.length).toBeGreaterThan(2); + const bands = out.cycles.map((c) => dayDiff(c.bandEnd, c.start)); + for (let i = 1; i < bands.length; i++) { + expect(bands[i]).toBeGreaterThan(bands[i - 1]); + } + }); + + it("stays monotonic for n = 12 with the sparsity penalty at its maximum", () => { + // No day logs at all: logSparsity = 0.5. Without the engine's penalty on + // the later bands, cycle 2 could come out no wider than cycle 1. + const cycles = cyclesFrom("2025-11-01", REGULAR); + const last = cycles.at(-1)!.startDate; + const { out } = run(cycles, addDays(last, 10)); + const bands = out.cycles.map((c) => dayDiff(c.bandEnd, c.start)); + expect(bands.length).toBeGreaterThan(1); + for (let i = 1; i < bands.length; i++) { + expect(bands[i]).toBeGreaterThan(bands[i - 1]); + } + }); + + it("anchors every later start on cycle 1 with no accumulated rounding drift", () => { + // A median of 28.5: rounding each cycle to 29 would drift two days by + // cycle 6; rounding (k − 1) · 28.5 from the anchor does not. + const lengths = [28, 29, 28, 29, 28, 29, 28, 29, 28, 29, 28, 29]; + const cycles = cyclesFrom("2025-11-01", lengths); + const last = cycles.at(-1)!.startDate; + const today = addDays(last, 10); + const { out } = run(cycles, today, { + dayLogs: denseLogs(last, today), + horizon: 6, + }); + const first = out.cycles[0].start; + for (const c of out.cycles.slice(1)) { + expect(c.start).toBe(addDays(first, Math.round((c.k - 1) * 28.5))); + } + expect(out.cycles.length).toBeGreaterThan(1); + }); + + it("stops at ±10 days, however many cycles were asked for", () => { + // Irregular: a robust SD of several days, so the band passes the cap + // within a few cycles. + const lengths = [24, 31, 27, 34, 25, 30, 33, 26, 29, 35, 24, 31]; + const cycles = cyclesFrom("2025-10-01", lengths); + const last = cycles.at(-1)!.startDate; + const { out } = run(cycles, addDays(last, 3), { horizon: 6 }); + expect(out.cycles.length).toBeLessThan(6); + for (const c of out.cycles.slice(1)) { + expect(dayDiff(c.bandEnd, c.start)).toBeLessThanOrEqual( + PROJECTION_BAND_CAP_DAYS, + ); + } + }); + + it("stops at half a cycle for short cycles", () => { + // 21-day cycles: the cap is 10.5, and each later band must sit within it. + const lengths = [20, 22, 21, 23, 19, 21, 22, 20, 21, 23, 20, 22]; + const cycles = cyclesFrom("2026-01-01", lengths); + const last = cycles.at(-1)!.startDate; + const { out } = run(cycles, addDays(last, 3), { horizon: 6 }); + for (const c of out.cycles.slice(1)) { + expect(dayDiff(c.bandEnd, c.start)).toBeLessThanOrEqual(21 / 2); + } + }); + + it("is not clamped at the engine's 14 days for later cycles", () => { + // The engine clamps cycle 1 at ±14. The projection has no such clamp for + // k ≥ 2; it has the cap, which sits below it. A band the cap admits is + // therefore the formula's own value, never a clamp's. + const cycles = cyclesFrom("2025-11-01", REGULAR); + const last = cycles.at(-1)!.startDate; + const { out } = run(cycles, addDays(last, 10), { horizon: 6 }); + for (const c of out.cycles.slice(1)) { + expect(dayDiff(c.bandEnd, c.start)).toBeLessThan(14); + } + }); + + it("honours the requested horizon and clamps it to 1..6", () => { + const cycles = cyclesFrom("2025-11-01", REGULAR); + const last = cycles.at(-1)!.startDate; + const today = addDays(last, 10); + const dayLogs = denseLogs(last, today); + expect(run(cycles, today, { dayLogs, horizon: 2 }).out.cycles).toHaveLength( + 2, + ); + expect(run(cycles, today, { dayLogs, horizon: 0 }).out.cycles).toHaveLength( + 1, + ); + expect( + run(cycles, today, { dayLogs, horizon: 99 }).out.cycles.length, + ).toBeLessThanOrEqual(PROJECTION_MAX_HORIZON); + }); + + it("carries the predicted period length on every row", () => { + const cycles = cyclesFrom("2025-11-01", REGULAR); + const last = cycles.at(-1)!.startDate; + const { input, out } = run(cycles, addDays(last, 10)); + for (const c of out.cycles) { + expect(c.periodLength).toBe(input.prediction!.predictedPeriodLength); + } + }); +}); diff --git a/src/lib/cycle/engine-adapter.ts b/src/lib/cycle/engine-adapter.ts index 7eaa6ee53..39d34fb5e 100644 --- a/src/lib/cycle/engine-adapter.ts +++ b/src/lib/cycle/engine-adapter.ts @@ -336,6 +336,43 @@ export function buildPhaseDayMap( return out; } +/** The rows every forecast reads: the record's cycles, day logs and nights. */ +export interface CycleEngineInputs { + cycles: readonly Pick< + MenstrualCycle, + | "startDate" + | "endDate" + | "periodEndDate" + | "ovulationDate" + | "ovulationConfirmed" + >[]; + dayLogs: readonly DayLogInput[]; + nights: readonly NightlyTempInput[]; +} + +/** + * The forecast, or null when the profile asks for none. + * + * One predicate for every surface that shows a forecast: the calendar, the + * Coach and MCP cycle block, and the calendar feed. Prediction switched off + * and raw-chart mode both answer null, so no caller can forget one of the two + * and paint a forecast the settings refused. + */ +export function gatedPrediction( + profile: CycleProfileInput, + inputs: CycleEngineInputs, + today: string, +): CyclePredictionResult | null { + if (!profile.predictionEnabled || profile.rawChartMode) return null; + return predictCycle( + toCycleInputs(inputs.cycles), + inputs.dayLogs, + profile, + today, + inputs.nights, + ); +} + export interface CalendarBuildResult { prediction: CyclePredictionResult | null; /** @@ -385,16 +422,11 @@ export function buildCalendar( const lutealLength = resolveLuteal(profile); // Forecast (only when prediction is on + not raw-chart mode). - let prediction: CyclePredictionResult | null = null; - if (profile.predictionEnabled && !profile.rawChartMode) { - prediction = predictCycle( - toCycleInputs(cycles), - toDayLogInputs(dayLogs), - toProfileInput(profile), - today, - nights, - ); - } + const prediction = gatedPrediction( + toProfileInput(profile), + { cycles, dayLogs: toDayLogInputs(dayLogs), nights }, + today, + ); // Cold-start gate: until ≥3 cycles are observed the engine's fertile/ // ovulation/phase output rests on a population prior, so the calendar must diff --git a/src/lib/cycle/load-inputs.ts b/src/lib/cycle/load-inputs.ts new file mode 100644 index 000000000..a6dacba2c --- /dev/null +++ b/src/lib/cycle/load-inputs.ts @@ -0,0 +1,223 @@ +/** + * The one loader for the rows a cycle forecast reads. + * + * The calendar read, the Coach and MCP cycle block and the calendar feed all + * run the same engine over the same three inputs — the record's cycles, its + * day logs and its overnight wrist temperatures — and they only agree with + * each other when they read the same rows. The day-log window is the part that + * matters: `estimatePeriodLength` falls back to the logged flow days when a + * cycle carries no `periodEndDate`, so a reader with a wider window can predict + * a different period length than one with a narrower window. Every consumer + * therefore states its window here, and the ones that claim to agree with the + * calendar pass the calendar's default. + * + * Server-only. No consumer recomputes any of this on a client. + */ +import { prisma } from "@/lib/db"; +import { addDays, dayDiff } from "@/lib/cycle/day-math"; +import { BBT_WINDOW, type NightlyTempInput } from "@/lib/cycle/types"; +import { resolveSensitiveFields } from "@/lib/cycle/dto"; +import type { + CalendarDayLogRow, + CycleEngineInputs, +} from "@/lib/cycle/engine-adapter"; +import { moodDateKey } from "@/lib/mood/date-key"; +import { startOfLocalDayKey } from "@/lib/tz/local-day"; +import type { MenstrualCycle } from "@/generated/prisma/client"; + +/** How far back the calendar shows by default, and its day-log window. */ +export const CALENDAR_DEFAULT_PAST_DAYS = 90; + +/** How far back the overnight wrist temperatures are read. */ +const NIGHTS_LOOKBACK_DAYS = 90; + +/** + * The earliest day log a calendar view starting at `from` needs: the view + * itself, or the symptothermal lookback before today, whichever is earlier. + */ +export function dayLogWindowStart(from: string, today: string): string { + const lookback = addDays(today, -BBT_WINDOW); + return dayDiff(from, lookback) <= 0 ? from : lookback; +} + +/** + * The day-log window of the calendar's default view. A consumer that promises + * to agree with what the calendar page shows reads exactly this. + */ +export function defaultCalendarDayLogStart(today: string): string { + return dayLogWindowStart(addDays(today, -CALENDAR_DEFAULT_PAST_DAYS), today); +} + +interface LoadWindow { + userId: string; + /** Today in the record's own timezone. */ + today: string; + /** The record's timezone. */ + tz: string; + /** The earliest day log to read, inclusive. */ + dayLogsFrom: string; +} + +/** Every live cycle of the record, oldest first. */ +function loadCycles(userId: string): Promise { + return prisma.menstrualCycle.findMany({ + where: { userId, deletedAt: null }, + orderBy: { startDate: "asc" }, + }); +} + +/** + * The record's overnight wrist temperatures as nightly values. A measurements + * read on a cycle surface, and it needs no section predicate for one reason: + * no value from it reaches any response. It goes into `predictCycle` and what + * comes out is a shifted date. + */ +async function loadNights( + userId: string, + today: string, + tz: string, +): Promise { + const rows = await prisma.measurement.findMany({ + where: { + userId, + deletedAt: null, + type: "WRIST_TEMPERATURE", + measuredAt: { + gte: startOfLocalDayKey(addDays(today, -NIGHTS_LOOKBACK_DAYS), tz), + }, + }, + orderBy: { measuredAt: "asc" }, + select: { measuredAt: true, value: true }, + }); + return rows.map((m) => ({ + date: moodDateKey(m.measuredAt, tz), + valueC: m.value, + })); +} + +/** The engine's own day-log fields, nothing the forecast does not read. */ +const ENGINE_DAY_LOG_SELECT = { + date: true, + flow: true, + basalBodyTempC: true, + temperatureExcluded: true, + ovulationTest: true, + cervicalMucus: true, + cervixPosition: true, + cervixFirmness: true, + cervixOpening: true, +} as const; + +/** The engine inputs, with the full cycle rows for callers that need them. */ +export interface LoadedEngineInputs extends CycleEngineInputs { + cycles: MenstrualCycle[]; +} + +/** + * The forecast's inputs and nothing else. The intent fields, notes and + * symptoms are not selected at all, so a consumer built on this loader cannot + * reach them even by mistake. + */ +export async function loadCycleEngineInputs( + window: LoadWindow, +): Promise { + const [cycles, dayLogs, nights] = await Promise.all([ + loadCycles(window.userId), + prisma.cycleDayLog.findMany({ + where: { + userId: window.userId, + deletedAt: null, + date: { gte: window.dayLogsFrom }, + }, + orderBy: { date: "asc" }, + select: ENGINE_DAY_LOG_SELECT, + }), + loadNights(window.userId, window.today, window.tz), + ]); + return { cycles, dayLogs, nights }; +} + +/** The calendar grid's inputs: the engine's, plus what each day cell shows. */ +export interface LoadedCalendarInputs { + cycles: MenstrualCycle[]; + dayLogs: CalendarDayLogRow[]; + nights: NightlyTempInput[]; +} + +/** + * The calendar read's inputs. Same cycles, same window rule and same nights + * as {@link loadCycleEngineInputs}; the day logs additionally carry the + * fields the grid paints, the intent fields resolved through the same reader + * the day-log route uses (same `cycle` grant, so the grid shows a delegate + * nothing the day log would not). + */ +export async function loadCalendarInputs( + window: LoadWindow, +): Promise { + const [cycles, dayLogRows, nights] = await Promise.all([ + loadCycles(window.userId), + prisma.cycleDayLog.findMany({ + where: { + userId: window.userId, + deletedAt: null, + date: { gte: window.dayLogsFrom }, + }, + orderBy: { date: "asc" }, + select: { + ...ENGINE_DAY_LOG_SELECT, + intermenstrualBleeding: true, + sexualActivity: true, + protectedSex: true, + pregnancyTest: true, + progesteroneTest: true, + contraceptive: true, + sensitiveEncrypted: true, + // Selected for presence only; the text is never decrypted here. + notesEncrypted: true, + _count: { select: { symptomLinks: true } }, + }, + }), + loadNights(window.userId, window.today, window.tz), + ]); + + const dayLogs: CalendarDayLogRow[] = dayLogRows.map((l) => { + const sensitive = resolveSensitiveFields(l); + return { + date: l.date, + flow: l.flow, + basalBodyTempC: l.basalBodyTempC, + temperatureExcluded: l.temperatureExcluded, + ovulationTest: l.ovulationTest, + cervicalMucus: l.cervicalMucus, + cervixPosition: l.cervixPosition, + cervixFirmness: l.cervixFirmness, + cervixOpening: l.cervixOpening, + intermenstrualBleeding: l.intermenstrualBleeding, + sexualActivity: sensitive.sexualActivity, + pregnancyTest: sensitive.pregnancyTest, + progesteroneTest: sensitive.progesteroneTest, + contraceptive: sensitive.contraceptive, + hasSymptoms: l._count.symptomLinks > 0, + hasNote: l.notesEncrypted != null && l.notesEncrypted !== "", + }; + }); + + return { cycles, dayLogs, nights }; +} + +/** + * The flow of every day log on or after `from`, for the history events of the + * calendar feed: a logged period without a `periodEndDate` is drawn from its + * observed bleeding days. Never fed to the forecast, which keeps its own + * window. + */ +export function loadFlowDays( + userId: string, + from: string, +): Promise> { + return prisma.cycleDayLog.findMany({ + where: { userId, deletedAt: null, date: { gte: from } }, + orderBy: { date: "asc" }, + select: { date: true, flow: true }, + }); +} diff --git a/src/lib/cycle/prediction.ts b/src/lib/cycle/prediction.ts index 4569f3933..4ec127279 100644 --- a/src/lib/cycle/prediction.ts +++ b/src/lib/cycle/prediction.ts @@ -89,8 +89,8 @@ export function mad(values: readonly number[], centre: number): number { /* §1 — cycle-length estimation */ /* ------------------------------------------------------------------ */ -/** Completed cycle = one whose successor's start exists, giving a known length. */ -function completedLengths(cycles: readonly CycleInput[]): number[] { +/** Completed cycle = one whose successor's start exists (shared with projection.ts). */ +export function completedLengths(cycles: readonly CycleInput[]): number[] { // Sort by startDate ascending (the canonical order), then diff consecutive // starts. A cycle is completed iff a later start exists. const starts = cycles.map((c) => c.startDate).sort((a, b) => dayDiff(a, b)); @@ -597,9 +597,9 @@ function varianceFactor(cv: number): number { /** * §3(b)/§3 — logging-density derived factors. Returns the adherence confidence - * factor and the LOG_SPARSITY band-penalty term. + * factor and the LOG_SPARSITY band-penalty term (also widens projection.ts). */ -function adherenceFactors( +export function adherenceFactors( lastStart: string, estimatedLength: number, dayLogs: readonly DayLogInput[], diff --git a/src/lib/cycle/projection.ts b/src/lib/cycle/projection.ts new file mode 100644 index 000000000..f07bf7f57 --- /dev/null +++ b/src/lib/cycle/projection.ts @@ -0,0 +1,165 @@ +/** + * The forecast beyond the next period: a few cycles ahead, each with a band + * that widens the further out it reaches (#1350). + * + * Pure and DB-free. It reads the engine's own result for the next cycle and + * the same length statistics the engine computed it from, and adds nothing the + * engine does not already know. Cycle 1 is the engine's forecast unchanged — + * a confirmed ovulation, an LH anchor or a cold-start band bonus all still + * apply to it — so every surface that shows "the next period" agrees with the + * projection's first row by construction. TypeScript only: the Swift engine + * mirrors algorithm.md §1–§5, and nothing here changes that output. + * + * The band of cycle k ≥ 2 is the robust SD of a sum of k cycle lengths whose + * mean is itself estimated from n observed ones, `σ·√(k + k²/n)`, widened by + * the same logging-sparsity penalty the engine applies to cycle 1. It never + * narrows from one cycle to the next. The horizon stops itself where the band + * passes ±10 days or half a cycle: beyond that a date is a guess, and the + * honest answer is not to print one. + */ +import { + adherenceFactors, + completedLengths, + estimateCycleLength, +} from "./prediction"; +import { addDays, dayDiff } from "./day-math"; +import type { + CycleInput, + CyclePredictionResult, + CycleTrackingGoal, + DayLogInput, +} from "./types"; + +/** The most cycles a projection ever reaches. */ +export const PROJECTION_MAX_HORIZON = 6; + +/** The widest band (± days) a projected cycle may carry and still be shown. */ +export const PROJECTION_BAND_CAP_DAYS = 10; + +/** + * Why the projection holds what it holds. + * + * `OK` the projected cycles, self-capped. + * `LEARNING` fewer than three observed cycles: the next period only, or + * nothing when no cycle is logged at all. + * `LATE` the next period's band has passed with no new period logged. + * No forecast until one is: a past-dated or re-anchored guess + * would be a claim the data does not support. + * `OFF` prediction is switched off, or raw-chart mode is on. + */ +export type ProjectionState = "OK" | "LEARNING" | "LATE" | "OFF"; + +export interface ProjectedCycle { + /** 1 = the next period; the engine's own forecast. */ + k: number; + /** The most likely first day, `YYYY-MM-DD`. */ + start: string; + /** Predicted bleeding days. */ + periodLength: number; + /** The earliest likely first day (start − band). */ + bandStart: string; + /** The latest likely first day (start + band). */ + bandEnd: string; +} + +export interface CycleProjection { + state: ProjectionState; + cycles: ProjectedCycle[]; +} + +export interface ProjectionInput { + /** The gated engine result (`gatedPrediction`), or null when gated off. */ + prediction: CyclePredictionResult | null; + /** The record's cycles, any order. */ + cycles: readonly CycleInput[]; + /** The day logs the prediction was computed from. */ + dayLogs: readonly DayLogInput[]; + goal: CycleTrackingGoal; + /** Today in the record's timezone. */ + today: string; + /** How many cycles the caller wants, at most. Clamped to 1..6. */ + horizon: number; +} + +function cycleOne(p: CyclePredictionResult): ProjectedCycle { + return { + k: 1, + start: p.nextPeriodStart, + periodLength: p.predictedPeriodLength, + bandStart: p.nextPeriodStartLow, + bandEnd: p.nextPeriodStartHigh, + }; +} + +/** + * Project the next `horizon` cycles. See the module comment for the rules; + * the order of the early returns is the order the states are decided in. + */ +export function projectCycles(input: ProjectionInput): CycleProjection { + const { prediction, today } = input; + if (prediction === null) return { state: "OFF", cycles: [] }; + + // No logged cycle at all: the engine would anchor a population prior on + // today. That is not the record's forecast, so nothing is projected. + if (input.cycles.length === 0) return { state: "LEARNING", cycles: [] }; + + // The late rule. The band of the next period ended before today and no new + // period was logged (a logged one would have moved the anchor). Deliberately + // stricter than the verdict's OVERDUE, which waits for typical + 14 days: + // a feed that kept printing a period in the past would be wrong every day. + if (dayDiff(today, prediction.nextPeriodStartHigh) > 0) { + return { state: "LATE", cycles: [] }; + } + + const first = cycleOne(prediction); + if (prediction.stillLearning) return { state: "LEARNING", cycles: [first] }; + // Perimenopause makes cycle length the very thing that changes; a chain of + // further dates would extrapolate exactly the regularity that has gone. + if (input.goal === "PERIMENOPAUSE") return { state: "OK", cycles: [first] }; + + const horizon = Math.max( + 1, + Math.min(PROJECTION_MAX_HORIZON, Math.trunc(input.horizon)), + ); + const est = estimateCycleLength(completedLengths(input.cycles), input.goal); + const n = est.cyclesObserved; + if (!Number.isFinite(est.lengthFractional) || n < 1) { + return { state: "OK", cycles: [first] }; + } + + const lastStart = + [...input.cycles] + .map((c) => c.startDate) + .sort((a, b) => dayDiff(a, b)) + .at(-1) ?? today; + const { logSparsity } = adherenceFactors( + lastStart, + est.lengthFractional, + input.dayLogs, + today, + ); + const cap = Math.min(PROJECTION_BAND_CAP_DAYS, est.lengthFractional / 2); + + const out: ProjectedCycle[] = [first]; + let previousBand = dayDiff(first.bandEnd, first.start); + for (let k = 2; k <= horizon; k++) { + const spread = est.sigma * (1 + logSparsity) * Math.sqrt(k + (k * k) / n); + const band = Math.max(previousBand + 1, Math.round(spread)); + if (band > cap) break; + // Rounded per cycle from the fractional length, anchored on cycle 1's + // start, so six cycles out carry no accumulated rounding drift. + const start = addDays( + first.start, + Math.round((k - 1) * est.lengthFractional), + ); + out.push({ + k, + start, + periodLength: prediction.predictedPeriodLength, + bandStart: addDays(start, -band), + bandEnd: addDays(start, band), + }); + previousBand = band; + } + return { state: "OK", cycles: out }; +} diff --git a/src/lib/insights/__tests__/illness-cycle-briefing.test.ts b/src/lib/insights/__tests__/illness-cycle-briefing.test.ts index 90cb0396f..15e7a31b7 100644 --- a/src/lib/insights/__tests__/illness-cycle-briefing.test.ts +++ b/src/lib/insights/__tests__/illness-cycle-briefing.test.ts @@ -120,6 +120,7 @@ describe("buildBriefingIllnessCyclePrompt", () => { goal: "GENERAL", cyclesObserved: 2, nextEvent: null, + upcoming: [], phaseInsight: null, }, }, @@ -151,6 +152,7 @@ describe("buildBriefingIllnessCyclePrompt", () => { goal: "GENERAL", cyclesObserved: 4, nextEvent: null, + upcoming: [], phaseInsight: null, }, },