Add optional time ranges and a calendar day view
Tasks can now have a start/end time on their deadline date. The new Calendar view shows a day as a timeline (with a week strip and projected repeats of recurring tasks), and the task form lists what is already planned that day and flags overlaps. Existing databases are migrated to schema v2 automatically, with a backup copy taken first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+43
-6
@@ -8,13 +8,14 @@ import time
|
||||
from collections import defaultdict, deque
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from datetime import time as Time
|
||||
from pathlib import Path
|
||||
from typing import Literal, Optional
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
from . import db
|
||||
|
||||
@@ -30,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 = 1
|
||||
EXPORT_VERSION = 2
|
||||
STATIC_DIR = Path(__file__).parent / "static"
|
||||
|
||||
# Changing the password (or secret) invalidates all existing sessions.
|
||||
@@ -186,6 +187,21 @@ def norm_tags(tags: list[str]) -> list[str]:
|
||||
return out[:20]
|
||||
|
||||
|
||||
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"
|
||||
if end and not start:
|
||||
return "An end time needs a start time"
|
||||
if start and end and end <= start:
|
||||
return "End time must be after start time"
|
||||
return None
|
||||
|
||||
|
||||
def clean_title(v: Optional[str]) -> Optional[str]:
|
||||
if v is None:
|
||||
return v
|
||||
@@ -212,6 +228,8 @@ class TaskIn(BaseModel):
|
||||
category_id: Optional[int] = None
|
||||
priority: int = Field(0, ge=0, le=3)
|
||||
due_date: Optional[date] = None
|
||||
start_time: Optional[Time] = None
|
||||
end_time: Optional[Time] = None
|
||||
tags: list[str] = []
|
||||
recurrence: Recurrence = "none"
|
||||
recur_interval: int = Field(1, ge=1, le=365)
|
||||
@@ -219,6 +237,13 @@ class TaskIn(BaseModel):
|
||||
|
||||
_title = field_validator("title")(clean_title)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _times(self):
|
||||
error = check_times(self.due_date, fmt_time(self.start_time), fmt_time(self.end_time))
|
||||
if error:
|
||||
raise ValueError(error)
|
||||
return self
|
||||
|
||||
|
||||
class TaskPatch(BaseModel):
|
||||
title: Optional[str] = Field(None, max_length=500)
|
||||
@@ -226,6 +251,8 @@ class TaskPatch(BaseModel):
|
||||
category_id: Optional[int] = None
|
||||
priority: Optional[int] = Field(None, ge=0, le=3)
|
||||
due_date: Optional[date] = None
|
||||
start_time: Optional[Time] = None
|
||||
end_time: Optional[Time] = None
|
||||
tags: Optional[list[str]] = None
|
||||
recurrence: Optional[Recurrence] = None
|
||||
recur_interval: Optional[int] = Field(None, ge=1, le=365)
|
||||
@@ -281,6 +308,8 @@ def row_to_task(r: sqlite3.Row, subtasks: list[dict]) -> dict:
|
||||
"category_id": r["category_id"],
|
||||
"priority": r["priority"],
|
||||
"due_date": r["due_date"],
|
||||
"start_time": r["start_time"],
|
||||
"end_time": r["end_time"],
|
||||
"tags": json.loads(r["tags"]),
|
||||
"recurrence": r["recurrence"],
|
||||
"recur_interval": r["recur_interval"],
|
||||
@@ -330,12 +359,13 @@ def insert_task(conn: sqlite3.Connection, t: dict, done: bool = False, done_at:
|
||||
now = now_iso()
|
||||
due = t.get("due_date")
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO tasks (title, notes, category_id, priority, due_date, tags, recurrence,
|
||||
recur_interval, done, done_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
"""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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
t["title"], t.get("notes", ""), t.get("category_id"), t.get("priority", 0),
|
||||
due.isoformat() if isinstance(due, date) else due,
|
||||
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,
|
||||
),
|
||||
@@ -413,7 +443,7 @@ def create_task(body: TaskIn, conn=Depends(get_conn)):
|
||||
|
||||
@app.patch("/api/tasks/{task_id}")
|
||||
def update_task(task_id: int, body: TaskPatch, conn=Depends(get_conn)):
|
||||
get_task(conn, task_id)
|
||||
current = get_task(conn, task_id)
|
||||
fields = body.model_dump(exclude_unset=True)
|
||||
subtasks = fields.pop("subtasks", None)
|
||||
for key in ("title", "notes", "priority", "tags", "recurrence", "recur_interval"):
|
||||
@@ -425,6 +455,13 @@ def update_task(task_id: int, body: TaskPatch, conn=Depends(get_conn)):
|
||||
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 ("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"])
|
||||
if error:
|
||||
raise HTTPException(422, error)
|
||||
fields["updated_at"] = now_iso()
|
||||
with conn:
|
||||
conn.execute(
|
||||
|
||||
Reference in New Issue
Block a user