143 lines
5.7 KiB
JavaScript
143 lines
5.7 KiB
JavaScript
(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"]);
|
|
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;
|
|
|
|
// 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) };
|
|
}
|
|
|
|
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,
|
|
current: null,
|
|
late: 0,
|
|
predictions: [],
|
|
fertile: []
|
|
};
|
|
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.current = {
|
|
start: last.start,
|
|
end: last.end,
|
|
cycleDay: today - last.start + 1,
|
|
onPeriod: last.end === today
|
|
};
|
|
|
|
const expected = last.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 = expected;
|
|
if (today > expected && !summary.current.onPeriod) {
|
|
summary.late = today - expected;
|
|
start = today + 1;
|
|
}
|
|
const thinData = summary.confidence !== "normal";
|
|
const spread = Math.max(summary.cycleSpread || 0, thinData ? 4 : 2);
|
|
let previousEnd = last.end;
|
|
for (let index = 1; index <= FORECAST; index += 1) {
|
|
const margin = Math.min(10, Math.round(spread * Math.sqrt(index)));
|
|
const end = start + summary.periodLength - 1;
|
|
summary.predictions.push({ start, end, earliest: start - margin, latest: start + margin });
|
|
// 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) summary.fertile.push(window);
|
|
previousEnd = end;
|
|
start += summary.cycleLength;
|
|
}
|
|
return summary;
|
|
}
|
|
|
|
return { PERIOD_FLOWS, LUTEAL_DAYS, MIN_CYCLE, MAX_CYCLE, toDay, toIso, periods, cycles, weightedStats, summarize };
|
|
});
|