2026-10-18 08:37:13 +01:00
|
|
|
(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"]);
|
2026-10-18 09:32:29 +01:00
|
|
|
// "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"];
|
2026-10-18 08:37:13 +01:00
|
|
|
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;
|
2026-10-18 09:58:55 +01:00
|
|
|
// Predictions further out than this are shown faded.
|
|
|
|
|
const FADE_AFTER = 42;
|
|
|
|
|
const MAX_MARGIN = 14;
|
2026-10-18 08:37:13 +01:00
|
|
|
|
|
|
|
|
// 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) };
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-18 09:32:29 +01:00
|
|
|
// 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));
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-18 08:37:13 +01:00
|
|
|
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,
|
2026-10-18 09:32:29 +01:00
|
|
|
periodSamples: 0,
|
2026-10-18 08:37:13 +01:00
|
|
|
current: null,
|
|
|
|
|
late: 0,
|
|
|
|
|
predictions: [],
|
2026-10-18 09:32:29 +01:00
|
|
|
fertile: [],
|
|
|
|
|
flowProfile: flowProfile(days, periodList)
|
2026-10-18 08:37:13 +01:00
|
|
|
};
|
|
|
|
|
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)));
|
2026-10-18 09:32:29 +01:00
|
|
|
summary.periodSamples = lengths.length;
|
2026-10-18 08:37:13 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
summary.current = {
|
|
|
|
|
start: last.start,
|
|
|
|
|
end: last.end,
|
|
|
|
|
cycleDay: today - last.start + 1,
|
|
|
|
|
onPeriod: last.end === today
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const expected = last.start + summary.cycleLength;
|
2026-10-18 09:58:55 +01:00
|
|
|
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;
|
2026-10-18 08:37:13 +01:00
|
|
|
// A late period keeps being expected "from tomorrow" rather than drifting
|
|
|
|
|
// into the past, and the cycles after it shift with it.
|
2026-10-18 09:58:55 +01:00
|
|
|
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)));
|
2026-10-18 08:37:13 +01:00
|
|
|
const end = start + summary.periodLength - 1;
|
2026-10-18 09:58:55 +01:00
|
|
|
result.predictions.push({ start, end, earliest: start - margin, latest: start + margin, faded: start - today > FADE_AFTER });
|
2026-10-18 08:37:13 +01:00
|
|
|
// 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);
|
2026-10-18 09:58:55 +01:00
|
|
|
if (window) result.fertile.push({ ...window, faded: window.ovulation - today > FADE_AFTER });
|
2026-10-18 08:37:13 +01:00
|
|
|
previousEnd = end;
|
|
|
|
|
start += summary.cycleLength;
|
|
|
|
|
}
|
2026-10-18 09:58:55 +01:00
|
|
|
return result;
|
2026-10-18 08:37:13 +01:00
|
|
|
}
|
|
|
|
|
|
2026-10-18 09:58:55 +01:00
|
|
|
return { PERIOD_FLOWS, FLOW_LEVELS, LEVEL_NAMES, LUTEAL_DAYS, MIN_CYCLE, MAX_CYCLE, FADE_AFTER, toDay, toIso, periods, cycles, weightedStats, flowProfile, summarize, forecast };
|
2026-10-18 08:37:13 +01:00
|
|
|
});
|