Flowtrack: private period tracker with cycle predictions

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-18 08:37:13 +01:00
commit 94372c6abc
19 changed files with 2166 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
result
__pycache__/
*.sqlite3*

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Fredrik Gregersen
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

56
README.md Normal file
View file

@ -0,0 +1,56 @@
# Flowtrack
A private, self-hosted period tracker. It logs period days with flow and
symptoms, predicts the next six periods, and estimates the fertile window.
It installs on a phone home screen as a web app.
The app has no runtime dependencies beyond the Python standard library, and it
stores everything in one SQLite file. Nginx and Authelia provide the access
boundary.
## Predictions
- Periods are runs of logged period days. Spotting doesn't count, and one
missing day inside a run doesn't split it.
- The cycle length is a weighted average of up to the last 12 cycles, with
recent cycles counting more. Cycles shorter than 15 days or longer than 60
are left out because they usually mean a log was missed.
- Each prediction is a range of ± the cycle's standard deviation (at least 2
days, or 4 with fewer than three cycles). The range widens with √n for later
cycles.
- Ovulation is estimated 14 days before the next period. The fertile window
runs from 5 days before ovulation to 1 day after. These are estimates, not
contraception.
- Once the expected day passes, the next period is expected from tomorrow,
and later predictions move with it.
The logic lives in `static/cycle.js` and is tested in `tests/test_cycle.js`.
## Access
Nginx must overwrite `Remote-User` with the Authelia username. Users listed
with `--editor` can log and export. Users listed with `--viewer` see period
days and predictions only. They also see flow and symptoms if the owner turns
on sharing in Settings. Everyone else gets 403.
## Run locally
```sh
python3 app.py --database /tmp/flowtrack.sqlite3 --editor me --dev-user me
```
Then open `http://127.0.0.1:8093`.
## NixOS module
```nix
services.flowtrack = {
enable = true;
editors = [ "kayla" ];
viewers = [ "fred" ];
backup.directory = "/mnt/disk2/backups/flowtrack"; # nightly, keeps 30
};
```
`python3 app.py --database DB --backup-to DIR --keep N` takes a consistent
snapshot by hand.

421
app.py Normal file
View file

