commit c5b049edba5dd0c2ed5e31b4b7bc875b112304f1 Author: goyban Date: Wed Oct 7 11:43:56 2026 +0200 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 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9f3f25e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.git +.env +data +**/__pycache__ +*.db +whattodo-export-*.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8a0f4d8 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..22dca19 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Secrets and local data: never commit +.env +data/ +*.db +*.db-* +whattodo-export-*.json + +__pycache__/ +*.pyc +.pytest_cache/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a01eda0 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..aab40e0 --- /dev/null +++ b/README.md @@ -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 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://: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 +``` diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..f8c3755 --- /dev/null +++ b/app/db.py @@ -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() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..05c5ba7 --- /dev/null +++ b/app/main.py @@ -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") diff --git a/app/static/app.js b/app/static/app.js new file mode 100644 index 0000000..944f0df --- /dev/null +++ b/app/static/app.js @@ -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(); diff --git a/app/static/icons/icon-180.png b/app/static/icons/icon-180.png new file mode 100644 index 0000000..84a1011 Binary files /dev/null and b/app/static/icons/icon-180.png differ diff --git a/app/static/icons/icon-192.png b/app/static/icons/icon-192.png new file mode 100644 index 0000000..7eecd2d Binary files /dev/null and b/app/static/icons/icon-192.png differ diff --git a/app/static/icons/icon-512.png b/app/static/icons/icon-512.png new file mode 100644 index 0000000..cc96b4c Binary files /dev/null and b/app/static/icons/icon-512.png differ diff --git a/app/static/icons/icon-maskable-512.png b/app/static/icons/icon-maskable-512.png new file mode 100644 index 0000000..95c58da Binary files /dev/null and b/app/static/icons/icon-maskable-512.png differ diff --git a/app/static/icons/icon.svg b/app/static/icons/icon.svg new file mode 100644 index 0000000..96c54f2 --- /dev/null +++ b/app/static/icons/icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/static/index.html b/app/static/index.html new file mode 100644 index 0000000..a2e726b --- /dev/null +++ b/app/static/index.html @@ -0,0 +1,172 @@ + + + + + + + + + WhatToDo + + + + + + + + + + + + +
+

Task

+ +
+ + +
+
+ + + +
+
+ + + + +
+ + +
+ Checklist +
    +
    + + +
    +
    +
    + + + + +
    +
    +
    + + +
    +

    Settings

    + +
    +

    Categories

    +
      +
      + + + +
      +
      + +
      +

      Export / import

      +

      Export downloads all tasks and categories as a JSON file. Import it on another + install, such as production, to move your data there.

      + +
      + + + +
      +
      + +
      +

      Session

      + +
      + +
      + + +
      +
      +
      + + + + diff --git a/app/static/manifest.webmanifest b/app/static/manifest.webmanifest new file mode 100644 index 0000000..b5ae8ea --- /dev/null +++ b/app/static/manifest.webmanifest @@ -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" } + ] +} diff --git a/app/static/style.css b/app/static/style.css new file mode 100644 index 0000000..9a4fa71 --- /dev/null +++ b/app/static/style.css @@ -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; } +} diff --git a/app/static/sw.js b/app/static/sw.js new file mode 100644 index 0000000..14ea266 --- /dev/null +++ b/app/static/sw.js @@ -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'))), + ); +}); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5d3bcde --- /dev/null +++ b/docker-compose.yml @@ -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"] diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..cc7efe1 --- /dev/null +++ b/docker-entrypoint.sh @@ -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 "$@" diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..c219f78 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +pytest>=8,<10 +httpx>=0.27,<1 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..65d99d9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +fastapi>=0.115,<1 +uvicorn>=0.30,<1 diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..ff92dcb --- /dev/null +++ b/tests/test_api.py @@ -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 diff --git a/tools/make_icons.py b/tools/make_icons.py new file mode 100644 index 0000000..6a70029 --- /dev/null +++ b/tools/make_icons.py @@ -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)