Files
HealthLog/src/lib/cycle/engine-adapter.ts
T
Marc-André BombeckandClaude Opus 4.8 c21131e26e feat(cycle): second-audit feature, QoL, and wiring close
Close the second-round audit UI cluster:

- Cycle reminders: add the missing notification-event labels so the
  period-soon / period-confirm toggles read correctly on the
  notifications page, and point to them from cycle settings.
- Per-cycle history: render a recent-cycles list (range, length, period
  length, confirmed-ovulation chip) — the cycles were fetched but only
  the aggregate stats showed.
- Confirmed ovulation: carry it on the prediction DTO and surface it as a
  solid BBT-chart marker + a predictions row + a history chip.
- Log-day sheet: deselecting an enum now clears it (edits route through
  the nullable PATCH); period start/end work on any selected date for
  retroactive correction; "end period" shows only while one is open.
  Extract the payload builders as pure, unit-tested functions.
- Cycle-tracking enable toggle in account settings so any account can opt
  in before the gated page is reachable; wire the cycle data-purge button
  to the hard-delete route and document the route in the OpenAPI spec.
- Calendar legend: add the symptom dot, render fertile as a ring to match
  the grid; tighten the luteal prior bound to the engine clamp; mute the
  BBT ovulation-label colour for contrast.
- Share the cycle-start backward-walk between the wheel and the chart.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:32:25 +02:00

320 lines
9.6 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;
/** 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" | "ovulationTest" | "cervicalMucus"
> & { 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" | "ovulationTest" | "cervicalMucus"
>[],
): DayLogInput[] {
return logs.map((l) => ({
date: l.date,
flow: l.flow,
basalBodyTempC: l.basalBodyTempC,
ovulationTest: l.ovulationTest,
cervicalMucus: l.cervicalMucus,
}));
}
/** 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,
};
}
/** 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;
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).
*/
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,
);
}
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 =
goalAllowsFertile &&
prediction?.fertileWindowStart != null &&
prediction.fertileWindowEnd != null &&
isWithin(
date,
prediction.fertileWindowStart,
prediction.fertileWindowEnd,
);
const isPredictedOvulation =
goalAllowsFertile &&
prediction?.predictedOvulation != null &&
prediction.predictedOvulation === date;
days.push({
date,
phase: 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,
ovulationTest: log?.ovulationTest ?? null,
cervicalMucus: log?.cervicalMucus ?? null,
});
}
return { prediction, days };
}