@ -0,0 +1,421 @@
#!/usr/bin/env python3
"""Small, dependency-free server for a private period tracker."""
from __future__ import annotations
import argparse
import csv
import io
import json
import logging
import mimetypes
import re
import sqlite3
from contextlib import closing
from datetime import date, datetime, timedelta, timezone
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import unquote, urlparse
APP_DIR = Path(__file__).resolve().parent
STATIC_DIR = APP_DIR / "static"
DAY_PATH = re.compile(r"^/api/days/(\d{4}-\d{2}-\d{2})$")
MAX_BODY = 64 * 1024
MAX_BATCH = 400
FLOWS = ("spotting", "period", "light", "medium", "heavy")
PERIOD_FLOWS = frozenset(("period", "light", "medium", "heavy"))
SYMPTOMS = (
("cramps", "Cramps"),
("headache", "Headache"),
("bloating", "Bloating"),
("tender-breasts", "Tender breasts"),
("acne", "Acne"),
("fatigue", "Fatigue"),
("back-pain", "Back pain"),
("nausea", "Nausea"),
("cravings", "Cravings"),
("mood-swings", "Mood swings"),
)
SYMPTOM_IDS = frozenset(symptom_id for symptom_id, _ in SYMPTOMS)
mimetypes.add_type("application/manifest+json", ".webmanifest")
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def parse_day(value: object) -> date | None:
"""A calendar date that is not in the future.
One day of slack covers a phone that is ahead of the server's timezone.
"""
if not isinstance(value, str) or not re.fullmatch(r"\d{4}-\d{2}-\d{2}", value):
return None
try:
parsed = date.fromisoformat(value)
except ValueError:
return None
if parsed.year < 1990 or parsed > date.today() + timedelta(days=1):
return None
return parsed
def clean_entry(value: object) -> dict | None:
"""Validate one day's log; returns None when it is malformed."""
if not isinstance(value, dict):
return None
flow = value.get("flow")
symptoms = value.get("symptoms", [])
if flow is not None and flow not in FLOWS:
return None
if not isinstance(symptoms, list) or not all(item in SYMPTOM_IDS for item in symptoms):
return None
return {"flow": flow, "symptoms": sorted(set(symptoms))}
class Store:
def __init__(self, path: str | Path):
self.path = str(path)
def connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(self.path, timeout=10)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA journal_mode=WAL")
connection.execute("PRAGMA busy_timeout=10000")
return connection
def initialize(self) -> None:
with closing(self.connect()) as db, db:
db.executescript(
"""
CREATE TABLE IF NOT EXISTS days (
day TEXT PRIMARY KEY,
flow TEXT,
symptoms TEXT NOT NULL DEFAULT '[]',
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
INSERT OR IGNORE INTO settings (key, value) VALUES ('share_details', 'false');
"""
)
def days(self) -> dict[str, dict]:
with closing(self.connect()) as db, db:
rows = db.execute("SELECT day, flow, symptoms FROM days ORDER BY day").fetchall()
return {row["day"]: {"flow": row["flow"], "symptoms": json.loads(row["symptoms"])} for row in rows}
def share_details(self) -> bool:
with closing(self.connect()) as db, db:
row = db.execute("SELECT value FROM settings WHERE key = 'share_details'").fetchone()
return row is not None and row["value"] == "true"
def set_share_details(self, enabled: bool) -> None:
with closing(self.connect()) as db, db:
db.execute(
"UPDATE settings SET value = ? WHERE key = 'share_details'",
("true" if enabled else "false",),
)
@staticmethod
def _write(db: sqlite3.Connection, day: str, entry: dict, changed_at: str) -> None:
# An empty log is the same as no log, so the row goes away.
if entry["flow"] is None and not entry["symptoms"]:
db.execute("DELETE FROM days WHERE day = ?", (day,))
return
db.execute(
"""
INSERT INTO days (day, flow, symptoms, updated_at) VALUES (?, ?, ?, ?)
ON CONFLICT(day) DO UPDATE SET
flow = excluded.flow,
symptoms = excluded.symptoms,
updated_at = excluded.updated_at
""",
(day, entry["flow"], json.dumps(entry["symptoms"]), changed_at),
)
def set_day(self, day: str, entry: dict) -> None:
with closing(self.connect()) as db, db:
self._write(db, day, entry, utc_now())
def fill_days(self, entries: dict[str, dict]) -> int:
"""Add logs for days that have none yet; existing logs always win."""
changed_at = utc_now()
added = 0
with closing(self.connect()) as db, db:
db.execute("BEGIN IMMEDIATE")
for day, entry in sorted(entries.items()):
if db.execute("SELECT 1 FROM days WHERE day = ?", (day,)).fetchone():
continue
self._write(db, day, entry, changed_at)
added += 1
return added
def backup(self, destination: Path, keep: int) -> Path:
"""Write today's consistent snapshot and prune all but the newest `keep`."""
destination.mkdir(parents=True, exist_ok=True)
target = destination / f"flowtrack-{date.today().isoformat()}.sqlite3"
partial = target.with_suffix(".partial")
with closing(self.connect()) as source, closing(sqlite3.connect(partial)) as copy:
source.backup(copy)
partial.replace(target)
snapshots = sorted(destination.glob("flowtrack-*.sqlite3"))
for old in snapshots[:-keep] if keep > 0 else []:
old.unlink()
return target
class TrackerServer(ThreadingHTTPServer):
daemon_threads = True
def __init__(self, address: tuple[str, int], store: Store, editors: set[str], viewers: set[str], dev_user: str | None = None):
super().__init__(address, Handler)
self.store = store
self.editors = editors
self.viewers = viewers
self.dev_user = dev_user
class Handler(BaseHTTPRequestHandler):
server: TrackerServer
def log_message(self, format: str, *args: object) -> None:
logging.info("%s - %s", self.address_string(), format % args)
def end_headers(self) -> None:
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("X-Frame-Options", "DENY")
self.send_header("Referrer-Policy", "no-referrer")
self.send_header("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
self.send_header(
"Content-Security-Policy",
"default-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
)
super().end_headers()
def send_body(self, body: bytes, content_type: str, status: HTTPStatus = HTTPStatus.OK, extra: dict | None = None) -> None:
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
for name, value in (extra or {}).items():
self.send_header(name, value)
self.end_headers()
self.wfile.write(body)
def json_response(self, payload: dict, status: HTTPStatus = HTTPStatus.OK) -> None:
self.send_body(json.dumps(payload, separators=(",", ":")).encode(), "application/json; charset=utf-8", status)
def error_json(self, message: str, status: HTTPStatus) -> None:
self.json_response({"error": message}, status)
def role(self) -> tuple[str, str | None]:
# nginx overwrites Remote-User with the Authelia identity, so it can
# be trusted here; the app itself only listens on loopback.
user = self.headers.get("Remote-User", "").strip() or self.server.dev_user or ""
if user in self.server.editors:
return user, "editor"
if user in self.server.viewers:
return user, "viewer"
return user, None
def visible_days(self, role: str) -> dict[str, dict]:
days = self.server.store.days()
if role == "editor" or self.server.store.share_details():
return days
# Without sharing a viewer sees which days were period days, nothing else.
return {
day: {"flow": "period", "symptoms": []}
for day, entry in days.items()
if entry["flow"] in PERIOD_FLOWS
}
def read_json(self) -> object:
if self.headers.get_content_type() != "application/json":
self.error_json("Expected application/json", HTTPStatus.UNSUPPORTED_MEDIA_TYPE)
return None
try:
length = int(self.headers.get("Content-Length", "0"))
except ValueError:
length = -1
if length <= 0 or length > MAX_BODY:
self.error_json("Invalid request size", HTTPStatus.BAD_REQUEST)
return None
try:
return json.loads(self.rfile.read(length))
except (json.JSONDecodeError, UnicodeDecodeError):
self.error_json("Invalid JSON", HTTPStatus.BAD_REQUEST)
return None
def do_GET(self) -> None:
path = urlparse(self.path).path
if path == "/health":
self.json_response({"status": "ok"})
return
if not path.startswith("/api/"):
self.serve_static(path)
return
user, role = self.role()
if role is None:
self.error_json("Not allowed", HTTPStatus.FORBIDDEN)
return
if path == "/api/state":
self.json_response(
{
"user": user,
"role": role,
"shareDetails": self.server.store.share_details(),
"symptoms": [{"id": symptom_id, "label": label} for symptom_id, label in SYMPTOMS],
"days": self.visible_days(role),
}
)
return
if path in ("/api/export.json", "/api/export.csv"):
if role != "editor":
self.error_json("Only the owner can export", HTTPStatus.FORBIDDEN)
return
self.export(path.endswith(".csv"))
return
self.error_json("Not found", HTTPStatus.NOT_FOUND)
def do_PUT(self) -> None:
path = urlparse(self.path).path
_, role = self.role()
if role != "editor":
self.error_json("Read-only access", HTTPStatus.FORBIDDEN)
return
match = DAY_PATH.fullmatch(path)
if match:
if parse_day(match.group(1)) is None:
self.error_json("Invalid or future date", HTTPStatus.BAD_REQUEST)
return
payload = self.read_json()
if payload is None:
return
entry = clean_entry(payload)
if entry is None:
self.error_json("Invalid day log", HTTPStatus.BAD_REQUEST)
return
self.server.store.set_day(match.group(1), entry)
self.json_response({"day": match.group(1), **entry})
return
if path == "/api/settings":
payload = self.read_json()
if payload is None:
return
share = payload.get("shareDetails") if isinstance(payload, dict) else None
if not isinstance(share, bool):
self.error_json("shareDetails must be boolean", HTTPStatus.BAD_REQUEST)
return
self.server.store.set_share_details(share)
self.json_response({"shareDetails": share})
return
self.error_json("Not found", HTTPStatus.NOT_FOUND)
def do_POST(self) -> None:
path = urlparse(self.path).path
_, role = self.role()
if role != "editor":
self.error_json("Read-only access", HTTPStatus.FORBIDDEN)
return
if path != "/api/days":
self.error_json("Not found", HTTPStatus.NOT_FOUND)
return
payload = self.read_json()
if payload is None:
return
days = payload.get("days") if isinstance(payload, dict) else None
if not isinstance(days, dict) or not days or len(days) > MAX_BATCH:
self.error_json("days must be a non-empty object", HTTPStatus.BAD_REQUEST)
return
entries = {}
for day, value in days.items():
entry = clean_entry(value)
if parse_day(day) is None or entry is None:
self.error_json(f"Invalid log for {day}", HTTPStatus.BAD_REQUEST)
return
entries[day] = entry
self.json_response({"added": self.server.store.fill_days(entries)})
def export(self, as_csv: bool) -> None:
days = self.server.store.days()
stamp = date.today().isoformat()
if as_csv:
labels = dict(SYMPTOMS)
buffer = io.StringIO()
writer = csv.writer(buffer)
writer.writerow(["date", "flow", "symptoms"])
for day, entry in days.items():
writer.writerow([day, entry["flow"] or "", "; ".join(labels[item] for item in entry["symptoms"])])
body, content_type = buffer.getvalue().encode(), "text/csv; charset=utf-8"
else:
body = json.dumps({"exportedAt": utc_now(), "days": days}, indent=2).encode()
content_type = "application/json; charset=utf-8"
extension = "csv" if as_csv else "json"
self.send_body(body, content_type, extra={
"Content-Disposition": f'attachment; filename="flowtrack-{stamp}.{extension}"'
})
def serve_static(self, request_path: str) -> None:
relative = "index.html" if request_path == "/" else unquote(request_path.lstrip("/"))
if not relative or ".." in Path(relative).parts:
self.send_error(HTTPStatus.NOT_FOUND)
return
target = (STATIC_DIR / relative).resolve()
try:
target.relative_to(STATIC_DIR.resolve())
except ValueError:
self.send_error(HTTPStatus.NOT_FOUND)
return
if not target.is_file():
self.send_error(HTTPStatus.NOT_FOUND)
return
body = target.read_bytes()
content_type = mimetypes.guess_type(target.name)[0] or "application/octet-stream"
self.send_response(HTTPStatus.OK)
textual = content_type.startswith("text/") or content_type in {
"application/javascript",
"application/json",
"application/manifest+json",
"image/svg+xml",
}
self.send_header("Content-Type", f"{content_type}; charset=utf-8" if textual else content_type)
self.send_header("Content-Length", str(len(body)))
# Revalidate code on every load so a deploy reaches the home-screen app
# the next time it opens.
self.send_header("Cache-Control", "no-cache" if textual else "public, max-age=86400")
self.end_headers()
self.wfile.write(body)
def main() -> None:
parser = argparse.ArgumentParser(description="Private period tracker")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8093)
parser.add_argument("--database", default="./flowtrack.sqlite3")
parser.add_argument("--editor", action="append", default=[], help="user who owns and edits the data (repeatable)")
parser.add_argument("--viewer", action="append", default=[], help="user with read-only access (repeatable)")
parser.add_argument("--dev-user", help="identity to assume when no Remote-User header is sent (local testing only)")
parser.add_argument("--backup-to", type=Path, help="write a snapshot of the database to this directory and exit")
parser.add_argument("--keep", type=int, default=30, help="snapshots to keep with --backup-to")
args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
Path(args.database).parent.mkdir(parents=True, exist_ok=True)
store = Store(args.database)
store.initialize()
if args.backup_to:
logging.info("Wrote %s", store.backup(args.backup_to, args.keep))
return
server = TrackerServer((args.host, args.port), store, set(args.editor), set(args.viewer), args.dev_user)
logging.info("Listening on http://%s:%s", args.host, args.port)
server.serve_forever()
if __name__ == "__main__":
main()

