Files
HealthLog/src/lib/cycle/engine-adapter.ts
T
Marc-André Bombeck 609d248252 feat(cycle): choose cervix as the symptothermal secondary symptom
Let a user pick the Sensiplan double-check's second indicator — cervical
mucus (default) or cervix observation — mirroring the published NFP
standard where the temperature primary sign pairs with one chosen
secondary sign.

- `CycleProfile.secondarySymptom` (MUCUS default | CERVIX) drives which
  second sign the engine confirms against. Casual users keep temperature
  + mucus untouched; the choice lives behind the advanced cycle settings.
- `CycleDayLog` gains the three standard cervix signs — position
  (LOW/HIGH), firmness (FIRM/SOFT), opening/os (CLOSED/OPEN) — as nullable
  columns (migration 0157), wired through Zod, the day-log upsert, the
  DTO, the sync feed, backup/restore and OpenAPI. Additive and
  back-compatible: existing profiles backfill MUCUS, existing day-logs
  keep NULL cervix signs, iOS inherits the fields.
- The engine's `confirmSymptothermal` takes the secondary-symptom choice;
  on CERVIX it confirms against `detectCervixPeak` (last fertile-cervix
  day followed by three closed days — the cervix-closure rule) instead of
  the mucus peak. The double-check still requires BOTH the temperature
  shift AND the secondary peak: a single cervix sign never confirms, and
  the still-learning gate and conservative posture are unchanged.

Engine derived clean-room from the Arbeitsgruppe-NFP / Raith-Paula &
Frank-Herrmann standard.
2026-06-14 15:23:26 +02:00

