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:
parent
94372c6abc
commit
ce66b857db
6 changed files with 149 additions and 55 deletions
136
static/app.js
136
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);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue