536 lines
23 KiB
JavaScript
536 lines
23 KiB
JavaScript
|
|
(() => {
|
|||
|
|
const Cycle = window.Cycle;
|
|||
|
|
const FLOWS = [
|
|||
|
|
[null, "None"],
|
|||
|
|
["spotting", "Spotting"],
|
|||
|
|
["light", "Light"],
|
|||
|
|
["medium", "Medium"],
|
|||
|
|
["heavy", "Heavy"]
|
|||
|
|
];
|
|||
|
|
const MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
|
|||
|
|
|
|||
|
|
const state = {
|
|||
|
|
role: null,
|
|||
|
|
shareDetails: false,
|
|||
|
|
symptoms: [],
|
|||
|
|
days: {},
|
|||
|
|
view: "today",
|
|||
|
|
month: null,
|
|||
|
|
sheetDay: null
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const $ = id => document.getElementById(id);
|
|||
|
|
|
|||
|
|
function h(tag, attributes = {}, ...children) {
|
|||
|
|
const element = document.createElement(tag);
|
|||
|
|
for (const [name, value] of Object.entries(attributes)) {
|
|||
|
|
if (value === false || value === null || value === undefined) continue;
|
|||
|
|
if (name === "class") element.className = value;
|
|||
|
|
else if (name.startsWith("on")) element.addEventListener(name.slice(2), value);
|
|||
|
|
else element.setAttribute(name, value === true ? "" : value);
|
|||
|
|
}
|
|||
|
|
for (const child of children.flat()) {
|
|||
|
|
if (child === null || child === undefined || child === false) continue;
|
|||
|
|
element.append(child instanceof Node ? child : document.createTextNode(String(child)));
|
|||
|
|
}
|
|||
|
|
return element;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const pad = number => String(number).padStart(2, "0");
|
|||
|
|
const todayIso = () => {
|
|||
|
|
const now = new Date();
|
|||
|
|
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
|||
|
|
};
|
|||
|
|
const formatDay = (number, options = { day: "numeric", month: "short" }) =>
|
|||
|
|
new Date(number * 86400000).toLocaleDateString("en-GB", { ...options, timeZone: "UTC" });
|
|||
|
|
const longDay = number => formatDay(number, { weekday: "long", day: "numeric", month: "long" });
|
|||
|
|
const plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
|
|||
|
|
const range = (from, to) => Array.from({ length: Math.max(0, to - from + 1) }, (_, index) => from + index);
|
|||
|
|
|
|||
|
|
// ---- Server --------------------------------------------------------------
|
|||
|
|
|
|||
|
|
async function api(path, options = {}) {
|
|||
|
|
// An expired Authelia session answers with a redirect to the login portal,
|
|||
|
|
// which fetch cannot follow cross-origin. Reloading lets the browser do it.
|
|||
|
|
const response = await fetch(path, { redirect: "manual", credentials: "same-origin", ...options });
|
|||
|
|
if (response.type === "opaqueredirect") {
|
|||
|
|
window.location.reload();
|
|||
|
|
throw new Error("Signing in again…");
|
|||
|
|
}
|
|||
|
|
const payload = await response.json().catch(() => ({}));
|
|||
|
|
if (!response.ok) throw new Error(payload.error || `Request failed (${response.status})`);
|
|||
|
|
return payload;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const sendJson = (method, path, body) =>
|
|||
|
|
api(path, { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
|||
|
|
|
|||
|
|
function showBanner(message, isError = false) {
|
|||
|
|
const banner = $("banner");
|
|||
|
|
banner.textContent = message;
|
|||
|
|
banner.classList.toggle("error", isError);
|
|||
|
|
banner.hidden = !message;
|
|||
|
|
clearTimeout(showBanner.timer);
|
|||
|
|
if (message && !isError) showBanner.timer = setTimeout(() => { banner.hidden = true; }, 4000);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function load() {
|
|||
|
|
try {
|
|||
|
|
const payload = await api("/api/state");
|
|||
|
|
Object.assign(state, {
|
|||
|
|
role: payload.role,
|
|||
|
|
shareDetails: payload.shareDetails,
|
|||
|
|
symptoms: payload.symptoms,
|
|||
|
|
days: payload.days
|
|||
|
|
});
|
|||
|
|
if (!$("banner").classList.contains("error")) showBanner("");
|
|||
|
|
render();
|
|||
|
|
} catch (error) {
|
|||
|
|
showBanner(error.message === "Not allowed" ? "This account does not have access to Flowtrack." : error.message, true);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function saveDay(iso, entry) {
|
|||
|
|
const previous = state.days[iso];
|
|||
|
|
if (entry.flow === null && !entry.symptoms.length) delete state.days[iso];
|
|||
|
|
else state.days[iso] = entry;
|
|||
|
|
render();
|
|||
|
|
try {
|
|||
|
|
await sendJson("PUT", `/api/days/${iso}`, entry);
|
|||
|
|
} catch (error) {
|
|||
|
|
if (previous) state.days[iso] = previous;
|
|||
|
|
else delete state.days[iso];
|
|||
|
|
render();
|
|||
|
|
showBanner(`Couldn't save: ${error.message}`, true);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---- Shared pieces -------------------------------------------------------
|
|||
|
|
|
|||
|
|
const editable = () => state.role === "editor";
|
|||
|
|
const detailsVisible = () => editable() || state.shareDetails;
|
|||
|
|
|
|||
|
|
function logEditor(iso) {
|
|||
|
|
const entry = state.days[iso] || { flow: null, symptoms: [] };
|
|||
|
|
const canEdit = editable();
|
|||
|
|
const flows = FLOWS.slice();
|
|||
|
|
// "period" is a period day without an intensity (added from past dates).
|
|||
|
|
if (entry.flow === "period") flows.push(["period", "Period"]);
|
|||
|
|
const update = change => saveDay(iso, { flow: entry.flow, symptoms: entry.symptoms, ...change });
|
|||
|
|
const flowChips = flows.map(([flow, label]) =>
|
|||
|
|
h("button", {
|
|||
|
|
type: "button",
|
|||
|
|
class: "chip",
|
|||
|
|
"aria-pressed": String(entry.flow === flow),
|
|||
|
|
disabled: !canEdit,
|
|||
|
|
onclick: () => update({ flow })
|
|||
|
|
}, label)
|
|||
|
|
);
|
|||
|
|
const symptomChips = state.symptoms.map(({ id, label }) => {
|
|||
|
|
const active = entry.symptoms.includes(id);
|
|||
|
|
return h("button", {
|
|||
|
|
type: "button",
|
|||
|
|
class: "chip symptom",
|
|||
|
|
"aria-pressed": String(active),
|
|||
|
|
disabled: !canEdit,
|
|||
|
|
onclick: () => update({
|
|||
|
|
symptoms: active ? entry.symptoms.filter(item => item !== id) : [...entry.symptoms, id]
|
|||
|
|
})
|
|||
|
|
}, label);
|
|||
|
|
});
|
|||
|
|
return h("div", {},
|
|||
|
|
h("section", { class: "log-section" }, h("h3", {}, "Flow"), h("div", { class: "chips" }, flowChips)),
|
|||
|
|
h("section", { class: "log-section" }, h("h3", {}, "Symptoms"), h("div", { class: "chips" }, symptomChips))
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Which calendar days carry which marker, from logs and the summary.
|
|||
|
|
function dayMarks(summary) {
|
|||
|
|
const marks = { period: new Set(), spotting: new Set(), symptoms: new Set(), predicted: new Set(), fertile: new Set(), ovulation: new Set() };
|
|||
|
|
for (const [iso, entry] of Object.entries(state.days)) {
|
|||
|
|
const day = Cycle.toDay(iso);
|
|||
|
|
if (Cycle.PERIOD_FLOWS.has(entry.flow)) marks.period.add(day);
|
|||
|
|
if (entry.flow === "spotting") marks.spotting.add(day);
|
|||
|
|
if (entry.symptoms.length) marks.symptoms.add(day);
|
|||
|
|
}
|
|||
|
|
const { current, predictions, fertile, today, periodLength } = summary;
|
|||
|
|
if (current && current.onPeriod) {
|
|||
|
|
for (const day of range(today + 1, current.start + periodLength - 1)) marks.predicted.add(day);
|
|||
|
|
}
|
|||
|
|
for (const prediction of predictions) {
|
|||
|
|
for (const day of range(Math.max(prediction.start, today + 1), prediction.end)) marks.predicted.add(day);
|
|||
|
|
}
|
|||
|
|
for (const window of fertile) {
|
|||
|
|
for (const day of range(window.start, window.end)) marks.fertile.add(day);
|
|||
|
|
marks.ovulation.add(window.ovulation);
|
|||
|
|
}
|
|||
|
|
return marks;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function seedForm(form) {
|
|||
|
|
const today = todayIso();
|
|||
|
|
const dateInput = () => h("input", { type: "date", max: today, "aria-label": "Period start date" });
|
|||
|
|
const dates = h("div", { class: "seed-dates" }, dateInput(), dateInput(), dateInput());
|
|||
|
|
const length = h("input", { type: "number", min: "1", max: "10", value: "5", inputmode: "numeric", class: "seed-length" });
|
|||
|
|
const submit = h("button", { type: "submit", class: "button" }, "Save periods");
|
|||
|
|
form.replaceChildren(
|
|||
|
|
h("label", {}, "Start dates", dates),
|
|||
|
|
h("button", { type: "button", class: "link-button", onclick: () => dates.append(dateInput()) }, "+ Add another date"),
|
|||
|
|
h("label", {}, "Usual length in days", length),
|
|||
|
|
submit
|
|||
|
|
);
|
|||
|
|
form.onsubmit = async event => {
|
|||
|
|
event.preventDefault();
|
|||
|
|
const days = Math.min(10, Math.max(1, Number(length.value) || 5));
|
|||
|
|
const todayNumber = Cycle.toDay(today);
|
|||
|
|
const entries = {};
|
|||
|
|
for (const input of dates.querySelectorAll("input")) {
|
|||
|
|
if (!input.value) continue;
|
|||
|
|
const start = Cycle.toDay(input.value);
|
|||
|
|
for (const day of range(start, Math.min(start + days - 1, todayNumber))) {
|
|||
|
|
entries[Cycle.toIso(day)] = { flow: "period", symptoms: [] };
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (!Object.keys(entries).length) {
|
|||
|
|
showBanner("Pick at least one start date.", true);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
submit.disabled = true;
|
|||
|
|
try {
|
|||
|
|
const { added } = await sendJson("POST", "/api/days", { days: entries });
|
|||
|
|
showBanner(added ? `Added ${plural(added, "day")}.` : "Those days were already logged.");
|
|||
|
|
await load();
|
|||
|
|
} catch (error) {
|
|||
|
|
showBanner(`Couldn't save: ${error.message}`, true);
|
|||
|
|
} finally {
|
|||
|
|
submit.disabled = false;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
return form;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---- Today ---------------------------------------------------------------
|
|||
|
|
|
|||
|
|
function renderHero(summary) {
|
|||
|
|
const hero = $("hero");
|
|||
|
|
hero.className = "hero card";
|
|||
|
|
const { current, predictions, late, today, confidence, sampleSize } = summary;
|
|||
|
|
if (!current) {
|
|||
|
|
hero.replaceChildren(...[
|
|||
|
|
h("p", { class: "eyebrow" }, "Welcome"),
|
|||
|
|
h("p", { class: "lead" }, editable() ? "When did your recent periods start?" : "Nothing has been logged yet."),
|
|||
|
|
editable() && h("p", { class: "detail" }, "Two or three dates are enough to start predicting. You can add more later in Settings."),
|
|||
|
|
editable() && seedForm(h("form", { class: "seed-form facts" }))
|
|||
|
|
].filter(Boolean));
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const next = predictions[0];
|
|||
|
|
const untilNext = next.start - today;
|
|||
|
|
let headline;
|
|||
|
|
if (current.onPeriod) {
|
|||
|
|
hero.classList.add("on-period");
|
|||
|
|
headline = [
|
|||
|
|
h("p", { class: "eyebrow" }, "Period"),
|
|||
|
|
h("p", { class: "figure" }, "Day ", today - current.start + 1),
|
|||
|
|
h("p", { class: "lead" }, `Usually lasts about ${plural(summary.periodLength, "day")}`)
|
|||
|
|
];
|
|||
|
|
} else if (late) {
|
|||
|
|
headline = [
|
|||
|
|
h("p", { class: "eyebrow" }, "Period expected"),
|
|||
|
|
h("p", { class: "figure" }, late, h("small", {}, late === 1 ? " day late" : " days late")),
|
|||
|
|
h("p", { class: "lead" }, `It was expected on ${formatDay(today - late, { weekday: "short", day: "numeric", month: "short" })}`),
|
|||
|
|
h("p", { class: "detail" }, "A few days either way is common. Log it here when it starts.")
|
|||
|
|
];
|
|||
|
|
} else {
|
|||
|
|
headline = [
|
|||
|
|
h("p", { class: "eyebrow" }, "Cycle day"),
|
|||
|
|
h("p", { class: "figure" }, current.cycleDay),
|
|||
|
|
h("p", { class: "lead" }, untilNext === 0 ? "Period expected today" : `Next period in about ${plural(untilNext, "day")}`),
|
|||
|
|
h("p", { class: "detail" }, `Around ${formatDay(next.start, { weekday: "short", day: "numeric", month: "short" })} · likely ${formatDay(next.earliest)} – ${formatDay(next.latest)}`)
|
|||
|
|
];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const facts = [];
|
|||
|
|
if (current.onPeriod || late) {
|
|||
|
|
const upcoming = late ? predictions[1] : next;
|
|||
|
|
facts.push(fact("key-predicted", "Next predicted period", `${formatDay(upcoming.start)} · likely ${formatDay(upcoming.earliest)} – ${formatDay(upcoming.latest)}`));
|
|||
|
|
}
|
|||
|
|
const window = summary.fertile.find(item => item.end >= today);
|
|||
|
|
if (window) {
|
|||
|
|
const inWindow = window.start <= today;
|
|||
|
|
facts.push(fact(
|
|||
|
|
"key-ovulation",
|
|||
|
|
inWindow ? "Fertile window now (estimate)" : "Next fertile window (estimate)",
|
|||
|
|
inWindow
|
|||
|
|
? `Until ${formatDay(window.end)} · ovulation around ${formatDay(window.ovulation)}`
|
|||
|
|
: `${formatDay(window.start)} – ${formatDay(window.end)} · ovulation around ${formatDay(window.ovulation)}`
|
|||
|
|
));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const note = confidence === "none"
|
|||
|
|
? "Log another period to personalise predictions. Until then they assume a 28-day cycle."
|
|||
|
|
: confidence === "low"
|
|||
|
|
? `Based on ${plural(sampleSize, "cycle")} so far. Predictions get sharper after three.`
|
|||
|
|
: null;
|
|||
|
|
hero.replaceChildren(...[...headline, facts.length && h("div", { class: "facts" }, facts), note && h("p", { class: "note" }, note)].filter(Boolean));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function fact(keyClass, title, text) {
|
|||
|
|
return h("div", { class: "fact" }, h("span", { class: `key ${keyClass}` }), h("div", {}, h("strong", {}, title), h("span", {}, text)));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderTodayLog() {
|
|||
|
|
const card = $("today-log");
|
|||
|
|
card.hidden = !detailsVisible();
|
|||
|
|
if (card.hidden) return;
|
|||
|
|
card.replaceChildren(h("h2", {}, editable() ? "How's today?" : "Today"), h("p", { class: "subtitle" }, longDay(Cycle.toDay(todayIso()))), logEditor(todayIso()));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---- Calendar ------------------------------------------------------------
|
|||
|
|
|
|||
|
|
function renderCalendar(summary) {
|
|||
|
|
const { year, month } = state.month;
|
|||
|
|
$("month-title").textContent = `${MONTHS[month]} ${year}`;
|
|||
|
|
const marks = dayMarks(summary);
|
|||
|
|
const first = Cycle.toDay(`${year}-${pad(month + 1)}-01`);
|
|||
|
|
const count = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
|
|||
|
|
const leading = (new Date(first * 86400000).getUTCDay() + 6) % 7;
|
|||
|
|
const cells = range(1, leading).map(() => h("span", { class: "day outside", "aria-hidden": "true" }));
|
|||
|
|
for (const day of range(first, first + count - 1)) {
|
|||
|
|
const kinds = [];
|
|||
|
|
if (marks.period.has(day)) kinds.push("period");
|
|||
|
|
else if (marks.predicted.has(day)) kinds.push("predicted");
|
|||
|
|
if (marks.fertile.has(day) && !marks.period.has(day)) kinds.push("fertile");
|
|||
|
|
if (day === summary.today) kinds.push("today");
|
|||
|
|
if (day > summary.today) kinds.push("future");
|
|||
|
|
const dots = [];
|
|||
|
|
if (marks.ovulation.has(day)) dots.push("ovulation");
|
|||
|
|
if (marks.spotting.has(day)) dots.push("spotting");
|
|||
|
|
if (marks.symptoms.has(day)) dots.push("symptoms");
|
|||
|
|
const described = [
|
|||
|
|
marks.period.has(day) && "period",
|
|||
|
|
marks.predicted.has(day) && !marks.period.has(day) && "predicted period",
|
|||
|
|
marks.fertile.has(day) && "fertile window estimate",
|
|||
|
|
marks.ovulation.has(day) && "estimated ovulation",
|
|||
|
|
marks.spotting.has(day) && "spotting",
|
|||
|
|
marks.symptoms.has(day) && "symptoms logged"
|
|||
|
|
].filter(Boolean);
|
|||
|
|
cells.push(h("button", {
|
|||
|
|
type: "button",
|
|||
|
|
class: ["day", ...kinds].join(" "),
|
|||
|
|
"aria-label": [longDay(day), ...described].join(", "),
|
|||
|
|
"aria-current": day === summary.today ? "date" : null,
|
|||
|
|
onclick: () => openSheet(day)
|
|||
|
|
}, formatDay(day, { day: "numeric" }), dots.length ? h("span", { class: "marks" }, dots.map(kind => h("span", { class: `mark ${kind}` }))) : null));
|
|||
|
|
}
|
|||
|
|
$("month-grid").replaceChildren(...cells);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function shiftMonth(delta) {
|
|||
|
|
const { year, month } = state.month;
|
|||
|
|
const next = new Date(Date.UTC(year, month + delta, 1));
|
|||
|
|
state.month = { year: next.getUTCFullYear(), month: next.getUTCMonth() };
|
|||
|
|
render();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function openSheet(day) {
|
|||
|
|
state.sheetDay = day;
|
|||
|
|
renderSheet(Cycle.summarize(state.days, todayIso()));
|
|||
|
|
const sheet = $("day-sheet");
|
|||
|
|
if (!sheet.open) sheet.showModal();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderSheet(summary) {
|
|||
|
|
const day = state.sheetDay;
|
|||
|
|
if (day === null) return;
|
|||
|
|
const marks = dayMarks(summary);
|
|||
|
|
$("sheet-title").textContent = longDay(day);
|
|||
|
|
const notes = [];
|
|||
|
|
if (marks.predicted.has(day) && !marks.period.has(day)) notes.push("Predicted period day.");
|
|||
|
|
if (marks.ovulation.has(day)) notes.push("Estimated ovulation day.");
|
|||
|
|
else if (marks.fertile.has(day)) notes.push("Inside the estimated fertile window.");
|
|||
|
|
const body = [];
|
|||
|
|
if (notes.length) body.push(h("p", { class: "prediction-note" }, notes.join(" ")));
|
|||
|
|
if (day > summary.today) {
|
|||
|
|
if (!notes.length) body.push(h("p", { class: "prediction-note" }, "Nothing predicted for this day."));
|
|||
|
|
} else if (detailsVisible()) {
|
|||
|
|
body.push(logEditor(Cycle.toIso(day)));
|
|||
|
|
} else {
|
|||
|
|
body.push(h("p", { class: "prediction-note" }, marks.period.has(day) ? "Period day." : "Nothing logged."));
|
|||
|
|
}
|
|||
|
|
$("sheet-body").replaceChildren(...body);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---- Stats ---------------------------------------------------------------
|
|||
|
|
|
|||
|
|
function tile(label, value, unit) {
|
|||
|
|
return h("div", { class: "tile" }, h("p", { class: "label" }, label), h("p", { class: "value" }, value, unit && h("small", {}, ` ${unit}`)));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderStats(summary) {
|
|||
|
|
const { sampleSize, cycleLength, cycleSpread, periodLength, cycles, current, today } = summary;
|
|||
|
|
$("tiles").replaceChildren(
|
|||
|
|
tile("Average cycle", sampleSize ? cycleLength : "–", sampleSize ? "days" : ""),
|
|||
|
|
tile("Cycle variation", sampleSize > 1 ? `±${Math.max(1, Math.round(cycleSpread))}` : "–", sampleSize > 1 ? "days" : ""),
|
|||
|
|
tile("Average period", current ? periodLength : "–", current ? "days" : ""),
|
|||
|
|
tile("Cycles tracked", sampleSize)
|
|||
|
|
);
|
|||
|
|
renderChart(cycles.filter(cycle => cycle.used).slice(-12), summary);
|
|||
|
|
|
|||
|
|
const rows = [];
|
|||
|
|
if (current) {
|
|||
|
|
rows.push(h("tr", {},
|
|||
|
|
h("td", {}, formatDay(current.start, { day: "numeric", month: "short", year: "numeric" })),
|
|||
|
|
h("td", {}, `Day ${current.cycleDay} so far`),
|
|||
|
|
h("td", {}, current.end >= today - 1 ? "Ongoing" : plural(current.end - current.start + 1, "day"))
|
|||
|
|
));
|
|||
|
|
}
|
|||
|
|
for (const cycle of cycles.slice().reverse()) {
|
|||
|
|
rows.push(h("tr", { class: cycle.used ? null : "excluded" },
|
|||
|
|
h("td", {}, formatDay(cycle.start, { day: "numeric", month: "short", year: "numeric" })),
|
|||
|
|
h("td", {}, plural(cycle.length, "day"), !cycle.used && h("small", {}, "Not used: likely a missed log")),
|
|||
|
|
h("td", {}, plural(cycle.periodLength, "day"))
|
|||
|
|
));
|
|||
|
|
}
|
|||
|
|
if (!rows.length) rows.push(h("tr", {}, h("td", { colspan: "3" }, "Nothing logged yet.")));
|
|||
|
|
$("history").replaceChildren(...rows);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderChart(data, summary) {
|
|||
|
|
const container = $("chart");
|
|||
|
|
if (data.length < 2) {
|
|||
|
|
container.replaceChildren(h("p", { class: "chart-empty" }, "The chart appears once two full cycles are logged."));
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const svgNs = "http://www.w3.org/2000/svg";
|
|||
|
|
const s = (tag, attributes = {}) => {
|
|||
|
|
const element = document.createElementNS(svgNs, tag);
|
|||
|
|
for (const [name, value] of Object.entries(attributes)) element.setAttribute(name, value);
|
|||
|
|
return element;
|
|||
|
|
};
|
|||
|
|
const width = 320;
|
|||
|
|
const height = 170;
|
|||
|
|
const left = 26;
|
|||
|
|
const top = 16;
|
|||
|
|
const bottom = 22;
|
|||
|
|
const plotHeight = height - top - bottom;
|
|||
|
|
const ceiling = Math.max(35, Math.ceil(Math.max(...data.map(cycle => cycle.length)) / 10) * 10);
|
|||
|
|
const y = value => top + plotHeight - (value / ceiling) * plotHeight;
|
|||
|
|
const band = (width - left) / data.length;
|
|||
|
|
const barWidth = Math.min(24, band - 4);
|
|||
|
|
|
|||
|
|
const svg = s("svg", { viewBox: `0 0 ${width} ${height}`, role: "img", "aria-label": `Cycle lengths for the last ${data.length} cycles; the history table below lists every value.` });
|
|||
|
|
for (let tick = 0; tick <= ceiling; tick += 10) {
|
|||
|
|
svg.append(s("line", { class: "grid", x1: left, x2: width, y1: y(tick), y2: y(tick) }));
|
|||
|
|
const label = s("text", { class: "axis-label", x: left - 6, y: y(tick) + 4, "text-anchor": "end" });
|
|||
|
|
label.textContent = tick;
|
|||
|
|
svg.append(label);
|
|||
|
|
}
|
|||
|
|
const tooltip = h("div", { class: "tooltip", hidden: true });
|
|||
|
|
const show = (index, x) => {
|
|||
|
|
const cycle = data[index];
|
|||
|
|
tooltip.textContent = `${formatDay(cycle.start)} · ${plural(cycle.length, "day")}`;
|
|||
|
|
tooltip.style.left = `${(x / width) * 100}%`;
|
|||
|
|
tooltip.style.top = `${(y(cycle.length) / height) * 100}%`;
|
|||
|
|
tooltip.hidden = false;
|
|||
|
|
};
|
|||
|
|
data.forEach((cycle, index) => {
|
|||
|
|
const x = left + band * index + (band - barWidth) / 2;
|
|||
|
|
const yTop = y(cycle.length);
|
|||
|
|
const base = y(0);
|
|||
|
|
const radius = Math.min(4, base - yTop);
|
|||
|
|
// Rounded at the data end, square at the baseline.
|
|||
|
|
svg.append(s("path", {
|
|||
|
|
class: "bar",
|
|||
|
|
d: `M${x},${base}V${yTop + radius}Q${x},${yTop} ${x + radius},${yTop}H${x + barWidth - radius}Q${x + barWidth},${yTop} ${x + barWidth},${yTop + radius}V${base}Z`
|
|||
|
|
}));
|
|||
|
|
const hit = s("rect", { class: "hit", x: left + band * index, y: top, width: band, height: plotHeight });
|
|||
|
|
hit.addEventListener("pointerenter", () => show(index, x + barWidth / 2));
|
|||
|
|
hit.addEventListener("click", () => show(index, x + barWidth / 2));
|
|||
|
|
hit.addEventListener("pointerleave", () => { tooltip.hidden = true; });
|
|||
|
|
svg.append(hit);
|
|||
|
|
});
|
|||
|
|
const average = s("line", { class: "avg", x1: left, x2: width, y1: y(summary.cycleLength), y2: y(summary.cycleLength) });
|
|||
|
|
const averageLabel = s("text", { class: "avg-label", x: width, y: y(summary.cycleLength) - 5, "text-anchor": "end" });
|
|||
|
|
averageLabel.textContent = `avg ${summary.cycleLength}`;
|
|||
|
|
svg.insertBefore(average, svg.querySelector(".hit"));
|
|||
|
|
svg.insertBefore(averageLabel, svg.querySelector(".hit"));
|
|||
|
|
for (const index of [0, data.length - 1]) {
|
|||
|
|
const label = s("text", { class: "axis-label", x: left + band * index + band / 2, y: height - 6, "text-anchor": "middle" });
|
|||
|
|
label.textContent = formatDay(data[index].start, { month: "short", year: "numeric" });
|
|||
|
|
svg.append(label);
|
|||
|
|
}
|
|||
|
|
container.replaceChildren(svg, tooltip);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---- Settings ------------------------------------------------------------
|
|||
|
|
|
|||
|
|
function renderSettings() {
|
|||
|
|
$("share-toggle").checked = state.shareDetails;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---- Shell ---------------------------------------------------------------
|
|||
|
|
|
|||
|
|
function setView(view) {
|
|||
|
|
state.view = view;
|
|||
|
|
for (const button of document.querySelectorAll(".tabs button")) {
|
|||
|
|
button.setAttribute("aria-selected", String(button.dataset.view === view));
|
|||
|
|
}
|
|||
|
|
for (const section of document.querySelectorAll(".view")) section.hidden = section.id !== `view-${view}`;
|
|||
|
|
window.scrollTo(0, 0);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function render() {
|
|||
|
|
if (!state.role) return;
|
|||
|
|
const summary = Cycle.summarize(state.days, todayIso());
|
|||
|
|
const viewer = !editable();
|
|||
|
|
$("role-badge").hidden = !viewer;
|
|||
|
|
$("tab-settings").hidden = viewer;
|
|||
|
|
document.querySelector(".tabs").classList.toggle("three", viewer);
|
|||
|
|
if (viewer && state.view === "settings") setView("today");
|
|||
|
|
renderHero(summary);
|
|||
|
|
renderTodayLog();
|
|||
|
|
renderCalendar(summary);
|
|||
|
|
renderStats(summary);
|
|||
|
|
renderSettings();
|
|||
|
|
renderSheet(summary);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function resetMonth() {
|
|||
|
|
const now = new Date();
|
|||
|
|
state.month = { year: now.getFullYear(), month: now.getMonth() };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
resetMonth();
|
|||
|
|
for (const button of document.querySelectorAll(".tabs button")) {
|
|||
|
|
button.addEventListener("click", () => setView(button.dataset.view));
|
|||
|
|
}
|
|||
|
|
const linked = window.location.hash.slice(1);
|
|||
|
|
if (["calendar", "stats", "settings"].includes(linked)) setView(linked);
|
|||
|
|
$("month-prev").addEventListener("click", () => shiftMonth(-1));
|
|||
|
|
$("month-next").addEventListener("click", () => shiftMonth(1));
|
|||
|
|
$("month-today").addEventListener("click", () => { resetMonth(); render(); });
|
|||
|
|
$("day-sheet").addEventListener("close", () => { state.sheetDay = null; });
|
|||
|
|
$("day-sheet").addEventListener("click", event => {
|
|||
|
|
if (event.target === event.currentTarget) event.currentTarget.close();
|
|||
|
|
});
|
|||
|
|
$("share-toggle").addEventListener("change", async event => {
|
|||
|
|
const shareDetails = event.target.checked;
|
|||
|
|
try {
|
|||
|
|
await sendJson("PUT", "/api/settings", { shareDetails });
|
|||
|
|
state.shareDetails = shareDetails;
|
|||
|
|
showBanner(shareDetails ? "Flow and symptoms are now shared." : "Flow and symptoms are private again.");
|
|||
|
|
} catch (error) {
|
|||
|
|
event.target.checked = !shareDetails;
|
|||
|
|
showBanner(`Couldn't save: ${error.message}`, true);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
seedForm($("seed-form"));
|
|||
|
|
// The home-screen app is often resumed rather than reopened, so refresh
|
|||
|
|
// whenever it comes back to the foreground (this also rolls "today" over).
|
|||
|
|
document.addEventListener("visibilitychange", () => {
|
|||
|
|
if (document.visibilityState === "visible") load();
|
|||
|
|
});
|
|||
|
|
load();
|
|||
|
|
})();
|