Initial WhatToDo app

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

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

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

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

After

Width:  |  Height:  |  Size: 253 B

+172
View File
@@ -0,0 +1,172 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#4f46e5">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="WhatToDo">
<title>WhatToDo</title>
<link rel="manifest" href="manifest.webmanifest">
<link rel="icon" href="icons/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="icons/icon-180.png">
<link rel="stylesheet" href="style.css">
<script src="app.js" defer></script>
</head>
<body>
<div id="login" class="login" hidden>
<form id="login-form" class="login-card">
<img src="icons/icon.svg" alt="" width="56" height="56">
<h1>WhatToDo</h1>
<input type="password" id="login-password" placeholder="Password" autocomplete="current-password" required>
<button type="submit" class="btn primary">Log in</button>
<p id="login-error" class="error" hidden></p>
</form>
</div>
<div id="app" class="app" hidden>
<aside id="sidebar" class="sidebar">
<div class="brand"><img src="icons/icon.svg" alt="" width="28" height="28"> WhatToDo</div>
<nav id="views" class="nav"></nav>
<div class="nav-heading">Categories</div>
<nav id="cats" class="nav"></nav>
<div class="sidebar-foot">
<button id="settings-btn" class="nav-item" type="button"><span class="nav-icon">⚙️</span> Settings</button>
</div>
</aside>
<div id="scrim" class="scrim"></div>
<main class="main">
<header class="topbar">
<button id="menu-btn" class="icon-btn menu-btn" type="button" aria-label="Open menu">☰</button>
<h2 id="view-title"></h2>
<input id="search" type="search" placeholder="Search or #tag" autocomplete="off">
</header>
<form id="quick-add" class="quick-add">
<input id="quick-title" placeholder="Add a task…" autocomplete="off" maxlength="500">
<button class="btn primary" type="submit">Add</button>
<button id="new-detailed" class="btn" type="button" title="New task with details">+ Details</button>
</form>
<div id="list" class="list"></div>
</main>
</div>
<dialog id="task-dialog">
<form id="task-form" class="dialog-body">
<h3 id="task-dialog-title">Task</h3>
<label class="field">
<span>Title</span>
<input id="t-title" required maxlength="500" autocomplete="off">
</label>
<div class="row">
<label class="field">
<span>Category</span>
<select id="t-category"></select>
</label>
<label class="field">
<span>Priority</span>
<select id="t-priority">
<option value="0">None</option>
<option value="1">Low</option>
<option value="2">Medium</option>
<option value="3">High</option>
</select>
</label>
</div>
<div class="row">
<label class="field">
<span>Deadline</span>
<input id="t-due" type="date">
</label>
<label class="field">
<span>Repeat</span>
<select id="t-repeat">
<option value="none">Never</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
<option value="yearly">Yearly</option>
</select>
</label>
<label class="field narrow" id="t-interval-wrap">
<span>Every</span>
<input id="t-interval" type="number" min="1" max="365" value="1">
</label>
</div>
<div class="quick-dates">
<button type="button" class="chip-btn" data-days="0">Today</button>
<button type="button" class="chip-btn" data-days="1">Tomorrow</button>
<button type="button" class="chip-btn" data-days="7">Next week</button>
<button type="button" class="chip-btn" data-days="">No date</button>
</div>
<label class="field">
<span>Tags <small>(comma separated)</small></span>
<input id="t-tags" placeholder="e.g. editing, urgent" autocomplete="off">
</label>
<label class="field">
<span>Notes</span>
<textarea id="t-notes" rows="4" maxlength="20000"></textarea>
</label>
<div class="field">
<span>Checklist</span>
<ul id="t-subtasks" class="subtasks"></ul>
<div class="subtask-add">
<input id="t-subtask-new" placeholder="Add a step and press Enter" autocomplete="off" maxlength="500">
<button id="t-subtask-add" type="button" class="btn">Add</button>
</div>
</div>
<div class="dialog-actions">
<button id="t-delete" type="button" class="btn danger">Delete</button>
<span class="spacer"></span>
<button id="t-cancel" type="button" class="btn">Cancel</button>
<button type="submit" class="btn primary">Save</button>
</div>
</form>
</dialog>
<dialog id="settings-dialog">
<div class="dialog-body">
<h3>Settings</h3>
<section class="settings-section">
<h4>Categories</h4>
<ul id="s-cats" class="cat-edit-list"></ul>
<form id="s-cat-add" class="cat-add">
<input id="s-cat-color" type="color" value="#0ea5e9" aria-label="Color">
<input id="s-cat-name" placeholder="New category" maxlength="50" required autocomplete="off">
<button class="btn" type="submit">Add</button>
</form>
</section>
<section class="settings-section">
<h4>Export / import</h4>
<p class="muted">Export downloads all tasks and categories as a JSON file. Import it on another
install, such as production, to move your data there.</p>
<div class="row wrap">
<a id="s-export" class="btn primary" href="api/export" download>Export data</a>
</div>
<div class="row wrap import-row">
<input id="s-import-file" type="file" accept="application/json,.json">
<select id="s-import-mode" aria-label="Import mode">
<option value="merge">Merge (add to current data)</option>
<option value="replace">Replace (delete current data first)</option>
</select>
<button id="s-import-btn" class="btn" type="button">Import</button>
</div>
</section>
<section class="settings-section">
<h4>Session</h4>
<button id="s-logout" class="btn" type="button">Log out</button>
</section>
<div class="dialog-actions">
<span class="spacer"></span>
<button id="s-close" class="btn primary" type="button">Done</button>
</div>
</div>
</dialog>
<div id="toast" class="toast" hidden></div>
</body>
</html>
+16
View File
@@ -0,0 +1,16 @@
{
"name": "WhatToDo",
"short_name": "WhatToDo",
"description": "Personal task list",
"start_url": "./",
"scope": "./",
"display": "standalone",
"background_color": "#f5f6fa",
"theme_color": "#4f46e5",
"icons": [
{ "src": "icons/icon.svg", "sizes": "any", "type": "image/svg+xml" },
{ "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
+237
View File
@@ -0,0 +1,237 @@
:root {
--bg: #f5f6fa;
--panel: #ffffff;
--panel-2: #f0f2f7;
--text: #1d2130;
--muted: #6b7183;
--border: #e2e5ee;
--accent: #4f46e5;
--accent-text: #ffffff;
--accent-weak: #ecedff;
--danger: #dc2626;
--overdue: #dc2626;
--today: #d97706;
--ok: #16a34a;
--p1: #60a5fa;
--p2: #f59e0b;
--p3: #ef4444;
--radius: 10px;
--shadow: 0 1px 2px rgba(16, 24, 40, 0.06);
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #111319;
--panel: #1a1d26;
--panel-2: #222633;
--text: #e6e8ef;
--muted: #9399ad;
--border: #2c303d;
--accent: #818cf8;
--accent-text: #0f1020;
--accent-weak: #262a45;
--danger: #f87171;
--overdue: #f87171;
--today: #fbbf24;
--ok: #4ade80;
--shadow: none;
color-scheme: dark;
}
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
html, body { margin: 0; height: 100%; }
body {
background: var(--bg);
color: var(--text);
font: 15px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
-webkit-tap-highlight-color: transparent;
}
button, input, select, textarea { font: inherit; color: inherit; }
input, select, textarea {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 9px 11px;
min-width: 0;
width: 100%;
}
input:focus, select:focus, textarea:focus { outline: 2px solid var(--accent); outline-offset: -1px; border-color: transparent; }
input[type="checkbox"] { width: 18px; height: 18px; accent-color: var(--accent); flex: none; }
input[type="color"] { width: 40px; height: 38px; padding: 3px; flex: none; cursor: pointer; }
textarea { resize: vertical; }
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
padding: 9px 14px; border-radius: 8px; border: 1px solid var(--border);
background: var(--panel); cursor: pointer; text-decoration: none; white-space: nowrap;
}
.btn:hover { background: var(--panel-2); }
.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); }
.btn.primary:hover { filter: brightness(1.08); }
.btn.danger { color: var(--danger); }
.icon-btn {
background: none; border: none; cursor: pointer; padding: 6px 8px; border-radius: 6px;
color: var(--muted); flex: none;
}
.icon-btn:hover:not(:disabled) { background: var(--panel-2); color: var(--text); }
.icon-btn:disabled { opacity: 0.3; cursor: default; }
.icon-btn.danger:hover { color: var(--danger); }
.link { background: none; border: none; color: var(--accent); font-weight: 600; cursor: pointer; padding: 0 4px; }
.muted { color: var(--muted); }
.error { color: var(--danger); margin: 0; }
/* Login */
.login { min-height: 100%; display: grid; place-items: center; padding: 16px; }
.login-card {
width: 100%; max-width: 340px; background: var(--panel); border: 1px solid var(--border);
border-radius: 16px; padding: 28px; display: flex; flex-direction: column; gap: 12px;
align-items: center; box-shadow: var(--shadow);
}
.login-card h1 { margin: 0 0 8px; font-size: 22px; }
.login-card .btn { width: 100%; }
/* App layout */
.app { display: grid; grid-template-columns: 250px 1fr; height: 100dvh; }
.sidebar {
background: var(--panel); border-right: 1px solid var(--border);
display: flex; flex-direction: column; padding: 16px 10px; overflow-y: auto;
padding-top: max(16px, env(safe-area-inset-top));
}
.brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 18px; padding: 4px 10px 16px; }
.nav { display: flex; flex-direction: column; gap: 2px; }
.nav-heading { font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); padding: 18px 10px 6px; }
.nav-item {
display: flex; align-items: center; gap: 10px; width: 100%; text-align: left;
background: none; border: none; border-radius: 8px; padding: 8px 10px; cursor: pointer;
}
.nav-item:hover { background: var(--panel-2); }
.nav-item.active { background: var(--accent-weak); color: var(--accent); font-weight: 600; }
.nav-icon { width: 20px; text-align: center; }
.nav-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.count { font-size: 12px; color: var(--muted); background: var(--panel-2); border-radius: 999px; padding: 1px 8px; }
.count.alert { background: var(--overdue); color: #fff; }
.dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; flex: none; margin: 0 5px; }
.chip .dot { width: 8px; height: 8px; margin: 0 2px 0 0; }
.sidebar-foot { margin-top: auto; padding-top: 16px; }
.scrim { display: none; }
.main { display: flex; flex-direction: column; min-width: 0; height: 100dvh; }
.topbar {
display: flex; align-items: center; gap: 12px; padding: 16px 24px 8px;
padding-top: max(16px, env(safe-area-inset-top));
}
.topbar h2 { margin: 0; font-size: 22px; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.topbar input[type="search"] { max-width: 260px; }
.menu-btn { display: none; font-size: 20px; color: var(--text); }
.quick-add { display: flex; gap: 8px; padding: 8px 24px 12px; }
.list { flex: 1; overflow-y: auto; padding: 0 24px 40px; padding-bottom: max(40px, env(safe-area-inset-bottom)); }
.group { margin-top: 14px; }
.group-title { font-size: 13px; text-transform: uppercase; letter-spacing: 0.05em; margin: 0 0 8px; color: var(--muted); }
.group-title.overdue { color: var(--overdue); }
.task {
display: flex; gap: 12px; align-items: flex-start;
background: var(--panel); border: 1px solid var(--border); border-left: 4px solid var(--border);
border-radius: var(--radius); padding: 11px 12px; margin-bottom: 8px; cursor: pointer; box-shadow: var(--shadow);
}
.task:hover { border-color: var(--accent); }
.task:focus-visible { outline: 2px solid var(--accent); }
.task.p1 { border-left-color: var(--p1); }
.task.p2 { border-left-color: var(--p2); }
.task.p3 { border-left-color: var(--p3); }
.check {
flex: none; width: 24px; height: 24px; margin-top: 1px; border-radius: 50%;
border: 2px solid var(--muted); background: none; cursor: pointer; padding: 0;
color: #fff; font-size: 14px; line-height: 1; display: grid; place-items: center;
}
.check:hover { border-color: var(--ok); background: color-mix(in srgb, var(--ok) 15%, transparent); }
.task.done .check { background: var(--ok); border-color: var(--ok); }
.task.done .task-title { text-decoration: line-through; color: var(--muted); }
.task-body { flex: 1; min-width: 0; }
.task-title { overflow-wrap: anywhere; }
.task-meta { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
.chip {
display: inline-flex; align-items: center; font-size: 12px; color: var(--muted);
background: var(--panel-2); border: none; border-radius: 999px; padding: 2px 8px; white-space: nowrap;
}
.chip.tag { cursor: pointer; color: var(--accent); }
.chip.tag:hover { background: var(--accent-weak); }
.chip.due.overdue { color: #fff; background: var(--overdue); }
.chip.due.today { color: var(--today); font-weight: 600; }
.chip.p3 { color: var(--p3); }
.chip.p2 { color: var(--p2); }
.empty { text-align: center; color: var(--muted); padding: 60px 16px; font-size: 16px; }
/* Dialogs */
dialog {
border: none; border-radius: 14px; padding: 0; width: min(560px, calc(100vw - 24px));
max-height: calc(100dvh - 24px); background: var(--panel); color: var(--text);
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.3);
}
dialog::backdrop { background: rgba(10, 12, 20, 0.5); }
.dialog-body { display: flex; flex-direction: column; gap: 14px; padding: 20px; }
.dialog-body h3 { margin: 0; font-size: 19px; }
.field { display: flex; flex-direction: column; gap: 5px; flex: 1; min-width: 0; }
.field > span { font-size: 13px; color: var(--muted); font-weight: 600; }
.field.narrow { flex: 0 0 80px; }
.row { display: flex; gap: 12px; }
.row.wrap { flex-wrap: wrap; align-items: center; }
.quick-dates { display: flex; flex-wrap: wrap; gap: 6px; margin-top: -6px; }
.chip-btn {
font-size: 13px; background: var(--panel-2); border: 1px solid var(--border);
border-radius: 999px; padding: 4px 10px; cursor: pointer;
}
.chip-btn:hover { border-color: var(--accent); }
.subtasks { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.subtask { display: flex; align-items: center; gap: 8px; }
.subtask input[type="text"] { padding: 6px 9px; }
.subtask-add { display: flex; gap: 8px; }
.dialog-actions { display: flex; gap: 8px; align-items: center; padding-top: 4px; }
.spacer { flex: 1; }
.settings-section { border-top: 1px solid var(--border); padding-top: 12px; }
.settings-section h4 { margin: 0 0 10px; }
.settings-section p { margin: 0 0 10px; font-size: 14px; }
.cat-edit-list { list-style: none; margin: 0 0 10px; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.cat-edit, .cat-add { display: flex; align-items: center; gap: 6px; }
.import-row { margin-top: 10px; }
.import-row input[type="file"] { flex: 1 1 200px; padding: 6px; }
.import-row select { flex: 1 1 200px; width: auto; }
.toast {
position: fixed; left: 50%; transform: translateX(-50%);
bottom: max(20px, env(safe-area-inset-bottom)); z-index: 50;
background: #1d2130; color: #fff; padding: 10px 16px; border-radius: 10px;
display: flex; gap: 12px; align-items: center; max-width: calc(100vw - 32px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
}
.toast .link { color: #a5b4fc; }
/* Mobile */
@media (max-width: 800px) {
.app { grid-template-columns: 1fr; }
.sidebar {
position: fixed; inset: 0 auto 0 0; width: 280px; max-width: 85vw; z-index: 30;
transform: translateX(-100%); transition: transform 0.2s ease;
}
body.nav-open .sidebar { transform: none; box-shadow: 0 0 40px rgba(0, 0, 0, 0.3); }
body.nav-open .scrim { display: block; position: fixed; inset: 0; z-index: 20; background: rgba(10, 12, 20, 0.4); }
.menu-btn { display: block; }
.topbar { padding: 12px 16px 6px; padding-top: max(12px, env(safe-area-inset-top)); flex-wrap: wrap; }
.topbar h2 { font-size: 20px; }
.topbar input[type="search"] { max-width: none; flex-basis: 100%; order: 3; }
.quick-add { padding: 6px 16px 10px; }
#new-detailed { padding: 9px 10px; }
.list { padding: 0 16px 40px; }
.row { flex-wrap: wrap; }
.row .field { flex: 1 1 140px; }
.row .field.narrow { flex: 0 0 80px; }
.dialog-body { padding: 16px; }
}
+35
View File
@@ -0,0 +1,35 @@
// Network-first service worker: always fresh when online, app shell still opens offline.
const CACHE = 'whattodo-v1';
const SHELL = [
'./', 'index.html', 'style.css', 'app.js', 'manifest.webmanifest',
'icons/icon.svg', 'icons/icon-192.png', 'icons/icon-512.png',
];
self.addEventListener('install', (event) => {
event.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting()));
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys()
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
.then(() => self.clients.claim()),
);
});
self.addEventListener('fetch', (event) => {
const req = event.request;
const url = new URL(req.url);
if (req.method !== 'GET' || url.origin !== self.location.origin || url.pathname.includes('/api/')) return;
event.respondWith(
fetch(req)
.then((res) => {
if (res.ok) {
const copy = res.clone();
caches.open(CACHE).then((c) => c.put(req, copy));
}
return res;
})
.catch(() => caches.match(req).then((hit) => hit || caches.match('index.html'))),
);
});