flowtrack/static/cycle.js
rope 94372c6abc Flowtrack: private period tracker with cycle predictions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 08:37:13 +01:00

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