Initial WhatToDo app

Self-hosted personal task list: FastAPI + SQLite in Docker, mobile-friendly
PWA frontend, deadlines, priorities, categories, tags, notes, checklists,
recurring tasks, single-password login, and JSON export/import.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
goyban
2026-10-07 11:43:56 +02:00
commit c5b049edba
23 changed files with 2045 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
.git
.env
data
**/__pycache__
*.db
whattodo-export-*.json
+20
View File
@@ -0,0 +1,20 @@
# Copy to .env and fill in. .env is git-ignored: never commit it.
# Password for logging in to the app.
APP_PASSWORD=change-me
# Long random string used to sign login cookies. Generate one with:
# head -c 32 /dev/urandom | base64
SECRET_KEY=change-me
# true when the app is served over HTTPS (production). Keep false for http://localhost.
COOKIE_SECURE=false
# How long a login lasts, in days.
SESSION_DAYS=30
# Port on the host, and which interface to listen on:
# 127.0.0.1 = only this computer, 0.0.0.0 = also other devices on your network (phone).
PORT=8080
BIND_ADDR=127.0.0.1
# Behind a reverse proxy, the IPs allowed to set X-Forwarded-For (for login rate limiting).
# FORWARDED_ALLOW_IPS=127.0.0.1
+10
View File
@@ -0,0 +1,10 @@
# Secrets and local data: never commit
.env
data/
*.db
*.db-*
whattodo-export-*.json
__pycache__/
*.pyc
.pytest_cache/
+26
View File
@@ -0,0 +1,26 @@
FROM python:3.13-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
DB_PATH=/data/whattodo.db
RUN useradd --uid 1000 --create-home app
WORKDIR /srv
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY app ./app
# `docker compose run --rm test`
FROM base AS test
COPY requirements-dev.txt .
RUN pip install -r requirements-dev.txt
COPY tests ./tests
USER app
CMD ["python", "-m", "pytest", "-q", "-p", "no:cacheprovider", "tests"]
FROM base AS runtime
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
EXPOSE 8000
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+83
View File
@@ -0,0 +1,83 @@
# WhatToDo
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**, **priority** (none / low / medium / high), **category**, **tags**, **notes**, and a **checklist** of steps.
- **Recurring tasks** (every N days/weeks/months/years): completing one creates the next occurrence.
- Views: **Today** (overdue, due today, high priority), **Upcoming**, **All open**, **Done**, plus one view per category.
- 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**.
- Login with a single password.
Stack: Python (FastAPI) + SQLite with a plain HTML/CSS/JS frontend and no build step. Everything runs in Docker.
## Run locally
Requirements: Docker with the Compose plugin. Nothing else is installed on the host.
```sh
cp .env.example .env # then edit .env: set APP_PASSWORD and SECRET_KEY
docker compose up -d --build
```
Open <http://localhost:8080> and log in with `APP_PASSWORD`.
The database lives in `./data/whattodo.db`, which is git-ignored.
Useful commands:
```sh
docker compose logs -f # follow logs
docker compose down # stop
docker compose up -d --build # rebuild after pulling changes
docker compose run --rm test # run the test suite (in Docker)
```
### 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.
Browsers only allow "Install app" / "Add to Home Screen" with offline support over **HTTPS** (or on `localhost`). Over plain LAN HTTP the app still works, but it won't install as a full app.
## 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`.
2. `docker compose up -d --build`. The app listens on `127.0.0.1:${PORT}`.
3. Put it behind a reverse proxy with HTTPS. For example, with Caddy:
```
todo.example.com {
reverse_proxy 127.0.0.1:8080
}
```
If the proxy runs in another container instead of on the host, set `BIND_ADDR`/networking to match and set `FORWARDED_ALLOW_IPS` to the proxy's IP. The app then sees the real client IP, which login rate limiting uses.
### Moving your data from this PC to production
1. Local app → **Settings → Export data**. This downloads `whattodo-export-YYYYMMDD-HHMM.json`.
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.
- **Merge** adds the file's tasks to what is already there. Categories with the same name are reused.
The export file contains your tasks. Keep it out of the repo (`whattodo-export-*.json` is git-ignored).
### Backups
Export regularly from Settings, or copy `./data/whattodo.db` while the app is stopped.
## Security notes
- `.env` and `data/` are git-ignored. Never commit them; the repository is public.
- Changing `APP_PASSWORD` or `SECRET_KEY` logs out all sessions.
- Failed logins are rate limited: 10 failures per 15 minutes per IP.
- Write requests require an `X-WhatToDo` header (CSRF protection). The app also sends a strict Content-Security-Policy.
## Project layout
```
app/main.py API: auth, tasks, categories, export/import
app/db.py SQLite schema and setup
app/static/ frontend (index.html, app.js, style.css, PWA files, icons)
tests/ API tests (run with: docker compose run --rm test)
tools/make_icons.py regenerates the PNG icons
```
+81
View File
@@ -0,0 +1,81 @@
import os
import sqlite3
from pathlib import Path
DB_PATH = os.environ.get("DB_PATH", "/data/whattodo.db")
SCHEMA_VERSION = 1
DEFAULT_CATEGORIES = [
("Work", "#2563eb"),
("Life", "#16a34a"),
("Personal", "#9333ea"),
("YouTube", "#dc2626"),
]
SCHEMA = """
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
color TEXT NOT NULL DEFAULT '#6b7280',
position INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
notes TEXT NOT NULL DEFAULT '',
category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
priority INTEGER NOT NULL DEFAULT 0,
due_date TEXT,
tags TEXT NOT NULL DEFAULT '[]',
recurrence TEXT NOT NULL DEFAULT 'none',
recur_interval INTEGER NOT NULL DEFAULT 1,
done INTEGER NOT NULL DEFAULT 0,
done_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS subtasks (
id INTEGER PRIMARY KEY,
task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
title TEXT NOT NULL,
done INTEGER NOT NULL DEFAULT 0,
position INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_subtasks_task ON subtasks(task_id);
"""
def connect() -> sqlite3.Connection:
# FastAPI may open a request's connection and run its endpoint on different threads;
# each connection is still used by only one request at a time.
conn = sqlite3.connect(DB_PATH, check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return conn
def init() -> None:
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
conn = connect()
try:
conn.execute("PRAGMA journal_mode = WAL")
conn.executescript(SCHEMA)
row = conn.execute("SELECT value FROM meta WHERE key = 'schema_version'").fetchone()
if row is None:
# Fresh database: seed default categories.
with conn:
for i, (name, color) in enumerate(DEFAULT_CATEGORIES):
conn.execute(
"INSERT INTO categories (name, color, position) VALUES (?, ?, ?)",
(name, color, i),
)
conn.execute(
"INSERT INTO meta (key, value) VALUES ('schema_version', ?)",
(str(SCHEMA_VERSION),),
)
finally:
conn.close()
+527
View File
@@ -0,0 +1,527 @@
import calendar
import hashlib
import hmac
import json
import os
import sqlite3
import time
from collections import defaultdict, deque
from contextlib import asynccontextmanager
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from typing import Literal, Optional
from fastapi import Depends, FastAPI, HTTPException, Request, Response
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field, field_validator
from . import db
# --- Configuration -----------------------------------------------------------
APP_PASSWORD = os.environ.get("APP_PASSWORD", "")
SECRET_KEY = os.environ.get("SECRET_KEY", "")
COOKIE_SECURE = os.environ.get("COOKIE_SECURE", "false").lower() in ("1", "true", "yes")
SESSION_DAYS = int(os.environ.get("SESSION_DAYS", "30"))
if not APP_PASSWORD or not SECRET_KEY or "change-me" in (APP_PASSWORD, SECRET_KEY):
raise SystemExit("APP_PASSWORD and SECRET_KEY must be set to real values (see .env.example)")
COOKIE_NAME = "whattodo_session"
CSRF_HEADER = "x-whattodo"
EXPORT_VERSION = 1
STATIC_DIR = Path(__file__).parent / "static"
# Changing the password (or secret) invalidates all existing sessions.
_SESSION_KEY = hashlib.sha256(f"{SECRET_KEY}\0{APP_PASSWORD}".encode()).digest()
@asynccontextmanager
async def lifespan(_app: FastAPI):
db.init()
yield
app = FastAPI(title="WhatToDo", lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None)
# --- Sessions & security ------------------------------------------------------
def make_token() -> str:
ts = str(int(time.time()))
sig = hmac.new(_SESSION_KEY, ts.encode(), hashlib.sha256).hexdigest()
return f"{ts}.{sig}"
def valid_token(token: Optional[str]) -> bool:
if not token or "." not in token:
return False
ts, sig = token.split(".", 1)
expected = hmac.new(_SESSION_KEY, ts.encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected):
return False
try:
return time.time() - int(ts) < SESSION_DAYS * 86400
except ValueError:
return False
PUBLIC_API = {"/api/login", "/api/health"}
CSP = (
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; "
"connect-src 'self'; manifest-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"
)
@app.middleware("http")
async def guard(request: Request, call_next):
path = request.url.path
if path.startswith("/api/"):
if path not in PUBLIC_API and not valid_token(request.cookies.get(COOKIE_NAME)):
return JSONResponse({"detail": "Not logged in"}, status_code=401)
# Custom header on every write request: browsers can't send it cross-site without CORS.
if request.method not in ("GET", "HEAD", "OPTIONS") and request.headers.get(CSRF_HEADER) != "1":
return JSONResponse({"detail": "Missing X-WhatToDo header"}, status_code=403)
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Referrer-Policy"] = "same-origin"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Content-Security-Policy"] = CSP
if path.startswith("/api/"):
response.headers["Cache-Control"] = "no-store"
else:
response.headers.setdefault("Cache-Control", "no-cache")
return response
_failed_logins: dict[str, deque] = defaultdict(deque)
MAX_FAILS, FAIL_WINDOW = 10, 15 * 60
class LoginIn(BaseModel):
password: str
@app.post("/api/login")
def login(body: LoginIn, request: Request, response: Response):
ip = request.client.host if request.client else "unknown"
fails, now = _failed_logins[ip], time.time()
while fails and now - fails[0] > FAIL_WINDOW:
fails.popleft()
if len(fails) >= MAX_FAILS:
raise HTTPException(429, "Too many failed attempts, try again in a few minutes")
if not hmac.compare_digest(body.password.encode(), APP_PASSWORD.encode()):
fails.append(now)
time.sleep(1)
raise HTTPException(401, "Wrong password")
fails.clear()
response.set_cookie(
COOKIE_NAME, make_token(), max_age=SESSION_DAYS * 86400,
httponly=True, samesite="lax", secure=COOKIE_SECURE, path="/",
)
return {"ok": True}
@app.post("/api/logout")
def logout(response: Response):
response.delete_cookie(COOKIE_NAME, path="/")
return {"ok": True}
@app.get("/api/health")
def health():
return {"ok": True}
@app.get("/api/me")
def me():
return {"ok": True}
# --- Helpers -----------------------------------------------------------------
def get_conn():
conn = db.connect()
try:
yield conn
finally:
conn.close()
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def add_months(d: date, n: int) -> date:
m = d.month - 1 + n
y, m = d.year + m // 12, m % 12 + 1
return date(y, m, min(d.day, calendar.monthrange(y, m)[1]))
def step(d: date, recurrence: str, n: int) -> date:
if recurrence == "daily":
return d + timedelta(days=n)
if recurrence == "weekly":
return d + timedelta(weeks=n)
if recurrence == "monthly":
return add_months(d, n)
return add_months(d, 12 * n) # yearly
def next_due(due: Optional[date], recurrence: str, n: int, today: date) -> date:
"""Next occurrence after `today`, so a late completion doesn't create an already-overdue task."""
nxt = step(due or today, recurrence, n)
while nxt <= today:
nxt = step(nxt, recurrence, n)
return nxt
def norm_tags(tags: list[str]) -> list[str]:
out: list[str] = []
for t in tags:
t = t.strip().lstrip("#").strip().lower()
if t and len(t) <= 40 and t not in out:
out.append(t)
return out[:20]
def clean_title(v: Optional[str]) -> Optional[str]:
if v is None:
return v
v = v.strip()
if not v:
raise ValueError("must not be empty")
return v
# --- Models ------------------------------------------------------------------
Recurrence = Literal["none", "daily", "weekly", "monthly", "yearly"]
HexColor = Field(pattern=r"^#[0-9a-fA-F]{6}$")
class SubtaskIn(BaseModel):
title: str = Field(min_length=1, max_length=500)
done: bool = False
class TaskIn(BaseModel):
title: str = Field(max_length=500)
notes: str = Field("", max_length=20000)
category_id: Optional[int] = None
priority: int = Field(0, ge=0, le=3)
due_date: Optional[date] = None
tags: list[str] = []
recurrence: Recurrence = "none"
recur_interval: int = Field(1, ge=1, le=365)
subtasks: list[SubtaskIn] = Field([], max_length=200)
_title = field_validator("title")(clean_title)
class TaskPatch(BaseModel):
title: Optional[str] = Field(None, max_length=500)
notes: Optional[str] = Field(None, max_length=20000)
category_id: Optional[int] = None
priority: Optional[int] = Field(None, ge=0, le=3)
due_date: Optional[date] = None
tags: Optional[list[str]] = None
recurrence: Optional[Recurrence] = None
recur_interval: Optional[int] = Field(None, ge=1, le=365)
subtasks: Optional[list[SubtaskIn]] = Field(None, max_length=200)
_title = field_validator("title")(clean_title)
class CompleteIn(BaseModel):
today: Optional[date] = None # the browser's local date
class CategoryIn(BaseModel):
name: str = Field(min_length=1, max_length=50)
color: str = HexColor
position: Optional[int] = None
class CategoryPatch(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=50)
color: Optional[str] = Field(None, pattern=r"^#[0-9a-fA-F]{6}$")
position: Optional[int] = None
class ExportCategory(BaseModel):
id: int
name: str = Field(min_length=1, max_length=50)
color: str = HexColor
position: int = 0
class ExportTask(TaskIn):
done: bool = False
done_at: Optional[str] = None
created_at: Optional[str] = None
updated_at: Optional[str] = None
class ExportData(BaseModel):
app: str
version: int
categories: list[ExportCategory] = []
tasks: list[ExportTask] = []
# --- Data access -------------------------------------------------------------
def row_to_task(r: sqlite3.Row, subtasks: list[dict]) -> dict:
return {
"id": r["id"],
"title": r["title"],
"notes": r["notes"],
"category_id": r["category_id"],
"priority": r["priority"],
"due_date": r["due_date"],
"tags": json.loads(r["tags"]),
"recurrence": r["recurrence"],
"recur_interval": r["recur_interval"],
"done": bool(r["done"]),
"done_at": r["done_at"],
"created_at": r["created_at"],
"updated_at": r["updated_at"],
"subtasks": subtasks,
}
def load_tasks(conn: sqlite3.Connection, task_id: Optional[int] = None) -> list[dict]:
where, params = ("WHERE id = ?", (task_id,)) if task_id is not None else ("", ())
sub_where = "WHERE task_id = ?" if task_id is not None else ""
subs: dict[int, list[dict]] = defaultdict(list)
for s in conn.execute(f"SELECT * FROM subtasks {sub_where} ORDER BY position, id", params):
subs[s["task_id"]].append({"id": s["id"], "title": s["title"], "done": bool(s["done"])})
rows = conn.execute(f"SELECT * FROM tasks {where} ORDER BY id", params).fetchall()
return [row_to_task(r, subs[r["id"]]) for r in rows]
def get_task(conn: sqlite3.Connection, task_id: int) -> dict:
tasks = load_tasks(conn, task_id)
if not tasks:
raise HTTPException(404, "Task not found")
return tasks[0]
def ensure_category(conn: sqlite3.Connection, category_id: Optional[int]) -> None:
if category_id is not None and not conn.execute(
"SELECT 1 FROM categories WHERE id = ?", (category_id,)
).fetchone():
raise HTTPException(400, "Unknown category")
def write_subtasks(conn: sqlite3.Connection, task_id: int, subtasks: list[dict]) -> None:
conn.execute("DELETE FROM subtasks WHERE task_id = ?", (task_id,))
for i, s in enumerate(subtasks):
conn.execute(
"INSERT INTO subtasks (task_id, title, done, position) VALUES (?, ?, ?, ?)",
(task_id, s["title"].strip(), int(s["done"]), i),
)
def insert_task(conn: sqlite3.Connection, t: dict, done: bool = False, done_at: Optional[str] = None,
created_at: Optional[str] = None, updated_at: Optional[str] = None) -> int:
now = now_iso()
due = t.get("due_date")
cur = conn.execute(
"""INSERT INTO tasks (title, notes, category_id, priority, due_date, tags, recurrence,
recur_interval, done, done_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
t["title"], t.get("notes", ""), t.get("category_id"), t.get("priority", 0),
due.isoformat() if isinstance(due, date) else due,
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,
),
)
write_subtasks(conn, cur.lastrowid, t.get("subtasks", []))
return cur.lastrowid
# --- Category endpoints ------------------------------------------------------
def list_categories(conn: sqlite3.Connection) -> list[dict]:
return [dict(r) for r in conn.execute("SELECT * FROM categories ORDER BY position, id")]
@app.get("/api/categories")
def get_categories(conn=Depends(get_conn)):
return list_categories(conn)
@app.post("/api/categories", status_code=201)
def create_category(body: CategoryIn, conn=Depends(get_conn)):
pos = body.position
if pos is None:
pos = conn.execute("SELECT COALESCE(MAX(position), -1) + 1 FROM categories").fetchone()[0]
try:
with conn:
cur = conn.execute(
"INSERT INTO categories (name, color, position) VALUES (?, ?, ?)",
(body.name.strip(), body.color, pos),
)
except sqlite3.IntegrityError:
raise HTTPException(409, "A category with that name already exists")
return dict(conn.execute("SELECT * FROM categories WHERE id = ?", (cur.lastrowid,)).fetchone())
@app.patch("/api/categories/{category_id}")
def update_category(category_id: int, body: CategoryPatch, conn=Depends(get_conn)):
ensure_category(conn, category_id)
fields = body.model_dump(exclude_unset=True, exclude_none=True)
if "name" in fields:
fields["name"] = fields["name"].strip()
if fields:
try:
with conn:
conn.execute(
f"UPDATE categories SET {', '.join(f'{k} = ?' for k in fields)} WHERE id = ?",
(*fields.values(), category_id),
)
except sqlite3.IntegrityError:
raise HTTPException(409, "A category with that name already exists")
return dict(conn.execute("SELECT * FROM categories WHERE id = ?", (category_id,)).fetchone())
@app.delete("/api/categories/{category_id}", status_code=204)
def delete_category(category_id: int, conn=Depends(get_conn)):
with conn:
conn.execute("DELETE FROM categories WHERE id = ?", (category_id,))
return Response(status_code=204)
# --- Task endpoints ----------------------------------------------------------
@app.get("/api/tasks")
def get_tasks(conn=Depends(get_conn)):
return load_tasks(conn)
@app.post("/api/tasks", status_code=201)
def create_task(body: TaskIn, conn=Depends(get_conn)):
ensure_category(conn, body.category_id)
with conn:
task_id = insert_task(conn, body.model_dump())
return get_task(conn, task_id)
@app.patch("/api/tasks/{task_id}")
def update_task(task_id: int, body: TaskPatch, conn=Depends(get_conn)):
get_task(conn, task_id)
fields = body.model_dump(exclude_unset=True)
subtasks = fields.pop("subtasks", None)
for key in ("title", "notes", "priority", "tags", "recurrence", "recur_interval"):
if key in fields and fields[key] is None:
del fields[key] # these columns can't be cleared, only changed
if "category_id" in fields:
ensure_category(conn, fields["category_id"])
if "tags" in fields:
fields["tags"] = json.dumps(norm_tags(fields["tags"]))
if fields.get("due_date") is not None:
fields["due_date"] = fields["due_date"].isoformat()
fields["updated_at"] = now_iso()
with conn:
conn.execute(
f"UPDATE tasks SET {', '.join(f'{k} = ?' for k in fields)} WHERE id = ?",
(*fields.values(), task_id),
)
if subtasks is not None:
write_subtasks(conn, task_id, subtasks)
return get_task(conn, task_id)
@app.delete("/api/tasks/{task_id}", status_code=204)
def delete_task(task_id: int, conn=Depends(get_conn)):
with conn:
conn.execute("DELETE FROM tasks WHERE id = ?", (task_id,))
return Response(status_code=204)
@app.post("/api/tasks/{task_id}/complete")
def complete_task(task_id: int, body: CompleteIn, conn=Depends(get_conn)):
"""Mark done. A recurring task also spawns its next occurrence (with subtasks unchecked)."""
task = get_task(conn, task_id)
if task["done"]:
return {"task": task, "next": None}
today = body.today or date.today()
next_id = None
with conn:
now = now_iso()
conn.execute("UPDATE tasks SET done = 1, done_at = ?, updated_at = ? WHERE id = ?", (now, now, task_id))
if task["recurrence"] != "none":
due = date.fromisoformat(task["due_date"]) if task["due_date"] else None
nxt = dict(task)
nxt["due_date"] = next_due(due, task["recurrence"], task["recur_interval"], today)
nxt["subtasks"] = [{"title": s["title"], "done": False} for s in task["subtasks"]]
next_id = insert_task(conn, nxt)
return {"task": get_task(conn, task_id), "next": get_task(conn, next_id) if next_id else None}
@app.post("/api/tasks/{task_id}/reopen")
def reopen_task(task_id: int, conn=Depends(get_conn)):
get_task(conn, task_id)
with conn:
conn.execute(
"UPDATE tasks SET done = 0, done_at = NULL, updated_at = ? WHERE id = ?", (now_iso(), task_id)
)
return get_task(conn, task_id)
# --- Export / import ---------------------------------------------------------
@app.get("/api/export")
def export_data(conn=Depends(get_conn)):
stamp = datetime.now().strftime("%Y%m%d-%H%M")
data = {
"app": "whattodo",
"version": EXPORT_VERSION,
"exported_at": now_iso(),
"categories": list_categories(conn),
"tasks": load_tasks(conn),
}
return Response(
json.dumps(data, indent=2, ensure_ascii=False),
media_type="application/json",
headers={"Content-Disposition": f'attachment; filename="whattodo-export-{stamp}.json"'},
)
@app.post("/api/import")
def import_data(data: ExportData, mode: Literal["replace", "merge"] = "merge", conn=Depends(get_conn)):
"""replace: wipe everything and load the file. merge: add the file's tasks, matching categories by name."""
if data.app != "whattodo" or data.version > EXPORT_VERSION:
raise HTTPException(400, "This file is not a WhatToDo export (or is from a newer version)")
with conn:
if mode == "replace":
conn.execute("DELETE FROM subtasks")
conn.execute("DELETE FROM tasks")
conn.execute("DELETE FROM categories")
existing = {r["name"].lower(): r["id"] for r in conn.execute("SELECT id, name FROM categories")}
next_pos = conn.execute("SELECT COALESCE(MAX(position), -1) + 1 FROM categories").fetchone()[0]
cat_map: dict[int, int] = {}
for c in sorted(data.categories, key=lambda c: c.position):
key = c.name.strip().lower()
if key not in existing:
cur = conn.execute(
"INSERT INTO categories (name, color, position) VALUES (?, ?, ?)",
(c.name.strip(), c.color, next_pos),
)
existing[key] = cur.lastrowid
next_pos += 1
cat_map[c.id] = existing[key]
for t in data.tasks:
fields = t.model_dump()
fields["category_id"] = cat_map.get(t.category_id) if t.category_id is not None else None
insert_task(conn, fields, done=t.done, done_at=t.done_at,
created_at=t.created_at, updated_at=t.updated_at)
return {"ok": True, "mode": mode, "categories": len(data.categories), "tasks": len(data.tasks)}
# Static frontend (must be mounted last so it doesn't shadow /api routes).
app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")
+607
View File
@@ -0,0 +1,607 @@
'use strict';
const VIEWS = [
{ id: 'today', label: 'Today', icon: '☀️' },
{ id: 'upcoming', label: 'Upcoming', icon: '📅' },
{ id: 'all', label: 'All open', icon: '📋' },
{ id: 'done', label: 'Done', icon: '✅' },
];
const PRIORITY_LABELS = ['', 'Low', 'Medium', 'High'];
const REPEAT_UNITS = { daily: 'day', weekly: 'week', monthly: 'month', yearly: 'year' };
const state = { tasks: [], categories: [], view: 'today', categoryId: null, search: '' };
let editing = null; // task open in the dialog; null when creating
let draftSubtasks = [];
let toastTimer = null;
const $ = (sel) => document.querySelector(sel);
// --- Small DOM helper ---------------------------------------------------------
function h(tag, attrs, ...children) {
const el = document.createElement(tag);
for (const [k, v] of Object.entries(attrs || {})) {
if (v == null || v === false) continue;
if (k === 'class') el.className = v;
else if (k === 'style') Object.assign(el.style, v);
else if (k.startsWith('on')) el.addEventListener(k.slice(2), v);
else if (k === 'value' || k === 'checked') el[k] = v;
else el.setAttribute(k, v === true ? '' : v);
}
for (const c of children.flat()) {
if (c != null && c !== false) el.append(c instanceof Node ? c : String(c));
}
return el;
}
// --- Dates (all local, as YYYY-MM-DD strings) ---------------------------------
const pad = (n) => String(n).padStart(2, '0');
const fmtDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
const parseDate = (s) => { const [y, m, d] = s.split('-').map(Number); return new Date(y, m - 1, d); };
const todayStr = () => fmtDate(new Date());
const addDays = (s, n) => { const d = parseDate(s); d.setDate(d.getDate() + n); return fmtDate(d); };
const dayDiff = (a, b) => {
const [ay, am, ad] = a.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);
};
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 ----------------------------------------------------------------------
async function api(path, { method = 'GET', body } = {}) {
const headers = { 'X-WhatToDo': '1' };
if (body !== undefined) headers['Content-Type'] = 'application/json';
const res = await fetch(path, {
method, headers, credentials: 'same-origin',
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (res.status === 401 && path !== 'api/login') {
showLogin();
throw new Error('Please log in');
}
if (!res.ok) {
let msg = `${res.status} ${res.statusText}`;
try {
const j = await res.json();
if (typeof j.detail === 'string') msg = j.detail;
else if (Array.isArray(j.detail)) msg = j.detail.map((e) => `${e.loc.slice(1).join('.')}: ${e.msg}`).join('; ');
} catch { /* not JSON */ }
throw new Error(msg);
}
return res.status === 204 ? null : res.json();
}
async function loadAll() {
const [categories, tasks] = await Promise.all([api('api/categories'), api('api/tasks')]);
state.categories = categories;
state.tasks = tasks;
}
// Run an action, report errors as a toast, then refresh.
async function act(fn) {
try {
await fn();
} catch (e) {
toast(e.message);
}
try {
await loadAll();
render();
} catch { /* login screen already shown */ }
}
// --- Persisted UI prefs -------------------------------------------------------
function savePrefs() {
try { localStorage.setItem('whattodo.view', JSON.stringify({ view: state.view, categoryId: state.categoryId })); } catch { /* ignore */ }
}
function loadPrefs() {
try {
const p = JSON.parse(localStorage.getItem('whattodo.view') || 'null');
if (p && typeof p.view === 'string') { state.view = p.view; state.categoryId = p.categoryId ?? null; }
} catch { /* ignore */ }
}
// --- Filtering & grouping -----------------------------------------------------
const catById = (id) => state.categories.find((c) => c.id === id);
function matchesSearch(t) {
const terms = state.search.toLowerCase().split(/\s+/).filter(Boolean);
return terms.every((term) => {
if (term.startsWith('#') && term.length > 1) return t.tags.some((tag) => tag.startsWith(term.slice(1)));
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();
return hay.includes(term);
});
}
function cmpOpen(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 (b.priority - a.priority) || (a.id - b.id);
}
function viewTasks() {
let tasks = state.tasks;
if (state.view === 'cat') tasks = tasks.filter((t) => (t.category_id || 0) === state.categoryId);
if (state.search.trim()) tasks = tasks.filter(matchesSearch);
return tasks;
}
function buildGroups(tasks) {
const t = todayStr();
const tomorrow = addDays(t, 1);
const week = addDays(t, 7);
if (state.view === 'done') {
const done = tasks.filter((x) => x.done).sort((a, b) => (b.done_at || '').localeCompare(a.done_at || ''));
return [{ title: 'Completed', tasks: done }];
}
const open = tasks.filter((x) => !x.done).sort(cmpOpen);
const group = (title, pred) => ({ title, tasks: open.filter(pred) });
const overdue = (x) => x.due_date && x.due_date < t;
const today = (x) => x.due_date === t;
if (state.view === 'today') {
return [
group('Overdue', overdue),
group('Today', today),
group('High priority', (x) => x.priority === 3 && (!x.due_date || x.due_date > t)),
];
}
if (state.view === 'upcoming') {
return [
group('Tomorrow', (x) => x.due_date === tomorrow),
group('Next 7 days', (x) => x.due_date && x.due_date > tomorrow && x.due_date <= week),
group('Later', (x) => x.due_date && x.due_date > week),
];
}
return [
group('Overdue', overdue),
group('Today', today),
group('Next 7 days', (x) => x.due_date && x.due_date > t && x.due_date <= week),
group('Later', (x) => x.due_date && x.due_date > week),
group('No deadline', (x) => !x.due_date),
];
}
function counts() {
const t = todayStr();
const open = state.tasks.filter((x) => !x.done);
const c = {
today: open.filter((x) => (x.due_date && x.due_date <= t) || x.priority === 3).length,
upcoming: open.filter((x) => x.due_date && x.due_date > t).length,
all: open.length,
done: null,
overdue: open.filter((x) => x.due_date && x.due_date < t).length,
cats: {},
};
for (const x of open) c.cats[x.category_id || 0] = (c.cats[x.category_id || 0] || 0) + 1;
return c;
}
// --- Rendering ----------------------------------------------------------------
function render() {
if (state.view === 'cat' && state.categoryId && !catById(state.categoryId)) state.view = 'today';
if (state.view !== 'cat' && !VIEWS.some((v) => v.id === state.view)) state.view = 'today';
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),
h('span', { class: 'nav-label' }, label),
count ? h('span', { class: 'count' + (alert ? ' alert' : '') }, count) : null);
$('#views').replaceChildren(...VIEWS.map((v) => navItem(
state.view === v.id, v.icon, v.label, c[v.id], () => setView(v.id), null, v.id === 'today' && c.overdue > 0,
)));
const catItems = state.categories.map((cat) => navItem(
state.view === 'cat' && state.categoryId === cat.id, null, cat.name, c.cats[cat.id], () => setView('cat', cat.id), cat.color,
));
if (c.cats[0]) {
catItems.push(navItem(state.view === 'cat' && state.categoryId === 0, null, 'No category', c.cats[0],
() => setView('cat', 0), 'var(--muted)'));
}
$('#cats').replaceChildren(...catItems);
let title = (VIEWS.find((v) => v.id === state.view) || {}).label;
if (state.view === 'cat') title = state.categoryId ? (catById(state.categoryId) || {}).name : 'No category';
$('#view-title').textContent = title || '';
document.title = `${title} · WhatToDo`;
$('#quick-add').hidden = state.view === 'done';
const groups = buildGroups(viewTasks()).filter((g) => g.tasks.length);
if (!groups.length) {
const msg = state.search ? 'No tasks match your search.'
: state.view === 'done' ? 'Nothing completed yet.'
: state.view === 'today' ? 'Nothing due today. 🎉' : 'No tasks here.';
$('#list').replaceChildren(h('div', { class: 'empty' }, msg));
return;
}
$('#list').replaceChildren(...groups.map((g) => h('section', { class: 'group' },
h('h3', { class: 'group-title' + (g.title === 'Overdue' ? ' overdue' : '') }, g.title, h('span', { class: 'muted' }, ` ${g.tasks.length}`)),
g.tasks.map(taskRow))));
}
function taskRow(task) {
const meta = [];
const cat = catById(task.category_id);
if (cat && state.view !== 'cat') {
meta.push(h('span', { class: 'chip' }, h('span', { class: 'dot', style: { background: cat.color } }), cat.name));
}
if (task.due_date) {
const d = dueInfo(task.due_date);
meta.push(h('span', { class: `chip due ${task.done ? '' : d.cls}` }, '📅 ', d.text));
}
if (task.priority) meta.push(h('span', { class: `chip prio-label p${task.priority}` }, '⚑ ', PRIORITY_LABELS[task.priority]));
if (task.recurrence !== 'none') {
const unit = REPEAT_UNITS[task.recurrence];
meta.push(h('span', { class: 'chip' }, '↻ ', task.recur_interval > 1 ? `every ${task.recur_interval} ${unit}s` : `every ${unit}`));
}
if (task.subtasks.length) {
const doneCount = task.subtasks.filter((s) => s.done).length;
meta.push(h('span', { class: 'chip' }, `☑ ${doneCount}/${task.subtasks.length}`));
}
if (task.notes.trim()) meta.push(h('span', { class: 'chip', title: 'Has notes' }, '📝'));
for (const tag of task.tags) {
meta.push(h('button', {
type: 'button', class: 'chip tag',
onclick: (e) => { e.stopPropagation(); setSearch(`#${tag}`); },
}, `#${tag}`));
}
return h('div', {
class: `task p${task.priority}` + (task.done ? ' done' : ''),
tabindex: '0',
onclick: () => openTask(task),
onkeydown: (e) => { if (e.key === 'Enter' && e.target === e.currentTarget) openTask(task); },
},
h('button', {
type: 'button', class: 'check', 'aria-label': task.done ? 'Mark as not done' : 'Mark as done',
onclick: (e) => { e.stopPropagation(); toggleDone(task); },
}, task.done ? '✓' : ''),
h('div', { class: 'task-body' },
h('div', { class: 'task-title' }, task.title),
meta.length ? h('div', { class: 'task-meta' }, meta) : null));
}
// --- Actions ------------------------------------------------------------------
function setView(view, categoryId = null) {
state.view = view;
state.categoryId = categoryId;
savePrefs();
document.body.classList.remove('nav-open');
render();
$('#list').scrollTop = 0;
}
function setSearch(text) {
state.search = text;
$('#search').value = text;
render();
}
function toggleDone(task) {
if (task.done) {
act(() => api(`api/tasks/${task.id}/reopen`, { method: 'POST' }));
return;
}
act(async () => {
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}.` : '';
toast(`Done: ${task.title}.${extra}`, {
label: 'Undo',
fn: () => act(async () => {
if (res.next) await api(`api/tasks/${res.next.id}`, { method: 'DELETE' });
await api(`api/tasks/${task.id}/reopen`, { method: 'POST' });
}),
});
});
}
function quickAdd(title) {
const body = { title };
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);
act(() => api('api/tasks', { method: 'POST', body }));
}
function toast(message, action) {
const el = $('#toast');
el.replaceChildren(h('span', {}, message), action ? h('button', {
type: 'button', class: 'link',
onclick: () => { el.hidden = true; action.fn(); },
}, action.label) : null);
el.hidden = false;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { el.hidden = true; }, action ? 6000 : 4000);
}
// --- Task dialog --------------------------------------------------------------
function fillCategorySelect(select, selected) {
select.replaceChildren(
h('option', { value: '' }, '— None —'),
...state.categories.map((c) => h('option', { value: String(c.id) }, c.name)),
);
select.value = selected ? String(selected) : '';
}
function updateIntervalVisibility() {
$('#t-interval-wrap').hidden = $('#t-repeat').value === 'none';
}
function openTask(task, defaults = {}) {
editing = task;
const t = task || {
title: '', notes: '', category_id: null, priority: 0, due_date: null,
tags: [], recurrence: 'none', recur_interval: 1, subtasks: [], ...defaults,
};
$('#task-dialog-title').textContent = task ? 'Edit task' : 'New task';
$('#t-title').value = t.title;
fillCategorySelect($('#t-category'), t.category_id);
$('#t-priority').value = String(t.priority);
$('#t-due').value = t.due_date || '';
$('#t-repeat').value = t.recurrence;
$('#t-interval').value = t.recur_interval;
$('#t-tags').value = t.tags.join(', ');
$('#t-notes').value = t.notes;
$('#t-subtask-new').value = '';
$('#t-delete').hidden = !task;
draftSubtasks = t.subtasks.map((s) => ({ title: s.title, done: s.done }));
renderDraftSubtasks();
updateIntervalVisibility();
$('#task-dialog').showModal();
if (!task) $('#t-title').focus();
}
function renderDraftSubtasks() {
$('#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: 'text', value: s.title, maxlength: '500', oninput: (e) => { s.title = e.target.value; } }),
h('button', {
type: 'button', class: 'icon-btn', 'aria-label': 'Remove step',
onclick: () => { draftSubtasks.splice(i, 1); renderDraftSubtasks(); },
}, '✕'))));
}
function addDraftSubtask() {
const input = $('#t-subtask-new');
const title = input.value.trim();
if (!title) return;
draftSubtasks.push({ title, done: false });
input.value = '';
renderDraftSubtasks();
input.focus();
}
function saveTask(e) {
e.preventDefault();
addDraftSubtask();
const body = {
title: $('#t-title').value.trim(),
category_id: $('#t-category').value ? Number($('#t-category').value) : null,
priority: Number($('#t-priority').value),
due_date: $('#t-due').value || null,
recurrence: $('#t-repeat').value,
recur_interval: Math.max(1, Number($('#t-interval').value) || 1),
tags: $('#t-tags').value.split(',').map((s) => s.trim()).filter(Boolean),
notes: $('#t-notes').value,
subtasks: draftSubtasks.map((s) => ({ title: s.title.trim(), done: s.done })).filter((s) => s.title),
};
if (!body.title) return;
const task = editing;
$('#task-dialog').close();
act(() => (task
? api(`api/tasks/${task.id}`, { method: 'PATCH', body })
: api('api/tasks', { method: 'POST', body })));
}
function deleteTask() {
const task = editing;
if (!task || !confirm(`Delete "${task.title}"?`)) return;
$('#task-dialog').close();
act(() => api(`api/tasks/${task.id}`, { method: 'DELETE' }));
}
// --- Settings dialog ----------------------------------------------------------
function renderSettingsCategories() {
const cats = state.categories;
const patch = (cat, body) => act(async () => { await api(`api/categories/${cat.id}`, { method: 'PATCH', body }); })
.then(renderSettingsCategories);
const move = (i, dir) => {
const other = cats[i + dir];
if (!other) return;
const order = cats.map((c) => c.id);
[order[i], order[i + dir]] = [order[i + dir], order[i]];
act(() => Promise.all(order.map((id, pos) => api(`api/categories/${id}`, { method: 'PATCH', body: { position: pos } }))))
.then(renderSettingsCategories);
};
$('#s-cats').replaceChildren(...cats.map((cat, i) => h('li', { class: 'cat-edit' },
h('input', { type: 'color', value: cat.color, 'aria-label': 'Color', onchange: (e) => patch(cat, { color: e.target.value }) }),
h('input', {
type: 'text', value: cat.name, maxlength: '50', 'aria-label': 'Name',
onchange: (e) => { const name = e.target.value.trim(); if (name) patch(cat, { name }); },
}),
h('button', { type: 'button', class: 'icon-btn', 'aria-label': 'Move up', disabled: i === 0, onclick: () => move(i, -1) }, '↑'),
h('button', { type: 'button', class: 'icon-btn', 'aria-label': 'Move down', disabled: i === cats.length - 1, onclick: () => move(i, 1) }, '↓'),
h('button', {
type: 'button', class: 'icon-btn danger', 'aria-label': 'Delete category',
onclick: () => {
if (!confirm(`Delete category "${cat.name}"? Its tasks are kept but will have no category.`)) return;
act(() => api(`api/categories/${cat.id}`, { method: 'DELETE' })).then(renderSettingsCategories);
},
}, '🗑'))));
}
async function importFile() {
const file = $('#s-import-file').files[0];
if (!file) { toast('Choose an export file first.'); return; }
const mode = $('#s-import-mode').value;
let data;
try {
data = JSON.parse(await file.text());
} catch {
toast('That file is not valid JSON.');
return;
}
const n = Array.isArray(data.tasks) ? data.tasks.length : 0;
const warning = mode === 'replace'
? `REPLACE: this deletes ALL current tasks and categories and loads ${n} tasks from the file. Continue?`
: `MERGE: add ${n} tasks from the file to your current data. Continue?`;
if (!confirm(warning)) return;
await act(async () => {
const res = await api(`api/import?mode=${mode}`, { method: 'POST', body: data });
toast(`Imported ${res.tasks} tasks and ${res.categories} categories (${res.mode}).`);
});
$('#s-import-file').value = '';
renderSettingsCategories();
}
// --- Login --------------------------------------------------------------------
function showLogin() {
$('#app').hidden = true;
$('#login').hidden = false;
for (const d of document.querySelectorAll('dialog[open]')) d.close();
$('#login-password').focus();
}
async function showApp() {
await loadAll();
$('#login').hidden = true;
$('#app').hidden = false;
render();
}
// --- Wiring -------------------------------------------------------------------
function bind() {
$('#login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const err = $('#login-error');
err.hidden = true;
try {
await api('api/login', { method: 'POST', body: { password: $('#login-password').value } });
$('#login-password').value = '';
await showApp();
} catch (ex) {
err.textContent = ex.message;
err.hidden = false;
}
});
$('#quick-add').addEventListener('submit', (e) => {
e.preventDefault();
const title = $('#quick-title').value.trim();
if (!title) return;
$('#quick-title').value = '';
quickAdd(title);
});
$('#new-detailed').addEventListener('click', () => {
const defaults = {};
if (state.view === 'cat' && state.categoryId) defaults.category_id = state.categoryId;
if (state.view === 'today') defaults.due_date = todayStr();
defaults.title = $('#quick-title').value.trim();
$('#quick-title').value = '';
openTask(null, defaults);
});
$('#search').addEventListener('input', (e) => { state.search = e.target.value; render(); });
$('#menu-btn').addEventListener('click', () => document.body.classList.add('nav-open'));
$('#scrim').addEventListener('click', () => document.body.classList.remove('nav-open'));
// Task dialog
$('#task-form').addEventListener('submit', saveTask);
$('#t-cancel').addEventListener('click', () => $('#task-dialog').close());
$('#t-delete').addEventListener('click', deleteTask);
$('#t-repeat').addEventListener('change', updateIntervalVisibility);
$('#t-subtask-add').addEventListener('click', addDraftSubtask);
$('#t-subtask-new').addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); addDraftSubtask(); }
});
for (const btn of document.querySelectorAll('.quick-dates .chip-btn')) {
btn.addEventListener('click', () => {
$('#t-due').value = btn.dataset.days === '' ? '' : addDays(todayStr(), Number(btn.dataset.days));
});
}
// Settings dialog
$('#settings-btn').addEventListener('click', () => {
document.body.classList.remove('nav-open');
renderSettingsCategories();
$('#settings-dialog').showModal();
});
$('#s-close').addEventListener('click', () => $('#settings-dialog').close());
$('#s-cat-add').addEventListener('submit', (e) => {
e.preventDefault();
const name = $('#s-cat-name').value.trim();
if (!name) return;
act(() => api('api/categories', { method: 'POST', body: { name, color: $('#s-cat-color').value } }))
.then(() => { $('#s-cat-name').value = ''; renderSettingsCategories(); });
});
$('#s-import-btn').addEventListener('click', importFile);
$('#s-logout').addEventListener('click', async () => {
try { await api('api/logout', { method: 'POST' }); } catch { /* ignore */ }
$('#settings-dialog').close();
showLogin();
});
// Clicking the backdrop closes a dialog.
for (const dlg of document.querySelectorAll('dialog')) {
dlg.addEventListener('click', (e) => { if (e.target === dlg) dlg.close(); });
}
// Keyboard shortcuts: "/" search, "n" new task.
document.addEventListener('keydown', (e) => {
if (document.querySelector('dialog[open]') || $('#app').hidden) return;
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement.tagName)) return;
if (e.key === '/') { e.preventDefault(); $('#search').focus(); }
if (e.key === 'n') { e.preventDefault(); $('#quick-title').focus(); }
});
// Refresh when returning to the app (e.g. phone unlocked, day changed).
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && !$('#app').hidden) act(async () => {});
});
}
async function init() {
loadPrefs();
bind();
if ('serviceWorker' in navigator) navigator.serviceWorker.register('sw.js').catch(() => {});
try {
await showApp();
} catch (e) {
// A 401 already switched to the login screen; anything else is worth reporting.
if ($('#login').hidden) toast(`Could not load: ${e.message}`);
}
}
init();
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" rx="22" fill="#4f46e5"/>
<path d="M28 53 L44 68 L73 36" fill="none" stroke="#fff" stroke-width="9" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 253 B

+172
View File
@@ -0,0 +1,172 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#4f46e5">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="WhatToDo">
<title>WhatToDo</title>
<link rel="manifest" href="manifest.webmanifest">
<link rel="icon" href="icons/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="icons/icon-180.png">
<link rel="stylesheet" href="style.css">
<script src="app.js" defer></script>
</head>
<body>
<div id="login" class="login" hidden>
<form id="login-form" class="login-card">
<img src="icons/icon.svg" alt="" width="56" height="56">
<h1>WhatToDo</h1>
<input type="password" id="login-password" placeholder="Password" autocomplete="current-password" required>
<button type="submit" class="btn primary">Log in</button>
<p id="login-error" class="error" hidden></p>
</form>
</div>
<div id="app" class="app" hidden>
<aside id="sidebar" class="sidebar">
<div class="brand"><img src="icons/icon.svg" alt="" width="28" height="28"> WhatToDo</div>
<nav id="views" class="nav"></nav>
<div class="nav-heading">Categories</div>
<nav id="cats" class="nav"></nav>
<div class="sidebar-foot">
<button id="settings-btn" class="nav-item" type="button"><span class="nav-icon">⚙️</span> Settings</button>
</div>
</aside>
<div id="scrim" class="scrim"></div>
<main class="main">
<header class="topbar">
<button id="menu-btn" class="icon-btn menu-btn" type="button" aria-label="Open menu">☰</button>
<h2 id="view-title"></h2>
<input id="search" type="search" placeholder="Search or #tag" autocomplete="off">
</header>
<form id="quick-add" class="quick-add">
<input id="quick-title" placeholder="Add a task…" autocomplete="off" maxlength="500">
<button class="btn primary" type="submit">Add</button>
<button id="new-detailed" class="btn" type="button" title="New task with details">+ Details</button>
</form>
<div id="list" class="list"></div>
</main>
</div>
<dialog id="task-dialog">
<form id="task-form" class="dialog-body">
<h3 id="task-dialog-title">Task</h3>
<label class="field">
<span>Title</span>
<input id="t-title" required maxlength="500" autocomplete="off">
</label>
<div class="row">
<label class="field">
<span>Category</span>
<select id="t-category"></select>
</label>
<label class="field">
<span>Priority</span>
<select id="t-priority">
<option value="0">None</option>
<option value="1">Low</option>
<option value="2">Medium</option>
<option value="3">High</option>
</select>
</label>
</div>
<div class="row">
<label class="field">
<span>Deadline</span>
<input id="t-due" type="date">
</label>
<label class="field">
<span>Repeat</span>
<select id="t-repeat">
<option value="none">Never</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
<option value="yearly">Yearly</option>
</select>
</label>
<label class="field narrow" id="t-interval-wrap">
<span>Every</span>
<input id="t-interval" type="number" min="1" max="365" value="1">
</label>
</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>
<label class="field">
<span>Tags <small>(comma separated)</small></span>
<input id="t-tags" placeholder="e.g. editing, urgent" autocomplete="off">
</label>
<label class="field">
<span>Notes</span>
<textarea id="t-notes" rows="4" maxlength="20000"></textarea>
</label>
<div class="field">
<span>Checklist</span>
<ul id="t-subtasks" class="subtasks"></ul>
<div class="subtask-add">
<input id="t-subtask-new" placeholder="Add a step and press Enter" autocomplete="off" maxlength="500">
<button id="t-subtask-add" type="button" class="btn">Add</button>
</div>
</div>
<div class="dialog-actions">
<button id="t-delete" type="button" class="btn danger">Delete</button>
<span class="spacer"></span>
<button id="t-cancel" type="button" class="btn">Cancel</button>
<button type="submit" class="btn primary">Save</button>
</div>
</form>
</dialog>
<dialog id="settings-dialog">
<div class="dialog-body">
<h3>Settings</h3>
<section class="settings-section">
<h4>Categories</h4>
<ul id="s-cats" class="cat-edit-list"></ul>
<form id="s-cat-add" class="cat-add">
<input id="s-cat-color" type="color" value="#0ea5e9" aria-label="Color">
<input id="s-cat-name" placeholder="New category" maxlength="50" required autocomplete="off">
<button class="btn" type="submit">Add</button>
</form>
</section>
<section class="settings-section">
<h4>Export / import</h4>
<p class="muted">Export downloads all tasks and categories as a JSON file. Import it on another
install, such as production, to move your data there.</p>
<div class="row wrap">
<a id="s-export" class="btn primary" href="api/export" download>Export data</a>
</div>
<div class="row wrap import-row">
<input id="s-import-file" type="file" accept="application/json,.json">
<select id="s-import-mode" aria-label="Import mode">
<option value="merge">Merge (add to current data)</option>
<option value="replace">Replace (delete current data first)</option>
</select>
<button id="s-import-btn" class="btn" type="button">Import</button>
</div>
</section>
<section class="settings-section">
<h4>Session</h4>
<button id="s-logout" class="btn" type="button">Log out</button>
</section>
<div class="dialog-actions">
<span class="spacer"></span>
<button id="s-close" class="btn primary" type="button">Done</button>
</div>
</div>
</dialog>
<div id="toast" class="toast" hidden></div>
</body>
</html>
+16
View File
@@ -0,0 +1,16 @@
{
"name": "WhatToDo",
"short_name": "WhatToDo",
"description": "Personal task list",
"start_url": "./",
"scope": "./",
"display": "standalone",
"background_color": "#f5f6fa",
"theme_color": "#4f46e5",
"icons": [
{ "src": "icons/icon.svg", "sizes": "any", "type": "image/svg+xml" },
{ "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
+237
View File
@@ -0,0 +1,237 @@
:root {
--bg: #f5f6fa;
--panel: #ffffff;
--panel-2: #f0f2f7;
--text: #1d2130;
--muted: #6b7183;
--border: #e2e5ee;
--accent: #4f46e5;
--accent-text: #ffffff;
--accent-weak: #ecedff;
--danger: #dc2626;
--overdue: #dc2626;
--today: #d97706;
--ok: #16a34a;
--p1: #60a5fa;
--p2: #f59e0b;
--p3: #ef4444;
--radius: 10px;
--shadow: 0 1px 2px rgba(16, 24, 40, 0.06);
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #111319;
--panel: #1a1d26;
--panel-2: #222633;
--text: #e6e8ef;
--muted: #9399ad;
--border: #2c303d;
--accent: #818cf8;
--accent-text: #0f1020;
--accent-weak: #262a45;
--danger: #f87171;
--overdue: #f87171;
--today: #fbbf24;
--ok: #4ade80;
--shadow: none;
color-scheme: dark;
}
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
html, body { margin: 0; height: 100%; }
body {
background: var(--bg);
color: var(--text);
font: 15px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
-webkit-tap-highlight-color: transparent;
}
button, input, select, textarea { font: inherit; color: inherit; }
input, select, textarea {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 9px 11px;
min-width: 0;
width: 100%;
}
input:focus, select:focus, textarea:focus { outline: 2px solid var(--accent); outline-offset: -1px; border-color: transparent; }
input[type="checkbox"] { width: 18px; height: 18px; accent-color: var(--accent); flex: none; }
input[type="color"] { width: 40px; height: 38px; padding: 3px; flex: none; cursor: pointer; }
textarea { resize: vertical; }
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
padding: 9px 14px; border-radius: 8px; border: 1px solid var(--border);
background: var(--panel); cursor: pointer; text-decoration: none; white-space: nowrap;
}
.btn:hover { background: var(--panel-2); }
.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
.btn.primary:hover { filter: brightness(1.08); }
.btn.danger { color: var(--danger); }
.icon-btn {
background: none; border: none; cursor: pointer; padding: 6px 8px; border-radius: 6px;
color: var(--muted); flex: none;
}
.icon-btn:hover:not(:disabled) { background: var(--panel-2); color: var(--text); }
.icon-btn:disabled { opacity: 0.3; cursor: default; }
.icon-btn.danger:hover { color: var(--danger); }
.link { background: none; border: none; color: var(--accent); font-weight: 600; cursor: pointer; padding: 0 4px; }
.muted { color: var(--muted); }
.error { color: var(--danger); margin: 0; }
/* Login */
.login { min-height: 100%; display: grid; place-items: center; padding: 16px; }
.login-card {
width: 100%; max-width: 340px; background: var(--panel); border: 1px solid var(--border);
border-radius: 16px; padding: 28px; display: flex; flex-direction: column; gap: 12px;
align-items: center; box-shadow: var(--shadow);
}
.login-card h1 { margin: 0 0 8px; font-size: 22px; }
.login-card .btn { width: 100%; }
/* App layout */
.app { display: grid; grid-template-columns: 250px 1fr; height: 100dvh; }
.sidebar {
background: var(--panel); border-right: 1px solid var(--border);
display: flex; flex-direction: column; padding: 16px 10px; overflow-y: auto;
padding-top: max(16px, env(safe-area-inset-top));
}
.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-heading { font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); padding: 18px 10px 6px; }
.nav-item {
display: flex; align-items: center; gap: 10px; width: 100%; text-align: left;
background: none; border: none; border-radius: 8px; padding: 8px 10px; cursor: pointer;
}
.nav-item:hover { background: var(--panel-2); }
.nav-item.active { background: var(--accent-weak); color: var(--accent); font-weight: 600; }
.nav-icon { width: 20px; text-align: center; }
.nav-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.count { font-size: 12px; color: var(--muted); background: var(--panel-2); border-radius: 999px; padding: 1px 8px; }
.count.alert { background: var(--overdue); color: #fff; }
.dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; flex: none; margin: 0 5px; }
.chip .dot { width: 8px; height: 8px; margin: 0 2px 0 0; }
.sidebar-foot { margin-top: auto; padding-top: 16px; }
.scrim { display: none; }
.main { display: flex; flex-direction: column; min-width: 0; height: 100dvh; }
.topbar {
display: flex; align-items: center; gap: 12px; padding: 16px 24px 8px;
padding-top: max(16px, env(safe-area-inset-top));
}
.topbar h2 { margin: 0; font-size: 22px; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.topbar input[type="search"] { max-width: 260px; }
.menu-btn { display: none; font-size: 20px; color: var(--text); }
.quick-add { display: flex; gap: 8px; padding: 8px 24px 12px; }
.list { flex: 1; overflow-y: auto; padding: 0 24px 40px; padding-bottom: max(40px, env(safe-area-inset-bottom)); }
.group { margin-top: 14px; }
.group-title { font-size: 13px; text-transform: uppercase; letter-spacing: 0.05em; margin: 0 0 8px; color: var(--muted); }
.group-title.overdue { color: var(--overdue); }
.task {
display: flex; gap: 12px; align-items: flex-start;
background: var(--panel); border: 1px solid var(--border); border-left: 4px solid var(--border);
border-radius: var(--radius); padding: 11px 12px; margin-bottom: 8px; cursor: pointer; box-shadow: var(--shadow);
}
.task:hover { border-color: var(--accent); }
.task:focus-visible { outline: 2px solid var(--accent); }
.task.p1 { border-left-color: var(--p1); }
.task.p2 { border-left-color: var(--p2); }
.task.p3 { border-left-color: var(--p3); }
.check {
flex: none; width: 24px; height: 24px; margin-top: 1px; border-radius: 50%;
border: 2px solid var(--muted); background: none; cursor: pointer; padding: 0;
color: #fff; font-size: 14px; line-height: 1; display: grid; place-items: center;
}
.check:hover { border-color: var(--ok); background: color-mix(in srgb, var(--ok) 15%, transparent); }
.task.done .check { background: var(--ok); border-color: var(--ok); }
.task.done .task-title { text-decoration: line-through; color: var(--muted); }
.task-body { flex: 1; min-width: 0; }
.task-title { overflow-wrap: anywhere; }
.task-meta { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
.chip {
display: inline-flex; align-items: center; font-size: 12px; color: var(--muted);
background: var(--panel-2); border: none; border-radius: 999px; padding: 2px 8px; white-space: nowrap;
}
.chip.tag { cursor: pointer; color: var(--accent); }
.chip.tag:hover { background: var(--accent-weak); }
.chip.due.overdue { color: #fff; background: var(--overdue); }
.chip.due.today { color: var(--today); font-weight: 600; }
.chip.p3 { color: var(--p3); }
.chip.p2 { color: var(--p2); }
.empty { text-align: center; color: var(--muted); padding: 60px 16px; font-size: 16px; }
/* Dialogs */
dialog {
border: none; border-radius: 14px; padding: 0; width: min(560px, calc(100vw - 24px));
max-height: calc(100dvh - 24px); background: var(--panel); color: var(--text);
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.3);
}
dialog::backdrop { background: rgba(10, 12, 20, 0.5); }
.dialog-body { display: flex; flex-direction: column; gap: 14px; padding: 20px; }
.dialog-body h3 { margin: 0; font-size: 19px; }
.field { display: flex; flex-direction: column; gap: 5px; flex: 1; min-width: 0; }
.field > span { font-size: 13px; color: var(--muted); font-weight: 600; }
.field.narrow { flex: 0 0 80px; }
.row { display: flex; gap: 12px; }
.row.wrap { flex-wrap: wrap; align-items: center; }
.quick-dates { display: flex; flex-wrap: wrap; gap: 6px; margin-top: -6px; }
.chip-btn {
font-size: 13px; background: var(--panel-2); border: 1px solid var(--border);
border-radius: 999px; padding: 4px 10px; cursor: pointer;
}
.chip-btn:hover { border-color: var(--accent); }
.subtasks { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.subtask { display: flex; align-items: center; gap: 8px; }
.subtask input[type="text"] { padding: 6px 9px; }
.subtask-add { display: flex; gap: 8px; }
.dialog-actions { display: flex; gap: 8px; align-items: center; padding-top: 4px; }
.spacer { flex: 1; }
.settings-section { border-top: 1px solid var(--border); padding-top: 12px; }
.settings-section h4 { margin: 0 0 10px; }
.settings-section p { margin: 0 0 10px; font-size: 14px; }
.cat-edit-list { list-style: none; margin: 0 0 10px; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.cat-edit, .cat-add { display: flex; align-items: center; gap: 6px; }
.import-row { margin-top: 10px; }
.import-row input[type="file"] { flex: 1 1 200px; padding: 6px; }
.import-row select { flex: 1 1 200px; width: auto; }
.toast {
position: fixed; left: 50%; transform: translateX(-50%);
bottom: max(20px, env(safe-area-inset-bottom)); z-index: 50;
background: #1d2130; color: #fff; padding: 10px 16px; border-radius: 10px;
display: flex; gap: 12px; align-items: center; max-width: calc(100vw - 32px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
}
.toast .link { color: #a5b4fc; }
/* Mobile */
@media (max-width: 800px) {
.app { grid-template-columns: 1fr; }
.sidebar {
position: fixed; inset: 0 auto 0 0; width: 280px; max-width: 85vw; z-index: 30;
transform: translateX(-100%); transition: transform 0.2s ease;
}
body.nav-open .sidebar { transform: none; box-shadow: 0 0 40px rgba(0, 0, 0, 0.3); }
body.nav-open .scrim { display: block; position: fixed; inset: 0; z-index: 20; background: rgba(10, 12, 20, 0.4); }
.menu-btn { display: block; }
.topbar { padding: 12px 16px 6px; padding-top: max(12px, env(safe-area-inset-top)); flex-wrap: wrap; }
.topbar h2 { font-size: 20px; }
.topbar input[type="search"] { max-width: none; flex-basis: 100%; order: 3; }
.quick-add { padding: 6px 16px 10px; }
#new-detailed { padding: 9px 10px; }
.list { padding: 0 16px 40px; }
.row { flex-wrap: wrap; }
.row .field { flex: 1 1 140px; }
.row .field.narrow { flex: 0 0 80px; }
.dialog-body { padding: 16px; }
}
+35
View File
@@ -0,0 +1,35 @@
// Network-first service worker: always fresh when online, app shell still opens offline.
const CACHE = 'whattodo-v1';
const SHELL = [
'./', 'index.html', 'style.css', 'app.js', 'manifest.webmanifest',
'icons/icon.svg', 'icons/icon-192.png', 'icons/icon-512.png',
];
self.addEventListener('install', (event) => {
event.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting()));
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys()
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
.then(() => self.clients.claim()),
);
});
self.addEventListener('fetch', (event) => {
const req = event.request;
const url = new URL(req.url);
if (req.method !== 'GET' || url.origin !== self.location.origin || url.pathname.includes('/api/')) return;
event.respondWith(
fetch(req)
.then((res) => {
if (res.ok) {
const copy = res.clone();
caches.open(CACHE).then((c) => c.put(req, copy));
}
return res;
})
.catch(() => caches.match(req).then((hit) => hit || caches.match('index.html'))),
);
});
+24
View File
@@ -0,0 +1,24 @@
services:
whattodo:
build:
context: .
target: runtime
image: whattodo: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
test:
build:
context: .
target: test
profiles: ["test"]
+6
View File
@@ -0,0 +1,6 @@
#!/bin/sh
# Make sure the mounted data directory is writable, then drop root privileges.
set -e
mkdir -p "$(dirname "$DB_PATH")"
chown -R app:app "$(dirname "$DB_PATH")"
exec setpriv --reuid=app --regid=app --init-groups "$@"
+2
View File
@@ -0,0 +1,2 @@
pytest>=8,<10
httpx>=0.27,<1
+2
View File
@@ -0,0 +1,2 @@
fastapi>=0.115,<1
uvicorn>=0.30,<1
+122
View File
@@ -0,0 +1,122 @@
import os
import tempfile
os.environ["DB_PATH"] = os.path.join(tempfile.mkdtemp(), "test.db")
os.environ["APP_PASSWORD"] = "test-password"
os.environ["SECRET_KEY"] = "test-secret"
import pytest # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
from app import main # noqa: E402
H = {"X-WhatToDo": "1"}
@pytest.fixture(scope="module")
def client():
with TestClient(main.app) as c:
assert c.post("/api/login", json={"password": "test-password"}, headers=H).status_code == 200
yield c
def test_requires_login():
with TestClient(main.app) as anon:
assert anon.get("/api/tasks").status_code == 401
assert anon.get("/api/health").status_code == 200
assert anon.get("/").status_code == 200 # static shell is public
def test_wrong_password():
with TestClient(main.app) as anon:
assert anon.post("/api/login", json={"password": "nope"}, headers=H).status_code == 401
def test_write_requires_header(client):
assert client.post("/api/tasks", json={"title": "x"}).status_code == 403
def test_default_categories(client):
names = [c["name"] for c in client.get("/api/categories").json()]
assert names[:4] == ["Work", "Life", "Personal", "YouTube"]
def test_task_crud(client):
cat = client.get("/api/categories").json()[0]
r = client.post("/api/tasks", headers=H, json={
"title": " Write script ", "category_id": cat["id"], "priority": 3,
"due_date": "2026-10-10", "tags": ["#Video", "video", " edit "],
"subtasks": [{"title": "outline"}, {"title": "draft"}],
})
assert r.status_code == 201, r.text
t = r.json()
assert t["title"] == "Write script"
assert t["tags"] == ["video", "edit"]
assert [s["title"] for s in t["subtasks"]] == ["outline", "draft"]
r = client.patch(f"/api/tasks/{t['id']}", headers=H, json={
"due_date": None, "subtasks": [{"title": "outline", "done": True}],
})
t2 = r.json()
assert t2["due_date"] is None and t2["priority"] == 3
assert t2["subtasks"] == [{"id": t2["subtasks"][0]["id"], "title": "outline", "done": True}]
assert client.post("/api/tasks", headers=H, json={"title": " "}).status_code == 422
assert client.post("/api/tasks", headers=H, json={"title": "x", "category_id": 9999}).status_code == 400
assert client.delete(f"/api/tasks/{t['id']}", headers=H).status_code == 204
assert client.patch(f"/api/tasks/{t['id']}", headers=H, json={"title": "x"}).status_code == 404
def test_recurring_completion(client):
t = client.post("/api/tasks", headers=H, json={
"title": "Upload video", "due_date": "2026-10-02", "recurrence": "weekly",
"subtasks": [{"title": "thumbnail", "done": True}],
}).json()
r = client.post(f"/api/tasks/{t['id']}/complete", headers=H, json={"today": "2026-10-07"}).json()
assert r["task"]["done"] is True
# Oct 2 + 1 week = Oct 9, which is after "today" (Oct 7).
assert r["next"]["due_date"] == "2026-10-09"
assert r["next"]["done"] is False
assert r["next"]["subtasks"][0]["done"] is False
reopened = client.post(f"/api/tasks/{t['id']}/reopen", headers=H).json()
assert reopened["done"] is False
def test_next_due_skips_past_and_clamps_month():
from datetime import date
assert main.next_due(date(2026, 9, 1), "daily", 1, date(2026, 10, 7)) == date(2026, 10, 8)
assert main.next_due(date(2026, 1, 31), "monthly", 1, date(2026, 1, 31)) == date(2026, 2, 28)
assert main.next_due(None, "yearly", 1, date(2026, 10, 7)) == date(2027, 10, 7)
def test_category_crud(client):
c = client.post("/api/categories", headers=H, json={"name": "Gym", "color": "#123456"}).json()
assert client.post("/api/categories", headers=H, json={"name": "gym", "color": "#123456"}).status_code == 409
t = client.post("/api/tasks", headers=H, json={"title": "Leg day", "category_id": c["id"]}).json()
assert client.patch(f"/api/categories/{c['id']}", headers=H, json={"name": "Fitness"}).json()["name"] == "Fitness"
client.delete(f"/api/categories/{c['id']}", headers=H)
tasks = {x["id"]: x for x in client.get("/api/tasks").json()}
assert tasks[t["id"]]["category_id"] is None
def test_export_import_roundtrip(client):
client.post("/api/tasks", headers=H, json={"title": "Export me", "category_id": 1, "tags": ["a"]})
exported = client.get("/api/export")
assert "attachment" in exported.headers["content-disposition"]
data = exported.json()
n = len(data["tasks"])
r = client.post("/api/import?mode=merge", headers=H, json=data).json()
assert r["tasks"] == n
assert len(client.get("/api/tasks").json()) == 2 * n
assert len(client.get("/api/categories").json()) == len(data["categories"]) # matched by name
client.post("/api/import?mode=replace", headers=H, json=data)
after = client.get("/api/tasks").json()
assert len(after) == n
cats = {c["id"]: c["name"] for c in client.get("/api/categories").json()}
exported_task = next(t for t in after if t["title"] == "Export me")
assert cats[exported_task["category_id"]] == "Work"
assert client.post("/api/import", headers=H, json={"app": "other", "version": 1}).status_code == 400
+65
View File
@@ -0,0 +1,65 @@
"""Generate the PNG app icons (pure Python, no dependencies).
Run inside Docker from the repo root:
docker run --rm --user "$(id -u):$(id -g)" -v "$PWD":/w -w /w python:3.13-slim python tools/make_icons.py
"""
import math
import struct
import zlib
from pathlib import Path
OUT = Path(__file__).resolve().parent.parent / "app" / "static" / "icons"
BG = (0x4F, 0x46, 0xE5)
FG = (0xFF, 0xFF, 0xFF)
# Check mark in unit coordinates, matching icon.svg.
POINTS = [(0.28, 0.53), (0.44, 0.68), (0.73, 0.36)]
STROKE = 0.09
def seg_dist(px, py, ax, ay, bx, by):
dx, dy = bx - ax, by - ay
t = max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy)))
return math.hypot(px - (ax + t * dx), py - (ay + t * dy))
def rounded_rect_alpha(x, y, size, radius):
# Signed-distance antialiasing for a rounded square covering the whole image.
cx = min(max(x, radius), size - radius)
cy = min(max(y, radius), size - radius)
d = math.hypot(x - cx, y - cy) - radius
return max(0.0, min(1.0, 0.5 - d))
def render(size, rounded, scale=1.0):
radius = size * 0.22 if rounded else 0
pts = [((0.5 + (x - 0.5) * scale) * size, (0.5 + (y - 0.5) * scale) * size) for x, y in POINTS]
half = STROKE * scale * size / 2
rows = []
for j in range(size):
row = bytearray([0])
for i in range(size):
x, y = i + 0.5, j + 0.5
a_bg = rounded_rect_alpha(x, y, size, radius) if rounded else 1.0
d = min(seg_dist(x, y, *pts[0], *pts[1]), seg_dist(x, y, *pts[1], *pts[2]))
a_fg = max(0.0, min(1.0, half - d + 0.5))
rgb = [round(b * (1 - a_fg) + f * a_fg) for b, f in zip(BG, FG)]
row += bytes(rgb) + bytes([round(255 * a_bg)])
rows.append(bytes(row))
return png(size, size, b"".join(rows))
def png(w, h, raw):
def chunk(tag, data):
return struct.pack(">I", len(data)) + tag + data + struct.pack(">I", zlib.crc32(tag + data))
ihdr = struct.pack(">IIBBBBB", w, h, 8, 6, 0, 0, 0)
return b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + chunk(b"IDAT", zlib.compress(raw, 9)) + chunk(b"IEND", b"")
if __name__ == "__main__":
OUT.mkdir(parents=True, exist_ok=True)
(OUT / "icon-192.png").write_bytes(render(192, rounded=True))
(OUT / "icon-512.png").write_bytes(render(512, rounded=True))
# Maskable/Apple icons: full-bleed background, check kept inside the safe zone.
(OUT / "icon-maskable-512.png").write_bytes(render(512, rounded=False, scale=0.8))
(OUT / "icon-180.png").write_bytes(render(180, rounded=False, scale=0.9))
print("icons written to", OUT)