Add plan dates, postponing, backlog and projects

- Tasks get an optional plan date separate from the deadline. Unfinished
  plans roll over in Today; only missed deadlines count as overdue.
- Postpone sheet on every task (tomorrow, weekend, next week, ...) that
  can move either the plan date or the deadline, with undo.
- Backlog view for undated tasks, with suggestions in Today.
- Projects with deadline, notes, progress, next step and "falling
  behind" warnings, shown in Today and in their own view.
- Times and the calendar now follow the plan date (else the deadline).
- Schema v3 migration (backup taken first); export format v3 includes
  projects and still imports older files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
goyban
2026-10-07 14:26:32 +02:00
parent 509b1a1320
commit 39842c72d7
8 changed files with 931 additions and 157 deletions
+64 -2
View File
@@ -177,6 +177,68 @@ def test_migration_from_v1(tmp_path, monkeypatch):
db.init()
conn = sqlite3.connect(path)
assert conn.execute("SELECT title, start_time FROM tasks").fetchall() == [("old task", None)]
assert conn.execute("SELECT value FROM meta").fetchone()[0] == "2"
assert conn.execute("SELECT value FROM meta").fetchone()[0] == str(db.SCHEMA_VERSION)
conn.close()
assert (tmp_path / "old.db.before-v2.bak").exists()
assert (tmp_path / f"old.db.before-v{db.SCHEMA_VERSION}.bak").exists()
cols = {r[1] for r in sqlite3.connect(path).execute("PRAGMA table_info(tasks)")}
assert {"start_time", "planned_date", "project_id"} <= cols
def test_planned_date(client):
t = client.post("/api/tasks", headers=H, json={
"title": "Clean garage", "planned_date": "2026-10-12", "start_time": "09:00",
})
assert t.status_code == 201, t.text
t = t.json()
assert (t["planned_date"], t["due_date"]) == ("2026-10-12", None)
# Removing the plan date while a time is set is rejected (time needs a date).
assert client.patch(f"/api/tasks/{t['id']}", headers=H, json={"planned_date": None}).status_code == 422
r = client.patch(f"/api/tasks/{t['id']}", headers=H, json={"planned_date": "2026-10-19"}).json()
assert r["planned_date"] == "2026-10-19"
def test_recurring_with_plan_dates(client):
# No deadline: repeats on plan dates.
gym = client.post("/api/tasks", headers=H, json={
"title": "Gym", "planned_date": "2026-10-05", "recurrence": "daily",
}).json()
nxt = client.post(f"/api/tasks/{gym['id']}/complete", headers=H, json={"today": "2026-10-07"}).json()["next"]
assert (nxt["planned_date"], nxt["due_date"]) == ("2026-10-08", None)
# Deadline + plan date: the plan keeps its distance to the deadline.
rpt = client.post("/api/tasks", headers=H, json={
"title": "Report", "due_date": "2026-10-09", "planned_date": "2026-10-07", "recurrence": "weekly",
}).json()
nxt = client.post(f"/api/tasks/{rpt['id']}/complete", headers=H, json={"today": "2026-10-07"}).json()["next"]
assert (nxt["due_date"], nxt["planned_date"]) == ("2026-10-16", "2026-10-14")
def test_projects(client):
p = client.post("/api/projects", headers=H, json={
"name": "Launch course", "due_date": "2026-12-31", "category_id": 1, "notes": "big one",
})
assert p.status_code == 201, p.text
p = p.json()
assert p["done"] is False and p["due_date"] == "2026-12-31"
t = client.post("/api/tasks", headers=H, json={"title": "Outline", "project_id": p["id"]}).json()
assert t["project_id"] == p["id"]
assert client.post("/api/tasks", headers=H, json={"title": "x", "project_id": 9999}).status_code == 400
assert client.post("/api/projects", headers=H, json={"name": " "}).status_code == 422
done = client.patch(f"/api/projects/{p['id']}", headers=H, json={"done": True}).json()
assert done["done"] is True and done["done_at"]
reopened = client.patch(f"/api/projects/{p['id']}", headers=H, json={"done": False}).json()
assert reopened["done_at"] is None
# Export/import keeps the project link.
data = client.get("/api/export").json()
client.post("/api/import?mode=replace", headers=H, json=data)
projects = client.get("/api/projects").json()
assert [x["name"] for x in projects] == ["Launch course"]
imported = next(x for x in client.get("/api/tasks").json() if x["title"] == "Outline")
assert imported["project_id"] == projects[0]["id"]
# Deleting a project keeps its tasks.
assert client.delete(f"/api/projects/{projects[0]['id']}", headers=H).status_code == 204
kept = next(x for x in client.get("/api/tasks").json() if x["title"] == "Outline")
assert kept["project_id"] is None