Flowtrack: private period tracker with cycle predictions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
94372c6abc
19 changed files with 2166 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
result
|
||||
__pycache__/
|
||||
*.sqlite3*
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 Fredrik Gregersen
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
56
README.md
Normal file
56
README.md
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
# Flowtrack
|
||||
|
||||
A private, self-hosted period tracker. It logs period days with flow and
|
||||
symptoms, predicts the next six periods, and estimates the fertile window.
|
||||
It installs on a phone home screen as a web app.
|
||||
|
||||
The app has no runtime dependencies beyond the Python standard library, and it
|
||||
stores everything in one SQLite file. Nginx and Authelia provide the access
|
||||
boundary.
|
||||
|
||||
## Predictions
|
||||
|
||||
- Periods are runs of logged period days. Spotting doesn't count, and one
|
||||
missing day inside a run doesn't split it.
|
||||
- The cycle length is a weighted average of up to the last 12 cycles, with
|
||||
recent cycles counting more. Cycles shorter than 15 days or longer than 60
|
||||
are left out because they usually mean a log was missed.
|
||||
- Each prediction is a range of ± the cycle's standard deviation (at least 2
|
||||
days, or 4 with fewer than three cycles). The range widens with √n for later
|
||||
cycles.
|
||||
- Ovulation is estimated 14 days before the next period. The fertile window
|
||||
runs from 5 days before ovulation to 1 day after. These are estimates, not
|
||||
contraception.
|
||||
- Once the expected day passes, the next period is expected from tomorrow,
|
||||
and later predictions move with it.
|
||||
|
||||
The logic lives in `static/cycle.js` and is tested in `tests/test_cycle.js`.
|
||||
|
||||
## Access
|
||||
|
||||
Nginx must overwrite `Remote-User` with the Authelia username. Users listed
|
||||
with `--editor` can log and export. Users listed with `--viewer` see period
|
||||
days and predictions only. They also see flow and symptoms if the owner turns
|
||||
on sharing in Settings. Everyone else gets 403.
|
||||
|
||||
## Run locally
|
||||
|
||||
```sh
|
||||
python3 app.py --database /tmp/flowtrack.sqlite3 --editor me --dev-user me
|
||||
```
|
||||
|
||||
Then open `http://127.0.0.1:8093`.
|
||||
|
||||
## NixOS module
|
||||
|
||||
```nix
|
||||
services.flowtrack = {
|
||||
enable = true;
|
||||
editors = [ "kayla" ];
|
||||
viewers = [ "fred" ];
|
||||
backup.directory = "/mnt/disk2/backups/flowtrack"; # nightly, keeps 30
|
||||
};
|
||||
```
|
||||
|
||||
`python3 app.py --database DB --backup-to DIR --keep N` takes a consistent
|
||||
snapshot by hand.
|
||||
421
app.py
Normal file
421
app.py
Normal file
|
|
@ -0,0 +1,421 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Small, dependency-free server for a private period tracker."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import re
|
||||
import sqlite3
|
||||
from contextlib import closing
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
|
||||
APP_DIR = Path(__file__).resolve().parent
|
||||
STATIC_DIR = APP_DIR / "static"
|
||||
DAY_PATH = re.compile(r"^/api/days/(\d{4}-\d{2}-\d{2})$")
|
||||
MAX_BODY = 64 * 1024
|
||||
MAX_BATCH = 400
|
||||
FLOWS = ("spotting", "period", "light", "medium", "heavy")
|
||||
PERIOD_FLOWS = frozenset(("period", "light", "medium", "heavy"))
|
||||
SYMPTOMS = (
|
||||
("cramps", "Cramps"),
|
||||
("headache", "Headache"),
|
||||
("bloating", "Bloating"),
|
||||
("tender-breasts", "Tender breasts"),
|
||||
("acne", "Acne"),
|
||||
("fatigue", "Fatigue"),
|
||||
("back-pain", "Back pain"),
|
||||
("nausea", "Nausea"),
|
||||
("cravings", "Cravings"),
|
||||
("mood-swings", "Mood swings"),
|
||||
)
|
||||
SYMPTOM_IDS = frozenset(symptom_id for symptom_id, _ in SYMPTOMS)
|
||||
|
||||
mimetypes.add_type("application/manifest+json", ".webmanifest")
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def parse_day(value: object) -> date | None:
|
||||
"""A calendar date that is not in the future.
|
||||
|
||||
One day of slack covers a phone that is ahead of the server's timezone.
|
||||
"""
|
||||
if not isinstance(value, str) or not re.fullmatch(r"\d{4}-\d{2}-\d{2}", value):
|
||||
return None
|
||||
try:
|
||||
parsed = date.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.year < 1990 or parsed > date.today() + timedelta(days=1):
|
||||
return None
|
||||
return parsed
|
||||
|
||||
|
||||
def clean_entry(value: object) -> dict | None:
|
||||
"""Validate one day's log; returns None when it is malformed."""
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
flow = value.get("flow")
|
||||
symptoms = value.get("symptoms", [])
|
||||
if flow is not None and flow not in FLOWS:
|
||||
return None
|
||||
if not isinstance(symptoms, list) or not all(item in SYMPTOM_IDS for item in symptoms):
|
||||
return None
|
||||
return {"flow": flow, "symptoms": sorted(set(symptoms))}
|
||||
|
||||
|
||||
class Store:
|
||||
def __init__(self, path: str | Path):
|
||||
self.path = str(path)
|
||||
|
||||
def connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(self.path, timeout=10)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute("PRAGMA busy_timeout=10000")
|
||||
return connection
|
||||
|
||||
def initialize(self) -> None:
|
||||
with closing(self.connect()) as db, db:
|
||||
db.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS days (
|
||||
day TEXT PRIMARY KEY,
|
||||
flow TEXT,
|
||||
symptoms TEXT NOT NULL DEFAULT '[]',
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
INSERT OR IGNORE INTO settings (key, value) VALUES ('share_details', 'false');
|
||||
"""
|
||||
)
|
||||
|
||||
def days(self) -> dict[str, dict]:
|
||||
with closing(self.connect()) as db, db:
|
||||
rows = db.execute("SELECT day, flow, symptoms FROM days ORDER BY day").fetchall()
|
||||
return {row["day"]: {"flow": row["flow"], "symptoms": json.loads(row["symptoms"])} for row in rows}
|
||||
|
||||
def share_details(self) -> bool:
|
||||
with closing(self.connect()) as db, db:
|
||||
row = db.execute("SELECT value FROM settings WHERE key = 'share_details'").fetchone()
|
||||
return row is not None and row["value"] == "true"
|
||||
|
||||
def set_share_details(self, enabled: bool) -> None:
|
||||
with closing(self.connect()) as db, db:
|
||||
db.execute(
|
||||
"UPDATE settings SET value = ? WHERE key = 'share_details'",
|
||||
("true" if enabled else "false",),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _write(db: sqlite3.Connection, day: str, entry: dict, changed_at: str) -> None:
|
||||
# An empty log is the same as no log, so the row goes away.
|
||||
if entry["flow"] is None and not entry["symptoms"]:
|
||||
db.execute("DELETE FROM days WHERE day = ?", (day,))
|
||||
return
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO days (day, flow, symptoms, updated_at) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(day) DO UPDATE SET
|
||||
flow = excluded.flow,
|
||||
symptoms = excluded.symptoms,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(day, entry["flow"], json.dumps(entry["symptoms"]), changed_at),
|
||||
)
|
||||
|
||||
def set_day(self, day: str, entry: dict) -> None:
|
||||
with closing(self.connect()) as db, db:
|
||||
self._write(db, day, entry, utc_now())
|
||||
|
||||
def fill_days(self, entries: dict[str, dict]) -> int:
|
||||
"""Add logs for days that have none yet; existing logs always win."""
|
||||
changed_at = utc_now()
|
||||
added = 0
|
||||
with closing(self.connect()) as db, db:
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
for day, entry in sorted(entries.items()):
|
||||
if db.execute("SELECT 1 FROM days WHERE day = ?", (day,)).fetchone():
|
||||
continue
|
||||
self._write(db, day, entry, changed_at)
|
||||
added += 1
|
||||
return added
|
||||
|
||||
def backup(self, destination: Path, keep: int) -> Path:
|
||||
"""Write today's consistent snapshot and prune all but the newest `keep`."""
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
target = destination / f"flowtrack-{date.today().isoformat()}.sqlite3"
|
||||
partial = target.with_suffix(".partial")
|
||||
with closing(self.connect()) as source, closing(sqlite3.connect(partial)) as copy:
|
||||
source.backup(copy)
|
||||
partial.replace(target)
|
||||
snapshots = sorted(destination.glob("flowtrack-*.sqlite3"))
|
||||
for old in snapshots[:-keep] if keep > 0 else []:
|
||||
old.unlink()
|
||||
return target
|
||||
|
||||
|
||||
class TrackerServer(ThreadingHTTPServer):
|
||||
daemon_threads = True
|
||||
|
||||
def __init__(self, address: tuple[str, int], store: Store, editors: set[str], viewers: set[str], dev_user: str | None = None):
|
||||
super().__init__(address, Handler)
|
||||
self.store = store
|
||||
self.editors = editors
|
||||
self.viewers = viewers
|
||||
self.dev_user = dev_user
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server: TrackerServer
|
||||
|
||||
def log_message(self, format: str, *args: object) -> None:
|
||||
logging.info("%s - %s", self.address_string(), format % args)
|
||||
|
||||
def end_headers(self) -> None:
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
self.send_header("X-Frame-Options", "DENY")
|
||||
self.send_header("Referrer-Policy", "no-referrer")
|
||||
self.send_header("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
||||
self.send_header(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
|
||||
)
|
||||
super().end_headers()
|
||||
|
||||
def send_body(self, body: bytes, content_type: str, status: HTTPStatus = HTTPStatus.OK, extra: dict | None = None) -> None:
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
for name, value in (extra or {}).items():
|
||||
self.send_header(name, value)
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def json_response(self, payload: dict, status: HTTPStatus = HTTPStatus.OK) -> None:
|
||||
self.send_body(json.dumps(payload, separators=(",", ":")).encode(), "application/json; charset=utf-8", status)
|
||||
|
||||
def error_json(self, message: str, status: HTTPStatus) -> None:
|
||||
self.json_response({"error": message}, status)
|
||||
|
||||
def role(self) -> tuple[str, str | None]:
|
||||
# nginx overwrites Remote-User with the Authelia identity, so it can
|
||||
# be trusted here; the app itself only listens on loopback.
|
||||
user = self.headers.get("Remote-User", "").strip() or self.server.dev_user or ""
|
||||
if user in self.server.editors:
|
||||
return user, "editor"
|
||||
if user in self.server.viewers:
|
||||
return user, "viewer"
|
||||
return user, None
|
||||
|
||||
def visible_days(self, role: str) -> dict[str, dict]:
|
||||
days = self.server.store.days()
|
||||
if role == "editor" or self.server.store.share_details():
|
||||
return days
|
||||
# Without sharing a viewer sees which days were period days, nothing else.
|
||||
return {
|
||||
day: {"flow": "period", "symptoms": []}
|
||||
for day, entry in days.items()
|
||||
if entry["flow"] in PERIOD_FLOWS
|
||||
}
|
||||
|
||||
def read_json(self) -> object:
|
||||
if self.headers.get_content_type() != "application/json":
|
||||
self.error_json("Expected application/json", HTTPStatus.UNSUPPORTED_MEDIA_TYPE)
|
||||
return None
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
except ValueError:
|
||||
length = -1
|
||||
if length <= 0 or length > MAX_BODY:
|
||||
self.error_json("Invalid request size", HTTPStatus.BAD_REQUEST)
|
||||
return None
|
||||
try:
|
||||
return json.loads(self.rfile.read(length))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
self.error_json("Invalid JSON", HTTPStatus.BAD_REQUEST)
|
||||
return None
|
||||
|
||||
def do_GET(self) -> None:
|
||||
path = urlparse(self.path).path
|
||||
if path == "/health":
|
||||
self.json_response({"status": "ok"})
|
||||
return
|
||||
if not path.startswith("/api/"):
|
||||
self.serve_static(path)
|
||||
return
|
||||
user, role = self.role()
|
||||
if role is None:
|
||||
self.error_json("Not allowed", HTTPStatus.FORBIDDEN)
|
||||
return
|
||||
if path == "/api/state":
|
||||
self.json_response(
|
||||
{
|
||||
"user": user,
|
||||
"role": role,
|
||||
"shareDetails": self.server.store.share_details(),
|
||||
"symptoms": [{"id": symptom_id, "label": label} for symptom_id, label in SYMPTOMS],
|
||||
"days": self.visible_days(role),
|
||||
}
|
||||
)
|
||||
return
|
||||
if path in ("/api/export.json", "/api/export.csv"):
|
||||
if role != "editor":
|
||||
self.error_json("Only the owner can export", HTTPStatus.FORBIDDEN)
|
||||
return
|
||||
self.export(path.endswith(".csv"))
|
||||
return
|
||||
self.error_json("Not found", HTTPStatus.NOT_FOUND)
|
||||
|
||||
def do_PUT(self) -> None:
|
||||
path = urlparse(self.path).path
|
||||
_, role = self.role()
|
||||
if role != "editor":
|
||||
self.error_json("Read-only access", HTTPStatus.FORBIDDEN)
|
||||
return
|
||||
match = DAY_PATH.fullmatch(path)
|
||||
if match:
|
||||
if parse_day(match.group(1)) is None:
|
||||
self.error_json("Invalid or future date", HTTPStatus.BAD_REQUEST)
|
||||
return
|
||||
payload = self.read_json()
|
||||
if payload is None:
|
||||
return
|
||||
entry = clean_entry(payload)
|
||||
if entry is None:
|
||||
self.error_json("Invalid day log", HTTPStatus.BAD_REQUEST)
|
||||
return
|
||||
self.server.store.set_day(match.group(1), entry)
|
||||
self.json_response({"day": match.group(1), **entry})
|
||||
return
|
||||
if path == "/api/settings":
|
||||
payload = self.read_json()
|
||||
if payload is None:
|
||||
return
|
||||
share = payload.get("shareDetails") if isinstance(payload, dict) else None
|
||||
if not isinstance(share, bool):
|
||||
self.error_json("shareDetails must be boolean", HTTPStatus.BAD_REQUEST)
|
||||
return
|
||||
self.server.store.set_share_details(share)
|
||||
self.json_response({"shareDetails": share})
|
||||
return
|
||||
self.error_json("Not found", HTTPStatus.NOT_FOUND)
|
||||
|
||||
def do_POST(self) -> None:
|
||||
path = urlparse(self.path).path
|
||||
_, role = self.role()
|
||||
if role != "editor":
|
||||
self.error_json("Read-only access", HTTPStatus.FORBIDDEN)
|
||||
return
|
||||
if path != "/api/days":
|
||||
self.error_json("Not found", HTTPStatus.NOT_FOUND)
|
||||
return
|
||||
payload = self.read_json()
|
||||
if payload is None:
|
||||
return
|
||||
days = payload.get("days") if isinstance(payload, dict) else None
|
||||
if not isinstance(days, dict) or not days or len(days) > MAX_BATCH:
|
||||
self.error_json("days must be a non-empty object", HTTPStatus.BAD_REQUEST)
|
||||
return
|
||||
entries = {}
|
||||
for day, value in days.items():
|
||||
entry = clean_entry(value)
|
||||
if parse_day(day) is None or entry is None:
|
||||
self.error_json(f"Invalid log for {day}", HTTPStatus.BAD_REQUEST)
|
||||
return
|
||||
entries[day] = entry
|
||||
self.json_response({"added": self.server.store.fill_days(entries)})
|
||||
|
||||
def export(self, as_csv: bool) -> None:
|
||||
days = self.server.store.days()
|
||||
stamp = date.today().isoformat()
|
||||
if as_csv:
|
||||
labels = dict(SYMPTOMS)
|
||||
buffer = io.StringIO()
|
||||
writer = csv.writer(buffer)
|
||||
writer.writerow(["date", "flow", "symptoms"])
|
||||
for day, entry in days.items():
|
||||
writer.writerow([day, entry["flow"] or "", "; ".join(labels[item] for item in entry["symptoms"])])
|
||||
body, content_type = buffer.getvalue().encode(), "text/csv; charset=utf-8"
|
||||
else:
|
||||
body = json.dumps({"exportedAt": utc_now(), "days": days}, indent=2).encode()
|
||||
content_type = "application/json; charset=utf-8"
|
||||
extension = "csv" if as_csv else "json"
|
||||
self.send_body(body, content_type, extra={
|
||||
"Content-Disposition": f'attachment; filename="flowtrack-{stamp}.{extension}"'
|
||||
})
|
||||
|
||||
def serve_static(self, request_path: str) -> None:
|
||||
relative = "index.html" if request_path == "/" else unquote(request_path.lstrip("/"))
|
||||
if not relative or ".." in Path(relative).parts:
|
||||
self.send_error(HTTPStatus.NOT_FOUND)
|
||||
return
|
||||
target = (STATIC_DIR / relative).resolve()
|
||||
try:
|
||||
target.relative_to(STATIC_DIR.resolve())
|
||||
except ValueError:
|
||||
self.send_error(HTTPStatus.NOT_FOUND)
|
||||
return
|
||||
if not target.is_file():
|
||||
self.send_error(HTTPStatus.NOT_FOUND)
|
||||
return
|
||||
body = target.read_bytes()
|
||||
content_type = mimetypes.guess_type(target.name)[0] or "application/octet-stream"
|
||||
self.send_response(HTTPStatus.OK)
|
||||
textual = content_type.startswith("text/") or content_type in {
|
||||
"application/javascript",
|
||||
"application/json",
|
||||
"application/manifest+json",
|
||||
"image/svg+xml",
|
||||
}
|
||||
self.send_header("Content-Type", f"{content_type}; charset=utf-8" if textual else content_type)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
# Revalidate code on every load so a deploy reaches the home-screen app
|
||||
# the next time it opens.
|
||||
self.send_header("Cache-Control", "no-cache" if textual else "public, max-age=86400")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Private period tracker")
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
parser.add_argument("--port", type=int, default=8093)
|
||||
parser.add_argument("--database", default="./flowtrack.sqlite3")
|
||||
parser.add_argument("--editor", action="append", default=[], help="user who owns and edits the data (repeatable)")
|
||||
parser.add_argument("--viewer", action="append", default=[], help="user with read-only access (repeatable)")
|
||||
parser.add_argument("--dev-user", help="identity to assume when no Remote-User header is sent (local testing only)")
|
||||
parser.add_argument("--backup-to", type=Path, help="write a snapshot of the database to this directory and exit")
|
||||
parser.add_argument("--keep", type=int, default=30, help="snapshots to keep with --backup-to")
|
||||
args = parser.parse_args()
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
Path(args.database).parent.mkdir(parents=True, exist_ok=True)
|
||||
store = Store(args.database)
|
||||
store.initialize()
|
||||
if args.backup_to:
|
||||
logging.info("Wrote %s", store.backup(args.backup_to, args.keep))
|
||||
return
|
||||
server = TrackerServer((args.host, args.port), store, set(args.editor), set(args.viewer), args.dev_user)
|
||||
logging.info("Listening on http://%s:%s", args.host, args.port)
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
27
flake.lock
generated
Normal file
27
flake.lock
generated
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1792030654,
|
||||
"narHash": "sha256-OaQwAtPMTjQiY2sT/2/62UbSPhzP/0aDnWzMm/NCiUw=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "26f4107e47e3ad0ffd0db220469ba83b6f6d96e8",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-26.05",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
38
flake.nix
Normal file
38
flake.nix
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
description = "Private, self-hosted period tracker with cycle predictions";
|
||||
|
||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
|
||||
|
||||
outputs = { self, nixpkgs }:
|
||||
let
|
||||
forAllSystems = nixpkgs.lib.genAttrs [ "x86_64-linux" "aarch64-linux" ];
|
||||
in
|
||||
{
|
||||
packages = forAllSystems (system:
|
||||
let pkgs = import nixpkgs { inherit system; };
|
||||
in {
|
||||
default = pkgs.callPackage ./package.nix { };
|
||||
flowtrack = self.packages.${system}.default;
|
||||
});
|
||||
|
||||
nixosModules.default = import ./module.nix self;
|
||||
nixosModules.flowtrack = self.nixosModules.default;
|
||||
|
||||
checks = forAllSystems (system:
|
||||
let pkgs = import nixpkgs { inherit system; };
|
||||
in {
|
||||
inherit (self.packages.${system}) default;
|
||||
tests = pkgs.runCommand "flowtrack-tests" { nativeBuildInputs = [ pkgs.python3 pkgs.nodejs ]; } ''
|
||||
cp -r ${self} source
|
||||
chmod -R u+w source
|
||||
cd source
|
||||
python3 -m unittest discover -s tests -v
|
||||
python3 -m py_compile app.py
|
||||
node --check static/app.js
|
||||
node --check static/cycle.js
|
||||
node tests/test_cycle.js
|
||||
touch $out
|
||||
'';
|
||||
});
|
||||
};
|
||||
}
|
||||
104
module.nix
Normal file
104
module.nix
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
self: { config, lib, pkgs, ... }:
|
||||
let
|
||||
cfg = config.services.flowtrack;
|
||||
database = "/var/lib/flowtrack/flowtrack.sqlite3";
|
||||
hardening = {
|
||||
User = "flowtrack";
|
||||
Group = "flowtrack";
|
||||
StateDirectory = "flowtrack";
|
||||
StateDirectoryMode = "0700";
|
||||
UMask = "0077";
|
||||
NoNewPrivileges = true;
|
||||
PrivateTmp = true;
|
||||
PrivateDevices = true;
|
||||
ProtectSystem = "strict";
|
||||
ProtectHome = true;
|
||||
ProtectKernelTunables = true;
|
||||
ProtectKernelModules = true;
|
||||
ProtectControlGroups = true;
|
||||
RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ];
|
||||
};
|
||||
userFlags = lib.concatMapStringsSep " " (user: "--editor ${lib.escapeShellArg user}") cfg.editors
|
||||
+ " " + lib.concatMapStringsSep " " (user: "--viewer ${lib.escapeShellArg user}") cfg.viewers;
|
||||
in
|
||||
{
|
||||
options.services.flowtrack = {
|
||||
enable = lib.mkEnableOption "the Flowtrack period tracker";
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
default = self.packages.${pkgs.stdenv.hostPlatform.system}.default;
|
||||
defaultText = lib.literalExpression "flowtrack.packages.${pkgs.stdenv.hostPlatform.system}.default";
|
||||
description = "The flowtrack package to run.";
|
||||
};
|
||||
port = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 8093;
|
||||
description = "Loopback TCP port used by the tracker.";
|
||||
};
|
||||
editors = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [ ];
|
||||
description = "Remote-User names that own and edit the data.";
|
||||
};
|
||||
viewers = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [ ];
|
||||
description = "Remote-User names with read-only access.";
|
||||
};
|
||||
backup = {
|
||||
directory = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
description = "Directory for nightly database snapshots; null disables backups.";
|
||||
};
|
||||
keep = lib.mkOption {
|
||||
type = lib.types.ints.positive;
|
||||
default = 30;
|
||||
description = "Number of nightly snapshots to keep.";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable (lib.mkMerge [
|
||||
{
|
||||
users.users.flowtrack = {
|
||||
isSystemUser = true;
|
||||
group = "flowtrack";
|
||||
description = "Flowtrack service user";
|
||||
};
|
||||
users.groups.flowtrack = { };
|
||||
|
||||
systemd.services.flowtrack = {
|
||||
description = "Flowtrack period tracker";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network.target" ];
|
||||
serviceConfig = hardening // {
|
||||
ExecStart = "${cfg.package}/bin/flowtrack --host 127.0.0.1 --port ${toString cfg.port} --database ${database} ${userFlags}";
|
||||
Restart = "on-failure";
|
||||
RestartSec = 3;
|
||||
};
|
||||
};
|
||||
}
|
||||
(lib.mkIf (cfg.backup.directory != null) {
|
||||
systemd.tmpfiles.rules = [ "d ${cfg.backup.directory} 0700 flowtrack flowtrack -" ];
|
||||
|
||||
systemd.services.flowtrack-backup = {
|
||||
description = "Snapshot the Flowtrack database";
|
||||
unitConfig.RequiresMountsFor = [ cfg.backup.directory ];
|
||||
serviceConfig = hardening // {
|
||||
Type = "oneshot";
|
||||
ExecStart = "${cfg.package}/bin/flowtrack --database ${database} --backup-to ${cfg.backup.directory} --keep ${toString cfg.backup.keep}";
|
||||
ReadWritePaths = [ cfg.backup.directory ];
|
||||
};
|
||||
};
|
||||
|
||||
systemd.timers.flowtrack-backup = {
|
||||
wantedBy = [ "timers.target" ];
|
||||
timerConfig = {
|
||||
OnCalendar = "*-*-* 03:15:00";
|
||||
Persistent = true;
|
||||
};
|
||||
};
|
||||
})
|
||||
]);
|
||||
}
|
||||
26
package.nix
Normal file
26
package.nix
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{ lib, stdenvNoCC, makeWrapper, python3 }:
|
||||
|
||||
stdenvNoCC.mkDerivation {
|
||||
pname = "flowtrack";
|
||||
version = "1.0.0";
|
||||
src = ./.;
|
||||
|
||||
nativeBuildInputs = [ makeWrapper ];
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
mkdir -p $out/share/flowtrack $out/bin
|
||||
cp app.py $out/share/flowtrack/app.py
|
||||
cp -r static $out/share/flowtrack/static
|
||||
makeWrapper ${python3}/bin/python3 $out/bin/flowtrack \
|
||||
--add-flags "$out/share/flowtrack/app.py"
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
meta = {
|
||||
description = "Private, self-hosted period tracker with cycle predictions";
|
||||
license = lib.licenses.mit;
|
||||
platforms = lib.platforms.linux;
|
||||
mainProgram = "flowtrack";
|
||||
};
|
||||
}
|
||||
535
static/app.js
Normal file
535
static/app.js
Normal file
|
|
@ -0,0 +1,535 @@
|
|||
(() => {
|
||||
const Cycle = window.Cycle;
|
||||
const FLOWS = [
|
||||
[null, "None"],
|
||||
["spotting", "Spotting"],
|
||||
["light", "Light"],
|
||||
["medium", "Medium"],
|
||||
["heavy", "Heavy"]
|
||||
];
|
||||
const MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
|
||||
|
||||
const state = {
|
||||
role: null,
|
||||
shareDetails: false,
|
||||
symptoms: [],
|
||||
days: {},
|
||||
view: "today",
|
||||
month: null,
|
||||
sheetDay: null
|
||||
};
|
||||
|
||||
const $ = id => document.getElementById(id);
|
||||
|
||||
function h(tag, attributes = {}, ...children) {
|
||||
const element = document.createElement(tag);
|
||||
for (const [name, value] of Object.entries(attributes)) {
|
||||
if (value === false || value === null || value === undefined) continue;
|
||||
if (name === "class") element.className = value;
|
||||
else if (name.startsWith("on")) element.addEventListener(name.slice(2), value);
|
||||
else element.setAttribute(name, value === true ? "" : value);
|
||||
}
|
||||
for (const child of children.flat()) {
|
||||
if (child === null || child === undefined || child === false) continue;
|
||||
element.append(child instanceof Node ? child : document.createTextNode(String(child)));
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
const pad = number => String(number).padStart(2, "0");
|
||||
const todayIso = () => {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
||||
};
|
||||
const formatDay = (number, options = { day: "numeric", month: "short" }) =>
|
||||
new Date(number * 86400000).toLocaleDateString("en-GB", { ...options, timeZone: "UTC" });
|
||||
const longDay = number => formatDay(number, { weekday: "long", day: "numeric", month: "long" });
|
||||
const plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
|
||||
const range = (from, to) => Array.from({ length: Math.max(0, to - from + 1) }, (_, index) => from + index);
|
||||
|
||||
// ---- Server --------------------------------------------------------------
|
||||
|
||||
async function api(path, options = {}) {
|
||||
// An expired Authelia session answers with a redirect to the login portal,
|
||||
// which fetch cannot follow cross-origin. Reloading lets the browser do it.
|
||||
const response = await fetch(path, { redirect: "manual", credentials: "same-origin", ...options });
|
||||
if (response.type === "opaqueredirect") {
|
||||
window.location.reload();
|
||||
throw new Error("Signing in again…");
|
||||
}
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.error || `Request failed (${response.status})`);
|
||||
return payload;
|
||||
}
|
||||
|
||||
const sendJson = (method, path, body) =>
|
||||
api(path, { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
||||
|
||||
function showBanner(message, isError = false) {
|
||||
const banner = $("banner");
|
||||
banner.textContent = message;
|
||||
banner.classList.toggle("error", isError);
|
||||
banner.hidden = !message;
|
||||
clearTimeout(showBanner.timer);
|
||||
if (message && !isError) showBanner.timer = setTimeout(() => { banner.hidden = true; }, 4000);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const payload = await api("/api/state");
|
||||
Object.assign(state, {
|
||||
role: payload.role,
|
||||
shareDetails: payload.shareDetails,
|
||||
symptoms: payload.symptoms,
|
||||
days: payload.days
|
||||
});
|
||||
if (!$("banner").classList.contains("error")) showBanner("");
|
||||
render();
|
||||
} catch (error) {
|
||||
showBanner(error.message === "Not allowed" ? "This account does not have access to Flowtrack." : error.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDay(iso, entry) {
|
||||
const previous = state.days[iso];
|
||||
if (entry.flow === null && !entry.symptoms.length) delete state.days[iso];
|
||||
else state.days[iso] = entry;
|
||||
render();
|
||||
try {
|
||||
await sendJson("PUT", `/api/days/${iso}`, entry);
|
||||
} catch (error) {
|
||||
if (previous) state.days[iso] = previous;
|
||||
else delete state.days[iso];
|
||||
render();
|
||||
showBanner(`Couldn't save: ${error.message}`, true);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Shared pieces -------------------------------------------------------
|
||||
|
||||
const editable = () => state.role === "editor";
|
||||
const detailsVisible = () => editable() || state.shareDetails;
|
||||
|
||||
function logEditor(iso) {
|
||||
const entry = state.days[iso] || { flow: null, symptoms: [] };
|
||||
const canEdit = editable();
|
||||
const flows = FLOWS.slice();
|
||||
// "period" is a period day without an intensity (added from past dates).
|
||||
if (entry.flow === "period") flows.push(["period", "Period"]);
|
||||
const update = change => saveDay(iso, { flow: entry.flow, symptoms: entry.symptoms, ...change });
|
||||
const flowChips = flows.map(([flow, label]) =>
|
||||
h("button", {
|
||||
type: "button",
|
||||
class: "chip",
|
||||
"aria-pressed": String(entry.flow === flow),
|
||||
disabled: !canEdit,
|
||||
onclick: () => update({ flow })
|
||||
}, label)
|
||||
);
|
||||
const symptomChips = state.symptoms.map(({ id, label }) => {
|
||||
const active = entry.symptoms.includes(id);
|
||||
return h("button", {
|
||||
type: "button",
|
||||
class: "chip symptom",
|
||||
"aria-pressed": String(active),
|
||||
disabled: !canEdit,
|
||||
onclick: () => update({
|
||||
symptoms: active ? entry.symptoms.filter(item => item !== id) : [...entry.symptoms, id]
|
||||
})
|
||||
}, label);
|
||||
});
|
||||
return h("div", {},
|
||||
h("section", { class: "log-section" }, h("h3", {}, "Flow"), h("div", { class: "chips" }, flowChips)),
|
||||
h("section", { class: "log-section" }, h("h3", {}, "Symptoms"), h("div", { class: "chips" }, symptomChips))
|
||||
);
|
||||
}
|
||||
|
||||
// Which calendar days carry which marker, from logs and the summary.
|
||||
function dayMarks(summary) {
|
||||
const marks = { period: new Set(), spotting: new Set(), symptoms: new Set(), predicted: new Set(), fertile: new Set(), ovulation: new Set() };
|
||||
for (const [iso, entry] of Object.entries(state.days)) {
|
||||
const day = Cycle.toDay(iso);
|
||||
if (Cycle.PERIOD_FLOWS.has(entry.flow)) marks.period.add(day);
|
||||
if (entry.flow === "spotting") marks.spotting.add(day);
|
||||
if (entry.symptoms.length) marks.symptoms.add(day);
|
||||
}
|
||||
const { current, predictions, fertile, today, periodLength } = summary;
|
||||
if (current && current.onPeriod) {
|
||||
for (const day of range(today + 1, current.start + periodLength - 1)) marks.predicted.add(day);
|
||||
}
|
||||
for (const prediction of predictions) {
|
||||
for (const day of range(Math.max(prediction.start, today + 1), prediction.end)) marks.predicted.add(day);
|
||||
}
|
||||
for (const window of fertile) {
|
||||
for (const day of range(window.start, window.end)) marks.fertile.add(day);
|
||||
marks.ovulation.add(window.ovulation);
|
||||
}
|
||||
return marks;
|
||||
}
|
||||
|
||||
function seedForm(form) {
|
||||
const today = todayIso();
|
||||
const dateInput = () => h("input", { type: "date", max: today, "aria-label": "Period start date" });
|
||||
const dates = h("div", { class: "seed-dates" }, dateInput(), dateInput(), dateInput());
|
||||
const length = h("input", { type: "number", min: "1", max: "10", value: "5", inputmode: "numeric", class: "seed-length" });
|
||||
const submit = h("button", { type: "submit", class: "button" }, "Save periods");
|
||||
form.replaceChildren(
|
||||
h("label", {}, "Start dates", dates),
|
||||
h("button", { type: "button", class: "link-button", onclick: () => dates.append(dateInput()) }, "+ Add another date"),
|
||||
h("label", {}, "Usual length in days", length),
|
||||
submit
|
||||
);
|
||||
form.onsubmit = async event => {
|
||||
event.preventDefault();
|
||||
const days = Math.min(10, Math.max(1, Number(length.value) || 5));
|
||||
const todayNumber = Cycle.toDay(today);
|
||||
const entries = {};
|
||||
for (const input of dates.querySelectorAll("input")) {
|
||||
if (!input.value) continue;
|
||||
const start = Cycle.toDay(input.value);
|
||||
for (const day of range(start, Math.min(start + days - 1, todayNumber))) {
|
||||
entries[Cycle.toIso(day)] = { flow: "period", symptoms: [] };
|
||||
}
|
||||
}
|
||||
if (!Object.keys(entries).length) {
|
||||
showBanner("Pick at least one start date.", true);
|
||||
return;
|
||||
}
|
||||
submit.disabled = true;
|
||||
try {
|
||||
const { added } = await sendJson("POST", "/api/days", { days: entries });
|
||||
showBanner(added ? `Added ${plural(added, "day")}.` : "Those days were already logged.");
|
||||
await load();
|
||||
} catch (error) {
|
||||
showBanner(`Couldn't save: ${error.message}`, true);
|
||||
} finally {
|
||||
submit.disabled = false;
|
||||
}
|
||||
};
|
||||
return form;
|
||||
}
|
||||
|
||||
// ---- Today ---------------------------------------------------------------
|
||||
|
||||
function renderHero(summary) {
|
||||
const hero = $("hero");
|
||||
hero.className = "hero card";
|
||||
const { current, predictions, late, today, confidence, sampleSize } = summary;
|
||||
if (!current) {
|
||||
hero.replaceChildren(...[
|
||||
h("p", { class: "eyebrow" }, "Welcome"),
|
||||
h("p", { class: "lead" }, editable() ? "When did your recent periods start?" : "Nothing has been logged yet."),
|
||||
editable() && h("p", { class: "detail" }, "Two or three dates are enough to start predicting. You can add more later in Settings."),
|
||||
editable() && seedForm(h("form", { class: "seed-form facts" }))
|
||||
].filter(Boolean));
|
||||
return;
|
||||
}
|
||||
const next = predictions[0];
|
||||
const untilNext = next.start - today;
|
||||
let headline;
|
||||
if (current.onPeriod) {
|
||||
hero.classList.add("on-period");
|
||||
headline = [
|
||||
h("p", { class: "eyebrow" }, "Period"),
|
||||
h("p", { class: "figure" }, "Day ", today - current.start + 1),
|
||||
h("p", { class: "lead" }, `Usually lasts about ${plural(summary.periodLength, "day")}`)
|
||||
];
|
||||
} else if (late) {
|
||||
headline = [
|
||||
h("p", { class: "eyebrow" }, "Period expected"),
|
||||
h("p", { class: "figure" }, late, h("small", {}, late === 1 ? " day late" : " days late")),
|
||||
h("p", { class: "lead" }, `It was expected on ${formatDay(today - late, { weekday: "short", day: "numeric", month: "short" })}`),
|
||||
h("p", { class: "detail" }, "A few days either way is common. Log it here when it starts.")
|
||||
];
|
||||
} else {
|
||||
headline = [
|
||||
h("p", { class: "eyebrow" }, "Cycle day"),
|
||||
h("p", { class: "figure" }, current.cycleDay),
|
||||
h("p", { class: "lead" }, untilNext === 0 ? "Period expected today" : `Next period in about ${plural(untilNext, "day")}`),
|
||||
h("p", { class: "detail" }, `Around ${formatDay(next.start, { weekday: "short", day: "numeric", month: "short" })} · likely ${formatDay(next.earliest)} – ${formatDay(next.latest)}`)
|
||||
];
|
||||
}
|
||||
|
||||
const facts = [];
|
||||
if (current.onPeriod || late) {
|
||||
const upcoming = late ? predictions[1] : next;
|
||||
facts.push(fact("key-predicted", "Next predicted period", `${formatDay(upcoming.start)} · likely ${formatDay(upcoming.earliest)} – ${formatDay(upcoming.latest)}`));
|
||||
}
|
||||
const window = summary.fertile.find(item => item.end >= today);
|
||||
if (window) {
|
||||
const inWindow = window.start <= today;
|
||||
facts.push(fact(
|
||||
"key-ovulation",
|
||||
inWindow ? "Fertile window now (estimate)" : "Next fertile window (estimate)",
|
||||
inWindow
|
||||
? `Until ${formatDay(window.end)} · ovulation around ${formatDay(window.ovulation)}`
|
||||
: `${formatDay(window.start)} – ${formatDay(window.end)} · ovulation around ${formatDay(window.ovulation)}`
|
||||
));
|
||||
}
|
||||
|
||||
const note = confidence === "none"
|
||||
? "Log another period to personalise predictions. Until then they assume a 28-day cycle."
|
||||
: confidence === "low"
|
||||
? `Based on ${plural(sampleSize, "cycle")} so far. Predictions get sharper after three.`
|
||||
: null;
|
||||
hero.replaceChildren(...[...headline, facts.length && h("div", { class: "facts" }, facts), note && h("p", { class: "note" }, note)].filter(Boolean));
|
||||
}
|
||||
|
||||
function fact(keyClass, title, text) {
|
||||
return h("div", { class: "fact" }, h("span", { class: `key ${keyClass}` }), h("div", {}, h("strong", {}, title), h("span", {}, text)));
|
||||
}
|
||||
|
||||
function renderTodayLog() {
|
||||
const card = $("today-log");
|
||||
card.hidden = !detailsVisible();
|
||||
if (card.hidden) return;
|
||||
card.replaceChildren(h("h2", {}, editable() ? "How's today?" : "Today"), h("p", { class: "subtitle" }, longDay(Cycle.toDay(todayIso()))), logEditor(todayIso()));
|
||||
}
|
||||
|
||||
// ---- Calendar ------------------------------------------------------------
|
||||
|
||||
function renderCalendar(summary) {
|
||||
const { year, month } = state.month;
|
||||
$("month-title").textContent = `${MONTHS[month]} ${year}`;
|
||||
const marks = dayMarks(summary);
|
||||
const first = Cycle.toDay(`${year}-${pad(month + 1)}-01`);
|
||||
const count = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
|
||||
const leading = (new Date(first * 86400000).getUTCDay() + 6) % 7;
|
||||
const cells = range(1, leading).map(() => h("span", { class: "day outside", "aria-hidden": "true" }));
|
||||
for (const day of range(first, first + count - 1)) {
|
||||
const kinds = [];
|
||||
if (marks.period.has(day)) kinds.push("period");
|
||||
else if (marks.predicted.has(day)) kinds.push("predicted");
|
||||
if (marks.fertile.has(day) && !marks.period.has(day)) kinds.push("fertile");
|
||||
if (day === summary.today) kinds.push("today");
|
||||
if (day > summary.today) kinds.push("future");
|
||||
const dots = [];
|
||||
if (marks.ovulation.has(day)) dots.push("ovulation");
|
||||
if (marks.spotting.has(day)) dots.push("spotting");
|
||||
if (marks.symptoms.has(day)) dots.push("symptoms");
|
||||
const described = [
|
||||
marks.period.has(day) && "period",
|
||||
marks.predicted.has(day) && !marks.period.has(day) && "predicted period",
|
||||
marks.fertile.has(day) && "fertile window estimate",
|
||||
marks.ovulation.has(day) && "estimated ovulation",
|
||||
marks.spotting.has(day) && "spotting",
|
||||
marks.symptoms.has(day) && "symptoms logged"
|
||||
].filter(Boolean);
|
||||
cells.push(h("button", {
|
||||
type: "button",
|
||||
class: ["day", ...kinds].join(" "),
|
||||
"aria-label": [longDay(day), ...described].join(", "),
|
||||
"aria-current": day === summary.today ? "date" : null,
|
||||
onclick: () => openSheet(day)
|
||||
}, formatDay(day, { day: "numeric" }), dots.length ? h("span", { class: "marks" }, dots.map(kind => h("span", { class: `mark ${kind}` }))) : null));
|
||||
}
|
||||
$("month-grid").replaceChildren(...cells);
|
||||
}
|
||||
|
||||
function shiftMonth(delta) {
|
||||
const { year, month } = state.month;
|
||||
const next = new Date(Date.UTC(year, month + delta, 1));
|
||||
state.month = { year: next.getUTCFullYear(), month: next.getUTCMonth() };
|
||||
render();
|
||||
}
|
||||
|
||||
function openSheet(day) {
|
||||
state.sheetDay = day;
|
||||
renderSheet(Cycle.summarize(state.days, todayIso()));
|
||||
const sheet = $("day-sheet");
|
||||
if (!sheet.open) sheet.showModal();
|
||||
}
|
||||
|
||||
function renderSheet(summary) {
|
||||
const day = state.sheetDay;
|
||||
if (day === null) return;
|
||||
const marks = dayMarks(summary);
|
||||
$("sheet-title").textContent = longDay(day);
|
||||
const notes = [];
|
||||
if (marks.predicted.has(day) && !marks.period.has(day)) notes.push("Predicted period day.");
|
||||
if (marks.ovulation.has(day)) notes.push("Estimated ovulation day.");
|
||||
else if (marks.fertile.has(day)) notes.push("Inside the estimated fertile window.");
|
||||
const body = [];
|
||||
if (notes.length) body.push(h("p", { class: "prediction-note" }, notes.join(" ")));
|
||||
if (day > summary.today) {
|
||||
if (!notes.length) body.push(h("p", { class: "prediction-note" }, "Nothing predicted for this day."));
|
||||
} else if (detailsVisible()) {
|
||||
body.push(logEditor(Cycle.toIso(day)));
|
||||
} else {
|
||||
body.push(h("p", { class: "prediction-note" }, marks.period.has(day) ? "Period day." : "Nothing logged."));
|
||||
}
|
||||
$("sheet-body").replaceChildren(...body);
|
||||
}
|
||||
|
||||
// ---- Stats ---------------------------------------------------------------
|
||||
|
||||
function tile(label, value, unit) {
|
||||
return h("div", { class: "tile" }, h("p", { class: "label" }, label), h("p", { class: "value" }, value, unit && h("small", {}, ` ${unit}`)));
|
||||
}
|
||||
|
||||
function renderStats(summary) {
|
||||
const { sampleSize, cycleLength, cycleSpread, periodLength, cycles, current, today } = summary;
|
||||
$("tiles").replaceChildren(
|
||||
tile("Average cycle", sampleSize ? cycleLength : "–", sampleSize ? "days" : ""),
|
||||
tile("Cycle variation", sampleSize > 1 ? `±${Math.max(1, Math.round(cycleSpread))}` : "–", sampleSize > 1 ? "days" : ""),
|
||||
tile("Average period", current ? periodLength : "–", current ? "days" : ""),
|
||||
tile("Cycles tracked", sampleSize)
|
||||
);
|
||||
renderChart(cycles.filter(cycle => cycle.used).slice(-12), summary);
|
||||
|
||||
const rows = [];
|
||||
if (current) {
|
||||
rows.push(h("tr", {},
|
||||
h("td", {}, formatDay(current.start, { day: "numeric", month: "short", year: "numeric" })),
|
||||
h("td", {}, `Day ${current.cycleDay} so far`),
|
||||
h("td", {}, current.end >= today - 1 ? "Ongoing" : plural(current.end - current.start + 1, "day"))
|
||||
));
|
||||
}
|
||||
for (const cycle of cycles.slice().reverse()) {
|
||||
rows.push(h("tr", { class: cycle.used ? null : "excluded" },
|
||||
h("td", {}, formatDay(cycle.start, { day: "numeric", month: "short", year: "numeric" })),
|
||||
h("td", {}, plural(cycle.length, "day"), !cycle.used && h("small", {}, "Not used: likely a missed log")),
|
||||
h("td", {}, plural(cycle.periodLength, "day"))
|
||||
));
|
||||
}
|
||||
if (!rows.length) rows.push(h("tr", {}, h("td", { colspan: "3" }, "Nothing logged yet.")));
|
||||
$("history").replaceChildren(...rows);
|
||||
}
|
||||
|
||||
function renderChart(data, summary) {
|
||||
const container = $("chart");
|
||||
if (data.length < 2) {
|
||||
container.replaceChildren(h("p", { class: "chart-empty" }, "The chart appears once two full cycles are logged."));
|
||||
return;
|
||||
}
|
||||
const svgNs = "http://www.w3.org/2000/svg";
|
||||
const s = (tag, attributes = {}) => {
|
||||
const element = document.createElementNS(svgNs, tag);
|
||||
for (const [name, value] of Object.entries(attributes)) element.setAttribute(name, value);
|
||||
return element;
|
||||
};
|
||||
const width = 320;
|
||||
const height = 170;
|
||||
const left = 26;
|
||||
const top = 16;
|
||||
const bottom = 22;
|
||||
const plotHeight = height - top - bottom;
|
||||
const ceiling = Math.max(35, Math.ceil(Math.max(...data.map(cycle => cycle.length)) / 10) * 10);
|
||||
const y = value => top + plotHeight - (value / ceiling) * plotHeight;
|
||||
const band = (width - left) / data.length;
|
||||
const barWidth = Math.min(24, band - 4);
|
||||
|
||||
const svg = s("svg", { viewBox: `0 0 ${width} ${height}`, role: "img", "aria-label": `Cycle lengths for the last ${data.length} cycles; the history table below lists every value.` });
|
||||
for (let tick = 0; tick <= ceiling; tick += 10) {
|
||||
svg.append(s("line", { class: "grid", x1: left, x2: width, y1: y(tick), y2: y(tick) }));
|
||||
const label = s("text", { class: "axis-label", x: left - 6, y: y(tick) + 4, "text-anchor": "end" });
|
||||
label.textContent = tick;
|
||||
svg.append(label);
|
||||
}
|
||||
const tooltip = h("div", { class: "tooltip", hidden: true });
|
||||
const show = (index, x) => {
|
||||
const cycle = data[index];
|
||||
tooltip.textContent = `${formatDay(cycle.start)} · ${plural(cycle.length, "day")}`;
|
||||
tooltip.style.left = `${(x / width) * 100}%`;
|
||||
tooltip.style.top = `${(y(cycle.length) / height) * 100}%`;
|
||||
tooltip.hidden = false;
|
||||
};
|
||||
data.forEach((cycle, index) => {
|
||||
const x = left + band * index + (band - barWidth) / 2;
|
||||
const yTop = y(cycle.length);
|
||||
const base = y(0);
|
||||
const radius = Math.min(4, base - yTop);
|
||||
// Rounded at the data end, square at the baseline.
|
||||
svg.append(s("path", {
|
||||
class: "bar",
|
||||
d: `M${x},${base}V${yTop + radius}Q${x},${yTop} ${x + radius},${yTop}H${x + barWidth - radius}Q${x + barWidth},${yTop} ${x + barWidth},${yTop + radius}V${base}Z`
|
||||
}));
|
||||
const hit = s("rect", { class: "hit", x: left + band * index, y: top, width: band, height: plotHeight });
|
||||
hit.addEventListener("pointerenter", () => show(index, x + barWidth / 2));
|
||||
hit.addEventListener("click", () => show(index, x + barWidth / 2));
|
||||
hit.addEventListener("pointerleave", () => { tooltip.hidden = true; });
|
||||
svg.append(hit);
|
||||
});
|
||||
const average = s("line", { class: "avg", x1: left, x2: width, y1: y(summary.cycleLength), y2: y(summary.cycleLength) });
|
||||
const averageLabel = s("text", { class: "avg-label", x: width, y: y(summary.cycleLength) - 5, "text-anchor": "end" });
|
||||
averageLabel.textContent = `avg ${summary.cycleLength}`;
|
||||
svg.insertBefore(average, svg.querySelector(".hit"));
|
||||
svg.insertBefore(averageLabel, svg.querySelector(".hit"));
|
||||
for (const index of [0, data.length - 1]) {
|
||||
const label = s("text", { class: "axis-label", x: left + band * index + band / 2, y: height - 6, "text-anchor": "middle" });
|
||||
label.textContent = formatDay(data[index].start, { month: "short", year: "numeric" });
|
||||
svg.append(label);
|
||||
}
|
||||
container.replaceChildren(svg, tooltip);
|
||||
}
|
||||
|
||||
// ---- Settings ------------------------------------------------------------
|
||||
|
||||
function renderSettings() {
|
||||
$("share-toggle").checked = state.shareDetails;
|
||||
}
|
||||
|
||||
// ---- Shell ---------------------------------------------------------------
|
||||
|
||||
function setView(view) {
|
||||
state.view = view;
|
||||
for (const button of document.querySelectorAll(".tabs button")) {
|
||||
button.setAttribute("aria-selected", String(button.dataset.view === view));
|
||||
}
|
||||
for (const section of document.querySelectorAll(".view")) section.hidden = section.id !== `view-${view}`;
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!state.role) return;
|
||||
const summary = Cycle.summarize(state.days, todayIso());
|
||||
const viewer = !editable();
|
||||
$("role-badge").hidden = !viewer;
|
||||
$("tab-settings").hidden = viewer;
|
||||
document.querySelector(".tabs").classList.toggle("three", viewer);
|
||||
if (viewer && state.view === "settings") setView("today");
|
||||
renderHero(summary);
|
||||
renderTodayLog();
|
||||
renderCalendar(summary);
|
||||
renderStats(summary);
|
||||
renderSettings();
|
||||
renderSheet(summary);
|
||||
}
|
||||
|
||||
function resetMonth() {
|
||||
const now = new Date();
|
||||
state.month = { year: now.getFullYear(), month: now.getMonth() };
|
||||
}
|
||||
|
||||
resetMonth();
|
||||
for (const button of document.querySelectorAll(".tabs button")) {
|
||||
button.addEventListener("click", () => setView(button.dataset.view));
|
||||
}
|
||||
const linked = window.location.hash.slice(1);
|
||||
if (["calendar", "stats", "settings"].includes(linked)) setView(linked);
|
||||
$("month-prev").addEventListener("click", () => shiftMonth(-1));
|
||||
$("month-next").addEventListener("click", () => shiftMonth(1));
|
||||
$("month-today").addEventListener("click", () => { resetMonth(); render(); });
|
||||
$("day-sheet").addEventListener("close", () => { state.sheetDay = null; });
|
||||
$("day-sheet").addEventListener("click", event => {
|
||||
if (event.target === event.currentTarget) event.currentTarget.close();
|
||||
});
|
||||
$("share-toggle").addEventListener("change", async event => {
|
||||
const shareDetails = event.target.checked;
|
||||
try {
|
||||
await sendJson("PUT", "/api/settings", { shareDetails });
|
||||
state.shareDetails = shareDetails;
|
||||
showBanner(shareDetails ? "Flow and symptoms are now shared." : "Flow and symptoms are private again.");
|
||||
} catch (error) {
|
||||
event.target.checked = !shareDetails;
|
||||
showBanner(`Couldn't save: ${error.message}`, true);
|
||||
}
|
||||
});
|
||||
seedForm($("seed-form"));
|
||||
// The home-screen app is often resumed rather than reopened, so refresh
|
||||
// whenever it comes back to the foreground (this also rolls "today" over).
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "visible") load();
|
||||
});
|
||||
load();
|
||||
})();
|
||||
BIN
static/apple-touch-icon.png
Normal file
BIN
static/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
143
static/cycle.js
Normal file
143
static/cycle.js
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
(function exposeCycle(root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module === "object" && module.exports) module.exports = api;
|
||||
if (root) root.Cycle = api;
|
||||
})(typeof window === "undefined" ? globalThis : window, () => {
|
||||
const MS_PER_DAY = 86400000;
|
||||
const PERIOD_FLOWS = new Set(["period", "light", "medium", "heavy"]);
|
||||
const DEFAULT_CYCLE = 28;
|
||||
const DEFAULT_PERIOD = 5;
|
||||
// Days from ovulation to the next period. Fairly constant between people,
|
||||
// which is why ovulation is counted backwards from the predicted start.
|
||||
const LUTEAL_DAYS = 14;
|
||||
// Outside this range a "cycle" is almost always a missed or stray log.
|
||||
const MIN_CYCLE = 15;
|
||||
const MAX_CYCLE = 60;
|
||||
const HISTORY = 12;
|
||||
const FORECAST = 6;
|
||||
|
||||
// Dates are handled as whole days since 1970-01-01 so month lengths and
|
||||
// DST shifts never enter the arithmetic.
|
||||
const toDay = iso => {
|
||||
const [year, month, day] = iso.split("-").map(Number);
|
||||
return Math.round(Date.UTC(year, month - 1, day) / MS_PER_DAY);
|
||||
};
|
||||
const toIso = number => new Date(number * MS_PER_DAY).toISOString().slice(0, 10);
|
||||
|
||||
// Consecutive period days form one period. A single unlogged day inside a
|
||||
// period (forgot to log, or a light day logged as nothing) does not split it.
|
||||
function periods(days) {
|
||||
const periodDays = Object.keys(days)
|
||||
.filter(iso => PERIOD_FLOWS.has(days[iso] && days[iso].flow))
|
||||
.map(toDay)
|
||||
.sort((a, b) => a - b);
|
||||
const result = [];
|
||||
for (const day of periodDays) {
|
||||
const last = result[result.length - 1];
|
||||
if (last && day - last.end <= 2) last.end = day;
|
||||
else result.push({ start: day, end: day });
|
||||
}
|
||||
for (const period of result) period.length = period.end - period.start + 1;
|
||||
return result;
|
||||
}
|
||||
|
||||
function cycles(periodList) {
|
||||
const result = [];
|
||||
for (let index = 1; index < periodList.length; index += 1) {
|
||||
const start = periodList[index - 1].start;
|
||||
const length = periodList[index].start - start;
|
||||
result.push({
|
||||
start,
|
||||
length,
|
||||
periodLength: periodList[index - 1].length,
|
||||
used: length >= MIN_CYCLE && length <= MAX_CYCLE
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Recent cycles count more (weights 1..n, oldest first), so a gradual shift
|
||||
// in her cycle shows up in predictions within a few months.
|
||||
function weightedStats(values) {
|
||||
const weights = values.map((_, index) => index + 1);
|
||||
const total = weights.reduce((sum, weight) => sum + weight, 0);
|
||||
const mean = values.reduce((sum, value, index) => sum + value * weights[index], 0) / total;
|
||||
const variance = values.reduce((sum, value, index) => sum + weights[index] * (value - mean) ** 2, 0) / total;
|
||||
return { mean, sd: Math.sqrt(variance) };
|
||||
}
|
||||
|
||||
function fertileWindow(nextStart, previousEnd) {
|
||||
const ovulation = nextStart - LUTEAL_DAYS;
|
||||
if (ovulation <= previousEnd) return null;
|
||||
return { ovulation, start: Math.max(ovulation - 5, previousEnd + 1), end: ovulation + 1 };
|
||||
}
|
||||
|
||||
function summarize(days, todayIso) {
|
||||
const today = toDay(todayIso);
|
||||
const periodList = periods(days).filter(period => period.start <= today);
|
||||
const cycleList = cycles(periodList);
|
||||
const used = cycleList.filter(cycle => cycle.used).slice(-HISTORY);
|
||||
const summary = {
|
||||
today,
|
||||
periods: periodList,
|
||||
cycles: cycleList,
|
||||
sampleSize: used.length,
|
||||
confidence: used.length === 0 ? "none" : used.length < 3 ? "low" : "normal",
|
||||
cycleLength: DEFAULT_CYCLE,
|
||||
cycleSpread: null,
|
||||
periodLength: DEFAULT_PERIOD,
|
||||
current: null,
|
||||
late: 0,
|
||||
predictions: [],
|
||||
fertile: []
|
||||
};
|
||||
if (!periodList.length) return summary;
|
||||
|
||||
if (used.length) {
|
||||
const stats = weightedStats(used.map(cycle => cycle.length));
|
||||
summary.cycleLength = Math.round(stats.mean);
|
||||
summary.cycleSpread = stats.sd;
|
||||
}
|
||||
const last = periodList[periodList.length - 1];
|
||||
// The latest period may still be going, so it says nothing about length yet.
|
||||
const finished = periodList.filter(period => period !== last || period.end < today - 1);
|
||||
const lengths = finished.filter(period => period.length >= 2).slice(-6).map(period => period.length);
|
||||
if (lengths.length) {
|
||||
const mean = lengths.reduce((sum, value) => sum + value, 0) / lengths.length;
|
||||
summary.periodLength = Math.min(10, Math.max(2, Math.round(mean)));
|
||||
}
|
||||
|
||||
summary.current = {
|
||||
start: last.start,
|
||||
end: last.end,
|
||||
cycleDay: today - last.start + 1,
|
||||
onPeriod: last.end === today
|
||||
};
|
||||
|
||||
const expected = last.start + summary.cycleLength;
|
||||
// A late period keeps being expected "from tomorrow" rather than drifting
|
||||
// into the past, and the cycles after it shift with it.
|
||||
let start = expected;
|
||||
if (today > expected && !summary.current.onPeriod) {
|
||||
summary.late = today - expected;
|
||||
start = today + 1;
|
||||
}
|
||||
const thinData = summary.confidence !== "normal";
|
||||
const spread = Math.max(summary.cycleSpread || 0, thinData ? 4 : 2);
|
||||
let previousEnd = last.end;
|
||||
for (let index = 1; index <= FORECAST; index += 1) {
|
||||
const margin = Math.min(10, Math.round(spread * Math.sqrt(index)));
|
||||
const end = start + summary.periodLength - 1;
|
||||
summary.predictions.push({ start, end, earliest: start - margin, latest: start + margin });
|
||||
// The current cycle's window is anchored on the original expectation:
|
||||
// being late does not move an ovulation that already happened.
|
||||
const window = fertileWindow(index === 1 ? expected : start, previousEnd);
|
||||
if (window) summary.fertile.push(window);
|
||||
previousEnd = end;
|
||||
start += summary.cycleLength;
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
return { PERIOD_FLOWS, LUTEAL_DAYS, MIN_CYCLE, MAX_CYCLE, toDay, toIso, periods, cycles, weightedStats, summarize };
|
||||
});
|
||||
BIN
static/icon-192.png
Normal file
BIN
static/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
BIN
static/icon-512.png
Normal file
BIN
static/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.7 KiB |
5
static/icon.svg
Normal file
5
static/icon.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="112" fill="#cf3f66"/>
|
||||
<path d="M256 92c62 78 118 152 118 220a118 118 0 0 1-236 0c0-68 56-142 118-220z" fill="#fff"/>
|
||||
<path d="M208 318a50 50 0 0 0 44 50" fill="none" stroke="#cf3f66" stroke-width="18" stroke-linecap="round" opacity=".55"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 351 B |
130
static/index.html
Normal file
130
static/index.html
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>Flowtrack</title>
|
||||
<meta name="description" content="A private period tracker with cycle predictions.">
|
||||
<meta name="theme-color" content="#faf6f4" media="(prefers-color-scheme: light)">
|
||||
<meta name="theme-color" content="#18141b" media="(prefers-color-scheme: dark)">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
<meta name="apple-mobile-web-app-title" content="Flowtrack">
|
||||
<link rel="manifest" href="/manifest.webmanifest" crossorigin="use-credentials">
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
<link rel="stylesheet" href="/styles.css?v=1.0.0">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<h1>Flowtrack</h1>
|
||||
<span class="badge" id="role-badge" hidden>View only</span>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<p class="banner" id="banner" role="status" hidden></p>
|
||||
|
||||
<section class="view" id="view-today" aria-labelledby="tab-today">
|
||||
<div class="hero card" id="hero"></div>
|
||||
<div class="card" id="today-log"></div>
|
||||
<p class="footnote">Predictions are estimates from your own history. They are not a form of contraception.</p>
|
||||
</section>
|
||||
|
||||
<section class="view" id="view-calendar" aria-labelledby="tab-calendar" hidden>
|
||||
<div class="card">
|
||||
<div class="month-nav">
|
||||
<button type="button" class="icon-button" id="month-prev" aria-label="Previous month">‹</button>
|
||||
<h2 id="month-title"></h2>
|
||||
<button type="button" class="icon-button" id="month-next" aria-label="Next month">›</button>
|
||||
</div>
|
||||
<div class="weekdays" aria-hidden="true">
|
||||
<span>Mon</span><span>Tue</span><span>Wed</span><span>Thu</span><span>Fri</span><span>Sat</span><span>Sun</span>
|
||||
</div>
|
||||
<div class="month-grid" id="month-grid" role="grid"></div>
|
||||
<button type="button" class="link-button" id="month-today">Back to today</button>
|
||||
</div>
|
||||
<ul class="legend card">
|
||||
<li><span class="key key-period"></span>Period</li>
|
||||
<li><span class="key key-predicted"></span>Predicted period</li>
|
||||
<li><span class="key key-fertile"></span>Fertile window (estimate)</li>
|
||||
<li><span class="key key-ovulation"></span>Estimated ovulation</li>
|
||||
<li><span class="key key-spotting"></span>Spotting</li>
|
||||
<li><span class="key key-symptoms"></span>Symptoms logged</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="view" id="view-stats" aria-labelledby="tab-stats" hidden>
|
||||
<div class="tiles" id="tiles"></div>
|
||||
<div class="card" id="chart-card">
|
||||
<h2>Cycle length</h2>
|
||||
<p class="subtitle">Days from one period to the next, most recent on the right</p>
|
||||
<div class="chart" id="chart"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>History</h2>
|
||||
<div class="table-wrap">
|
||||
<table class="history">
|
||||
<thead><tr><th scope="col">Started</th><th scope="col">Cycle</th><th scope="col">Period</th></tr></thead>
|
||||
<tbody id="history"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="view" id="view-settings" aria-labelledby="tab-settings" hidden>
|
||||
<div class="card" id="share-card">
|
||||
<h2>Sharing</h2>
|
||||
<label class="switch-row">
|
||||
<span>
|
||||
<strong>Share flow and symptoms</strong>
|
||||
<small>View-only users always see period days and predictions. Turn this on to also show them your flow and symptoms.</small>
|
||||
</span>
|
||||
<input type="checkbox" id="share-toggle" role="switch">
|
||||
</label>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Add past periods</h2>
|
||||
<p class="subtitle">Enter when recent periods started. Days you already logged are left untouched.</p>
|
||||
<form id="seed-form" class="seed-form"></form>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Export</h2>
|
||||
<p class="subtitle">Download everything you have logged.</p>
|
||||
<div class="button-row">
|
||||
<a class="button secondary" href="/api/export.csv" download>CSV</a>
|
||||
<a class="button secondary" href="/api/export.json" download>JSON</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<nav class="tabs" role="tablist" aria-label="Sections">
|
||||
<button type="button" role="tab" id="tab-today" data-view="today" aria-selected="true">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3c3.5 4.4 6 7.9 6 11a6 6 0 0 1-12 0c0-3.1 2.5-6.6 6-11z"/></svg>Today
|
||||
</button>
|
||||
<button type="button" role="tab" id="tab-calendar" data-view="calendar" aria-selected="false">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="5" width="16" height="15" rx="2"/><path d="M4 10h16M9 3v4M15 3v4"/></svg>Calendar
|
||||
</button>
|
||||
<button type="button" role="tab" id="tab-stats" data-view="stats" aria-selected="false">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 20V11M12 20V5M19 20v-7"/></svg>Stats
|
||||
</button>
|
||||
<button type="button" role="tab" id="tab-settings" data-view="settings" aria-selected="false">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M5.6 18.4l2.1-2.1M16.3 7.7l2.1-2.1"/></svg>Settings
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<dialog class="sheet" id="day-sheet" aria-labelledby="sheet-title">
|
||||
<form method="dialog" class="sheet-inner">
|
||||
<div class="sheet-head">
|
||||
<h2 id="sheet-title"></h2>
|
||||
<button class="icon-button" value="close" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div id="sheet-body"></div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<script src="/cycle.js?v=1.0.0"></script>
|
||||
<script src="/app.js?v=1.0.0"></script>
|
||||
</body>
|
||||
</html>
|
||||
15
static/manifest.webmanifest
Normal file
15
static/manifest.webmanifest
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"name": "Flowtrack",
|
||||
"short_name": "Flowtrack",
|
||||
"description": "A private period tracker with cycle predictions.",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#faf6f4",
|
||||
"theme_color": "#faf6f4",
|
||||
"icons": [
|
||||
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" },
|
||||
{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml" }
|
||||
]
|
||||
}
|
||||
368
static/styles.css
Normal file
368
static/styles.css
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #faf6f4;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #f4eeec;
|
||||
--ink: #2b2330;
|
||||
--ink-2: #5d5463;
|
||||
--muted: #847a89;
|
||||
--line: #e9e1df;
|
||||
--period: #cf3f66;
|
||||
--period-soft: #f9dde5;
|
||||
--period-ink: #ffffff;
|
||||
--fertile: #1f8f8a;
|
||||
--fertile-soft: #d6efed;
|
||||
--focus: #6d4fc4;
|
||||
--danger: #b3261e;
|
||||
--shadow: 0 1px 2px rgb(43 35 48 / 6%), 0 4px 16px rgb(43 35 48 / 5%);
|
||||
--radius: 18px;
|
||||
--tabs-height: 64px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #18141b;
|
||||
--surface: #221d26;
|
||||
--surface-2: #2c2631;
|
||||
--ink: #f2ecf3;
|
||||
--ink-2: #c3b9c7;
|
||||
--muted: #9a8f9f;
|
||||
--line: #3a3340;
|
||||
--period: #ee6b8d;
|
||||
--period-soft: #4b2534;
|
||||
--period-ink: #1e0b12;
|
||||
--fertile: #4cc4bd;
|
||||
--fertile-soft: #183937;
|
||||
--focus: #a991f0;
|
||||
--danger: #ff8a80;
|
||||
--shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html { -webkit-text-size-adjust: 100%; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-size: 16px;
|
||||
line-height: 1.45;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
button, input, select { font: inherit; color: inherit; }
|
||||
|
||||
:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
|
||||
|
||||
h1, h2 { margin: 0; line-height: 1.2; }
|
||||
h2 { font-size: 1.05rem; font-weight: 600; }
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
padding: calc(env(safe-area-inset-top) + 14px) 16px 6px;
|
||||
}
|
||||
.topbar h1 { font-size: 1.35rem; font-weight: 700; letter-spacing: -0.01em; }
|
||||
|
||||
.badge {
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-2);
|
||||
color: var(--ink-2);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
padding: 8px 16px calc(var(--tabs-height) + env(safe-area-inset-bottom) + 24px);
|
||||
}
|
||||
|
||||
.view { display: grid; gap: 14px; }
|
||||
.view[hidden] { display: none; }
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
padding: 18px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.card > h2 + .subtitle { margin-top: 4px; }
|
||||
|
||||
.subtitle { margin: 0 0 14px; color: var(--muted); font-size: 0.9rem; }
|
||||
.footnote { margin: 0; padding: 0 4px; color: var(--muted); font-size: 0.8rem; text-align: center; }
|
||||
|
||||
.banner {
|
||||
margin: 0 0 12px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
background: var(--surface-2);
|
||||
color: var(--ink);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.banner.error { color: var(--danger); }
|
||||
|
||||
/* Today */
|
||||
|
||||
.hero { text-align: center; padding: 26px 18px 22px; }
|
||||
.hero .eyebrow { margin: 0; color: var(--muted); font-size: 0.85rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; }
|
||||
.hero .figure { margin: 4px 0 2px; font-size: 3.4rem; font-weight: 700; letter-spacing: -0.03em; line-height: 1.05; font-variant-numeric: tabular-nums; }
|
||||
.hero .figure small { font-size: 1.1rem; font-weight: 600; color: var(--ink-2); letter-spacing: 0; }
|
||||
.hero .lead { margin: 6px 0 0; font-size: 1.05rem; font-weight: 600; }
|
||||
.hero .detail { margin: 2px 0 0; color: var(--ink-2); font-size: 0.92rem; }
|
||||
.hero .note { margin: 14px auto 0; max-width: 34ch; color: var(--muted); font-size: 0.85rem; }
|
||||
.hero.on-period .figure { color: var(--period); }
|
||||
|
||||
.facts { display: grid; gap: 10px; margin: 18px 0 0; padding: 16px 0 0; border-top: 1px solid var(--line); text-align: left; }
|
||||
.fact { display: flex; align-items: center; gap: 12px; }
|
||||
.fact .key { flex: none; }
|
||||
.fact div { display: grid; }
|
||||
.fact strong { font-size: 0.95rem; font-weight: 600; }
|
||||
.fact span { color: var(--ink-2); font-size: 0.88rem; }
|
||||
|
||||
.log-section + .log-section { margin-top: 18px; }
|
||||
.log-section h3 { margin: 0 0 10px; color: var(--ink-2); font-size: 0.85rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.chip {
|
||||
min-height: 40px;
|
||||
padding: 8px 14px;
|
||||
border: 1.5px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: var(--surface);
|
||||
font-size: 0.92rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
.chip[aria-pressed="true"] { border-color: var(--period); background: var(--period); color: var(--period-ink); }
|
||||
.chip.symptom[aria-pressed="true"] { border-color: var(--ink); background: var(--ink); color: var(--surface); }
|
||||
.chip:disabled { cursor: default; }
|
||||
.chip:disabled:not([aria-pressed="true"]) { opacity: 0.55; }
|
||||
|
||||
.empty-log { margin: 0; color: var(--muted); font-size: 0.92rem; }
|
||||
|
||||
/* Buttons */
|
||||
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 44px;
|
||||
padding: 10px 18px;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
background: var(--period);
|
||||
color: var(--period-ink);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.button.secondary { background: var(--surface-2); color: var(--ink); }
|
||||
.button:disabled { opacity: 0.6; cursor: default; }
|
||||
.button-row { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||
|
||||
.icon-button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-2);
|
||||
font-size: 1.4rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.link-button {
|
||||
display: block;
|
||||
margin: 12px auto 0;
|
||||
padding: 6px 10px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--ink-2);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Calendar */
|
||||
|
||||
.month-nav { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
|
||||
.month-nav h2 { font-size: 1.1rem; }
|
||||
|
||||
.weekdays, .month-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; }
|
||||
.weekdays span { color: var(--muted); font-size: 0.75rem; font-weight: 600; text-align: center; }
|
||||
.month-grid { margin-top: 6px; }
|
||||
|
||||
.day {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
aspect-ratio: 1;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
font-size: 0.95rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
.day.outside { visibility: hidden; }
|
||||
.day.future { cursor: default; }
|
||||
.day.fertile { background: var(--fertile-soft); }
|
||||
.day.predicted { box-shadow: inset 0 0 0 2px var(--period); background: var(--period-soft); }
|
||||
.day.period { background: var(--period); color: var(--period-ink); font-weight: 600; }
|
||||
.day.today::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -3px;
|
||||
border: 2px solid var(--ink);
|
||||
border-radius: 999px;
|
||||
}
|
||||
.day .marks { position: absolute; bottom: 12%; display: flex; gap: 3px; }
|
||||
.day .mark { width: 5px; height: 5px; border-radius: 999px; }
|
||||
.mark.ovulation { background: var(--fertile); }
|
||||
.mark.spotting { background: var(--period); }
|
||||
.mark.symptoms { background: var(--muted); }
|
||||
.day.period .mark.symptoms { background: var(--period-ink); }
|
||||
|
||||
.legend { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px 16px; margin: 0; list-style: none; font-size: 0.9rem; color: var(--ink-2); }
|
||||
.legend li { display: flex; align-items: center; gap: 10px; }
|
||||
|
||||
.key { display: inline-block; width: 16px; height: 16px; border-radius: 999px; }
|
||||
.key-period { background: var(--period); }
|
||||
.key-predicted { background: var(--period-soft); box-shadow: inset 0 0 0 2px var(--period); }
|
||||
.key-fertile { background: var(--fertile-soft); }
|
||||
.key-ovulation { position: relative; background: var(--fertile-soft); }
|
||||
.key-ovulation::after { content: ""; position: absolute; inset: 5px; border-radius: 999px; background: var(--fertile); }
|
||||
.key-spotting, .key-symptoms { position: relative; }
|
||||
.key-spotting::after, .key-symptoms::after { content: ""; position: absolute; inset: 5px; border-radius: 999px; }
|
||||
.key-spotting::after { background: var(--period); }
|
||||
.key-symptoms::after { background: var(--muted); }
|
||||
|
||||
/* Stats */
|
||||
|
||||
.tiles { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
|
||||
.tile { background: var(--surface); border-radius: var(--radius); padding: 14px 16px; box-shadow: var(--shadow); }
|
||||
.tile .label { margin: 0; color: var(--ink-2); font-size: 0.85rem; }
|
||||
.tile .value { margin: 2px 0 0; font-size: 1.6rem; font-weight: 650; letter-spacing: -0.01em; font-variant-numeric: tabular-nums; }
|
||||
.tile .value small { font-size: 0.95rem; font-weight: 500; color: var(--ink-2); }
|
||||
|
||||
.chart { position: relative; }
|
||||
.chart svg { display: block; width: 100%; height: auto; overflow: visible; }
|
||||
.chart .grid { stroke: var(--line); stroke-width: 1; }
|
||||
.chart .axis-label { fill: var(--muted); font-size: 11px; }
|
||||
.chart .bar { fill: var(--period); }
|
||||
.chart .bar.dim { opacity: 0.35; }
|
||||
.chart .avg { stroke: var(--ink-2); stroke-width: 1; stroke-dasharray: 3 3; }
|
||||
.chart .avg-label { fill: var(--ink-2); font-size: 11px; font-weight: 600; }
|
||||
.chart .hit { fill: transparent; cursor: pointer; }
|
||||
.chart-empty { margin: 0; color: var(--muted); font-size: 0.92rem; }
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
padding: 6px 10px;
|
||||
border-radius: 10px;
|
||||
background: var(--ink);
|
||||
color: var(--surface);
|
||||
font-size: 0.82rem;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, calc(-100% - 8px));
|
||||
}
|
||||
.tooltip[hidden] { display: none; }
|
||||
|
||||
.table-wrap { margin-top: 12px; overflow-x: auto; }
|
||||
.history { width: 100%; border-collapse: collapse; font-size: 0.92rem; font-variant-numeric: tabular-nums; }
|
||||
.history th { padding: 0 0 8px; color: var(--muted); font-size: 0.8rem; font-weight: 600; text-align: left; }
|
||||
.history td { padding: 9px 0; border-top: 1px solid var(--line); }
|
||||
.history th:not(:first-child), .history td:not(:first-child) { text-align: right; }
|
||||
.history .excluded { color: var(--muted); }
|
||||
.history .excluded small { display: block; font-size: 0.78rem; }
|
||||
|
||||
/* Settings */
|
||||
|
||||
.switch-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; cursor: pointer; }
|
||||
.switch-row span { display: grid; gap: 2px; }
|
||||
.switch-row small { color: var(--muted); font-size: 0.85rem; }
|
||||
.switch-row input { flex: none; width: 24px; height: 24px; accent-color: var(--period); }
|
||||
|
||||
.seed-form { display: grid; gap: 12px; }
|
||||
.seed-form label { display: grid; gap: 4px; color: var(--ink-2); font-size: 0.88rem; font-weight: 600; }
|
||||
.seed-form input {
|
||||
min-height: 44px;
|
||||
padding: 8px 12px;
|
||||
border: 1.5px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
font-weight: 400;
|
||||
}
|
||||
.seed-dates { display: grid; gap: 8px; }
|
||||
.seed-form .seed-length { max-width: 120px; }
|
||||
|
||||
/* Tabs */
|
||||
|
||||
.tabs {
|
||||
position: fixed;
|
||||
inset: auto 0 0;
|
||||
z-index: 5;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
padding: 6px max(8px, env(safe-area-inset-left)) calc(6px + env(safe-area-inset-bottom));
|
||||
border-top: 1px solid var(--line);
|
||||
background: color-mix(in srgb, var(--surface) 92%, transparent);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
.tabs.three { grid-template-columns: repeat(3, 1fr); }
|
||||
.tabs button {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 2px;
|
||||
min-height: 52px;
|
||||
padding: 4px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tabs button[hidden] { display: none; }
|
||||
.tabs svg { width: 24px; height: 24px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.tabs button[aria-selected="true"] { color: var(--period); }
|
||||
|
||||
/* Day sheet */
|
||||
|
||||
.sheet {
|
||||
width: min(560px, 100%);
|
||||
max-width: 100%;
|
||||
max-height: 90vh;
|
||||
margin: auto auto 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 22px 22px 0 0;
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
}
|
||||
.sheet::backdrop { background: rgb(0 0 0 / 35%); }
|
||||
.sheet-inner { padding: 18px 18px calc(22px + env(safe-area-inset-bottom)); }
|
||||
.sheet-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
|
||||
.sheet-head h2 { font-size: 1.15rem; }
|
||||
.sheet .prediction-note { margin: 0 0 16px; color: var(--ink-2); font-size: 0.9rem; }
|
||||
|
||||
@media (min-width: 600px) {
|
||||
.sheet { margin: auto; border-radius: 22px; }
|
||||
.tiles { grid-template-columns: repeat(4, 1fr); }
|
||||
}
|
||||
150
tests/test_app.py
Normal file
150
tests/test_app.py
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import json
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from datetime import date, timedelta
|
||||
from http.client import HTTPConnection
|
||||
from pathlib import Path
|
||||
|
||||
import app
|
||||
|
||||
|
||||
class StoreTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.store = app.Store(Path(self.temp.name) / "flowtrack.sqlite3")
|
||||
self.store.initialize()
|
||||
|
||||
def tearDown(self):
|
||||
self.temp.cleanup()
|
||||
|
||||
def test_empty_log_deletes_the_day(self):
|
||||
self.store.set_day("2026-10-01", {"flow": "heavy", "symptoms": ["cramps"]})
|
||||
self.assertEqual(self.store.days(), {"2026-10-01": {"flow": "heavy", "symptoms": ["cramps"]}})
|
||||
self.store.set_day("2026-10-01", {"flow": None, "symptoms": []})
|
||||
self.assertEqual(self.store.days(), {})
|
||||
|
||||
def test_fill_never_overwrites(self):
|
||||
self.store.set_day("2026-10-01", {"flow": "heavy", "symptoms": []})
|
||||
added = self.store.fill_days({
|
||||
"2026-10-01": {"flow": "period", "symptoms": []},
|
||||
"2026-10-02": {"flow": "period", "symptoms": []},
|
||||
})
|
||||
self.assertEqual(added, 1)
|
||||
self.assertEqual(self.store.days()["2026-10-01"]["flow"], "heavy")
|
||||
|
||||
def test_backup_rotates(self):
|
||||
self.store.set_day("2026-10-01", {"flow": "light", "symptoms": []})
|
||||
destination = Path(self.temp.name) / "backups"
|
||||
destination.mkdir()
|
||||
for stale in ("2026-01-01", "2026-01-02", "2026-01-03"):
|
||||
(destination / f"flowtrack-{stale}.sqlite3").touch()
|
||||
target = self.store.backup(destination, keep=2)
|
||||
self.assertEqual(sorted(path.name for path in destination.iterdir()), ["flowtrack-2026-01-03.sqlite3", target.name])
|
||||
with sqlite3.connect(target) as copy:
|
||||
self.assertEqual(copy.execute("SELECT flow FROM days").fetchone()[0], "light")
|
||||
|
||||
|
||||
class ValidationTests(unittest.TestCase):
|
||||
def test_dates(self):
|
||||
self.assertIsNotNone(app.parse_day(date.today().isoformat()))
|
||||
self.assertIsNotNone(app.parse_day((date.today() + timedelta(days=1)).isoformat()))
|
||||
self.assertIsNone(app.parse_day((date.today() + timedelta(days=2)).isoformat()))
|
||||
self.assertIsNone(app.parse_day("2026-02-30"))
|
||||
self.assertIsNone(app.parse_day("26-10-01"))
|
||||
|
||||
def test_entries(self):
|
||||
self.assertEqual(app.clean_entry({"flow": "light", "symptoms": ["acne", "cramps", "acne"]}), {"flow": "light", "symptoms": ["acne", "cramps"]})
|
||||
self.assertIsNone(app.clean_entry({"flow": "gushing"}))
|
||||
self.assertIsNone(app.clean_entry({"flow": None, "symptoms": ["<script>"]}))
|
||||
self.assertIsNone(app.clean_entry({"flow": None, "symptoms": "cramps"}))
|
||||
|
||||
|
||||
class ServerTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
store = app.Store(Path(self.temp.name) / "flowtrack.sqlite3")
|
||||
store.initialize()
|
||||
self.server = app.TrackerServer(("127.0.0.1", 0), store, {"kayla"}, {"fred"})
|
||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
def tearDown(self):
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.temp.cleanup()
|
||||
|
||||
def request(self, method, path, user=None, body=None):
|
||||
connection = HTTPConnection("127.0.0.1", self.server.server_address[1], timeout=5)
|
||||
headers = {"Remote-User": user} if user else {}
|
||||
payload = None
|
||||
if body is not None:
|
||||
payload = json.dumps(body).encode()
|
||||
headers["Content-Type"] = "application/json"
|
||||
connection.request(method, path, body=payload, headers=headers)
|
||||
response = connection.getresponse()
|
||||
data = response.read()
|
||||
connection.close()
|
||||
try:
|
||||
return response.status, json.loads(data)
|
||||
except json.JSONDecodeError:
|
||||
return response.status, data.decode()
|
||||
|
||||
def test_unknown_users_get_nothing(self):
|
||||
self.assertEqual(self.request("GET", "/api/state")[0], 403)
|
||||
self.assertEqual(self.request("GET", "/api/state", user="mallory")[0], 403)
|
||||
self.assertEqual(self.request("PUT", "/api/days/2026-10-01", user="mallory", body={"flow": "light"})[0], 403)
|
||||
|
||||
def test_editor_round_trip_and_export(self):
|
||||
status, _ = self.request("PUT", "/api/days/2026-10-01", user="kayla", body={"flow": "heavy", "symptoms": ["cramps"]})
|
||||
self.assertEqual(status, 200)
|
||||
status, state = self.request("GET", "/api/state", user="kayla")
|
||||
self.assertEqual(state["role"], "editor")
|
||||
self.assertEqual(state["days"]["2026-10-01"], {"flow": "heavy", "symptoms": ["cramps"]})
|
||||
status, exported = self.request("GET", "/api/export.csv", user="kayla")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIn("2026-10-01,heavy,Cramps", exported)
|
||||
|
||||
def test_viewer_is_read_only_and_details_are_opt_in(self):
|
||||
self.request("PUT", "/api/days/2026-10-01", user="kayla", body={"flow": "heavy", "symptoms": ["cramps"]})
|
||||
self.request("PUT", "/api/days/2026-10-09", user="kayla", body={"flow": "spotting", "symptoms": []})
|
||||
self.request("PUT", "/api/days/2026-10-10", user="kayla", body={"flow": None, "symptoms": ["acne"]})
|
||||
|
||||
status, state = self.request("GET", "/api/state", user="fred")
|
||||
self.assertEqual((status, state["role"]), (200, "viewer"))
|
||||
self.assertEqual(state["days"], {"2026-10-01": {"flow": "period", "symptoms": []}})
|
||||
|
||||
self.assertEqual(self.request("PUT", "/api/days/2026-10-02", user="fred", body={"flow": "light"})[0], 403)
|
||||
self.assertEqual(self.request("PUT", "/api/settings", user="fred", body={"shareDetails": True})[0], 403)
|
||||
self.assertEqual(self.request("POST", "/api/days", user="fred", body={"days": {"2026-10-02": {"flow": "period"}}})[0], 403)
|
||||
self.assertEqual(self.request("GET", "/api/export.json", user="fred")[0], 403)
|
||||
|
||||
self.assertEqual(self.request("PUT", "/api/settings", user="kayla", body={"shareDetails": True})[0], 200)
|
||||
_, state = self.request("GET", "/api/state", user="fred")
|
||||
self.assertEqual(len(state["days"]), 3)
|
||||
self.assertEqual(state["days"]["2026-10-01"]["symptoms"], ["cramps"])
|
||||
|
||||
def test_rejects_bad_writes(self):
|
||||
future = (date.today() + timedelta(days=5)).isoformat()
|
||||
self.assertEqual(self.request("PUT", f"/api/days/{future}", user="kayla", body={"flow": "light"})[0], 400)
|
||||
self.assertEqual(self.request("PUT", "/api/days/2026-10-01", user="kayla", body={"flow": "lots"})[0], 400)
|
||||
self.assertEqual(self.request("POST", "/api/days", user="kayla", body={"days": {"bad": {"flow": "period"}}})[0], 400)
|
||||
|
||||
def test_seed_batch(self):
|
||||
status, result = self.request("POST", "/api/days", user="kayla", body={"days": {
|
||||
"2026-09-01": {"flow": "period", "symptoms": []},
|
||||
"2026-09-02": {"flow": "period", "symptoms": []},
|
||||
}})
|
||||
self.assertEqual((status, result), (200, {"added": 2}))
|
||||
|
||||
def test_static_files(self):
|
||||
status, body = self.request("GET", "/")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIn("Flowtrack", body)
|
||||
self.assertEqual(self.request("GET", "/../app.py")[0], 404)
|
||||
self.assertEqual(self.request("GET", "/manifest.webmanifest")[0], 200)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
124
tests/test_cycle.js
Normal file
124
tests/test_cycle.js
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
const assert = require("node:assert/strict");
|
||||
const cycle = require("../static/cycle.js");
|
||||
|
||||
const { toDay, toIso } = cycle;
|
||||
|
||||
// Logs `length` period days starting at each date.
|
||||
function logPeriods(starts, length = 5, flow = "medium") {
|
||||
const days = {};
|
||||
for (const start of starts) {
|
||||
for (let offset = 0; offset < length; offset += 1) days[toIso(toDay(start) + offset)] = { flow, symptoms: [] };
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
// Date helpers round-trip across month, year and leap-day boundaries.
|
||||
for (const iso of ["2024-02-29", "2025-12-31", "2026-03-29", "2026-10-25"]) assert.equal(toIso(toDay(iso)), iso);
|
||||
assert.equal(toDay("2026-03-01") - toDay("2026-02-28"), 1);
|
||||
|
||||
// Period days join into periods; one unlogged day does not split a period,
|
||||
// spotting never counts, and two missing days do split it.
|
||||
{
|
||||
const days = logPeriods(["2026-01-01"], 3);
|
||||
days["2026-01-05"] = { flow: "light", symptoms: [] };
|
||||
days["2026-01-10"] = { flow: "spotting", symptoms: [] };
|
||||
days["2026-01-13"] = { flow: "period", symptoms: [] };
|
||||
const periods = cycle.periods(days);
|
||||
assert.deepEqual(periods.map(period => [toIso(period.start), period.length]), [["2026-01-01", 5], ["2026-01-13", 1]]);
|
||||
}
|
||||
|
||||
// Nothing logged: no predictions at all.
|
||||
{
|
||||
const summary = cycle.summarize({}, "2026-10-18");
|
||||
assert.equal(summary.current, null);
|
||||
assert.equal(summary.confidence, "none");
|
||||
assert.deepEqual(summary.predictions, []);
|
||||
}
|
||||
|
||||
// One period: falls back to 28 days with low confidence and a wide range.
|
||||
{
|
||||
const summary = cycle.summarize(logPeriods(["2026-10-01"]), "2026-10-10");
|
||||
assert.equal(summary.confidence, "none");
|
||||
assert.equal(summary.cycleLength, 28);
|
||||
assert.equal(toIso(summary.predictions[0].start), "2026-10-29");
|
||||
assert.equal(summary.predictions[0].latest - summary.predictions[0].start, 4);
|
||||
assert.equal(summary.current.cycleDay, 10);
|
||||
}
|
||||
|
||||
// Regular 30-day cycles predict 30 days ahead, with the minimum ±2 range
|
||||
// that widens for later cycles, and ovulation 14 days before each period.
|
||||
{
|
||||
const starts = ["2026-04-01", "2026-05-01", "2026-05-31", "2026-06-30", "2026-07-30", "2026-08-29", "2026-09-28"];
|
||||
const summary = cycle.summarize(logPeriods(starts), "2026-10-10");
|
||||
assert.equal(summary.confidence, "normal");
|
||||
assert.equal(summary.sampleSize, 6);
|
||||
assert.equal(summary.cycleLength, 30);
|
||||
assert.equal(summary.periodLength, 5);
|
||||
const [next, second] = summary.predictions;
|
||||
assert.equal(toIso(next.start), "2026-10-28");
|
||||
assert.equal(toIso(next.end), "2026-11-01");
|
||||
assert.equal(next.start - next.earliest, 2);
|
||||
assert.equal(toIso(second.start), "2026-11-27");
|
||||
assert.equal(second.start - second.earliest, 3);
|
||||
assert.equal(summary.predictions.length, 6);
|
||||
const window = summary.fertile[0];
|
||||
assert.equal(toIso(window.ovulation), "2026-10-14");
|
||||
assert.equal(toIso(window.start), "2026-10-09");
|
||||
assert.equal(toIso(window.end), "2026-10-15");
|
||||
}
|
||||
|
||||
// Recent cycles weigh more: a shift from 26 to 32 days pulls the mean
|
||||
// above the plain average of 29.
|
||||
{
|
||||
const starts = ["2026-01-01", "2026-01-27", "2026-02-22", "2026-03-20", "2026-04-21", "2026-05-23", "2026-06-24"];
|
||||
const summary = cycle.summarize(logPeriods(starts), "2026-07-01");
|
||||
assert.ok(summary.cycleLength >= 30, `expected recent cycles to dominate, got ${summary.cycleLength}`);
|
||||
}
|
||||
|
||||
// A gap from a forgotten period (two cycles' worth) is excluded from the average.
|
||||
{
|
||||
const starts = ["2026-01-01", "2026-01-29", "2026-04-23", "2026-05-21", "2026-06-18"];
|
||||
const summary = cycle.summarize(logPeriods(starts), "2026-06-20");
|
||||
assert.deepEqual(summary.cycles.map(item => item.used), [true, false, true, true]);
|
||||
assert.equal(summary.cycleLength, 28);
|
||||
}
|
||||
|
||||
// Late: once the expected day has passed, the next period is expected from
|
||||
// tomorrow, while this cycle's fertile window stays where it was.
|
||||
{
|
||||
const starts = ["2026-07-01", "2026-07-29", "2026-08-26", "2026-09-23"];
|
||||
const summary = cycle.summarize(logPeriods(starts), "2026-10-24");
|
||||
assert.equal(summary.late, 3);
|
||||
assert.equal(toIso(summary.predictions[0].start), "2026-10-25");
|
||||
assert.equal(toIso(summary.fertile[0].ovulation), "2026-10-07");
|
||||
}
|
||||
|
||||
// Due today is not late yet.
|
||||
{
|
||||
const summary = cycle.summarize(logPeriods(["2026-08-26", "2026-09-23"]), "2026-10-21");
|
||||
assert.equal(summary.late, 0);
|
||||
assert.equal(toIso(summary.predictions[0].start), "2026-10-21");
|
||||
}
|
||||
|
||||
// An ongoing period is flagged and does not count towards period length yet.
|
||||
{
|
||||
const days = logPeriods(["2026-08-01", "2026-08-29"], 6);
|
||||
Object.assign(days, logPeriods(["2026-09-26"], 2));
|
||||
const summary = cycle.summarize(days, "2026-09-27");
|
||||
assert.equal(summary.current.onPeriod, true);
|
||||
assert.equal(summary.periodLength, 6);
|
||||
}
|
||||
|
||||
// Days logged after "today" (a phone ahead of the server) are ignored.
|
||||
{
|
||||
const summary = cycle.summarize(logPeriods(["2026-09-01", "2026-10-19"]), "2026-10-18");
|
||||
assert.equal(toIso(summary.current.start), "2026-09-01");
|
||||
}
|
||||
|
||||
// Very short cycles never place the fertile window inside the period.
|
||||
{
|
||||
const summary = cycle.summarize(logPeriods(["2026-08-01", "2026-08-19", "2026-09-06", "2026-09-24"], 5), "2026-09-30");
|
||||
for (const window of summary.fertile) assert.ok(window.start > toDay("2026-09-28"));
|
||||
}
|
||||
|
||||
console.log("cycle tests passed");
|
||||
Loading…
Add table
Add a link
Reference in a new issue