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:
goyban
2026-10-07 12:08:09 +02:00
parent c5b049edba
commit 509b1a1320
8 changed files with 447 additions and 19 deletions
+43 -6
View File
@@ -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(