|
| 1 | +// cycle.ts — menstrual cycle estimation. |
| 2 | +// |
| 3 | +// LOG-ANCHORED + calendar method: the user logs period-start dates; prediction is |
| 4 | +// driven by those, never inferred from biometrics alone (we don't claim to detect |
| 5 | +// ovulation from temperature — that would be fabrication). The luteal phase is |
| 6 | +// physiologically stable at ~14 days (Wilcox 2000), so ovulation ≈ next-period − 14 |
| 7 | +// and the fertile window is the 5 days before ovulation + ovulation day. |
| 8 | +// |
| 9 | +// Biometric overlay (skin-temp / RHR / HRV shifts across the cycle) is rendered by |
| 10 | +// the caller from stored `daily` values — it ENRICHES the view but is descriptive, |
| 11 | +// never the basis of the prediction. Honest: an ESTIMATE, not medical or |
| 12 | +// contraceptive guidance. |
| 13 | + |
| 14 | +import type { Metric } from './types' |
| 15 | +import { median } from './util' |
| 16 | + |
| 17 | +const DAY_MS = 86400000 |
| 18 | +const toMs = (d: string) => Date.parse(d + 'T00:00:00Z') |
| 19 | +const toDate = (ms: number) => new Date(ms).toISOString().slice(0, 10) |
| 20 | +const daysBetween = (a: string, b: string) => Math.round((toMs(b) - toMs(a)) / DAY_MS) |
| 21 | + |
| 22 | +export type CyclePhase = 'menstruation' | 'follicular' | 'ovulation' | 'luteal' | 'unknown' |
| 23 | + |
| 24 | +export interface CycleValue { |
| 25 | + cycle_day: number | null // 1-based day within the current cycle (day 1 = start) |
| 26 | + phase: CyclePhase |
| 27 | + mean_length: number | null // median observed cycle length (days); null if <2 starts |
| 28 | + length_history: number[] // observed consecutive-start gaps (days) |
| 29 | + last_start: string | null // most recent logged period start (YYYY-MM-DD) |
| 30 | + predicted_next: string | null // predicted next period start |
| 31 | + days_until_next: number | null |
| 32 | + ovulation_est: string | null // predicted_next − 14d |
| 33 | + fertile_start: string | null // ovulation − 5d |
| 34 | + fertile_end: string | null // ovulation + 1d |
| 35 | + note: string |
| 36 | +} |
| 37 | + |
| 38 | +const DEFAULT_LEN = 28 // population default until the user has ≥2 logged periods |
| 39 | +const LUTEAL = 14 // stable luteal length → ovulation = next_period − LUTEAL |
| 40 | +const MENSES = 5 // assumed menses length when no explicit end is logged |
| 41 | + |
| 42 | +/** Estimate the current cycle position + next-period / fertile-window prediction |
| 43 | + * from a list of logged period-START dates. `today` is supplied (pure, no clock). */ |
| 44 | +export function calcCycle(startsRaw: string[], today: string): Metric<CycleValue> { |
| 45 | + const empty = (note: string): Metric<CycleValue> => ({ |
| 46 | + cycle_day: null, phase: 'unknown', mean_length: null, length_history: [], |
| 47 | + last_start: null, predicted_next: null, days_until_next: null, |
| 48 | + ovulation_est: null, fertile_start: null, fertile_end: null, note, |
| 49 | + confidence: 0, tier: 'ESTIMATE', inputs_used: ['period_log'], |
| 50 | + }) |
| 51 | + |
| 52 | + const starts = Array.from(new Set(startsRaw)) |
| 53 | + .filter((d) => /^\d{4}-\d{2}-\d{2}$/.test(d) && toMs(d) <= toMs(today)) |
| 54 | + .sort() |
| 55 | + if (starts.length === 0) return empty('Log a period to start tracking your cycle.') |
| 56 | + |
| 57 | + // Observed cycle lengths between consecutive starts (keep physiological 15–60d). |
| 58 | + const lengths: number[] = [] |
| 59 | + for (let i = 1; i < starts.length; i++) { |
| 60 | + const len = daysBetween(starts[i - 1], starts[i]) |
| 61 | + if (len >= 15 && len <= 60) lengths.push(len) |
| 62 | + } |
| 63 | + const med = lengths.length ? median(lengths) : null |
| 64 | + const meanLen = med == null ? null : Math.round(med) |
| 65 | + const useLen = meanLen ?? DEFAULT_LEN |
| 66 | + |
| 67 | + const last = starts[starts.length - 1] |
| 68 | + const cycleDay = daysBetween(last, today) + 1 // day 1 = the start date itself |
| 69 | + |
| 70 | + const nextMs = toMs(last) + useLen * DAY_MS |
| 71 | + const predictedNext = toDate(nextMs) |
| 72 | + const daysUntil = daysBetween(today, predictedNext) |
| 73 | + const ovMs = nextMs - LUTEAL * DAY_MS |
| 74 | + const ovulation = toDate(ovMs) |
| 75 | + const fertileStart = toDate(ovMs - 5 * DAY_MS) |
| 76 | + const fertileEnd = toDate(ovMs + 1 * DAY_MS) |
| 77 | + |
| 78 | + // Phase by calendar method. |
| 79 | + const todayMs = toMs(today) |
| 80 | + let phase: CyclePhase |
| 81 | + if (cycleDay <= MENSES) phase = 'menstruation' |
| 82 | + else if (todayMs >= toMs(fertileStart) && todayMs <= toMs(fertileEnd)) phase = 'ovulation' |
| 83 | + else if (todayMs < ovMs) phase = 'follicular' |
| 84 | + else phase = 'luteal' |
| 85 | + |
| 86 | + // Confidence grows with the number of observed cycles; collapses if very overdue |
| 87 | + // (a missed/late period makes the calendar prediction unreliable — say so). |
| 88 | + let conf = lengths.length === 0 ? 0.3 : Math.min(0.9, 0.4 + 0.15 * lengths.length) |
| 89 | + if (cycleDay > useLen * 1.6) { phase = 'unknown'; conf = Math.min(conf, 0.2) } |
| 90 | + |
| 91 | + return { |
| 92 | + cycle_day: cycleDay, phase, mean_length: meanLen, length_history: lengths, |
| 93 | + last_start: last, predicted_next: predictedNext, days_until_next: daysUntil, |
| 94 | + ovulation_est: ovulation, fertile_start: fertileStart, fertile_end: fertileEnd, |
| 95 | + note: lengths.length === 0 |
| 96 | + ? 'Based on one logged period and a 28-day default — accuracy improves as you log more.' |
| 97 | + : `Based on ${lengths.length + 1} logged periods (median ${useLen}-day cycle).`, |
| 98 | + confidence: conf, tier: 'ESTIMATE', inputs_used: ['period_log'], |
| 99 | + } |
| 100 | +} |
0 commit comments