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

@ -158,10 +158,15 @@
return h("span", { class: "flow", "aria-hidden": "true" }, [1, 2, 3].map(step => h("i", { class: step <= level ? "on" : null })));
}
// Which calendar days carry which marker, from logs and the summary.
function dayMarks(summary) {
// Which calendar days carry which marker, from logs and predictions up to
// `until` (the last day on screen; predictions have no horizon).
function dayMarks(summary, until) {
const marks = {
period: new Set(), spotting: new Set(), symptoms: new Set(), predicted: new Set(), fertile: new Set(), ovulation: new Set(),
// Predictions far enough ahead to be shown as less certain.
faded: new Set(),
// day -> the predicted period it belongs to.
prediction: new Map(),
// day -> 1..3: the logged strength, or the usual strength for that day
// of the period when predicted.
level: new Map()
@ -173,18 +178,24 @@
if (entry.flow === "spotting") marks.spotting.add(day);
if (entry.symptoms.length) marks.symptoms.add(day);
}
const { current, predictions, fertile, today, periodLength, flowProfile } = summary;
const predict = (from, to, start) => {
const { current, today, periodLength, flowProfile } = summary;
const { predictions, fertile } = Cycle.forecast(summary, until);
const predict = (from, to, start, prediction) => {
for (const day of range(from, to)) {
if (marks.period.has(day)) continue;
marks.predicted.add(day);
if (prediction) marks.prediction.set(day, prediction);
if (prediction && prediction.faded) marks.faded.add(day);
if (flowProfile[day - start]) marks.level.set(day, flowProfile[day - start]);
}
};
if (current && current.onPeriod) predict(today + 1, current.start + periodLength - 1, current.start);
for (const prediction of predictions) predict(Math.max(prediction.start, today + 1), prediction.end, prediction.start);
if (current && current.onPeriod) predict(today + 1, current.start + periodLength - 1, current.start, null);
for (const prediction of predictions) predict(Math.max(prediction.start, today + 1), prediction.end, prediction.start, prediction);
for (const window of fertile) {
for (const day of range(window.start, window.end)) marks.fertile.add(day);
for (const day of range(window.start, window.end)) {
marks.fertile.add(day);
if (window.faded) marks.faded.add(day);
}
marks.ovulation.add(window.ovulation);
}
return marks;
@ -331,9 +342,9 @@
function renderCalendar(summary) {
const { year, month } = state.month;
$("month-title").textContent = `${MONTHS[month]} ${year}`;
const marks = dayMarks(summary);
const first = Cycle.toDay(`${year}-${pad(month + 1)}-01`);
const count = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
const marks = dayMarks(summary, first + count - 1);
const leading = (new Date(first * 86400000).getUTCDay() + 6) % 7;
const cells = range(1, leading).map(() => h("span", { class: "day outside", "aria-hidden": "true" }));
for (const day of range(first, first + count - 1)) {
@ -343,6 +354,7 @@
if (marks.fertile.has(day) && !marks.period.has(day)) kinds.push("fertile");
if (day === summary.today) kinds.push("today");
if (day > summary.today) kinds.push("future");
if (marks.faded.has(day)) kinds.push("faded");
const dots = [];
if (marks.ovulation.has(day)) dots.push("ovulation");
if (marks.spotting.has(day)) dots.push("spotting");
@ -355,7 +367,8 @@
marks.fertile.has(day) && "fertile window estimate",
marks.ovulation.has(day) && "estimated ovulation",
marks.spotting.has(day) && "spotting",
marks.symptoms.has(day) && "symptoms logged"
marks.symptoms.has(day) && "symptoms logged",
marks.faded.has(day) && "less certain this far ahead"
].filter(Boolean);
cells.push(h("button", {
type: "button",
@ -387,15 +400,18 @@
function renderSheet(summary) {
const day = state.sheetDay;
if (day === null) return;
const marks = dayMarks(summary);
const marks = dayMarks(summary, day);
$("sheet-title").textContent = longDay(day);
const notes = [];
if (marks.predicted.has(day)) {
const level = marks.level.get(day);
notes.push(level ? `Predicted period day, usually ${Cycle.LEVEL_NAMES[level]} flow.` : "Predicted period day.");
const prediction = marks.prediction.get(day);
if (prediction) notes.push(`This period is expected to start around ${formatDay(prediction.start)}, likely between ${formatDay(prediction.earliest)} and ${formatDay(prediction.latest)}.`);
}
if (marks.ovulation.has(day)) notes.push("Estimated ovulation day.");
else if (marks.fertile.has(day)) notes.push("Inside the estimated fertile window.");
if (marks.faded.has(day)) notes.push("This far ahead the prediction is less certain.");
const body = [];
if (notes.length) body.push(h("p", { class: "prediction-note" }, notes.join(" ")));
if (day > summary.today) {
@ -423,6 +439,7 @@
tile("Cycles tracked", sampleSize)
);
renderChart(cycles.filter(cycle => cycle.used).slice(-12), summary);
renderUpcoming(summary);
const rows = [];
if (current) {
@ -443,6 +460,23 @@
$("history").replaceChildren(...rows);
}
// A year of predictions as a list, for planning travel and the like.
function renderUpcoming(summary) {
const { predictions } = Cycle.forecast(summary, summary.today + 365, 1);
const thisYear = new Date(summary.today * 86400000).getUTCFullYear();
// Built by hand: en-GB adds a comma after the weekday once a year is included.
const dated = number => {
const year = new Date(number * 86400000).getUTCFullYear();
const day = formatDay(number, { weekday: "short", day: "numeric", month: "short" });
return year === thisYear ? day : `${day} ${year}`;
};
const rows = predictions.map(prediction => h("li", { class: prediction.faded ? "faded" : null },
h("span", { class: "when" }, dated(prediction.start)),
h("span", { class: "likely" }, `likely ${formatDay(prediction.earliest)} – ${formatDay(prediction.latest)}`, prediction.faded && h("small", {}, "less certain"))
));
$("upcoming").replaceChildren(...(rows.length ? rows : [h("li", {}, "Log a period to see predictions.")]));
}
function renderChart(data, summary) {
const container = $("chart");
if (data.length < 2) {