feat(cycle): project the cycles after the next one

The calendar read, the Coach and MCP cycle block and the coming calendar
feed now share one loader for the forecast's inputs (cycles, day logs,
overnight wrist temperatures) and one gate (`gatedPrediction`: no
forecast while prediction is off or raw-chart mode is on). The Coach
block reads the calendar's default day-log window, so its next period
is the date the calendar page shows, and resolves the luteal length
through the engine's own clamp.

`projectCycles` extends the engine's next-period forecast a few cycles
ahead. Cycle 1 is the engine's result unchanged; later starts are
anchored on it from the fractional median length, so six cycles out
carry no rounding drift; later bands widen as the SD of a sum of k
lengths with an estimated mean, with the engine's sparsity penalty, and
never narrow. The list stops where a band passes ten days or half a
cycle, holds the next period only while the engine is still learning or
for perimenopause, and is empty once the next period's band has passed
with nothing logged.

`/api/cycle/calendar` publishes it additively as `projection` and
`projectionState`; the Coach block carries it as `upcoming`.
This commit is contained in:
Marc-André Bombeck committed 2026-11-06 18:03:27 +01:00
1 parent 2480a0138d
commit ac31ac40b5
11 files changed
+913 -191

No files matched your search

@@ -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<Record<string, unknown>>;
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.
+38 -103
View File
@@ -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 },
});
@@ -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", () => {
+74 -72
View File
@@ -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,
};
}
+4 -2
View File
@@ -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
+262
View File
@@ -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);
}
});
});
+42 -10
View File
@@ -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
+223
View File
@@ -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<MenstrualCycle[]> {
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<NightlyTempInput[]> {
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<LoadedEngineInputs> {
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<LoadedCalendarInputs> {
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<Array<{ date: string; flow: CalendarDayLogRow["flow"] }>> {
return prisma.cycleDayLog.findMany({
where: { userId, deletedAt: null, date: { gte: from } },
orderBy: { date: "asc" },
select: { date: true, flow: true },
});
}
+4 -4
View File
@@ -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[],
+165
View File
@@ -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 };
}
@@ -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,
},
},