(() => { const Cycle = window.Cycle; const STRENGTHS = [ ["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, push: null, // This device's PushSubscription: undefined until checked, then null or set. device: undefined }; 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, push: payload.push }); if (!$("banner").classList.contains("error")) showBanner(""); render(); if (state.device === undefined) await checkDevice(); } 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 isPeriod = Cycle.PERIOD_FLOWS.has(entry.flow); const update = change => saveDay(iso, { flow: entry.flow, symptoms: entry.symptoms, ...change }); 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", { 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", {}, "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(), // 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, 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); } return marks; } function seedForm(form) { const today = todayIso(); 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( 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(); // 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 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: [] }; } } 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."); for (const input of rows.querySelectorAll("input")) input.value = ""; 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 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; } 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")}`), 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"), 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)); } 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))); } 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 level = marks.level.get(day); const described = [ marks.period.has(day) && "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", 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" }), level || dots.length ? h("span", { class: "marks" }, level && flowBars(level), 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)) { 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 = []; 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 ------------------------------------------------------------ // ---- Notifications ------------------------------------------------------- const isIos = () => /iPhone|iPad|iPod/.test(navigator.userAgent) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); const installed = () => window.navigator.standalone === true || window.matchMedia("(display-mode: standalone)").matches; const pushSupported = () => "serviceWorker" in navigator && "PushManager" in window && "Notification" in window; const keyBytes = key => Uint8Array.from(atob(key.replace(/-/g, "+").replace(/_/g, "/")), char => char.charCodeAt(0)); const keyText = buffer => btoa(String.fromCharCode(...new Uint8Array(buffer))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); // serviceWorker.ready never settles if registration failed, so give up. const workerReady = () => Promise.race([ navigator.serviceWorker.ready, new Promise((_, reject) => setTimeout(() => reject(new Error("the notification worker did not start")), 8000)) ]); async function checkDevice() { if (!editable() || !state.push || !state.push.available || !pushSupported()) { state.device = null; return; } try { const registration = await workerReady(); let subscription = await registration.pushManager.getSubscription(); // A subscription made for a different server key can never be // delivered to, so drop it rather than show notifications as on. if (subscription && subscription.options.applicationServerKey && keyText(subscription.options.applicationServerKey) !== state.push.publicKey) { await subscription.unsubscribe(); subscription = null; } // Re-register on every launch so the server copy survives a restore. if (subscription) await sendJson("POST", "/api/push/subscribe", subscription.toJSON()); state.device = subscription; } catch { state.device = null; } render(); } async function enablePush() { try { // iOS only shows the permission prompt straight from a tap, so this // must be the first thing awaited in the click handler. const permission = await Notification.requestPermission(); if (permission !== "granted") { showBanner("Notifications were not allowed.", true); render(); return; } const registration = await workerReady(); const subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: keyBytes(state.push.publicKey) }); const { devices } = await sendJson("POST", "/api/push/subscribe", subscription.toJSON()); state.device = subscription; state.push.devices = devices; showBanner("Notifications are on. Send a test to check."); } catch (error) { showBanner(`Couldn't turn on notifications: ${error.message}`, true); } render(); } async function disablePush() { try { const { devices } = await sendJson("POST", "/api/push/unsubscribe", { endpoint: state.device.endpoint }); await state.device.unsubscribe(); state.device = null; state.push.devices = devices; showBanner("Notifications are off on this device."); } catch (error) { showBanner(`Couldn't turn off notifications: ${error.message}`, true); } render(); } async function testPush(button) { button.disabled = true; try { const result = await api("/api/push/test", { method: "POST" }); if (result.sent) showBanner(`Test sent to ${plural(result.sent, "device")}. It should arrive within a few seconds.`); else showBanner(result.errors[0] ? `Test failed: ${result.errors[0]}` : "No devices are signed up for notifications.", true); } catch (error) { showBanner(`Test failed: ${error.message}`, true); } finally { button.disabled = false; } } async function setRemindDays(remindDays) { try { await sendJson("PUT", "/api/settings", { remindDays }); state.push.remindDays = remindDays; showBanner(`You'll be reminded ${plural(remindDays, "day")} before.`); render(); } catch (error) { showBanner(`Couldn't save: ${error.message}`, true); } } function renderNotifications() { const body = $("notify-body"); const note = text => h("p", { class: "notify-note" }, text); if (!state.push || !state.push.available) { body.replaceChildren(note("Notifications are not set up on the server yet.")); return; } if (!pushSupported()) { body.replaceChildren(note(isIos() && !installed() ? "On iPhone, notifications only work from the Home Screen app. In Safari tap Share, then Add to Home Screen, and open Flowtrack from there." : "This browser does not support notifications.")); return; } if (Notification.permission === "denied") { body.replaceChildren(note(isIos() ? "Notifications are blocked. Turn them on in the iPhone Settings app under Notifications, Flowtrack." : "Notifications are blocked for this site in the browser settings.")); return; } if (state.device === undefined) { body.replaceChildren(note("Checking…")); return; } const days = h("select", { "aria-label": "Days before" }, range(1, 7).map(count => h("option", { value: String(count), selected: count === state.push.remindDays }, String(count)) )); days.addEventListener("change", () => setRemindDays(Number(days.value))); const remind = h("label", { class: "remind-row" }, "Remind me", days, state.push.remindDays === 1 ? "day before" : "days before"); if (!state.device) { body.replaceChildren( h("button", { type: "button", class: "button", onclick: enablePush }, "Turn on notifications"), remind ); return; } const test = h("button", { type: "button", class: "button" }, "Send test notification"); test.addEventListener("click", () => testPush(test)); body.replaceChildren( h("p", { class: "notify-status" }, h("span", { class: "status-dot" }), "On for this device"), remind, h("div", { class: "button-row" }, test, h("button", { type: "button", class: "button secondary", onclick: disablePush }, "Turn off")) ); } function renderSettings(summary) { renderNotifications(); $("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 --------------------------------------------------------------- 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(summary); 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")); if ("serviceWorker" in navigator) navigator.serviceWorker.register("/sw.js").catch(() => {}); // 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(); })();