765 lines
34 KiB
JavaScript
765 lines
34 KiB
JavaScript
(() => {
|
||
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();
|
||
})();
|