Initial WhatToDo app
Self-hosted personal task list: FastAPI + SQLite in Docker, mobile-friendly PWA frontend, deadlines, priorities, categories, tags, notes, checklists, recurring tasks, single-password login, and JSON export/import. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
.git
|
||||
.env
|
||||
data
|
||||
**/__pycache__
|
||||
*.db
|
||||
whattodo-export-*.json
|
||||
@@ -0,0 +1,20 @@
|
||||
# Copy to .env and fill in. .env is git-ignored: never commit it.
|
||||
|
||||
# Password for logging in to the app.
|
||||
APP_PASSWORD=change-me
|
||||
# Long random string used to sign login cookies. Generate one with:
|
||||
# head -c 32 /dev/urandom | base64
|
||||
SECRET_KEY=change-me
|
||||
|
||||
# true when the app is served over HTTPS (production). Keep false for http://localhost.
|
||||
COOKIE_SECURE=false
|
||||
# How long a login lasts, in days.
|
||||
SESSION_DAYS=30
|
||||
|
||||
# Port on the host, and which interface to listen on:
|
||||
# 127.0.0.1 = only this computer, 0.0.0.0 = also other devices on your network (phone).
|
||||
PORT=8080
|
||||
BIND_ADDR=127.0.0.1
|
||||
|
||||
# Behind a reverse proxy, the IPs allowed to set X-Forwarded-For (for login rate limiting).
|
||||
# FORWARDED_ALLOW_IPS=127.0.0.1
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
# Secrets and local data: never commit
|
||||
.env
|
||||
data/
|
||||
*.db
|
||||
*.db-*
|
||||
whattodo-export-*.json
|
||||
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
+26
@@ -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"]
|
||||
@@ -0,0 +1,83 @@
|
||||
# WhatToDo
|
||||
|
||||
A small, self-hosted to-do app for one person. It works in a desktop browser and on a phone, and you can install it to your home screen.
|
||||
|
||||
- Tasks have an optional **deadline**, **priority** (none / low / medium / high), **category**, **tags**, **notes**, and a **checklist** of steps.
|
||||
- **Recurring tasks** (every N days/weeks/months/years): completing one creates the next occurrence.
|
||||
- Views: **Today** (overdue, due today, high priority), **Upcoming**, **All open**, **Done**, plus one view per category.
|
||||
- You can rename, recolor, reorder, add, or delete categories in **Settings**. It starts with Work, Life, Personal, YouTube.
|
||||
- **Export / import** of all data as JSON, from **Settings**.
|
||||
- Login with a single password.
|
||||
|
||||
Stack: Python (FastAPI) + SQLite with a plain HTML/CSS/JS frontend and no build step. Everything runs in Docker.
|
||||
|
||||
## Run locally
|
||||
|
||||
Requirements: Docker with the Compose plugin. Nothing else is installed on the host.
|
||||
|
||||
```sh
|
||||
cp .env.example .env # then edit .env: set APP_PASSWORD and SECRET_KEY
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Open <http://localhost:8080> and log in with `APP_PASSWORD`.
|
||||
|
||||
The database lives in `./data/whattodo.db`, which is git-ignored.
|
||||
|
||||
Useful commands:
|
||||
|
||||
```sh
|
||||
docker compose logs -f # follow logs
|
||||
docker compose down # stop
|
||||
docker compose up -d --build # rebuild after pulling changes
|
||||
docker compose run --rm test # run the test suite (in Docker)
|
||||
```
|
||||
|
||||
### Testing on your phone (same Wi-Fi)
|
||||
|
||||
Set `BIND_ADDR=0.0.0.0` in `.env`, run `docker compose up -d`, then open `http://<this-computer's-LAN-IP>:8080` on the phone.
|
||||
Browsers only allow "Install app" / "Add to Home Screen" with offline support over **HTTPS** (or on `localhost`). Over plain LAN HTTP the app still works, but it won't install as a full app.
|
||||
|
||||
## Deploy to production
|
||||
|
||||
1. On the server, clone the repo and create `.env` from `.env.example`. Use a **new strong** `APP_PASSWORD` and `SECRET_KEY`, and set `COOKIE_SECURE=true`.
|
||||
2. `docker compose up -d --build`. The app listens on `127.0.0.1:${PORT}`.
|
||||
3. Put it behind a reverse proxy with HTTPS. For example, with Caddy:
|
||||
|
||||
```
|
||||
todo.example.com {
|
||||
reverse_proxy 127.0.0.1:8080
|
||||
}
|
||||
```
|
||||
|
||||
If the proxy runs in another container instead of on the host, set `BIND_ADDR`/networking to match and set `FORWARDED_ALLOW_IPS` to the proxy's IP. The app then sees the real client IP, which login rate limiting uses.
|
||||
|
||||
### Moving your data from this PC to production
|
||||
|
||||
1. Local app → **Settings → Export data**. This downloads `whattodo-export-YYYYMMDD-HHMM.json`.
|
||||
2. Production app → **Settings → Import**, pick the file:
|
||||
- **Replace** deletes everything on that install and loads the file. Use this for the first move to prod.
|
||||
- **Merge** adds the file's tasks to what is already there. Categories with the same name are reused.
|
||||
|
||||
The export file contains your tasks. Keep it out of the repo (`whattodo-export-*.json` is git-ignored).
|
||||
|
||||
### Backups
|
||||
|
||||
Export regularly from Settings, or copy `./data/whattodo.db` while the app is stopped.
|
||||
|
||||
## Security notes
|
||||
|
||||
- `.env` and `data/` are git-ignored. Never commit them; the repository is public.
|
||||
- Changing `APP_PASSWORD` or `SECRET_KEY` logs out all sessions.
|
||||
- Failed logins are rate limited: 10 failures per 15 minutes per IP.
|
||||
- Write requests require an `X-WhatToDo` header (CSRF protection). The app also sends a strict Content-Security-Policy.
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
app/main.py API: auth, tasks, categories, export/import
|
||||
app/db.py SQLite schema and setup
|
||||
app/static/ frontend (index.html, app.js, style.css, PWA files, icons)
|
||||
tests/ API tests (run with: docker compose run --rm test)
|
||||
tools/make_icons.py regenerates the PNG icons
|
||||
```
|
||||
@@ -0,0 +1,81 @@
|
||||
import os
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
DB_PATH = os.environ.get("DB_PATH", "/data/whattodo.db")
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
DEFAULT_CATEGORIES = [
|
||||
("Work", "#2563eb"),
|
||||
("Life", "#16a34a"),
|
||||
("Personal", "#9333ea"),
|
||||
("YouTube", "#dc2626"),
|
||||
]
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
color TEXT NOT NULL DEFAULT '#6b7280',
|
||||
position INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id INTEGER PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
due_date TEXT,
|
||||
tags TEXT NOT NULL DEFAULT '[]',
|
||||
recurrence TEXT NOT NULL DEFAULT 'none',
|
||||
recur_interval INTEGER NOT NULL DEFAULT 1,
|
||||
done INTEGER NOT NULL DEFAULT 0,
|
||||
done_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS subtasks (
|
||||
id INTEGER PRIMARY KEY,
|
||||
task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
done INTEGER NOT NULL DEFAULT 0,
|
||||
position INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_subtasks_task ON subtasks(task_id);
|
||||
"""
|
||||
|
||||
|
||||
def connect() -> sqlite3.Connection:
|
||||
# FastAPI may open a request's connection and run its endpoint on different threads;
|
||||
# each connection is still used by only one request at a time.
|
||||
conn = sqlite3.connect(DB_PATH, check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
return conn
|
||||
|
||||
|
||||
def init() -> None:
|
||||
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = connect()
|
||||
try:
|
||||
conn.execute("PRAGMA journal_mode = WAL")
|
||||
conn.executescript(SCHEMA)
|
||||
row = conn.execute("SELECT value FROM meta WHERE key = 'schema_version'").fetchone()
|
||||
if row is None:
|
||||
# Fresh database: seed default categories.
|
||||
with conn:
|
||||
for i, (name, color) in enumerate(DEFAULT_CATEGORIES):
|
||||
conn.execute(
|
||||
"INSERT INTO categories (name, color, position) VALUES (?, ?, ?)",
|
||||
(name, color, i),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO meta (key, value) VALUES ('schema_version', ?)",
|
||||
(str(SCHEMA_VERSION),),
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
+527
@@ -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")
|
||||
@@ -0,0 +1,607 @@
|
||||
'use strict';
|
||||
|
||||
const VIEWS = [
|
||||
{ id: 'today', label: 'Today', icon: '☀️' },
|
||||
{ id: 'upcoming', label: 'Upcoming', icon: '📅' },
|
||||
{ id: 'all', label: 'All open', icon: '📋' },
|
||||
{ id: 'done', label: 'Done', icon: '✅' },
|
||||
];
|
||||
const PRIORITY_LABELS = ['', 'Low', 'Medium', 'High'];
|
||||
const REPEAT_UNITS = { daily: 'day', weekly: 'week', monthly: 'month', yearly: 'year' };
|
||||
|
||||
const state = { tasks: [], categories: [], view: 'today', categoryId: null, search: '' };
|
||||
let editing = null; // task open in the dialog; null when creating
|
||||
let draftSubtasks = [];
|
||||
let toastTimer = null;
|
||||
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
|
||||
// --- Small DOM helper ---------------------------------------------------------
|
||||
|
||||
function h(tag, attrs, ...children) {
|
||||
const el = document.createElement(tag);
|
||||
for (const [k, v] of Object.entries(attrs || {})) {
|
||||
if (v == null || v === false) continue;
|
||||
if (k === 'class') el.className = v;
|
||||
else if (k === 'style') Object.assign(el.style, v);
|
||||
else if (k.startsWith('on')) el.addEventListener(k.slice(2), v);
|
||||
else if (k === 'value' || k === 'checked') el[k] = v;
|
||||
else el.setAttribute(k, v === true ? '' : v);
|
||||
}
|
||||
for (const c of children.flat()) {
|
||||
if (c != null && c !== false) el.append(c instanceof Node ? c : String(c));
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
// --- Dates (all local, as YYYY-MM-DD strings) ---------------------------------
|
||||
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
const fmtDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
const parseDate = (s) => { const [y, m, d] = s.split('-').map(Number); return new Date(y, m - 1, d); };
|
||||
const todayStr = () => fmtDate(new Date());
|
||||
const addDays = (s, n) => { const d = parseDate(s); d.setDate(d.getDate() + n); return fmtDate(d); };
|
||||
const dayDiff = (a, b) => {
|
||||
const [ay, am, ad] = a.split('-').map(Number);
|
||||
const [by, bm, bd] = b.split('-').map(Number);
|
||||
return Math.round((Date.UTC(by, bm - 1, bd) - Date.UTC(ay, am - 1, ad)) / 86400000);
|
||||
};
|
||||
|
||||
function dueInfo(due) {
|
||||
const diff = dayDiff(todayStr(), due);
|
||||
const d = parseDate(due);
|
||||
let text;
|
||||
if (diff === 0) text = 'Today';
|
||||
else if (diff === 1) text = 'Tomorrow';
|
||||
else if (diff === -1) text = 'Yesterday';
|
||||
else if (diff > 1 && diff < 7) text = d.toLocaleDateString(undefined, { weekday: 'long' });
|
||||
else {
|
||||
const opts = { weekday: 'short', day: 'numeric', month: 'short' };
|
||||
if (d.getFullYear() !== new Date().getFullYear()) opts.year = 'numeric';
|
||||
text = d.toLocaleDateString(undefined, opts);
|
||||
}
|
||||
if (diff < -1) text += ` · ${-diff} days late`;
|
||||
return { text, cls: diff < 0 ? 'overdue' : diff === 0 ? 'today' : '' };
|
||||
}
|
||||
|
||||
// --- API ----------------------------------------------------------------------
|
||||
|
||||
async function api(path, { method = 'GET', body } = {}) {
|
||||
const headers = { 'X-WhatToDo': '1' };
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
const res = await fetch(path, {
|
||||
method, headers, credentials: 'same-origin',
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (res.status === 401 && path !== 'api/login') {
|
||||
showLogin();
|
||||
throw new Error('Please log in');
|
||||
}
|
||||
if (!res.ok) {
|
||||
let msg = `${res.status} ${res.statusText}`;
|
||||
try {
|
||||
const j = await res.json();
|
||||
if (typeof j.detail === 'string') msg = j.detail;
|
||||
else if (Array.isArray(j.detail)) msg = j.detail.map((e) => `${e.loc.slice(1).join('.')}: ${e.msg}`).join('; ');
|
||||
} catch { /* not JSON */ }
|
||||
throw new Error(msg);
|
||||
}
|
||||
return res.status === 204 ? null : res.json();
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
const [categories, tasks] = await Promise.all([api('api/categories'), api('api/tasks')]);
|
||||
state.categories = categories;
|
||||
state.tasks = tasks;
|
||||
}
|
||||
|
||||
// Run an action, report errors as a toast, then refresh.
|
||||
async function act(fn) {
|
||||
try {
|
||||
await fn();
|
||||
} catch (e) {
|
||||
toast(e.message);
|
||||
}
|
||||
try {
|
||||
await loadAll();
|
||||
render();
|
||||
} catch { /* login screen already shown */ }
|
||||
}
|
||||
|
||||
// --- Persisted UI prefs -------------------------------------------------------
|
||||
|
||||
function savePrefs() {
|
||||
try { localStorage.setItem('whattodo.view', JSON.stringify({ view: state.view, categoryId: state.categoryId })); } catch { /* ignore */ }
|
||||
}
|
||||
function loadPrefs() {
|
||||
try {
|
||||
const p = JSON.parse(localStorage.getItem('whattodo.view') || 'null');
|
||||
if (p && typeof p.view === 'string') { state.view = p.view; state.categoryId = p.categoryId ?? null; }
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// --- Filtering & grouping -----------------------------------------------------
|
||||
|
||||
const catById = (id) => state.categories.find((c) => c.id === id);
|
||||
|
||||
function matchesSearch(t) {
|
||||
const terms = state.search.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
return terms.every((term) => {
|
||||
if (term.startsWith('#') && term.length > 1) return t.tags.some((tag) => tag.startsWith(term.slice(1)));
|
||||
const cat = catById(t.category_id);
|
||||
const hay = [t.title, t.notes, cat ? cat.name : '', ...t.tags, ...t.subtasks.map((s) => s.title)].join(' ').toLowerCase();
|
||||
return hay.includes(term);
|
||||
});
|
||||
}
|
||||
|
||||
function cmpOpen(a, b) {
|
||||
if (a.due_date !== b.due_date) {
|
||||
if (!a.due_date) return 1;
|
||||
if (!b.due_date) return -1;
|
||||
return a.due_date < b.due_date ? -1 : 1;
|
||||
}
|
||||
return (b.priority - a.priority) || (a.id - b.id);
|
||||
}
|
||||
|
||||
function viewTasks() {
|
||||
let tasks = state.tasks;
|
||||
if (state.view === 'cat') tasks = tasks.filter((t) => (t.category_id || 0) === state.categoryId);
|
||||
if (state.search.trim()) tasks = tasks.filter(matchesSearch);
|
||||
return tasks;
|
||||
}
|
||||
|
||||
function buildGroups(tasks) {
|
||||
const t = todayStr();
|
||||
const tomorrow = addDays(t, 1);
|
||||
const week = addDays(t, 7);
|
||||
if (state.view === 'done') {
|
||||
const done = tasks.filter((x) => x.done).sort((a, b) => (b.done_at || '').localeCompare(a.done_at || ''));
|
||||
return [{ title: 'Completed', tasks: done }];
|
||||
}
|
||||
const open = tasks.filter((x) => !x.done).sort(cmpOpen);
|
||||
const group = (title, pred) => ({ title, tasks: open.filter(pred) });
|
||||
const overdue = (x) => x.due_date && x.due_date < t;
|
||||
const today = (x) => x.due_date === t;
|
||||
if (state.view === 'today') {
|
||||
return [
|
||||
group('Overdue', overdue),
|
||||
group('Today', today),
|
||||
group('High priority', (x) => x.priority === 3 && (!x.due_date || x.due_date > t)),
|
||||
];
|
||||
}
|
||||
if (state.view === 'upcoming') {
|
||||
return [
|
||||
group('Tomorrow', (x) => x.due_date === tomorrow),
|
||||
group('Next 7 days', (x) => x.due_date && x.due_date > tomorrow && x.due_date <= week),
|
||||
group('Later', (x) => x.due_date && x.due_date > week),
|
||||
];
|
||||
}
|
||||
return [
|
||||
group('Overdue', overdue),
|
||||
group('Today', today),
|
||||
group('Next 7 days', (x) => x.due_date && x.due_date > t && x.due_date <= week),
|
||||
group('Later', (x) => x.due_date && x.due_date > week),
|
||||
group('No deadline', (x) => !x.due_date),
|
||||
];
|
||||
}
|
||||
|
||||
function counts() {
|
||||
const t = todayStr();
|
||||
const open = state.tasks.filter((x) => !x.done);
|
||||
const c = {
|
||||
today: open.filter((x) => (x.due_date && x.due_date <= t) || x.priority === 3).length,
|
||||
upcoming: open.filter((x) => x.due_date && x.due_date > t).length,
|
||||
all: open.length,
|
||||
done: null,
|
||||
overdue: open.filter((x) => x.due_date && x.due_date < t).length,
|
||||
cats: {},
|
||||
};
|
||||
for (const x of open) c.cats[x.category_id || 0] = (c.cats[x.category_id || 0] || 0) + 1;
|
||||
return c;
|
||||
}
|
||||
|
||||
// --- Rendering ----------------------------------------------------------------
|
||||
|
||||
function render() {
|
||||
if (state.view === 'cat' && state.categoryId && !catById(state.categoryId)) state.view = 'today';
|
||||
if (state.view !== 'cat' && !VIEWS.some((v) => v.id === state.view)) state.view = 'today';
|
||||
const c = counts();
|
||||
const navItem = (active, icon, label, count, onclick, color, alert) => h('button', {
|
||||
type: 'button', class: 'nav-item' + (active ? ' active' : ''), onclick,
|
||||
},
|
||||
color ? h('span', { class: 'dot', style: { background: color } }) : h('span', { class: 'nav-icon' }, icon),
|
||||
h('span', { class: 'nav-label' }, label),
|
||||
count ? h('span', { class: 'count' + (alert ? ' alert' : '') }, count) : null);
|
||||
|
||||
$('#views').replaceChildren(...VIEWS.map((v) => navItem(
|
||||
state.view === v.id, v.icon, v.label, c[v.id], () => setView(v.id), null, v.id === 'today' && c.overdue > 0,
|
||||
)));
|
||||
|
||||
const catItems = state.categories.map((cat) => navItem(
|
||||
state.view === 'cat' && state.categoryId === cat.id, null, cat.name, c.cats[cat.id], () => setView('cat', cat.id), cat.color,
|
||||
));
|
||||
if (c.cats[0]) {
|
||||
catItems.push(navItem(state.view === 'cat' && state.categoryId === 0, null, 'No category', c.cats[0],
|
||||
() => setView('cat', 0), 'var(--muted)'));
|
||||
}
|
||||
$('#cats').replaceChildren(...catItems);
|
||||
|
||||
let title = (VIEWS.find((v) => v.id === state.view) || {}).label;
|
||||
if (state.view === 'cat') title = state.categoryId ? (catById(state.categoryId) || {}).name : 'No category';
|
||||
$('#view-title').textContent = title || '';
|
||||
document.title = `${title} · WhatToDo`;
|
||||
$('#quick-add').hidden = state.view === 'done';
|
||||
|
||||
const groups = buildGroups(viewTasks()).filter((g) => g.tasks.length);
|
||||
if (!groups.length) {
|
||||
const msg = state.search ? 'No tasks match your search.'
|
||||
: state.view === 'done' ? 'Nothing completed yet.'
|
||||
: state.view === 'today' ? 'Nothing due today. 🎉' : 'No tasks here.';
|
||||
$('#list').replaceChildren(h('div', { class: 'empty' }, msg));
|
||||
return;
|
||||
}
|
||||
$('#list').replaceChildren(...groups.map((g) => h('section', { class: 'group' },
|
||||
h('h3', { class: 'group-title' + (g.title === 'Overdue' ? ' overdue' : '') }, g.title, h('span', { class: 'muted' }, ` ${g.tasks.length}`)),
|
||||
g.tasks.map(taskRow))));
|
||||
}
|
||||
|
||||
function taskRow(task) {
|
||||
const meta = [];
|
||||
const cat = catById(task.category_id);
|
||||
if (cat && state.view !== 'cat') {
|
||||
meta.push(h('span', { class: 'chip' }, h('span', { class: 'dot', style: { background: cat.color } }), cat.name));
|
||||
}
|
||||
if (task.due_date) {
|
||||
const d = dueInfo(task.due_date);
|
||||
meta.push(h('span', { class: `chip due ${task.done ? '' : d.cls}` }, '📅 ', d.text));
|
||||
}
|
||||
if (task.priority) meta.push(h('span', { class: `chip prio-label p${task.priority}` }, '⚑ ', PRIORITY_LABELS[task.priority]));
|
||||
if (task.recurrence !== 'none') {
|
||||
const unit = REPEAT_UNITS[task.recurrence];
|
||||
meta.push(h('span', { class: 'chip' }, '↻ ', task.recur_interval > 1 ? `every ${task.recur_interval} ${unit}s` : `every ${unit}`));
|
||||
}
|
||||
if (task.subtasks.length) {
|
||||
const doneCount = task.subtasks.filter((s) => s.done).length;
|
||||
meta.push(h('span', { class: 'chip' }, `☑ ${doneCount}/${task.subtasks.length}`));
|
||||
}
|
||||
if (task.notes.trim()) meta.push(h('span', { class: 'chip', title: 'Has notes' }, '📝'));
|
||||
for (const tag of task.tags) {
|
||||
meta.push(h('button', {
|
||||
type: 'button', class: 'chip tag',
|
||||
onclick: (e) => { e.stopPropagation(); setSearch(`#${tag}`); },
|
||||
}, `#${tag}`));
|
||||
}
|
||||
|
||||
return h('div', {
|
||||
class: `task p${task.priority}` + (task.done ? ' done' : ''),
|
||||
tabindex: '0',
|
||||
onclick: () => openTask(task),
|
||||
onkeydown: (e) => { if (e.key === 'Enter' && e.target === e.currentTarget) openTask(task); },
|
||||
},
|
||||
h('button', {
|
||||
type: 'button', class: 'check', 'aria-label': task.done ? 'Mark as not done' : 'Mark as done',
|
||||
onclick: (e) => { e.stopPropagation(); toggleDone(task); },
|
||||
}, task.done ? '✓' : ''),
|
||||
h('div', { class: 'task-body' },
|
||||
h('div', { class: 'task-title' }, task.title),
|
||||
meta.length ? h('div', { class: 'task-meta' }, meta) : null));
|
||||
}
|
||||
|
||||
// --- Actions ------------------------------------------------------------------
|
||||
|
||||
function setView(view, categoryId = null) {
|
||||
state.view = view;
|
||||
state.categoryId = categoryId;
|
||||
savePrefs();
|
||||
document.body.classList.remove('nav-open');
|
||||
render();
|
||||
$('#list').scrollTop = 0;
|
||||
}
|
||||
|
||||
function setSearch(text) {
|
||||
state.search = text;
|
||||
$('#search').value = text;
|
||||
render();
|
||||
}
|
||||
|
||||
function toggleDone(task) {
|
||||
if (task.done) {
|
||||
act(() => api(`api/tasks/${task.id}/reopen`, { method: 'POST' }));
|
||||
return;
|
||||
}
|
||||
act(async () => {
|
||||
const res = await api(`api/tasks/${task.id}/complete`, { method: 'POST', body: { today: todayStr() } });
|
||||
const extra = res.next ? ` Next one: ${dueInfo(res.next.due_date).text}.` : '';
|
||||
toast(`Done: ${task.title}.${extra}`, {
|
||||
label: 'Undo',
|
||||
fn: () => act(async () => {
|
||||
if (res.next) await api(`api/tasks/${res.next.id}`, { method: 'DELETE' });
|
||||
await api(`api/tasks/${task.id}/reopen`, { method: 'POST' });
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function quickAdd(title) {
|
||||
const body = { title };
|
||||
if (state.view === 'cat' && state.categoryId) body.category_id = state.categoryId;
|
||||
if (state.view === 'today') body.due_date = todayStr();
|
||||
if (state.view === 'upcoming') body.due_date = addDays(todayStr(), 1);
|
||||
act(() => api('api/tasks', { method: 'POST', body }));
|
||||
}
|
||||
|
||||
function toast(message, action) {
|
||||
const el = $('#toast');
|
||||
el.replaceChildren(h('span', {}, message), action ? h('button', {
|
||||
type: 'button', class: 'link',
|
||||
onclick: () => { el.hidden = true; action.fn(); },
|
||||
}, action.label) : null);
|
||||
el.hidden = false;
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => { el.hidden = true; }, action ? 6000 : 4000);
|
||||
}
|
||||
|
||||
// --- Task dialog --------------------------------------------------------------
|
||||
|
||||
function fillCategorySelect(select, selected) {
|
||||
select.replaceChildren(
|
||||
h('option', { value: '' }, '— None —'),
|
||||
...state.categories.map((c) => h('option', { value: String(c.id) }, c.name)),
|
||||
);
|
||||
select.value = selected ? String(selected) : '';
|
||||
}
|
||||
|
||||
function updateIntervalVisibility() {
|
||||
$('#t-interval-wrap').hidden = $('#t-repeat').value === 'none';
|
||||
}
|
||||
|
||||
function openTask(task, defaults = {}) {
|
||||
editing = task;
|
||||
const t = task || {
|
||||
title: '', notes: '', category_id: null, priority: 0, due_date: null,
|
||||
tags: [], recurrence: 'none', recur_interval: 1, subtasks: [], ...defaults,
|
||||
};
|
||||
$('#task-dialog-title').textContent = task ? 'Edit task' : 'New task';
|
||||
$('#t-title').value = t.title;
|
||||
fillCategorySelect($('#t-category'), t.category_id);
|
||||
$('#t-priority').value = String(t.priority);
|
||||
$('#t-due').value = t.due_date || '';
|
||||
$('#t-repeat').value = t.recurrence;
|
||||
$('#t-interval').value = t.recur_interval;
|
||||
$('#t-tags').value = t.tags.join(', ');
|
||||
$('#t-notes').value = t.notes;
|
||||
$('#t-subtask-new').value = '';
|
||||
$('#t-delete').hidden = !task;
|
||||
draftSubtasks = t.subtasks.map((s) => ({ title: s.title, done: s.done }));
|
||||
renderDraftSubtasks();
|
||||
updateIntervalVisibility();
|
||||
$('#task-dialog').showModal();
|
||||
if (!task) $('#t-title').focus();
|
||||
}
|
||||
|
||||
function renderDraftSubtasks() {
|
||||
$('#t-subtasks').replaceChildren(...draftSubtasks.map((s, i) => h('li', { class: 'subtask' },
|
||||
h('input', { type: 'checkbox', checked: s.done, 'aria-label': 'Done', onchange: (e) => { s.done = e.target.checked; } }),
|
||||
h('input', { type: 'text', value: s.title, maxlength: '500', oninput: (e) => { s.title = e.target.value; } }),
|
||||
h('button', {
|
||||
type: 'button', class: 'icon-btn', 'aria-label': 'Remove step',
|
||||
onclick: () => { draftSubtasks.splice(i, 1); renderDraftSubtasks(); },
|
||||
}, '✕'))));
|
||||
}
|
||||
|
||||
function addDraftSubtask() {
|
||||
const input = $('#t-subtask-new');
|
||||
const title = input.value.trim();
|
||||
if (!title) return;
|
||||
draftSubtasks.push({ title, done: false });
|
||||
input.value = '';
|
||||
renderDraftSubtasks();
|
||||
input.focus();
|
||||
}
|
||||
|
||||
function saveTask(e) {
|
||||
e.preventDefault();
|
||||
addDraftSubtask();
|
||||
const body = {
|
||||
title: $('#t-title').value.trim(),
|
||||
category_id: $('#t-category').value ? Number($('#t-category').value) : null,
|
||||
priority: Number($('#t-priority').value),
|
||||
due_date: $('#t-due').value || null,
|
||||
recurrence: $('#t-repeat').value,
|
||||
recur_interval: Math.max(1, Number($('#t-interval').value) || 1),
|
||||
tags: $('#t-tags').value.split(',').map((s) => s.trim()).filter(Boolean),
|
||||
notes: $('#t-notes').value,
|
||||
subtasks: draftSubtasks.map((s) => ({ title: s.title.trim(), done: s.done })).filter((s) => s.title),
|
||||
};
|
||||
if (!body.title) return;
|
||||
const task = editing;
|
||||
$('#task-dialog').close();
|
||||
act(() => (task
|
||||
? api(`api/tasks/${task.id}`, { method: 'PATCH', body })
|
||||
: api('api/tasks', { method: 'POST', body })));
|
||||
}
|
||||
|
||||
function deleteTask() {
|
||||
const task = editing;
|
||||
if (!task || !confirm(`Delete "${task.title}"?`)) return;
|
||||
$('#task-dialog').close();
|
||||
act(() => api(`api/tasks/${task.id}`, { method: 'DELETE' }));
|
||||
}
|
||||
|
||||
// --- Settings dialog ----------------------------------------------------------
|
||||
|
||||
function renderSettingsCategories() {
|
||||
const cats = state.categories;
|
||||
const patch = (cat, body) => act(async () => { await api(`api/categories/${cat.id}`, { method: 'PATCH', body }); })
|
||||
.then(renderSettingsCategories);
|
||||
const move = (i, dir) => {
|
||||
const other = cats[i + dir];
|
||||
if (!other) return;
|
||||
const order = cats.map((c) => c.id);
|
||||
[order[i], order[i + dir]] = [order[i + dir], order[i]];
|
||||
act(() => Promise.all(order.map((id, pos) => api(`api/categories/${id}`, { method: 'PATCH', body: { position: pos } }))))
|
||||
.then(renderSettingsCategories);
|
||||
};
|
||||
$('#s-cats').replaceChildren(...cats.map((cat, i) => h('li', { class: 'cat-edit' },
|
||||
h('input', { type: 'color', value: cat.color, 'aria-label': 'Color', onchange: (e) => patch(cat, { color: e.target.value }) }),
|
||||
h('input', {
|
||||
type: 'text', value: cat.name, maxlength: '50', 'aria-label': 'Name',
|
||||
onchange: (e) => { const name = e.target.value.trim(); if (name) patch(cat, { name }); },
|
||||
}),
|
||||
h('button', { type: 'button', class: 'icon-btn', 'aria-label': 'Move up', disabled: i === 0, onclick: () => move(i, -1) }, '↑'),
|
||||
h('button', { type: 'button', class: 'icon-btn', 'aria-label': 'Move down', disabled: i === cats.length - 1, onclick: () => move(i, 1) }, '↓'),
|
||||
h('button', {
|
||||
type: 'button', class: 'icon-btn danger', 'aria-label': 'Delete category',
|
||||
onclick: () => {
|
||||
if (!confirm(`Delete category "${cat.name}"? Its tasks are kept but will have no category.`)) return;
|
||||
act(() => api(`api/categories/${cat.id}`, { method: 'DELETE' })).then(renderSettingsCategories);
|
||||
},
|
||||
}, '🗑'))));
|
||||
}
|
||||
|
||||
async function importFile() {
|
||||
const file = $('#s-import-file').files[0];
|
||||
if (!file) { toast('Choose an export file first.'); return; }
|
||||
const mode = $('#s-import-mode').value;
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(await file.text());
|
||||
} catch {
|
||||
toast('That file is not valid JSON.');
|
||||
return;
|
||||
}
|
||||
const n = Array.isArray(data.tasks) ? data.tasks.length : 0;
|
||||
const warning = mode === 'replace'
|
||||
? `REPLACE: this deletes ALL current tasks and categories and loads ${n} tasks from the file. Continue?`
|
||||
: `MERGE: add ${n} tasks from the file to your current data. Continue?`;
|
||||
if (!confirm(warning)) return;
|
||||
await act(async () => {
|
||||
const res = await api(`api/import?mode=${mode}`, { method: 'POST', body: data });
|
||||
toast(`Imported ${res.tasks} tasks and ${res.categories} categories (${res.mode}).`);
|
||||
});
|
||||
$('#s-import-file').value = '';
|
||||
renderSettingsCategories();
|
||||
}
|
||||
|
||||
// --- Login --------------------------------------------------------------------
|
||||
|
||||
function showLogin() {
|
||||
$('#app').hidden = true;
|
||||
$('#login').hidden = false;
|
||||
for (const d of document.querySelectorAll('dialog[open]')) d.close();
|
||||
$('#login-password').focus();
|
||||
}
|
||||
|
||||
async function showApp() {
|
||||
await loadAll();
|
||||
$('#login').hidden = true;
|
||||
$('#app').hidden = false;
|
||||
render();
|
||||
}
|
||||
|
||||
// --- Wiring -------------------------------------------------------------------
|
||||
|
||||
function bind() {
|
||||
$('#login-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const err = $('#login-error');
|
||||
err.hidden = true;
|
||||
try {
|
||||
await api('api/login', { method: 'POST', body: { password: $('#login-password').value } });
|
||||
$('#login-password').value = '';
|
||||
await showApp();
|
||||
} catch (ex) {
|
||||
err.textContent = ex.message;
|
||||
err.hidden = false;
|
||||
}
|
||||
});
|
||||
|
||||
$('#quick-add').addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
const title = $('#quick-title').value.trim();
|
||||
if (!title) return;
|
||||
$('#quick-title').value = '';
|
||||
quickAdd(title);
|
||||
});
|
||||
$('#new-detailed').addEventListener('click', () => {
|
||||
const defaults = {};
|
||||
if (state.view === 'cat' && state.categoryId) defaults.category_id = state.categoryId;
|
||||
if (state.view === 'today') defaults.due_date = todayStr();
|
||||
defaults.title = $('#quick-title').value.trim();
|
||||
$('#quick-title').value = '';
|
||||
openTask(null, defaults);
|
||||
});
|
||||
|
||||
$('#search').addEventListener('input', (e) => { state.search = e.target.value; render(); });
|
||||
|
||||
$('#menu-btn').addEventListener('click', () => document.body.classList.add('nav-open'));
|
||||
$('#scrim').addEventListener('click', () => document.body.classList.remove('nav-open'));
|
||||
|
||||
// Task dialog
|
||||
$('#task-form').addEventListener('submit', saveTask);
|
||||
$('#t-cancel').addEventListener('click', () => $('#task-dialog').close());
|
||||
$('#t-delete').addEventListener('click', deleteTask);
|
||||
$('#t-repeat').addEventListener('change', updateIntervalVisibility);
|
||||
$('#t-subtask-add').addEventListener('click', addDraftSubtask);
|
||||
$('#t-subtask-new').addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); addDraftSubtask(); }
|
||||
});
|
||||
for (const btn of document.querySelectorAll('.quick-dates .chip-btn')) {
|
||||
btn.addEventListener('click', () => {
|
||||
$('#t-due').value = btn.dataset.days === '' ? '' : addDays(todayStr(), Number(btn.dataset.days));
|
||||
});
|
||||
}
|
||||
|
||||
// Settings dialog
|
||||
$('#settings-btn').addEventListener('click', () => {
|
||||
document.body.classList.remove('nav-open');
|
||||
renderSettingsCategories();
|
||||
$('#settings-dialog').showModal();
|
||||
});
|
||||
$('#s-close').addEventListener('click', () => $('#settings-dialog').close());
|
||||
$('#s-cat-add').addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
const name = $('#s-cat-name').value.trim();
|
||||
if (!name) return;
|
||||
act(() => api('api/categories', { method: 'POST', body: { name, color: $('#s-cat-color').value } }))
|
||||
.then(() => { $('#s-cat-name').value = ''; renderSettingsCategories(); });
|
||||
});
|
||||
$('#s-import-btn').addEventListener('click', importFile);
|
||||
$('#s-logout').addEventListener('click', async () => {
|
||||
try { await api('api/logout', { method: 'POST' }); } catch { /* ignore */ }
|
||||
$('#settings-dialog').close();
|
||||
showLogin();
|
||||
});
|
||||
|
||||
// Clicking the backdrop closes a dialog.
|
||||
for (const dlg of document.querySelectorAll('dialog')) {
|
||||
dlg.addEventListener('click', (e) => { if (e.target === dlg) dlg.close(); });
|
||||
}
|
||||
|
||||
// Keyboard shortcuts: "/" search, "n" new task.
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (document.querySelector('dialog[open]') || $('#app').hidden) return;
|
||||
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement.tagName)) return;
|
||||
if (e.key === '/') { e.preventDefault(); $('#search').focus(); }
|
||||
if (e.key === 'n') { e.preventDefault(); $('#quick-title').focus(); }
|
||||
});
|
||||
|
||||
// Refresh when returning to the app (e.g. phone unlocked, day changed).
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible' && !$('#app').hidden) act(async () => {});
|
||||
});
|
||||
}
|
||||
|
||||
async function init() {
|
||||
loadPrefs();
|
||||
bind();
|
||||
if ('serviceWorker' in navigator) navigator.serviceWorker.register('sw.js').catch(() => {});
|
||||
try {
|
||||
await showApp();
|
||||
} catch (e) {
|
||||
// A 401 already switched to the login screen; anything else is worth reporting.
|
||||
if ($('#login').hidden) toast(`Could not load: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.7 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect width="100" height="100" rx="22" fill="#4f46e5"/>
|
||||
<path d="M28 53 L44 68 L73 36" fill="none" stroke="#fff" stroke-width="9" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 253 B |
@@ -0,0 +1,172 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#4f46e5">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-title" content="WhatToDo">
|
||||
<title>WhatToDo</title>
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<link rel="icon" href="icons/icon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="icons/icon-180.png">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<script src="app.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="login" class="login" hidden>
|
||||
<form id="login-form" class="login-card">
|
||||
<img src="icons/icon.svg" alt="" width="56" height="56">
|
||||
<h1>WhatToDo</h1>
|
||||
<input type="password" id="login-password" placeholder="Password" autocomplete="current-password" required>
|
||||
<button type="submit" class="btn primary">Log in</button>
|
||||
<p id="login-error" class="error" hidden></p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="app" class="app" hidden>
|
||||
<aside id="sidebar" class="sidebar">
|
||||
<div class="brand"><img src="icons/icon.svg" alt="" width="28" height="28"> WhatToDo</div>
|
||||
<nav id="views" class="nav"></nav>
|
||||
<div class="nav-heading">Categories</div>
|
||||
<nav id="cats" class="nav"></nav>
|
||||
<div class="sidebar-foot">
|
||||
<button id="settings-btn" class="nav-item" type="button"><span class="nav-icon">⚙️</span> Settings</button>
|
||||
</div>
|
||||
</aside>
|
||||
<div id="scrim" class="scrim"></div>
|
||||
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
<button id="menu-btn" class="icon-btn menu-btn" type="button" aria-label="Open menu">☰</button>
|
||||
<h2 id="view-title"></h2>
|
||||
<input id="search" type="search" placeholder="Search or #tag" autocomplete="off">
|
||||
</header>
|
||||
<form id="quick-add" class="quick-add">
|
||||
<input id="quick-title" placeholder="Add a task…" autocomplete="off" maxlength="500">
|
||||
<button class="btn primary" type="submit">Add</button>
|
||||
<button id="new-detailed" class="btn" type="button" title="New task with details">+ Details</button>
|
||||
</form>
|
||||
<div id="list" class="list"></div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<dialog id="task-dialog">
|
||||
<form id="task-form" class="dialog-body">
|
||||
<h3 id="task-dialog-title">Task</h3>
|
||||
<label class="field">
|
||||
<span>Title</span>
|
||||
<input id="t-title" required maxlength="500" autocomplete="off">
|
||||
</label>
|
||||
<div class="row">
|
||||
<label class="field">
|
||||
<span>Category</span>
|
||||
<select id="t-category"></select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Priority</span>
|
||||
<select id="t-priority">
|
||||
<option value="0">None</option>
|
||||
<option value="1">Low</option>
|
||||
<option value="2">Medium</option>
|
||||
<option value="3">High</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="field">
|
||||
<span>Deadline</span>
|
||||
<input id="t-due" type="date">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Repeat</span>
|
||||
<select id="t-repeat">
|
||||
<option value="none">Never</option>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="yearly">Yearly</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field narrow" id="t-interval-wrap">
|
||||
<span>Every</span>
|
||||
<input id="t-interval" type="number" min="1" max="365" value="1">
|
||||
</label>
|
||||
</div>
|
||||
<div class="quick-dates">
|
||||
<button type="button" class="chip-btn" data-days="0">Today</button>
|
||||
<button type="button" class="chip-btn" data-days="1">Tomorrow</button>
|
||||
<button type="button" class="chip-btn" data-days="7">Next week</button>
|
||||
<button type="button" class="chip-btn" data-days="">No date</button>
|
||||
</div>
|
||||
<label class="field">
|
||||
<span>Tags <small>(comma separated)</small></span>
|
||||
<input id="t-tags" placeholder="e.g. editing, urgent" autocomplete="off">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Notes</span>
|
||||
<textarea id="t-notes" rows="4" maxlength="20000"></textarea>
|
||||
</label>
|
||||
<div class="field">
|
||||
<span>Checklist</span>
|
||||
<ul id="t-subtasks" class="subtasks"></ul>
|
||||
<div class="subtask-add">
|
||||
<input id="t-subtask-new" placeholder="Add a step and press Enter" autocomplete="off" maxlength="500">
|
||||
<button id="t-subtask-add" type="button" class="btn">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dialog-actions">
|
||||
<button id="t-delete" type="button" class="btn danger">Delete</button>
|
||||
<span class="spacer"></span>
|
||||
<button id="t-cancel" type="button" class="btn">Cancel</button>
|
||||
<button type="submit" class="btn primary">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="settings-dialog">
|
||||
<div class="dialog-body">
|
||||
<h3>Settings</h3>
|
||||
|
||||
<section class="settings-section">
|
||||
<h4>Categories</h4>
|
||||
<ul id="s-cats" class="cat-edit-list"></ul>
|
||||
<form id="s-cat-add" class="cat-add">
|
||||
<input id="s-cat-color" type="color" value="#0ea5e9" aria-label="Color">
|
||||
<input id="s-cat-name" placeholder="New category" maxlength="50" required autocomplete="off">
|
||||
<button class="btn" type="submit">Add</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<h4>Export / import</h4>
|
||||
<p class="muted">Export downloads all tasks and categories as a JSON file. Import it on another
|
||||
install, such as production, to move your data there.</p>
|
||||
<div class="row wrap">
|
||||
<a id="s-export" class="btn primary" href="api/export" download>Export data</a>
|
||||
</div>
|
||||
<div class="row wrap import-row">
|
||||
<input id="s-import-file" type="file" accept="application/json,.json">
|
||||
<select id="s-import-mode" aria-label="Import mode">
|
||||
<option value="merge">Merge (add to current data)</option>
|
||||
<option value="replace">Replace (delete current data first)</option>
|
||||
</select>
|
||||
<button id="s-import-btn" class="btn" type="button">Import</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<h4>Session</h4>
|
||||
<button id="s-logout" class="btn" type="button">Log out</button>
|
||||
</section>
|
||||
|
||||
<div class="dialog-actions">
|
||||
<span class="spacer"></span>
|
||||
<button id="s-close" class="btn primary" type="button">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<div id="toast" class="toast" hidden></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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'))),
|
||||
);
|
||||
});
|
||||
@@ -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"]
|
||||
@@ -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 "$@"
|
||||
@@ -0,0 +1,2 @@
|
||||
pytest>=8,<10
|
||||
httpx>=0.27,<1
|
||||
@@ -0,0 +1,2 @@
|
||||
fastapi>=0.115,<1
|
||||
uvicorn>=0.30,<1
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user