Compare commits

..

2 Commits

Author SHA1 Message Date
goyban 0060cd11bb Run from a published image; move source build to docker-compose.build.yaml
docker-compose.yaml now pulls git.goyban.com/goyban/whattodo so it can be used
without the source. publish.sh builds the committed version and pushes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:58:25 +02:00
goyban 39842c72d7 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>
2026-10-07 14:26:32 +02:00
13 changed files with 1012 additions and 168 deletions
+3
View File
@@ -18,3 +18,6 @@ BIND_ADDR=127.0.0.1
# Behind a reverse proxy, the IPs allowed to set X-Forwarded-For (for login rate limiting). # Behind a reverse proxy, the IPs allowed to set X-Forwarded-For (for login rate limiting).
# FORWARDED_ALLOW_IPS=127.0.0.1 # FORWARDED_ALLOW_IPS=127.0.0.1
# Development only: build from source instead of pulling the published image.
# COMPOSE_FILE=docker-compose.build.yaml
+1 -1
View File
@@ -10,7 +10,7 @@ COPY requirements.txt .
RUN pip install -r requirements.txt RUN pip install -r requirements.txt
COPY app ./app COPY app ./app
# `docker compose run --rm test` # `docker compose -f docker-compose.build.yaml run --rm test`
FROM base AS test FROM base AS test
COPY requirements-dev.txt . COPY requirements-dev.txt .
RUN pip install -r requirements-dev.txt RUN pip install -r requirements-dev.txt
+39 -13
View File
@@ -2,38 +2,64 @@
A small, self-hosted to-do app for one person. It works in a desktop browser and on a phone, and you can install it to your home screen. A small, self-hosted to-do app for one person. It works in a desktop browser and on a phone, and you can install it to your home screen.
- Tasks have an optional **deadline** with an optional **time range** (e.g. 10:00–12:00), **priority** (none / low / medium / high), **category**, **tags**, **notes**, and a **checklist** of steps. - Tasks have an optional **plan date** (when you'll do it), an optional **deadline** (when it must be done), and an optional **time range** (e.g. 10:00–12:00). Each task also has a **priority**, **category**, **tags**, **notes**, and a **checklist** of steps.
- **Postpone** any task from the ⏭ button: today, tomorrow, the weekend, next week, in 2 weeks, next month, or a date you pick. You can move either the plan date or the deadline.
- Unfinished planned tasks **roll over**: they stay in Today until you do them or postpone them, and they never turn red. Only missed **deadlines** count as overdue.
- **Backlog** lists tasks with no date at all. Today suggests a couple of them so they don't get forgotten.
- **Projects** group the tasks of a bigger goal and have their own deadline, notes and progress bar. Today shows each active project's **next step**, how much time is left, and a warning if it's falling behind or nothing was done on it in over a week.
- **Recurring tasks** (every N days/weeks/months/years): completing one creates the next occurrence. - **Recurring tasks** (every N days/weeks/months/years): completing one creates the next occurrence.
- When you pick a date and time, the task form lists what else is planned that day and warns about overlaps. - When you pick a date and time, the task form lists what else is planned that day and warns about overlaps.
- Views: **Today** (overdue, due today, high priority), **Upcoming**, **Calendar** (day timeline with week strip; tap an hour to add a task there), **All open**, **Done**, plus one view per category. - Views: **Today**, **Upcoming**, **Calendar** (day timeline with week strip; tap an hour to add a task there), **Backlog**, **All open**, **Done**, plus one view per project and per category.
- You can rename, recolor, reorder, add, or delete categories in **Settings**. It starts with Work, Life, Personal, YouTube. - You can rename, recolor, reorder, add, or delete categories in **Settings**. It starts with Work, Life, Personal, YouTube.
- **Export / import** of all data as JSON, from **Settings**. - **Export / import** of all data as JSON, from **Settings**.
- Login with a single password. - Login with a single password.
Stack: Python (FastAPI) + SQLite with a plain HTML/CSS/JS frontend and no build step. Everything runs in Docker. Stack: Python (FastAPI) + SQLite with a plain HTML/CSS/JS frontend and no build step. Everything runs in Docker.
## Run locally ## Run
Requirements: Docker with the Compose plugin. Nothing else is installed on the host. Requirements: Docker with the Compose plugin. You don't need the source code: only `docker-compose.yaml` and a `.env`.
```sh ```sh
mkdir whattodo && cd whattodo
# copy docker-compose.yaml and .env.example from this repo into this folder
cp .env.example .env # then edit .env: set APP_PASSWORD and SECRET_KEY cp .env.example .env # then edit .env: set APP_PASSWORD and SECRET_KEY
docker compose up -d --build docker compose up -d
``` ```
Open <http://localhost:8080> and log in with `APP_PASSWORD`. Open <http://localhost:8080> and log in with `APP_PASSWORD`.
The database lives in `./data/whattodo.db`, which is git-ignored. The database lives in `./data/whattodo.db`.
Useful commands: Useful commands:
```sh ```sh
docker compose logs -f # follow logs docker compose logs -f # follow logs
docker compose down # stop docker compose down # stop
docker compose up -d --build # rebuild after pulling changes docker compose pull && docker compose up -d # update to the newest published version
```
To pin a version, set `WHATTODO_TAG=<commit>` in `.env` (default: `latest`).
## Develop (build from source)
`docker-compose.build.yaml` builds the image from this checkout. Add this line to your `.env` so plain `docker compose` uses it:
```sh
COMPOSE_FILE=docker-compose.build.yaml
```
```sh
docker compose up -d --build # build and start
docker compose run --rm test # run the test suite (in Docker) docker compose run --rm test # run the test suite (in Docker)
``` ```
### Publishing the image
`./publish.sh` builds the last commit (uncommitted changes are left out) and pushes it to
`git.goyban.com/goyban/whattodo` as `:latest` and `:<commit>`. `./publish.sh <tag|branch|commit>` publishes another version.
Log in once first with `docker login git.goyban.com`, using a Gitea token with package write access as the password.
### Testing on your phone (same Wi-Fi) ### Testing on your phone (same Wi-Fi)
Set `BIND_ADDR=0.0.0.0` in `.env`, run `docker compose up -d`, then open `http://<this-computer's-LAN-IP>:8080` on the phone. Set `BIND_ADDR=0.0.0.0` in `.env`, run `docker compose up -d`, then open `http://<this-computer's-LAN-IP>:8080` on the phone.
@@ -41,8 +67,8 @@ Browsers only allow "Install app" / "Add to Home Screen" with offline support ov
## Deploy to production ## Deploy to production
1. On the server, clone the repo and create `.env` from `.env.example`. Use a **new strong** `APP_PASSWORD` and `SECRET_KEY`, and set `COOKIE_SECURE=true`. 1. On the server, put `docker-compose.yaml` in a folder and create `.env` from `.env.example`. Use a **new strong** `APP_PASSWORD` and `SECRET_KEY`, and set `COOKIE_SECURE=true`.
2. `docker compose up -d --build`. The app listens on `127.0.0.1:${PORT}`. 2. `docker compose up -d`. The app listens on `127.0.0.1:${PORT}`.
3. Put it behind a reverse proxy with HTTPS. For example, with Caddy: 3. Put it behind a reverse proxy with HTTPS. For example, with Caddy:
``` ```
@@ -55,7 +81,7 @@ Browsers only allow "Install app" / "Add to Home Screen" with offline support ov
### Moving your data from this PC to production ### Moving your data from this PC to production
1. Local app → **Settings → Export data**. This downloads `whattodo-export-YYYYMMDD-HHMM.json`. 1. Local app → **Settings → Export data**. The export includes tasks, projects and categories. This downloads `whattodo-export-YYYYMMDD-HHMM.json`.
2. Production app → **Settings → Import**, pick the file: 2. Production app → **Settings → Import**, pick the file:
- **Replace** deletes everything on that install and loads the file. Use this for the first move to prod. - **Replace** deletes everything on that install and loads the file. Use this for the first move to prod.
- **Merge** adds the file's tasks to what is already there. Categories with the same name are reused. - **Merge** adds the file's tasks to what is already there. Categories with the same name are reused.
@@ -79,6 +105,6 @@ Export regularly from Settings, or copy `./data/whattodo.db` while the app is st
app/main.py API: auth, tasks, categories, export/import app/main.py API: auth, tasks, categories, export/import
app/db.py SQLite schema and setup app/db.py SQLite schema and setup
app/static/ frontend (index.html, app.js, style.css, PWA files, icons) app/static/ frontend (index.html, app.js, style.css, PWA files, icons)
tests/ API tests (run with: docker compose run --rm test) tests/ API tests (run with: docker compose -f docker-compose.build.yaml run --rm test)
tools/make_icons.py regenerates the PNG icons tools/make_icons.py regenerates the PNG icons
``` ```
+20 -1
View File
@@ -3,7 +3,7 @@ import sqlite3
from pathlib import Path from pathlib import Path
DB_PATH = os.environ.get("DB_PATH", "/data/whattodo.db") DB_PATH = os.environ.get("DB_PATH", "/data/whattodo.db")
SCHEMA_VERSION = 2 SCHEMA_VERSION = 3
DEFAULT_CATEGORIES = [ DEFAULT_CATEGORIES = [
("Work", "#2563eb"), ("Work", "#2563eb"),
@@ -23,13 +23,26 @@ CREATE TABLE IF NOT EXISTS categories (
color TEXT NOT NULL DEFAULT '#6b7280', color TEXT NOT NULL DEFAULT '#6b7280',
position INTEGER NOT NULL DEFAULT 0 position INTEGER NOT NULL DEFAULT 0
); );
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
notes TEXT NOT NULL DEFAULT '',
category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
due_date TEXT,
done INTEGER NOT NULL DEFAULT 0,
done_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS tasks ( CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
title TEXT NOT NULL, title TEXT NOT NULL,
notes TEXT NOT NULL DEFAULT '', notes TEXT NOT NULL DEFAULT '',
category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL, category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL,
priority INTEGER NOT NULL DEFAULT 0, priority INTEGER NOT NULL DEFAULT 0,
due_date TEXT, due_date TEXT,
planned_date TEXT,
start_time TEXT, start_time TEXT,
end_time TEXT, end_time TEXT,
tags TEXT NOT NULL DEFAULT '[]', tags TEXT NOT NULL DEFAULT '[]',
@@ -96,6 +109,12 @@ def migrate(conn: sqlite3.Connection, version: int) -> None:
if version < 2: if version < 2:
conn.execute("ALTER TABLE tasks ADD COLUMN start_time TEXT") conn.execute("ALTER TABLE tasks ADD COLUMN start_time TEXT")
conn.execute("ALTER TABLE tasks ADD COLUMN end_time TEXT") conn.execute("ALTER TABLE tasks ADD COLUMN end_time TEXT")
if version < 3:
# The projects table itself is created by SCHEMA before migrating.
conn.execute("ALTER TABLE tasks ADD COLUMN planned_date TEXT")
conn.execute(
"ALTER TABLE tasks ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL"
)
conn.execute( conn.execute(
"UPDATE meta SET value = ? WHERE key = 'schema_version'", (str(SCHEMA_VERSION),) "UPDATE meta SET value = ? WHERE key = 'schema_version'", (str(SCHEMA_VERSION),)
) )
+160 -18
View File
@@ -31,7 +31,7 @@ if not APP_PASSWORD or not SECRET_KEY or "change-me" in (APP_PASSWORD, SECRET_KE
COOKIE_NAME = "whattodo_session" COOKIE_NAME = "whattodo_session"
CSRF_HEADER = "x-whattodo" CSRF_HEADER = "x-whattodo"
EXPORT_VERSION = 2 EXPORT_VERSION = 3
STATIC_DIR = Path(__file__).parent / "static" STATIC_DIR = Path(__file__).parent / "static"
# Changing the password (or secret) invalidates all existing sessions. # 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 return t.strftime("%H:%M") if isinstance(t, Time) else t
def check_times(due, start: Optional[str], end: Optional[str]) -> Optional[str]: 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.""" """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" `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: if end and not start:
return "An end time needs a start time" return "An end time needs a start time"
if start and end and end <= start: if start and end and end <= start:
@@ -226,8 +229,10 @@ class TaskIn(BaseModel):
title: str = Field(max_length=500) title: str = Field(max_length=500)
notes: str = Field("", max_length=20000) notes: str = Field("", max_length=20000)
category_id: Optional[int] = None category_id: Optional[int] = None
project_id: Optional[int] = None
priority: int = Field(0, ge=0, le=3) priority: int = Field(0, ge=0, le=3)
due_date: Optional[date] = None due_date: Optional[date] = None
planned_date: Optional[date] = None
start_time: Optional[Time] = None start_time: Optional[Time] = None
end_time: Optional[Time] = None end_time: Optional[Time] = None
tags: list[str] = [] tags: list[str] = []
@@ -239,7 +244,7 @@ class TaskIn(BaseModel):
@model_validator(mode="after") @model_validator(mode="after")
def _times(self): 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: if error:
raise ValueError(error) raise ValueError(error)
return self return self
@@ -249,8 +254,10 @@ class TaskPatch(BaseModel):
title: Optional[str] = Field(None, max_length=500) title: Optional[str] = Field(None, max_length=500)
notes: Optional[str] = Field(None, max_length=20000) notes: Optional[str] = Field(None, max_length=20000)
category_id: Optional[int] = None category_id: Optional[int] = None
project_id: Optional[int] = None
priority: Optional[int] = Field(None, ge=0, le=3) priority: Optional[int] = Field(None, ge=0, le=3)
due_date: Optional[date] = None due_date: Optional[date] = None
planned_date: Optional[date] = None
start_time: Optional[Time] = None start_time: Optional[Time] = None
end_time: Optional[Time] = None end_time: Optional[Time] = None
tags: Optional[list[str]] = None tags: Optional[list[str]] = None
@@ -277,6 +284,26 @@ class CategoryPatch(BaseModel):
position: Optional[int] = None 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): class ExportCategory(BaseModel):
id: int id: int
name: str = Field(min_length=1, max_length=50) name: str = Field(min_length=1, max_length=50)
@@ -291,10 +318,18 @@ class ExportTask(TaskIn):
updated_at: Optional[str] = None 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): class ExportData(BaseModel):
app: str app: str
version: int version: int
categories: list[ExportCategory] = [] categories: list[ExportCategory] = []
projects: list[ExportProject] = []
tasks: list[ExportTask] = [] tasks: list[ExportTask] = []
@@ -306,8 +341,10 @@ def row_to_task(r: sqlite3.Row, subtasks: list[dict]) -> dict:
"title": r["title"], "title": r["title"],
"notes": r["notes"], "notes": r["notes"],
"category_id": r["category_id"], "category_id": r["category_id"],
"project_id": r["project_id"],
"priority": r["priority"], "priority": r["priority"],
"due_date": r["due_date"], "due_date": r["due_date"],
"planned_date": r["planned_date"],
"start_time": r["start_time"], "start_time": r["start_time"],
"end_time": r["end_time"], "end_time": r["end_time"],
"tags": json.loads(r["tags"]), "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") 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: def write_subtasks(conn: sqlite3.Connection, task_id: int, subtasks: list[dict]) -> None:
conn.execute("DELETE FROM subtasks WHERE task_id = ?", (task_id,)) conn.execute("DELETE FROM subtasks WHERE task_id = ?", (task_id,))
for i, s in enumerate(subtasks): 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, 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: created_at: Optional[str] = None, updated_at: Optional[str] = None) -> int:
now = now_iso() now = now_iso()
due = t.get("due_date")
cur = conn.execute( cur = conn.execute(
"""INSERT INTO tasks (title, notes, category_id, priority, due_date, start_time, end_time, """INSERT INTO tasks (title, notes, category_id, project_id, priority, due_date, planned_date,
tags, recurrence, recur_interval, done, done_at, created_at, updated_at) start_time, end_time, tags, recurrence, recur_interval, done, done_at,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
( (
t["title"], t.get("notes", ""), t.get("category_id"), t.get("priority", 0), t["title"], t.get("notes", ""), t.get("category_id"), t.get("project_id"), t.get("priority", 0),
due.isoformat() if isinstance(due, date) else due, iso(t.get("due_date")), iso(t.get("planned_date")),
fmt_time(t.get("start_time")), fmt_time(t.get("end_time")), fmt_time(t.get("start_time")), fmt_time(t.get("end_time")),
json.dumps(norm_tags(t.get("tags", []))), t.get("recurrence", "none"), 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, 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) @app.post("/api/tasks", status_code=201)
def create_task(body: TaskIn, conn=Depends(get_conn)): def create_task(body: TaskIn, conn=Depends(get_conn)):
ensure_category(conn, body.category_id) ensure_category(conn, body.category_id)
ensure_project(conn, body.project_id)
with conn: with conn:
task_id = insert_task(conn, body.model_dump()) task_id = insert_task(conn, body.model_dump())
return get_task(conn, task_id) 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 del fields[key] # these columns can't be cleared, only changed
if "category_id" in fields: if "category_id" in fields:
ensure_category(conn, fields["category_id"]) ensure_category(conn, fields["category_id"])
if "project_id" in fields:
ensure_project(conn, fields["project_id"])
if "tags" in fields: if "tags" in fields:
fields["tags"] = json.dumps(norm_tags(fields["tags"])) fields["tags"] = json.dumps(norm_tags(fields["tags"]))
if fields.get("due_date") is not None: for key in ("due_date", "planned_date"):
fields["due_date"] = fields["due_date"].isoformat() if key in fields:
fields[key] = iso(fields[key])
for key in ("start_time", "end_time"): for key in ("start_time", "end_time"):
if key in fields: if key in fields:
fields[key] = fmt_time(fields[key]) fields[key] = fmt_time(fields[key])
merged = {k: fields.get(k, current[k]) for k in ("due_date", "start_time", "end_time")} merged = {k: fields.get(k, current[k]) for k in ("due_date", "planned_date", "start_time", "end_time")}
error = check_times(merged["due_date"], merged["start_time"], merged["end_time"]) error = check_times(merged["planned_date"] or merged["due_date"], merged["start_time"], merged["end_time"])
if error: if error:
raise HTTPException(422, error) raise HTTPException(422, error)
fields["updated_at"] = now_iso() 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)) conn.execute("UPDATE tasks SET done = 1, done_at = ?, updated_at = ? WHERE id = ?", (now, now, task_id))
if task["recurrence"] != "none": if task["recurrence"] != "none":
due = date.fromisoformat(task["due_date"]) if task["due_date"] else 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 = 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"]] nxt["subtasks"] = [{"title": s["title"], "done": False} for s in task["subtasks"]]
next_id = insert_task(conn, nxt) next_id = insert_task(conn, nxt)
return {"task": get_task(conn, task_id), "next": get_task(conn, next_id) if next_id else None} 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) 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 --------------------------------------------------------- # --- Export / import ---------------------------------------------------------
@app.get("/api/export") @app.get("/api/export")
@@ -520,6 +653,7 @@ def export_data(conn=Depends(get_conn)):
"version": EXPORT_VERSION, "version": EXPORT_VERSION,
"exported_at": now_iso(), "exported_at": now_iso(),
"categories": list_categories(conn), "categories": list_categories(conn),
"projects": list_projects(conn),
"tasks": load_tasks(conn), "tasks": load_tasks(conn),
} }
return Response( return Response(
@@ -538,6 +672,7 @@ def import_data(data: ExportData, mode: Literal["replace", "merge"] = "merge", c
if mode == "replace": if mode == "replace":
conn.execute("DELETE FROM subtasks") conn.execute("DELETE FROM subtasks")
conn.execute("DELETE FROM tasks") conn.execute("DELETE FROM tasks")
conn.execute("DELETE FROM projects")
conn.execute("DELETE FROM categories") conn.execute("DELETE FROM categories")
existing = {r["name"].lower(): r["id"] for r in conn.execute("SELECT id, name 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] 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 existing[key] = cur.lastrowid
next_pos += 1 next_pos += 1
cat_map[c.id] = existing[key] 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: for t in data.tasks:
fields = t.model_dump() fields = t.model_dump()
fields["category_id"] = cat_map.get(t.category_id) if t.category_id is not None else None 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, insert_task(conn, fields, done=t.done, done_at=t.done_at,
created_at=t.created_at, updated_at=t.updated_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). # Static frontend (must be mounted last so it doesn't shadow /api routes).
+503 -104
View File
@@ -4,15 +4,23 @@ const VIEWS = [
{ id: 'today', label: 'Today', icon: '☀️' }, { id: 'today', label: 'Today', icon: '☀️' },
{ id: 'upcoming', label: 'Upcoming', icon: '📅' }, { id: 'upcoming', label: 'Upcoming', icon: '📅' },
{ id: 'calendar', label: 'Calendar', icon: '🗓️' }, { id: 'calendar', label: 'Calendar', icon: '🗓️' },
{ id: 'backlog', label: 'Backlog', icon: '📥' },
{ id: 'all', label: 'All open', icon: '📋' }, { id: 'all', label: 'All open', icon: '📋' },
{ id: 'done', label: 'Done', icon: '✅' }, { id: 'done', label: 'Done', icon: '✅' },
]; ];
const PRIORITY_LABELS = ['', 'Low', 'Medium', 'High']; const PRIORITY_LABELS = ['', 'Low', 'Medium', 'High'];
const REPEAT_UNITS = { daily: 'day', weekly: 'week', monthly: 'month', yearly: 'year' }; const REPEAT_UNITS = { daily: 'day', weekly: 'week', monthly: 'month', yearly: 'year' };
const HOUR_PX = 48; const HOUR_PX = 48;
const BACKLOG_SUGGESTIONS = 2;
const state = { tasks: [], categories: [], view: 'today', categoryId: null, search: '', calDate: null }; const state = {
tasks: [], categories: [], projects: [],
view: 'today', categoryId: null, projectId: null,
search: '', calDate: null, showDoneProjects: false,
};
let editing = null; // task open in the dialog; null when creating let editing = null; // task open in the dialog; null when creating
let editingProject = null;
let planning = null; // { task, mode } for the postpone sheet
let draftSubtasks = []; let draftSubtasks = [];
let toastTimer = null; let toastTimer = null;
@@ -48,7 +56,6 @@ const dayDiff = (a, b) => {
const [by, bm, bd] = b.split('-').map(Number); const [by, bm, bd] = b.split('-').map(Number);
return Math.round((Date.UTC(by, bm - 1, bd) - Date.UTC(ay, am - 1, ad)) / 86400000); return Math.round((Date.UTC(by, bm - 1, bd) - Date.UTC(ay, am - 1, ad)) / 86400000);
}; };
const addMonths = (s, n) => { const addMonths = (s, n) => {
const [y, m, d] = s.split('-').map(Number); const [y, m, d] = s.split('-').map(Number);
const total = m - 1 + n; const total = m - 1 + n;
@@ -64,6 +71,41 @@ function stepDate(s, recurrence, n) {
return addMonths(s, 12 * n); return addMonths(s, 12 * n);
} }
// Named dates used by the postpone sheet and the "Plan:" shortcuts.
function namedDate(name) {
const t = todayStr();
const dow = parseDate(t).getDay(); // 0 = Sunday
const nextMonday = addDays(t, ((8 - dow) % 7) || 7);
switch (name) {
case 'today': return t;
case 'tomorrow': return addDays(t, 1);
case 'weekend': return dow === 6 || dow === 0 ? addDays(nextMonday, 5) : addDays(t, 6 - dow);
case 'nextweek': return nextMonday;
case 'twoweeks': return addDays(nextMonday, 7);
case 'nextmonth': { const d = parseDate(t); return fmtDate(new Date(d.getFullYear(), d.getMonth() + 1, 1)); }
default: return null;
}
}
function dayName(day) {
const diff = dayDiff(todayStr(), day);
const d = parseDate(day);
if (diff === 0) return 'Today';
if (diff === 1) return 'Tomorrow';
if (diff === -1) return 'Yesterday';
if (diff > 1 && diff < 7) return d.toLocaleDateString(undefined, { weekday: 'long' });
const opts = { weekday: 'short', day: 'numeric', month: 'short' };
if (d.getFullYear() !== new Date().getFullYear()) opts.year = 'numeric';
return d.toLocaleDateString(undefined, opts);
}
function dueInfo(due) {
const diff = dayDiff(todayStr(), due);
let text = dayName(due);
if (diff < -1) text += ` · ${-diff} days late`;
return { text, cls: diff < 0 ? 'overdue' : diff === 0 ? 'today' : '' };
}
const toMin = (t) => { const [hh, mm] = t.split(':').map(Number); return hh * 60 + mm; }; const toMin = (t) => { const [hh, mm] = t.split(':').map(Number); return hh * 60 + mm; };
const fromMin = (m) => `${pad(Math.floor(m / 60))}:${pad(m % 60)}`; const fromMin = (m) => `${pad(Math.floor(m / 60))}:${pad(m % 60)}`;
// A start time without an end counts as one hour. // A start time without an end counts as one hour.
@@ -73,15 +115,27 @@ const span = (t) => {
}; };
const timeText = (t) => (t.start_time ? (t.end_time ? `${t.start_time}–${t.end_time}` : t.start_time) : ''); const timeText = (t) => (t.start_time ? (t.end_time ? `${t.start_time}–${t.end_time}` : t.start_time) : '');
// Tasks on a given day, including future repeats of open recurring tasks (marked projected). // --- Task date semantics ------------------------------------------------------
// planned_date = when I intend to do it; due_date = when it must be done.
// The "when" date (plan, else deadline) decides where a task is scheduled; times apply to it.
const whenDate = (t) => t.planned_date || t.due_date || null;
const isOverdue = (t, today) => !t.done && t.due_date && t.due_date < today;
// Planned for today or earlier (unfinished plans roll over), or due today.
const isForToday = (t, today) => !t.done && !isOverdue(t, today)
&& ((t.planned_date && t.planned_date <= today) || t.due_date === today);
const isBacklog = (t) => !t.done && !t.planned_date && !t.due_date;
// Tasks on a given day (by "when" date), including future repeats of open recurring tasks.
function itemsOn(day) { function itemsOn(day) {
const out = []; const out = [];
for (const t of state.tasks) { for (const t of state.tasks) {
if (!t.due_date) continue; const w = whenDate(t);
if (t.due_date === day) { if (!w) continue;
if (w === day) {
out.push({ task: t, projected: false }); out.push({ task: t, projected: false });
} else if (!t.done && t.recurrence !== 'none' && t.due_date < day) { } else if (!t.done && t.recurrence !== 'none' && w < day) {
let d = t.due_date; let d = w;
for (let i = 0; d < day && i < 2000; i++) d = stepDate(d, t.recurrence, t.recur_interval); for (let i = 0; d < day && i < 2000; i++) d = stepDate(d, t.recurrence, t.recur_interval);
if (d === day) out.push({ task: t, projected: true }); if (d === day) out.push({ task: t, projected: true });
} }
@@ -89,23 +143,6 @@ function itemsOn(day) {
return out; return out;
} }
function dueInfo(due) {
const diff = dayDiff(todayStr(), due);
const d = parseDate(due);
let text;
if (diff === 0) text = 'Today';
else if (diff === 1) text = 'Tomorrow';
else if (diff === -1) text = 'Yesterday';
else if (diff > 1 && diff < 7) text = d.toLocaleDateString(undefined, { weekday: 'long' });
else {
const opts = { weekday: 'short', day: 'numeric', month: 'short' };
if (d.getFullYear() !== new Date().getFullYear()) opts.year = 'numeric';
text = d.toLocaleDateString(undefined, opts);
}
if (diff < -1) text += ` · ${-diff} days late`;
return { text, cls: diff < 0 ? 'overdue' : diff === 0 ? 'today' : '' };
}
// --- API ---------------------------------------------------------------------- // --- API ----------------------------------------------------------------------
async function api(path, { method = 'GET', body } = {}) { async function api(path, { method = 'GET', body } = {}) {
@@ -138,8 +175,11 @@ async function api(path, { method = 'GET', body } = {}) {
} }
async function loadAll() { async function loadAll() {
const [categories, tasks] = await Promise.all([api('api/categories'), api('api/tasks')]); const [categories, projects, tasks] = await Promise.all([
api('api/categories'), api('api/projects'), api('api/tasks'),
]);
state.categories = categories; state.categories = categories;
state.projects = projects;
state.tasks = tasks; state.tasks = tasks;
} }
@@ -159,34 +199,48 @@ async function act(fn) {
// --- Persisted UI prefs ------------------------------------------------------- // --- Persisted UI prefs -------------------------------------------------------
function savePrefs() { function savePrefs() {
try { localStorage.setItem('whattodo.view', JSON.stringify({ view: state.view, categoryId: state.categoryId })); } catch { /* ignore */ } try {
localStorage.setItem('whattodo.view', JSON.stringify({
view: state.view, categoryId: state.categoryId, projectId: state.projectId,
}));
} catch { /* ignore */ }
} }
function loadPrefs() { function loadPrefs() {
try { try {
const p = JSON.parse(localStorage.getItem('whattodo.view') || 'null'); const p = JSON.parse(localStorage.getItem('whattodo.view') || 'null');
if (p && typeof p.view === 'string') { state.view = p.view; state.categoryId = p.categoryId ?? null; } if (p && typeof p.view === 'string') {
state.view = p.view;
state.categoryId = p.categoryId ?? null;
state.projectId = p.projectId ?? null;
}
} catch { /* ignore */ } } catch { /* ignore */ }
} }
// --- Filtering & grouping ----------------------------------------------------- // --- Lookups, filtering & grouping ---------------------------------------------
const catById = (id) => state.categories.find((c) => c.id === id); const catById = (id) => state.categories.find((c) => c.id === id);
const projectById = (id) => state.projects.find((p) => p.id === id);
const projectTasks = (p) => state.tasks.filter((t) => t.project_id === p.id);
function matchesSearch(t) { function matchesSearch(t) {
const terms = state.search.toLowerCase().split(/\s+/).filter(Boolean); const terms = state.search.toLowerCase().split(/\s+/).filter(Boolean);
return terms.every((term) => { return terms.every((term) => {
if (term.startsWith('#') && term.length > 1) return t.tags.some((tag) => tag.startsWith(term.slice(1))); if (term.startsWith('#') && term.length > 1) return t.tags.some((tag) => tag.startsWith(term.slice(1)));
const cat = catById(t.category_id); const cat = catById(t.category_id);
const hay = [t.title, t.notes, cat ? cat.name : '', ...t.tags, ...t.subtasks.map((s) => s.title)].join(' ').toLowerCase(); const proj = projectById(t.project_id);
const hay = [t.title, t.notes, cat ? cat.name : '', proj ? proj.name : '', ...t.tags,
...t.subtasks.map((s) => s.title)].join(' ').toLowerCase();
return hay.includes(term); return hay.includes(term);
}); });
} }
function cmpOpen(a, b) { function cmpOpen(a, b) {
if (a.due_date !== b.due_date) { const wa = whenDate(a);
if (!a.due_date) return 1; const wb = whenDate(b);
if (!b.due_date) return -1; if (wa !== wb) {
return a.due_date < b.due_date ? -1 : 1; if (!wa) return 1;
if (!wb) return -1;
return wa < wb ? -1 : 1;
} }
if (a.start_time !== b.start_time) { if (a.start_time !== b.start_time) {
if (!a.start_time) return 1; if (!a.start_time) return 1;
@@ -199,10 +253,18 @@ function cmpOpen(a, b) {
function viewTasks() { function viewTasks() {
let tasks = state.tasks; let tasks = state.tasks;
if (state.view === 'cat') tasks = tasks.filter((t) => (t.category_id || 0) === state.categoryId); if (state.view === 'cat') tasks = tasks.filter((t) => (t.category_id || 0) === state.categoryId);
if (state.view === 'project') tasks = tasks.filter((t) => t.project_id === state.projectId);
if (state.search.trim()) tasks = tasks.filter(matchesSearch); if (state.search.trim()) tasks = tasks.filter(matchesSearch);
return tasks; return tasks;
} }
// Backlog tasks worth suggesting for today: highest priority first, then the oldest.
function backlogSuggestions() {
return state.tasks.filter((t) => isBacklog(t) && t.priority < 3)
.sort((a, b) => (b.priority - a.priority) || (a.id - b.id))
.slice(0, BACKLOG_SUGGESTIONS);
}
function buildGroups(tasks) { function buildGroups(tasks) {
const t = todayStr(); const t = todayStr();
const tomorrow = addDays(t, 1); const tomorrow = addDays(t, 1);
@@ -212,75 +274,179 @@ function buildGroups(tasks) {
return [{ title: 'Completed', tasks: done }]; return [{ title: 'Completed', tasks: done }];
} }
const open = tasks.filter((x) => !x.done).sort(cmpOpen); const open = tasks.filter((x) => !x.done).sort(cmpOpen);
const group = (title, pred) => ({ title, tasks: open.filter(pred) }); const group = (title, pred, extra = {}) => ({ title, tasks: open.filter(pred), ...extra });
const overdue = (x) => x.due_date && x.due_date < t; const overdue = (x) => isOverdue(x, t);
const today = (x) => x.due_date === t; const today = (x) => isForToday(x, t);
const later = (x) => !overdue(x) && !today(x);
if (state.view === 'today') { if (state.view === 'today') {
const shown = new Set(open.filter((x) => overdue(x) || today(x)).map((x) => x.id));
return [ return [
group('Overdue', overdue), group('Overdue', overdue),
group('Today', today), group('Today', today),
group('High priority', (x) => x.priority === 3 && (!x.due_date || x.due_date > t)), // High priority things you haven't scheduled yet keep showing up until you plan them.
group('High priority, not planned', (x) => x.priority === 3 && !shown.has(x.id) && !x.planned_date),
{ title: 'From your backlog', tasks: state.search ? [] : backlogSuggestions(), hint: 'No date yet. Plan one for today?' },
]; ];
} }
if (state.view === 'upcoming') { if (state.view === 'upcoming') {
return [ return [
group('Tomorrow', (x) => x.due_date === tomorrow), group('Tomorrow', (x) => later(x) && whenDate(x) === tomorrow),
group('Next 7 days', (x) => x.due_date && x.due_date > tomorrow && x.due_date <= week), group('Next 7 days', (x) => later(x) && whenDate(x) > tomorrow && whenDate(x) <= week),
group('Later', (x) => x.due_date && x.due_date > week), group('Later', (x) => later(x) && whenDate(x) > week),
]; ];
} }
return [ if (state.view === 'backlog') {
const backlog = (p) => (x) => isBacklog(x) && x.priority === p;
return [
group('High priority', backlog(3)),
group('Medium priority', backlog(2)),
group('Low priority', backlog(1)),
group('No priority', backlog(0)),
];
}
const groups = [
group('Overdue', overdue), group('Overdue', overdue),
group('Today', today), group('Today', today),
group('Next 7 days', (x) => x.due_date && x.due_date > t && x.due_date <= week), group('Next 7 days', (x) => later(x) && whenDate(x) && whenDate(x) <= week),
group('Later', (x) => x.due_date && x.due_date > week), group('Later', (x) => later(x) && whenDate(x) > week),
group('No deadline', (x) => !x.due_date), group('No date', isBacklog),
]; ];
if (state.view === 'project') {
const done = tasks.filter((x) => x.done).sort((a, b) => (b.done_at || '').localeCompare(a.done_at || ''));
groups.push({ title: 'Completed', tasks: done });
}
return groups;
} }
function counts() { function counts() {
const t = todayStr(); const t = todayStr();
const open = state.tasks.filter((x) => !x.done); const open = state.tasks.filter((x) => !x.done);
const overdue = open.filter((x) => isOverdue(x, t)).length;
const c = { const c = {
today: open.filter((x) => (x.due_date && x.due_date <= t) || x.priority === 3).length, today: open.filter((x) => isOverdue(x, t) || isForToday(x, t) || (x.priority === 3 && !x.planned_date)).length,
upcoming: open.filter((x) => x.due_date && x.due_date > t).length, upcoming: open.filter((x) => !isOverdue(x, t) && !isForToday(x, t) && whenDate(x)).length,
backlog: open.filter(isBacklog).length,
all: open.length, all: open.length,
done: null, done: null,
overdue: open.filter((x) => x.due_date && x.due_date < t).length, calendar: null,
overdue,
cats: {}, cats: {},
projects: {},
}; };
for (const x of open) c.cats[x.category_id || 0] = (c.cats[x.category_id || 0] || 0) + 1; for (const x of open) {
c.cats[x.category_id || 0] = (c.cats[x.category_id || 0] || 0) + 1;
if (x.project_id) c.projects[x.project_id] = (c.projects[x.project_id] || 0) + 1;
}
return c; return c;
} }
// Progress and warnings for a project.
function projectHealth(p) {
const t = todayStr();
const tasks = projectTasks(p);
const total = tasks.length;
const done = tasks.filter((x) => x.done).length;
const open = tasks.filter((x) => !x.done).sort(cmpOpen);
const info = { total, done, pct: total ? Math.round((done / total) * 100) : 0, next: open[0] || null, warn: null, daysLeft: null };
if (p.done) return info;
const started = p.created_at.slice(0, 10);
if (p.due_date) {
info.daysLeft = dayDiff(t, p.due_date);
const totalDays = dayDiff(started, p.due_date);
const timeFrac = totalDays > 0 ? Math.min(1, dayDiff(started, t) / totalDays) : 1;
if (info.daysLeft < 0) info.warn = 'Deadline passed';
else if (total && timeFrac - done / total > 0.25) {
info.warn = `Behind: ${Math.round(timeFrac * 100)}% of the time used, ${info.pct}% done`;
}
}
if (!info.warn && !total) info.warn = 'No tasks yet. Break it into steps.';
if (!info.warn && open.length && dayDiff(started, t) >= 7) {
const lastDone = tasks.map((x) => x.done_at || '').sort().pop();
if (!lastDone || dayDiff(lastDone.slice(0, 10), t) >= 7) info.warn = 'Nothing done on this in over a week';
}
return info;
}
function daysLeftText(n) {
if (n == null) return '';
if (n < 0) return `${-n} days overdue`;
if (n === 0) return 'due today';
if (n === 1) return '1 day left';
if (n < 60) return `${n} days left`;
return `${Math.round(n / 7)} weeks left`;
}
function sortedProjects() {
return [...state.projects].sort((a, b) => {
if (a.due_date !== b.due_date) {
if (!a.due_date) return 1;
if (!b.due_date) return -1;
return a.due_date < b.due_date ? -1 : 1;
}
return a.id - b.id;
});
}
// --- Rendering ---------------------------------------------------------------- // --- Rendering ----------------------------------------------------------------
function render() { function navItem({ active, icon, label, count, onclick, color, alert, muted }) {
if (state.view === 'cat' && state.categoryId && !catById(state.categoryId)) state.view = 'today'; return h('button', {
if (state.view !== 'cat' && !VIEWS.some((v) => v.id === state.view)) state.view = 'today'; type: 'button', class: 'nav-item' + (active ? ' active' : '') + (muted ? ' muted' : ''), onclick,
const c = counts();
const navItem = (active, icon, label, count, onclick, color, alert) => h('button', {
type: 'button', class: 'nav-item' + (active ? ' active' : ''), onclick,
}, },
color ? h('span', { class: 'dot', style: { background: color } }) : h('span', { class: 'nav-icon' }, icon), color ? h('span', { class: 'dot', style: { background: color } }) : h('span', { class: 'nav-icon' }, icon),
h('span', { class: 'nav-label' }, label), h('span', { class: 'nav-label' }, label),
count ? h('span', { class: 'count' + (alert ? ' alert' : '') }, count) : null); count ? h('span', { class: 'count' + (alert ? ' alert' : '') }, count) : null);
}
$('#views').replaceChildren(...VIEWS.map((v) => navItem( function renderNav(c) {
state.view === v.id, v.icon, v.label, c[v.id], () => setView(v.id), null, v.id === 'today' && c.overdue > 0, $('#views').replaceChildren(...VIEWS.map((v) => navItem({
))); active: state.view === v.id, icon: v.icon, label: v.label, count: c[v.id],
onclick: () => setView(v.id), alert: v.id === 'today' && c.overdue > 0,
})));
const catItems = state.categories.map((cat) => navItem( const projects = sortedProjects();
state.view === 'cat' && state.categoryId === cat.id, null, cat.name, c.cats[cat.id], () => setView('cat', cat.id), cat.color, const active = projects.filter((p) => !p.done);
)); const finished = projects.filter((p) => p.done);
const projItem = (p) => navItem({
active: state.view === 'project' && state.projectId === p.id, icon: p.done ? '✔' : '📁', label: p.name,
count: c.projects[p.id], onclick: () => setView('project', { projectId: p.id }), muted: p.done,
alert: !p.done && p.due_date && p.due_date < todayStr(),
});
const items = active.map(projItem);
if (finished.length) {
items.push(h('button', {
type: 'button', class: 'nav-toggle',
onclick: () => { state.showDoneProjects = !state.showDoneProjects; render(); },
}, state.showDoneProjects ? 'Hide finished' : `Show finished (${finished.length})`));
if (state.showDoneProjects) items.push(...finished.map(projItem));
}
if (!projects.length) items.push(h('div', { class: 'nav-empty' }, 'No projects yet'));
$('#projects').replaceChildren(...items);
const catItems = state.categories.map((cat) => navItem({
active: state.view === 'cat' && state.categoryId === cat.id, label: cat.name, count: c.cats[cat.id],
onclick: () => setView('cat', { categoryId: cat.id }), color: cat.color,
}));
if (c.cats[0]) { if (c.cats[0]) {
catItems.push(navItem(state.view === 'cat' && state.categoryId === 0, null, 'No category', c.cats[0], catItems.push(navItem({
() => setView('cat', 0), 'var(--muted)')); active: state.view === 'cat' && state.categoryId === 0, label: 'No category', count: c.cats[0],
onclick: () => setView('cat', { categoryId: 0 }), color: 'var(--muted)',
}));
} }
$('#cats').replaceChildren(...catItems); $('#cats').replaceChildren(...catItems);
}
function render() {
if (state.view === 'cat' && state.categoryId && !catById(state.categoryId)) state.view = 'today';
if (state.view === 'project' && !projectById(state.projectId)) state.view = 'today';
if (!['cat', 'project'].includes(state.view) && !VIEWS.some((v) => v.id === state.view)) state.view = 'today';
renderNav(counts());
let title = (VIEWS.find((v) => v.id === state.view) || {}).label; let title = (VIEWS.find((v) => v.id === state.view) || {}).label;
if (state.view === 'cat') title = state.categoryId ? (catById(state.categoryId) || {}).name : 'No category'; if (state.view === 'cat') title = state.categoryId ? catById(state.categoryId).name : 'No category';
if (state.view === 'project') title = projectById(state.projectId).name;
$('#view-title').textContent = title || ''; $('#view-title').textContent = title || '';
document.title = `${title} · WhatToDo`; document.title = `${title} · WhatToDo`;
$('#quick-add').hidden = state.view === 'done'; $('#quick-add').hidden = state.view === 'done';
@@ -291,29 +457,102 @@ function render() {
return; return;
} }
const groups = buildGroups(viewTasks()).filter((g) => g.tasks.length); const sections = [];
if (!groups.length) { if (state.view === 'project') sections.push(projectHeader(projectById(state.projectId)));
const groups = buildGroups(viewTasks());
if (state.view === 'today' && !state.search) {
const cards = sortedProjects().filter((p) => !p.done).map(projectCard);
// Projects go right after the things that are due/planned today.
const at = groups.findIndex((g) => g.title === 'High priority, not planned');
if (cards.length) groups.splice(at, 0, { title: 'Projects', nodes: cards });
}
for (const g of groups) {
const items = g.nodes || g.tasks.map(taskRow);
if (!items.length) continue;
sections.push(h('section', { class: 'group' },
h('h3', { class: 'group-title' + (g.title === 'Overdue' ? ' overdue' : '') },
g.title, g.tasks ? h('span', { class: 'muted' }, ` ${g.tasks.length}`) : null),
g.hint ? h('p', { class: 'group-hint' }, g.hint) : null,
items));
}
if (sections.length === (state.view === 'project' ? 1 : 0)) {
const msg = state.search ? 'No tasks match your search.' const msg = state.search ? 'No tasks match your search.'
: state.view === 'done' ? 'Nothing completed yet.' : state.view === 'done' ? 'Nothing completed yet.'
: state.view === 'today' ? 'Nothing due today. 🎉' : 'No tasks here.'; : state.view === 'today' ? 'Nothing planned for today. 🎉'
$('#list').replaceChildren(h('div', { class: 'empty' }, msg)); : state.view === 'backlog' ? 'Backlog is empty: everything has a date.'
return; : state.view === 'project' ? 'No tasks in this project yet. Add the first step above.'
: 'No tasks here.';
sections.push(h('div', { class: 'empty' }, msg));
} }
$('#list').replaceChildren(...groups.map((g) => h('section', { class: 'group' }, $('#list').replaceChildren(...sections);
h('h3', { class: 'group-title' + (g.title === 'Overdue' ? ' overdue' : '') }, g.title, h('span', { class: 'muted' }, ` ${g.tasks.length}`)), }
g.tasks.map(taskRow))));
function progressBar(pct) {
return h('div', { class: 'progress', role: 'progressbar', 'aria-valuenow': String(pct), 'aria-valuemin': '0', 'aria-valuemax': '100' },
h('div', { class: 'progress-fill', style: { width: `${pct}%` } }));
}
function projectCard(p) {
const info = projectHealth(p);
const cat = catById(p.category_id);
return h('div', {
class: 'project-card', tabindex: '0',
onclick: () => setView('project', { projectId: p.id }),
onkeydown: (e) => { if (e.key === 'Enter') setView('project', { projectId: p.id }); },
style: { borderLeftColor: cat ? cat.color : 'var(--accent)' },
},
h('div', { class: 'project-card-head' },
h('span', { class: 'project-name' }, '📁 ', p.name),
h('span', { class: 'muted small' }, [
p.due_date ? `${dayName(p.due_date)} · ${daysLeftText(info.daysLeft)}` : 'no deadline',
])),
h('div', { class: 'project-progress' }, progressBar(info.pct), h('span', { class: 'small muted' }, `${info.done}/${info.total}`)),
info.next ? h('div', { class: 'project-next' }, h('span', { class: 'muted' }, 'Next: '), info.next.title,
whenDate(info.next) ? h('span', { class: 'muted' }, ` · ${dayName(whenDate(info.next))}`) : null) : null,
info.warn ? h('div', { class: 'project-warn' }, '⚠ ', info.warn) : null);
}
function projectHeader(p) {
const info = projectHealth(p);
const cat = catById(p.category_id);
return h('section', { class: 'project-header' },
h('div', { class: 'project-header-top' },
h('div', { class: 'task-meta' },
cat ? h('span', { class: 'chip' }, h('span', { class: 'dot', style: { background: cat.color } }), cat.name) : null,
p.due_date ? h('span', { class: `chip due ${info.daysLeft < 0 ? 'overdue' : ''}` }, '🏁 ', dayName(p.due_date), ` · ${daysLeftText(info.daysLeft)}`) : null,
p.done ? h('span', { class: 'chip' }, '✔ Finished') : null),
h('button', { type: 'button', class: 'btn', onclick: () => openProject(p) }, 'Edit project')),
h('div', { class: 'project-progress big' }, progressBar(info.pct),
h('span', { class: 'small' }, `${info.done}/${info.total} done (${info.pct}%)`)),
info.warn ? h('div', { class: 'project-warn' }, '⚠ ', info.warn) : null,
p.notes.trim() ? h('p', { class: 'project-notes' }, p.notes) : null);
} }
function taskRow(task) { function taskRow(task) {
const t = todayStr();
const meta = []; const meta = [];
const cat = catById(task.category_id); const cat = catById(task.category_id);
if (cat && state.view !== 'cat') { if (cat && state.view !== 'cat') {
meta.push(h('span', { class: 'chip' }, h('span', { class: 'dot', style: { background: cat.color } }), cat.name)); meta.push(h('span', { class: 'chip' }, h('span', { class: 'dot', style: { background: cat.color } }), cat.name));
} }
const proj = projectById(task.project_id);
if (proj && state.view !== 'project') {
meta.push(h('button', {
type: 'button', class: 'chip link-chip', title: 'Open project',
onclick: (e) => { e.stopPropagation(); setView('project', { projectId: proj.id }); },
}, '📁 ', proj.name));
}
const time = timeText(task);
if (task.planned_date) {
const diff = dayDiff(t, task.planned_date);
const label = diff < 0 && !task.done ? `from ${dayName(task.planned_date)}` : dayName(task.planned_date);
meta.push(h('span', { class: 'chip plan' + (diff <= 0 && !task.done ? ' now' : ''), title: 'Planned to do on' },
'▶ ', label, time ? ` · ${time}` : ''));
}
if (task.due_date) { if (task.due_date) {
const d = dueInfo(task.due_date); const d = dueInfo(task.due_date);
const time = timeText(task); meta.push(h('span', { class: `chip due ${task.done ? '' : d.cls}`, title: 'Deadline' },
meta.push(h('span', { class: `chip due ${task.done ? '' : d.cls}` }, '📅 ', d.text, time ? ` · ${time}` : '')); '📅 ', task.planned_date ? 'due ' : '', d.text, !task.planned_date && time ? ` · ${time}` : ''));
} }
if (task.priority) meta.push(h('span', { class: `chip prio-label p${task.priority}` }, '⚑ ', PRIORITY_LABELS[task.priority])); if (task.priority) meta.push(h('span', { class: `chip prio-label p${task.priority}` }, '⚑ ', PRIORITY_LABELS[task.priority]));
if (task.recurrence !== 'none') { if (task.recurrence !== 'none') {
@@ -344,7 +583,11 @@ function taskRow(task) {
}, task.done ? '✓' : ''), }, task.done ? '✓' : ''),
h('div', { class: 'task-body' }, h('div', { class: 'task-body' },
h('div', { class: 'task-title' }, task.title), h('div', { class: 'task-title' }, task.title),
meta.length ? h('div', { class: 'task-meta' }, meta) : null)); meta.length ? h('div', { class: 'task-meta' }, meta) : null),
task.done ? null : h('button', {
type: 'button', class: 'icon-btn plan-btn', 'aria-label': 'Plan or postpone', title: 'Plan / postpone',
onclick: (e) => { e.stopPropagation(); openPlan(task); },
}, '⏭'));
} }
// --- Calendar ----------------------------------------------------------------- // --- Calendar -----------------------------------------------------------------
@@ -398,6 +641,9 @@ function renderCalendar() {
const items = itemsOn(day); const items = itemsOn(day);
const timed = items.filter((it) => it.task.start_time); const timed = items.filter((it) => it.task.start_time);
const untimed = items.filter((it) => !it.task.start_time).map((it) => it.task).sort(cmpOpen); const untimed = items.filter((it) => !it.task.start_time).map((it) => it.task).sort(cmpOpen);
// Deadlines that fall on this day but are planned for another day, and project deadlines.
const deadlines = state.tasks.filter((t) => t.due_date === day && t.planned_date && t.planned_date !== day);
const projectDeadlines = state.projects.filter((p) => !p.done && p.due_date === day);
let startHour = 7; let startHour = 7;
let endHour = 22; let endHour = 22;
@@ -412,7 +658,9 @@ function renderCalendar() {
grid.append(h('div', { grid.append(h('div', {
class: 'hour', style: { top: `${(hr - startHour) * HOUR_PX}px`, height: `${HOUR_PX}px` }, class: 'hour', style: { top: `${(hr - startHour) * HOUR_PX}px`, height: `${HOUR_PX}px` },
title: 'Add a task at this time', title: 'Add a task at this time',
onclick: () => openTask(null, { due_date: day, start_time: fromMin(hr * 60), end_time: fromMin(Math.min(hr * 60 + 60, 23 * 60 + 59)) }), onclick: () => openTask(null, {
planned_date: day, start_time: fromMin(hr * 60), end_time: fromMin(Math.min(hr * 60 + 60, 23 * 60 + 59)),
}),
}, h('span', { class: 'hour-label' }, `${pad(hr)}:00`))); }, h('span', { class: 'hour-label' }, `${pad(hr)}:00`)));
} }
for (const it of layoutBlocks(timed)) { for (const it of layoutBlocks(timed)) {
@@ -444,6 +692,14 @@ function renderCalendar() {
} }
const sections = [weekStrip(day)]; const sections = [weekStrip(day)];
if (deadlines.length || projectDeadlines.length) {
sections.push(h('section', { class: 'group' },
h('h3', { class: 'group-title' }, 'Deadlines this day'),
projectDeadlines.map((p) => h('button', {
type: 'button', class: 'deadline-line', onclick: () => setView('project', { projectId: p.id }),
}, '🏁 ', h('strong', {}, p.name), ' (project)')),
deadlines.map(taskRow)));
}
if (untimed.length) { if (untimed.length) {
sections.push(h('section', { class: 'group' }, sections.push(h('section', { class: 'group' },
h('h3', { class: 'group-title' }, 'No time set', h('span', { class: 'muted' }, ` ${untimed.length}`)), h('h3', { class: 'group-title' }, 'No time set', h('span', { class: 'muted' }, ` ${untimed.length}`)),
@@ -460,9 +716,10 @@ function setCalDate(day) {
// --- Actions ------------------------------------------------------------------ // --- Actions ------------------------------------------------------------------
function setView(view, categoryId = null) { function setView(view, { categoryId = null, projectId = null } = {}) {
state.view = view; state.view = view;
state.categoryId = categoryId; state.categoryId = categoryId;
state.projectId = projectId;
savePrefs(); savePrefs();
document.body.classList.remove('nav-open'); document.body.classList.remove('nav-open');
render(); render();
@@ -482,7 +739,7 @@ function toggleDone(task) {
} }
act(async () => { act(async () => {
const res = await api(`api/tasks/${task.id}/complete`, { method: 'POST', body: { today: todayStr() } }); const res = await api(`api/tasks/${task.id}/complete`, { method: 'POST', body: { today: todayStr() } });
const extra = res.next ? ` Next one: ${dueInfo(res.next.due_date).text}.` : ''; const extra = res.next ? ` Next one: ${dayName(whenDate(res.next))}.` : '';
toast(`Done: ${task.title}.${extra}`, { toast(`Done: ${task.title}.${extra}`, {
label: 'Undo', label: 'Undo',
fn: () => act(async () => { fn: () => act(async () => {
@@ -493,13 +750,23 @@ function toggleDone(task) {
}); });
} }
// Defaults for a task created from the current view.
function viewDefaults() {
const d = {};
if (state.view === 'cat' && state.categoryId) d.category_id = state.categoryId;
if (state.view === 'project') {
const p = projectById(state.projectId);
d.project_id = p.id;
if (p.category_id) d.category_id = p.category_id;
}
if (state.view === 'today') d.planned_date = todayStr();
if (state.view === 'upcoming') d.planned_date = addDays(todayStr(), 1);
if (state.view === 'calendar') d.planned_date = state.calDate || todayStr();
return d;
}
function quickAdd(title) { function quickAdd(title) {
const body = { title }; act(() => api('api/tasks', { method: 'POST', body: { title, ...viewDefaults() } }));
if (state.view === 'cat' && state.categoryId) body.category_id = state.categoryId;
if (state.view === 'today') body.due_date = todayStr();
if (state.view === 'upcoming') body.due_date = addDays(todayStr(), 1);
if (state.view === 'calendar') body.due_date = state.calDate || todayStr();
act(() => api('api/tasks', { method: 'POST', body }));
} }
function toast(message, action) { function toast(message, action) {
@@ -516,12 +783,69 @@ function toast(message, action) {
toastTimer = setTimeout(() => { el.hidden = true; }, action ? 6000 : 4000); toastTimer = setTimeout(() => { el.hidden = true; }, action ? 6000 : 4000);
} }
// --- Postpone / plan sheet ----------------------------------------------------
const PLAN_OPTIONS = [
['today', 'Today'], ['tomorrow', 'Tomorrow'], ['weekend', 'This weekend'],
['nextweek', 'Next week'], ['twoweeks', 'In 2 weeks'], ['nextmonth', 'Next month'],
];
function openPlan(task) {
// Overdue tasks can't be fixed by planning, so default to moving the deadline for them.
const mode = task.due_date && task.due_date < todayStr() ? 'due' : 'plan';
planning = { task, mode };
$('#p-title').textContent = task.title;
renderPlan();
$('#plan-dialog').showModal();
}
function renderPlan() {
const { task, mode } = planning;
const field = mode === 'plan' ? 'planned_date' : 'due_date';
$('#p-mode').hidden = !task.due_date;
for (const btn of $('#p-mode').querySelectorAll('button')) btn.classList.toggle('active', btn.dataset.mode === mode);
const current = task[field];
$('#p-current').textContent = mode === 'plan'
? (current ? `Planned for ${dayName(current)}. Move it to:` : 'Not planned yet. Do it on:')
: `Deadline is ${dayName(current)}. Move it to:`;
$('#p-options').replaceChildren(...PLAN_OPTIONS.map(([key, label]) => {
const date = namedDate(key);
if (key === 'weekend' && [0, 6].includes(parseDate(todayStr()).getDay())) label = 'Next weekend';
return h('button', { type: 'button', class: 'plan-option', onclick: () => applyPlan(date) },
h('span', {}, label),
h('span', { class: 'muted small' }, parseDate(date).toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short' })));
}));
$('#p-date').value = current || '';
$('#p-clear').textContent = mode === 'plan' ? 'Remove plan date' : 'Remove deadline';
$('#p-clear').hidden = !current;
}
function applyPlan(date) {
const { task, mode } = planning;
const field = mode === 'plan' ? 'planned_date' : 'due_date';
const before = {
planned_date: task.planned_date, due_date: task.due_date, start_time: task.start_time, end_time: task.end_time,
};
const body = { [field]: date };
const after = { ...before, ...body };
if (!after.planned_date && !after.due_date) { body.start_time = null; body.end_time = null; }
$('#plan-dialog').close();
act(async () => {
await api(`api/tasks/${task.id}`, { method: 'PATCH', body });
const what = mode === 'plan' ? 'Planned for' : 'Deadline moved to';
toast(date ? `${what} ${dayName(date)}.` : (mode === 'plan' ? 'Plan date removed.' : 'Deadline removed.'), {
label: 'Undo',
fn: () => act(() => api(`api/tasks/${task.id}`, { method: 'PATCH', body: before })),
});
});
}
// --- Task dialog -------------------------------------------------------------- // --- Task dialog --------------------------------------------------------------
function fillCategorySelect(select, selected) { function fillSelect(select, items, selected, noneLabel = '— None —') {
select.replaceChildren( select.replaceChildren(
h('option', { value: '' }, '— None —'), h('option', { value: '' }, noneLabel),
...state.categories.map((c) => h('option', { value: String(c.id) }, c.name)), ...items.map((x) => h('option', { value: String(x.id) }, x.name)),
); );
select.value = selected ? String(selected) : ''; select.value = selected ? String(selected) : '';
} }
@@ -533,13 +857,16 @@ function updateIntervalVisibility() {
function openTask(task, defaults = {}) { function openTask(task, defaults = {}) {
editing = task; editing = task;
const t = task || { const t = task || {
title: '', notes: '', category_id: null, priority: 0, due_date: null, title: '', notes: '', category_id: null, project_id: null, priority: 0, due_date: null, planned_date: null,
tags: [], recurrence: 'none', recur_interval: 1, subtasks: [], ...defaults, tags: [], recurrence: 'none', recur_interval: 1, subtasks: [], ...defaults,
}; };
$('#task-dialog-title').textContent = task ? 'Edit task' : 'New task'; $('#task-dialog-title').textContent = task ? 'Edit task' : 'New task';
$('#t-title').value = t.title; $('#t-title').value = t.title;
fillCategorySelect($('#t-category'), t.category_id); fillSelect($('#t-category'), state.categories, t.category_id);
// Finished projects are only listed for the task already in one.
fillSelect($('#t-project'), sortedProjects().filter((p) => !p.done || p.id === t.project_id), t.project_id);
$('#t-priority').value = String(t.priority); $('#t-priority').value = String(t.priority);
$('#t-planned').value = t.planned_date || '';
$('#t-due').value = t.due_date || ''; $('#t-due').value = t.due_date || '';
$('#t-start').value = t.start_time || ''; $('#t-start').value = t.start_time || '';
$('#t-end').value = t.end_time || ''; $('#t-end').value = t.end_time || '';
@@ -557,9 +884,9 @@ function openTask(task, defaults = {}) {
if (!task) $('#t-title').focus(); if (!task) $('#t-title').focus();
} }
// Times only make sense with a date; show what else is scheduled that day and flag overlaps. // Times apply to the plan date (or the deadline). Show what else is scheduled that day and flag overlaps.
function updateTimeFields() { function updateTimeFields() {
const day = $('#t-due').value; const day = $('#t-planned').value || $('#t-due').value;
for (const id of ['#t-start', '#t-end']) { for (const id of ['#t-start', '#t-end']) {
$(id).disabled = !day; $(id).disabled = !day;
if (!day) $(id).value = ''; if (!day) $(id).value = '';
@@ -582,7 +909,7 @@ function updateTimeFields() {
}); });
box.replaceChildren( box.replaceChildren(
h('div', { class: 'day-head' + (clashes ? ' clash' : '') }, h('div', { class: 'day-head' + (clashes ? ' clash' : '') },
clashes ? `⚠ Overlaps with ${clashes} item${clashes > 1 ? 's' : ''} on this day` : `Already planned on ${dueInfo(day).text.split(' · ')[0]}`), clashes ? `⚠ Overlaps with ${clashes} item${clashes > 1 ? 's' : ''} on this day` : `Already planned on ${dayName(day)}`),
h('ul', {}, rows)); h('ul', {}, rows));
box.hidden = false; box.hidden = false;
} }
@@ -595,6 +922,11 @@ function onStartChange() {
updateTimeFields(); updateTimeFields();
} }
function onProjectChange() {
const p = projectById(Number($('#t-project').value));
if (p && p.category_id && !$('#t-category').value) $('#t-category').value = String(p.category_id);
}
function renderDraftSubtasks() { function renderDraftSubtasks() {
$('#t-subtasks').replaceChildren(...draftSubtasks.map((s, i) => h('li', { class: 'subtask' }, $('#t-subtasks').replaceChildren(...draftSubtasks.map((s, i) => h('li', { class: 'subtask' },
h('input', { type: 'checkbox', checked: s.done, 'aria-label': 'Done', onchange: (e) => { s.done = e.target.checked; } }), h('input', { type: 'checkbox', checked: s.done, 'aria-label': 'Done', onchange: (e) => { s.done = e.target.checked; } }),
@@ -621,7 +953,9 @@ function saveTask(e) {
const body = { const body = {
title: $('#t-title').value.trim(), title: $('#t-title').value.trim(),
category_id: $('#t-category').value ? Number($('#t-category').value) : null, category_id: $('#t-category').value ? Number($('#t-category').value) : null,
project_id: $('#t-project').value ? Number($('#t-project').value) : null,
priority: Number($('#t-priority').value), priority: Number($('#t-priority').value),
planned_date: $('#t-planned').value || null,
due_date: $('#t-due').value || null, due_date: $('#t-due').value || null,
start_time: $('#t-start').value || null, start_time: $('#t-start').value || null,
end_time: ($('#t-start').value && $('#t-end').value) || null, end_time: ($('#t-start').value && $('#t-end').value) || null,
@@ -646,6 +980,58 @@ function deleteTask() {
act(() => api(`api/tasks/${task.id}`, { method: 'DELETE' })); act(() => api(`api/tasks/${task.id}`, { method: 'DELETE' }));
} }
// --- Project dialog -----------------------------------------------------------
function openProject(project) {
editingProject = project;
document.body.classList.remove('nav-open');
const p = project || { name: '', notes: '', category_id: null, due_date: null, done: false };
$('#pr-dialog-title').textContent = project ? 'Edit project' : 'New project';
$('#pr-name').value = p.name;
fillSelect($('#pr-category'), state.categories, p.category_id);
$('#pr-due').value = p.due_date || '';
$('#pr-notes').value = p.notes;
$('#pr-done').checked = p.done;
$('#pr-done').closest('label').hidden = !project;
$('#pr-delete').hidden = !project;
$('#project-dialog').showModal();
if (!project) $('#pr-name').focus();
}
function saveProject(e) {
e.preventDefault();
const body = {
name: $('#pr-name').value.trim(),
category_id: $('#pr-category').value ? Number($('#pr-category').value) : null,
due_date: $('#pr-due').value || null,
notes: $('#pr-notes').value,
done: $('#pr-done').checked,
};
if (!body.name) return;
const project = editingProject;
$('#project-dialog').close();
act(async () => {
if (project) {
await api(`api/projects/${project.id}`, { method: 'PATCH', body });
} else {
const created = await api('api/projects', { method: 'POST', body });
state.view = 'project';
state.projectId = created.id;
savePrefs();
toast('Project created. Add its steps as tasks, with deadlines for milestones.');
}
});
}
function deleteProject() {
const p = editingProject;
if (!p) return;
const n = projectTasks(p).length;
if (!confirm(`Delete project "${p.name}"? Its ${n} task(s) are kept, just no longer in a project.`)) return;
$('#project-dialog').close();
act(() => api(`api/projects/${p.id}`, { method: 'DELETE' }));
}
// --- Settings dialog ---------------------------------------------------------- // --- Settings dialog ----------------------------------------------------------
function renderSettingsCategories() { function renderSettingsCategories() {
@@ -690,12 +1076,12 @@ async function importFile() {
} }
const n = Array.isArray(data.tasks) ? data.tasks.length : 0; const n = Array.isArray(data.tasks) ? data.tasks.length : 0;
const warning = mode === 'replace' const warning = mode === 'replace'
? `REPLACE: this deletes ALL current tasks and categories and loads ${n} tasks from the file. Continue?` ? `REPLACE: this deletes ALL current tasks, projects and categories and loads ${n} tasks from the file. Continue?`
: `MERGE: add ${n} tasks from the file to your current data. Continue?`; : `MERGE: add ${n} tasks from the file to your current data. Continue?`;
if (!confirm(warning)) return; if (!confirm(warning)) return;
await act(async () => { await act(async () => {
const res = await api(`api/import?mode=${mode}`, { method: 'POST', body: data }); const res = await api(`api/import?mode=${mode}`, { method: 'POST', body: data });
toast(`Imported ${res.tasks} tasks and ${res.categories} categories (${res.mode}).`); toast(`Imported ${res.tasks} tasks, ${res.projects} projects and ${res.categories} categories (${res.mode}).`);
}); });
$('#s-import-file').value = ''; $('#s-import-file').value = '';
renderSettingsCategories(); renderSettingsCategories();
@@ -742,11 +1128,7 @@ function bind() {
quickAdd(title); quickAdd(title);
}); });
$('#new-detailed').addEventListener('click', () => { $('#new-detailed').addEventListener('click', () => {
const defaults = {}; const defaults = { ...viewDefaults(), title: $('#quick-title').value.trim() };
if (state.view === 'cat' && state.categoryId) defaults.category_id = state.categoryId;
if (state.view === 'today') defaults.due_date = todayStr();
if (state.view === 'calendar') defaults.due_date = state.calDate || todayStr();
defaults.title = $('#quick-title').value.trim();
$('#quick-title').value = ''; $('#quick-title').value = '';
openTask(null, defaults); openTask(null, defaults);
}); });
@@ -761,25 +1143,42 @@ function bind() {
$('#t-cancel').addEventListener('click', () => $('#task-dialog').close()); $('#t-cancel').addEventListener('click', () => $('#task-dialog').close());
$('#t-delete').addEventListener('click', deleteTask); $('#t-delete').addEventListener('click', deleteTask);
$('#t-repeat').addEventListener('change', updateIntervalVisibility); $('#t-repeat').addEventListener('change', updateIntervalVisibility);
$('#t-planned').addEventListener('change', updateTimeFields);
$('#t-due').addEventListener('change', updateTimeFields); $('#t-due').addEventListener('change', updateTimeFields);
$('#t-start').addEventListener('change', onStartChange); $('#t-start').addEventListener('change', onStartChange);
$('#t-end').addEventListener('change', updateTimeFields); $('#t-end').addEventListener('change', updateTimeFields);
$('#t-project').addEventListener('change', onProjectChange);
$('#cal-prev').addEventListener('click', () => setCalDate(addDays(state.calDate || todayStr(), -1)));
$('#cal-next').addEventListener('click', () => setCalDate(addDays(state.calDate || todayStr(), 1)));
$('#cal-today').addEventListener('click', () => setCalDate(todayStr()));
$('#cal-date').addEventListener('change', (e) => { if (e.target.value) setCalDate(e.target.value); });
$('#t-subtask-add').addEventListener('click', addDraftSubtask); $('#t-subtask-add').addEventListener('click', addDraftSubtask);
$('#t-subtask-new').addEventListener('keydown', (e) => { $('#t-subtask-new').addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); addDraftSubtask(); } if (e.key === 'Enter') { e.preventDefault(); addDraftSubtask(); }
}); });
for (const btn of document.querySelectorAll('.quick-dates .chip-btn')) { for (const btn of document.querySelectorAll('.quick-dates .chip-btn')) {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
$('#t-due').value = btn.dataset.days === '' ? '' : addDays(todayStr(), Number(btn.dataset.days)); $('#t-planned').value = namedDate(btn.dataset.plan) || '';
updateTimeFields(); updateTimeFields();
}); });
} }
// Calendar
$('#cal-prev').addEventListener('click', () => setCalDate(addDays(state.calDate || todayStr(), -1)));
$('#cal-next').addEventListener('click', () => setCalDate(addDays(state.calDate || todayStr(), 1)));
$('#cal-today').addEventListener('click', () => setCalDate(todayStr()));
$('#cal-date').addEventListener('change', (e) => { if (e.target.value) setCalDate(e.target.value); });
// Postpone sheet
for (const btn of $('#p-mode').querySelectorAll('button')) {
btn.addEventListener('click', () => { planning.mode = btn.dataset.mode; renderPlan(); });
}
$('#p-set').addEventListener('click', () => { if ($('#p-date').value) applyPlan($('#p-date').value); });
$('#p-clear').addEventListener('click', () => applyPlan(null));
$('#p-cancel').addEventListener('click', () => $('#plan-dialog').close());
// Projects
$('#new-project').addEventListener('click', () => openProject(null));
$('#project-form').addEventListener('submit', saveProject);
$('#pr-cancel').addEventListener('click', () => $('#project-dialog').close());
$('#pr-delete').addEventListener('click', deleteProject);
// Settings dialog // Settings dialog
$('#settings-btn').addEventListener('click', () => { $('#settings-btn').addEventListener('click', () => {
document.body.classList.remove('nav-open'); document.body.classList.remove('nav-open');
+98 -27
View File
@@ -28,7 +28,12 @@
<aside id="sidebar" class="sidebar"> <aside id="sidebar" class="sidebar">
<div class="brand"><img src="icons/icon.svg" alt="" width="28" height="28"> WhatToDo</div> <div class="brand"><img src="icons/icon.svg" alt="" width="28" height="28"> WhatToDo</div>
<nav id="views" class="nav"></nav> <nav id="views" class="nav"></nav>
<div class="nav-heading">Categories</div> <div class="nav-heading">
<span>Projects</span>
<button id="new-project" class="icon-btn small" type="button" aria-label="New project" title="New project">+</button>
</div>
<nav id="projects" class="nav"></nav>
<div class="nav-heading"><span>Categories</span></div>
<nav id="cats" class="nav"></nav> <nav id="cats" class="nav"></nav>
<div class="sidebar-foot"> <div class="sidebar-foot">
<button id="settings-btn" class="nav-item" type="button"><span class="nav-icon">⚙️</span> Settings</button> <button id="settings-btn" class="nav-item" type="button"><span class="nav-icon">⚙️</span> Settings</button>
@@ -65,10 +70,45 @@
<input id="t-title" required maxlength="500" autocomplete="off"> <input id="t-title" required maxlength="500" autocomplete="off">
</label> </label>
<div class="row"> <div class="row">
<label class="field">
<span>Plan to do on</span>
<input id="t-planned" type="date">
</label>
<label class="field">
<span>Deadline</span>
<input id="t-due" type="date">
</label>
</div>
<div class="quick-dates">
<span class="muted small-label">Plan:</span>
<button type="button" class="chip-btn" data-plan="today">Today</button>
<button type="button" class="chip-btn" data-plan="tomorrow">Tomorrow</button>
<button type="button" class="chip-btn" data-plan="weekend">Weekend</button>
<button type="button" class="chip-btn" data-plan="nextweek">Next week</button>
<button type="button" class="chip-btn" data-plan="">No plan</button>
</div>
<div class="row">
<label class="field">
<span>From <small>(optional)</small></span>
<input id="t-start" type="time" step="300">
</label>
<label class="field">
<span>To</span>
<input id="t-end" type="time" step="300">
</label>
</div>
<div id="t-day" class="day-preview" hidden></div>
<div class="row">
<label class="field">
<span>Project</span>
<select id="t-project"></select>
</label>
<label class="field"> <label class="field">
<span>Category</span> <span>Category</span>
<select id="t-category"></select> <select id="t-category"></select>
</label> </label>
</div>
<div class="row">
<label class="field"> <label class="field">
<span>Priority</span> <span>Priority</span>
<select id="t-priority"> <select id="t-priority">
@@ -78,12 +118,6 @@
<option value="3">High</option> <option value="3">High</option>
</select> </select>
</label> </label>
</div>
<div class="row">
<label class="field">
<span>Deadline</span>
<input id="t-due" type="date">
</label>
<label class="field"> <label class="field">
<span>Repeat</span> <span>Repeat</span>
<select id="t-repeat"> <select id="t-repeat">
@@ -99,30 +133,13 @@
<input id="t-interval" type="number" min="1" max="365" value="1"> <input id="t-interval" type="number" min="1" max="365" value="1">
</label> </label>
</div> </div>
<div class="quick-dates">
<button type="button" class="chip-btn" data-days="0">Today</button>
<button type="button" class="chip-btn" data-days="1">Tomorrow</button>
<button type="button" class="chip-btn" data-days="7">Next week</button>
<button type="button" class="chip-btn" data-days="">No date</button>
</div>
<div class="row">
<label class="field">
<span>From <small>(optional)</small></span>
<input id="t-start" type="time" step="300">
</label>
<label class="field">
<span>To</span>
<input id="t-end" type="time" step="300">
</label>
</div>
<div id="t-day" class="day-preview" hidden></div>
<label class="field"> <label class="field">
<span>Tags <small>(comma separated)</small></span> <span>Tags <small>(comma separated)</small></span>
<input id="t-tags" placeholder="e.g. editing, urgent" autocomplete="off"> <input id="t-tags" placeholder="e.g. editing, urgent" autocomplete="off">
</label> </label>
<label class="field"> <label class="field">
<span>Notes</span> <span>Notes</span>
<textarea id="t-notes" rows="4" maxlength="20000"></textarea> <textarea id="t-notes" rows="3" maxlength="20000"></textarea>
</label> </label>
<div class="field"> <div class="field">
<span>Checklist</span> <span>Checklist</span>
@@ -141,6 +158,60 @@
</form> </form>
</dialog> </dialog>
<dialog id="plan-dialog" class="sheet">
<div class="dialog-body">
<h3 id="p-title">Postpone</h3>
<div id="p-mode" class="segmented" role="group" aria-label="What to move">
<button type="button" data-mode="plan">Plan date</button>
<button type="button" data-mode="due">Deadline</button>
</div>
<p id="p-current" class="muted"></p>
<div id="p-options" class="plan-options"></div>
<div class="row">
<input id="p-date" type="date" aria-label="Pick a date">
<button id="p-set" class="btn primary" type="button">Set</button>
</div>
<div class="dialog-actions">
<button id="p-clear" class="btn" type="button">Remove date</button>
<span class="spacer"></span>
<button id="p-cancel" class="btn" type="button">Cancel</button>
</div>
</div>
</dialog>
<dialog id="project-dialog">
<form id="project-form" class="dialog-body">
<h3 id="pr-dialog-title">Project</h3>
<label class="field">
<span>Name</span>
<input id="pr-name" required maxlength="100" autocomplete="off">
</label>
<div class="row">
<label class="field">
<span>Category</span>
<select id="pr-category"></select>
</label>
<label class="field">
<span>Deadline</span>
<input id="pr-due" type="date">
</label>
</div>
<label class="field">
<span>Goal / notes</span>
<textarea id="pr-notes" rows="4" maxlength="20000"></textarea>
</label>
<label class="check-field">
<input id="pr-done" type="checkbox"> Finished
</label>
<div class="dialog-actions">
<button id="pr-delete" type="button" class="btn danger">Delete</button>
<span class="spacer"></span>
<button id="pr-cancel" type="button" class="btn">Cancel</button>
<button type="submit" class="btn primary">Save</button>
</div>
</form>
</dialog>
<dialog id="settings-dialog"> <dialog id="settings-dialog">
<div class="dialog-body"> <div class="dialog-body">
<h3>Settings</h3> <h3>Settings</h3>
@@ -157,8 +228,8 @@
<section class="settings-section"> <section class="settings-section">
<h4>Export / import</h4> <h4>Export / import</h4>
<p class="muted">Export downloads all tasks and categories as a JSON file. Import it on another <p class="muted">Export downloads all tasks, projects and categories as a JSON file. Import it on
install, such as production, to move your data there.</p> another install, such as production, to move your data there.</p>
<div class="row wrap"> <div class="row wrap">
<a id="s-export" class="btn primary" href="api/export" download>Export data</a> <a id="s-export" class="btn primary" href="api/export" download>Export data</a>
</div> </div>
+78 -1
View File
@@ -104,7 +104,10 @@ textarea { resize: vertical; }
} }
.brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 18px; padding: 4px 10px 16px; } .brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 18px; padding: 4px 10px 16px; }
.nav { display: flex; flex-direction: column; gap: 2px; } .nav { display: flex; flex-direction: column; gap: 2px; }
.nav-heading { font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); padding: 18px 10px 6px; } .nav-heading {
display: flex; align-items: center; justify-content: space-between;
font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); padding: 18px 10px 6px;
}
.nav-item { .nav-item {
display: flex; align-items: center; gap: 10px; width: 100%; text-align: left; display: flex; align-items: center; gap: 10px; width: 100%; text-align: left;
background: none; border: none; border-radius: 8px; padding: 8px 10px; cursor: pointer; background: none; border: none; border-radius: 8px; padding: 8px 10px; cursor: pointer;
@@ -300,3 +303,77 @@ input:disabled { opacity: 0.5; }
.week-strip { gap: 4px; } .week-strip { gap: 4px; }
} }
.block.short .block-time { order: 2; } .block.short .block-time { order: 2; }
/* Small helpers */
.small { font-size: 12px; }
.small-label { font-size: 13px; align-self: center; }
.icon-btn.small { padding: 2px 6px; font-size: 15px; line-height: 1; }
/* Sidebar: projects */
.nav-item.muted { color: var(--muted); }
.nav-toggle {
background: none; border: none; color: var(--muted); font-size: 12px; cursor: pointer;
text-align: left; padding: 4px 10px 4px 40px;
}
.nav-toggle:hover { color: var(--text); }
.nav-empty { color: var(--muted); font-size: 13px; padding: 2px 10px 2px 40px; }
/* Task row: plan chip and postpone button */
.chip.plan { color: var(--accent); }
.chip.plan.now { background: var(--accent-weak); font-weight: 600; }
.link-chip { cursor: pointer; }
.link-chip:hover { color: var(--accent); }
.plan-btn { align-self: center; font-size: 16px; }
.group-hint { margin: -4px 0 8px; font-size: 13px; color: var(--muted); }
/* Projects */
.progress { flex: 1; height: 8px; background: var(--panel-2); border-radius: 999px; overflow: hidden; }
.progress-fill { height: 100%; background: var(--ok); border-radius: 999px; }
.project-progress { display: flex; align-items: center; gap: 10px; margin-top: 8px; }
.project-progress.big .progress { height: 10px; }
.project-card {
background: var(--panel); border: 1px solid var(--border); border-left: 4px solid var(--accent);
border-radius: var(--radius); padding: 11px 14px; margin-bottom: 8px; cursor: pointer; box-shadow: var(--shadow);
}
.project-card:hover { border-color: var(--accent); }
.project-card-head { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; flex-wrap: wrap; }
.project-name { font-weight: 600; }
.project-next { margin-top: 6px; font-size: 14px; overflow-wrap: anywhere; }
.project-warn { margin-top: 6px; font-size: 13px; color: var(--today); font-weight: 600; }
.project-header {
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
padding: 14px; margin-top: 6px; box-shadow: var(--shadow);
}
.project-header-top { display: flex; justify-content: space-between; align-items: center; gap: 10px; flex-wrap: wrap; }
.project-header-top .task-meta { margin-top: 0; }
.project-notes { white-space: pre-wrap; margin: 10px 0 0; color: var(--muted); overflow-wrap: anywhere; }
.check-field { display: flex; align-items: center; gap: 8px; cursor: pointer; }
.deadline-line {
display: block; width: 100%; text-align: left; background: var(--panel); border: 1px dashed var(--border);
border-radius: var(--radius); padding: 10px 12px; margin-bottom: 8px; cursor: pointer;
}
/* Postpone sheet */
.segmented { display: flex; background: var(--panel-2); border-radius: 9px; padding: 3px; gap: 3px; }
.segmented button {
flex: 1; border: none; background: none; border-radius: 7px; padding: 7px; cursor: pointer; color: var(--muted);
}
.segmented button.active { background: var(--panel); color: var(--text); font-weight: 600; box-shadow: var(--shadow); }
#p-current { margin: 0; }
.plan-options { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.plan-option {
display: flex; flex-direction: column; align-items: flex-start; gap: 2px; text-align: left;
background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 10px 12px; cursor: pointer;
}
.plan-option:hover { border-color: var(--accent); background: var(--accent-weak); }
dialog.sheet { width: min(420px, calc(100vw - 24px)); }
@media (max-width: 800px) {
/* The postpone sheet slides up from the bottom on phones. */
dialog.sheet {
width: 100vw; max-width: 100vw; margin: auto 0 0; border-radius: 16px 16px 0 0;
padding-bottom: env(safe-area-inset-bottom);
}
}
#plan-dialog .row { flex-wrap: nowrap; }
#plan-dialog .row input { flex: 1; }
+1 -1
View File
@@ -1,5 +1,5 @@
// Network-first service worker: always fresh when online, app shell still opens offline. // Network-first service worker: always fresh when online, app shell still opens offline.
const CACHE = 'whattodo-v2'; const CACHE = 'whattodo-v3';
const SHELL = [ const SHELL = [
'./', 'index.html', 'style.css', 'app.js', 'manifest.webmanifest', './', 'index.html', 'style.css', 'app.js', 'manifest.webmanifest',
'icons/icon.svg', 'icons/icon-192.png', 'icons/icon-512.png', 'icons/icon.svg', 'icons/icon-192.png', 'icons/icon-512.png',
@@ -1,3 +1,5 @@
# Builds the image from source (development). Used instead of docker-compose.yaml when
# COMPOSE_FILE=docker-compose.build.yaml is set in .env, or with -f docker-compose.build.yaml.
services: services:
whattodo: whattodo:
build: build:
+20
View File
@@ -0,0 +1,20 @@
# Runs the published image; no source code needed. Put this file and a .env (from .env.example) in a folder, then:
# docker compose up -d
# Update to the newest published version:
# docker compose pull && docker compose up -d
# To build from source instead, use docker-compose.build.yaml.
services:
whattodo:
image: git.goyban.com/goyban/whattodo:${WHATTODO_TAG:-latest}
restart: unless-stopped
env_file: .env
ports:
# BIND_ADDR=127.0.0.1 -> this computer only; 0.0.0.0 -> also reachable from your phone on the LAN.
- "${BIND_ADDR:-127.0.0.1}:${PORT:-8080}:8000"
volumes:
- ./data:/data
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health')"]
interval: 30s
timeout: 5s
retries: 3
Executable
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Build the app image from a committed version and push it to the Gitea registry.
# Only committed files are used (via `git archive`), so uncommitted work never ends up in the image.
#
# ./publish.sh # builds HEAD
# ./publish.sh v1.2 # builds a tag/branch/commit
#
# One-time setup: docker login git.goyban.com (password = a Gitea token with package write access)
set -euo pipefail
REGISTRY=git.goyban.com
IMAGE="$REGISTRY/goyban/whattodo"
REF="${1:-HEAD}"
cd "$(dirname "$0")"
SHA="$(git rev-parse --short "$REF^{commit}")"
echo "Building $IMAGE from $REF ($SHA)"
git archive --format=tar "$REF" | docker build --target runtime -t "$IMAGE:$SHA" -t "$IMAGE:latest" -
docker push "$IMAGE:$SHA"
docker push "$IMAGE:latest"
echo "Pushed $IMAGE:$SHA and $IMAGE:latest"
+64 -2
View File
@@ -177,6 +177,68 @@ def test_migration_from_v1(tmp_path, monkeypatch):
db.init() db.init()
conn = sqlite3.connect(path) conn = sqlite3.connect(path)
assert conn.execute("SELECT title, start_time FROM tasks").fetchall() == [("old task", None)] 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() 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