Flowtrack: private period tracker with cycle predictions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
94372c6abc
19 changed files with 2166 additions and 0 deletions
143
static/cycle.js
Normal file
143
static/cycle.js
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
(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 };
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue