commit 94372c6abc7f93070eddd9d0ac35962a36567309 Author: rope Date: Sun Oct 18 08:37:13 2026 +0100 Flowtrack: private period tracker with cycle predictions Co-Authored-By: Claude Opus 5.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..43b78d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +result +__pycache__/ +*.sqlite3* diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c4c083a --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..68e355d --- /dev/null +++ b/README.md @@ -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. diff --git a/app.py b/app.py new file mode 100644 index 0000000..76cdbb0 --- /dev/null +++ b/app.py @@ -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() diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..84c7095 --- /dev/null +++ b/flake.lock @@ -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 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..7c1ce7c --- /dev/null +++ b/flake.nix @@ -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 + ''; + }); + }; +} diff --git a/module.nix b/module.nix new file mode 100644 index 0000000..f73e128 --- /dev/null +++ b/module.nix @@ -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; + }; + }; + }) + ]); +} diff --git a/package.nix b/package.nix new file mode 100644 index 0000000..7f4c84e --- /dev/null +++ b/package.nix @@ -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"; + }; +} diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..06dd394 --- /dev/null +++ b/static/app.js @@ -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(); +})(); diff --git a/static/apple-touch-icon.png b/static/apple-touch-icon.png new file mode 100644 index 0000000..53fd863 Binary files /dev/null and b/static/apple-touch-icon.png differ diff --git a/static/cycle.js b/static/cycle.js new file mode 100644 index 0000000..b3c2240 --- /dev/null +++ b/static/cycle.js @@ -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 }; +}); diff --git a/static/icon-192.png b/static/icon-192.png new file mode 100644 index 0000000..7be5691 Binary files /dev/null and b/static/icon-192.png differ diff --git a/static/icon-512.png b/static/icon-512.png new file mode 100644 index 0000000..6f04ca6 Binary files /dev/null and b/static/icon-512.png differ diff --git a/static/icon.svg b/static/icon.svg new file mode 100644 index 0000000..bff2783 --- /dev/null +++ b/static/icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..4200907 --- /dev/null +++ b/static/index.html @@ -0,0 +1,130 @@ + + + + + + Flowtrack + + + + + + + + + + + + + +
+

Flowtrack

+ +
+ +
+ + +
+
+
+

Predictions are estimates from your own history. They are not a form of contraception.

+
+ + + + + + +
+ + + + +
+
+

+ +
+
+
+
+ + + + + diff --git a/static/manifest.webmanifest b/static/manifest.webmanifest new file mode 100644 index 0000000..428cffd --- /dev/null +++ b/static/manifest.webmanifest @@ -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" } + ] +} diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..e66ffc0 --- /dev/null +++ b/static/styles.css @@ -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); } +} diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..cba7066 --- /dev/null +++ b/tests/test_app.py @@ -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": ["