27
flake.lock generated Normal file
View file

@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1792030654,
"narHash": "sha256-OaQwAtPMTjQiY2sT/2/62UbSPhzP/0aDnWzMm/NCiUw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "26f4107e47e3ad0ffd0db220469ba83b6f6d96e8",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-26.05",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

38
flake.nix Normal file
View file

@ -0,0 +1,38 @@
{
description = "Private, self-hosted period tracker with cycle predictions";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
outputs = { self, nixpkgs }:
let
forAllSystems = nixpkgs.lib.genAttrs [ "x86_64-linux" "aarch64-linux" ];
in
{
packages = forAllSystems (system:
let pkgs = import nixpkgs { inherit system; };
in {
default = pkgs.callPackage ./package.nix { };
flowtrack = self.packages.${system}.default;
});
nixosModules.default = import ./module.nix self;
nixosModules.flowtrack = self.nixosModules.default;
checks = forAllSystems (system:
let pkgs = import nixpkgs { inherit system; };
in {
inherit (self.packages.${system}) default;
tests = pkgs.runCommand "flowtrack-tests" { nativeBuildInputs = [ pkgs.python3 pkgs.nodejs ]; } ''
cp -r ${self} source
chmod -R u+w source
cd source
python3 -m unittest discover -s tests -v
python3 -m py_compile app.py
node --check static/app.js
node --check static/cycle.js
node tests/test_cycle.js
touch $out
'';
});
};
}

104
module.nix Normal file
View file

@ -0,0 +1,104 @@
self: { config, lib, pkgs, ... }:
let
cfg = config.services.flowtrack;
database = "/var/lib/flowtrack/flowtrack.sqlite3";
hardening = {
User = "flowtrack";
Group = "flowtrack";
StateDirectory = "flowtrack";
StateDirectoryMode = "0700";
UMask = "0077";
NoNewPrivileges = true;
PrivateTmp = true;
PrivateDevices = true;
ProtectSystem = "strict";
ProtectHome = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ];
};
userFlags = lib.concatMapStringsSep " " (user: "--editor ${lib.escapeShellArg user}") cfg.editors
+ " " + lib.concatMapStringsSep " " (user: "--viewer ${lib.escapeShellArg user}") cfg.viewers;
in
{
options.services.flowtrack = {
enable = lib.mkEnableOption "the Flowtrack period tracker";
package = lib.mkOption {
type = lib.types.package;
default = self.packages.${pkgs.stdenv.hostPlatform.system}.default;
defaultText = lib.literalExpression "flowtrack.packages.${pkgs.stdenv.hostPlatform.system}.default";
description = "The flowtrack package to run.";
};
port = lib.mkOption {
type = lib.types.port;
default = 8093;
description = "Loopback TCP port used by the tracker.";
};
editors = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = "Remote-User names that own and edit the data.";
};
viewers = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = "Remote-User names with read-only access.";
};
backup = {
directory = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = "Directory for nightly database snapshots; null disables backups.";
};
keep = lib.mkOption {
type = lib.types.ints.positive;
default = 30;
description = "Number of nightly snapshots to keep.";
};
};
};
config = lib.mkIf cfg.enable (lib.mkMerge [
{
users.users.flowtrack = {
isSystemUser = true;
group = "flowtrack";
description = "Flowtrack service user";
};
users.groups.flowtrack = { };
systemd.services.flowtrack = {
description = "Flowtrack period tracker";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
serviceConfig = hardening // {
ExecStart = "${cfg.package}/bin/flowtrack --host 127.0.0.1 --port ${toString cfg.port} --database ${database} ${userFlags}";
Restart = "on-failure";
RestartSec = 3;
};
};
}
(lib.mkIf (cfg.backup.directory != null) {
systemd.tmpfiles.rules = [ "d ${cfg.backup.directory} 0700 flowtrack flowtrack -" ];
systemd.services.flowtrack-backup = {
description = "Snapshot the Flowtrack database";
unitConfig.RequiresMountsFor = [ cfg.backup.directory ];
serviceConfig = hardening // {
Type = "oneshot";
ExecStart = "${cfg.package}/bin/flowtrack --database ${database} --backup-to ${cfg.backup.directory} --keep ${toString cfg.backup.keep}";
ReadWritePaths = [ cfg.backup.directory ];
};
};
systemd.timers.flowtrack-backup = {
wantedBy = [ "timers.target" ];
timerConfig = {
OnCalendar = "*-*-* 03:15:00";
Persistent = true;
};
};
})
]);
}

26
package.nix Normal file
View file

@ -0,0 +1,26 @@
{ lib, stdenvNoCC, makeWrapper, python3 }:
stdenvNoCC.mkDerivation {
pname = "flowtrack";
version = "1.0.0";
src = ./.;
nativeBuildInputs = [ makeWrapper ];
installPhase = ''
runHook preInstall
mkdir -p $out/share/flowtrack $out/bin
cp app.py $out/share/flowtrack/app.py
cp -r static $out/share/flowtrack/static
makeWrapper ${python3}/bin/python3 $out/bin/flowtrack \
--add-flags "$out/share/flowtrack/app.py"
runHook postInstall
'';
meta = {
description = "Private, self-hosted period tracker with cycle predictions";
license = lib.licenses.mit;
platforms = lib.platforms.linux;
mainProgram = "flowtrack";
};
}

535
static/app.js Normal file
View file

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

BIN
static/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

143
static/cycle.js Normal file
View file

