Files
HealthLog/src/lib/cycle/export-data.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

144 lines
5.3 KiB
TypeScript

/**
* 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<CycleExportSummary | null> {
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,
};
}