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

113 lines
4.1 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.
/**
* Phase assignment — pure, per calendar date, both engines identical.
*
* Implements algorithm.md §"Phase channel". Given a cycle [start, nextStart)
* with length L and an ovulation day (confirmed or estimated as
* start + (L − lutealLength)):
*
* - MENSTRUAL = [start, start + P̂ − 1]
* - OVULATORY = [ovulationDay − 1, ovulationDay + 1] (3-day window)
* - FOLLICULAR = (end of menstrual, ovulatory start) day after period → ovulatory
* - LUTEAL = (ovulatory end, nextStart) after ovulatory → day before next start
*
* Precedence on overlap: MENSTRUAL > OVULATORY > FOLLICULAR/LUTEAL (a day is
* menstrual if bleeding even when the calendar window says otherwise).
*
* This MUST match the calendar route, the CYCLE_PHASE correlation channel, and
* what iOS computes offline.
*/
import { addDays, dayDiff } from "./day-math";
import { clampLuteal } from "./prediction";
import {
LUTEAL_DEFAULT,
POPULATION_DEFAULT_PERIOD,
type CyclePhase,
} from "./types";
/** A resolved cycle window the phase mapper needs. */
export interface PhaseCycle {
/** First bleeding day, `YYYY-MM-DD`. */
startDate: string;
/** First day of the NEXT cycle, `YYYY-MM-DD`. The cycle is [start, nextStart). */
nextStart: string;
/** Ovulation day (confirmed or estimated). Null → estimate from length/luteal. */
ovulationDate: string | null;
/** Period (bleeding) length in days. Null → POPULATION_DEFAULT_PERIOD. */
periodLength: number | null;
/** Luteal length used to estimate ovulation when ovulationDate is null. */
lutealLength?: number;
}
/**
* Map a single date to its phase within the supplied cycle, plus the 1-based
* day-of-cycle. Returns `{ phase: null, dayOfCycle: null }` when the date is
* outside `[startDate, nextStart)`.
*/
export function phaseForDay(
date: string,
cycle: PhaseCycle,
): { phase: CyclePhase | null; dayOfCycle: number | null } {
const offsetFromStart = dayDiff(date, cycle.startDate);
const cycleLength = dayDiff(cycle.nextStart, cycle.startDate);
// Outside [start, nextStart): not part of this cycle.
if (offsetFromStart < 0 || offsetFromStart >= cycleLength) {
return { phase: null, dayOfCycle: null };
}
const dayOfCycle = offsetFromStart + 1; // 1-based
const periodLength = cycle.periodLength ?? POPULATION_DEFAULT_PERIOD;
// Clamp identically to the prediction engine (QA HIGH: one source of truth).
const lutealLength = clampLuteal(cycle.lutealLength ?? LUTEAL_DEFAULT);
// Ovulation: confirmed/estimated date, or back-calculated from length.
const ovulationDate =
cycle.ovulationDate ?? addDays(cycle.startDate, cycleLength - lutealLength);
// Boundary days (inclusive offsets from start).
const menstrualEnd = periodLength - 1; // [0, P-1]
const ovulationOffset = dayDiff(ovulationDate, cycle.startDate);
const ovulatoryStart = ovulationOffset - 1; // [ov-1, ov+1]
const ovulatoryEnd = ovulationOffset + 1;
// Precedence: MENSTRUAL first (bleeding wins).
if (offsetFromStart <= menstrualEnd) {
return { phase: "MENSTRUAL", dayOfCycle };
}
// OVULATORY next.
if (offsetFromStart >= ovulatoryStart && offsetFromStart <= ovulatoryEnd) {
return { phase: "OVULATORY", dayOfCycle };
}
// FOLLICULAR = after menstrual, before the ovulatory window.
if (offsetFromStart < ovulatoryStart) {
return { phase: "FOLLICULAR", dayOfCycle };
}
// LUTEAL = after the ovulatory window, to the day before nextStart.
return { phase: "LUTEAL", dayOfCycle };
}
/**
* Convenience: produce the daily phase series across `[from, to]` inclusive for
* a single cycle. Days outside the cycle window carry `phase: null`. This is the
* shape the CYCLE_PHASE behaviour channel consumes (one categorical value/day).
*/
export function phaseSeries(
from: string,
to: string,
cycle: PhaseCycle,
): { date: string; phase: CyclePhase | null; dayOfCycle: number | null }[] {
const span = dayDiff(to, from);
const out: {
date: string;
phase: CyclePhase | null;
dayOfCycle: number | null;
}[] = [];
for (let i = 0; i <= span; i++) {
const date = addDays(from, i);
out.push({ date, ...phaseForDay(date, cycle) });
}
return out;
}