@ -0,0 +1,143 @@
(function exposeCycle(root, factory) {
const api = factory();
if (typeof module === "object" && module.exports) module.exports = api;
if (root) root.Cycle = api;
})(typeof window === "undefined" ? globalThis : window, () => {
const MS_PER_DAY = 86400000;
const PERIOD_FLOWS = new Set(["period", "light", "medium", "heavy"]);
const DEFAULT_CYCLE = 28;
const DEFAULT_PERIOD = 5;
// Days from ovulation to the next period. Fairly constant between people,
// which is why ovulation is counted backwards from the predicted start.
const LUTEAL_DAYS = 14;
// Outside this range a "cycle" is almost always a missed or stray log.
const MIN_CYCLE = 15;
const MAX_CYCLE = 60;
const HISTORY = 12;
const FORECAST = 6;
// Dates are handled as whole days since 1970-01-01 so month lengths and
// DST shifts never enter the arithmetic.
const toDay = iso => {
const [year, month, day] = iso.split("-").map(Number);
return Math.round(Date.UTC(year, month - 1, day) / MS_PER_DAY);
};
const toIso = number => new Date(number * MS_PER_DAY).toISOString().slice(0, 10);
// Consecutive period days form one period. A single unlogged day inside a
// period (forgot to log, or a light day logged as nothing) does not split it.
function periods(days) {
const periodDays = Object.keys(days)
.filter(iso => PERIOD_FLOWS.has(days[iso] && days[iso].flow))
.map(toDay)
.sort((a, b) => a - b);
const result = [];
for (const day of periodDays) {
const last = result[result.length - 1];
if (last && day - last.end <= 2) last.end = day;
else result.push({ start: day, end: day });
}
for (const period of result) period.length = period.end - period.start + 1;
return result;
}
function cycles(periodList) {
const result = [];
for (let index = 1; index < periodList.length; index += 1) {
const start = periodList[index - 1].start;
const length = periodList[index].start - start;
result.push({
start,
length,
periodLength: periodList[index - 1].length,
used: length >= MIN_CYCLE && length <= MAX_CYCLE
});
}
return result;
}
// Recent cycles count more (weights 1..n, oldest first), so a gradual shift
// in her cycle shows up in predictions within a few months.
function weightedStats(values) {
const weights = values.map((_, index) => index + 1);
const total = weights.reduce((sum, weight) => sum + weight, 0);
const mean = values.reduce((sum, value, index) => sum + value * weights[index], 0) / total;
const variance = values.reduce((sum, value, index) => sum + weights[index] * (value - mean) ** 2, 0) / total;
return { mean, sd: Math.sqrt(variance) };
}
function fertileWindow(nextStart, previousEnd) {
const ovulation = nextStart - LUTEAL_DAYS;
if (ovulation <= previousEnd) return null;
return { ovulation, start: Math.max(ovulation - 5, previousEnd + 1), end: ovulation + 1 };
}
function summarize(days, todayIso) {
const today = toDay(todayIso);
const periodList = periods(days).filter(period => period.start <= today);
const cycleList = cycles(periodList);
const used = cycleList.filter(cycle => cycle.used).slice(-HISTORY);
const summary = {
today,
periods: periodList,
cycles: cycleList,
sampleSize: used.length,
confidence: used.length === 0 ? "none" : used.length < 3 ? "low" : "normal",
cycleLength: DEFAULT_CYCLE,
cycleSpread: null,
periodLength: DEFAULT_PERIOD,
current: null,
late: 0,
predictions: [],
fertile: []
};
if (!periodList.length) return summary;
if (used.length) {
const stats = weightedStats(used.map(cycle => cycle.length));
summary.cycleLength = Math.round(stats.mean);
summary.cycleSpread = stats.sd;
}
const last = periodList[periodList.length - 1];
// The latest period may still be going, so it says nothing about length yet.
const finished = periodList.filter(period => period !== last || period.end < today - 1);
const lengths = finished.filter(period => period.length >= 2).slice(-6).map(period => period.length);
if (lengths.length) {
const mean = lengths.reduce((sum, value) => sum + value, 0) / lengths.length;
summary.periodLength = Math.min(10, Math.max(2, Math.round(mean)));
}
summary.current = {
start: last.start,
end: last.end,
cycleDay: today - last.start + 1,
onPeriod: last.end === today
};
const expected = last.start + summary.cycleLength;
// A late period keeps being expected "from tomorrow" rather than drifting
// into the past, and the cycles after it shift with it.
let start = expected;
if (today > expected && !summary.current.onPeriod) {
summary.late = today - expected;
start = today + 1;
}
const thinData = summary.confidence !== "normal";
const spread = Math.max(summary.cycleSpread || 0, thinData ? 4 : 2);
let previousEnd = last.end;
for (let index = 1; index <= FORECAST; index += 1) {
const margin = Math.min(10, Math.round(spread * Math.sqrt(index)));
const end = start + summary.periodLength - 1;
summary.predictions.push({ start, end, earliest: start - margin, latest: start + margin });
// The current cycle's window is anchored on the original expectation:
// being late does not move an ovulation that already happened.
const window = fertileWindow(index === 1 ? expected : start, previousEnd);
if (window) summary.fertile.push(window);
previousEnd = end;
start += summary.cycleLength;
}
return summary;
}
return { PERIOD_FLOWS, LUTEAL_DAYS, MIN_CYCLE, MAX_CYCLE, toDay, toIso, periods, cycles, weightedStats, summarize };
});

BIN
static/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

BIN
static/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

5
static/icon.svg Normal file
View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="112" fill="#cf3f66"/>
<path d="M256 92c62 78 118 152 118 220a118 118 0 0 1-236 0c0-68 56-142 118-220z" fill="#fff"/>
<path d="M208 318a50 50 0 0 0 44 50" fill="none" stroke="#cf3f66" stroke-width="18" stroke-linecap="round" opacity=".55"/>
</svg>

After

Width:  |  Height:  |  Size: 351 B

130
static/index.html Normal file
View file

