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
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
result
|
||||||
|
__pycache__/
|
||||||
|
*.sqlite3*
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Fredrik Gregersen
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
56
README.md
Normal file
56
README.md
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
# Flowtrack
|
||||||
|
|
||||||
|
A private, self-hosted period tracker. It logs period days with flow and
|
||||||
|
symptoms, predicts the next six periods, and estimates the fertile window.
|
||||||
|
It installs on a phone home screen as a web app.
|
||||||
|
|
||||||
|
The app has no runtime dependencies beyond the Python standard library, and it
|
||||||
|
stores everything in one SQLite file. Nginx and Authelia provide the access
|
||||||
|
boundary.
|
||||||
|
|
||||||
|
## Predictions
|
||||||
|
|
||||||
|
- Periods are runs of logged period days. Spotting doesn't count, and one
|
||||||
|
missing day inside a run doesn't split it.
|
||||||
|
- The cycle length is a weighted average of up to the last 12 cycles, with
|
||||||
|
recent cycles counting more. Cycles shorter than 15 days or longer than 60
|
||||||
|
are left out because they usually mean a log was missed.
|
||||||
|
- Each prediction is a range of ± the cycle's standard deviation (at least 2
|
||||||
|
days, or 4 with fewer than three cycles). The range widens with √n for later
|
||||||
|
cycles.
|
||||||
|
- Ovulation is estimated 14 days before the next period. The fertile window
|
||||||
|
runs from 5 days before ovulation to 1 day after. These are estimates, not
|
||||||
|
contraception.
|
||||||
|
- Once the expected day passes, the next period is expected from tomorrow,
|
||||||
|
and later predictions move with it.
|
||||||
|
|
||||||
|
The logic lives in `static/cycle.js` and is tested in `tests/test_cycle.js`.
|
||||||
|
|
||||||
|
## Access
|
||||||
|
|
||||||
|
Nginx must overwrite `Remote-User` with the Authelia username. Users listed
|
||||||
|
with `--editor` can log and export. Users listed with `--viewer` see period
|
||||||
|
days and predictions only. They also see flow and symptoms if the owner turns
|
||||||
|
on sharing in Settings. Everyone else gets 403.
|
||||||
|
|
||||||
|
## Run locally
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 app.py --database /tmp/flowtrack.sqlite3 --editor me --dev-user me
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open `http://127.0.0.1:8093`.
|
||||||
|
|
||||||
|
## NixOS module
|
||||||
|
|
||||||
|
```nix
|
||||||
|
services.flowtrack = {
|
||||||
|
enable = true;
|
||||||
|
editors = [ "kayla" ];
|
||||||
|
viewers = [ "fred" ];
|
||||||
|
backup.directory = "/mnt/disk2/backups/flowtrack"; # nightly, keeps 30
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`python3 app.py --database DB --backup-to DIR --keep N` takes a consistent
|
||||||
|
snapshot by hand.
|
||||||
421
app.py
Normal file
421
app.py
Normal file
|
|
@ -0,0 +1,421 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Small, dependency-free server for a private period tracker."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import mimetypes
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
from contextlib import closing
|
||||||
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
from http import HTTPStatus
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import unquote, urlparse
|
||||||
|
|
||||||
|
|
||||||
|
APP_DIR = Path(__file__).resolve().parent
|
||||||
|
STATIC_DIR = APP_DIR / "static"
|
||||||
|
DAY_PATH = re.compile(r"^/api/days/(\d{4}-\d{2}-\d{2})$")
|
||||||
|
MAX_BODY = 64 * 1024
|
||||||
|
MAX_BATCH = 400
|
||||||
|
FLOWS = ("spotting", "period", "light", "medium", "heavy")
|
||||||
|
PERIOD_FLOWS = frozenset(("period", "light", "medium", "heavy"))
|
||||||
|
SYMPTOMS = (
|
||||||
|
("cramps", "Cramps"),
|
||||||
|
("headache", "Headache"),
|
||||||
|
("bloating", "Bloating"),
|
||||||
|
("tender-breasts", "Tender breasts"),
|
||||||
|
("acne", "Acne"),
|
||||||
|
("fatigue", "Fatigue"),
|
||||||
|
("back-pain", "Back pain"),
|
||||||
|
("nausea", "Nausea"),
|
||||||
|
("cravings", "Cravings"),
|
||||||
|
("mood-swings", "Mood swings"),
|
||||||
|
)
|
||||||
|
SYMPTOM_IDS = frozenset(symptom_id for symptom_id, _ in SYMPTOMS)
|
||||||
|
|
||||||
|
mimetypes.add_type("application/manifest+json", ".webmanifest")
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_day(value: object) -> date | None:
|
||||||
|
"""A calendar date that is not in the future.
|
||||||
|
|
||||||
|
One day of slack covers a phone that is ahead of the server's timezone.
|
||||||
|
"""
|
||||||
|
if not isinstance(value, str) or not re.fullmatch(r"\d{4}-\d{2}-\d{2}", value):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = date.fromisoformat(value)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if parsed.year < 1990 or parsed > date.today() + timedelta(days=1):
|
||||||
|
return None
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def clean_entry(value: object) -> dict | None:
|
||||||
|
"""Validate one day's log; returns None when it is malformed."""
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return None
|
||||||
|
flow = value.get("flow")
|
||||||
|
symptoms = value.get("symptoms", [])
|
||||||
|
if flow is not None and flow not in FLOWS:
|
||||||
|
return None
|
||||||
|
if not isinstance(symptoms, list) or not all(item in SYMPTOM_IDS for item in symptoms):
|
||||||
|
return None
|
||||||
|
return {"flow": flow, "symptoms": sorted(set(symptoms))}
|
||||||
|
|
||||||
|
|
||||||
|
class Store:
|
||||||
|
def __init__(self, path: str | Path):
|
||||||
|
self.path = str(path)
|
||||||
|
|
||||||
|
def connect(self) -> sqlite3.Connection:
|
||||||
|
connection = sqlite3.connect(self.path, timeout=10)
|
||||||
|
connection.row_factory = sqlite3.Row
|
||||||
|
connection.execute("PRAGMA journal_mode=WAL")
|
||||||
|
connection.execute("PRAGMA busy_timeout=10000")
|
||||||
|
return connection
|
||||||
|
|
||||||
|
def initialize(self) -> None:
|
||||||
|
with closing(self.connect()) as db, db:
|
||||||
|
db.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS days (
|
||||||
|
day TEXT PRIMARY KEY,
|
||||||
|
flow TEXT,
|
||||||
|
symptoms TEXT NOT NULL DEFAULT '[]',
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
INSERT OR IGNORE INTO settings (key, value) VALUES ('share_details', 'false');
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
def days(self) -> dict[str, dict]:
|
||||||
|
with closing(self.connect()) as db, db:
|
||||||
|
rows = db.execute("SELECT day, flow, symptoms FROM days ORDER BY day").fetchall()
|
||||||
|
return {row["day"]: {"flow": row["flow"], "symptoms": json.loads(row["symptoms"])} for row in rows}
|
||||||
|
|
||||||
|
def share_details(self) -> bool:
|
||||||
|
with closing(self.connect()) as db, db:
|
||||||
|
row = db.execute("SELECT value FROM settings WHERE key = 'share_details'").fetchone()
|
||||||
|
return row is not None and row["value"] == "true"
|
||||||
|
|
||||||
|
def set_share_details(self, enabled: bool) -> None:
|
||||||
|
with closing(self.connect()) as db, db:
|
||||||
|
db.execute(
|
||||||
|
"UPDATE settings SET value = ? WHERE key = 'share_details'",
|
||||||
|
("true" if enabled else "false",),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _write(db: sqlite3.Connection, day: str, entry: dict, changed_at: str) -> None:
|
||||||
|
# An empty log is the same as no log, so the row goes away.
|
||||||
|
if entry["flow"] is None and not entry["symptoms"]:
|
||||||
|
db.execute("DELETE FROM days WHERE day = ?", (day,))
|
||||||
|
return
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO days (day, flow, symptoms, updated_at) VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(day) DO UPDATE SET
|
||||||
|
flow = excluded.flow,
|
||||||
|
symptoms = excluded.symptoms,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
""",
|
||||||
|
(day, entry["flow"], json.dumps(entry["symptoms"]), changed_at),
|
||||||
|
)
|
||||||
|
|
||||||
|
def set_day(self, day: str, entry: dict) -> None:
|
||||||
|
with closing(self.connect()) as db, db:
|
||||||
|
self._write(db, day, entry, utc_now())
|
||||||
|
|
||||||
|
def fill_days(self, entries: dict[str, dict]) -> int:
|
||||||
|
"""Add logs for days that have none yet; existing logs always win."""
|
||||||
|
changed_at = utc_now()
|
||||||
|
added = 0
|
||||||
|
with closing(self.connect()) as db, db:
|
||||||
|
db.execute("BEGIN IMMEDIATE")
|
||||||
|
for day, entry in sorted(entries.items()):
|
||||||
|
if db.execute("SELECT 1 FROM days WHERE day = ?", (day,)).fetchone():
|
||||||
|
continue
|
||||||
|
self._write(db, day, entry, changed_at)
|
||||||
|
added += 1
|
||||||
|
return added
|
||||||
|
|
||||||
|
def backup(self, destination: Path, keep: int) -> Path:
|
||||||
|
"""Write today's consistent snapshot and prune all but the newest `keep`."""
|
||||||
|
destination.mkdir(parents=True, exist_ok=True)
|
||||||
|
target = destination / f"flowtrack-{date.today().isoformat()}.sqlite3"
|
||||||
|
partial = target.with_suffix(".partial")
|
||||||
|
with closing(self.connect()) as source, closing(sqlite3.connect(partial)) as copy:
|
||||||
|
source.backup(copy)
|
||||||
|
partial.replace(target)
|
||||||
|
snapshots = sorted(destination.glob("flowtrack-*.sqlite3"))
|
||||||
|
for old in snapshots[:-keep] if keep > 0 else []:
|
||||||
|
old.unlink()
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
class TrackerServer(ThreadingHTTPServer):
|
||||||
|
daemon_threads = True
|
||||||
|
|
||||||
|
def __init__(self, address: tuple[str, int], store: Store, editors: set[str], viewers: set[str], dev_user: str | None = None):
|
||||||
|
super().__init__(address, Handler)
|
||||||
|
self.store = store
|
||||||
|
self.editors = editors
|
||||||
|
self.viewers = viewers
|
||||||
|
self.dev_user = dev_user
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
server: TrackerServer
|
||||||
|
|
||||||
|
def log_message(self, format: str, *args: object) -> None:
|
||||||
|
logging.info("%s - %s", self.address_string(), format % args)
|
||||||
|
|
||||||
|
def end_headers(self) -> None:
|
||||||
|
self.send_header("X-Content-Type-Options", "nosniff")
|
||||||
|
self.send_header("X-Frame-Options", "DENY")
|
||||||
|
self.send_header("Referrer-Policy", "no-referrer")
|
||||||
|
self.send_header("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
||||||
|
self.send_header(
|
||||||
|
"Content-Security-Policy",
|
||||||
|
"default-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
|
||||||
|
)
|
||||||
|
super().end_headers()
|
||||||
|
|
||||||
|
def send_body(self, body: bytes, content_type: str, status: HTTPStatus = HTTPStatus.OK, extra: dict | None = None) -> None:
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", content_type)
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
for name, value in (extra or {}).items():
|
||||||
|
self.send_header(name, value)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def json_response(self, payload: dict, status: HTTPStatus = HTTPStatus.OK) -> None:
|
||||||
|
self.send_body(json.dumps(payload, separators=(",", ":")).encode(), "application/json; charset=utf-8", status)
|
||||||
|
|
||||||
|
def error_json(self, message: str, status: HTTPStatus) -> None:
|
||||||
|
self.json_response({"error": message}, status)
|
||||||
|
|
||||||
|
def role(self) -> tuple[str, str | None]:
|
||||||
|
# nginx overwrites Remote-User with the Authelia identity, so it can
|
||||||
|
# be trusted here; the app itself only listens on loopback.
|
||||||
|
user = self.headers.get("Remote-User", "").strip() or self.server.dev_user or ""
|
||||||
|
if user in self.server.editors:
|
||||||
|
return user, "editor"
|
||||||
|
if user in self.server.viewers:
|
||||||
|
return user, "viewer"
|
||||||
|
return user, None
|
||||||
|
|
||||||
|
def visible_days(self, role: str) -> dict[str, dict]:
|
||||||
|
days = self.server.store.days()
|
||||||
|
if role == "editor" or self.server.store.share_details():
|
||||||
|
return days
|
||||||
|
# Without sharing a viewer sees which days were period days, nothing else.
|
||||||
|
return {
|
||||||
|
day: {"flow": "period", "symptoms": []}
|
||||||
|
for day, entry in days.items()
|
||||||
|
if entry["flow"] in PERIOD_FLOWS
|
||||||
|
}
|
||||||
|
|
||||||
|
def read_json(self) -> object:
|
||||||
|
if self.headers.get_content_type() != "application/json":
|
||||||
|
self.error_json("Expected application/json", HTTPStatus.UNSUPPORTED_MEDIA_TYPE)
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
|
except ValueError:
|
||||||
|
length = -1
|
||||||
|
if length <= 0 or length > MAX_BODY:
|
||||||
|
self.error_json("Invalid request size", HTTPStatus.BAD_REQUEST)
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(self.rfile.read(length))
|
||||||
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||||
|
self.error_json("Invalid JSON", HTTPStatus.BAD_REQUEST)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def do_GET(self) -> None:
|
||||||
|
path = urlparse(self.path).path
|
||||||
|
if path == "/health":
|
||||||
|
self.json_response({"status": "ok"})
|
||||||
|
return
|
||||||
|
if not path.startswith("/api/"):
|
||||||
|
self.serve_static(path)
|
||||||
|
return
|
||||||
|
user, role = self.role()
|
||||||
|
if role is None:
|
||||||
|
self.error_json("Not allowed", HTTPStatus.FORBIDDEN)
|
||||||
|
return
|
||||||
|
if path == "/api/state":
|
||||||
|
self.json_response(
|
||||||
|
{
|
||||||
|
"user": user,
|
||||||
|
"role": role,
|
||||||
|
"shareDetails": self.server.store.share_details(),
|
||||||
|
"symptoms": [{"id": symptom_id, "label": label} for symptom_id, label in SYMPTOMS],
|
||||||
|
"days": self.visible_days(role),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if path in ("/api/export.json", "/api/export.csv"):
|
||||||
|
if role != "editor":
|
||||||
|
self.error_json("Only the owner can export", HTTPStatus.FORBIDDEN)
|
||||||
|
return
|
||||||
|
self.export(path.endswith(".csv"))
|
||||||
|
return
|
||||||
|
self.error_json("Not found", HTTPStatus.NOT_FOUND)
|
||||||
|
|
||||||
|
def do_PUT(self) -> None:
|
||||||
|
path = urlparse(self.path).path
|
||||||
|
_, role = self.role()
|
||||||
|
if role != "editor":
|
||||||
|
self.error_json("Read-only access", HTTPStatus.FORBIDDEN)
|
||||||
|
return
|
||||||
|
match = DAY_PATH.fullmatch(path)
|
||||||
|
if match:
|
||||||
|
if parse_day(match.group(1)) is None:
|
||||||
|
self.error_json("Invalid or future date", HTTPStatus.BAD_REQUEST)
|
||||||
|
return
|
||||||
|
payload = self.read_json()
|
||||||
|
if payload is None:
|
||||||
|
return
|
||||||
|
entry = clean_entry(payload)
|
||||||
|
if entry is None:
|
||||||
|
self.error_json("Invalid day log", HTTPStatus.BAD_REQUEST)
|
||||||
|
return
|
||||||
|
self.server.store.set_day(match.group(1), entry)
|
||||||
|
self.json_response({"day": match.group(1), **entry})
|
||||||
|
return
|
||||||
|
if path == "/api/settings":
|
||||||
|
payload = self.read_json()
|
||||||
|
if payload is None:
|
||||||
|
return
|
||||||
|
share = payload.get("shareDetails") if isinstance(payload, dict) else None
|
||||||
|
if not isinstance(share, bool):
|
||||||
|
self.error_json("shareDetails must be boolean", HTTPStatus.BAD_REQUEST)
|
||||||
|
return
|
||||||
|
self.server.store.set_share_details(share)
|
||||||
|
self.json_response({"shareDetails": share})
|
||||||
|
return
|
||||||
|
self.error_json("Not found", HTTPStatus.NOT_FOUND)
|
||||||
|
|
||||||
|
def do_POST(self) -> None:
|
||||||
|
path = urlparse(self.path).path
|
||||||
|
_, role = self.role()
|
||||||
|
if role != "editor":
|
||||||
|
self.error_json("Read-only access", HTTPStatus.FORBIDDEN)
|
||||||
|
return
|
||||||
|
if path != "/api/days":
|
||||||
|
self.error_json("Not found", HTTPStatus.NOT_FOUND)
|
||||||
|
return
|
||||||
|
payload = self.read_json()
|
||||||
|
if payload is None:
|
||||||
|
return
|
||||||
|
days = payload.get("days") if isinstance(payload, dict) else None
|
||||||
|
if not isinstance(days, dict) or not days or len(days) > MAX_BATCH:
|
||||||
|
self.error_json("days must be a non-empty object", HTTPStatus.BAD_REQUEST)
|
||||||
|
return
|
||||||
|
entries = {}
|
||||||
|
for day, value in days.items():
|
||||||
|
entry = clean_entry(value)
|
||||||
|
if parse_day(day) is None or entry is None:
|
||||||
|
self.error_json(f"Invalid log for {day}", HTTPStatus.BAD_REQUEST)
|
||||||
|
return
|
||||||
|
entries[day] = entry
|
||||||
|
self.json_response({"added": self.server.store.fill_days(entries)})
|
||||||
|
|
||||||
|
def export(self, as_csv: bool) -> None:
|
||||||
|
days = self.server.store.days()
|
||||||
|
stamp = date.today().isoformat()
|
||||||
|
if as_csv:
|
||||||
|
labels = dict(SYMPTOMS)
|
||||||
|
buffer = io.StringIO()
|
||||||
|
writer = csv.writer(buffer)
|
||||||
|
writer.writerow(["date", "flow", "symptoms"])
|
||||||
|
for day, entry in days.items():
|
||||||
|
writer.writerow([day, entry["flow"] or "", "; ".join(labels[item] for item in entry["symptoms"])])
|
||||||
|
body, content_type = buffer.getvalue().encode(), "text/csv; charset=utf-8"
|
||||||
|
else:
|
||||||
|
body = json.dumps({"exportedAt": utc_now(), "days": days}, indent=2).encode()
|
||||||
|
content_type = "application/json; charset=utf-8"
|
||||||
|
extension = "csv" if as_csv else "json"
|
||||||
|
self.send_body(body, content_type, extra={
|
||||||
|
"Content-Disposition": f'attachment; filename="flowtrack-{stamp}.{extension}"'
|
||||||
|
})
|
||||||
|
|
||||||
|
def serve_static(self, request_path: str) -> None:
|
||||||
|
relative = "index.html" if request_path == "/" else unquote(request_path.lstrip("/"))
|
||||||
|
if not relative or ".." in Path(relative).parts:
|
||||||
|
self.send_error(HTTPStatus.NOT_FOUND)
|
||||||
|
return
|
||||||
|
target = (STATIC_DIR / relative).resolve()
|
||||||
|
try:
|
||||||
|
target.relative_to(STATIC_DIR.resolve())
|
||||||
|
except ValueError:
|
||||||
|
self.send_error(HTTPStatus.NOT_FOUND)
|
||||||
|
return
|
||||||
|
if not target.is_file():
|
||||||
|
self.send_error(HTTPStatus.NOT_FOUND)
|
||||||
|
return
|
||||||
|
body = target.read_bytes()
|
||||||
|
content_type = mimetypes.guess_type(target.name)[0] or "application/octet-stream"
|
||||||
|
self.send_response(HTTPStatus.OK)
|
||||||
|
textual = content_type.startswith("text/") or content_type in {
|
||||||
|
"application/javascript",
|
||||||
|
"application/json",
|
||||||
|
"application/manifest+json",
|
||||||
|
"image/svg+xml",
|
||||||
|
}
|
||||||
|
self.send_header("Content-Type", f"{content_type}; charset=utf-8" if textual else content_type)
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
# Revalidate code on every load so a deploy reaches the home-screen app
|
||||||
|
# the next time it opens.
|
||||||
|
self.send_header("Cache-Control", "no-cache" if textual else "public, max-age=86400")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="Private period tracker")
|
||||||
|
parser.add_argument("--host", default="127.0.0.1")
|
||||||
|
parser.add_argument("--port", type=int, default=8093)
|
||||||
|
parser.add_argument("--database", default="./flowtrack.sqlite3")
|
||||||
|
parser.add_argument("--editor", action="append", default=[], help="user who owns and edits the data (repeatable)")
|
||||||
|
parser.add_argument("--viewer", action="append", default=[], help="user with read-only access (repeatable)")
|
||||||
|
parser.add_argument("--dev-user", help="identity to assume when no Remote-User header is sent (local testing only)")
|
||||||
|
parser.add_argument("--backup-to", type=Path, help="write a snapshot of the database to this directory and exit")
|
||||||
|
parser.add_argument("--keep", type=int, default=30, help="snapshots to keep with --backup-to")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||||
|
Path(args.database).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
store = Store(args.database)
|
||||||
|
store.initialize()
|
||||||
|
if args.backup_to:
|
||||||
|
logging.info("Wrote %s", store.backup(args.backup_to, args.keep))
|
||||||
|
return
|
||||||
|
server = TrackerServer((args.host, args.port), store, set(args.editor), set(args.viewer), args.dev_user)
|
||||||
|
logging.info("Listening on http://%s:%s", args.host, args.port)
|
||||||
|
server.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
27
flake.lock
generated
Normal file
27
flake.lock
generated
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1792030654,
|
||||||
|
"narHash": "sha256-OaQwAtPMTjQiY2sT/2/62UbSPhzP/0aDnWzMm/NCiUw=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "26f4107e47e3ad0ffd0db220469ba83b6f6d96e8",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-26.05",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
38
flake.nix
Normal file
38
flake.nix
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
{
|
||||||
|
description = "Private, self-hosted period tracker with cycle predictions";
|
||||||
|
|
||||||
|
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
|
||||||
|
|
||||||
|
outputs = { self, nixpkgs }:
|
||||||
|
let
|
||||||
|
forAllSystems = nixpkgs.lib.genAttrs [ "x86_64-linux" "aarch64-linux" ];
|
||||||
|
in
|
||||||
|
{
|
||||||
|
packages = forAllSystems (system:
|
||||||
|
let pkgs = import nixpkgs { inherit system; };
|
||||||
|
in {
|
||||||
|
default = pkgs.callPackage ./package.nix { };
|
||||||
|
flowtrack = self.packages.${system}.default;
|
||||||
|
});
|
||||||
|
|
||||||
|
nixosModules.default = import ./module.nix self;
|
||||||
|
nixosModules.flowtrack = self.nixosModules.default;
|
||||||
|
|
||||||
|
checks = forAllSystems (system:
|
||||||
|
let pkgs = import nixpkgs { inherit system; };
|
||||||
|
in {
|
||||||
|
inherit (self.packages.${system}) default;
|
||||||
|
tests = pkgs.runCommand "flowtrack-tests" { nativeBuildInputs = [ pkgs.python3 pkgs.nodejs ]; } ''
|
||||||
|
cp -r ${self} source
|
||||||
|
chmod -R u+w source
|
||||||
|
cd source
|
||||||
|
python3 -m unittest discover -s tests -v
|
||||||
|
python3 -m py_compile app.py
|
||||||
|
node --check static/app.js
|
||||||
|
node --check static/cycle.js
|
||||||
|
node tests/test_cycle.js
|
||||||
|
touch $out
|
||||||
|
'';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
104
module.nix
Normal file
104
module.nix
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
self: { config, lib, pkgs, ... }:
|
||||||
|
let
|
||||||
|
cfg = config.services.flowtrack;
|
||||||
|
database = "/var/lib/flowtrack/flowtrack.sqlite3";
|
||||||
|
hardening = {
|
||||||
|
User = "flowtrack";
|
||||||
|
Group = "flowtrack";
|
||||||
|
StateDirectory = "flowtrack";
|
||||||
|
StateDirectoryMode = "0700";
|
||||||
|
UMask = "0077";
|
||||||
|
NoNewPrivileges = true;
|
||||||
|
PrivateTmp = true;
|
||||||
|
PrivateDevices = true;
|
||||||
|
ProtectSystem = "strict";
|
||||||
|
ProtectHome = true;
|
||||||
|
ProtectKernelTunables = true;
|
||||||
|
ProtectKernelModules = true;
|
||||||
|
ProtectControlGroups = true;
|
||||||
|
RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ];
|
||||||
|
};
|
||||||
|
userFlags = lib.concatMapStringsSep " " (user: "--editor ${lib.escapeShellArg user}") cfg.editors
|
||||||
|
+ " " + lib.concatMapStringsSep " " (user: "--viewer ${lib.escapeShellArg user}") cfg.viewers;
|
||||||
|
in
|
||||||
|
{
|
||||||
|
options.services.flowtrack = {
|
||||||
|
enable = lib.mkEnableOption "the Flowtrack period tracker";
|
||||||
|
package = lib.mkOption {
|
||||||
|
type = lib.types.package;
|
||||||
|
default = self.packages.${pkgs.stdenv.hostPlatform.system}.default;
|
||||||
|
defaultText = lib.literalExpression "flowtrack.packages.${pkgs.stdenv.hostPlatform.system}.default";
|
||||||
|
description = "The flowtrack package to run.";
|
||||||
|
};
|
||||||
|
port = lib.mkOption {
|
||||||
|
type = lib.types.port;
|
||||||
|
default = 8093;
|
||||||
|
description = "Loopback TCP port used by the tracker.";
|
||||||
|
};
|
||||||
|
editors = lib.mkOption {
|
||||||
|
type = lib.types.listOf lib.types.str;
|
||||||
|
default = [ ];
|
||||||
|
description = "Remote-User names that own and edit the data.";
|
||||||
|
};
|
||||||
|
viewers = lib.mkOption {
|
||||||
|
type = lib.types.listOf lib.types.str;
|
||||||
|
default = [ ];
|
||||||
|
description = "Remote-User names with read-only access.";
|
||||||
|
};
|
||||||
|
backup = {
|
||||||
|
directory = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.str;
|
||||||
|
default = null;
|
||||||
|
description = "Directory for nightly database snapshots; null disables backups.";
|
||||||
|
};
|
||||||
|
keep = lib.mkOption {
|
||||||
|
type = lib.types.ints.positive;
|
||||||
|
default = 30;
|
||||||
|
description = "Number of nightly snapshots to keep.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf cfg.enable (lib.mkMerge [
|
||||||
|
{
|
||||||
|
users.users.flowtrack = {
|
||||||
|
isSystemUser = true;
|
||||||
|
group = "flowtrack";
|
||||||
|
description = "Flowtrack service user";
|
||||||
|
};
|
||||||
|
users.groups.flowtrack = { };
|
||||||
|
|
||||||
|
systemd.services.flowtrack = {
|
||||||
|
description = "Flowtrack period tracker";
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
after = [ "network.target" ];
|
||||||
|
serviceConfig = hardening // {
|
||||||
|
ExecStart = "${cfg.package}/bin/flowtrack --host 127.0.0.1 --port ${toString cfg.port} --database ${database} ${userFlags}";
|
||||||
|
Restart = "on-failure";
|
||||||
|
RestartSec = 3;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
(lib.mkIf (cfg.backup.directory != null) {
|
||||||
|
systemd.tmpfiles.rules = [ "d ${cfg.backup.directory} 0700 flowtrack flowtrack -" ];
|
||||||
|
|
||||||
|
systemd.services.flowtrack-backup = {
|
||||||
|
description = "Snapshot the Flowtrack database";
|
||||||
|
unitConfig.RequiresMountsFor = [ cfg.backup.directory ];
|
||||||
|
serviceConfig = hardening // {
|
||||||
|
Type = "oneshot";
|
||||||
|
ExecStart = "${cfg.package}/bin/flowtrack --database ${database} --backup-to ${cfg.backup.directory} --keep ${toString cfg.backup.keep}";
|
||||||
|
ReadWritePaths = [ cfg.backup.directory ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
systemd.timers.flowtrack-backup = {
|
||||||
|
wantedBy = [ "timers.target" ];
|
||||||
|
timerConfig = {
|
||||||
|
OnCalendar = "*-*-* 03:15:00";
|
||||||
|
Persistent = true;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
}
|
||||||
26
package.nix
Normal file
26
package.nix
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
{ lib, stdenvNoCC, makeWrapper, python3 }:
|
||||||
|
|
||||||
|
stdenvNoCC.mkDerivation {
|
||||||
|
pname = "flowtrack";
|
||||||
|
version = "1.0.0";
|
||||||
|
src = ./.;
|
||||||
|
|
||||||
|
nativeBuildInputs = [ makeWrapper ];
|
||||||
|
|
||||||
|
installPhase = ''
|
||||||
|
runHook preInstall
|
||||||
|
mkdir -p $out/share/flowtrack $out/bin
|
||||||
|
cp app.py $out/share/flowtrack/app.py
|
||||||
|
cp -r static $out/share/flowtrack/static
|
||||||
|
makeWrapper ${python3}/bin/python3 $out/bin/flowtrack \
|
||||||
|
--add-flags "$out/share/flowtrack/app.py"
|
||||||
|
runHook postInstall
|
||||||
|
'';
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
description = "Private, self-hosted period tracker with cycle predictions";
|
||||||
|
license = lib.licenses.mit;
|
||||||
|
platforms = lib.platforms.linux;
|
||||||
|
mainProgram = "flowtrack";
|
||||||
|
};
|
||||||
|
}
|
||||||
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); }
|
||||||
|
}
|
||||||
150
tests/test_app.py
Normal file
150
tests/test_app.py
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from http.client import HTTPConnection
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import app
|
||||||
|
|
||||||
|
|
||||||
|
class StoreTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
|
self.store = app.Store(Path(self.temp.name) / "flowtrack.sqlite3")
|
||||||
|
self.store.initialize()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.temp.cleanup()
|
||||||
|
|
||||||
|
def test_empty_log_deletes_the_day(self):
|
||||||
|
self.store.set_day("2026-10-01", {"flow": "heavy", "symptoms": ["cramps"]})
|
||||||
|
self.assertEqual(self.store.days(), {"2026-10-01": {"flow": "heavy", "symptoms": ["cramps"]}})
|
||||||
|
self.store.set_day("2026-10-01", {"flow": None, "symptoms": []})
|
||||||
|
self.assertEqual(self.store.days(), {})
|
||||||
|
|
||||||
|
def test_fill_never_overwrites(self):
|
||||||
|
self.store.set_day("2026-10-01", {"flow": "heavy", "symptoms": []})
|
||||||
|
added = self.store.fill_days({
|
||||||
|
"2026-10-01": {"flow": "period", "symptoms": []},
|
||||||
|
"2026-10-02": {"flow": "period", "symptoms": []},
|
||||||
|
})
|
||||||
|
self.assertEqual(added, 1)
|
||||||
|
self.assertEqual(self.store.days()["2026-10-01"]["flow"], "heavy")
|
||||||
|
|
||||||
|
def test_backup_rotates(self):
|
||||||
|
self.store.set_day("2026-10-01", {"flow": "light", "symptoms": []})
|
||||||
|
destination = Path(self.temp.name) / "backups"
|
||||||
|
destination.mkdir()
|
||||||
|
for stale in ("2026-01-01", "2026-01-02", "2026-01-03"):
|
||||||
|
(destination / f"flowtrack-{stale}.sqlite3").touch()
|
||||||
|
target = self.store.backup(destination, keep=2)
|
||||||
|
self.assertEqual(sorted(path.name for path in destination.iterdir()), ["flowtrack-2026-01-03.sqlite3", target.name])
|
||||||
|
with sqlite3.connect(target) as copy:
|
||||||
|
self.assertEqual(copy.execute("SELECT flow FROM days").fetchone()[0], "light")
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationTests(unittest.TestCase):
|
||||||
|
def test_dates(self):
|
||||||
|
self.assertIsNotNone(app.parse_day(date.today().isoformat()))
|
||||||
|
self.assertIsNotNone(app.parse_day((date.today() + timedelta(days=1)).isoformat()))
|
||||||
|
self.assertIsNone(app.parse_day((date.today() + timedelta(days=2)).isoformat()))
|
||||||
|
self.assertIsNone(app.parse_day("2026-02-30"))
|
||||||
|
self.assertIsNone(app.parse_day("26-10-01"))
|
||||||
|
|
||||||
|
def test_entries(self):
|
||||||
|
self.assertEqual(app.clean_entry({"flow": "light", "symptoms": ["acne", "cramps", "acne"]}), {"flow": "light", "symptoms": ["acne", "cramps"]})
|
||||||
|
self.assertIsNone(app.clean_entry({"flow": "gushing"}))
|
||||||
|
self.assertIsNone(app.clean_entry({"flow": None, "symptoms": ["<script>"]}))
|
||||||
|
self.assertIsNone(app.clean_entry({"flow": None, "symptoms": "cramps"}))
|
||||||
|
|
||||||
|
|
||||||
|
class ServerTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
|
store = app.Store(Path(self.temp.name) / "flowtrack.sqlite3")
|
||||||
|
store.initialize()
|
||||||
|
self.server = app.TrackerServer(("127.0.0.1", 0), store, {"kayla"}, {"fred"})
|
||||||
|
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||||
|
self.thread.start()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.server.shutdown()
|
||||||
|
self.server.server_close()
|
||||||
|
self.temp.cleanup()
|
||||||
|
|
||||||
|
def request(self, method, path, user=None, body=None):
|
||||||
|
connection = HTTPConnection("127.0.0.1", self.server.server_address[1], timeout=5)
|
||||||
|
headers = {"Remote-User": user} if user else {}
|
||||||
|
payload = None
|
||||||
|
if body is not None:
|
||||||
|
payload = json.dumps(body).encode()
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
connection.request(method, path, body=payload, headers=headers)
|
||||||
|
response = connection.getresponse()
|
||||||
|
data = response.read()
|
||||||
|
connection.close()
|
||||||
|
try:
|
||||||
|
return response.status, json.loads(data)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return response.status, data.decode()
|
||||||
|
|
||||||
|
def test_unknown_users_get_nothing(self):
|
||||||
|
self.assertEqual(self.request("GET", "/api/state")[0], 403)
|
||||||
|
self.assertEqual(self.request("GET", "/api/state", user="mallory")[0], 403)
|
||||||
|
self.assertEqual(self.request("PUT", "/api/days/2026-10-01", user="mallory", body={"flow": "light"})[0], 403)
|
||||||
|
|
||||||
|
def test_editor_round_trip_and_export(self):
|
||||||
|
status, _ = self.request("PUT", "/api/days/2026-10-01", user="kayla", body={"flow": "heavy", "symptoms": ["cramps"]})
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
status, state = self.request("GET", "/api/state", user="kayla")
|
||||||
|
self.assertEqual(state["role"], "editor")
|
||||||
|
self.assertEqual(state["days"]["2026-10-01"], {"flow": "heavy", "symptoms": ["cramps"]})
|
||||||
|
status, exported = self.request("GET", "/api/export.csv", user="kayla")
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertIn("2026-10-01,heavy,Cramps", exported)
|
||||||
|
|
||||||
|
def test_viewer_is_read_only_and_details_are_opt_in(self):
|
||||||
|
self.request("PUT", "/api/days/2026-10-01", user="kayla", body={"flow": "heavy", "symptoms": ["cramps"]})
|
||||||
|
self.request("PUT", "/api/days/2026-10-09", user="kayla", body={"flow": "spotting", "symptoms": []})
|
||||||
|
self.request("PUT", "/api/days/2026-10-10", user="kayla", body={"flow": None, "symptoms": ["acne"]})
|
||||||
|
|
||||||
|
status, state = self.request("GET", "/api/state", user="fred")
|
||||||
|
self.assertEqual((status, state["role"]), (200, "viewer"))
|
||||||
|
self.assertEqual(state["days"], {"2026-10-01": {"flow": "period", "symptoms": []}})
|
||||||
|
|
||||||
|
self.assertEqual(self.request("PUT", "/api/days/2026-10-02", user="fred", body={"flow": "light"})[0], 403)
|
||||||
|
self.assertEqual(self.request("PUT", "/api/settings", user="fred", body={"shareDetails": True})[0], 403)
|
||||||
|
self.assertEqual(self.request("POST", "/api/days", user="fred", body={"days": {"2026-10-02": {"flow": "period"}}})[0], 403)
|
||||||
|
self.assertEqual(self.request("GET", "/api/export.json", user="fred")[0], 403)
|
||||||
|
|
||||||
|
self.assertEqual(self.request("PUT", "/api/settings", user="kayla", body={"shareDetails": True})[0], 200)
|
||||||
|
_, state = self.request("GET", "/api/state", user="fred")
|
||||||
|
self.assertEqual(len(state["days"]), 3)
|
||||||
|
self.assertEqual(state["days"]["2026-10-01"]["symptoms"], ["cramps"])
|
||||||
|
|
||||||
|
def test_rejects_bad_writes(self):
|
||||||
|
future = (date.today() + timedelta(days=5)).isoformat()
|
||||||
|
self.assertEqual(self.request("PUT", f"/api/days/{future}", user="kayla", body={"flow": "light"})[0], 400)
|
||||||
|
self.assertEqual(self.request("PUT", "/api/days/2026-10-01", user="kayla", body={"flow": "lots"})[0], 400)
|
||||||
|
self.assertEqual(self.request("POST", "/api/days", user="kayla", body={"days": {"bad": {"flow": "period"}}})[0], 400)
|
||||||
|
|
||||||
|
def test_seed_batch(self):
|
||||||
|
status, result = self.request("POST", "/api/days", user="kayla", body={"days": {
|
||||||
|
"2026-09-01": {"flow": "period", "symptoms": []},
|
||||||
|
"2026-09-02": {"flow": "period", "symptoms": []},
|
||||||
|
}})
|
||||||
|
self.assertEqual((status, result), (200, {"added": 2}))
|
||||||
|
|
||||||
|
def test_static_files(self):
|
||||||
|
status, body = self.request("GET", "/")
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertIn("Flowtrack", body)
|
||||||
|
self.assertEqual(self.request("GET", "/../app.py")[0], 404)
|
||||||
|
self.assertEqual(self.request("GET", "/manifest.webmanifest")[0], 200)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
124
tests/test_cycle.js
Normal file
124
tests/test_cycle.js
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const cycle = require("../static/cycle.js");
|
||||||
|
|
||||||
|
const { toDay, toIso } = cycle;
|
||||||
|
|
||||||
|
// Logs `length` period days starting at each date.
|
||||||
|
function logPeriods(starts, length = 5, flow = "medium") {
|
||||||
|
const days = {};
|
||||||
|
for (const start of starts) {
|
||||||
|
for (let offset = 0; offset < length; offset += 1) days[toIso(toDay(start) + offset)] = { flow, symptoms: [] };
|
||||||
|
}
|
||||||
|
return days;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date helpers round-trip across month, year and leap-day boundaries.
|
||||||
|
for (const iso of ["2024-02-29", "2025-12-31", "2026-03-29", "2026-10-25"]) assert.equal(toIso(toDay(iso)), iso);
|
||||||
|
assert.equal(toDay("2026-03-01") - toDay("2026-02-28"), 1);
|
||||||
|
|
||||||
|
// Period days join into periods; one unlogged day does not split a period,
|
||||||
|
// spotting never counts, and two missing days do split it.
|
||||||
|
{
|
||||||
|
const days = logPeriods(["2026-01-01"], 3);
|
||||||
|
days["2026-01-05"] = { flow: "light", symptoms: [] };
|
||||||
|
days["2026-01-10"] = { flow: "spotting", symptoms: [] };
|
||||||
|
days["2026-01-13"] = { flow: "period", symptoms: [] };
|
||||||
|
const periods = cycle.periods(days);
|
||||||
|
assert.deepEqual(periods.map(period => [toIso(period.start), period.length]), [["2026-01-01", 5], ["2026-01-13", 1]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing logged: no predictions at all.
|
||||||
|
{
|
||||||
|
const summary = cycle.summarize({}, "2026-10-18");
|
||||||
|
assert.equal(summary.current, null);
|
||||||
|
assert.equal(summary.confidence, "none");
|
||||||
|
assert.deepEqual(summary.predictions, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One period: falls back to 28 days with low confidence and a wide range.
|
||||||
|
{
|
||||||
|
const summary = cycle.summarize(logPeriods(["2026-10-01"]), "2026-10-10");
|
||||||
|
assert.equal(summary.confidence, "none");
|
||||||
|
assert.equal(summary.cycleLength, 28);
|
||||||
|
assert.equal(toIso(summary.predictions[0].start), "2026-10-29");
|
||||||
|
assert.equal(summary.predictions[0].latest - summary.predictions[0].start, 4);
|
||||||
|
assert.equal(summary.current.cycleDay, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular 30-day cycles predict 30 days ahead, with the minimum ±2 range
|
||||||
|
// that widens for later cycles, and ovulation 14 days before each period.
|
||||||
|
{
|
||||||
|
const starts = ["2026-04-01", "2026-05-01", "2026-05-31", "2026-06-30", "2026-07-30", "2026-08-29", "2026-09-28"];
|
||||||
|
const summary = cycle.summarize(logPeriods(starts), "2026-10-10");
|
||||||
|
assert.equal(summary.confidence, "normal");
|
||||||
|
assert.equal(summary.sampleSize, 6);
|
||||||
|
assert.equal(summary.cycleLength, 30);
|
||||||
|
assert.equal(summary.periodLength, 5);
|
||||||
|
const [next, second] = summary.predictions;
|
||||||
|
assert.equal(toIso(next.start), "2026-10-28");
|
||||||
|
assert.equal(toIso(next.end), "2026-11-01");
|
||||||
|
assert.equal(next.start - next.earliest, 2);
|
||||||
|
assert.equal(toIso(second.start), "2026-11-27");
|
||||||
|
assert.equal(second.start - second.earliest, 3);
|
||||||
|
assert.equal(summary.predictions.length, 6);
|
||||||
|
const window = summary.fertile[0];
|
||||||
|
assert.equal(toIso(window.ovulation), "2026-10-14");
|
||||||
|
assert.equal(toIso(window.start), "2026-10-09");
|
||||||
|
assert.equal(toIso(window.end), "2026-10-15");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent cycles weigh more: a shift from 26 to 32 days pulls the mean
|
||||||
|
// above the plain average of 29.
|
||||||
|
{
|
||||||
|
const starts = ["2026-01-01", "2026-01-27", "2026-02-22", "2026-03-20", "2026-04-21", "2026-05-23", "2026-06-24"];
|
||||||
|
const summary = cycle.summarize(logPeriods(starts), "2026-07-01");
|
||||||
|
assert.ok(summary.cycleLength >= 30, `expected recent cycles to dominate, got ${summary.cycleLength}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A gap from a forgotten period (two cycles' worth) is excluded from the average.
|
||||||
|
{
|
||||||
|
const starts = ["2026-01-01", "2026-01-29", "2026-04-23", "2026-05-21", "2026-06-18"];
|
||||||
|
const summary = cycle.summarize(logPeriods(starts), "2026-06-20");
|
||||||
|
assert.deepEqual(summary.cycles.map(item => item.used), [true, false, true, true]);
|
||||||
|
assert.equal(summary.cycleLength, 28);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Late: once the expected day has passed, the next period is expected from
|
||||||
|
// tomorrow, while this cycle's fertile window stays where it was.
|
||||||
|
{
|
||||||
|
const starts = ["2026-07-01", "2026-07-29", "2026-08-26", "2026-09-23"];
|
||||||
|
const summary = cycle.summarize(logPeriods(starts), "2026-10-24");
|
||||||
|
assert.equal(summary.late, 3);
|
||||||
|
assert.equal(toIso(summary.predictions[0].start), "2026-10-25");
|
||||||
|
assert.equal(toIso(summary.fertile[0].ovulation), "2026-10-07");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Due today is not late yet.
|
||||||
|
{
|
||||||
|
const summary = cycle.summarize(logPeriods(["2026-08-26", "2026-09-23"]), "2026-10-21");
|
||||||
|
assert.equal(summary.late, 0);
|
||||||
|
assert.equal(toIso(summary.predictions[0].start), "2026-10-21");
|
||||||
|
}
|
||||||
|
|
||||||
|
// An ongoing period is flagged and does not count towards period length yet.
|
||||||
|
{
|
||||||
|
const days = logPeriods(["2026-08-01", "2026-08-29"], 6);
|
||||||
|
Object.assign(days, logPeriods(["2026-09-26"], 2));
|
||||||
|
const summary = cycle.summarize(days, "2026-09-27");
|
||||||
|
assert.equal(summary.current.onPeriod, true);
|
||||||
|
assert.equal(summary.periodLength, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Days logged after "today" (a phone ahead of the server) are ignored.
|
||||||
|
{
|
||||||
|
const summary = cycle.summarize(logPeriods(["2026-09-01", "2026-10-19"]), "2026-10-18");
|
||||||
|
assert.equal(toIso(summary.current.start), "2026-09-01");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Very short cycles never place the fertile window inside the period.
|
||||||
|
{
|
||||||
|
const summary = cycle.summarize(logPeriods(["2026-08-01", "2026-08-19", "2026-09-06", "2026-09-24"], 5), "2026-09-30");
|
||||||
|
for (const window of summary.fertile) assert.ok(window.start > toDay("2026-09-28"));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("cycle tests passed");
|
||||||
Loading…
Add table
Add a link
Reference in a new issue