diff --git a/package.nix b/package.nix index dccb368..132c889 100644 --- a/package.nix +++ b/package.nix @@ -2,7 +2,7 @@ stdenvNoCC.mkDerivation { pname = "flowtrack"; - version = "1.2.0"; + version = "1.3.0"; src = ./.; nativeBuildInputs = [ makeWrapper ]; diff --git a/static/app.js b/static/app.js index 5e86d6f..e5bec11 100644 --- a/static/app.js +++ b/static/app.js @@ -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) { diff --git a/static/cycle.js b/static/cycle.js index 0df4a04..e8088e1 100644 --- a/static/cycle.js +++ b/static/cycle.js @@ -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 }; }); diff --git a/static/index.html b/static/index.html index 80cfa02..2c4db2f 100644 --- a/static/index.html +++ b/static/index.html @@ -14,7 +14,7 @@ - +
The next year, for planning ahead. The likely range widens the further out it is.
+Days from one period to the next, most recent on the right
@@ -130,7 +136,7 @@ - - + +