flowtrack/static/app.js
rope ce84bee359 Open-ended predictions, faded past six weeks; upcoming periods list
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 09:58:55 +01:00

765 lines
34 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 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 predictions up to
// `until` (the last day on screen; predictions have no horizon).
function dayMarks(summary, until) {
const marks = {
period: new Set(), spotting: new Set(), symptoms: new Set(), predicted: new Set(), fertile: new Set(), ovulation: new Set(),
// Predictions far enough ahead to be shown as less certain.
faded: new Set(),
// day -> the predicted period it belongs to.
prediction: new Map(),
// 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, today, periodLength, flowProfile } = summary;
const { predictions, fertile } = Cycle.forecast(summary, until);
const predict = (from, to, start, prediction) => {
for (const day of range(from, to)) {
if (marks.period.has(day)) continue;
marks.predicted.add(day);
if (prediction) marks.prediction.set(day, prediction);
if (prediction && prediction.faded) marks.faded.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, null);
for (const prediction of predictions) predict(Math.max(prediction.start, today + 1), prediction.end, prediction.start, prediction);
for (const window of fertile) {
for (const day of range(window.start, window.end)) {
marks.fertile.add(day);
if (window.faded) marks.faded.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 first = Cycle.toDay(`${year}-${pad(month + 1)}-01`);
const count = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
const marks = dayMarks(summary, first + count - 1);
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");
if (marks.faded.has(day)) kinds.push("faded");
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",
marks.faded.has(day) && "less certain this far ahead"
].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, day);
$("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.");
const prediction = marks.prediction.get(day);
if (prediction) notes.push(`This period is expected to start around ${formatDay(prediction.start)}, likely between ${formatDay(prediction.earliest)} and ${formatDay(prediction.latest)}.`);
}
if (marks.ovulation.has(day)) notes.push("Estimated ovulation day.");
else if (marks.fertile.has(day)) notes.push("Inside the estimated fertile window.");
if (marks.faded.has(day)) notes.push("This far ahead the prediction is less certain.");
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);
renderUpcoming(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);
}
// A year of predictions as a list, for planning travel and the like.
function renderUpcoming(summary) {
const { predictions } = Cycle.forecast(summary, summary.today + 365, 1);
const thisYear = new Date(summary.today * 86400000).getUTCFullYear();
// Built by hand: en-GB adds a comma after the weekday once a year is included.
const dated = number => {
const year = new Date(number * 86400000).getUTCFullYear();
const day = formatDay(number, { weekday: "short", day: "numeric", month: "short" });
return year === thisYear ? day : `${day} ${year}`;
};
const rows = predictions.map(prediction => h("li", { class: prediction.faded ? "faded" : null },
h("span", { class: "when" }, dated(prediction.start)),
h("span", { class: "likely" }, `likely ${formatDay(prediction.earliest)} – ${formatDay(prediction.latest)}`, prediction.faded && h("small", {}, "less certain"))
));
$("upcoming").replaceChildren(...(rows.length ? rows : [h("li", {}, "Log a period to see predictions.")]));
}
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();
})();