(function exposeCycle(root, factory) { const api = factory(); if (typeof module === "object" && module.exports) module.exports = api; if (root) root.Cycle = api; })(typeof window === "undefined" ? globalThis : window, () => { const MS_PER_DAY = 86400000; const PERIOD_FLOWS = new Set(["period", "light", "medium", "heavy"]); // "period" is a period day without a strength, so it has no level. const FLOW_LEVELS = { light: 1, medium: 2, heavy: 3 }; const LEVEL_NAMES = [null, "light", "medium", "heavy"]; const DEFAULT_CYCLE = 28; const DEFAULT_PERIOD = 5; // Days from ovulation to the next period. Fairly constant between people, // which is why ovulation is counted backwards from the predicted start. const LUTEAL_DAYS = 14; // Outside this range a "cycle" is almost always a missed or stray log. const MIN_CYCLE = 15; const MAX_CYCLE = 60; const HISTORY = 12; const FORECAST = 6; // Predictions further out than this are shown faded. const FADE_AFTER = 42; const MAX_MARGIN = 14; // Dates are handled as whole days since 1970-01-01 so month lengths and // DST shifts never enter the arithmetic. const toDay = iso => { const [year, month, day] = iso.split("-").map(Number); return Math.round(Date.UTC(year, month - 1, day) / MS_PER_DAY); }; const toIso = number => new Date(number * MS_PER_DAY).toISOString().slice(0, 10); // Consecutive period days form one period. A single unlogged day inside a // period (forgot to log, or a light day logged as nothing) does not split it. function periods(days) { const periodDays = Object.keys(days) .filter(iso => PERIOD_FLOWS.has(days[iso] && days[iso].flow)) .map(toDay) .sort((a, b) => a - b); const result = []; for (const day of periodDays) { const last = result[result.length - 1]; if (last && day - last.end <= 2) last.end = day; else result.push({ start: day, end: day }); } for (const period of result) period.length = period.end - period.start + 1; return result; } function cycles(periodList) { const result = []; for (let index = 1; index < periodList.length; index += 1) { const start = periodList[index - 1].start; const length = periodList[index].start - start; result.push({ start, length, periodLength: periodList[index - 1].length, used: length >= MIN_CYCLE && length <= MAX_CYCLE }); } return result; } // Recent cycles count more (weights 1..n, oldest first), so a gradual shift // in her cycle shows up in predictions within a few months. function weightedStats(values) { const weights = values.map((_, index) => index + 1); const total = weights.reduce((sum, weight) => sum + weight, 0); const mean = values.reduce((sum, value, index) => sum + value * weights[index], 0) / total; const variance = values.reduce((sum, value, index) => sum + weights[index] * (value - mean) ** 2, 0) / total; return { mean, sd: Math.sqrt(variance) }; } // Typical strength on each day of a period (index 0 = first day), averaged // over recent periods. Days nobody rated stay null. function flowProfile(days, periodList, recent = 6) { const sums = []; const counts = []; for (const period of periodList.slice(-recent)) { for (let index = 0; index < period.length; index += 1) { const entry = days[toIso(period.start + index)]; const level = entry && FLOW_LEVELS[entry.flow]; if (!level) continue; sums[index] = (sums[index] || 0) + level; counts[index] = (counts[index] || 0) + 1; } } return Array.from({ length: sums.length }, (_, index) => (counts[index] ? Math.round(sums[index] / counts[index]) : null)); } function fertileWindow(nextStart, previousEnd) { const ovulation = nextStart - LUTEAL_DAYS; if (ovulation <= previousEnd) return null; return { ovulation, start: Math.max(ovulation - 5, previousEnd + 1), end: ovulation + 1 }; } function summarize(days, todayIso) { const today = toDay(todayIso); const periodList = periods(days).filter(period => period.start <= today); const cycleList = cycles(periodList); const used = cycleList.filter(cycle => cycle.used).slice(-HISTORY); const summary = { today, periods: periodList, cycles: cycleList, sampleSize: used.length, confidence: used.length === 0 ? "none" : used.length < 3 ? "low" : "normal", cycleLength: DEFAULT_CYCLE, cycleSpread: null, periodLength: DEFAULT_PERIOD, periodSamples: 0, current: null, late: 0, predictions: [], fertile: [], flowProfile: flowProfile(days, periodList) }; if (!periodList.length) return summary; if (used.length) { const stats = weightedStats(used.map(cycle => cycle.length)); summary.cycleLength = Math.round(stats.mean); summary.cycleSpread = stats.sd; } const last = periodList[periodList.length - 1]; // The latest period may still be going, so it says nothing about length yet. const finished = periodList.filter(period => period !== last || period.end < today - 1); const lengths = finished.filter(period => period.length >= 2).slice(-6).map(period => period.length); if (lengths.length) { const mean = lengths.reduce((sum, value) => sum + value, 0) / lengths.length; summary.periodLength = Math.min(10, Math.max(2, Math.round(mean))); summary.periodSamples = lengths.length; } summary.current = { start: last.start, end: last.end, cycleDay: today - last.start + 1, onPeriod: last.end === today }; const expected = last.start + summary.cycleLength; if (today > expected && !summary.current.onPeriod) summary.late = today - expected; Object.assign(summary, forecast(summary, -Infinity, FORECAST)); return summary; } // Predicted periods (and the fertile window before each) up to `until`, // or at least `minimum` of them. There is no horizon: the range just keeps // widening, and anything more than FADE_AFTER days out is flagged `faded` // for display as a less certain prediction. function forecast(summary, until, minimum = 0) { const result = { predictions: [], fertile: [] }; const { current, today } = summary; if (!current) return result; const expected = current.start + summary.cycleLength; // A late period keeps being expected "from tomorrow" rather than drifting // into the past, and the cycles after it shift with it. let start = summary.late ? today + 1 : expected; const spread = Math.max(summary.cycleSpread || 0, summary.confidence !== "normal" ? 4 : 2); let previousEnd = current.end; for (let index = 1; index <= minimum || start <= until; index += 1) { const margin = Math.min(MAX_MARGIN, Math.round(spread * Math.sqrt(index))); const end = start + summary.periodLength - 1; result.predictions.push({ start, end, earliest: start - margin, latest: start + margin, faded: start - today > FADE_AFTER }); // The current cycle's window is anchored on the original expectation: // being late does not move an ovulation that already happened. const window = fertileWindow(index === 1 ? expected : start, previousEnd); if (window) result.fertile.push({ ...window, faded: window.ovulation - today > FADE_AFTER }); previousEnd = end; start += summary.cycleLength; } return result; } return { PERIOD_FLOWS, FLOW_LEVELS, LEVEL_NAMES, LUTEAL_DAYS, MIN_CYCLE, MAX_CYCLE, FADE_AFTER, toDay, toIso, periods, cycles, weightedStats, flowProfile, summarize, forecast }; });