From 94372c6abc7f93070eddd9d0ac35962a36567309 Mon Sep 17 00:00:00 2001 From: rope Date: Sun, 18 Oct 2026 08:37:13 +0100 Subject: [PATCH] Flowtrack: private period tracker with cycle predictions Co-Authored-By: Claude Opus 5.5 --- .gitignore | 3 + LICENSE | 21 ++ README.md | 56 ++++ app.py | 421 ++++++++++++++++++++++++++++ flake.lock | 27 ++ flake.nix | 38 +++ module.nix | 104 +++++++ package.nix | 26 ++ static/app.js | 535 ++++++++++++++++++++++++++++++++++++ static/apple-touch-icon.png | Bin 0 -> 3394 bytes static/cycle.js | 143 ++++++++++ static/icon-192.png | Bin 0 -> 3601 bytes static/icon-512.png | Bin 0 -> 9906 bytes static/icon.svg | 5 + static/index.html | 130 +++++++++ static/manifest.webmanifest | 15 + static/styles.css | 368 +++++++++++++++++++++++++ tests/test_app.py | 150 ++++++++++ tests/test_cycle.js | 124 +++++++++ 19 files changed, 2166 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 app.py create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 module.nix create mode 100644 package.nix create mode 100644 static/app.js create mode 100644 static/apple-touch-icon.png create mode 100644 static/cycle.js create mode 100644 static/icon-192.png create mode 100644 static/icon-512.png create mode 100644 static/icon.svg create mode 100644 static/index.html create mode 100644 static/manifest.webmanifest create mode 100644 static/styles.css create mode 100644 tests/test_app.py create mode 100644 tests/test_cycle.js 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 0000000000000000000000000000000000000000..53fd863ed172746ca1c1dd035e0bb736b0a171fb GIT binary patch literal 3394 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 iHGovN{s%C1No5S { + 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 0000000000000000000000000000000000000000..7be5691db94f67655f3c79014d4a39ab401cb640 GIT binary patch literal 3601 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

Z8Zv9bagX_3We8x8yglz{ij1Q>CMq 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 LBlq#1`Sbq=(!|o} literal 0 HcmV?d00001 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": ["