diff --git a/package.nix b/package.nix index 7f4c84e..4e89347 100644 --- a/package.nix +++ b/package.nix @@ -2,7 +2,7 @@ stdenvNoCC.mkDerivation { pname = "flowtrack"; - version = "1.0.0"; + version = "1.1.0"; src = ./.; nativeBuildInputs = [ makeWrapper ]; diff --git a/static/app.js b/static/app.js index 06dd394..a14e3bf 100644 --- a/static/app.js +++ b/static/app.js @@ -1,8 +1,6 @@ (() => { const Cycle = window.Cycle; - const FLOWS = [ - [null, "None"], - ["spotting", "Spotting"], + const STRENGTHS = [ ["light", "Light"], ["medium", "Medium"], ["heavy", "Heavy"] @@ -113,19 +111,25 @@ function logEditor(iso) { const entry = state.days[iso] || { flow: null, symptoms: [] }; const canEdit = editable(); - const flows = FLOWS.slice(); - // "period" is a period day without an intensity (added from past dates). - if (entry.flow === "period") flows.push(["period", "Period"]); + const isPeriod = Cycle.PERIOD_FLOWS.has(entry.flow); const update = change => saveDay(iso, { flow: entry.flow, symptoms: entry.symptoms, ...change }); - const flowChips = flows.map(([flow, label]) => - h("button", { - type: "button", - class: "chip", - "aria-pressed": String(entry.flow === flow), - disabled: !canEdit, - onclick: () => update({ flow }) - }, label) - ); + const chip = (label, pressed, onclick) => + h("button", { type: "button", class: "chip", "aria-pressed": String(pressed), disabled: !canEdit, onclick }, label); + const periodChips = [ + chip("Yes", isPeriod, () => { if (!isPeriod) update({ flow: "period" }); }), + chip("No", !isPeriod, () => { if (isPeriod) update({ flow: null }); }) + ]; + // Strength is optional: tapping the selected one again clears it back to + // a plain period day. + const detail = isPeriod + ? h("section", { class: "log-section" }, + h("h3", {}, "Flow strength"), + h("div", { class: "chips" }, STRENGTHS.map(([flow, label]) => + chip(label, entry.flow === flow, () => update({ flow: entry.flow === flow ? "period" : flow })) + ))) + : h("section", { class: "log-section" }, + h("h3", {}, "Spotting"), + h("div", { class: "chips" }, chip("Spotting", entry.flow === "spotting", () => update({ flow: entry.flow === "spotting" ? null : "spotting" })))); const symptomChips = state.symptoms.map(({ id, label }) => { const active = entry.symptoms.includes(id); return h("button", { @@ -139,27 +143,41 @@ }, label); }); return h("div", {}, - h("section", { class: "log-section" }, h("h3", {}, "Flow"), h("div", { class: "chips" }, flowChips)), + h("section", { class: "log-section" }, h("h3", {}, "Period day"), h("div", { class: "chips" }, periodChips)), + detail, h("section", { class: "log-section" }, h("h3", {}, "Symptoms"), h("div", { class: "chips" }, symptomChips)) ); } + function flowBars(level) { + 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) { - const marks = { period: new Set(), spotting: new Set(), symptoms: new Set(), predicted: new Set(), fertile: new Set(), ovulation: new Set() }; + const marks = { + period: new Set(), spotting: new Set(), symptoms: new Set(), predicted: new Set(), fertile: new Set(), ovulation: new Set(), + // day -> 1..3: the logged strength, or the usual strength for that day + // of the period when predicted. + level: new Map() + }; for (const [iso, entry] of Object.entries(state.days)) { const day = Cycle.toDay(iso); if (Cycle.PERIOD_FLOWS.has(entry.flow)) marks.period.add(day); + if (Cycle.FLOW_LEVELS[entry.flow]) marks.level.set(day, Cycle.FLOW_LEVELS[entry.flow]); if (entry.flow === "spotting") marks.spotting.add(day); if (entry.symptoms.length) marks.symptoms.add(day); } - const { current, predictions, fertile, today, periodLength } = summary; - if (current && current.onPeriod) { - for (const day of range(today + 1, current.start + periodLength - 1)) marks.predicted.add(day); - } - for (const prediction of predictions) { - for (const day of range(Math.max(prediction.start, today + 1), prediction.end)) marks.predicted.add(day); - } + const { current, predictions, fertile, today, periodLength, flowProfile } = summary; + const predict = (from, to, start) => { + for (const day of range(from, to)) { + if (marks.period.has(day)) continue; + marks.predicted.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); for (const window of fertile) { for (const day of range(window.start, window.end)) marks.fertile.add(day); marks.ovulation.add(window.ovulation); @@ -169,25 +187,37 @@ function seedForm(form) { const today = todayIso(); - const dateInput = () => h("input", { type: "date", max: today, "aria-label": "Period start date" }); - const dates = h("div", { class: "seed-dates" }, dateInput(), dateInput(), dateInput()); - const length = h("input", { type: "number", min: "1", max: "10", value: "5", inputmode: "numeric", class: "seed-length" }); + const periodRow = () => { + const start = h("input", { type: "date", max: today, class: "seed-start" }); + const end = h("input", { type: "date", max: today, class: "seed-end" }); + start.addEventListener("change", () => { end.min = start.value; }); + return h("div", { class: "seed-row" }, h("label", {}, "Started", start), h("label", {}, "Ended", end)); + }; + const rows = h("div", { class: "seed-rows" }, periodRow(), periodRow()); const submit = h("button", { type: "submit", class: "button" }, "Save periods"); form.replaceChildren( - h("label", {}, "Start dates", dates), - h("button", { type: "button", class: "link-button", onclick: () => dates.append(dateInput()) }, "+ Add another date"), - h("label", {}, "Usual length in days", length), + rows, + h("button", { type: "button", class: "link-button", onclick: () => rows.append(periodRow()) }, "+ Add another period"), + h("p", { class: "seed-hint" }), submit ); form.onsubmit = async event => { event.preventDefault(); - const days = Math.min(10, Math.max(1, Number(length.value) || 5)); + // A missing end uses the average length of what is already logged. + const usual = Cycle.summarize(state.days, today).periodLength; const todayNumber = Cycle.toDay(today); const entries = {}; - for (const input of dates.querySelectorAll("input")) { - if (!input.value) continue; - const start = Cycle.toDay(input.value); - for (const day of range(start, Math.min(start + days - 1, todayNumber))) { + for (const row of rows.children) { + const start = row.querySelector(".seed-start").value; + const end = row.querySelector(".seed-end").value; + if (!start) continue; + const first = Cycle.toDay(start); + const last = end ? Cycle.toDay(end) : first + usual - 1; + if (last < first || last - first > 14) { + showBanner(`The period starting ${formatDay(first)} needs an end date within two weeks of its start.`, true); + return; + } + for (const day of range(first, Math.min(last, todayNumber))) { entries[Cycle.toIso(day)] = { flow: "period", symptoms: [] }; } } @@ -199,6 +229,7 @@ try { const { added } = await sendJson("POST", "/api/days", { days: entries }); showBanner(added ? `Added ${plural(added, "day")}.` : "Those days were already logged."); + for (const input of rows.querySelectorAll("input")) input.value = ""; await load(); } catch (error) { showBanner(`Couldn't save: ${error.message}`, true); @@ -218,9 +249,10 @@ if (!current) { hero.replaceChildren(...[ h("p", { class: "eyebrow" }, "Welcome"), - h("p", { class: "lead" }, editable() ? "When did your recent periods start?" : "Nothing has been logged yet."), - editable() && h("p", { class: "detail" }, "Two or three dates are enough to start predicting. You can add more later in Settings."), - editable() && seedForm(h("form", { class: "seed-form facts" })) + h("p", { class: "lead" }, editable() ? "When were your recent periods?" : "Nothing has been logged yet."), + editable() && h("p", { class: "detail" }, "Two or three periods are enough to start predicting. You can add more later in Settings."), + // Kept across renders so a refresh does not wipe half-entered dates. + editable() && (renderHero.seed ||= seedForm(h("form", { class: "seed-form facts" }))) ].filter(Boolean)); return; } @@ -232,8 +264,9 @@ headline = [ h("p", { class: "eyebrow" }, "Period"), h("p", { class: "figure" }, "Day ", today - current.start + 1), - h("p", { class: "lead" }, `Usually lasts about ${plural(summary.periodLength, "day")}`) - ]; + h("p", { class: "lead" }, `Usually lasts about ${plural(summary.periodLength, "day")}`), + usualFlow(summary, today - current.start) && h("p", { class: "detail" }, `Day ${today - current.start + 1} is usually ${usualFlow(summary, today - current.start)}`) + ].filter(Boolean); } else if (late) { headline = [ h("p", { class: "eyebrow" }, "Period expected"), @@ -275,6 +308,8 @@ hero.replaceChildren(...[...headline, facts.length && h("div", { class: "facts" }, facts), note && h("p", { class: "note" }, note)].filter(Boolean)); } + const usualFlow = (summary, index) => Cycle.LEVEL_NAMES[summary.flowProfile[index]] || null; + function fact(keyClass, title, text) { return h("div", { class: "fact" }, h("span", { class: `key ${keyClass}` }), h("div", {}, h("strong", {}, title), h("span", {}, text))); } @@ -307,9 +342,11 @@ if (marks.ovulation.has(day)) dots.push("ovulation"); if (marks.spotting.has(day)) dots.push("spotting"); if (marks.symptoms.has(day)) dots.push("symptoms"); + const level = marks.level.get(day); const described = [ marks.period.has(day) && "period", - marks.predicted.has(day) && !marks.period.has(day) && "predicted period", + marks.predicted.has(day) && "predicted period", + level && `${marks.predicted.has(day) ? "usually " : ""}${Cycle.LEVEL_NAMES[level]} flow`, marks.fertile.has(day) && "fertile window estimate", marks.ovulation.has(day) && "estimated ovulation", marks.spotting.has(day) && "spotting", @@ -321,7 +358,9 @@ "aria-label": [longDay(day), ...described].join(", "), "aria-current": day === summary.today ? "date" : null, onclick: () => openSheet(day) - }, formatDay(day, { day: "numeric" }), dots.length ? h("span", { class: "marks" }, dots.map(kind => h("span", { class: `mark ${kind}` }))) : null)); + }, formatDay(day, { day: "numeric" }), level || dots.length + ? h("span", { class: "marks" }, level && flowBars(level), dots.map(kind => h("span", { class: `mark ${kind}` }))) + : null)); } $("month-grid").replaceChildren(...cells); } @@ -346,7 +385,10 @@ const marks = dayMarks(summary); $("sheet-title").textContent = longDay(day); const notes = []; - if (marks.predicted.has(day) && !marks.period.has(day)) notes.push("Predicted period day."); + 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."); + } if (marks.ovulation.has(day)) notes.push("Estimated ovulation day."); else if (marks.fertile.has(day)) notes.push("Inside the estimated fertile window."); const body = []; @@ -465,8 +507,12 @@ // ---- Settings ------------------------------------------------------------ - function renderSettings() { + function renderSettings(summary) { $("share-toggle").checked = state.shareDetails; + const hint = summary.periodSamples + ? `Leave "Ended" empty to use the average length so far, ${plural(summary.periodLength, "day")}.` + : `Leave "Ended" empty to assume ${plural(summary.periodLength, "day")} until there is more to average.`; + for (const element of document.querySelectorAll(".seed-hint")) element.textContent = hint; } // ---- Shell --------------------------------------------------------------- @@ -492,7 +538,7 @@ renderTodayLog(); renderCalendar(summary); renderStats(summary); - renderSettings(); + renderSettings(summary); renderSheet(summary); } diff --git a/static/cycle.js b/static/cycle.js index b3c2240..0df4a04 100644 --- a/static/cycle.js +++ b/static/cycle.js @@ -5,6 +5,9 @@ })(typeof window === "undefined" ? globalThis : window, () => { const MS_PER_DAY = 86400000; const PERIOD_FLOWS = new Set(["period", "light", "medium", "heavy"]); + // "period" is a period day without a strength, so it has no level. + const FLOW_LEVELS = { light: 1, medium: 2, heavy: 3 }; + const LEVEL_NAMES = [null, "light", "medium", "heavy"]; const DEFAULT_CYCLE = 28; const DEFAULT_PERIOD = 5; // Days from ovulation to the next period. Fairly constant between people, @@ -66,6 +69,23 @@ return { mean, sd: Math.sqrt(variance) }; } + // Typical strength on each day of a period (index 0 = first day), averaged + // over recent periods. Days nobody rated stay null. + function flowProfile(days, periodList, recent = 6) { + const sums = []; + const counts = []; + for (const period of periodList.slice(-recent)) { + for (let index = 0; index < period.length; index += 1) { + const entry = days[toIso(period.start + index)]; + const level = entry && FLOW_LEVELS[entry.flow]; + if (!level) continue; + sums[index] = (sums[index] || 0) + level; + counts[index] = (counts[index] || 0) + 1; + } + } + return Array.from({ length: sums.length }, (_, index) => (counts[index] ? Math.round(sums[index] / counts[index]) : null)); + } + function fertileWindow(nextStart, previousEnd) { const ovulation = nextStart - LUTEAL_DAYS; if (ovulation <= previousEnd) return null; @@ -86,10 +106,12 @@ cycleLength: DEFAULT_CYCLE, cycleSpread: null, periodLength: DEFAULT_PERIOD, + periodSamples: 0, current: null, late: 0, predictions: [], - fertile: [] + fertile: [], + flowProfile: flowProfile(days, periodList) }; if (!periodList.length) return summary; @@ -105,6 +127,7 @@ if (lengths.length) { const mean = lengths.reduce((sum, value) => sum + value, 0) / lengths.length; summary.periodLength = Math.min(10, Math.max(2, Math.round(mean))); + summary.periodSamples = lengths.length; } summary.current = { @@ -139,5 +162,5 @@ return summary; } - return { PERIOD_FLOWS, LUTEAL_DAYS, MIN_CYCLE, MAX_CYCLE, toDay, toIso, periods, cycles, weightedStats, summarize }; + return { PERIOD_FLOWS, FLOW_LEVELS, LEVEL_NAMES, LUTEAL_DAYS, MIN_CYCLE, MAX_CYCLE, toDay, toIso, periods, cycles, weightedStats, flowProfile, summarize }; }); diff --git a/static/index.html b/static/index.html index 4200907..40b6dea 100644 --- a/static/index.html +++ b/static/index.html @@ -14,7 +14,7 @@ - +
@@ -49,6 +49,7 @@
  • Predicted period
  • Fertile window (estimate)
  • Estimated ovulation
  • +
  • Flow strength, logged or usual
  • Spotting
  • Symptoms logged
  • @@ -85,7 +86,7 @@

    Add past periods

    -

    Enter when recent periods started. Days you already logged are left untouched.

    +

    Enter when recent periods started and ended. Days you already logged are left untouched.

    @@ -124,7 +125,7 @@ - - + + diff --git a/static/styles.css b/static/styles.css index e66ffc0..78b519f 100644 --- a/static/styles.css +++ b/static/styles.css @@ -230,7 +230,16 @@ main { border: 2px solid var(--ink); border-radius: 999px; } -.day .marks { position: absolute; bottom: 12%; display: flex; gap: 3px; } +.day .marks { position: absolute; bottom: 12%; display: flex; align-items: flex-end; gap: 3px; } +/* Flow strength as 1-3 rising bars, like signal strength. */ +.flow { display: inline-flex; align-items: flex-end; gap: 1.5px; height: 8px; color: var(--period); } +.flow i { width: 3px; border-radius: 1px; background: currentColor; opacity: 0.3; } +.flow i:nth-child(1) { height: 4px; } +.flow i:nth-child(2) { height: 6px; } +.flow i:nth-child(3) { height: 8px; } +.flow i.on { opacity: 1; } +.day.period .flow { color: var(--period-ink); } +.legend .flow { width: 16px; justify-content: center; } .day .mark { width: 5px; height: 5px; border-radius: 999px; } .mark.ovulation { background: var(--fertile); } .mark.spotting { background: var(--period); } @@ -308,8 +317,10 @@ main { background: var(--surface); font-weight: 400; } -.seed-dates { display: grid; gap: 8px; } -.seed-form .seed-length { max-width: 120px; } +.seed-rows { display: grid; gap: 10px; } +.seed-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } +.seed-row input { width: 100%; min-width: 0; } +.seed-hint { margin: 0; color: var(--muted); font-size: 0.85rem; } /* Tabs */ diff --git a/tests/test_cycle.js b/tests/test_cycle.js index c8e19cb..170fe77 100644 --- a/tests/test_cycle.js +++ b/tests/test_cycle.js @@ -121,4 +121,17 @@ 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 usual strength per period day averages rated days only: unrated +// ("period") days and days nobody logged that far into a period stay null. +{ + const days = {}; + const rate = (start, flows) => flows.forEach((flow, offset) => { days[toIso(toDay(start) + offset)] = { flow, symptoms: [] }; }); + rate("2026-08-01", ["heavy", "heavy", "medium", "light"]); + rate("2026-08-29", ["medium", "heavy", "period", "light", "light"]); + const summary = cycle.summarize(days, "2026-09-20"); + assert.deepEqual(summary.flowProfile, [3, 3, 2, 1, 1]); + assert.equal(summary.periodSamples, 2); + assert.equal(summary.periodLength, 5); +} + console.log("cycle tests passed");