mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
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:
1 parent
ac13dd28ed
commit
15bece9849
13 files changed
+354
-36
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in new issue
Block a user