fix(cycle): scope symptothermal to the current cycle, unify luteal clamp, widen fertile + crosstab coverage

The symptothermal layer ran against the full day-log history: detectTempShift
returned the earliest 3-over-6 rise and detectMucusPeak the last egg-white day
across all cycles, so a multi-cycle user with historical BBT confirmed a
months-old ovulation and the predicted next-period start landed in the past.
Scope the temperature/mucus detectors to the current cycle window
[lastConfirmedStart − BBT_WINDOW, today], take the latest qualifying temp shift,
and reject any confirmation whose next-start is already in the past.

Resolve luteal length through a single clamp. The predictor clamped to [10,16]
while the calendar adapter and phase mapper used the raw stored value, so the
ovulation dot and the OVULATORY band could land on different days for an
out-of-clamp profile. Export resolveLuteal/clampLuteal, route buildCalendar,
buildPhaseCycles and phaseForDay through it, and tighten the profile-prefs Zod
bound to the same [10,16] range.

Show the fertile window for AVOID_PREGNANCY as well as TRYING_TO_CONCEIVE per the
algorithm spec; it stays hidden for GENERAL_HEALTH/PERIMENOPAUSE.

Bound the day-log reads on the calendar and insights routes to the rendered span
plus the symptothermal lookback instead of the full history; cycle-length stats
already run off the MenstrualCycle rows.

Add mood and blood glucose as phase-crosstab outcome metrics under the same FDR
and per-group day floors. Mood is read from MoodEntry and injected as a synthetic
channel since it is not a measurement row.

Add multi-cycle, luteal-clamp and fertile-window fixtures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-06 12:06:48 +02:00
1 parent ac13dd28ed
commit 15bece9849
13 files changed
+354 -36

No files matched your search

+16 -2
View File
@@ -28,7 +28,8 @@ import {
goalAllowsFertileWindow,
} from "@/lib/cycle/dto";
import { persistPredictionCache } from "@/lib/cycle/prediction-cache";
import { addDays } from "@/lib/cycle/day-math";
import { addDays, dayDiff } from "@/lib/cycle/day-math";
import { BBT_WINDOW } from "@/lib/cycle/types";
import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
@@ -75,8 +76,21 @@ export const GET = apiHandler(async (request: NextRequest) => {
where: { userId: user.id, deletedAt: null },
orderBy: { startDate: "asc" },
}),
// Bound the day-log read to the rendered span plus the symptothermal
// lookback — the earlier of `from` and (today − BBT_WINDOW). Cycle-length
// stats run off MenstrualCycle rows, so day-logs can be windowed (QA: perf
// — unbounded full-history read on a hot, per-navigation route).
prisma.cycleDayLog.findMany({
where: { userId: user.id, deletedAt: null },
where: {
userId: user.id,
deletedAt: null,
date: {
gte:
dayDiff(from, addDays(today, -BBT_WINDOW)) <= 0
? from
: addDays(today, -BBT_WINDOW),
},
},
orderBy: { date: "asc" },
select: {
date: true,
+38 -3
View File
@@ -35,6 +35,7 @@ import {
discoverPhaseCorrelations,
selectHeadlinePhaseRow,
PHASE_CROSSTAB_METRIC_TYPES,
MOOD_CHANNEL_KEY,
} from "@/lib/cycle/phase-crosstab";
import type { CrossMetricMeasurement } from "@/lib/insights/mood-aggregates";
import { addDays } from "@/lib/cycle/day-math";
@@ -56,14 +57,24 @@ export const GET = apiHandler(async () => {
const today = moodDateKey(new Date(), tz);
const from = addDays(today, -WINDOW_DAYS);
const [cycles, dayLogRows, nightlyTempRows, measurementRows, prefsRow] =
await Promise.all([
const [
cycles,
dayLogRows,
nightlyTempRows,
measurementRows,
prefsRow,
moodRows,
] = await Promise.all([
prisma.menstrualCycle.findMany({
where: { userId: user.id, deletedAt: null },
orderBy: { startDate: "asc" },
}),
// Bound the day-log read to the rendered window (`from` = today − 365d),
// which is already earlier than the symptothermal lookback. Cycle-length
// stats run off MenstrualCycle rows so the day-logs can be windowed (QA:
// perf — unbounded full-history read).
prisma.cycleDayLog.findMany({
where: { userId: user.id, deletedAt: null },
where: { userId: user.id, deletedAt: null, date: { gte: from } },
orderBy: { date: "asc" },
select: {
date: true,
@@ -112,6 +123,18 @@ export const GET = apiHandler(async () => {
where: { id: user.id },
select: { sourcePriorityJson: true },
}),
// MOOD outcome (QA HIGH): mood lives in MoodEntry (1–5 score), not a
// Measurement row, so read it here and inject it into the crosstab as a
// synthetic MOOD_CHANNEL_KEY measurement (same FDR / day-floor guards).
prisma.moodEntry.findMany({
where: {
userId: user.id,
deletedAt: null,
moodLoggedAt: { gte: new Date(Date.parse(`${from}T00:00:00Z`)) },
},
orderBy: { moodLoggedAt: "asc" },
select: { score: true, moodLoggedAt: true },
}),
]);
// The latest open cycle runs to the predicted next-period start so the
@@ -146,6 +169,18 @@ export const GET = apiHandler(async () => {
source: m.source,
deviceType: m.deviceType,
}));
// Inject mood as a synthetic MOOD_CHANNEL_KEY series (no source ladder — the
// metricDayMap pass-through keeps every row, averaging multiple same-day
// entries like every other channel).
for (const m of moodRows) {
measurements.push({
type: MOOD_CHANNEL_KEY,
value: m.score,
measuredAt: m.moodLoggedAt,
source: null,
deviceType: null,
});
}
const userPriorityJson = prefsRow?.sourcePriorityJson ?? null;