flowtrack/static/cycle.js

179 lines
7.5 KiB
JavaScript
Raw Permalink Normal View History

(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 };
});