Period day toggle, flow strength on calendar, end dates for past periods

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-18 09:32:29 +01:00
parent 94372c6abc
commit ce66b857db
6 changed files with 149 additions and 55 deletions

View file

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

View file

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

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.0.0">
<link rel="stylesheet" href="/styles.css?v=1.1.0">
</head>
<body>
<header class="topbar">
@ -49,6 +49,7 @@
<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-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>
<li><span class="key key-symptoms"></span>Symptoms logged</li>
</ul>
@ -85,7 +86,7 @@
</div>
<div class="card">
<h2>Add past periods</h2>
<p class="subtitle">Enter when recent periods started. Days you already logged are left untouched.</p>
<p class="subtitle">Enter when recent periods started and ended. Days you already logged are left untouched.</p>
<form id="seed-form" class="seed-form"></form>
</div>
<div class="card">
@ -124,7 +125,7 @@
</form>
</dialog>
<script src="/cycle.js?v=1.0.0"></script>
<script src="/app.js?v=1.0.0"></script>
<script src="/cycle.js?v=1.1.0"></script>
<script src="/app.js?v=1.1.0"></script>
</body>
</html>

View file

@ -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 */