Flowtrack: private period tracker with cycle predictions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
94372c6abc
19 changed files with 2166 additions and 0 deletions
535
static/app.js
Normal file
535
static/app.js
Normal file
|
|
@ -0,0 +1,535 @@
|
|||
(() => {
|
||||
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();
|
||||
})();
|
||||
BIN
static/apple-touch-icon.png
Normal file
BIN
static/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
143
static/cycle.js
Normal file
143
static/cycle.js
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
(function exposeCycle(root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === "object" && module.exports) module.exports = api;
|
||||
if (root) root.Cycle = api;
|
||||
})(typeof window === "undefined" ? globalThis : window, () => {
|
||||
const MS_PER_DAY = 86400000;
|
||||
const PERIOD_FLOWS = new Set(["period", "light", "medium", "heavy"]);
|
||||
const DEFAULT_CYCLE = 28;
|
||||
const DEFAULT_PERIOD = 5;
|
||||
// Days from ovulation to the next period. Fairly constant between people,
|
||||
// which is why ovulation is counted backwards from the predicted start.
|
||||
const LUTEAL_DAYS = 14;
|
||||
// Outside this range a "cycle" is almost always a missed or stray log.
|
||||
const MIN_CYCLE = 15;
|
||||
const MAX_CYCLE = 60;
|
||||
const HISTORY = 12;
|
||||
const FORECAST = 6;
|
||||
|
||||
// Dates are handled as whole days since 1970-01-01 so month lengths and
|
||||
// DST shifts never enter the arithmetic.
|
||||
const toDay = iso => {
|
||||
const [year, month, day] = iso.split("-").map(Number);
|
||||
return Math.round(Date.UTC(year, month - 1, day) / MS_PER_DAY);
|
||||
};
|
||||
const toIso = number => new Date(number * MS_PER_DAY).toISOString().slice(0, 10);
|
||||
|
||||
// Consecutive period days form one period. A single unlogged day inside a
|
||||
// period (forgot to log, or a light day logged as nothing) does not split it.
|
||||
function periods(days) {
|
||||
const periodDays = Object.keys(days)
|
||||
.filter(iso => PERIOD_FLOWS.has(days[iso] && days[iso].flow))
|
||||
.map(toDay)
|
||||
.sort((a, b) => a - b);
|
||||
const result = [];
|
||||
for (const day of periodDays) {
|
||||
const last = result[result.length - 1];
|
||||
if (last && day - last.end <= 2) last.end = day;
|
||||
else result.push({ start: day, end: day });
|
||||
}
|
||||
for (const period of result) period.length = period.end - period.start + 1;
|
||||
return result;
|
||||
}
|
||||
|
||||
function cycles(periodList) {
|
||||
const result = [];
|
||||
for (let index = 1; index < periodList.length; index += 1) {
|
||||
const start = periodList[index - 1].start;
|
||||
const length = periodList[index].start - start;
|
||||
result.push({
|
||||
start,
|
||||
length,
|
||||
periodLength: periodList[index - 1].length,
|
||||
used: length >= MIN_CYCLE && length <= MAX_CYCLE
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Recent cycles count more (weights 1..n, oldest first), so a gradual shift
|
||||
// in her cycle shows up in predictions within a few months.
|
||||
function weightedStats(values) {
|
||||
const weights = values.map((_, index) => index + 1);
|
||||
const total = weights.reduce((sum, weight) => sum + weight, 0);
|
||||
const mean = values.reduce((sum, value, index) => sum + value * weights[index], 0) / total;
|
||||
const variance = values.reduce((sum, value, index) => sum + weights[index] * (value - mean) ** 2, 0) / total;
|
||||
return { mean, sd: Math.sqrt(variance) };
|
||||
}
|
||||
|
||||
function fertileWindow(nextStart, previousEnd) {
|
||||
const ovulation = nextStart - LUTEAL_DAYS;
|
||||
if (ovulation <= previousEnd) return null;
|
||||
return { ovulation, start: Math.max(ovulation - 5, previousEnd + 1), end: ovulation + 1 };
|
||||
}
|
||||
|
||||
function summarize(days, todayIso) {
|
||||
const today = toDay(todayIso);
|
||||
const periodList = periods(days).filter(period => period.start <= today);
|
||||
const cycleList = cycles(periodList);
|
||||
const used = cycleList.filter(cycle => cycle.used).slice(-HISTORY);
|
||||
const summary = {
|
||||
today,
|
||||
periods: periodList,
|
||||
cycles: cycleList,
|
||||
sampleSize: used.length,
|
||||
confidence: used.length === 0 ? "none" : used.length < 3 ? "low" : "normal",
|
||||
cycleLength: DEFAULT_CYCLE,
|
||||
cycleSpread: null,
|
||||
periodLength: DEFAULT_PERIOD,
|
||||
current: null,
|
||||
late: 0,
|
||||
predictions: [],
|
||||
fertile: []
|
||||
};
|
||||
if (!periodList.length) return summary;
|
||||
|
||||
if (used.length) {
|
||||
const stats = weightedStats(used.map(cycle => cycle.length));
|
||||
summary.cycleLength = Math.round(stats.mean);
|
||||
summary.cycleSpread = stats.sd;
|
||||
}
|
||||
const last = periodList[periodList.length - 1];
|
||||
// The latest period may still be going, so it says nothing about length yet.
|
||||
const finished = periodList.filter(period => period !== last || period.end < today - 1);
|
||||
const lengths = finished.filter(period => period.length >= 2).slice(-6).map(period => period.length);
|
||||
if (lengths.length) {
|
||||
const mean = lengths.reduce((sum, value) => sum + value, 0) / lengths.length;
|
||||
summary.periodLength = Math.min(10, Math.max(2, Math.round(mean)));
|
||||
}
|
||||
|
||||
summary.current = {
|
||||
start: last.start,
|
||||
end: last.end,
|
||||
cycleDay: today - last.start + 1,
|
||||
onPeriod: last.end === today
|
||||
};
|
||||
|
||||
const expected = last.start + summary.cycleLength;
|
||||
// A late period keeps being expected "from tomorrow" rather than drifting
|
||||
// into the past, and the cycles after it shift with it.
|
||||
let start = expected;
|
||||
if (today > expected && !summary.current.onPeriod) {
|
||||
summary.late = today - expected;
|
||||
start = today + 1;
|
||||
}
|
||||
const thinData = summary.confidence !== "normal";
|
||||
const spread = Math.max(summary.cycleSpread || 0, thinData ? 4 : 2);
|
||||
let previousEnd = last.end;
|
||||
for (let index = 1; index <= FORECAST; index += 1) {
|
||||
const margin = Math.min(10, Math.round(spread * Math.sqrt(index)));
|
||||
const end = start + summary.periodLength - 1;
|
||||
summary.predictions.push({ start, end, earliest: start - margin, latest: start + margin });
|
||||
// The current cycle's window is anchored on the original expectation:
|
||||
// being late does not move an ovulation that already happened.
|
||||
const window = fertileWindow(index === 1 ? expected : start, previousEnd);
|
||||
if (window) summary.fertile.push(window);
|
||||
previousEnd = end;
|
||||
start += summary.cycleLength;
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
return { PERIOD_FLOWS, LUTEAL_DAYS, MIN_CYCLE, MAX_CYCLE, toDay, toIso, periods, cycles, weightedStats, summarize };
|
||||
});
|
||||
BIN
static/icon-192.png
Normal file
BIN
static/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
BIN
static/icon-512.png
Normal file
BIN
static/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.7 KiB |
5
static/icon.svg
Normal file
5
static/icon.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="112" fill="#cf3f66"/>
|
||||
<path d="M256 92c62 78 118 152 118 220a118 118 0 0 1-236 0c0-68 56-142 118-220z" fill="#fff"/>
|
||||
<path d="M208 318a50 50 0 0 0 44 50" fill="none" stroke="#cf3f66" stroke-width="18" stroke-linecap="round" opacity=".55"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 351 B |
130
static/index.html
Normal file
130
static/index.html
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>Flowtrack</title>
|
||||
<meta name="description" content="A private period tracker with cycle predictions.">
|
||||
<meta name="theme-color" content="#faf6f4" media="(prefers-color-scheme: light)">
|
||||
<meta name="theme-color" content="#18141b" media="(prefers-color-scheme: dark)">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
<meta name="apple-mobile-web-app-title" content="Flowtrack">
|
||||
<link rel="manifest" href="/manifest.webmanifest" crossorigin="use-credentials">
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
<link rel="stylesheet" href="/styles.css?v=1.0.0">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<h1>Flowtrack</h1>
|
||||
<span class="badge" id="role-badge" hidden>View only</span>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<p class="banner" id="banner" role="status" hidden></p>
|
||||
|
||||
<section class="view" id="view-today" aria-labelledby="tab-today">
|
||||
<div class="hero card" id="hero"></div>
|
||||
<div class="card" id="today-log"></div>
|
||||
<p class="footnote">Predictions are estimates from your own history. They are not a form of contraception.</p>
|
||||
</section>
|
||||
|
||||
<section class="view" id="view-calendar" aria-labelledby="tab-calendar" hidden>
|
||||
<div class="card">
|
||||
<div class="month-nav">
|
||||
<button type="button" class="icon-button" id="month-prev" aria-label="Previous month">‹</button>
|
||||
<h2 id="month-title"></h2>
|
||||
<button type="button" class="icon-button" id="month-next" aria-label="Next month">›</button>
|
||||
</div>
|
||||
<div class="weekdays" aria-hidden="true">
|
||||
<span>Mon</span><span>Tue</span><span>Wed</span><span>Thu</span><span>Fri</span><span>Sat</span><span>Sun</span>
|
||||
</div>
|
||||
<div class="month-grid" id="month-grid" role="grid"></div>
|
||||
<button type="button" class="link-button" id="month-today">Back to today</button>
|
||||
</div>
|
||||
<ul class="legend card">
|
||||
<li><span class="key key-period"></span>Period</li>
|
||||
<li><span class="key key-predicted"></span>Predicted period</li>
|
||||
<li><span class="key key-fertile"></span>Fertile window (estimate)</li>
|
||||
<li><span class="key key-ovulation"></span>Estimated ovulation</li>
|
||||
<li><span class="key key-spotting"></span>Spotting</li>
|
||||
<li><span class="key key-symptoms"></span>Symptoms logged</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="view" id="view-stats" aria-labelledby="tab-stats" hidden>
|
||||
<div class="tiles" id="tiles"></div>
|
||||
<div class="card" id="chart-card">
|
||||
<h2>Cycle length</h2>
|
||||
<p class="subtitle">Days from one period to the next, most recent on the right</p>
|
||||
<div class="chart" id="chart"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>History</h2>
|
||||
<div class="table-wrap">
|
||||
<table class="history">
|
||||
<thead><tr><th scope="col">Started</th><th scope="col">Cycle</th><th scope="col">Period</th></tr></thead>
|
||||
<tbody id="history"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="view" id="view-settings" aria-labelledby="tab-settings" hidden>
|
||||
<div class="card" id="share-card">
|
||||
<h2>Sharing</h2>
|
||||
<label class="switch-row">
|
||||
<span>
|
||||
<strong>Share flow and symptoms</strong>
|
||||
<small>View-only users always see period days and predictions. Turn this on to also show them your flow and symptoms.</small>
|
||||
</span>
|
||||
<input type="checkbox" id="share-toggle" role="switch">
|
||||
</label>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Add past periods</h2>
|
||||
<p class="subtitle">Enter when recent periods started. Days you already logged are left untouched.</p>
|
||||
<form id="seed-form" class="seed-form"></form>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Export</h2>
|
||||
<p class="subtitle">Download everything you have logged.</p>
|
||||
<div class="button-row">
|
||||
<a class="button secondary" href="/api/export.csv" download>CSV</a>
|
||||
<a class="button secondary" href="/api/export.json" download>JSON</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<nav class="tabs" role="tablist" aria-label="Sections">
|
||||
<button type="button" role="tab" id="tab-today" data-view="today" aria-selected="true">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3c3.5 4.4 6 7.9 6 11a6 6 0 0 1-12 0c0-3.1 2.5-6.6 6-11z"/></svg>Today
|
||||
</button>
|
||||
<button type="button" role="tab" id="tab-calendar" data-view="calendar" aria-selected="false">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="5" width="16" height="15" rx="2"/><path d="M4 10h16M9 3v4M15 3v4"/></svg>Calendar
|
||||
</button>
|
||||
<button type="button" role="tab" id="tab-stats" data-view="stats" aria-selected="false">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 20V11M12 20V5M19 20v-7"/></svg>Stats
|
||||
</button>
|
||||
<button type="button" role="tab" id="tab-settings" data-view="settings" aria-selected="false">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M5.6 18.4l2.1-2.1M16.3 7.7l2.1-2.1"/></svg>Settings
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<dialog class="sheet" id="day-sheet" aria-labelledby="sheet-title">
|
||||
<form method="dialog" class="sheet-inner">
|
||||
<div class="sheet-head">
|
||||
<h2 id="sheet-title"></h2>
|
||||
<button class="icon-button" value="close" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div id="sheet-body"></div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<script src="/cycle.js?v=1.0.0"></script>
|
||||
<script src="/app.js?v=1.0.0"></script>
|
||||
</body>
|
||||
</html>
|
||||
15
static/manifest.webmanifest
Normal file
15
static/manifest.webmanifest
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"name": "Flowtrack",
|
||||
"short_name": "Flowtrack",
|
||||
"description": "A private period tracker with cycle predictions.",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#faf6f4",
|
||||
"theme_color": "#faf6f4",
|
||||
"icons": [
|
||||
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" },
|
||||
{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml" }
|
||||
]
|
||||
}
|
||||
368
static/styles.css
Normal file
368
static/styles.css
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #faf6f4;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #f4eeec;
|
||||
--ink: #2b2330;
|
||||
--ink-2: #5d5463;
|
||||
--muted: #847a89;
|
||||
--line: #e9e1df;
|
||||
--period: #cf3f66;
|
||||
--period-soft: #f9dde5;
|
||||
--period-ink: #ffffff;
|
||||
--fertile: #1f8f8a;
|
||||
--fertile-soft: #d6efed;
|
||||
--focus: #6d4fc4;
|
||||
--danger: #b3261e;
|
||||
--shadow: 0 1px 2px rgb(43 35 48 / 6%), 0 4px 16px rgb(43 35 48 / 5%);
|
||||
--radius: 18px;
|
||||
--tabs-height: 64px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #18141b;
|
||||
--surface: #221d26;
|
||||
--surface-2: #2c2631;
|
||||
--ink: #f2ecf3;
|
||||
--ink-2: #c3b9c7;
|
||||
--muted: #9a8f9f;
|
||||
--line: #3a3340;
|
||||
--period: #ee6b8d;
|
||||
--period-soft: #4b2534;
|
||||
--period-ink: #1e0b12;
|
||||
--fertile: #4cc4bd;
|
||||
--fertile-soft: #183937;
|
||||
--focus: #a991f0;
|
||||
--danger: #ff8a80;
|
||||
--shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html { -webkit-text-size-adjust: 100%; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-size: 16px;
|
||||
line-height: 1.45;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
button, input, select { font: inherit; color: inherit; }
|
||||
|
||||
:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
|
||||
|
||||
h1, h2 { margin: 0; line-height: 1.2; }
|
||||
h2 { font-size: 1.05rem; font-weight: 600; }
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
padding: calc(env(safe-area-inset-top) + 14px) 16px 6px;
|
||||
}
|
||||
.topbar h1 { font-size: 1.35rem; font-weight: 700; letter-spacing: -0.01em; }
|
||||
|
||||
.badge {
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-2);
|
||||
color: var(--ink-2);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
padding: 8px 16px calc(var(--tabs-height) + env(safe-area-inset-bottom) + 24px);
|
||||
}
|
||||
|
||||
.view { display: grid; gap: 14px; }
|
||||
.view[hidden] { display: none; }
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
padding: 18px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.card > h2 + .subtitle { margin-top: 4px; }
|
||||
|
||||
.subtitle { margin: 0 0 14px; color: var(--muted); font-size: 0.9rem; }
|
||||
.footnote { margin: 0; padding: 0 4px; color: var(--muted); font-size: 0.8rem; text-align: center; }
|
||||
|
||||
.banner {
|
||||
margin: 0 0 12px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
background: var(--surface-2);
|
||||
color: var(--ink);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.banner.error { color: var(--danger); }
|
||||
|
||||
/* Today */
|
||||
|
||||
.hero { text-align: center; padding: 26px 18px 22px; }
|
||||
.hero .eyebrow { margin: 0; color: var(--muted); font-size: 0.85rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; }
|
||||
.hero .figure { margin: 4px 0 2px; font-size: 3.4rem; font-weight: 700; letter-spacing: -0.03em; line-height: 1.05; font-variant-numeric: tabular-nums; }
|
||||
.hero .figure small { font-size: 1.1rem; font-weight: 600; color: var(--ink-2); letter-spacing: 0; }
|
||||
.hero .lead { margin: 6px 0 0; font-size: 1.05rem; font-weight: 600; }
|
||||
.hero .detail { margin: 2px 0 0; color: var(--ink-2); font-size: 0.92rem; }
|
||||
.hero .note { margin: 14px auto 0; max-width: 34ch; color: var(--muted); font-size: 0.85rem; }
|
||||
.hero.on-period .figure { color: var(--period); }
|
||||
|
||||
.facts { display: grid; gap: 10px; margin: 18px 0 0; padding: 16px 0 0; border-top: 1px solid var(--line); text-align: left; }
|
||||
.fact { display: flex; align-items: center; gap: 12px; }
|
||||
.fact .key { flex: none; }
|
||||
.fact div { display: grid; }
|
||||
.fact strong { font-size: 0.95rem; font-weight: 600; }
|
||||
.fact span { color: var(--ink-2); font-size: 0.88rem; }
|
||||
|
||||
.log-section + .log-section { margin-top: 18px; }
|
||||
.log-section h3 { margin: 0 0 10px; color: var(--ink-2); font-size: 0.85rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.chip {
|
||||
min-height: 40px;
|
||||
padding: 8px 14px;
|
||||
border: 1.5px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: var(--surface);
|
||||
font-size: 0.92rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
.chip[aria-pressed="true"] { border-color: var(--period); background: var(--period); color: var(--period-ink); }
|
||||
.chip.symptom[aria-pressed="true"] { border-color: var(--ink); background: var(--ink); color: var(--surface); }
|
||||
.chip:disabled { cursor: default; }
|
||||
.chip:disabled:not([aria-pressed="true"]) { opacity: 0.55; }
|
||||
|
||||
.empty-log { margin: 0; color: var(--muted); font-size: 0.92rem; }
|
||||
|
||||
/* Buttons */
|
||||
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 44px;
|
||||
padding: 10px 18px;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
background: var(--period);
|
||||
color: var(--period-ink);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.button.secondary { background: var(--surface-2); color: var(--ink); }
|
||||
.button:disabled { opacity: 0.6; cursor: default; }
|
||||
.button-row { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||
|
||||
.icon-button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-2);
|
||||
font-size: 1.4rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.link-button {
|
||||
display: block;
|
||||
margin: 12px auto 0;
|
||||
padding: 6px 10px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--ink-2);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Calendar */
|
||||
|
||||
.month-nav { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
|
||||
.month-nav h2 { font-size: 1.1rem; }
|
||||
|
||||
.weekdays, .month-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; }
|
||||
.weekdays span { color: var(--muted); font-size: 0.75rem; font-weight: 600; text-align: center; }
|
||||
.month-grid { margin-top: 6px; }
|
||||
|
||||
.day {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
aspect-ratio: 1;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
font-size: 0.95rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
.day.outside { visibility: hidden; }
|
||||
.day.future { cursor: default; }
|
||||
.day.fertile { background: var(--fertile-soft); }
|
||||
.day.predicted { box-shadow: inset 0 0 0 2px var(--period); background: var(--period-soft); }
|
||||
.day.period { background: var(--period); color: var(--period-ink); font-weight: 600; }
|
||||
.day.today::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -3px;
|
||||
border: 2px solid var(--ink);
|
||||
border-radius: 999px;
|
||||
}
|
||||
.day .marks { position: absolute; bottom: 12%; display: flex; gap: 3px; }
|
||||
.day .mark { width: 5px; height: 5px; border-radius: 999px; }
|
||||
.mark.ovulation { background: var(--fertile); }
|
||||
.mark.spotting { background: var(--period); }
|
||||
.mark.symptoms { background: var(--muted); }
|
||||
.day.period .mark.symptoms { background: var(--period-ink); }
|
||||
|
||||
.legend { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px 16px; margin: 0; list-style: none; font-size: 0.9rem; color: var(--ink-2); }
|
||||
.legend li { display: flex; align-items: center; gap: 10px; }
|
||||
|
||||
.key { display: inline-block; width: 16px; height: 16px; border-radius: 999px; }
|
||||
.key-period { background: var(--period); }
|
||||
.key-predicted { background: var(--period-soft); box-shadow: inset 0 0 0 2px var(--period); }
|
||||
.key-fertile { background: var(--fertile-soft); }
|
||||
.key-ovulation { position: relative; background: var(--fertile-soft); }
|
||||
.key-ovulation::after { content: ""; position: absolute; inset: 5px; border-radius: 999px; background: var(--fertile); }
|
||||
.key-spotting, .key-symptoms { position: relative; }
|
||||
.key-spotting::after, .key-symptoms::after { content: ""; position: absolute; inset: 5px; border-radius: 999px; }
|
||||
.key-spotting::after { background: var(--period); }
|
||||
.key-symptoms::after { background: var(--muted); }
|
||||
|
||||
/* Stats */
|
||||
|
||||
.tiles { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
|
||||
.tile { background: var(--surface); border-radius: var(--radius); padding: 14px 16px; box-shadow: var(--shadow); }
|
||||
.tile .label { margin: 0; color: var(--ink-2); font-size: 0.85rem; }
|
||||
.tile .value { margin: 2px 0 0; font-size: 1.6rem; font-weight: 650; letter-spacing: -0.01em; font-variant-numeric: tabular-nums; }
|
||||
.tile .value small { font-size: 0.95rem; font-weight: 500; color: var(--ink-2); }
|
||||
|
||||
.chart { position: relative; }
|
||||
.chart svg { display: block; width: 100%; height: auto; overflow: visible; }
|
||||
.chart .grid { stroke: var(--line); stroke-width: 1; }
|
||||
.chart .axis-label { fill: var(--muted); font-size: 11px; }
|
||||
.chart .bar { fill: var(--period); }
|
||||
.chart .bar.dim { opacity: 0.35; }
|
||||
.chart .avg { stroke: var(--ink-2); stroke-width: 1; stroke-dasharray: 3 3; }
|
||||
.chart .avg-label { fill: var(--ink-2); font-size: 11px; font-weight: 600; }
|
||||
.chart .hit { fill: transparent; cursor: pointer; }
|
||||
.chart-empty { margin: 0; color: var(--muted); font-size: 0.92rem; }
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
padding: 6px 10px;
|
||||
border-radius: 10px;
|
||||
background: var(--ink);
|
||||
color: var(--surface);
|
||||
font-size: 0.82rem;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, calc(-100% - 8px));
|
||||
}
|
||||
.tooltip[hidden] { display: none; }
|
||||
|
||||
.table-wrap { margin-top: 12px; overflow-x: auto; }
|
||||
.history { width: 100%; border-collapse: collapse; font-size: 0.92rem; font-variant-numeric: tabular-nums; }
|
||||
.history th { padding: 0 0 8px; color: var(--muted); font-size: 0.8rem; font-weight: 600; text-align: left; }
|
||||
.history td { padding: 9px 0; border-top: 1px solid var(--line); }
|
||||
.history th:not(:first-child), .history td:not(:first-child) { text-align: right; }
|
||||
.history .excluded { color: var(--muted); }
|
||||
.history .excluded small { display: block; font-size: 0.78rem; }
|
||||
|
||||
/* Settings */
|
||||
|
||||
.switch-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; cursor: pointer; }
|
||||
.switch-row span { display: grid; gap: 2px; }
|
||||
.switch-row small { color: var(--muted); font-size: 0.85rem; }
|
||||
.switch-row input { flex: none; width: 24px; height: 24px; accent-color: var(--period); }
|
||||
|
||||
.seed-form { display: grid; gap: 12px; }
|
||||
.seed-form label { display: grid; gap: 4px; color: var(--ink-2); font-size: 0.88rem; font-weight: 600; }
|
||||
.seed-form input {
|
||||
min-height: 44px;
|
||||
padding: 8px 12px;
|
||||
border: 1.5px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
font-weight: 400;
|
||||
}
|
||||
.seed-dates { display: grid; gap: 8px; }
|
||||
.seed-form .seed-length { max-width: 120px; }
|
||||
|
||||
/* Tabs */
|
||||
|
||||
.tabs {
|
||||
position: fixed;
|
||||
inset: auto 0 0;
|
||||
z-index: 5;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
padding: 6px max(8px, env(safe-area-inset-left)) calc(6px + env(safe-area-inset-bottom));
|
||||
border-top: 1px solid var(--line);
|
||||
background: color-mix(in srgb, var(--surface) 92%, transparent);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
.tabs.three { grid-template-columns: repeat(3, 1fr); }
|
||||
.tabs button {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 2px;
|
||||
min-height: 52px;
|
||||
padding: 4px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tabs button[hidden] { display: none; }
|
||||
.tabs svg { width: 24px; height: 24px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.tabs button[aria-selected="true"] { color: var(--period); }
|
||||
|
||||
/* Day sheet */
|
||||
|
||||
.sheet {
|
||||
width: min(560px, 100%);
|
||||
max-width: 100%;
|
||||
max-height: 90vh;
|
||||
margin: auto auto 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 22px 22px 0 0;
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
}
|
||||
.sheet::backdrop { background: rgb(0 0 0 / 35%); }
|
||||
.sheet-inner { padding: 18px 18px calc(22px + env(safe-area-inset-bottom)); }
|
||||
.sheet-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
|
||||
.sheet-head h2 { font-size: 1.15rem; }
|
||||
.sheet .prediction-note { margin: 0 0 16px; color: var(--ink-2); font-size: 0.9rem; }
|
||||
|
||||
@media (min-width: 600px) {
|
||||
.sheet { margin: auto; border-radius: 22px; }
|
||||
.tiles { grid-template-columns: repeat(4, 1fr); }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue