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) {

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

View file

@ -14,7 +14,7 @@
<link rel="manifest" href="/manifest.webmanifest" crossorigin="use-credentials">
<link rel="icon" href="/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="stylesheet" href="/styles.css?v=1.2.0">
<link rel="stylesheet" href="/styles.css?v=1.3.0">
</head>
<body>
<header class="topbar">
@ -48,6 +48,7 @@
<li><span class="key key-period"></span>Period</li>
<li><span class="key key-predicted"></span>Predicted period</li>
<li><span class="key key-fertile"></span>Fertile window (estimate)</li>
<li><span class="key key-predicted faded"></span>More than 6 weeks ahead, less certain</li>
<li><span class="key key-ovulation"></span>Estimated ovulation</li>
<li><span class="flow" aria-hidden="true"><i class="on"></i><i class="on"></i><i></i></span>Flow strength, logged or usual</li>
<li><span class="key key-spotting"></span>Spotting</li>
@ -57,6 +58,11 @@
<section class="view" id="view-stats" aria-labelledby="tab-stats" hidden>
<div class="tiles" id="tiles"></div>
<div class="card">
<h2>Upcoming periods</h2>
<p class="subtitle">The next year, for planning ahead. The likely range widens the further out it is.</p>
<ul class="upcoming" id="upcoming"></ul>
</div>
<div class="card" id="chart-card">
<h2>Cycle length</h2>
<p class="subtitle">Days from one period to the next, most recent on the right</p>
@ -130,7 +136,7 @@
</form>
</dialog>
<script src="/cycle.js?v=1.2.0"></script>
<script src="/app.js?v=1.2.0"></script>
<script src="/cycle.js?v=1.3.0"></script>
<script src="/app.js?v=1.3.0"></script>
</body>
</html>

View file

@ -223,6 +223,13 @@ main {
.day.fertile { background: var(--fertile-soft); }
.day.predicted { box-shadow: inset 0 0 0 2px var(--period); background: var(--period-soft); }
.day.period { background: var(--period); color: var(--period-ink); font-weight: 600; }
/* More than six weeks out: same marks, washed out. */
.day.fertile.faded { background: color-mix(in srgb, var(--fertile-soft) 45%, transparent); }
.day.predicted.faded {
background: color-mix(in srgb, var(--period-soft) 45%, transparent);
box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--period) 40%, transparent);
}
.day.faded .flow, .day.faded .mark.ovulation { opacity: 0.45; }
.day.today::after {
content: "";
position: absolute;
@ -253,6 +260,10 @@ main {
.key-period { background: var(--period); }
.key-predicted { background: var(--period-soft); box-shadow: inset 0 0 0 2px var(--period); }
.key-fertile { background: var(--fertile-soft); }
.key-predicted.faded {
background: color-mix(in srgb, var(--period-soft) 45%, transparent);
box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--period) 40%, transparent);
}
.key-ovulation { position: relative; background: var(--fertile-soft); }
.key-ovulation::after { content: ""; position: absolute; inset: 5px; border-radius: 999px; background: var(--fertile); }
.key-spotting, .key-symptoms { position: relative; }
@ -292,6 +303,14 @@ main {
}
.tooltip[hidden] { display: none; }
.upcoming { display: grid; margin: 0; padding: 0; list-style: none; font-size: 0.92rem; font-variant-numeric: tabular-nums; }
.upcoming li { display: flex; justify-content: space-between; gap: 12px; padding: 9px 0; border-top: 1px solid var(--line); }
.upcoming li:first-child { border-top: 0; padding-top: 0; }
.upcoming .when { font-weight: 600; }
.upcoming .likely { color: var(--ink-2); text-align: right; }
.upcoming .likely small { display: block; color: var(--muted); font-size: 0.78rem; }
.upcoming li.faded .when { color: var(--ink-2); font-weight: 500; }
.table-wrap { margin-top: 12px; overflow-x: auto; }
.history { width: 100%; border-collapse: collapse; font-size: 0.92rem; font-variant-numeric: tabular-nums; }
.history th { padding: 0 0 8px; color: var(--muted); font-size: 0.8rem; font-weight: 600; text-align: left; }