From 39842c72d7c96b2efadf7f91ed76a644e5b6b5a2 Mon Sep 17 00:00:00 2001 From: goyban Date: Wed, 7 Oct 2026 14:26:32 +0200 Subject: [PATCH] Add plan dates, postponing, backlog and projects - Tasks get an optional plan date separate from the deadline. Unfinished plans roll over in Today; only missed deadlines count as overdue. - Postpone sheet on every task (tomorrow, weekend, next week, ...) that can move either the plan date or the deadline, with undo. - Backlog view for undated tasks, with suggestions in Today. - Projects with deadline, notes, progress, next step and "falling behind" warnings, shown in Today and in their own view. - Times and the calendar now follow the plan date (else the deadline). - Schema v3 migration (backup taken first); export format v3 includes projects and still imports older files. Co-Authored-By: Claude Opus 5.5 --- README.md | 10 +- app/db.py | 21 +- app/main.py | 178 +++++++++++-- app/static/app.js | 607 ++++++++++++++++++++++++++++++++++-------- app/static/index.html | 125 +++++++-- app/static/style.css | 79 +++++- app/static/sw.js | 2 +- tests/test_api.py | 66 ++++- 8 files changed, 931 insertions(+), 157 deletions(-) diff --git a/README.md b/README.md index 28ff4be..0cd7dfa 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,14 @@ 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** with an optional **time range** (e.g. 10:00–12:00), **priority** (none / low / medium / high), **category**, **tags**, **notes**, and a **checklist** of steps. +- Tasks have an optional **plan date** (when you'll do it), an optional **deadline** (when it must be done), and an optional **time range** (e.g. 10:00–12:00). Each task also has a **priority**, **category**, **tags**, **notes**, and a **checklist** of steps. +- **Postpone** any task from the ⏭ button: today, tomorrow, the weekend, next week, in 2 weeks, next month, or a date you pick. You can move either the plan date or the deadline. +- Unfinished planned tasks **roll over**: they stay in Today until you do them or postpone them, and they never turn red. Only missed **deadlines** count as overdue. +- **Backlog** lists tasks with no date at all. Today suggests a couple of them so they don't get forgotten. +- **Projects** group the tasks of a bigger goal and have their own deadline, notes and progress bar. Today shows each active project's **next step**, how much time is left, and a warning if it's falling behind or nothing was done on it in over a week. - **Recurring tasks** (every N days/weeks/months/years): completing one creates the next occurrence. - When you pick a date and time, the task form lists what else is planned that day and warns about overlaps. -- Views: **Today** (overdue, due today, high priority), **Upcoming**, **Calendar** (day timeline with week strip; tap an hour to add a task there), **All open**, **Done**, plus one view per category. +- Views: **Today**, **Upcoming**, **Calendar** (day timeline with week strip; tap an hour to add a task there), **Backlog**, **All open**, **Done**, plus one view per project and 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. @@ -55,7 +59,7 @@ Browsers only allow "Install app" / "Add to Home Screen" with offline support ov ### Moving your data from this PC to production -1. Local app → **Settings → Export data**. This downloads `whattodo-export-YYYYMMDD-HHMM.json`. +1. Local app → **Settings → Export data**. The export includes tasks, projects and categories. 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. diff --git a/app/db.py b/app/db.py index c0cfa91..ef34068 100644 --- a/app/db.py +++ b/app/db.py @@ -3,7 +3,7 @@ import sqlite3 from pathlib import Path DB_PATH = os.environ.get("DB_PATH", "/data/whattodo.db") -SCHEMA_VERSION = 2 +SCHEMA_VERSION = 3 DEFAULT_CATEGORIES = [ ("Work", "#2563eb"), @@ -23,13 +23,26 @@ CREATE TABLE IF NOT EXISTS categories ( color TEXT NOT NULL DEFAULT '#6b7280', position INTEGER NOT NULL DEFAULT 0 ); +CREATE TABLE IF NOT EXISTS projects ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + notes TEXT NOT NULL DEFAULT '', + category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL, + due_date TEXT, + done INTEGER NOT NULL DEFAULT 0, + done_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); 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, + project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL, priority INTEGER NOT NULL DEFAULT 0, due_date TEXT, + planned_date TEXT, start_time TEXT, end_time TEXT, tags TEXT NOT NULL DEFAULT '[]', @@ -96,6 +109,12 @@ def migrate(conn: sqlite3.Connection, version: int) -> None: if version < 2: conn.execute("ALTER TABLE tasks ADD COLUMN start_time TEXT") conn.execute("ALTER TABLE tasks ADD COLUMN end_time TEXT") + if version < 3: + # The projects table itself is created by SCHEMA before migrating. + conn.execute("ALTER TABLE tasks ADD COLUMN planned_date TEXT") + conn.execute( + "ALTER TABLE tasks ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL" + ) conn.execute( "UPDATE meta SET value = ? WHERE key = 'schema_version'", (str(SCHEMA_VERSION),) ) diff --git a/app/main.py b/app/main.py index 0dd48e4..c7f869c 100644 --- a/app/main.py +++ b/app/main.py @@ -31,7 +31,7 @@ if not APP_PASSWORD or not SECRET_KEY or "change-me" in (APP_PASSWORD, SECRET_KE COOKIE_NAME = "whattodo_session" CSRF_HEADER = "x-whattodo" -EXPORT_VERSION = 2 +EXPORT_VERSION = 3 STATIC_DIR = Path(__file__).parent / "static" # Changing the password (or secret) invalidates all existing sessions. @@ -191,10 +191,13 @@ def fmt_time(t) -> Optional[str]: return t.strftime("%H:%M") if isinstance(t, Time) else t -def check_times(due, start: Optional[str], end: Optional[str]) -> Optional[str]: - """Return an error message if the time fields don't make sense together.""" - if (start or end) and not due: - return "A time needs a deadline date" +def check_times(day, start: Optional[str], end: Optional[str]) -> Optional[str]: + """Return an error message if the time fields don't make sense together. + + `day` is the date the time applies to: the planned date if set, else the deadline. + """ + if (start or end) and not day: + return "A time needs a date (planned or deadline)" if end and not start: return "An end time needs a start time" if start and end and end <= start: @@ -226,8 +229,10 @@ class TaskIn(BaseModel): title: str = Field(max_length=500) notes: str = Field("", max_length=20000) category_id: Optional[int] = None + project_id: Optional[int] = None priority: int = Field(0, ge=0, le=3) due_date: Optional[date] = None + planned_date: Optional[date] = None start_time: Optional[Time] = None end_time: Optional[Time] = None tags: list[str] = [] @@ -239,7 +244,7 @@ class TaskIn(BaseModel): @model_validator(mode="after") def _times(self): - error = check_times(self.due_date, fmt_time(self.start_time), fmt_time(self.end_time)) + error = check_times(self.planned_date or self.due_date, fmt_time(self.start_time), fmt_time(self.end_time)) if error: raise ValueError(error) return self @@ -249,8 +254,10 @@ class TaskPatch(BaseModel): title: Optional[str] = Field(None, max_length=500) notes: Optional[str] = Field(None, max_length=20000) category_id: Optional[int] = None + project_id: Optional[int] = None priority: Optional[int] = Field(None, ge=0, le=3) due_date: Optional[date] = None + planned_date: Optional[date] = None start_time: Optional[Time] = None end_time: Optional[Time] = None tags: Optional[list[str]] = None @@ -277,6 +284,26 @@ class CategoryPatch(BaseModel): position: Optional[int] = None +class ProjectIn(BaseModel): + name: str = Field(max_length=100) + notes: str = Field("", max_length=20000) + category_id: Optional[int] = None + due_date: Optional[date] = None + done: bool = False + + _name = field_validator("name")(clean_title) + + +class ProjectPatch(BaseModel): + name: Optional[str] = Field(None, max_length=100) + notes: Optional[str] = Field(None, max_length=20000) + category_id: Optional[int] = None + due_date: Optional[date] = None + done: Optional[bool] = None + + _name = field_validator("name")(clean_title) + + class ExportCategory(BaseModel): id: int name: str = Field(min_length=1, max_length=50) @@ -291,10 +318,18 @@ class ExportTask(TaskIn): updated_at: Optional[str] = None +class ExportProject(ProjectIn): + id: int + 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] = [] + projects: list[ExportProject] = [] tasks: list[ExportTask] = [] @@ -306,8 +341,10 @@ def row_to_task(r: sqlite3.Row, subtasks: list[dict]) -> dict: "title": r["title"], "notes": r["notes"], "category_id": r["category_id"], + "project_id": r["project_id"], "priority": r["priority"], "due_date": r["due_date"], + "planned_date": r["planned_date"], "start_time": r["start_time"], "end_time": r["end_time"], "tags": json.loads(r["tags"]), @@ -345,6 +382,17 @@ def ensure_category(conn: sqlite3.Connection, category_id: Optional[int]) -> Non raise HTTPException(400, "Unknown category") +def ensure_project(conn: sqlite3.Connection, project_id: Optional[int]) -> None: + if project_id is not None and not conn.execute( + "SELECT 1 FROM projects WHERE id = ?", (project_id,) + ).fetchone(): + raise HTTPException(400, "Unknown project") + + +def iso(d) -> Optional[str]: + return d.isoformat() if isinstance(d, date) else d + + 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): @@ -357,14 +405,14 @@ def write_subtasks(conn: sqlite3.Connection, task_id: int, subtasks: list[dict]) 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, start_time, end_time, - tags, recurrence, recur_interval, done, done_at, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + """INSERT INTO tasks (title, notes, category_id, project_id, priority, due_date, planned_date, + start_time, end_time, 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, + t["title"], t.get("notes", ""), t.get("category_id"), t.get("project_id"), t.get("priority", 0), + iso(t.get("due_date")), iso(t.get("planned_date")), fmt_time(t.get("start_time")), fmt_time(t.get("end_time")), 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, @@ -436,6 +484,7 @@ def get_tasks(conn=Depends(get_conn)): @app.post("/api/tasks", status_code=201) def create_task(body: TaskIn, conn=Depends(get_conn)): ensure_category(conn, body.category_id) + ensure_project(conn, body.project_id) with conn: task_id = insert_task(conn, body.model_dump()) return get_task(conn, task_id) @@ -451,15 +500,18 @@ def update_task(task_id: int, body: TaskPatch, conn=Depends(get_conn)): del fields[key] # these columns can't be cleared, only changed if "category_id" in fields: ensure_category(conn, fields["category_id"]) + if "project_id" in fields: + ensure_project(conn, fields["project_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() + for key in ("due_date", "planned_date"): + if key in fields: + fields[key] = iso(fields[key]) for key in ("start_time", "end_time"): if key in fields: fields[key] = fmt_time(fields[key]) - merged = {k: fields.get(k, current[k]) for k in ("due_date", "start_time", "end_time")} - error = check_times(merged["due_date"], merged["start_time"], merged["end_time"]) + merged = {k: fields.get(k, current[k]) for k in ("due_date", "planned_date", "start_time", "end_time")} + error = check_times(merged["planned_date"] or merged["due_date"], merged["start_time"], merged["end_time"]) if error: raise HTTPException(422, error) fields["updated_at"] = now_iso() @@ -493,8 +545,15 @@ def complete_task(task_id: int, body: CompleteIn, conn=Depends(get_conn)): 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 + planned = date.fromisoformat(task["planned_date"]) if task["planned_date"] else None nxt = dict(task) - nxt["due_date"] = next_due(due, task["recurrence"], task["recur_interval"], today) + if due: + # The deadline drives the series; a plan date keeps its distance to the deadline. + nxt["due_date"] = next_due(due, task["recurrence"], task["recur_interval"], today) + nxt["planned_date"] = planned + (nxt["due_date"] - due) if planned else None + else: + # No deadline: repeat on plan dates, so the task never shows up as overdue. + nxt["planned_date"] = next_due(planned, 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} @@ -510,6 +569,80 @@ def reopen_task(task_id: int, conn=Depends(get_conn)): return get_task(conn, task_id) +# --- Project endpoints ------------------------------------------------------- + +def get_project(conn: sqlite3.Connection, project_id: int) -> dict: + row = conn.execute("SELECT * FROM projects WHERE id = ?", (project_id,)).fetchone() + if row is None: + raise HTTPException(404, "Project not found") + return {**dict(row), "done": bool(row["done"])} + + +def list_projects(conn: sqlite3.Connection) -> list[dict]: + return [{**dict(r), "done": bool(r["done"])} for r in conn.execute("SELECT * FROM projects ORDER BY id")] + + +def insert_project(conn: sqlite3.Connection, p: dict) -> int: + now = now_iso() + done = bool(p.get("done")) + cur = conn.execute( + """INSERT INTO projects (name, notes, category_id, due_date, done, done_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + ( + p["name"], p.get("notes", ""), p.get("category_id"), iso(p.get("due_date")), int(done), + p.get("done_at") or (now if done else None), p.get("created_at") or now, p.get("updated_at") or now, + ), + ) + return cur.lastrowid + + +@app.get("/api/projects") +def get_projects(conn=Depends(get_conn)): + return list_projects(conn) + + +@app.post("/api/projects", status_code=201) +def create_project(body: ProjectIn, conn=Depends(get_conn)): + ensure_category(conn, body.category_id) + with conn: + project_id = insert_project(conn, body.model_dump()) + return get_project(conn, project_id) + + +@app.patch("/api/projects/{project_id}") +def update_project(project_id: int, body: ProjectPatch, conn=Depends(get_conn)): + current = get_project(conn, project_id) + fields = body.model_dump(exclude_unset=True) + for key in ("name", "notes", "done"): + if key in fields and fields[key] is None: + del fields[key] + if "category_id" in fields: + ensure_category(conn, fields["category_id"]) + if "due_date" in fields: + fields["due_date"] = iso(fields["due_date"]) + if "done" in fields: + if fields["done"] and not current["done"]: + fields["done_at"] = now_iso() + elif not fields["done"]: + fields["done_at"] = None + fields["done"] = int(fields["done"]) + fields["updated_at"] = now_iso() + with conn: + conn.execute( + f"UPDATE projects SET {', '.join(f'{k} = ?' for k in fields)} WHERE id = ?", + (*fields.values(), project_id), + ) + return get_project(conn, project_id) + + +@app.delete("/api/projects/{project_id}", status_code=204) +def delete_project(project_id: int, conn=Depends(get_conn)): + """Deletes the project only; its tasks are kept and simply no longer belong to a project.""" + with conn: + conn.execute("DELETE FROM projects WHERE id = ?", (project_id,)) + return Response(status_code=204) + + # --- Export / import --------------------------------------------------------- @app.get("/api/export") @@ -520,6 +653,7 @@ def export_data(conn=Depends(get_conn)): "version": EXPORT_VERSION, "exported_at": now_iso(), "categories": list_categories(conn), + "projects": list_projects(conn), "tasks": load_tasks(conn), } return Response( @@ -538,6 +672,7 @@ def import_data(data: ExportData, mode: Literal["replace", "merge"] = "merge", c if mode == "replace": conn.execute("DELETE FROM subtasks") conn.execute("DELETE FROM tasks") + conn.execute("DELETE FROM projects") 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] @@ -552,12 +687,19 @@ def import_data(data: ExportData, mode: Literal["replace", "merge"] = "merge", c existing[key] = cur.lastrowid next_pos += 1 cat_map[c.id] = existing[key] + project_map: dict[int, int] = {} + for p in data.projects: + fields = p.model_dump() + fields["category_id"] = cat_map.get(p.category_id) if p.category_id is not None else None + project_map[p.id] = insert_project(conn, fields) 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 + fields["project_id"] = project_map.get(t.project_id) if t.project_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)} + return {"ok": True, "mode": mode, "categories": len(data.categories), "projects": len(data.projects), + "tasks": len(data.tasks)} # Static frontend (must be mounted last so it doesn't shadow /api routes). diff --git a/app/static/app.js b/app/static/app.js index 360fbed..3281921 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -4,15 +4,23 @@ const VIEWS = [ { id: 'today', label: 'Today', icon: '☀️' }, { id: 'upcoming', label: 'Upcoming', icon: '📅' }, { id: 'calendar', label: 'Calendar', icon: '🗓️' }, + { id: 'backlog', label: 'Backlog', 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 HOUR_PX = 48; +const BACKLOG_SUGGESTIONS = 2; -const state = { tasks: [], categories: [], view: 'today', categoryId: null, search: '', calDate: null }; +const state = { + tasks: [], categories: [], projects: [], + view: 'today', categoryId: null, projectId: null, + search: '', calDate: null, showDoneProjects: false, +}; let editing = null; // task open in the dialog; null when creating +let editingProject = null; +let planning = null; // { task, mode } for the postpone sheet let draftSubtasks = []; let toastTimer = null; @@ -48,7 +56,6 @@ const dayDiff = (a, b) => { const [by, bm, bd] = b.split('-').map(Number); return Math.round((Date.UTC(by, bm - 1, bd) - Date.UTC(ay, am - 1, ad)) / 86400000); }; - const addMonths = (s, n) => { const [y, m, d] = s.split('-').map(Number); const total = m - 1 + n; @@ -64,6 +71,41 @@ function stepDate(s, recurrence, n) { return addMonths(s, 12 * n); } +// Named dates used by the postpone sheet and the "Plan:" shortcuts. +function namedDate(name) { + const t = todayStr(); + const dow = parseDate(t).getDay(); // 0 = Sunday + const nextMonday = addDays(t, ((8 - dow) % 7) || 7); + switch (name) { + case 'today': return t; + case 'tomorrow': return addDays(t, 1); + case 'weekend': return dow === 6 || dow === 0 ? addDays(nextMonday, 5) : addDays(t, 6 - dow); + case 'nextweek': return nextMonday; + case 'twoweeks': return addDays(nextMonday, 7); + case 'nextmonth': { const d = parseDate(t); return fmtDate(new Date(d.getFullYear(), d.getMonth() + 1, 1)); } + default: return null; + } +} + +function dayName(day) { + const diff = dayDiff(todayStr(), day); + const d = parseDate(day); + if (diff === 0) return 'Today'; + if (diff === 1) return 'Tomorrow'; + if (diff === -1) return 'Yesterday'; + if (diff > 1 && diff < 7) return d.toLocaleDateString(undefined, { weekday: 'long' }); + const opts = { weekday: 'short', day: 'numeric', month: 'short' }; + if (d.getFullYear() !== new Date().getFullYear()) opts.year = 'numeric'; + return d.toLocaleDateString(undefined, opts); +} + +function dueInfo(due) { + const diff = dayDiff(todayStr(), due); + let text = dayName(due); + if (diff < -1) text += ` · ${-diff} days late`; + return { text, cls: diff < 0 ? 'overdue' : diff === 0 ? 'today' : '' }; +} + const toMin = (t) => { const [hh, mm] = t.split(':').map(Number); return hh * 60 + mm; }; const fromMin = (m) => `${pad(Math.floor(m / 60))}:${pad(m % 60)}`; // A start time without an end counts as one hour. @@ -73,15 +115,27 @@ const span = (t) => { }; const timeText = (t) => (t.start_time ? (t.end_time ? `${t.start_time}–${t.end_time}` : t.start_time) : ''); -// Tasks on a given day, including future repeats of open recurring tasks (marked projected). +// --- Task date semantics ------------------------------------------------------ +// planned_date = when I intend to do it; due_date = when it must be done. +// The "when" date (plan, else deadline) decides where a task is scheduled; times apply to it. + +const whenDate = (t) => t.planned_date || t.due_date || null; +const isOverdue = (t, today) => !t.done && t.due_date && t.due_date < today; +// Planned for today or earlier (unfinished plans roll over), or due today. +const isForToday = (t, today) => !t.done && !isOverdue(t, today) + && ((t.planned_date && t.planned_date <= today) || t.due_date === today); +const isBacklog = (t) => !t.done && !t.planned_date && !t.due_date; + +// Tasks on a given day (by "when" date), including future repeats of open recurring tasks. function itemsOn(day) { const out = []; for (const t of state.tasks) { - if (!t.due_date) continue; - if (t.due_date === day) { + const w = whenDate(t); + if (!w) continue; + if (w === day) { out.push({ task: t, projected: false }); - } else if (!t.done && t.recurrence !== 'none' && t.due_date < day) { - let d = t.due_date; + } else if (!t.done && t.recurrence !== 'none' && w < day) { + let d = w; for (let i = 0; d < day && i < 2000; i++) d = stepDate(d, t.recurrence, t.recur_interval); if (d === day) out.push({ task: t, projected: true }); } @@ -89,23 +143,6 @@ function itemsOn(day) { return out; } -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 } = {}) { @@ -138,8 +175,11 @@ async function api(path, { method = 'GET', body } = {}) { } async function loadAll() { - const [categories, tasks] = await Promise.all([api('api/categories'), api('api/tasks')]); + const [categories, projects, tasks] = await Promise.all([ + api('api/categories'), api('api/projects'), api('api/tasks'), + ]); state.categories = categories; + state.projects = projects; state.tasks = tasks; } @@ -159,34 +199,48 @@ async function act(fn) { // --- Persisted UI prefs ------------------------------------------------------- function savePrefs() { - try { localStorage.setItem('whattodo.view', JSON.stringify({ view: state.view, categoryId: state.categoryId })); } catch { /* ignore */ } + try { + localStorage.setItem('whattodo.view', JSON.stringify({ + view: state.view, categoryId: state.categoryId, projectId: state.projectId, + })); + } 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; } + if (p && typeof p.view === 'string') { + state.view = p.view; + state.categoryId = p.categoryId ?? null; + state.projectId = p.projectId ?? null; + } } catch { /* ignore */ } } -// --- Filtering & grouping ----------------------------------------------------- +// --- Lookups, filtering & grouping --------------------------------------------- const catById = (id) => state.categories.find((c) => c.id === id); +const projectById = (id) => state.projects.find((p) => p.id === id); +const projectTasks = (p) => state.tasks.filter((t) => t.project_id === p.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(); + const proj = projectById(t.project_id); + const hay = [t.title, t.notes, cat ? cat.name : '', proj ? proj.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; + const wa = whenDate(a); + const wb = whenDate(b); + if (wa !== wb) { + if (!wa) return 1; + if (!wb) return -1; + return wa < wb ? -1 : 1; } if (a.start_time !== b.start_time) { if (!a.start_time) return 1; @@ -199,10 +253,18 @@ function cmpOpen(a, b) { function viewTasks() { let tasks = state.tasks; if (state.view === 'cat') tasks = tasks.filter((t) => (t.category_id || 0) === state.categoryId); + if (state.view === 'project') tasks = tasks.filter((t) => t.project_id === state.projectId); if (state.search.trim()) tasks = tasks.filter(matchesSearch); return tasks; } +// Backlog tasks worth suggesting for today: highest priority first, then the oldest. +function backlogSuggestions() { + return state.tasks.filter((t) => isBacklog(t) && t.priority < 3) + .sort((a, b) => (b.priority - a.priority) || (a.id - b.id)) + .slice(0, BACKLOG_SUGGESTIONS); +} + function buildGroups(tasks) { const t = todayStr(); const tomorrow = addDays(t, 1); @@ -212,75 +274,179 @@ function buildGroups(tasks) { 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; + const group = (title, pred, extra = {}) => ({ title, tasks: open.filter(pred), ...extra }); + const overdue = (x) => isOverdue(x, t); + const today = (x) => isForToday(x, t); + const later = (x) => !overdue(x) && !today(x); + if (state.view === 'today') { + const shown = new Set(open.filter((x) => overdue(x) || today(x)).map((x) => x.id)); return [ group('Overdue', overdue), group('Today', today), - group('High priority', (x) => x.priority === 3 && (!x.due_date || x.due_date > t)), + // High priority things you haven't scheduled yet keep showing up until you plan them. + group('High priority, not planned', (x) => x.priority === 3 && !shown.has(x.id) && !x.planned_date), + { title: 'From your backlog', tasks: state.search ? [] : backlogSuggestions(), hint: 'No date yet. Plan one for today?' }, ]; } 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), + group('Tomorrow', (x) => later(x) && whenDate(x) === tomorrow), + group('Next 7 days', (x) => later(x) && whenDate(x) > tomorrow && whenDate(x) <= week), + group('Later', (x) => later(x) && whenDate(x) > week), ]; } - return [ + if (state.view === 'backlog') { + const backlog = (p) => (x) => isBacklog(x) && x.priority === p; + return [ + group('High priority', backlog(3)), + group('Medium priority', backlog(2)), + group('Low priority', backlog(1)), + group('No priority', backlog(0)), + ]; + } + const groups = [ 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), + group('Next 7 days', (x) => later(x) && whenDate(x) && whenDate(x) <= week), + group('Later', (x) => later(x) && whenDate(x) > week), + group('No date', isBacklog), ]; + if (state.view === 'project') { + const done = tasks.filter((x) => x.done).sort((a, b) => (b.done_at || '').localeCompare(a.done_at || '')); + groups.push({ title: 'Completed', tasks: done }); + } + return groups; } function counts() { const t = todayStr(); const open = state.tasks.filter((x) => !x.done); + const overdue = open.filter((x) => isOverdue(x, t)).length; 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, + today: open.filter((x) => isOverdue(x, t) || isForToday(x, t) || (x.priority === 3 && !x.planned_date)).length, + upcoming: open.filter((x) => !isOverdue(x, t) && !isForToday(x, t) && whenDate(x)).length, + backlog: open.filter(isBacklog).length, all: open.length, done: null, - overdue: open.filter((x) => x.due_date && x.due_date < t).length, + calendar: null, + overdue, cats: {}, + projects: {}, }; - for (const x of open) c.cats[x.category_id || 0] = (c.cats[x.category_id || 0] || 0) + 1; + for (const x of open) { + c.cats[x.category_id || 0] = (c.cats[x.category_id || 0] || 0) + 1; + if (x.project_id) c.projects[x.project_id] = (c.projects[x.project_id] || 0) + 1; + } return c; } +// Progress and warnings for a project. +function projectHealth(p) { + const t = todayStr(); + const tasks = projectTasks(p); + const total = tasks.length; + const done = tasks.filter((x) => x.done).length; + const open = tasks.filter((x) => !x.done).sort(cmpOpen); + const info = { total, done, pct: total ? Math.round((done / total) * 100) : 0, next: open[0] || null, warn: null, daysLeft: null }; + if (p.done) return info; + const started = p.created_at.slice(0, 10); + if (p.due_date) { + info.daysLeft = dayDiff(t, p.due_date); + const totalDays = dayDiff(started, p.due_date); + const timeFrac = totalDays > 0 ? Math.min(1, dayDiff(started, t) / totalDays) : 1; + if (info.daysLeft < 0) info.warn = 'Deadline passed'; + else if (total && timeFrac - done / total > 0.25) { + info.warn = `Behind: ${Math.round(timeFrac * 100)}% of the time used, ${info.pct}% done`; + } + } + if (!info.warn && !total) info.warn = 'No tasks yet. Break it into steps.'; + if (!info.warn && open.length && dayDiff(started, t) >= 7) { + const lastDone = tasks.map((x) => x.done_at || '').sort().pop(); + if (!lastDone || dayDiff(lastDone.slice(0, 10), t) >= 7) info.warn = 'Nothing done on this in over a week'; + } + return info; +} + +function daysLeftText(n) { + if (n == null) return ''; + if (n < 0) return `${-n} days overdue`; + if (n === 0) return 'due today'; + if (n === 1) return '1 day left'; + if (n < 60) return `${n} days left`; + return `${Math.round(n / 7)} weeks left`; +} + +function sortedProjects() { + return [...state.projects].sort((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 a.id - b.id; + }); +} + // --- 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, +function navItem({ active, icon, label, count, onclick, color, alert, muted }) { + return h('button', { + type: 'button', class: 'nav-item' + (active ? ' active' : '') + (muted ? ' muted' : ''), 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, - ))); +function renderNav(c) { + $('#views').replaceChildren(...VIEWS.map((v) => navItem({ + active: state.view === v.id, icon: v.icon, label: v.label, count: c[v.id], + onclick: () => setView(v.id), alert: 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, - )); + const projects = sortedProjects(); + const active = projects.filter((p) => !p.done); + const finished = projects.filter((p) => p.done); + const projItem = (p) => navItem({ + active: state.view === 'project' && state.projectId === p.id, icon: p.done ? '✔' : '📁', label: p.name, + count: c.projects[p.id], onclick: () => setView('project', { projectId: p.id }), muted: p.done, + alert: !p.done && p.due_date && p.due_date < todayStr(), + }); + const items = active.map(projItem); + if (finished.length) { + items.push(h('button', { + type: 'button', class: 'nav-toggle', + onclick: () => { state.showDoneProjects = !state.showDoneProjects; render(); }, + }, state.showDoneProjects ? 'Hide finished' : `Show finished (${finished.length})`)); + if (state.showDoneProjects) items.push(...finished.map(projItem)); + } + if (!projects.length) items.push(h('div', { class: 'nav-empty' }, 'No projects yet')); + $('#projects').replaceChildren(...items); + + const catItems = state.categories.map((cat) => navItem({ + active: state.view === 'cat' && state.categoryId === cat.id, label: cat.name, count: c.cats[cat.id], + onclick: () => setView('cat', { categoryId: cat.id }), color: 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)')); + catItems.push(navItem({ + active: state.view === 'cat' && state.categoryId === 0, label: 'No category', count: c.cats[0], + onclick: () => setView('cat', { categoryId: 0 }), color: 'var(--muted)', + })); } $('#cats').replaceChildren(...catItems); +} + +function render() { + if (state.view === 'cat' && state.categoryId && !catById(state.categoryId)) state.view = 'today'; + if (state.view === 'project' && !projectById(state.projectId)) state.view = 'today'; + if (!['cat', 'project'].includes(state.view) && !VIEWS.some((v) => v.id === state.view)) state.view = 'today'; + + renderNav(counts()); let title = (VIEWS.find((v) => v.id === state.view) || {}).label; - if (state.view === 'cat') title = state.categoryId ? (catById(state.categoryId) || {}).name : 'No category'; + if (state.view === 'cat') title = state.categoryId ? catById(state.categoryId).name : 'No category'; + if (state.view === 'project') title = projectById(state.projectId).name; $('#view-title').textContent = title || ''; document.title = `${title} · WhatToDo`; $('#quick-add').hidden = state.view === 'done'; @@ -291,29 +457,102 @@ function render() { return; } - const groups = buildGroups(viewTasks()).filter((g) => g.tasks.length); - if (!groups.length) { + const sections = []; + if (state.view === 'project') sections.push(projectHeader(projectById(state.projectId))); + const groups = buildGroups(viewTasks()); + if (state.view === 'today' && !state.search) { + const cards = sortedProjects().filter((p) => !p.done).map(projectCard); + // Projects go right after the things that are due/planned today. + const at = groups.findIndex((g) => g.title === 'High priority, not planned'); + if (cards.length) groups.splice(at, 0, { title: 'Projects', nodes: cards }); + } + for (const g of groups) { + const items = g.nodes || g.tasks.map(taskRow); + if (!items.length) continue; + sections.push(h('section', { class: 'group' }, + h('h3', { class: 'group-title' + (g.title === 'Overdue' ? ' overdue' : '') }, + g.title, g.tasks ? h('span', { class: 'muted' }, ` ${g.tasks.length}`) : null), + g.hint ? h('p', { class: 'group-hint' }, g.hint) : null, + items)); + } + if (sections.length === (state.view === 'project' ? 1 : 0)) { 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; + : state.view === 'today' ? 'Nothing planned for today. 🎉' + : state.view === 'backlog' ? 'Backlog is empty: everything has a date.' + : state.view === 'project' ? 'No tasks in this project yet. Add the first step above.' + : 'No tasks here.'; + sections.push(h('div', { class: 'empty' }, msg)); } - $('#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)))); + $('#list').replaceChildren(...sections); +} + +function progressBar(pct) { + return h('div', { class: 'progress', role: 'progressbar', 'aria-valuenow': String(pct), 'aria-valuemin': '0', 'aria-valuemax': '100' }, + h('div', { class: 'progress-fill', style: { width: `${pct}%` } })); +} + +function projectCard(p) { + const info = projectHealth(p); + const cat = catById(p.category_id); + return h('div', { + class: 'project-card', tabindex: '0', + onclick: () => setView('project', { projectId: p.id }), + onkeydown: (e) => { if (e.key === 'Enter') setView('project', { projectId: p.id }); }, + style: { borderLeftColor: cat ? cat.color : 'var(--accent)' }, + }, + h('div', { class: 'project-card-head' }, + h('span', { class: 'project-name' }, '📁 ', p.name), + h('span', { class: 'muted small' }, [ + p.due_date ? `${dayName(p.due_date)} · ${daysLeftText(info.daysLeft)}` : 'no deadline', + ])), + h('div', { class: 'project-progress' }, progressBar(info.pct), h('span', { class: 'small muted' }, `${info.done}/${info.total}`)), + info.next ? h('div', { class: 'project-next' }, h('span', { class: 'muted' }, 'Next: '), info.next.title, + whenDate(info.next) ? h('span', { class: 'muted' }, ` · ${dayName(whenDate(info.next))}`) : null) : null, + info.warn ? h('div', { class: 'project-warn' }, '⚠ ', info.warn) : null); +} + +function projectHeader(p) { + const info = projectHealth(p); + const cat = catById(p.category_id); + return h('section', { class: 'project-header' }, + h('div', { class: 'project-header-top' }, + h('div', { class: 'task-meta' }, + cat ? h('span', { class: 'chip' }, h('span', { class: 'dot', style: { background: cat.color } }), cat.name) : null, + p.due_date ? h('span', { class: `chip due ${info.daysLeft < 0 ? 'overdue' : ''}` }, '🏁 ', dayName(p.due_date), ` · ${daysLeftText(info.daysLeft)}`) : null, + p.done ? h('span', { class: 'chip' }, '✔ Finished') : null), + h('button', { type: 'button', class: 'btn', onclick: () => openProject(p) }, 'Edit project')), + h('div', { class: 'project-progress big' }, progressBar(info.pct), + h('span', { class: 'small' }, `${info.done}/${info.total} done (${info.pct}%)`)), + info.warn ? h('div', { class: 'project-warn' }, '⚠ ', info.warn) : null, + p.notes.trim() ? h('p', { class: 'project-notes' }, p.notes) : null); } function taskRow(task) { + const t = todayStr(); 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)); } + const proj = projectById(task.project_id); + if (proj && state.view !== 'project') { + meta.push(h('button', { + type: 'button', class: 'chip link-chip', title: 'Open project', + onclick: (e) => { e.stopPropagation(); setView('project', { projectId: proj.id }); }, + }, '📁 ', proj.name)); + } + const time = timeText(task); + if (task.planned_date) { + const diff = dayDiff(t, task.planned_date); + const label = diff < 0 && !task.done ? `from ${dayName(task.planned_date)}` : dayName(task.planned_date); + meta.push(h('span', { class: 'chip plan' + (diff <= 0 && !task.done ? ' now' : ''), title: 'Planned to do on' }, + '▶ ', label, time ? ` · ${time}` : '')); + } if (task.due_date) { const d = dueInfo(task.due_date); - const time = timeText(task); - meta.push(h('span', { class: `chip due ${task.done ? '' : d.cls}` }, '📅 ', d.text, time ? ` · ${time}` : '')); + meta.push(h('span', { class: `chip due ${task.done ? '' : d.cls}`, title: 'Deadline' }, + '📅 ', task.planned_date ? 'due ' : '', d.text, !task.planned_date && time ? ` · ${time}` : '')); } if (task.priority) meta.push(h('span', { class: `chip prio-label p${task.priority}` }, '⚑ ', PRIORITY_LABELS[task.priority])); if (task.recurrence !== 'none') { @@ -344,7 +583,11 @@ function taskRow(task) { }, task.done ? '✓' : ''), h('div', { class: 'task-body' }, h('div', { class: 'task-title' }, task.title), - meta.length ? h('div', { class: 'task-meta' }, meta) : null)); + meta.length ? h('div', { class: 'task-meta' }, meta) : null), + task.done ? null : h('button', { + type: 'button', class: 'icon-btn plan-btn', 'aria-label': 'Plan or postpone', title: 'Plan / postpone', + onclick: (e) => { e.stopPropagation(); openPlan(task); }, + }, '⏭')); } // --- Calendar ----------------------------------------------------------------- @@ -398,6 +641,9 @@ function renderCalendar() { const items = itemsOn(day); const timed = items.filter((it) => it.task.start_time); const untimed = items.filter((it) => !it.task.start_time).map((it) => it.task).sort(cmpOpen); + // Deadlines that fall on this day but are planned for another day, and project deadlines. + const deadlines = state.tasks.filter((t) => t.due_date === day && t.planned_date && t.planned_date !== day); + const projectDeadlines = state.projects.filter((p) => !p.done && p.due_date === day); let startHour = 7; let endHour = 22; @@ -412,7 +658,9 @@ function renderCalendar() { grid.append(h('div', { class: 'hour', style: { top: `${(hr - startHour) * HOUR_PX}px`, height: `${HOUR_PX}px` }, title: 'Add a task at this time', - onclick: () => openTask(null, { due_date: day, start_time: fromMin(hr * 60), end_time: fromMin(Math.min(hr * 60 + 60, 23 * 60 + 59)) }), + onclick: () => openTask(null, { + planned_date: day, start_time: fromMin(hr * 60), end_time: fromMin(Math.min(hr * 60 + 60, 23 * 60 + 59)), + }), }, h('span', { class: 'hour-label' }, `${pad(hr)}:00`))); } for (const it of layoutBlocks(timed)) { @@ -444,6 +692,14 @@ function renderCalendar() { } const sections = [weekStrip(day)]; + if (deadlines.length || projectDeadlines.length) { + sections.push(h('section', { class: 'group' }, + h('h3', { class: 'group-title' }, 'Deadlines this day'), + projectDeadlines.map((p) => h('button', { + type: 'button', class: 'deadline-line', onclick: () => setView('project', { projectId: p.id }), + }, '🏁 ', h('strong', {}, p.name), ' (project)')), + deadlines.map(taskRow))); + } if (untimed.length) { sections.push(h('section', { class: 'group' }, h('h3', { class: 'group-title' }, 'No time set', h('span', { class: 'muted' }, ` ${untimed.length}`)), @@ -460,9 +716,10 @@ function setCalDate(day) { // --- Actions ------------------------------------------------------------------ -function setView(view, categoryId = null) { +function setView(view, { categoryId = null, projectId = null } = {}) { state.view = view; state.categoryId = categoryId; + state.projectId = projectId; savePrefs(); document.body.classList.remove('nav-open'); render(); @@ -482,7 +739,7 @@ function toggleDone(task) { } 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}.` : ''; + const extra = res.next ? ` Next one: ${dayName(whenDate(res.next))}.` : ''; toast(`Done: ${task.title}.${extra}`, { label: 'Undo', fn: () => act(async () => { @@ -493,13 +750,23 @@ function toggleDone(task) { }); } +// Defaults for a task created from the current view. +function viewDefaults() { + const d = {}; + if (state.view === 'cat' && state.categoryId) d.category_id = state.categoryId; + if (state.view === 'project') { + const p = projectById(state.projectId); + d.project_id = p.id; + if (p.category_id) d.category_id = p.category_id; + } + if (state.view === 'today') d.planned_date = todayStr(); + if (state.view === 'upcoming') d.planned_date = addDays(todayStr(), 1); + if (state.view === 'calendar') d.planned_date = state.calDate || todayStr(); + return d; +} + 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); - if (state.view === 'calendar') body.due_date = state.calDate || todayStr(); - act(() => api('api/tasks', { method: 'POST', body })); + act(() => api('api/tasks', { method: 'POST', body: { title, ...viewDefaults() } })); } function toast(message, action) { @@ -516,12 +783,69 @@ function toast(message, action) { toastTimer = setTimeout(() => { el.hidden = true; }, action ? 6000 : 4000); } +// --- Postpone / plan sheet ---------------------------------------------------- + +const PLAN_OPTIONS = [ + ['today', 'Today'], ['tomorrow', 'Tomorrow'], ['weekend', 'This weekend'], + ['nextweek', 'Next week'], ['twoweeks', 'In 2 weeks'], ['nextmonth', 'Next month'], +]; + +function openPlan(task) { + // Overdue tasks can't be fixed by planning, so default to moving the deadline for them. + const mode = task.due_date && task.due_date < todayStr() ? 'due' : 'plan'; + planning = { task, mode }; + $('#p-title').textContent = task.title; + renderPlan(); + $('#plan-dialog').showModal(); +} + +function renderPlan() { + const { task, mode } = planning; + const field = mode === 'plan' ? 'planned_date' : 'due_date'; + $('#p-mode').hidden = !task.due_date; + for (const btn of $('#p-mode').querySelectorAll('button')) btn.classList.toggle('active', btn.dataset.mode === mode); + const current = task[field]; + $('#p-current').textContent = mode === 'plan' + ? (current ? `Planned for ${dayName(current)}. Move it to:` : 'Not planned yet. Do it on:') + : `Deadline is ${dayName(current)}. Move it to:`; + $('#p-options').replaceChildren(...PLAN_OPTIONS.map(([key, label]) => { + const date = namedDate(key); + if (key === 'weekend' && [0, 6].includes(parseDate(todayStr()).getDay())) label = 'Next weekend'; + return h('button', { type: 'button', class: 'plan-option', onclick: () => applyPlan(date) }, + h('span', {}, label), + h('span', { class: 'muted small' }, parseDate(date).toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short' }))); + })); + $('#p-date').value = current || ''; + $('#p-clear').textContent = mode === 'plan' ? 'Remove plan date' : 'Remove deadline'; + $('#p-clear').hidden = !current; +} + +function applyPlan(date) { + const { task, mode } = planning; + const field = mode === 'plan' ? 'planned_date' : 'due_date'; + const before = { + planned_date: task.planned_date, due_date: task.due_date, start_time: task.start_time, end_time: task.end_time, + }; + const body = { [field]: date }; + const after = { ...before, ...body }; + if (!after.planned_date && !after.due_date) { body.start_time = null; body.end_time = null; } + $('#plan-dialog').close(); + act(async () => { + await api(`api/tasks/${task.id}`, { method: 'PATCH', body }); + const what = mode === 'plan' ? 'Planned for' : 'Deadline moved to'; + toast(date ? `${what} ${dayName(date)}.` : (mode === 'plan' ? 'Plan date removed.' : 'Deadline removed.'), { + label: 'Undo', + fn: () => act(() => api(`api/tasks/${task.id}`, { method: 'PATCH', body: before })), + }); + }); +} + // --- Task dialog -------------------------------------------------------------- -function fillCategorySelect(select, selected) { +function fillSelect(select, items, selected, noneLabel = '— None —') { select.replaceChildren( - h('option', { value: '' }, '— None —'), - ...state.categories.map((c) => h('option', { value: String(c.id) }, c.name)), + h('option', { value: '' }, noneLabel), + ...items.map((x) => h('option', { value: String(x.id) }, x.name)), ); select.value = selected ? String(selected) : ''; } @@ -533,13 +857,16 @@ function updateIntervalVisibility() { function openTask(task, defaults = {}) { editing = task; const t = task || { - title: '', notes: '', category_id: null, priority: 0, due_date: null, + title: '', notes: '', category_id: null, project_id: null, priority: 0, due_date: null, planned_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); + fillSelect($('#t-category'), state.categories, t.category_id); + // Finished projects are only listed for the task already in one. + fillSelect($('#t-project'), sortedProjects().filter((p) => !p.done || p.id === t.project_id), t.project_id); $('#t-priority').value = String(t.priority); + $('#t-planned').value = t.planned_date || ''; $('#t-due').value = t.due_date || ''; $('#t-start').value = t.start_time || ''; $('#t-end').value = t.end_time || ''; @@ -557,9 +884,9 @@ function openTask(task, defaults = {}) { if (!task) $('#t-title').focus(); } -// Times only make sense with a date; show what else is scheduled that day and flag overlaps. +// Times apply to the plan date (or the deadline). Show what else is scheduled that day and flag overlaps. function updateTimeFields() { - const day = $('#t-due').value; + const day = $('#t-planned').value || $('#t-due').value; for (const id of ['#t-start', '#t-end']) { $(id).disabled = !day; if (!day) $(id).value = ''; @@ -582,7 +909,7 @@ function updateTimeFields() { }); box.replaceChildren( h('div', { class: 'day-head' + (clashes ? ' clash' : '') }, - clashes ? `⚠ Overlaps with ${clashes} item${clashes > 1 ? 's' : ''} on this day` : `Already planned on ${dueInfo(day).text.split(' · ')[0]}`), + clashes ? `⚠ Overlaps with ${clashes} item${clashes > 1 ? 's' : ''} on this day` : `Already planned on ${dayName(day)}`), h('ul', {}, rows)); box.hidden = false; } @@ -595,6 +922,11 @@ function onStartChange() { updateTimeFields(); } +function onProjectChange() { + const p = projectById(Number($('#t-project').value)); + if (p && p.category_id && !$('#t-category').value) $('#t-category').value = String(p.category_id); +} + 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; } }), @@ -621,7 +953,9 @@ function saveTask(e) { const body = { title: $('#t-title').value.trim(), category_id: $('#t-category').value ? Number($('#t-category').value) : null, + project_id: $('#t-project').value ? Number($('#t-project').value) : null, priority: Number($('#t-priority').value), + planned_date: $('#t-planned').value || null, due_date: $('#t-due').value || null, start_time: $('#t-start').value || null, end_time: ($('#t-start').value && $('#t-end').value) || null, @@ -646,6 +980,58 @@ function deleteTask() { act(() => api(`api/tasks/${task.id}`, { method: 'DELETE' })); } +// --- Project dialog ----------------------------------------------------------- + +function openProject(project) { + editingProject = project; + document.body.classList.remove('nav-open'); + const p = project || { name: '', notes: '', category_id: null, due_date: null, done: false }; + $('#pr-dialog-title').textContent = project ? 'Edit project' : 'New project'; + $('#pr-name').value = p.name; + fillSelect($('#pr-category'), state.categories, p.category_id); + $('#pr-due').value = p.due_date || ''; + $('#pr-notes').value = p.notes; + $('#pr-done').checked = p.done; + $('#pr-done').closest('label').hidden = !project; + $('#pr-delete').hidden = !project; + $('#project-dialog').showModal(); + if (!project) $('#pr-name').focus(); +} + +function saveProject(e) { + e.preventDefault(); + const body = { + name: $('#pr-name').value.trim(), + category_id: $('#pr-category').value ? Number($('#pr-category').value) : null, + due_date: $('#pr-due').value || null, + notes: $('#pr-notes').value, + done: $('#pr-done').checked, + }; + if (!body.name) return; + const project = editingProject; + $('#project-dialog').close(); + act(async () => { + if (project) { + await api(`api/projects/${project.id}`, { method: 'PATCH', body }); + } else { + const created = await api('api/projects', { method: 'POST', body }); + state.view = 'project'; + state.projectId = created.id; + savePrefs(); + toast('Project created. Add its steps as tasks, with deadlines for milestones.'); + } + }); +} + +function deleteProject() { + const p = editingProject; + if (!p) return; + const n = projectTasks(p).length; + if (!confirm(`Delete project "${p.name}"? Its ${n} task(s) are kept, just no longer in a project.`)) return; + $('#project-dialog').close(); + act(() => api(`api/projects/${p.id}`, { method: 'DELETE' })); +} + // --- Settings dialog ---------------------------------------------------------- function renderSettingsCategories() { @@ -690,12 +1076,12 @@ async function importFile() { } 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?` + ? `REPLACE: this deletes ALL current tasks, projects 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}).`); + toast(`Imported ${res.tasks} tasks, ${res.projects} projects and ${res.categories} categories (${res.mode}).`); }); $('#s-import-file').value = ''; renderSettingsCategories(); @@ -742,11 +1128,7 @@ function bind() { 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(); - if (state.view === 'calendar') defaults.due_date = state.calDate || todayStr(); - defaults.title = $('#quick-title').value.trim(); + const defaults = { ...viewDefaults(), title: $('#quick-title').value.trim() }; $('#quick-title').value = ''; openTask(null, defaults); }); @@ -761,25 +1143,42 @@ function bind() { $('#t-cancel').addEventListener('click', () => $('#task-dialog').close()); $('#t-delete').addEventListener('click', deleteTask); $('#t-repeat').addEventListener('change', updateIntervalVisibility); + $('#t-planned').addEventListener('change', updateTimeFields); $('#t-due').addEventListener('change', updateTimeFields); $('#t-start').addEventListener('change', onStartChange); $('#t-end').addEventListener('change', updateTimeFields); - - $('#cal-prev').addEventListener('click', () => setCalDate(addDays(state.calDate || todayStr(), -1))); - $('#cal-next').addEventListener('click', () => setCalDate(addDays(state.calDate || todayStr(), 1))); - $('#cal-today').addEventListener('click', () => setCalDate(todayStr())); - $('#cal-date').addEventListener('change', (e) => { if (e.target.value) setCalDate(e.target.value); }); + $('#t-project').addEventListener('change', onProjectChange); $('#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)); + $('#t-planned').value = namedDate(btn.dataset.plan) || ''; updateTimeFields(); }); } + // Calendar + $('#cal-prev').addEventListener('click', () => setCalDate(addDays(state.calDate || todayStr(), -1))); + $('#cal-next').addEventListener('click', () => setCalDate(addDays(state.calDate || todayStr(), 1))); + $('#cal-today').addEventListener('click', () => setCalDate(todayStr())); + $('#cal-date').addEventListener('change', (e) => { if (e.target.value) setCalDate(e.target.value); }); + + // Postpone sheet + for (const btn of $('#p-mode').querySelectorAll('button')) { + btn.addEventListener('click', () => { planning.mode = btn.dataset.mode; renderPlan(); }); + } + $('#p-set').addEventListener('click', () => { if ($('#p-date').value) applyPlan($('#p-date').value); }); + $('#p-clear').addEventListener('click', () => applyPlan(null)); + $('#p-cancel').addEventListener('click', () => $('#plan-dialog').close()); + + // Projects + $('#new-project').addEventListener('click', () => openProject(null)); + $('#project-form').addEventListener('submit', saveProject); + $('#pr-cancel').addEventListener('click', () => $('#project-dialog').close()); + $('#pr-delete').addEventListener('click', deleteProject); + // Settings dialog $('#settings-btn').addEventListener('click', () => { document.body.classList.remove('nav-open'); diff --git a/app/static/index.html b/app/static/index.html index 8e8182c..c652114 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -28,7 +28,12 @@