Open-ended predictions, faded past six weeks; upcoming periods list

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-18 09:58:55 +01:00
parent bb19bc0aeb
commit ce84bee359
6 changed files with 124 additions and 36 deletions

View file

@ -18,6 +18,9 @@
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.
@ -138,29 +141,38 @@
};
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;
}
if (today > expected && !summary.current.onPeriod) summary.late = today - expected;
Object.assign(summary, forecast(summary, -Infinity, FORECAST));
return summary;
}
return { PERIOD_FLOWS, FLOW_LEVELS, LEVEL_NAMES, LUTEAL_DAYS, MIN_CYCLE, MAX_CYCLE, toDay, toIso, periods, cycles, weightedStats, flowProfile, summarize };
// 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 };
});