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