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:
+64
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user