flowtrack/static/app.js
rope 94372c6abc Flowtrack: private period tracker with cycle predictions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 08:37:13 +01:00

535 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(() => {
const Cycle = window.Cycle;
const FLOWS = [
[null, "None"],
["spotting", "Spotting"],
["light", "Light"],
["medium", "Medium"],
["heavy", "Heavy"]
];
const MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
const state = {
role: null,
shareDetails: false,
symptoms: [],
days: {},
view: "today",
month: null,
sheetDay: null
};
const $ = id => document.getElementById(id);
function h(tag, attributes = {}, ...children) {
const element = document.createElement(tag);
for (const [name, value] of Object.entries(attributes)) {
if (value === false || value === null || value === undefined) continue;
if (name === "class") element.className = value;
else if (name.startsWith("on")) element.addEventListener(name.slice(2), value);
else element.setAttribute(name, value === true ? "" : value);
}
for (const child of children.flat()) {
if (child === null || child === undefined || child === false) continue;
element.append(child instanceof Node ? child : document.createTextNode(String(child)));
}
return element;
}
const pad = number => String(number).padStart(2, "0");
const todayIso = () => {
const now = new Date();
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
};
const formatDay = (number, options = { day: "numeric", month: "short" }) =>
new Date(number * 86400000).toLocaleDateString("en-GB", { ...options, timeZone: "UTC" });
const longDay = number => formatDay(number, { weekday: "long", day: "numeric", month: "long" });
const plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
const range = (from, to) => Array.from({ length: Math.max(0, to - from + 1) }, (_, index) => from + index);
// ---- Server --------------------------------------------------------------
async function api(path, options = {}) {
// An expired Authelia session answers with a redirect to the login portal,
// which fetch cannot follow cross-origin. Reloading lets the browser do it.
const response = await fetch(path, { redirect: "manual", credentials: "same-origin", ...options });
if (response.type === "opaqueredirect") {
window.location.reload();
throw new Error("Signing in again…");
}
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || `Request failed (${response.status})`);
return payload;
}
const sendJson = (method, path, body) =>
api(path, { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
function showBanner(message, isError = false) {
const banner = $("banner");
banner.textContent = message;
banner.classList.toggle("error", isError);
banner.hidden = !message;
clearTimeout(showBanner.timer);
if (message && !isError) showBanner.timer = setTimeout(() => { banner.hidden = true; }, 4000);
}
async function load() {
try {
const payload = await api("/api/state");
Object.assign(state, {
role: payload.role,
shareDetails: payload.shareDetails,
symptoms: payload.symptoms,
days: payload.days
});
if (!$("banner").classList.contains("error")) showBanner("");
render();
} catch (error) {
showBanner(error.message === "Not allowed" ? "This account does not have access to Flowtrack." : error.message, true);
}
}
async function saveDay(iso, entry) {
const previous = state.days[iso];
if (entry.flow === null && !entry.symptoms.length) delete state.days[iso];
else state.days[iso] = entry;
render();
try {
await sendJson("PUT", `/api/days/${iso}`, entry);
} catch (error) {
if (previous) state.days[iso] = previous;
else delete state.days[iso];
render();
showBanner(`Couldn't save: ${error.message}`, true);
}
}
// ---- Shared pieces -------------------------------------------------------
const editable = () => state.role === "editor";
const detailsVisible = () => editable() || state.shareDetails;
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 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 symptomChips = state.symptoms.map(({ id, label }) => {
const active = entry.symptoms.includes(id);
return h("button", {
type: "button",
class: "chip symptom",
"aria-pressed": String(active),
disabled: !canEdit,
onclick: () => update({
symptoms: active ? entry.symptoms.filter(item => item !== id) : [...entry.symptoms, id]
})
}, label);
});
return h("div", {},
h("section", { class: "log-section" }, h("h3", {}, "Flow"), h("div", { class: "chips" }, flowChips)),
h("section", { class: "log-section" }, h("h3", {}, "Symptoms"), h("div", { class: "chips" }, symptomChips))
);
}
// 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() };
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 (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);
}
for (const window of fertile) {
for (const day of range(window.start, window.end)) marks.fertile.add(day);
marks.ovulation.add(window.ovulation);
}
return marks;
}
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 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),
submit
);
form.onsubmit = async event => {
event.preventDefault();
const days = Math.min(10, Math.max(1, Number(length.value) || 5));
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))) {
entries[Cycle.toIso(day)] = { flow: "period", symptoms: [] };
}
}
if (!Object.keys(entries).length) {
showBanner("Pick at least one start date.", true);
return;
}
submit.disabled = true;
try {
const { added } = await sendJson("POST", "/api/days", { days: entries });
showBanner(added ? `Added ${plural(added, "day")}.` : "Those days were already logged.");
await load();
} catch (error) {
showBanner(`Couldn't save: ${error.message}`, true);
} finally {
submit.disabled = false;
}
};
return form;
}
// ---- Today ---------------------------------------------------------------
function renderHero(summary) {
const hero = $("hero");
hero.className = "hero card";
const { current, predictions, late, today, confidence, sampleSize } = summary;
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" }))
].filter(Boolean));
return;
}
const next = predictions[0];
const untilNext = next.start - today;
let headline;
if (current.onPeriod) {
hero.classList.add("on-period");
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")}`)
];
} else if (late) {
headline = [
h("p", { class: "eyebrow" }, "Period expected"),
h("p", { class: "figure" }, late, h("small", {}, late === 1 ? " day late" : " days late")),
h("p", { class: "lead" }, `It was expected on ${formatDay(today - late, { weekday: "short", day: "numeric", month: "short" })}`),
h("p", { class: "detail" }, "A few days either way is common. Log it here when it starts.")
];
} else {
headline = [
h("p", { class: "eyebrow" }, "Cycle day"),
h("p", { class: "figure" }, current.cycleDay),
h("p", { class: "lead" }, untilNext === 0 ? "Period expected today" : `Next period in about ${plural(untilNext, "day")}`),
h("p", { class: "detail" }, `Around ${formatDay(next.start, { weekday: "short", day: "numeric", month: "short" })} · likely ${formatDay(next.earliest)} – ${formatDay(next.latest)}`)
];
}
const facts = [];
if (current.onPeriod || late) {
const upcoming = late ? predictions[1] : next;
facts.push(fact("key-predicted", "Next predicted period", `${formatDay(upcoming.start)} · likely ${formatDay(upcoming.earliest)} – ${formatDay(upcoming.latest)}`));
}
const window = summary.fertile.find(item => item.end >= today);
if (window) {
const inWindow = window.start <= today;
facts.push(fact(
"key-ovulation",
inWindow ? "Fertile window now (estimate)" : "Next fertile window (estimate)",
inWindow
? `Until ${formatDay(window.end)} · ovulation around ${formatDay(window.ovulation)}`
: `${formatDay(window.start)} – ${formatDay(window.end)} · ovulation around ${formatDay(window.ovulation)}`
));
}
const note = confidence === "none"
? "Log another period to personalise predictions. Until then they assume a 28-day cycle."
: confidence === "low"
? `Based on ${plural(sampleSize, "cycle")} so far. Predictions get sharper after three.`
: null;
hero.replaceChildren(...[...headline, facts.length && h("div", { class: "facts" }, facts), note && h("p", { class: "note" }, note)].filter(Boolean));
}
function fact(keyClass, title, text) {
return h("div", { class: "fact" }, h("span", { class: `key ${keyClass}` }), h("div", {}, h("strong", {}, title), h("span", {}, text)));
}
function renderTodayLog() {
const card = $("today-log");
card.hidden = !detailsVisible();
if (card.hidden) return;
card.replaceChildren(h("h2", {}, editable() ? "How's today?" : "Today"), h("p", { class: "subtitle" }, longDay(Cycle.toDay(todayIso()))), logEditor(todayIso()));
}
// ---- Calendar ------------------------------------------------------------
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 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)) {
const kinds = [];
if (marks.period.has(day)) kinds.push("period");
else if (marks.predicted.has(day)) kinds.push("predicted");
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");
const dots = [];
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 described = [
marks.period.has(day) && "period",
marks.predicted.has(day) && !marks.period.has(day) && "predicted period",
marks.fertile.has(day) && "fertile window estimate",
marks.ovulation.has(day) && "estimated ovulation",
marks.spotting.has(day) && "spotting",
marks.symptoms.has(day) && "symptoms logged"
].filter(Boolean);
cells.push(h("button", {
type: "button",
class: ["day", ...kinds].join(" "),
"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));
}
$("month-grid").replaceChildren(...cells);
}
function shiftMonth(delta) {
const { year, month } = state.month;
const next = new Date(Date.UTC(year, month + delta, 1));
state.month = { year: next.getUTCFullYear(), month: next.getUTCMonth() };
render();
}
function openSheet(day) {
state.sheetDay = day;
renderSheet(Cycle.summarize(state.days, todayIso()));
const sheet = $("day-sheet");
if (!sheet.open) sheet.showModal();
}
function renderSheet(summary) {
const day = state.sheetDay;
if (day === null) return;
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.ovulation.has(day)) notes.push("Estimated ovulation day.");
else if (marks.fertile.has(day)) notes.push("Inside the estimated fertile window.");
const body = [];
if (notes.length) body.push(h("p", { class: "prediction-note" }, notes.join(" ")));
if (day > summary.today) {
if (!notes.length) body.push(h("p", { class: "prediction-note" }, "Nothing predicted for this day."));
} else if (detailsVisible()) {
body.push(logEditor(Cycle.toIso(day)));
} else {
body.push(h("p", { class: "prediction-note" }, marks.period.has(day) ? "Period day." : "Nothing logged."));
}
$("sheet-body").replaceChildren(...body);
}
// ---- Stats ---------------------------------------------------------------
function tile(label, value, unit) {
return h("div", { class: "tile" }, h("p", { class: "label" }, label), h("p", { class: "value" }, value, unit && h("small", {}, ` ${unit}`)));
}
function renderStats(summary) {
const { sampleSize, cycleLength, cycleSpread, periodLength, cycles, current, today } = summary;
$("tiles").replaceChildren(
tile("Average cycle", sampleSize ? cycleLength : "–", sampleSize ? "days" : ""),
tile("Cycle variation", sampleSize > 1 ? `±${Math.max(1, Math.round(cycleSpread))}` : "–", sampleSize > 1 ? "days" : ""),
tile("Average period", current ? periodLength : "–", current ? "days" : ""),
tile("Cycles tracked", sampleSize)
);
renderChart(cycles.filter(cycle => cycle.used).slice(-12), summary);
const rows = [];
if (current) {
rows.push(h("tr", {},
h("td", {}, formatDay(current.start, { day: "numeric", month: "short", year: "numeric" })),
h("td", {}, `Day ${current.cycleDay} so far`),
h("td", {}, current.end >= today - 1 ? "Ongoing" : plural(current.end - current.start + 1, "day"))
));
}
for (const cycle of cycles.slice().reverse()) {
rows.push(h("tr", { class: cycle.used ? null : "excluded" },
h("td", {}, formatDay(cycle.start, { day: "numeric", month: "short", year: "numeric" })),
h("td", {}, plural(cycle.length, "day"), !cycle.used && h("small", {}, "Not used: likely a missed log")),
h("td", {}, plural(cycle.periodLength, "day"))
));
}
if (!rows.length) rows.push(h("tr", {}, h("td", { colspan: "3" }, "Nothing logged yet.")));
$("history").replaceChildren(...rows);
}
function renderChart(data, summary) {
const container = $("chart");
if (data.length < 2) {
container.replaceChildren(h("p", { class: "chart-empty" }, "The chart appears once two full cycles are logged."));
return;
}
const svgNs = "http://www.w3.org/2000/svg";
const s = (tag, attributes = {}) => {
const element = document.createElementNS(svgNs, tag);
for (const [name, value] of Object.entries(attributes)) element.setAttribute(name, value);
return element;
};
const width = 320;
const height = 170;
const left = 26;
const top = 16;
const bottom = 22;
const plotHeight = height - top - bottom;
const ceiling = Math.max(35, Math.ceil(Math.max(...data.map(cycle => cycle.length)) / 10) * 10);
const y = value => top + plotHeight - (value / ceiling) * plotHeight;
const band = (width - left) / data.length;
const barWidth = Math.min(24, band - 4);
const svg = s("svg", { viewBox: `0 0 ${width} ${height}`, role: "img", "aria-label": `Cycle lengths for the last ${data.length} cycles; the history table below lists every value.` });
for (let tick = 0; tick <= ceiling; tick += 10) {
svg.append(s("line", { class: "grid", x1: left, x2: width, y1: y(tick), y2: y(tick) }));
const label = s("text", { class: "axis-label", x: left - 6, y: y(tick) + 4, "text-anchor": "end" });
label.textContent = tick;
svg.append(label);
}
const tooltip = h("div", { class: "tooltip", hidden: true });
const show = (index, x) => {
const cycle = data[index];
tooltip.textContent = `${formatDay(cycle.start)} · ${plural(cycle.length, "day")}`;
tooltip.style.left = `${(x / width) * 100}%`;
tooltip.style.top = `${(y(cycle.length) / height) * 100}%`;
tooltip.hidden = false;
};
data.forEach((cycle, index) => {
const x = left + band * index + (band - barWidth) / 2;
const yTop = y(cycle.length);
const base = y(0);
const radius = Math.min(4, base - yTop);
// Rounded at the data end, square at the baseline.
svg.append(s("path", {
class: "bar",
d: `M${x},${base}V${yTop + radius}Q${x},${yTop} ${x + radius},${yTop}H${x + barWidth - radius}Q${x + barWidth},${yTop} ${x + barWidth},${yTop + radius}V${base}Z`
}));
const hit = s("rect", { class: "hit", x: left + band * index, y: top, width: band, height: plotHeight });
hit.addEventListener("pointerenter", () => show(index, x + barWidth / 2));
hit.addEventListener("click", () => show(index, x + barWidth / 2));
hit.addEventListener("pointerleave", () => { tooltip.hidden = true; });
svg.append(hit);
});
const average = s("line", { class: "avg", x1: left, x2: width, y1: y(summary.cycleLength), y2: y(summary.cycleLength) });
const averageLabel = s("text", { class: "avg-label", x: width, y: y(summary.cycleLength) - 5, "text-anchor": "end" });
averageLabel.textContent = `avg ${summary.cycleLength}`;
svg.insertBefore(average, svg.querySelector(".hit"));
svg.insertBefore(averageLabel, svg.querySelector(".hit"));
for (const index of [0, data.length - 1]) {
const label = s("text", { class: "axis-label", x: left + band * index + band / 2, y: height - 6, "text-anchor": "middle" });
label.textContent = formatDay(data[index].start, { month: "short", year: "numeric" });
svg.append(label);
}
container.replaceChildren(svg, tooltip);
}
// ---- Settings ------------------------------------------------------------
function renderSettings() {
$("share-toggle").checked = state.shareDetails;
}
// ---- Shell ---------------------------------------------------------------
function setView(view) {
state.view = view;
for (const button of document.querySelectorAll(".tabs button")) {
button.setAttribute("aria-selected", String(button.dataset.view === view));
}
for (const section of document.querySelectorAll(".view")) section.hidden = section.id !== `view-${view}`;
window.scrollTo(0, 0);
}
function render() {
if (!state.role) return;
const summary = Cycle.summarize(state.days, todayIso());
const viewer = !editable();
$("role-badge").hidden = !viewer;
$("tab-settings").hidden = viewer;
document.querySelector(".tabs").classList.toggle("three", viewer);
if (viewer && state.view === "settings") setView("today");
renderHero(summary);
renderTodayLog();
renderCalendar(summary);
renderStats(summary);
renderSettings();
renderSheet(summary);
}
function resetMonth() {
const now = new Date();
state.month = { year: now.getFullYear(), month: now.getMonth() };
}
resetMonth();
for (const button of document.querySelectorAll(".tabs button")) {
button.addEventListener("click", () => setView(button.dataset.view));
}
const linked = window.location.hash.slice(1);
if (["calendar", "stats", "settings"].includes(linked)) setView(linked);
$("month-prev").addEventListener("click", () => shiftMonth(-1));
$("month-next").addEventListener("click", () => shiftMonth(1));
$("month-today").addEventListener("click", () => { resetMonth(); render(); });
$("day-sheet").addEventListener("close", () => { state.sheetDay = null; });
$("day-sheet").addEventListener("click", event => {
if (event.target === event.currentTarget) event.currentTarget.close();
});
$("share-toggle").addEventListener("change", async event => {
const shareDetails = event.target.checked;
try {
await sendJson("PUT", "/api/settings", { shareDetails });
state.shareDetails = shareDetails;
showBanner(shareDetails ? "Flow and symptoms are now shared." : "Flow and symptoms are private again.");
} catch (error) {
event.target.checked = !shareDetails;
showBanner(`Couldn't save: ${error.message}`, true);
}
});
seedForm($("seed-form"));
// The home-screen app is often resumed rather than reopened, so refresh
// whenever it comes back to the foreground (this also rolls "today" over).
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") load();
});
load();
})();