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:
+160
-18
@@ -31,7 +31,7 @@ if not APP_PASSWORD or not SECRET_KEY or "change-me" in (APP_PASSWORD, SECRET_KE
|
||||
|
||||
COOKIE_NAME = "whattodo_session"
|
||||
CSRF_HEADER = "x-whattodo"
|
||||
EXPORT_VERSION = 2
|
||||
EXPORT_VERSION = 3
|
||||
STATIC_DIR = Path(__file__).parent / "static"
|
||||
|
||||
# Changing the password (or secret) invalidates all existing sessions.
|
||||
@@ -191,10 +191,13 @@ def fmt_time(t) -> Optional[str]:
|
||||
return t.strftime("%H:%M") if isinstance(t, Time) else t
|
||||
|
||||
|
||||
def check_times(due, start: Optional[str], end: Optional[str]) -> Optional[str]:
|
||||
"""Return an error message if the time fields don't make sense together."""
|
||||
if (start or end) and not due:
|
||||
return "A time needs a deadline date"
|
||||
def check_times(day, start: Optional[str], end: Optional[str]) -> Optional[str]:
|
||||
"""Return an error message if the time fields don't make sense together.
|
||||
|
||||
`day` is the date the time applies to: the planned date if set, else the deadline.
|
||||
"""
|
||||
if (start or end) and not day:
|
||||
return "A time needs a date (planned or deadline)"
|
||||
if end and not start:
|
||||
return "An end time needs a start time"
|
||||
if start and end and end <= start:
|
||||
@@ -226,8 +229,10 @@ class TaskIn(BaseModel):
|
||||
title: str = Field(max_length=500)
|
||||
notes: str = Field("", max_length=20000)
|
||||
category_id: Optional[int] = None
|
||||
project_id: Optional[int] = None
|
||||
priority: int = Field(0, ge=0, le=3)
|
||||
due_date: Optional[date] = None
|
||||
planned_date: Optional[date] = None
|
||||
start_time: Optional[Time] = None
|
||||
end_time: Optional[Time] = None
|
||||
tags: list[str] = []
|
||||
@@ -239,7 +244,7 @@ class TaskIn(BaseModel):
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _times(self):
|
||||
error = check_times(self.due_date, fmt_time(self.start_time), fmt_time(self.end_time))
|
||||
error = check_times(self.planned_date or self.due_date, fmt_time(self.start_time), fmt_time(self.end_time))
|
||||
if error:
|
||||
raise ValueError(error)
|
||||
return self
|
||||
@@ -249,8 +254,10 @@ class TaskPatch(BaseModel):
|
||||
title: Optional[str] = Field(None, max_length=500)
|
||||
notes: Optional[str] = Field(None, max_length=20000)
|
||||
category_id: Optional[int] = None
|
||||
project_id: Optional[int] = None
|
||||
priority: Optional[int] = Field(None, ge=0, le=3)
|
||||
due_date: Optional[date] = None
|
||||
planned_date: Optional[date] = None
|
||||
start_time: Optional[Time] = None
|
||||
end_time: Optional[Time] = None
|
||||
tags: Optional[list[str]] = None
|
||||
@@ -277,6 +284,26 @@ class CategoryPatch(BaseModel):
|
||||
position: Optional[int] = None
|
||||
|
||||
|
||||
class ProjectIn(BaseModel):
|
||||
name: str = Field(max_length=100)
|
||||
notes: str = Field("", max_length=20000)
|
||||
category_id: Optional[int] = None
|
||||
due_date: Optional[date] = None
|
||||
done: bool = False
|
||||
|
||||
_name = field_validator("name")(clean_title)
|
||||
|
||||
|
||||
class ProjectPatch(BaseModel):
|
||||
name: Optional[str] = Field(None, max_length=100)
|
||||
notes: Optional[str] = Field(None, max_length=20000)
|
||||
category_id: Optional[int] = None
|
||||
due_date: Optional[date] = None
|
||||
done: Optional[bool] = None
|
||||
|
||||
_name = field_validator("name")(clean_title)
|
||||
|
||||
|
||||
class ExportCategory(BaseModel):
|
||||
id: int
|
||||
name: str = Field(min_length=1, max_length=50)
|
||||
@@ -291,10 +318,18 @@ class ExportTask(TaskIn):
|
||||
updated_at: Optional[str] = None
|
||||
|
||||
|
||||
class ExportProject(ProjectIn):
|
||||
id: int
|
||||
done_at: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
updated_at: Optional[str] = None
|
||||
|
||||
|
||||
class ExportData(BaseModel):
|
||||
app: str
|
||||
version: int
|
||||
categories: list[ExportCategory] = []
|
||||
projects: list[ExportProject] = []
|
||||
tasks: list[ExportTask] = []
|
||||
|
||||
|
||||
@@ -306,8 +341,10 @@ def row_to_task(r: sqlite3.Row, subtasks: list[dict]) -> dict:
|
||||
"title": r["title"],
|
||||
"notes": r["notes"],
|
||||
"category_id": r["category_id"],
|
||||
"project_id": r["project_id"],
|
||||
"priority": r["priority"],
|
||||
"due_date": r["due_date"],
|
||||
"planned_date": r["planned_date"],
|
||||
"start_time": r["start_time"],
|
||||
"end_time": r["end_time"],
|
||||
"tags": json.loads(r["tags"]),
|
||||
@@ -345,6 +382,17 @@ def ensure_category(conn: sqlite3.Connection, category_id: Optional[int]) -> Non
|
||||
raise HTTPException(400, "Unknown category")
|
||||
|
||||
|
||||
def ensure_project(conn: sqlite3.Connection, project_id: Optional[int]) -> None:
|
||||
if project_id is not None and not conn.execute(
|
||||
"SELECT 1 FROM projects WHERE id = ?", (project_id,)
|
||||
).fetchone():
|
||||
raise HTTPException(400, "Unknown project")
|
||||
|
||||
|
||||
def iso(d) -> Optional[str]:
|
||||
return d.isoformat() if isinstance(d, date) else d
|
||||
|
||||
|
||||
def write_subtasks(conn: sqlite3.Connection, task_id: int, subtasks: list[dict]) -> None:
|
||||
conn.execute("DELETE FROM subtasks WHERE task_id = ?", (task_id,))
|
||||
for i, s in enumerate(subtasks):
|
||||
@@ -357,14 +405,14 @@ def write_subtasks(conn: sqlite3.Connection, task_id: int, subtasks: list[dict])
|
||||
def insert_task(conn: sqlite3.Connection, t: dict, done: bool = False, done_at: Optional[str] = None,
|
||||
created_at: Optional[str] = None, updated_at: Optional[str] = None) -> int:
|
||||
now = now_iso()
|
||||
due = t.get("due_date")
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO tasks (title, notes, category_id, priority, due_date, start_time, end_time,
|
||||
tags, recurrence, recur_interval, done, done_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
"""INSERT INTO tasks (title, notes, category_id, project_id, priority, due_date, planned_date,
|
||||
start_time, end_time, tags, recurrence, recur_interval, done, done_at,
|
||||
created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
t["title"], t.get("notes", ""), t.get("category_id"), t.get("priority", 0),
|
||||
due.isoformat() if isinstance(due, date) else due,
|
||||
t["title"], t.get("notes", ""), t.get("category_id"), t.get("project_id"), t.get("priority", 0),
|
||||
iso(t.get("due_date")), iso(t.get("planned_date")),
|
||||
fmt_time(t.get("start_time")), fmt_time(t.get("end_time")),
|
||||
json.dumps(norm_tags(t.get("tags", []))), t.get("recurrence", "none"),
|
||||
t.get("recur_interval", 1), int(done), done_at, created_at or now, updated_at or now,
|
||||
@@ -436,6 +484,7 @@ def get_tasks(conn=Depends(get_conn)):
|
||||
@app.post("/api/tasks", status_code=201)
|
||||
def create_task(body: TaskIn, conn=Depends(get_conn)):
|
||||
ensure_category(conn, body.category_id)
|
||||
ensure_project(conn, body.project_id)
|
||||
with conn:
|
||||
task_id = insert_task(conn, body.model_dump())
|
||||
return get_task(conn, task_id)
|
||||
@@ -451,15 +500,18 @@ def update_task(task_id: int, body: TaskPatch, conn=Depends(get_conn)):
|
||||
del fields[key] # these columns can't be cleared, only changed
|
||||
if "category_id" in fields:
|
||||
ensure_category(conn, fields["category_id"])
|
||||
if "project_id" in fields:
|
||||
ensure_project(conn, fields["project_id"])
|
||||
if "tags" in fields:
|
||||
fields["tags"] = json.dumps(norm_tags(fields["tags"]))
|
||||
if fields.get("due_date") is not None:
|
||||
fields["due_date"] = fields["due_date"].isoformat()
|
||||
for key in ("due_date", "planned_date"):
|
||||
if key in fields:
|
||||
fields[key] = iso(fields[key])
|
||||
for key in ("start_time", "end_time"):
|
||||
if key in fields:
|
||||
fields[key] = fmt_time(fields[key])
|
||||
merged = {k: fields.get(k, current[k]) for k in ("due_date", "start_time", "end_time")}
|
||||
error = check_times(merged["due_date"], merged["start_time"], merged["end_time"])
|
||||
merged = {k: fields.get(k, current[k]) for k in ("due_date", "planned_date", "start_time", "end_time")}
|
||||
error = check_times(merged["planned_date"] or merged["due_date"], merged["start_time"], merged["end_time"])
|
||||
if error:
|
||||
raise HTTPException(422, error)
|
||||
fields["updated_at"] = now_iso()
|
||||
@@ -493,8 +545,15 @@ def complete_task(task_id: int, body: CompleteIn, conn=Depends(get_conn)):
|
||||
conn.execute("UPDATE tasks SET done = 1, done_at = ?, updated_at = ? WHERE id = ?", (now, now, task_id))
|
||||
if task["recurrence"] != "none":
|
||||
due = date.fromisoformat(task["due_date"]) if task["due_date"] else None
|
||||
planned = date.fromisoformat(task["planned_date"]) if task["planned_date"] else None
|
||||
nxt = dict(task)
|
||||
nxt["due_date"] = next_due(due, task["recurrence"], task["recur_interval"], today)
|
||||
if due:
|
||||
# The deadline drives the series; a plan date keeps its distance to the deadline.
|
||||
nxt["due_date"] = next_due(due, task["recurrence"], task["recur_interval"], today)
|
||||
nxt["planned_date"] = planned + (nxt["due_date"] - due) if planned else None
|
||||
else:
|
||||
# No deadline: repeat on plan dates, so the task never shows up as overdue.
|
||||
nxt["planned_date"] = next_due(planned, task["recurrence"], task["recur_interval"], today)
|
||||
nxt["subtasks"] = [{"title": s["title"], "done": False} for s in task["subtasks"]]
|
||||
next_id = insert_task(conn, nxt)
|
||||
return {"task": get_task(conn, task_id), "next": get_task(conn, next_id) if next_id else None}
|
||||
@@ -510,6 +569,80 @@ def reopen_task(task_id: int, conn=Depends(get_conn)):
|
||||
return get_task(conn, task_id)
|
||||
|
||||
|
||||
# --- Project endpoints -------------------------------------------------------
|
||||
|
||||
def get_project(conn: sqlite3.Connection, project_id: int) -> dict:
|
||||
row = conn.execute("SELECT * FROM projects WHERE id = ?", (project_id,)).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "Project not found")
|
||||
return {**dict(row), "done": bool(row["done"])}
|
||||
|
||||
|
||||
def list_projects(conn: sqlite3.Connection) -> list[dict]:
|
||||
return [{**dict(r), "done": bool(r["done"])} for r in conn.execute("SELECT * FROM projects ORDER BY id")]
|
||||
|
||||
|
||||
def insert_project(conn: sqlite3.Connection, p: dict) -> int:
|
||||
now = now_iso()
|
||||
done = bool(p.get("done"))
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO projects (name, notes, category_id, due_date, done, done_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
p["name"], p.get("notes", ""), p.get("category_id"), iso(p.get("due_date")), int(done),
|
||||
p.get("done_at") or (now if done else None), p.get("created_at") or now, p.get("updated_at") or now,
|
||||
),
|
||||
)
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
@app.get("/api/projects")
|
||||
def get_projects(conn=Depends(get_conn)):
|
||||
return list_projects(conn)
|
||||
|
||||
|
||||
@app.post("/api/projects", status_code=201)
|
||||
def create_project(body: ProjectIn, conn=Depends(get_conn)):
|
||||
ensure_category(conn, body.category_id)
|
||||
with conn:
|
||||
project_id = insert_project(conn, body.model_dump())
|
||||
return get_project(conn, project_id)
|
||||
|
||||
|
||||
@app.patch("/api/projects/{project_id}")
|
||||
def update_project(project_id: int, body: ProjectPatch, conn=Depends(get_conn)):
|
||||
current = get_project(conn, project_id)
|
||||
fields = body.model_dump(exclude_unset=True)
|
||||
for key in ("name", "notes", "done"):
|
||||
if key in fields and fields[key] is None:
|
||||
del fields[key]
|
||||
if "category_id" in fields:
|
||||
ensure_category(conn, fields["category_id"])
|
||||
if "due_date" in fields:
|
||||
fields["due_date"] = iso(fields["due_date"])
|
||||
if "done" in fields:
|
||||
if fields["done"] and not current["done"]:
|
||||
fields["done_at"] = now_iso()
|
||||
elif not fields["done"]:
|
||||
fields["done_at"] = None
|
||||
fields["done"] = int(fields["done"])
|
||||
fields["updated_at"] = now_iso()
|
||||
with conn:
|
||||
conn.execute(
|
||||
f"UPDATE projects SET {', '.join(f'{k} = ?' for k in fields)} WHERE id = ?",
|
||||
(*fields.values(), project_id),
|
||||
)
|
||||
return get_project(conn, project_id)
|
||||
|
||||
|
||||
@app.delete("/api/projects/{project_id}", status_code=204)
|
||||
def delete_project(project_id: int, conn=Depends(get_conn)):
|
||||
"""Deletes the project only; its tasks are kept and simply no longer belong to a project."""
|
||||
with conn:
|
||||
conn.execute("DELETE FROM projects WHERE id = ?", (project_id,))
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
# --- Export / import ---------------------------------------------------------
|
||||
|
||||
@app.get("/api/export")
|
||||
@@ -520,6 +653,7 @@ def export_data(conn=Depends(get_conn)):
|
||||
"version": EXPORT_VERSION,
|
||||
"exported_at": now_iso(),
|
||||
"categories": list_categories(conn),
|
||||
"projects": list_projects(conn),
|
||||
"tasks": load_tasks(conn),
|
||||
}
|
||||
return Response(
|
||||
@@ -538,6 +672,7 @@ def import_data(data: ExportData, mode: Literal["replace", "merge"] = "merge", c
|
||||
if mode == "replace":
|
||||
conn.execute("DELETE FROM subtasks")
|
||||
conn.execute("DELETE FROM tasks")
|
||||
conn.execute("DELETE FROM projects")
|
||||
conn.execute("DELETE FROM categories")
|
||||
existing = {r["name"].lower(): r["id"] for r in conn.execute("SELECT id, name FROM categories")}
|
||||
next_pos = conn.execute("SELECT COALESCE(MAX(position), -1) + 1 FROM categories").fetchone()[0]
|
||||
@@ -552,12 +687,19 @@ def import_data(data: ExportData, mode: Literal["replace", "merge"] = "merge", c
|
||||
existing[key] = cur.lastrowid
|
||||
next_pos += 1
|
||||
cat_map[c.id] = existing[key]
|
||||
project_map: dict[int, int] = {}
|
||||
for p in data.projects:
|
||||
fields = p.model_dump()
|
||||
fields["category_id"] = cat_map.get(p.category_id) if p.category_id is not None else None
|
||||
project_map[p.id] = insert_project(conn, fields)
|
||||
for t in data.tasks:
|
||||
fields = t.model_dump()
|
||||
fields["category_id"] = cat_map.get(t.category_id) if t.category_id is not None else None
|
||||
fields["project_id"] = project_map.get(t.project_id) if t.project_id is not None else None
|
||||
insert_task(conn, fields, done=t.done, done_at=t.done_at,
|
||||
created_at=t.created_at, updated_at=t.updated_at)
|
||||
return {"ok": True, "mode": mode, "categories": len(data.categories), "tasks": len(data.tasks)}
|
||||
return {"ok": True, "mode": mode, "categories": len(data.categories), "projects": len(data.projects),
|
||||
"tasks": len(data.tasks)}
|
||||
|
||||
|
||||
# Static frontend (must be mounted last so it doesn't shadow /api routes).
|
||||
|
||||
Reference in New Issue
Block a user