@ -0,0 +1,130 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Flowtrack</title>
<meta name="description" content="A private period tracker with cycle predictions.">
<meta name="theme-color" content="#faf6f4" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#18141b" media="(prefers-color-scheme: dark)">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="Flowtrack">
<link rel="manifest" href="/manifest.webmanifest" crossorigin="use-credentials">
<link rel="icon" href="/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="stylesheet" href="/styles.css?v=1.0.0">
</head>
<body>
<header class="topbar">
<h1>Flowtrack</h1>
<span class="badge" id="role-badge" hidden>View only</span>
</header>
<main>
<p class="banner" id="banner" role="status" hidden></p>
<section class="view" id="view-today" aria-labelledby="tab-today">
<div class="hero card" id="hero"></div>
<div class="card" id="today-log"></div>
<p class="footnote">Predictions are estimates from your own history. They are not a form of contraception.</p>
</section>
<section class="view" id="view-calendar" aria-labelledby="tab-calendar" hidden>
<div class="card">
<div class="month-nav">
<button type="button" class="icon-button" id="month-prev" aria-label="Previous month">‹</button>
<h2 id="month-title"></h2>
<button type="button" class="icon-button" id="month-next" aria-label="Next month">›</button>
</div>
<div class="weekdays" aria-hidden="true">
<span>Mon</span><span>Tue</span><span>Wed</span><span>Thu</span><span>Fri</span><span>Sat</span><span>Sun</span>
</div>
<div class="month-grid" id="month-grid" role="grid"></div>
<button type="button" class="link-button" id="month-today">Back to today</button>
</div>
<ul class="legend card">
<li><span class="key key-period"></span>Period</li>
<li><span class="key key-predicted"></span>Predicted period</li>
<li><span class="key key-fertile"></span>Fertile window (estimate)</li>
<li><span class="key key-ovulation"></span>Estimated ovulation</li>
<li><span class="key key-spotting"></span>Spotting</li>
<li><span class="key key-symptoms"></span>Symptoms logged</li>
</ul>
</section>
<section class="view" id="view-stats" aria-labelledby="tab-stats" hidden>
<div class="tiles" id="tiles"></div>
<div class="card" id="chart-card">
<h2>Cycle length</h2>
<p class="subtitle">Days from one period to the next, most recent on the right</p>
<div class="chart" id="chart"></div>
</div>
<div class="card">
<h2>History</h2>
<div class="table-wrap">
<table class="history">
<thead><tr><th scope="col">Started</th><th scope="col">Cycle</th><th scope="col">Period</th></tr></thead>
<tbody id="history"></tbody>
</table>
</div>
</div>
</section>
<section class="view" id="view-settings" aria-labelledby="tab-settings" hidden>
<div class="card" id="share-card">
<h2>Sharing</h2>
<label class="switch-row">
<span>
<strong>Share flow and symptoms</strong>
<small>View-only users always see period days and predictions. Turn this on to also show them your flow and symptoms.</small>
</span>
<input type="checkbox" id="share-toggle" role="switch">
</label>
</div>
<div class="card">
<h2>Add past periods</h2>
<p class="subtitle">Enter when recent periods started. Days you already logged are left untouched.</p>
<form id="seed-form" class="seed-form"></form>
</div>
<div class="card">
<h2>Export</h2>
<p class="subtitle">Download everything you have logged.</p>
<div class="button-row">
<a class="button secondary" href="/api/export.csv" download>CSV</a>
<a class="button secondary" href="/api/export.json" download>JSON</a>
</div>
</div>
</section>
</main>
<nav class="tabs" role="tablist" aria-label="Sections">
<button type="button" role="tab" id="tab-today" data-view="today" aria-selected="true">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3c3.5 4.4 6 7.9 6 11a6 6 0 0 1-12 0c0-3.1 2.5-6.6 6-11z"/></svg>Today
</button>
<button type="button" role="tab" id="tab-calendar" data-view="calendar" aria-selected="false">
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="5" width="16" height="15" rx="2"/><path d="M4 10h16M9 3v4M15 3v4"/></svg>Calendar
</button>
<button type="button" role="tab" id="tab-stats" data-view="stats" aria-selected="false">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 20V11M12 20V5M19 20v-7"/></svg>Stats
</button>
<button type="button" role="tab" id="tab-settings" data-view="settings" aria-selected="false">
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M5.6 18.4l2.1-2.1M16.3 7.7l2.1-2.1"/></svg>Settings
</button>
</nav>
<dialog class="sheet" id="day-sheet" aria-labelledby="sheet-title">
<form method="dialog" class="sheet-inner">
<div class="sheet-head">
<h2 id="sheet-title"></h2>
<button class="icon-button" value="close" aria-label="Close">×</button>
</div>
<div id="sheet-body"></div>
</form>
</dialog>
<script src="/cycle.js?v=1.0.0"></script>
<script src="/app.js?v=1.0.0"></script>
</body>
</html>

View file

@ -0,0 +1,15 @@
{
"name": "Flowtrack",
"short_name": "Flowtrack",
"description": "A private period tracker with cycle predictions.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#faf6f4",
"theme_color": "#faf6f4",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml" }
]
}

368
static/styles.css Normal file
View file

