Flowtrack: private period tracker with cycle predictions

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

150
tests/test_app.py Normal file
View file

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

124
tests/test_cycle.js Normal file
View file

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