371 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Prisma-row → engine-input adapters + calendar composition
* (ios-contract §2.D). The engine itself (`predictCycle`, `phaseSeries`)
* is pure + DB-free; this module bridges the persisted rows to the plain
* input shapes and assembles the `CalendarDayDTO` grid the read returns.
*/
import {
predictCycle,
phaseForDay,
addDays,
dayDiff,
isWithin,
type CycleInput,
type DayLogInput,
type NightlyTempInput,
type CycleProfileInput,
type CyclePredictionResult,
type CyclePhase,
type PhaseCycle,
resolveLuteal,
} from "@/lib/cycle";
import type {
CycleProfile,
CycleDayLog,
MenstrualCycle,
} from "@/generated/prisma/client";
export interface CalendarDayDTO {
date: string;
phase: CyclePhase | null;
isPredictedPeriod: boolean;
isFertileWindow: boolean;
isPredictedOvulation: boolean;
isPeriodLogged: boolean;
flow: string | null;
hasSymptoms: boolean;
confidence: number;
/** Logged basal body temperature (°C), or null. Feeds the web BBT chart. */
basalBodyTempC: number | null;
/** Whether the day's BBT is marked disturbed (excluded from the engine). */
temperatureExcluded: boolean;
/** Logged ovulation-test result, or null. */
ovulationTest: string | null;
/** Logged cervical-mucus quality, or null. */
cervicalMucus: string | null;
}
/** Rows the calendar needs from each day-log. */
export type CalendarDayLogRow = Pick<
CycleDayLog,
| "date"
| "flow"
| "basalBodyTempC"
| "temperatureExcluded"
| "ovulationTest"
| "cervicalMucus"
| "cervixPosition"
| "cervixFirmness"
| "cervixOpening"
> & { hasSymptoms: boolean };
/** Map MenstrualCycle rows (oldest→newest) to engine `CycleInput`. */
export function toCycleInputs(
cycles: readonly Pick<
MenstrualCycle,
| "startDate"
| "endDate"
| "periodEndDate"
| "ovulationDate"
| "ovulationConfirmed"
>[],
): CycleInput[] {
return cycles.map((c) => ({
startDate: c.startDate,
endDate: c.endDate,
periodEndDate: c.periodEndDate,
ovulationDate: c.ovulationDate,
ovulationConfirmed: c.ovulationConfirmed,
}));
}
/** Map CycleDayLog rows to engine `DayLogInput`. */
export function toDayLogInputs(
logs: readonly Pick<
CycleDayLog,
| "date"
| "flow"
| "basalBodyTempC"
| "temperatureExcluded"
| "ovulationTest"
| "cervicalMucus"
| "cervixPosition"
| "cervixFirmness"
| "cervixOpening"
>[],
): DayLogInput[] {
return logs.map((l) => ({
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,
}));
}
/** Map a CycleProfile row to the engine's `CycleProfileInput`. */
export function toProfileInput(profile: CycleProfile): CycleProfileInput {
return {
goal: profile.goal,
typicalCycleLength: profile.typicalCycleLength,
typicalPeriodLength: profile.typicalPeriodLength,
lutealPhaseLength: profile.lutealPhaseLength,
predictionEnabled: profile.predictionEnabled,
rawChartMode: profile.rawChartMode,
secondarySymptom: profile.secondarySymptom,
};
}
/** Period-length of an observed cycle: periodEnd − start + 1, or default. */
function periodLengthOf(
cycle: Pick<MenstrualCycle, "startDate" | "periodEndDate">,
): number | null {
if (!cycle.periodEndDate) return null;
return dayDiff(cycle.periodEndDate, cycle.startDate) + 1;
}
/**
* Build a contiguous `PhaseCycle[]` from the observed cycles (each cycle's
* span ends where the next begins; the latest open cycle runs to the
* predicted next-period start). Used to label every calendar day's phase.
*/
export function buildPhaseCycles(
cycles: readonly Pick<
MenstrualCycle,
"startDate" | "endDate" | "periodEndDate" | "ovulationDate"
>[],
predictedNextStart: string | null,
lutealLength: number,
): PhaseCycle[] {
const sorted = [...cycles].sort((a, b) => dayDiff(a.startDate, b.startDate));
const out: PhaseCycle[] = [];
for (let i = 0; i < sorted.length; i++) {
const c = sorted[i];
const next = sorted[i + 1];
const nextStart =
next?.startDate ?? predictedNextStart ?? addDays(c.startDate, 28);
out.push({
startDate: c.startDate,
nextStart,
ovulationDate: c.ovulationDate,
periodLength: periodLengthOf(c),
lutealLength,
});
}
return out;
}
/** Phase of a single date across the contiguous cycle list (or null). */
export function phaseAcross(
date: string,
phaseCycles: readonly PhaseCycle[],
): CyclePhase | null {
for (const pc of phaseCycles) {
const r = phaseForDay(date, pc);
if (r.phase !== null) return r.phase;
}
return null;
}
/**
* Resolve the phase + 1-based day-of-cycle of a single `YYYY-MM-DD` date
* across the observed cycles, with the latest open cycle running to
* `predictedNextStart` (or +28d). Returns `{ phase: null, dayOfCycle: null }`
* when the date falls outside every cycle window. Used by the Coach snapshot
* to state "you are on day N, luteal phase".
*/
export function phaseForDate(
date: string,
cycles: readonly Pick<
MenstrualCycle,
"startDate" | "endDate" | "periodEndDate" | "ovulationDate"
>[],
predictedNextStart: string | null,
lutealLength: number,
): { phase: CyclePhase | null; dayOfCycle: number | null } {
const phaseCycles = buildPhaseCycles(
cycles,
predictedNextStart,
lutealLength,
);
for (const pc of phaseCycles) {
const r = phaseForDay(date, pc);
if (r.phase !== null) return r;
}
return { phase: null, dayOfCycle: null };
}
/**
* Build a `YYYY-MM-DD → CyclePhase` map across `[from, to]` inclusive from the
* observed cycles. Days that fall outside every cycle window are omitted (no
* key) rather than carrying a null — a consumer joins on presence. The latest
* open cycle runs to `predictedNextStart` (or +28d) like `buildCalendar`, so
* the trailing window is labelled even before the next period is logged.
*
* This is the per-day phase series the CYCLE_PHASE correlation channel and the
* Coach phase block both consume, derived identically to the calendar grid so
* the phase a day shows in the UI matches the phase the stats engine uses.
*/
export function buildPhaseDayMap(
cycles: readonly Pick<
MenstrualCycle,
"startDate" | "endDate" | "periodEndDate" | "ovulationDate"
>[],
predictedNextStart: string | null,
lutealLength: number,
from: string,
to: string,
): Map<string, CyclePhase> {
const phaseCycles = buildPhaseCycles(
cycles,
predictedNextStart,
lutealLength,
);
const out = new Map<string, CyclePhase>();
const span = dayDiff(to, from);
for (let i = 0; i <= span; i++) {
const date = addDays(from, i);
const phase = phaseAcross(date, phaseCycles);
if (phase !== null) out.set(date, phase);
}
return out;
}
export interface CalendarBuildResult {
prediction: CyclePredictionResult | null;
/**
* True while the engine is "still learning" the user's cycle (< 3 observed
* cycles, mirrors `prediction.stillLearning`). When set, the calendar grid
* does NOT assert a fertile window, an ovulation dot, or a population-framed
* phase band — those are population guesses the app has not yet earned the
* confidence to show as fact. The client renders a calm "learning your cycle"
* state instead. False when no prediction ran (raw-chart mode / disabled).
*/
stillLearning: boolean;
days: CalendarDayDTO[];
}
/**
* Compose the calendar grid for `[from, to]` inclusive. Runs the engine
* once for the forward forecast, then labels each day's phase, predicted
* period bar, fertile window, and logged-flow overlay.
*
* Fertile-window fields are caller-gated: pass `goalAllowsFertile=false`
* (GENERAL_HEALTH / PERIMENOPAUSE) to suppress `isFertileWindow` +
* `isPredictedOvulation` at the grid level (the prediction's own window
* fields are nulled upstream by the engine for those goals).
*
* Cold-start honesty (C1): while the engine reports `stillLearning` (< 3
* observed cycles), the grid suppresses the fertile window, the predicted-
* ovulation dot, AND the phase band — all of which would otherwise be painted
* from a population 28/14 prior at ~0.20 confidence. The predicted-period bar
* is kept (the predictions panel shows it too while learning). This matches
* the `stillLearning` gate the predictions panel already applies, so the
* calendar grid never asserts "these are your fertile days" off a single
* logged cycle.
*/
export function buildCalendar(
profile: CycleProfile,
cycles: readonly MenstrualCycle[],
dayLogs: readonly CalendarDayLogRow[],
nights: readonly NightlyTempInput[],
from: string,
to: string,
today: string,
goalAllowsFertile: boolean,
): CalendarBuildResult {
// Clamp identically to the engine's resolveLuteal so the predicted-ovulation
// dot (engine-clamped) and the OVULATORY phase band (this value) never
// diverge for an out-of-clamp stored luteal length (QA HIGH).
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,
);
}
// Cold-start gate: until ≥3 cycles are observed the engine's fertile/
// ovulation/phase output rests on a population prior, so the calendar must
// present it as a calm "learning" state rather than asserting it as fact.
const stillLearning = prediction?.stillLearning ?? false;
const phaseCycles = buildPhaseCycles(
cycles,
prediction?.nextPeriodStart ?? null,
lutealLength,
);
// Forward predicted period bar: [nextPeriodStart, +predictedPeriodLength).
const predictedPeriodStart = prediction?.nextPeriodStart ?? null;
const predictedPeriodEnd =
prediction && predictedPeriodStart
? addDays(
predictedPeriodStart,
Math.max(0, prediction.predictedPeriodLength - 1),
)
: null;
const logByDate = new Map<string, CalendarDayLogRow>();
for (const l of dayLogs) logByDate.set(l.date, l);
const days: CalendarDayDTO[] = [];
const span = dayDiff(to, from);
for (let i = 0; i <= span; i++) {
const date = addDays(from, i);
const log = logByDate.get(date);
const isPredictedPeriod =
predictedPeriodStart !== null &&
predictedPeriodEnd !== null &&
isWithin(date, predictedPeriodStart, predictedPeriodEnd);
const isFertileWindow =
!stillLearning &&
goalAllowsFertile &&
prediction?.fertileWindowStart != null &&
prediction.fertileWindowEnd != null &&
isWithin(
date,
prediction.fertileWindowStart,
prediction.fertileWindowEnd,
);
const isPredictedOvulation =
!stillLearning &&
goalAllowsFertile &&
prediction?.predictedOvulation != null &&
prediction.predictedOvulation === date;
days.push({
date,
// No asserted phase band while learning — a population-28 frame off a
// single cycle is not yet earned (Lower: single-cycle phase band).
phase: stillLearning ? null : phaseAcross(date, phaseCycles),
isPredictedPeriod,
isFertileWindow,
isPredictedOvulation,
isPeriodLogged: log?.flow != null && log.flow !== "NONE",
flow: log?.flow ?? null,
hasSymptoms: log?.hasSymptoms ?? false,
confidence: prediction?.confidence ?? 0,
basalBodyTempC: log?.basalBodyTempC ?? null,
temperatureExcluded: log?.temperatureExcluded ?? false,
ovulationTest: log?.ovulationTest ?? null,
cervicalMucus: log?.cervicalMucus ?? null,
});
}
return { prediction, stillLearning, days };
}