@ -0,0 +1,368 @@
:root {
color-scheme: light dark;
--bg: #faf6f4;
--surface: #ffffff;
--surface-2: #f4eeec;
--ink: #2b2330;
--ink-2: #5d5463;
--muted: #847a89;
--line: #e9e1df;
--period: #cf3f66;
--period-soft: #f9dde5;
--period-ink: #ffffff;
--fertile: #1f8f8a;
--fertile-soft: #d6efed;
--focus: #6d4fc4;
--danger: #b3261e;
--shadow: 0 1px 2px rgb(43 35 48 / 6%), 0 4px 16px rgb(43 35 48 / 5%);
--radius: 18px;
--tabs-height: 64px;
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, "Segoe UI", Roboto, sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #18141b;
--surface: #221d26;
--surface-2: #2c2631;
--ink: #f2ecf3;
--ink-2: #c3b9c7;
--muted: #9a8f9f;
--line: #3a3340;
--period: #ee6b8d;
--period-soft: #4b2534;
--period-ink: #1e0b12;
--fertile: #4cc4bd;
--fertile-soft: #183937;
--focus: #a991f0;
--danger: #ff8a80;
--shadow: none;
}
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--ink);
font-size: 16px;
line-height: 1.45;
-webkit-tap-highlight-color: transparent;
}
button, input, select { font: inherit; color: inherit; }
:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
h1, h2 { margin: 0; line-height: 1.2; }
h2 { font-size: 1.05rem; font-weight: 600; }
.topbar {
display: flex;
align-items: center;
gap: 10px;
max-width: 560px;
margin: 0 auto;
padding: calc(env(safe-area-inset-top) + 14px) 16px 6px;
}
.topbar h1 { font-size: 1.35rem; font-weight: 700; letter-spacing: -0.01em; }
.badge {
padding: 2px 10px;
border-radius: 999px;
background: var(--surface-2);
color: var(--ink-2);
font-size: 0.8rem;
font-weight: 600;
}
main {
max-width: 560px;
margin: 0 auto;
padding: 8px 16px calc(var(--tabs-height) + env(safe-area-inset-bottom) + 24px);
}
.view { display: grid; gap: 14px; }
.view[hidden] { display: none; }
.card {
background: var(--surface);
border-radius: var(--radius);
padding: 18px;
box-shadow: var(--shadow);
}
.card > h2 + .subtitle { margin-top: 4px; }
.subtitle { margin: 0 0 14px; color: var(--muted); font-size: 0.9rem; }
.footnote { margin: 0; padding: 0 4px; color: var(--muted); font-size: 0.8rem; text-align: center; }
.banner {
margin: 0 0 12px;
padding: 10px 14px;
border-radius: 12px;
background: var(--surface-2);
color: var(--ink);
font-size: 0.9rem;
}
.banner.error { color: var(--danger); }
/* Today */
.hero { text-align: center; padding: 26px 18px 22px; }
.hero .eyebrow { margin: 0; color: var(--muted); font-size: 0.85rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; }
.hero .figure { margin: 4px 0 2px; font-size: 3.4rem; font-weight: 700; letter-spacing: -0.03em; line-height: 1.05; font-variant-numeric: tabular-nums; }
.hero .figure small { font-size: 1.1rem; font-weight: 600; color: var(--ink-2); letter-spacing: 0; }
.hero .lead { margin: 6px 0 0; font-size: 1.05rem; font-weight: 600; }
.hero .detail { margin: 2px 0 0; color: var(--ink-2); font-size: 0.92rem; }
.hero .note { margin: 14px auto 0; max-width: 34ch; color: var(--muted); font-size: 0.85rem; }
.hero.on-period .figure { color: var(--period); }
.facts { display: grid; gap: 10px; margin: 18px 0 0; padding: 16px 0 0; border-top: 1px solid var(--line); text-align: left; }
.fact { display: flex; align-items: center; gap: 12px; }
.fact .key { flex: none; }
.fact div { display: grid; }
.fact strong { font-size: 0.95rem; font-weight: 600; }
.fact span { color: var(--ink-2); font-size: 0.88rem; }
.log-section + .log-section { margin-top: 18px; }
.log-section h3 { margin: 0 0 10px; color: var(--ink-2); font-size: 0.85rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; }
.chips { display: flex; flex-wrap: wrap; gap: 8px; }
.chip {
min-height: 40px;
padding: 8px 14px;
border: 1.5px solid var(--line);
border-radius: 999px;
background: var(--surface);
font-size: 0.92rem;
font-weight: 500;
cursor: pointer;
touch-action: manipulation;
}
.chip[aria-pressed="true"] { border-color: var(--period); background: var(--period); color: var(--period-ink); }
.chip.symptom[aria-pressed="true"] { border-color: var(--ink); background: var(--ink); color: var(--surface); }
.chip:disabled { cursor: default; }
.chip:disabled:not([aria-pressed="true"]) { opacity: 0.55; }
.empty-log { margin: 0; color: var(--muted); font-size: 0.92rem; }
/* Buttons */
.button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 10px 18px;
border: 0;
border-radius: 12px;
background: var(--period);
color: var(--period-ink);
font-weight: 600;
text-decoration: none;
cursor: pointer;
}
.button.secondary { background: var(--surface-2); color: var(--ink); }
.button:disabled { opacity: 0.6; cursor: default; }
.button-row { display: flex; flex-wrap: wrap; gap: 10px; }
.icon-button {
display: grid;
place-items: center;
width: 40px;
height: 40px;
border: 0;
border-radius: 999px;
background: var(--surface-2);
font-size: 1.4rem;
line-height: 1;
cursor: pointer;
}
.link-button {
display: block;
margin: 12px auto 0;
padding: 6px 10px;
border: 0;
background: none;
color: var(--ink-2);
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
}
/* Calendar */
.month-nav { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.month-nav h2 { font-size: 1.1rem; }
.weekdays, .month-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; }
.weekdays span { color: var(--muted); font-size: 0.75rem; font-weight: 600; text-align: center; }
.month-grid { margin-top: 6px; }
.day {
position: relative;
display: grid;
place-items: center;
aspect-ratio: 1;
min-width: 0;
border: 0;
border-radius: 999px;
background: transparent;
font-size: 0.95rem;
font-variant-numeric: tabular-nums;
cursor: pointer;
touch-action: manipulation;
}
.day.outside { visibility: hidden; }
.day.future { cursor: default; }
.day.fertile { background: var(--fertile-soft); }
.day.predicted { box-shadow: inset 0 0 0 2px var(--period); background: var(--period-soft); }
.day.period { background: var(--period); color: var(--period-ink); font-weight: 600; }
.day.today::after {
content: "";
position: absolute;
inset: -3px;
border: 2px solid var(--ink);
border-radius: 999px;
}
.day .marks { position: absolute; bottom: 12%; display: flex; gap: 3px; }
.day .mark { width: 5px; height: 5px; border-radius: 999px; }
.mark.ovulation { background: var(--fertile); }
.mark.spotting { background: var(--period); }
.mark.symptoms { background: var(--muted); }
.day.period .mark.symptoms { background: var(--period-ink); }
.legend { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px 16px; margin: 0; list-style: none; font-size: 0.9rem; color: var(--ink-2); }
.legend li { display: flex; align-items: center; gap: 10px; }
.key { display: inline-block; width: 16px; height: 16px; border-radius: 999px; }
.key-period { background: var(--period); }
.key-predicted { background: var(--period-soft); box-shadow: inset 0 0 0 2px var(--period); }
.key-fertile { background: var(--fertile-soft); }
.key-ovulation { position: relative; background: var(--fertile-soft); }
.key-ovulation::after { content: ""; position: absolute; inset: 5px; border-radius: 999px; background: var(--fertile); }
.key-spotting, .key-symptoms { position: relative; }
.key-spotting::after, .key-symptoms::after { content: ""; position: absolute; inset: 5px; border-radius: 999px; }
.key-spotting::after { background: var(--period); }
.key-symptoms::after { background: var(--muted); }
/* Stats */
.tiles { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
.tile { background: var(--surface); border-radius: var(--radius); padding: 14px 16px; box-shadow: var(--shadow); }
.tile .label { margin: 0; color: var(--ink-2); font-size: 0.85rem; }
.tile .value { margin: 2px 0 0; font-size: 1.6rem; font-weight: 650; letter-spacing: -0.01em; font-variant-numeric: tabular-nums; }
.tile .value small { font-size: 0.95rem; font-weight: 500; color: var(--ink-2); }
.chart { position: relative; }
.chart svg { display: block; width: 100%; height: auto; overflow: visible; }
.chart .grid { stroke: var(--line); stroke-width: 1; }
.chart .axis-label { fill: var(--muted); font-size: 11px; }
.chart .bar { fill: var(--period); }
.chart .bar.dim { opacity: 0.35; }
.chart .avg { stroke: var(--ink-2); stroke-width: 1; stroke-dasharray: 3 3; }
.chart .avg-label { fill: var(--ink-2); font-size: 11px; font-weight: 600; }
.chart .hit { fill: transparent; cursor: pointer; }
.chart-empty { margin: 0; color: var(--muted); font-size: 0.92rem; }
.tooltip {
position: absolute;
z-index: 2;
padding: 6px 10px;
border-radius: 10px;
background: var(--ink);
color: var(--surface);
font-size: 0.82rem;
white-space: nowrap;
pointer-events: none;
transform: translate(-50%, calc(-100% - 8px));
}
.tooltip[hidden] { display: none; }
.table-wrap { margin-top: 12px; overflow-x: auto; }
.history { width: 100%; border-collapse: collapse; font-size: 0.92rem; font-variant-numeric: tabular-nums; }
.history th { padding: 0 0 8px; color: var(--muted); font-size: 0.8rem; font-weight: 600; text-align: left; }
.history td { padding: 9px 0; border-top: 1px solid var(--line); }
.history th:not(:first-child), .history td:not(:first-child) { text-align: right; }
.history .excluded { color: var(--muted); }
.history .excluded small { display: block; font-size: 0.78rem; }
/* Settings */
.switch-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; cursor: pointer; }
.switch-row span { display: grid; gap: 2px; }
.switch-row small { color: var(--muted); font-size: 0.85rem; }
.switch-row input { flex: none; width: 24px; height: 24px; accent-color: var(--period); }
.seed-form { display: grid; gap: 12px; }
.seed-form label { display: grid; gap: 4px; color: var(--ink-2); font-size: 0.88rem; font-weight: 600; }
.seed-form input {
min-height: 44px;
padding: 8px 12px;
border: 1.5px solid var(--line);
border-radius: 12px;
background: var(--surface);
font-weight: 400;
}
.seed-dates { display: grid; gap: 8px; }
.seed-form .seed-length { max-width: 120px; }
/* Tabs */
.tabs {
position: fixed;
inset: auto 0 0;
z-index: 5;
display: grid;
grid-template-columns: repeat(4, 1fr);
padding: 6px max(8px, env(safe-area-inset-left)) calc(6px + env(safe-area-inset-bottom));
border-top: 1px solid var(--line);
background: color-mix(in srgb, var(--surface) 92%, transparent);
-webkit-backdrop-filter: blur(16px);
backdrop-filter: blur(16px);
}
.tabs.three { grid-template-columns: repeat(3, 1fr); }
.tabs button {
display: grid;
justify-items: center;
gap: 2px;
min-height: 52px;
padding: 4px;
border: 0;
background: none;
color: var(--muted);
font-size: 0.72rem;
font-weight: 600;
cursor: pointer;
}
.tabs button[hidden] { display: none; }
.tabs svg { width: 24px; height: 24px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.tabs button[aria-selected="true"] { color: var(--period); }
/* Day sheet */
.sheet {
width: min(560px, 100%);
max-width: 100%;
max-height: 90vh;
margin: auto auto 0;
padding: 0;
border: 0;
border-radius: 22px 22px 0 0;
background: var(--surface);
color: var(--ink);
}
.sheet::backdrop { background: rgb(0 0 0 / 35%); }
.sheet-inner { padding: 18px 18px calc(22px + env(safe-area-inset-bottom)); }
.sheet-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.sheet-head h2 { font-size: 1.15rem; }
.sheet .prediction-note { margin: 0 0 16px; color: var(--ink-2); font-size: 0.9rem; }
@media (min-width: 600px) {
.sheet { margin: auto; border-radius: 22px; }
.tiles { grid-template-columns: repeat(4, 1fr); }
}

150
tests/test_app.py Normal file
View file

@ -0,0 +1,150 @@
import json
import sqlite3
import tempfile
import threading
import unittest
from datetime import date, timedelta
from http.client import HTTPConnection
from pathlib import Path
import app
class StoreTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.store = app.Store(Path(self.temp.name) / "flowtrack.sqlite3")
self.store.initialize()
def tearDown(self):
self.temp.cleanup()
def test_empty_log_deletes_the_day(self):
self.store.set_day("2026-10-01", {"flow": "heavy", "symptoms": ["cramps"]})
self.assertEqual(self.store.days(), {"2026-10-01": {"flow": "heavy", "symptoms": ["cramps"]}})
self.store.set_day("2026-10-01", {"flow": None, "symptoms": []})
self.assertEqual(self.store.days(), {})
def test_fill_never_overwrites(self):
self.store.set_day("2026-10-01", {"flow": "heavy", "symptoms": []})
added = self.store.fill_days({
"2026-10-01": {"flow": "period", "symptoms": []},
"2026-10-02": {"flow": "period", "symptoms": []},
})
self.assertEqual(added, 1)
self.assertEqual(self.store.days()["2026-10-01"]["flow"], "heavy")
def test_backup_rotates(self):
self.store.set_day("2026-10-01", {"flow": "light", "symptoms": []})
destination = Path(self.temp.name) / "backups"
destination.mkdir()
for stale in ("2026-01-01", "2026-01-02", "2026-01-03"):
(destination / f"flowtrack-{stale}.sqlite3").touch()
target = self.store.backup(destination, keep=2)
self.assertEqual(sorted(path.name for path in destination.iterdir()), ["flowtrack-2026-01-03.sqlite3", target.name])
with sqlite3.connect(target) as copy:
self.assertEqual(copy.execute("SELECT flow FROM days").fetchone()[0], "light")
class ValidationTests(unittest.TestCase):
def test_dates(self):
self.assertIsNotNone(app.parse_day(date.today().isoformat()))
self.assertIsNotNone(app.parse_day((date.today() + timedelta(days=1)).isoformat()))
self.assertIsNone(app.parse_day((date.today() + timedelta(days=2)).isoformat()))
self.assertIsNone(app.parse_day("2026-02-30"))
self.assertIsNone(app.parse_day("26-10-01"))
def test_entries(self):
self.assertEqual(app.clean_entry({"flow": "light", "symptoms": ["acne", "cramps", "acne"]}), {"flow": "light", "symptoms": ["acne", "cramps"]})
self.assertIsNone(app.clean_entry({"flow": "gushing"}))
self.assertIsNone(app.clean_entry({"flow": None, "symptoms": ["<script>"]}))
self.assertIsNone(app.clean_entry({"flow": None, "symptoms": "cramps"}))
class ServerTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
store = app.Store(Path(self.temp.name) / "flowtrack.sqlite3")
store.initialize()
self.server = app.TrackerServer(("127.0.0.1", 0), store, {"kayla"}, {"fred"})
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
self.thread.start()
def tearDown(self):
self.server.shutdown()
self.server.server_close()
self.temp.cleanup()
def request(self, method, path, user=None, body=None):
connection = HTTPConnection("127.0.0.1", self.server.server_address[1], timeout=5)
headers = {"Remote-User": user} if user else {}
payload = None
if body is not None:
payload = json.dumps(body).encode()
headers["Content-Type"] = "application/json"
connection.request(method, path, body=payload, headers=headers)
response = connection.getresponse()
data = response.read()
connection.close()
try:
return response.status, json.loads(data)
except json.JSONDecodeError:
return response.status, data.decode()
def test_unknown_users_get_nothing(self):
self.assertEqual(self.request("GET", "/api/state")[0], 403)
self.assertEqual(self.request("GET", "/api/state", user="mallory")[0], 403)
self.assertEqual(self.request("PUT", "/api/days/2026-10-01", user="mallory", body={"flow": "light"})[0], 403)
def test_editor_round_trip_and_export(self):
status, _ = self.request("PUT", "/api/days/2026-10-01", user="kayla", body={"flow": "heavy", "symptoms": ["cramps"]})
self.assertEqual(status, 200)
status, state = self.request("GET", "/api/state", user="kayla")
self.assertEqual(state["role"], "editor")
self.assertEqual(state["days"]["2026-10-01"], {"flow": "heavy", "symptoms": ["cramps"]})
status, exported = self.request("GET", "/api/export.csv", user="kayla")
self.assertEqual(status, 200)
self.assertIn("2026-10-01,heavy,Cramps", exported)
def test_viewer_is_read_only_and_details_are_opt_in(self):
self.request("PUT", "/api/days/2026-10-01", user="kayla", body={"flow": "heavy", "symptoms": ["cramps"]})
self.request("PUT", "/api/days/2026-10-09", user="kayla", body={"flow": "spotting", "symptoms": []})
self.request("PUT", "/api/days/2026-10-10", user="kayla", body={"flow": None, "symptoms": ["acne"]})
status, state = self.request("GET", "/api/state", user="fred")
self.assertEqual((status, state["role"]), (200, "viewer"))
self.assertEqual(state["days"], {"2026-10-01": {"flow": "period", "symptoms": []}})
self.assertEqual(self.request("PUT", "/api/days/2026-10-02", user="fred", body={"flow": "light"})[0], 403)
self.assertEqual(self.request("PUT", "/api/settings", user="fred", body={"shareDetails": True})[0], 403)
self.assertEqual(self.request("POST", "/api/days", user="fred", body={"days": {"2026-10-02": {"flow": "period"}}})[0], 403)
self.assertEqual(self.request("GET", "/api/export.json", user="fred")[0], 403)
self.assertEqual(self.request("PUT", "/api/settings", user="kayla", body={"shareDetails": True})[0], 200)
_, state = self.request("GET", "/api/state", user="fred")
self.assertEqual(len(state["days"]), 3)
self.assertEqual(state["days"]["2026-10-01"]["symptoms"], ["cramps"])
def test_rejects_bad_writes(self):
future = (date.today() + timedelta(days=5)).isoformat()
self.assertEqual(self.request("PUT", f"/api/days/{future}", user="kayla", body={"flow": "light"})[0], 400)
self.assertEqual(self.request("PUT", "/api/days/2026-10-01", user="kayla", body={"flow": "lots"})[0], 400)
self.assertEqual(self.request("POST", "/api/days", user="kayla", body={"days": {"bad": {"flow": "period"}}})[0], 400)
def test_seed_batch(self):
status, result = self.request("POST", "/api/days", user="kayla", body={"days": {
"2026-09-01": {"flow": "period", "symptoms": []},
"2026-09-02": {"flow": "period", "symptoms": []},
}})
self.assertEqual((status, result), (200, {"added": 2}))
def test_static_files(self):
status, body = self.request("GET", "/")
self.assertEqual(status, 200)
self.assertIn("Flowtrack", body)
self.assertEqual(self.request("GET", "/../app.py")[0], 404)
self.assertEqual(self.request("GET", "/manifest.webmanifest")[0], 200)
if __name__ == "__main__":
unittest.main()

124
tests/test_cycle.js Normal file
View file

@ -0,0 +1,124 @@
const assert = require("node:assert/strict");
const cycle = require("../static/cycle.js");
const { toDay, toIso } = cycle;
// Logs `length` period days starting at each date.
function logPeriods(starts, length = 5, flow = "medium") {
const days = {};
for (const start of starts) {
for (let offset = 0; offset < length; offset += 1) days[toIso(toDay(start) + offset)] = { flow, symptoms: [] };
}
return days;
}
// Date helpers round-trip across month, year and leap-day boundaries.
for (const iso of ["2024-02-29", "2025-12-31", "2026-03-29", "2026-10-25"]) assert.equal(toIso(toDay(iso)), iso);
assert.equal(toDay("2026-03-01") - toDay("2026-02-28"), 1);
// Period days join into periods; one unlogged day does not split a period,
// spotting never counts, and two missing days do split it.
{
const days = logPeriods(["2026-01-01"], 3);
days["2026-01-05"] = { flow: "light", symptoms: [] };
days["2026-01-10"] = { flow: "spotting", symptoms: [] };
days["2026-01-13"] = { flow: "period", symptoms: [] };
const periods = cycle.periods(days);
assert.deepEqual(periods.map(period => [toIso(period.start), period.length]), [["2026-01-01", 5], ["2026-01-13", 1]]);
}
// Nothing logged: no predictions at all.
{
const summary = cycle.summarize({}, "2026-10-18");
assert.equal(summary.current, null);
assert.equal(summary.confidence, "none");
assert.deepEqual(summary.predictions, []);
}
// One period: falls back to 28 days with low confidence and a wide range.
{
const summary = cycle.summarize(logPeriods(["2026-10-01"]), "2026-10-10");
assert.equal(summary.confidence, "none");
assert.equal(summary.cycleLength, 28);
assert.equal(toIso(summary.predictions[0].start), "2026-10-29");
assert.equal(summary.predictions[0].latest - summary.predictions[0].start, 4);
assert.equal(summary.current.cycleDay, 10);
}
// Regular 30-day cycles predict 30 days ahead, with the minimum ±2 range
// that widens for later cycles, and ovulation 14 days before each period.
{
const starts = ["2026-04-01", "2026-05-01", "2026-05-31", "2026-06-30", "2026-07-30", "2026-08-29", "2026-09-28"];
const summary = cycle.summarize(logPeriods(starts), "2026-10-10");
assert.equal(summary.confidence, "normal");
assert.equal(summary.sampleSize, 6);
assert.equal(summary.cycleLength, 30);
assert.equal(summary.periodLength, 5);
const [next, second] = summary.predictions;
assert.equal(toIso(next.start), "2026-10-28");
assert.equal(toIso(next.end), "2026-11-01");
assert.equal(next.start - next.earliest, 2);
assert.equal(toIso(second.start), "2026-11-27");
assert.equal(second.start - second.earliest, 3);
assert.equal(summary.predictions.length, 6);
const window = summary.fertile[0];
assert.equal(toIso(window.ovulation), "2026-10-14");
assert.equal(toIso(window.start), "2026-10-09");
assert.equal(toIso(window.end), "2026-10-15");
}
// Recent cycles weigh more: a shift from 26 to 32 days pulls the mean
// above the plain average of 29.
{
const starts = ["2026-01-01", "2026-01-27", "2026-02-22", "2026-03-20", "2026-04-21", "2026-05-23", "2026-06-24"];
const summary = cycle.summarize(logPeriods(starts), "2026-07-01");
assert.ok(summary.cycleLength >= 30, `expected recent cycles to dominate, got ${summary.cycleLength}`);
}
// A gap from a forgotten period (two cycles' worth) is excluded from the average.
{
const starts = ["2026-01-01", "2026-01-29", "2026-04-23", "2026-05-21", "2026-06-18"];
const summary = cycle.summarize(logPeriods(starts), "2026-06-20");
assert.deepEqual(summary.cycles.map(item => item.used), [true, false, true, true]);
assert.equal(summary.cycleLength, 28);
}
// Late: once the expected day has passed, the next period is expected from
// tomorrow, while this cycle's fertile window stays where it was.
{
const starts = ["2026-07-01", "2026-07-29", "2026-08-26", "2026-09-23"];
const summary = cycle.summarize(logPeriods(starts), "2026-10-24");
assert.equal(summary.late, 3);
assert.equal(toIso(summary.predictions[0].start), "2026-10-25");
assert.equal(toIso(summary.fertile[0].ovulation), "2026-10-07");
}
// Due today is not late yet.
{
const summary = cycle.summarize(logPeriods(["2026-08-26", "2026-09-23"]), "2026-10-21");
assert.equal(summary.late, 0);
assert.equal(toIso(summary.predictions[0].start), "2026-10-21");
}
// An ongoing period is flagged and does not count towards period length yet.
{
const days = logPeriods(["2026-08-01", "2026-08-29"], 6);
Object.assign(days, logPeriods(["2026-09-26"], 2));
const summary = cycle.summarize(days, "2026-09-27");
assert.equal(summary.current.onPeriod, true);
assert.equal(summary.periodLength, 6);
}
// Days logged after "today" (a phone ahead of the server) are ignored.
{
const summary = cycle.summarize(logPeriods(["2026-09-01", "2026-10-19"]), "2026-10-18");
assert.equal(toIso(summary.current.start), "2026-09-01");
}
// Very short cycles never place the fertile window inside the period.
{
const summary = cycle.summarize(logPeriods(["2026-08-01", "2026-08-19", "2026-09-06", "2026-09-24"], 5), "2026-09-30");
for (const window of summary.fertile) assert.ok(window.start > toDay("2026-09-28"));
}
console.log("cycle tests passed");