From c5b049edba5dd0c2ed5e31b4b7bc875b112304f1 Mon Sep 17 00:00:00 2001 From: goyban Date: Wed, 7 Oct 2026 11:43:56 +0200 Subject: [PATCH] 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 --- .dockerignore | 6 + .env.example | 20 + .gitignore | 10 + Dockerfile | 26 ++ README.md | 83 ++++ app/db.py | 81 ++++ app/main.py | 527 +++++++++++++++++++++ app/static/app.js | 607 +++++++++++++++++++++++++ app/static/icons/icon-180.png | Bin 0 -> 1549 bytes app/static/icons/icon-192.png | Bin 0 -> 2454 bytes app/static/icons/icon-512.png | Bin 0 -> 7806 bytes app/static/icons/icon-maskable-512.png | Bin 0 -> 4842 bytes app/static/icons/icon.svg | 4 + app/static/index.html | 172 +++++++ app/static/manifest.webmanifest | 16 + app/static/style.css | 237 ++++++++++ app/static/sw.js | 35 ++ docker-compose.yml | 24 + docker-entrypoint.sh | 6 + requirements-dev.txt | 2 + requirements.txt | 2 + tests/test_api.py | 122 +++++ tools/make_icons.py | 65 +++ 23 files changed, 2045 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/db.py create mode 100644 app/main.py create mode 100644 app/static/app.js create mode 100644 app/static/icons/icon-180.png create mode 100644 app/static/icons/icon-192.png create mode 100644 app/static/icons/icon-512.png create mode 100644 app/static/icons/icon-maskable-512.png create mode 100644 app/static/icons/icon.svg create mode 100644 app/static/index.html create mode 100644 app/static/manifest.webmanifest create mode 100644 app/static/style.css create mode 100644 app/static/sw.js create mode 100644 docker-compose.yml create mode 100644 docker-entrypoint.sh create mode 100644 requirements-dev.txt create mode 100644 requirements.txt create mode 100644 tests/test_api.py create mode 100644 tools/make_icons.py 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 0000000000000000000000000000000000000000..84a10114f62b350ecafb6790681bb6b4029d134d GIT binary patch literal 1549 zcmai!dpHwn7{GPha}?!q%_T}stPZ)&<#ASX8O{p1%(X*oxs+(*5OY>XIUW{IgsBaW zLlkw`T;^6-$!^MIZl^*kJ9BAf#>Sb$)2YAu5haq6l+0-%KB=?erzjoH;4F{WSGqBQPMWHUY+Ks6T zT72o-uUT!Jr}5C2*jK4$bcX2Z?=A0R{++RrapbM}i^5Fal#qDiIyjGo67uNOQqH07 z1>Arvj}GMU3dxsn`+79JQk9GlS%h5}1tncMRW<9Mm@*()In}Lnhi|3Z{MvTe76)yY zZSl|TMu`90--C8$egW02KhOO7uI%Wk10FB1aFs%6V0F*~$hkU;=_nw3Sp6U()t%$# zqfDo)N5sWjdodHc0jJ%gySOub--CKP?}cg=T$(?1vqJ8RtTixTXM3|#1bspHj37C5P^kt zS{MZ$yE7blPi)KBg|_EVh4RD_bXU;rA}{H8!@UpSeR4$fZR4;b+#DXHwJfQ9fbHgm_@me7AD4o|HGA7ATrfB!* zgg8dkS*8o@%|IbN)aPb*;$kfgwFg$;v(=jioyKedIjvL0B-JKcrLn5uz#B7IZd%A# zLqF=O%C zS|e{EIn|2!CJw?XiAXz0#~C*&PJRHT)ch$c5X^yMTmiqXSB59|G%rM>>BO>eJC zDa#2@OfhqPPLk8l^*>vQ7`fY?7GpDRkIpA>66X~=xgIr8i}}g+G`J0eNO2iZf0O{~ zCWZ)ei`Zcy$U7VQWH-2t-MXP6$g$766zg`h&IiZi5bKvwZ0i`CO{C;XgW2GMQD~f9 zKwA41hAIxpYbkVUMFr3DFHmERrrfr-W10Av;PP#uy187XIlnhl@tT~m6p##^n`2`?FBovpHMWINaa;=N$W z0Zp(;M&C_VBd3B!o(gKN+%WfG6pLJ|o$EzK;iTpabX0$6R9U-E0CsINA8%D(O3=z{ zyPqWRggDQ&?SQ*%W@}8L7TMuexi8R*KcM&`dRl|@*BM-PpT4rltG`WPgORu`~ytSjfa0CD_bMDy|`?%!S zO^-E2s9^DHzXqHDg`mX43r%IpW0li2R4K3De#0bMy!>jSgAQLtHC6x2H27s+Bm27| zAe??VDWBjxASMRxZxKy@8c%tDKzy|wHGToV*4Q_dnZ=bTdtH?G UvlqK%NHs45afN~#PM*X33mz!rFaQ7m literal 0 HcmV?d00001 diff --git a/app/static/icons/icon-192.png b/app/static/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..7eecd2d596d7c4d32df411d114456ab190b290a8 GIT binary patch literal 2454 zcma)8c{r3^8-Je1$c*L9z9c&tB(h9|j3qNEni6HgShLI0NQ5axl#lj|!L-PdA$gH% z%y^Tmkv02}@ES2+y<pJ&+vT^PhDe)cR002lG!a5%p zB6DlOw+ZF(i6}7uK=>YVb~uqV_O)m*)g?GDK4CPS{pP;=6 zQ?E~sB*?WEv5OR|3x_%7i<`#3JE55&SLhFismiu$-?OUl@4@cyzFztd!=x7BSeWECT@e?NesGlJLjud%ufE@- zrrs5t6eTxP8Wj;VrQQMD)>ggv942ABEB<6l1$L&nQ$?6RDdo;rUn)!u_)0V zOL}<3Y^M9qrrqciCLksk5v)nk3x$P=e|mc<yszsLY1_a!5?l{I(0XMvS;EwLKPe1AOCgKeAw+TXO_ZlL@N0wh`4=n%(dS;r^cd@sA&n@vEQ}Z(7VmSp%}f|`Mf71Z zm zt;2$Lo1l$)fYLv605uEfPQMnU8wNw_wTPOQH%lvmw}gAQwIUQFJ#9*oVRd%R?m%P=lig2{oW z_wabUx$pRU8DJu4#Mt9Z$c3Ik{Lz&n2O##khOb?Kna9ErdE#8GkgHbxQa5BXHGat1 zlW?)&GQirVl)7+7g};!K{i1mzPyu90x!TQDHq0@y0qK-mXLs*V{W3<5>>Mt@q2+3+quE@%rJI1uF<`JIZH;N4~W$vJQ=Tb>^>* zW=#^5wpn+heum)fZBDl@2vn7y?V#;*Bfq;!u>{p$hLU-pX0gxtgJVxT7OJEKj~SG> zW(=HVFsbykdxTJpSKTZtA%D4xy=J1JL1;j2IQ?3>F|4omBk&?ds&@mU%1{*_rEbXx= z<2JBZN>r{y|XL z|F~MHN@J#8G|qC`*w+nHmU02!a_0jlF{NRRu_I4ix;n@(SGKzr5pRr6a z`s0Po6E5c61u)TXU%30lQ_0`sh{PUS!Hw4o4JKbp8c`bptJ4~fNtI1WH@$AAHFE`#n16VBgK7(xWnK$igrc}Jz#;CnlwMlbzm#bmKJMxKl>!2v& zn8&x?O-$E(zr-wk!ZXqAPamZq- zG&&R2Uq3xcY5F|emT5EEzGPCRR9qCwN|j-@X+O}VOr8(@{2@0wn7a@;ch_9<^--u@ z&hPXP56$nr86CnsOy|yPXRH(gFQ)wK)W>}pME^o>{s}K4bNeorEuJ|sAQtdqJm_^)+RFPgUW|z;dORivn);e**7XJ5<0d9H|Dl?E#}6289LJq}ImK_Lr!hWQ@+aM{)CxbOO6PHJXJPT-Y&f6$sT+sLEOsw6*0m< z=nc)?Zz*I^6Mx5rd+qKfG+XZbJN?0GkK4kEMU4C|O+9;XmF~3VG;=rY=5z*7*_3j! zZG(#)-fhem(E68W$=1nM z2zs&w#?TF6C+;kV=Rc95-gyEy#p<99GPKc`T}a1ewb5d^KM?tBE`22 zC_e+i^)TrqcY*;pwuPq+z$+jbR&0i|T~!s%rN_VL68e)<9n*X*%7R^p=#$TX6wX3a z#6Im(L@z#!VgU2Re@{d-cgdg^ zOAA=a^e^3sO%R-Kp|JBIW1^|r83?#G@7S$U8T`~p0d0l8t@U(g`MKg}G9(6!wPBJ@ zgJRtP_u}su1K@lXv*5K|1FYR%pB12DF{^bll=fZqD)3Xkt;!8;H=>Gv^kA|ahV-EZ zU6+rFA+9-F*Bz6{CzQ^oLeFb^r{%eC#-yp79y7n1D@ebfs7A{&lqmAzN5837{diHs z>^*-QrCplz1asatVyPKD%A1((D?52!e_tN*jcfbcd9I zbSTXdA`Q~H?=1J;_xC>U``7#Y^_{bG=FFK>bM`X<+M25DECMV5zdo$)<~^3Jv)sjR_?*jQ?_La|SCp0CCdTo{?5oeFA~L;r;bFCX&5gTaBkW_R z$0sz7VuU2T-6cGy7xb>iD3f#Avq&bY|g_JEV0|w}T_aGoY3`kh|_gB3%6*spKSC8~xGx z31~|vn44&%sm8v7o7m4)5q=2Oy~C&wz+5xr^S9;h~-P;ss?DZypF9p06^bb}@=g=3$+(EJ|L9 zf{=sejU-M+yiAyRgAWFGX+%_K6$fW*%yd}k$q$StJgci2adYZHl592eAxejue!Zr1 zV0)yO$_45J{sHb1M`53gWU??8f2U5(x!@ZFUDDM#O;5gCfNL7U(n)D*=xLxsPOSxO zlQ;}Ja&gH=uy_umCqKrXVzu`hWa;mNp(;-9hJtWt{p_SSiTq%B<3?636aJB1z4c?z zX3R3e!sp>D2|-Y3c}cShi+O=540RvI#5!u0{>V3m zhc~Tsis;G2W&f&UFPO=9^AieIoS@Fehq&{82oMCge|xAXMi0?io`IcuPd?Shp)nyVCG(xIpa3ik)x2DQNRuj z3V%T2!;#+tkmeu{+E;al#KB8BqwOQ$uiDjF+uLKyvHn<)&A>@w`mz2Pkaan^8;cLU z2jo}Q?_D9B!UeGs=B1lT z0yaCKKC1&B9~x=f3&l6yU~sy|Nw-11?*QV9GO2?P%?xOinxX^ppMGNTX;GISAp_K) zk$9>z$oIr!@p~eTzkt+tKQ#K5dIjY7H`$%W#Bvp8bt+APLslI<`BXsxH^#K&5gO?! z_d@KXB=EMl4>AL*9S(S#D>4|!p(X2#;OoObGn7G3{_`j&u-JXQhAn3iM8D?V$j04&L3aX~L2h%Rt+{C1fW=XB+`->joilNXes(#>)gjQ=P4o55#glru?KTL8RSf zz_D0}?1uPGE_hJUT1i)KD~N@Q;ofd=&Hgl+tmWhcK|3ZmbA6D4iT&;ecwP@q26Brn z1DqMomVro{eSkYS5|8Ou7sSBDg1^{7+KwL1n3)T}&mTc>y>^8@$`c4obAC=1>N*4~=
  • ~|wZ8@9P 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 Nsc4=_J7s$Je*q_gt6u;B literal 0 HcmV?d00001 diff --git a/app/static/icons/icon-maskable-512.png b/app/static/icons/icon-maskable-512.png new file mode 100644 index 0000000000000000000000000000000000000000..95c58daf06fb53202bf0adafd58a9082ab4b6fd7 GIT binary patch literal 4842 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 NcQ|b?+Ir~1e*k867QO%g literal 0 HcmV?d00001 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)