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:
parent
bb19bc0aeb
commit
ce84bee359
6 changed files with 124 additions and 36 deletions
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
stdenvNoCC.mkDerivation {
|
||||
pname = "flowtrack";
|
||||
version = "1.2.0";
|
||||
version = "1.3.0";
|
||||
src = ./.;
|
||||
|
||||
nativeBuildInputs = [ makeWrapper ];
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -121,6 +121,23 @@ assert.equal(toDay("2026-03-01") - toDay("2026-02-28"), 1);
|
|||
for (const window of summary.fertile) assert.ok(window.start > toDay("2026-09-28"));
|
||||
}
|
||||
|
||||
// The forecast has no horizon: it runs to any date, keeps cycles evenly
|
||||
// spaced, fades predictions beyond six weeks and caps the range at ±14.
|
||||
{
|
||||
const starts = ["2026-04-01", "2026-05-01", "2026-05-31", "2026-06-30", "2026-07-30", "2026-08-29", "2026-09-28"];
|
||||
const summary = cycle.summarize(logPeriods(starts), "2026-10-10");
|
||||
const { predictions, fertile } = cycle.forecast(summary, toDay("2028-12-31"));
|
||||
assert.ok(predictions.length > 25);
|
||||
assert.ok(predictions[predictions.length - 1].start <= toDay("2028-12-31"));
|
||||
assert.deepEqual(predictions.slice(0, 6), summary.predictions);
|
||||
for (let index = 1; index < predictions.length; index += 1) assert.equal(predictions[index].start - predictions[index - 1].start, 30);
|
||||
assert.deepEqual(predictions.slice(0, 3).map(item => item.faded), [false, true, true], "28 Oct is within six weeks, 27 Nov is not");
|
||||
assert.ok(predictions.every(item => item.latest - item.start <= 14));
|
||||
assert.equal(fertile.length, predictions.length);
|
||||
assert.equal(fertile[0].faded, false);
|
||||
assert.equal(cycle.forecast(cycle.summarize({}, "2026-10-10"), toDay("2030-01-01")).predictions.length, 0);
|
||||
}
|
||||
|
||||
// The usual strength per period day averages rated days only: unrated
|
||||
// ("period") days and days nobody logged that far into a period stay null.
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue