/** * Cycle export summary — the shared read both the FHIR Observation emitter * and the doctor-PDF cycle section consume, so the two surfaces describe * identical numbers (the source-of-truth property the export family holds). * * Reads cycle data scoped to `userId` + `deletedAt: null`. Computes the * concise clinical summary: last menstrual period (LMP), recent observed * cycles, average length + variability, average period length, and the * current phase. Pure stats; no prediction (predictions are forecasts, not * a clinical record of what happened). * * `notesEncrypted` is NEVER touched here — the export summary is * statistics only, so no plaintext free-text ever reaches a report surface * through this path. */ import { prisma } from "@/lib/db"; import { median, mad } from "@/lib/cycle/prediction"; import { phaseForDay } from "@/lib/cycle/phase"; import { dayDiff, addDays } from "@/lib/cycle/day-math"; import { LUTEAL_DEFAULT, type CyclePhase } from "@/lib/cycle/types"; /** One observed cycle in the recent-cycles table. */ export interface CycleExportCycle { /** YYYY-MM-DD period start. */ startDate: string; /** Observed cycle length in days (start→next start), null for the open cycle. */ lengthDays: number | null; /** Observed period (bleeding) length in days, null when unknown. */ periodLengthDays: number | null; } /** The concise cycle summary surfaced in FHIR + the doctor PDF. */ export interface CycleExportSummary { /** YYYY-MM-DD of the most recent period start (LMP). */ lastPeriodStart: string | null; /** Observed cycles within the window, newest first (capped). */ recentCycles: CycleExportCycle[]; /** Count of observed cycles used for the stats. */ observedCycleCount: number; /** Median observed cycle length (days). Null with < 1 completed cycle. */ averageCycleLengthDays: number | null; /** * Cycle-length variability (days) — the median absolute deviation of the * observed lengths, a robust spread measure. Null with < 2 cycles. */ cycleLengthVariabilityDays: number | null; /** Median observed period length (days). Null when no period length known. */ averagePeriodLengthDays: number | null; /** The phase the report date falls in, or null when it can't be resolved. */ currentPhase: CyclePhase | null; } /** Max observed cycles surfaced in the recent-cycles table. */ const RECENT_CYCLES_CAP = 12; /** * Build the cycle export summary for a user. `asOf` anchors the * current-phase calculation (defaults to today). Returns null when the * user has no observed cycles at all (the caller then omits the section). */ export async function buildCycleExportSummary( userId: string, asOf: string, lutealLength: number = LUTEAL_DEFAULT, ): Promise { const cycles = await prisma.menstrualCycle.findMany({ where: { userId, deletedAt: null, isPredicted: false }, orderBy: { startDate: "asc" }, select: { startDate: true, endDate: true, periodEndDate: true, lengthDays: true, ovulationDate: true, }, }); if (cycles.length === 0) return null; // Observed length: prefer the stored lengthDays, else derive from the // next cycle's start (the canonical start→next-start definition). const observed: CycleExportCycle[] = cycles.map((c, i) => { const next = cycles[i + 1]; const lengthDays = c.lengthDays ?? (next ? dayDiff(next.startDate, c.startDate) : null); const periodLengthDays = c.periodEndDate !== null ? dayDiff(c.periodEndDate, c.startDate) + 1 : null; return { startDate: c.startDate, lengthDays, periodLengthDays }; }); const lengths = observed .map((c) => c.lengthDays) .filter((n): n is number => n !== null && n > 0); const periodLengths = observed .map((c) => c.periodLengthDays) .filter((n): n is number => n !== null && n > 0); const averageCycleLengthDays = lengths.length >= 1 ? Math.round(median(lengths) * 10) / 10 : null; const cycleLengthVariabilityDays = lengths.length >= 2 ? Math.round(mad(lengths, median(lengths)) * 10) / 10 : null; const averagePeriodLengthDays = periodLengths.length >= 1 ? Math.round(median(periodLengths) * 10) / 10 : null; const lastPeriodStart = cycles[cycles.length - 1]?.startDate ?? null; // Current phase: resolve against the cycle whose window contains `asOf`. let currentPhase: CyclePhase | null = null; const last = cycles[cycles.length - 1]; if (last) { // The open cycle's nextStart is unknown; estimate it from the median // length so the phase math has a window. const estLength = averageCycleLengthDays ?? 28; const nextStart = last.endDate ? addDays(last.endDate, 1) : addDays(last.startDate, Math.round(estLength)); const periodLen = observed[observed.length - 1]?.periodLengthDays ?? null; const { phase } = phaseForDay(asOf, { startDate: last.startDate, nextStart, ovulationDate: last.ovulationDate, periodLength: periodLen, lutealLength, }); currentPhase = phase; } return { lastPeriodStart, recentCycles: observed.slice(-RECENT_CYCLES_CAP).reverse(), observedCycleCount: observed.length, averageCycleLengthDays, cycleLengthVariabilityDays, averagePeriodLengthDays, currentPhase, }; }