flowtrack/static/app.js

536 lines
23 KiB
JavaScript
Raw Normal View History

(() => {
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();
})();