Skip to content

Commit ee9ee22

Browse files
committed
feat(cycle): calcCycle — log-anchored menstrual cycle estimator
- New calcCycle(starts, today): median cycle length, next-period + ovulation (next-14, Wilcox 2000) + fertile window, phase, confidence that collapses when overdue. Pure + honest (abstains with no logs). - Exported CycleValue/CyclePhase; +13 unit tests (201 pass).
1 parent 05afc53 commit ee9ee22

3 files changed

Lines changed: 143 additions & 0 deletions

File tree

src/__tests__/analytics.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { resolveMaxHr } from '../util';
2525
import { calcCircadian, stageSleep } from '../circadian';
2626
import { detectSleepCycles } from '../cycles';
2727
import { detectWakeState, peekRecentState } from '../wake';
28+
import { calcCycle } from '../cycle';
2829

2930
const baseline: Baseline = {
3031
resting_hr: 50,
@@ -876,4 +877,42 @@ console.log('--- §Circadian calcCircadian ---');
876877
assert(noRr.wake_ts === null, `no-RR + no-motion quiet wake cannot be confirmed (honest) (got ${noRr.wake_ts})`);
877878
}
878879

880+
// ── menstrual cycle (log-anchored calendar method) ───────────────────────────
881+
{
882+
// No logs → empty/abstain.
883+
const none = calcCycle([], '2026-06-20');
884+
assert(none.confidence === 0 && none.phase === 'unknown' && none.predicted_next === null,
885+
'cycle: no logs → abstain');
886+
887+
// Three regular 28-day starts → median 28, prediction = last + 28.
888+
const starts = ['2026-04-04', '2026-05-02', '2026-05-30'];
889+
const c = calcCycle(starts, '2026-06-06'); // day 8 of the cycle that began 05-30
890+
assert(c.mean_length === 28, `cycle: median length 28 (got ${c.mean_length})`);
891+
assert(c.length_history.length === 2, `cycle: 2 observed lengths (got ${c.length_history.length})`);
892+
assert(c.cycle_day === 8, `cycle: cycle day 8 (got ${c.cycle_day})`);
893+
assert(c.predicted_next === '2026-06-27', `cycle: next period 06-27 (got ${c.predicted_next})`);
894+
assert(c.ovulation_est === '2026-06-13', `cycle: ovulation = next−14 (got ${c.ovulation_est})`);
895+
assert(c.fertile_start === '2026-06-08' && c.fertile_end === '2026-06-14',
896+
`cycle: fertile window ov−5..ov+1 (got ${c.fertile_start}..${c.fertile_end})`);
897+
assert(c.phase === 'follicular', `cycle: day 8 pre-ovulation → follicular (got ${c.phase})`);
898+
assert(c.confidence > 0.5, `cycle: confidence grows with cycles (got ${c.confidence})`);
899+
900+
// Menstruation window (day ≤ 5).
901+
const m = calcCycle(starts, '2026-05-31'); // day 2
902+
assert(m.phase === 'menstruation', `cycle: day 2 → menstruation (got ${m.phase})`);
903+
904+
// Luteal: after the fertile window, before next period.
905+
const l = calcCycle(starts, '2026-06-20'); // day 22
906+
assert(l.phase === 'luteal', `cycle: day 22 → luteal (got ${l.phase})`);
907+
908+
// Very overdue → prediction unreliable, abstain on phase + low confidence.
909+
const od = calcCycle(starts, '2026-07-25'); // ~56 days since last start
910+
assert(od.phase === 'unknown' && od.confidence <= 0.2, `cycle: very overdue → unknown/low conf (got ${od.phase}/${od.confidence})`);
911+
912+
// Single log → 28-day default, low-but-nonzero confidence.
913+
const one = calcCycle(['2026-06-10'], '2026-06-15');
914+
assert(one.mean_length === null && one.predicted_next === '2026-07-08' && one.confidence > 0,
915+
`cycle: single log uses 28d default (got ${one.predicted_next}/${one.confidence})`);
916+
}
917+
879918
summary('analytics');

src/cycle.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
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+
}

src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ export type { NightHypnogram } from './sleep';
3333
export { detectSleepCycles } from './cycles';
3434
export type { SleepCycle, SleepCyclesValue } from './cycles';
3535

36+
// §Menstrual cycle — log-anchored calendar method + fertile window (Wilcox 2000).
37+
export { calcCycle } from './cycle';
38+
export type { CycleValue, CyclePhase } from './cycle';
39+
3640
// §6 Sleep regularity (SRI)
3741
export { calcSleepRegularity } from './regularity';
3842

0 commit comments

Comments
 (0)