Add write operations to the web UI

Amolith created

Change summary

src/cmd/create.rs     |  56 ++---
src/cmd/dep.rs        |  65 ------
src/cmd/done.rs       |  11 
src/cmd/label.rs      |  23 --
src/cmd/log.rs        |  23 --
src/cmd/reopen.rs     |  11 
src/cmd/rm.rs         |  91 ++-------
src/cmd/update.rs     |  38 +--
src/cmd/webui.rs      | 427 ++++++++++++++++++++++++++++++++++++++++++++
src/lib.rs            |   1 
src/ops.rs            | 357 +++++++++++++++++++++++++++++++++++++
static/td.css         |  58 ++++++
static/td.js          |  50 +++-
templates/base.html   | 106 +++++++++++
templates/macros.html |   8 
templates/task.html   |  65 ++++++
16 files changed, 1,106 insertions(+), 284 deletions(-)

Detailed changes

src/cmd/create.rs πŸ”—

@@ -1,9 +1,9 @@
 use anyhow::{anyhow, Result};
-use loro::LoroMap;
 use std::path::Path;
 
 use crate::db;
 use crate::editor;
+use crate::ops;
 
 pub struct Opts<'a> {
     pub title: Option<&'a str>,
@@ -47,9 +47,7 @@ pub fn run(root: &Path, opts: Opts) -> Result<()> {
         )
     };
 
-    let ts = db::now_utc();
     let store = db::open(root)?;
-    let id = db::gen_id();
 
     let parent = if let Some(raw) = opts.parent {
         Some(db::resolve_task_id(&store, raw, false)?)
@@ -57,41 +55,29 @@ pub fn run(root: &Path, opts: Opts) -> Result<()> {
         None
     };
 
-    store.apply_and_persist(|doc| {
-        let tasks = doc.get_map("tasks");
-        let task = db::insert_task_map(&tasks, &id)?;
-
-        task.insert("title", title)?;
-        task.insert("description", desc)?;
-        task.insert("type", opts.task_type)?;
-        task.insert("priority", db::priority_label(opts.priority))?;
-        task.insert("status", db::status_label(db::Status::Open))?;
-        task.insert("effort", db::effort_label(opts.effort))?;
-        task.insert("parent", parent.as_ref().map(|p| p.as_str()).unwrap_or(""))?;
-        task.insert("created_at", ts.clone())?;
-        task.insert("updated_at", ts.clone())?;
-        task.insert("deleted_at", "")?;
-        task.insert_container("labels", LoroMap::new())?;
-        task.insert_container("blockers", LoroMap::new())?;
-        task.insert_container("logs", LoroMap::new())?;
-
-        if let Some(label_str) = opts.labels {
-            let labels = db::get_or_create_child_map(&task, "labels")?;
-            for lbl in label_str
-                .split(',')
+    let labels = opts
+        .labels
+        .map(|s| {
+            s.split(',')
                 .map(str::trim)
                 .filter(|l| !l.is_empty())
-            {
-                labels.insert(lbl, true)?;
-            }
-        }
-
-        Ok(())
-    })?;
+                .map(String::from)
+                .collect()
+        })
+        .unwrap_or_default();
 
-    let task = store
-        .get_task(&id, false)?
-        .ok_or_else(|| anyhow!("failed to reload created task"))?;
+    let task = ops::create_task(
+        &store,
+        ops::CreateOpts {
+            title: title.to_owned(),
+            description: desc.to_owned(),
+            task_type: opts.task_type.to_owned(),
+            priority: opts.priority,
+            effort: opts.effort,
+            parent,
+            labels,
+        },
+    )?;
 
     if opts.json {
         println!("{}", serde_json::to_string(&task)?);

src/cmd/dep.rs πŸ”—

@@ -1,9 +1,9 @@
-use anyhow::{anyhow, bail, Result};
-use std::collections::{HashMap, HashSet, VecDeque};
+use anyhow::Result;
 use std::path::Path;
 
 use crate::cli::DepAction;
 use crate::db;
+use crate::ops;
 
 pub fn run(root: &Path, action: &DepAction, json: bool) -> Result<()> {
     let store = db::open(root)?;
@@ -12,22 +12,7 @@ pub fn run(root: &Path, action: &DepAction, json: bool) -> Result<()> {
         DepAction::Add { child, parent } => {
             let child_id = db::resolve_task_id(&store, child, false)?;
             let parent_id = db::resolve_task_id(&store, parent, false)?;
-            if child_id == parent_id {
-                bail!("adding dependency would create a cycle");
-            }
-            if would_cycle(&store, &child_id, &parent_id)? {
-                bail!("adding dependency would create a cycle");
-            }
-            let ts = db::now_utc();
-            store.apply_and_persist(|doc| {
-                let tasks = doc.get_map("tasks");
-                let child_task = db::get_task_map(&tasks, &child_id)?
-                    .ok_or_else(|| anyhow!("task not found"))?;
-                let blockers = db::get_or_create_child_map(&child_task, "blockers")?;
-                blockers.insert(parent_id.as_str(), true)?;
-                child_task.insert("updated_at", ts.clone())?;
-                Ok(())
-            })?;
+            ops::add_dep(&store, &child_id, &parent_id)?;
             if json {
                 println!(
                     "{}",
@@ -44,16 +29,7 @@ pub fn run(root: &Path, action: &DepAction, json: bool) -> Result<()> {
         DepAction::Rm { child, parent } => {
             let child_id = db::resolve_task_id(&store, child, false)?;
             let parent_id = db::resolve_task_id(&store, parent, true)?;
-            let ts = db::now_utc();
-            store.apply_and_persist(|doc| {
-                let tasks = doc.get_map("tasks");
-                let child_task = db::get_task_map(&tasks, &child_id)?
-                    .ok_or_else(|| anyhow!("task not found"))?;
-                let blockers = db::get_or_create_child_map(&child_task, "blockers")?;
-                blockers.delete(parent_id.as_str())?;
-                child_task.insert("updated_at", ts.clone())?;
-                Ok(())
-            })?;
+            ops::remove_dep(&store, &child_id, &parent_id)?;
             if !json {
                 let c = crate::color::stdout_theme();
                 println!(
@@ -80,36 +56,3 @@ pub fn run(root: &Path, action: &DepAction, json: bool) -> Result<()> {
 
     Ok(())
 }
-
-fn would_cycle(store: &db::Store, child: &db::TaskId, parent: &db::TaskId) -> Result<bool> {
-    let tasks = store.list_tasks_unfiltered()?;
-    let mut graph: HashMap<String, HashSet<String>> = HashMap::new();
-    for task in tasks {
-        for blocker in task.blockers {
-            graph
-                .entry(task.id.as_str().to_string())
-                .or_default()
-                .insert(blocker.as_str().to_string());
-        }
-    }
-    graph
-        .entry(child.as_str().to_string())
-        .or_default()
-        .insert(parent.as_str().to_string());
-
-    let mut seen = HashSet::new();
-    let mut queue = VecDeque::from([parent.as_str().to_string()]);
-    while let Some(node) = queue.pop_front() {
-        if node == child.as_str() {
-            return Ok(true);
-        }
-        if !seen.insert(node.clone()) {
-            continue;
-        }
-        if let Some(nexts) = graph.get(&node) {
-            queue.extend(nexts.iter().cloned());
-        }
-    }
-
-    Ok(false)
-}

src/cmd/done.rs πŸ”—

@@ -2,22 +2,15 @@ use anyhow::Result;
 use std::path::Path;
 
 use crate::db;
+use crate::ops;
 
 pub fn run(root: &Path, ids: &[String], json: bool) -> Result<()> {
     let store = db::open(root)?;
-    let ts = db::now_utc();
 
     let mut closed = Vec::new();
     for raw in ids {
         let id = db::resolve_task_id(&store, raw, false)?;
-        store.apply_and_persist(|doc| {
-            let tasks = doc.get_map("tasks");
-            if let Some(task) = db::get_task_map(&tasks, &id)? {
-                task.insert("status", db::status_label(db::Status::Closed))?;
-                task.insert("updated_at", ts.clone())?;
-            }
-            Ok(())
-        })?;
+        ops::mark_done(&store, &id)?;
         closed.push(id);
     }
 

src/cmd/label.rs πŸ”—

@@ -4,6 +4,7 @@ use std::path::Path;
 
 use crate::cli::LabelAction;
 use crate::db;
+use crate::ops;
 
 pub fn run(root: &Path, action: &LabelAction, json: bool) -> Result<()> {
     let store = db::open(root)?;
@@ -11,16 +12,7 @@ pub fn run(root: &Path, action: &LabelAction, json: bool) -> Result<()> {
     match action {
         LabelAction::Add { id, label } => {
             let task_id = db::resolve_task_id(&store, id, false)?;
-            let ts = db::now_utc();
-            store.apply_and_persist(|doc| {
-                let tasks = doc.get_map("tasks");
-                let task =
-                    db::get_task_map(&tasks, &task_id)?.ok_or_else(|| anyhow!("task not found"))?;
-                let labels = db::get_or_create_child_map(&task, "labels")?;
-                labels.insert(label, true)?;
-                task.insert("updated_at", ts.clone())?;
-                Ok(())
-            })?;
+            ops::add_label(&store, &task_id, label)?;
 
             if json {
                 println!("{}", serde_json::json!({"id": task_id, "label": label}));
@@ -31,16 +23,7 @@ pub fn run(root: &Path, action: &LabelAction, json: bool) -> Result<()> {
         }
         LabelAction::Rm { id, label } => {
             let task_id = db::resolve_task_id(&store, id, false)?;
-            let ts = db::now_utc();
-            store.apply_and_persist(|doc| {
-                let tasks = doc.get_map("tasks");
-                let task =
-                    db::get_task_map(&tasks, &task_id)?.ok_or_else(|| anyhow!("task not found"))?;
-                let labels = db::get_or_create_child_map(&task, "labels")?;
-                labels.delete(label)?;
-                task.insert("updated_at", ts.clone())?;
-                Ok(())
-            })?;
+            ops::remove_label(&store, &task_id, label)?;
 
             if !json {
                 let c = crate::color::stdout_theme();

src/cmd/log.rs πŸ”—

@@ -1,32 +1,13 @@
 use anyhow::Result;
-use loro::LoroMap;
 use std::path::Path;
 
 use crate::db;
+use crate::ops;
 
 pub fn run(root: &Path, id: &str, message: &str, json: bool) -> Result<()> {
     let store = db::open(root)?;
     let task_id = db::resolve_task_id(&store, id, false)?;
-    let log_id = db::gen_id();
-    let ts = db::now_utc();
-
-    store.apply_and_persist(|doc| {
-        let tasks = doc.get_map("tasks");
-        let task =
-            db::get_task_map(&tasks, &task_id)?.ok_or_else(|| anyhow::anyhow!("task not found"))?;
-        let logs = db::get_or_create_child_map(&task, "logs")?;
-        let entry = logs.insert_container(log_id.as_str(), LoroMap::new())?;
-        entry.insert("timestamp", ts.clone())?;
-        entry.insert("message", message)?;
-        task.insert("updated_at", ts.clone())?;
-        Ok(())
-    })?;
-
-    let entry = db::LogEntry {
-        id: log_id,
-        timestamp: ts,
-        message: message.to_string(),
-    };
+    let entry = ops::add_log(&store, &task_id, message)?;
 
     if json {
         println!("{}", serde_json::to_string(&entry)?);

src/cmd/reopen.rs πŸ”—

@@ -2,22 +2,15 @@ use anyhow::Result;
 use std::path::Path;
 
 use crate::db;
+use crate::ops;
 
 pub fn run(root: &Path, ids: &[String], json: bool) -> Result<()> {
     let store = db::open(root)?;
-    let ts = db::now_utc();
 
     let mut reopened = Vec::new();
     for raw in ids {
         let id = db::resolve_task_id(&store, raw, false)?;
-        store.apply_and_persist(|doc| {
-            let tasks = doc.get_map("tasks");
-            if let Some(task) = db::get_task_map(&tasks, &id)? {
-                task.insert("status", db::status_label(db::Status::Open))?;
-                task.insert("updated_at", ts.clone())?;
-            }
-            Ok(())
-        })?;
+        ops::reopen_task(&store, &id)?;
         reopened.push(id);
     }
 

src/cmd/rm.rs πŸ”—

@@ -1,9 +1,9 @@
-use anyhow::{anyhow, bail, Result};
+use anyhow::Result;
 use serde::Serialize;
-use std::collections::{BTreeSet, HashSet};
 use std::path::Path;
 
 use crate::db;
+use crate::ops;
 
 #[derive(Serialize)]
 struct RmResult {
@@ -14,93 +14,40 @@ struct RmResult {
 
 pub fn run(root: &Path, ids: &[String], recursive: bool, force: bool, json: bool) -> Result<()> {
     let store = db::open(root)?;
-    let all = store.list_tasks_unfiltered()?;
 
-    let mut to_delete = BTreeSet::new();
-    for raw in ids {
-        let id = db::resolve_task_id(&store, raw, false)?;
-        if recursive {
-            collect_subtree(&all, &id, &mut to_delete);
-        } else {
-            if all
-                .iter()
-                .any(|t| t.parent.as_ref() == Some(&id) && t.deleted_at.is_none())
-            {
-                bail!("task '{id}' has children; use --recursive to delete subtree");
-            }
-            to_delete.insert(id);
-        }
-    }
-
-    let deleted_ids: Vec<db::TaskId> = to_delete.into_iter().collect();
-    let deleted_set: HashSet<String> = deleted_ids
+    let resolved: Vec<db::TaskId> = ids
         .iter()
-        .map(|id| id.as_str().to_string())
-        .collect();
+        .map(|raw| db::resolve_task_id(&store, raw, false))
+        .collect::<Result<_>>()?;
 
-    let unblocked_ids: Vec<db::TaskId> = all
-        .iter()
-        .filter(|t| !deleted_set.contains(t.id.as_str()))
-        .filter(|t| t.blockers.iter().any(|b| deleted_set.contains(b.as_str())))
-        .map(|t| t.id.clone())
-        .collect();
+    let result = ops::soft_delete(&store, &resolved, recursive)?;
 
-    let ts = db::now_utc();
-    store.apply_and_persist(|doc| {
-        let tasks = doc.get_map("tasks");
-
-        for task_id in &deleted_ids {
-            let task =
-                db::get_task_map(&tasks, task_id)?.ok_or_else(|| anyhow!("task not found"))?;
-            task.insert("deleted_at", ts.clone())?;
-            task.insert("updated_at", ts.clone())?;
-            task.insert("status", db::status_label(db::Status::Closed))?;
-        }
-
-        for task in store.list_tasks_unfiltered()? {
-            if deleted_set.contains(task.id.as_str()) {
-                continue;
-            }
-            if let Some(task_map) = db::get_task_map(&tasks, &task.id)? {
-                let blockers = db::get_or_create_child_map(&task_map, "blockers")?;
-                for deleted in &deleted_ids {
-                    blockers.delete(deleted.as_str())?;
-                }
-            }
-        }
-
-        Ok(())
-    })?;
-
-    if !force && !unblocked_ids.is_empty() {
-        let short: Vec<String> = unblocked_ids.iter().map(ToString::to_string).collect();
+    if !force && !result.unblocked_ids.is_empty() {
+        let short: Vec<String> = result
+            .unblocked_ids
+            .iter()
+            .map(ToString::to_string)
+            .collect();
         eprintln!("warning: removed blockers from {}", short.join(", "));
     }
 
     if json {
         let out = RmResult {
             requested_ids: ids.to_vec(),
-            deleted_ids: deleted_ids.iter().map(ToString::to_string).collect(),
-            unblocked_ids: unblocked_ids.iter().map(ToString::to_string).collect(),
+            deleted_ids: result.deleted_ids.iter().map(ToString::to_string).collect(),
+            unblocked_ids: result
+                .unblocked_ids
+                .iter()
+                .map(ToString::to_string)
+                .collect(),
         };
         println!("{}", serde_json::to_string(&out)?);
     } else {
         let c = crate::color::stdout_theme();
-        for id in deleted_ids {
+        for id in result.deleted_ids {
             println!("{}deleted{} {id}", c.green, c.reset);
         }
     }
 
     Ok(())
 }
-
-fn collect_subtree(all: &[db::Task], root: &db::TaskId, out: &mut BTreeSet<db::TaskId>) {
-    if !out.insert(root.clone()) {
-        return;
-    }
-    for task in all {
-        if task.parent.as_ref() == Some(root) && task.deleted_at.is_none() {
-            collect_subtree(all, &task.id, out);
-        }
-    }
-}

src/cmd/update.rs πŸ”—

@@ -3,6 +3,7 @@ use std::path::Path;
 
 use crate::db;
 use crate::editor;
+use crate::ops;
 
 pub struct Opts<'a> {
     pub status: Option<&'a str>,
@@ -16,7 +17,6 @@ pub struct Opts<'a> {
 pub fn run(root: &Path, id: &str, opts: Opts) -> Result<()> {
     let store = db::open(root)?;
     let task_id = db::resolve_task_id(&store, id, false)?;
-    let ts = db::now_utc();
 
     let parsed_status = opts.status.map(db::parse_status).transpose()?;
 
@@ -63,33 +63,19 @@ pub fn run(root: &Path, id: &str, opts: Opts) -> Result<()> {
         (opts.title, opts.desc)
     };
 
-    store.apply_and_persist(|doc| {
-        let tasks = doc.get_map("tasks");
-        let task = db::get_task_map(&tasks, &task_id)?.ok_or_else(|| anyhow!("task not found"))?;
-
-        if let Some(s) = parsed_status {
-            task.insert("status", db::status_label(s))?;
-        }
-        if let Some(p) = opts.priority {
-            task.insert("priority", db::priority_label(p))?;
-        }
-        if let Some(e) = opts.effort {
-            task.insert("effort", db::effort_label(e))?;
-        }
-        if let Some(t) = title_override {
-            task.insert("title", t)?;
-        }
-        if let Some(d) = desc_override {
-            task.insert("description", d)?;
-        }
-        task.insert("updated_at", ts.clone())?;
-        Ok(())
-    })?;
+    let task = ops::update_task(
+        &store,
+        &task_id,
+        ops::UpdateOpts {
+            status: parsed_status,
+            priority: opts.priority,
+            effort: opts.effort,
+            title: title_override.map(String::from),
+            description: desc_override.map(String::from),
+        },
+    )?;
 
     if opts.json {
-        let task = store
-            .get_task(&task_id, false)?
-            .ok_or_else(|| anyhow!("task not found"))?;
         println!("{}", serde_json::to_string(&task)?);
     } else {
         let c = crate::color::stdout_theme();

src/cmd/webui.rs πŸ”—

@@ -5,12 +5,13 @@ use std::sync::Arc;
 use anyhow::Result;
 use askama::Template;
 use axum::extract::{Path as AxumPath, Query, State};
-use axum::http::StatusCode;
-use axum::response::{Html, IntoResponse, Response};
-use axum::routing::get;
-use axum::Router;
+use axum::http::{HeaderMap, StatusCode};
+use axum::response::{Html, IntoResponse, Redirect, Response};
+use axum::routing::{get, post};
+use axum::{Form, Json, Router};
 
 use crate::db::{self, Store, TaskId};
+use crate::ops;
 use crate::score;
 
 const PAGE_SIZE: usize = 25;
@@ -785,6 +786,411 @@ async fn task_handler(
     }
 }
 
+// ---------------------------------------------------------------------------
+// Content negotiation
+// ---------------------------------------------------------------------------
+
+/// Returns true when the client prefers a JSON response.
+fn wants_json(headers: &HeaderMap) -> bool {
+    headers
+        .get("accept")
+        .and_then(|v| v.to_str().ok())
+        .map(|v| v.contains("application/json"))
+        .unwrap_or(false)
+}
+
+/// Build a redirect-or-JSON response after a successful mutation.
+fn mutation_response(
+    headers: &HeaderMap,
+    redirect_to: &str,
+    json_body: serde_json::Value,
+) -> Response {
+    if wants_json(headers) {
+        Json(json_body).into_response()
+    } else {
+        Redirect::to(redirect_to).into_response()
+    }
+}
+
+/// Build an error response appropriate for the caller (JSON or HTML).
+fn mutation_error(headers: &HeaderMap, code: u16, err: &anyhow::Error) -> Response {
+    let status = StatusCode::from_u16(code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
+    if wants_json(headers) {
+        (status, Json(serde_json::json!({"error": err.to_string()}))).into_response()
+    } else {
+        error_response(code, &err.to_string(), &[])
+    }
+}
+
+// ---------------------------------------------------------------------------
+// POST form/JSON input types
+// ---------------------------------------------------------------------------
+
+#[derive(serde::Deserialize)]
+struct CreateProjectForm {
+    name: String,
+    #[serde(default)]
+    bind_path: String,
+}
+
+#[derive(serde::Deserialize)]
+struct CreateForm {
+    title: String,
+    #[serde(default)]
+    description: String,
+    #[serde(default = "default_task_type")]
+    task_type: String,
+    #[serde(default = "default_priority")]
+    priority: String,
+    #[serde(default = "default_effort")]
+    effort: String,
+    #[serde(default)]
+    labels: String,
+    #[serde(default)]
+    parent: String,
+}
+
+fn default_task_type() -> String {
+    "task".to_string()
+}
+fn default_priority() -> String {
+    "medium".to_string()
+}
+fn default_effort() -> String {
+    "medium".to_string()
+}
+
+#[derive(serde::Deserialize)]
+struct UpdateForm {
+    #[serde(default)]
+    title: Option<String>,
+    #[serde(default)]
+    description: Option<String>,
+    #[serde(default)]
+    status: Option<String>,
+    #[serde(default)]
+    priority: Option<String>,
+    #[serde(default)]
+    effort: Option<String>,
+}
+
+#[derive(serde::Deserialize)]
+struct LogForm {
+    message: String,
+}
+
+#[derive(serde::Deserialize)]
+struct LabelForm {
+    /// "add" or "rm"
+    action: String,
+    label: String,
+}
+
+#[derive(serde::Deserialize)]
+struct DepForm {
+    /// "add" or "rm"
+    action: String,
+    blocker: String,
+}
+
+// ---------------------------------------------------------------------------
+// POST handlers
+// ---------------------------------------------------------------------------
+
+async fn create_project_handler(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Form(form): Form<CreateProjectForm>,
+) -> Response {
+    let root = state.data_root.clone();
+    let result = tokio::task::spawn_blocking(move || -> Result<String> {
+        let bind = if form.bind_path.is_empty() {
+            None
+        } else {
+            Some(std::path::PathBuf::from(&form.bind_path))
+        };
+        ops::init_project(&root, &form.name, bind.as_deref())?;
+        Ok(form.name)
+    })
+    .await;
+
+    match result {
+        Ok(Ok(name)) => {
+            let redirect = format!("/projects/{name}");
+            mutation_response(&headers, &redirect, serde_json::json!({"name": name}))
+        }
+        Ok(Err(e)) => mutation_error(&headers, 400, &e),
+        Err(e) => mutation_error(&headers, 500, &e.into()),
+    }
+}
+
+async fn create_handler(
+    State(state): State<AppState>,
+    AxumPath(name): AxumPath<String>,
+    headers: HeaderMap,
+    Form(form): Form<CreateForm>,
+) -> Response {
+    let root = state.data_root.clone();
+    let result = tokio::task::spawn_blocking(move || -> Result<(db::Task, String)> {
+        let store = Store::open(&root, &name)?;
+
+        let parent = if form.parent.is_empty() {
+            None
+        } else {
+            Some(db::resolve_task_id(&store, &form.parent, false)?)
+        };
+
+        let labels: Vec<String> = form
+            .labels
+            .split(',')
+            .map(str::trim)
+            .filter(|l| !l.is_empty())
+            .map(String::from)
+            .collect();
+
+        let task = ops::create_task(
+            &store,
+            ops::CreateOpts {
+                title: form.title,
+                description: form.description,
+                task_type: form.task_type,
+                priority: db::parse_priority(&form.priority)?,
+                effort: db::parse_effort(&form.effort)?,
+                parent,
+                labels,
+            },
+        )?;
+
+        let redirect = format!("/projects/{}/tasks/{}", name, task.id.as_str());
+        Ok((task, redirect))
+    })
+    .await;
+
+    match result {
+        Ok(Ok((task, redirect))) => mutation_response(
+            &headers,
+            &redirect,
+            serde_json::to_value(&task).unwrap_or_default(),
+        ),
+        Ok(Err(e)) => mutation_error(&headers, 400, &e),
+        Err(e) => mutation_error(&headers, 500, &e.into()),
+    }
+}
+
+async fn update_handler(
+    State(state): State<AppState>,
+    AxumPath((name, id)): AxumPath<(String, String)>,
+    headers: HeaderMap,
+    Form(form): Form<UpdateForm>,
+) -> Response {
+    let root = state.data_root.clone();
+    let result = tokio::task::spawn_blocking(move || -> Result<(db::Task, String)> {
+        let store = Store::open(&root, &name)?;
+        let task_id = db::resolve_task_id(&store, &id, false)?;
+
+        let task = ops::update_task(
+            &store,
+            &task_id,
+            ops::UpdateOpts {
+                status: form
+                    .status
+                    .as_deref()
+                    .filter(|s| !s.is_empty())
+                    .map(db::parse_status)
+                    .transpose()?,
+                priority: form
+                    .priority
+                    .as_deref()
+                    .filter(|s| !s.is_empty())
+                    .map(db::parse_priority)
+                    .transpose()?,
+                effort: form
+                    .effort
+                    .as_deref()
+                    .filter(|s| !s.is_empty())
+                    .map(db::parse_effort)
+                    .transpose()?,
+                title: form.title.filter(|s| !s.is_empty()),
+                description: form.description,
+            },
+        )?;
+
+        let redirect = format!("/projects/{}/tasks/{}", name, task.id.as_str());
+        Ok((task, redirect))
+    })
+    .await;
+
+    match result {
+        Ok(Ok((task, redirect))) => mutation_response(
+            &headers,
+            &redirect,
+            serde_json::to_value(&task).unwrap_or_default(),
+        ),
+        Ok(Err(e)) => mutation_error(&headers, 400, &e),
+        Err(e) => mutation_error(&headers, 500, &e.into()),
+    }
+}
+
+async fn log_handler(
+    State(state): State<AppState>,
+    AxumPath((name, id)): AxumPath<(String, String)>,
+    headers: HeaderMap,
+    Form(form): Form<LogForm>,
+) -> Response {
+    let root = state.data_root.clone();
+    let result = tokio::task::spawn_blocking(move || -> Result<(db::LogEntry, String)> {
+        let store = Store::open(&root, &name)?;
+        let task_id = db::resolve_task_id(&store, &id, false)?;
+        let entry = ops::add_log(&store, &task_id, &form.message)?;
+        let redirect = format!("/projects/{}/tasks/{}", name, task_id.as_str());
+        Ok((entry, redirect))
+    })
+    .await;
+
+    match result {
+        Ok(Ok((entry, redirect))) => mutation_response(
+            &headers,
+            &redirect,
+            serde_json::to_value(&entry).unwrap_or_default(),
+        ),
+        Ok(Err(e)) => mutation_error(&headers, 400, &e),
+        Err(e) => mutation_error(&headers, 500, &e.into()),
+    }
+}
+
+async fn done_handler(
+    State(state): State<AppState>,
+    AxumPath((name, id)): AxumPath<(String, String)>,
+    headers: HeaderMap,
+) -> Response {
+    let root = state.data_root.clone();
+    let result = tokio::task::spawn_blocking(move || -> Result<(TaskId, String)> {
+        let store = Store::open(&root, &name)?;
+        let task_id = db::resolve_task_id(&store, &id, false)?;
+        ops::mark_done(&store, &task_id)?;
+        let redirect = format!("/projects/{}/tasks/{}", name, task_id.as_str());
+        Ok((task_id, redirect))
+    })
+    .await;
+
+    match result {
+        Ok(Ok((task_id, redirect))) => mutation_response(
+            &headers,
+            &redirect,
+            serde_json::json!({"id": task_id, "status": "closed"}),
+        ),
+        Ok(Err(e)) => mutation_error(&headers, 400, &e),
+        Err(e) => mutation_error(&headers, 500, &e.into()),
+    }
+}
+
+async fn reopen_handler(
+    State(state): State<AppState>,
+    AxumPath((name, id)): AxumPath<(String, String)>,
+    headers: HeaderMap,
+) -> Response {
+    let root = state.data_root.clone();
+    let result = tokio::task::spawn_blocking(move || -> Result<(TaskId, String)> {
+        let store = Store::open(&root, &name)?;
+        let task_id = db::resolve_task_id(&store, &id, false)?;
+        ops::reopen_task(&store, &task_id)?;
+        let redirect = format!("/projects/{}/tasks/{}", name, task_id.as_str());
+        Ok((task_id, redirect))
+    })
+    .await;
+
+    match result {
+        Ok(Ok((task_id, redirect))) => mutation_response(
+            &headers,
+            &redirect,
+            serde_json::json!({"id": task_id, "status": "open"}),
+        ),
+        Ok(Err(e)) => mutation_error(&headers, 400, &e),
+        Err(e) => mutation_error(&headers, 500, &e.into()),
+    }
+}
+
+async fn label_handler(
+    State(state): State<AppState>,
+    AxumPath((name, id)): AxumPath<(String, String)>,
+    headers: HeaderMap,
+    Form(form): Form<LabelForm>,
+) -> Response {
+    let root = state.data_root.clone();
+    let result = tokio::task::spawn_blocking(move || -> Result<String> {
+        let store = Store::open(&root, &name)?;
+        let task_id = db::resolve_task_id(&store, &id, false)?;
+        match form.action.as_str() {
+            "add" => ops::add_label(&store, &task_id, &form.label)?,
+            "rm" => ops::remove_label(&store, &task_id, &form.label)?,
+            other => anyhow::bail!("unknown label action '{other}'; expected 'add' or 'rm'"),
+        }
+        Ok(format!("/projects/{}/tasks/{}", name, task_id.as_str()))
+    })
+    .await;
+
+    match result {
+        Ok(Ok(redirect)) => mutation_response(&headers, &redirect, serde_json::json!({"ok": true})),
+        Ok(Err(e)) => mutation_error(&headers, 400, &e),
+        Err(e) => mutation_error(&headers, 500, &e.into()),
+    }
+}
+
+async fn dep_handler(
+    State(state): State<AppState>,
+    AxumPath((name, id)): AxumPath<(String, String)>,
+    headers: HeaderMap,
+    Form(form): Form<DepForm>,
+) -> Response {
+    let root = state.data_root.clone();
+    let result = tokio::task::spawn_blocking(move || -> Result<String> {
+        let store = Store::open(&root, &name)?;
+        let child_id = db::resolve_task_id(&store, &id, false)?;
+        let blocker_id = db::resolve_task_id(&store, &form.blocker, form.action == "rm")?;
+        match form.action.as_str() {
+            "add" => ops::add_dep(&store, &child_id, &blocker_id)?,
+            "rm" => ops::remove_dep(&store, &child_id, &blocker_id)?,
+            other => anyhow::bail!("unknown dep action '{other}'; expected 'add' or 'rm'"),
+        }
+        Ok(format!("/projects/{}/tasks/{}", name, child_id.as_str()))
+    })
+    .await;
+
+    match result {
+        Ok(Ok(redirect)) => mutation_response(&headers, &redirect, serde_json::json!({"ok": true})),
+        Ok(Err(e)) => mutation_error(&headers, 400, &e),
+        Err(e) => mutation_error(&headers, 500, &e.into()),
+    }
+}
+
+async fn delete_handler(
+    State(state): State<AppState>,
+    AxumPath((name, id)): AxumPath<(String, String)>,
+    headers: HeaderMap,
+) -> Response {
+    let root = state.data_root.clone();
+    let result = tokio::task::spawn_blocking(move || -> Result<(ops::DeleteResult, String)> {
+        let store = Store::open(&root, &name)?;
+        let task_id = db::resolve_task_id(&store, &id, false)?;
+        let dr = ops::soft_delete(&store, &[task_id], false)?;
+        let redirect = format!("/projects/{}", name);
+        Ok((dr, redirect))
+    })
+    .await;
+
+    match result {
+        Ok(Ok((dr, redirect))) => {
+            let json_body = serde_json::json!({
+                "deleted_ids": dr.deleted_ids.iter().map(ToString::to_string).collect::<Vec<_>>(),
+                "unblocked_ids": dr.unblocked_ids.iter().map(ToString::to_string).collect::<Vec<_>>(),
+            });
+            mutation_response(&headers, &redirect, json_body)
+        }
+        Ok(Err(e)) => mutation_error(&headers, 400, &e),
+        Err(e) => mutation_error(&headers, 500, &e.into()),
+    }
+}
+
 async fn static_oat_css() -> impl IntoResponse {
     (
         [(axum::http::header::CONTENT_TYPE, "text/css; charset=utf-8")],
@@ -831,8 +1237,19 @@ pub fn run(cwd: &Path, host: &str, port: u16, explicit_project: Option<&str>) ->
 
     let app = Router::new()
         .route("/", get(index_handler))
+        .route("/projects", post(create_project_handler))
         .route("/projects/{name}", get(project_handler))
-        .route("/projects/{name}/tasks/{id}", get(task_handler))
+        .route("/projects/{name}/tasks", post(create_handler))
+        .route(
+            "/projects/{name}/tasks/{id}",
+            get(task_handler).post(update_handler),
+        )
+        .route("/projects/{name}/tasks/{id}/log", post(log_handler))
+        .route("/projects/{name}/tasks/{id}/done", post(done_handler))
+        .route("/projects/{name}/tasks/{id}/reopen", post(reopen_handler))
+        .route("/projects/{name}/tasks/{id}/labels", post(label_handler))
+        .route("/projects/{name}/tasks/{id}/deps", post(dep_handler))
+        .route("/projects/{name}/tasks/{id}/delete", post(delete_handler))
         .route("/static/oat.min.css", get(static_oat_css))
         .route("/static/td.css", get(static_td_css))
         .route("/static/oat.min.js", get(static_js))

src/lib.rs πŸ”—

@@ -4,6 +4,7 @@ pub mod color;
 pub mod db;
 pub mod editor;
 pub mod migrate;
+pub mod ops;
 pub mod score;
 
 use clap::Parser;

src/ops.rs πŸ”—

@@ -0,0 +1,357 @@
+//! Shared mutation helpers for task operations.
+//!
+//! These functions encapsulate the Loro document mutations so that both
+//! CLI commands and web handlers can perform the same operations without
+//! duplicating store logic.
+
+use std::collections::{HashMap, HashSet, VecDeque};
+use std::path::Path;
+
+use anyhow::{anyhow, bail, Result};
+use loro::LoroMap;
+
+use crate::db::{self, LogEntry, Store, Task, TaskId};
+
+/// Create a new project and optionally bind a directory path to it.
+pub fn init_project(root: &Path, name: &str, bind_path: Option<&Path>) -> Result<()> {
+    std::fs::create_dir_all(root.join(db::PROJECTS_DIR))?;
+    Store::init(root, name)?;
+    if let Some(path) = bind_path {
+        db::use_project(path, name)?;
+    }
+    Ok(())
+}
+
+/// Input for creating a new task.
+pub struct CreateOpts {
+    pub title: String,
+    pub description: String,
+    pub task_type: String,
+    pub priority: db::Priority,
+    pub effort: db::Effort,
+    pub parent: Option<TaskId>,
+    pub labels: Vec<String>,
+}
+
+/// Create a task and return the hydrated result.
+pub fn create_task(store: &Store, opts: CreateOpts) -> Result<Task> {
+    let ts = db::now_utc();
+    let id = db::gen_id();
+
+    store.apply_and_persist(|doc| {
+        let tasks = doc.get_map("tasks");
+        let task = db::insert_task_map(&tasks, &id)?;
+
+        task.insert("title", opts.title.as_str())?;
+        task.insert("description", opts.description.as_str())?;
+        task.insert("type", opts.task_type.as_str())?;
+        task.insert("priority", db::priority_label(opts.priority))?;
+        task.insert("status", db::status_label(db::Status::Open))?;
+        task.insert("effort", db::effort_label(opts.effort))?;
+        task.insert(
+            "parent",
+            opts.parent.as_ref().map(|p| p.as_str()).unwrap_or(""),
+        )?;
+        task.insert("created_at", ts.clone())?;
+        task.insert("updated_at", ts.clone())?;
+        task.insert("deleted_at", "")?;
+        task.insert_container("labels", LoroMap::new())?;
+        task.insert_container("blockers", LoroMap::new())?;
+        task.insert_container("logs", LoroMap::new())?;
+
+        if !opts.labels.is_empty() {
+            let labels = db::get_or_create_child_map(&task, "labels")?;
+            for lbl in &opts.labels {
+                labels.insert(lbl, true)?;
+            }
+        }
+
+        Ok(())
+    })?;
+
+    store
+        .get_task(&id, false)?
+        .ok_or_else(|| anyhow!("failed to reload created task"))
+}
+
+/// Input for updating an existing task.  All fields are optional; only
+/// populated fields are written.
+pub struct UpdateOpts {
+    pub status: Option<db::Status>,
+    pub priority: Option<db::Priority>,
+    pub effort: Option<db::Effort>,
+    pub title: Option<String>,
+    pub description: Option<String>,
+}
+
+/// Update task fields and return the refreshed task.
+pub fn update_task(store: &Store, task_id: &TaskId, opts: UpdateOpts) -> Result<Task> {
+    let ts = db::now_utc();
+
+    store.apply_and_persist(|doc| {
+        let tasks = doc.get_map("tasks");
+        let task = db::get_task_map(&tasks, task_id)?.ok_or_else(|| anyhow!("task not found"))?;
+
+        if let Some(s) = opts.status {
+            task.insert("status", db::status_label(s))?;
+        }
+        if let Some(p) = opts.priority {
+            task.insert("priority", db::priority_label(p))?;
+        }
+        if let Some(e) = opts.effort {
+            task.insert("effort", db::effort_label(e))?;
+        }
+        if let Some(ref t) = opts.title {
+            task.insert("title", t.as_str())?;
+        }
+        if let Some(ref d) = opts.description {
+            task.insert("description", d.as_str())?;
+        }
+        task.insert("updated_at", ts.clone())?;
+        Ok(())
+    })?;
+
+    store
+        .get_task(task_id, false)?
+        .ok_or_else(|| anyhow!("task not found"))
+}
+
+/// Mark a single task as closed.
+pub fn mark_done(store: &Store, task_id: &TaskId) -> Result<()> {
+    let ts = db::now_utc();
+    store.apply_and_persist(|doc| {
+        let tasks = doc.get_map("tasks");
+        if let Some(task) = db::get_task_map(&tasks, task_id)? {
+            task.insert("status", db::status_label(db::Status::Closed))?;
+            task.insert("updated_at", ts.clone())?;
+        }
+        Ok(())
+    })?;
+    Ok(())
+}
+
+/// Reopen a single closed task.
+pub fn reopen_task(store: &Store, task_id: &TaskId) -> Result<()> {
+    let ts = db::now_utc();
+    store.apply_and_persist(|doc| {
+        let tasks = doc.get_map("tasks");
+        if let Some(task) = db::get_task_map(&tasks, task_id)? {
+            task.insert("status", db::status_label(db::Status::Open))?;
+            task.insert("updated_at", ts.clone())?;
+        }
+        Ok(())
+    })?;
+    Ok(())
+}
+
+/// Append a log entry to a task and return the entry.
+pub fn add_log(store: &Store, task_id: &TaskId, message: &str) -> Result<LogEntry> {
+    let log_id = db::gen_id();
+    let ts = db::now_utc();
+
+    store.apply_and_persist(|doc| {
+        let tasks = doc.get_map("tasks");
+        let task = db::get_task_map(&tasks, task_id)?.ok_or_else(|| anyhow!("task not found"))?;
+        let logs = db::get_or_create_child_map(&task, "logs")?;
+        let entry = logs.insert_container(log_id.as_str(), LoroMap::new())?;
+        entry.insert("timestamp", ts.clone())?;
+        entry.insert("message", message)?;
+        task.insert("updated_at", ts.clone())?;
+        Ok(())
+    })?;
+
+    Ok(LogEntry {
+        id: log_id,
+        timestamp: ts,
+        message: message.to_string(),
+    })
+}
+
+/// Add a label to a task.
+pub fn add_label(store: &Store, task_id: &TaskId, label: &str) -> Result<()> {
+    let ts = db::now_utc();
+    store.apply_and_persist(|doc| {
+        let tasks = doc.get_map("tasks");
+        let task = db::get_task_map(&tasks, task_id)?.ok_or_else(|| anyhow!("task not found"))?;
+        let labels = db::get_or_create_child_map(&task, "labels")?;
+        labels.insert(label, true)?;
+        task.insert("updated_at", ts.clone())?;
+        Ok(())
+    })?;
+    Ok(())
+}
+
+/// Remove a label from a task.
+pub fn remove_label(store: &Store, task_id: &TaskId, label: &str) -> Result<()> {
+    let ts = db::now_utc();
+    store.apply_and_persist(|doc| {
+        let tasks = doc.get_map("tasks");
+        let task = db::get_task_map(&tasks, task_id)?.ok_or_else(|| anyhow!("task not found"))?;
+        let labels = db::get_or_create_child_map(&task, "labels")?;
+        labels.delete(label)?;
+        task.insert("updated_at", ts.clone())?;
+        Ok(())
+    })?;
+    Ok(())
+}
+
+/// Add a blocker dependency (child is blocked by blocker).
+///
+/// Rejects self-references and cycles.
+pub fn add_dep(store: &Store, child_id: &TaskId, blocker_id: &TaskId) -> Result<()> {
+    if child_id == blocker_id {
+        bail!("adding dependency would create a cycle");
+    }
+    if would_cycle(store, child_id, blocker_id)? {
+        bail!("adding dependency would create a cycle");
+    }
+
+    let ts = db::now_utc();
+    store.apply_and_persist(|doc| {
+        let tasks = doc.get_map("tasks");
+        let child_task =
+            db::get_task_map(&tasks, child_id)?.ok_or_else(|| anyhow!("task not found"))?;
+        let blockers = db::get_or_create_child_map(&child_task, "blockers")?;
+        blockers.insert(blocker_id.as_str(), true)?;
+        child_task.insert("updated_at", ts.clone())?;
+        Ok(())
+    })?;
+    Ok(())
+}
+
+/// Remove a blocker dependency.
+pub fn remove_dep(store: &Store, child_id: &TaskId, blocker_id: &TaskId) -> Result<()> {
+    let ts = db::now_utc();
+    store.apply_and_persist(|doc| {
+        let tasks = doc.get_map("tasks");
+        let child_task =
+            db::get_task_map(&tasks, child_id)?.ok_or_else(|| anyhow!("task not found"))?;
+        let blockers = db::get_or_create_child_map(&child_task, "blockers")?;
+        blockers.delete(blocker_id.as_str())?;
+        child_task.insert("updated_at", ts.clone())?;
+        Ok(())
+    })?;
+    Ok(())
+}
+
+/// Result of a soft-delete operation.
+pub struct DeleteResult {
+    pub deleted_ids: Vec<TaskId>,
+    pub unblocked_ids: Vec<TaskId>,
+}
+
+/// Soft-delete one or more tasks.
+///
+/// When `recursive` is true, subtrees rooted at the given IDs are also
+/// deleted.  Blocker references from surviving tasks are cleaned up
+/// automatically; pass `force` to suppress the warning about unblocked
+/// tasks.
+pub fn soft_delete(store: &Store, ids: &[TaskId], recursive: bool) -> Result<DeleteResult> {
+    use std::collections::BTreeSet;
+
+    let all = store.list_tasks_unfiltered()?;
+
+    let mut to_delete = BTreeSet::new();
+    for id in ids {
+        if recursive {
+            collect_subtree(&all, id, &mut to_delete);
+        } else {
+            if all
+                .iter()
+                .any(|t| t.parent.as_ref() == Some(id) && t.deleted_at.is_none())
+            {
+                bail!("task '{id}' has children; use --recursive to delete subtree");
+            }
+            to_delete.insert(id.clone());
+        }
+    }
+
+    let deleted_ids: Vec<TaskId> = to_delete.into_iter().collect();
+    let deleted_set: HashSet<String> = deleted_ids
+        .iter()
+        .map(|id| id.as_str().to_string())
+        .collect();
+
+    let unblocked_ids: Vec<TaskId> = all
+        .iter()
+        .filter(|t| !deleted_set.contains(t.id.as_str()))
+        .filter(|t| t.blockers.iter().any(|b| deleted_set.contains(b.as_str())))
+        .map(|t| t.id.clone())
+        .collect();
+
+    let ts = db::now_utc();
+    store.apply_and_persist(|doc| {
+        let tasks = doc.get_map("tasks");
+
+        for task_id in &deleted_ids {
+            let task =
+                db::get_task_map(&tasks, task_id)?.ok_or_else(|| anyhow!("task not found"))?;
+            task.insert("deleted_at", ts.clone())?;
+            task.insert("updated_at", ts.clone())?;
+            task.insert("status", db::status_label(db::Status::Closed))?;
+        }
+
+        for task in store.list_tasks_unfiltered()? {
+            if deleted_set.contains(task.id.as_str()) {
+                continue;
+            }
+            if let Some(task_map) = db::get_task_map(&tasks, &task.id)? {
+                let blockers = db::get_or_create_child_map(&task_map, "blockers")?;
+                for deleted in &deleted_ids {
+                    blockers.delete(deleted.as_str())?;
+                }
+            }
+        }
+
+        Ok(())
+    })?;
+
+    Ok(DeleteResult {
+        deleted_ids,
+        unblocked_ids,
+    })
+}
+
+fn would_cycle(store: &Store, child: &TaskId, parent: &TaskId) -> Result<bool> {
+    let tasks = store.list_tasks_unfiltered()?;
+    let mut graph: HashMap<String, HashSet<String>> = HashMap::new();
+    for task in tasks {
+        for blocker in task.blockers {
+            graph
+                .entry(task.id.as_str().to_string())
+                .or_default()
+                .insert(blocker.as_str().to_string());
+        }
+    }
+    graph
+        .entry(child.as_str().to_string())
+        .or_default()
+        .insert(parent.as_str().to_string());
+
+    let mut seen = HashSet::new();
+    let mut queue = VecDeque::from([parent.as_str().to_string()]);
+    while let Some(node) = queue.pop_front() {
+        if node == child.as_str() {
+            return Ok(true);
+        }
+        if !seen.insert(node.clone()) {
+            continue;
+        }
+        if let Some(nexts) = graph.get(&node) {
+            queue.extend(nexts.iter().cloned());
+        }
+    }
+
+    Ok(false)
+}
+
+fn collect_subtree(all: &[Task], root: &TaskId, out: &mut std::collections::BTreeSet<TaskId>) {
+    if !out.insert(root.clone()) {
+        return;
+    }
+    for task in all {
+        if task.parent.as_ref() == Some(root) && task.deleted_at.is_none() {
+            collect_subtree(all, &task.id, out);
+        }
+    }
+}

static/td.css πŸ”—

@@ -49,3 +49,61 @@ code, pre, .mono { font-family: var(--font-mono); }
   transform: translateX(-50%);
   top: 0;
 }
+[hidden] {
+  display: none !important;
+}
+
+/* FAB */
+.fab-group {
+  position: fixed;
+  bottom: 1.5rem;
+  right: 1.5rem;
+  display: flex;
+  flex-direction: column;
+  align-items: flex-end;
+  gap: 0.5rem;
+  z-index: 100;
+}
+.fab-toggle {
+  width: 3.5rem;
+  height: 3.5rem;
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 0;
+  cursor: pointer;
+  box-shadow: 0 2px 8px rgba(0,0,0,0.2);
+  transition: transform 0.2s ease;
+}
+.fab-toggle:hover {
+  transform: scale(1.05);
+}
+.fab-toggle .fab-icon-close {
+  display: none;
+}
+.fab-toggle[aria-expanded="true"] .fab-icon-plus {
+  display: none;
+}
+.fab-toggle[aria-expanded="true"] .fab-icon-close {
+  display: block;
+}
+.fab-actions {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-end;
+  gap: 0.5rem;
+}
+.fab-actions[hidden] {
+  display: none !important;
+}
+.fab-action {
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+  padding: 0.5rem 1rem;
+  border-radius: 2rem;
+  cursor: pointer;
+  white-space: nowrap;
+  box-shadow: 0 2px 6px rgba(0,0,0,0.15);
+}

static/td.js πŸ”—

@@ -1,35 +1,49 @@
-console.log("[td] td.js loaded");
-
 document.addEventListener("DOMContentLoaded", () => {
-  console.log("[td] DOMContentLoaded fired");
-
-  const timeEls = document.querySelectorAll("time[datetime]");
-  console.log("[td] Found %d <time> elements", timeEls.length);
-  timeEls.forEach((el, i) => {
-    const raw = el.getAttribute("datetime");
-    const d = new Date(raw);
-    const before = el.textContent;
-    console.log("[td] time[%d] raw=%s parsed=%s valid=%s before=%s", i, raw, d.toISOString?.() ?? d, !isNaN(d), before);
+  document.querySelectorAll("time[datetime]").forEach(el => {
+    const d = new Date(el.getAttribute("datetime"));
     if (!isNaN(d)) {
       el.textContent = d.toLocaleString(undefined, {
         day: "numeric", month: "short", year: "numeric",
         hour: "2-digit", minute: "2-digit"
       });
-      console.log("[td] time[%d] after=%s", i, el.textContent);
-    } else {
-      console.warn("[td] Could not parse datetime: %s", raw);
     }
   });
 
+  // FAB toggle
+  const fabToggle = document.getElementById("fab-toggle");
+  const fabActions = document.getElementById("fab-actions");
+  if (fabToggle && fabActions) {
+    fabToggle.addEventListener("click", () => {
+      const expanded = fabToggle.getAttribute("aria-expanded") === "true";
+      fabToggle.setAttribute("aria-expanded", String(!expanded));
+      fabActions.hidden = expanded;
+    });
+    // Collapse when a dialog opens from a FAB action
+    fabActions.querySelectorAll(".fab-action").forEach(btn => {
+      btn.addEventListener("click", () => {
+        fabToggle.setAttribute("aria-expanded", "false");
+        fabActions.hidden = true;
+      });
+    });
+  }
+
+  // New task form: set the action URL based on the selected project
+  const taskForm = document.getElementById("form-new-task");
+  const projectSelect = document.getElementById("nt-project");
+  if (taskForm && projectSelect) {
+    const updateAction = () => {
+      taskForm.action = "/projects/" + encodeURIComponent(projectSelect.value) + "/tasks";
+    };
+    updateAction();
+    projectSelect.addEventListener("change", updateAction);
+  }
+
   // Reveal copy-to-clipboard buttons only when the API is available
   // (requires a secure context: HTTPS or localhost).
-  console.log("[td] navigator.clipboard available: %s", !!navigator.clipboard);
   if (navigator.clipboard) {
     const copyIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>';
     const checkIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m12 15 2 2 4-4"/><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>';
-    const copyBtns = document.querySelectorAll(".js-copy-id");
-    console.log("[td] Found %d copy buttons", copyBtns.length);
-    copyBtns.forEach(btn => {
+    document.querySelectorAll(".js-copy-id").forEach(btn => {
       btn.hidden = false;
       btn.addEventListener("click", () => {
         navigator.clipboard.writeText(btn.dataset.copy).then(() => {

templates/base.html πŸ”—

@@ -35,5 +35,111 @@
       {% block content %}{% endblock %}
     </div>
   </main>
+
+  <!-- FAB -->
+  <div class="fab-group" id="fab-group">
+    <div class="fab-actions" id="fab-actions" hidden>
+      <button commandfor="dlg-new-task" command="show-modal" class="fab-action" aria-label="New task">
+        <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.376 3.622a1 1 0 0 1 3.002 3.002L7.368 18.635a2 2 0 0 1-.855.506l-2.872.838a.5.5 0 0 1-.62-.62l.838-2.872a2 2 0 0 1 .506-.854z"/></svg>
+        <span>Task</span>
+      </button>
+      <button commandfor="dlg-new-project" command="show-modal" class="fab-action" aria-label="New project">
+        <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2z"/></svg>
+        <span>Project</span>
+      </button>
+    </div>
+    <button class="fab-toggle" id="fab-toggle" aria-label="Create new…" aria-expanded="false">
+      <svg class="fab-icon-plus" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
+      <svg class="fab-icon-close" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
+    </button>
+  </div>
+
+  <!-- New task dialog -->
+  <dialog id="dlg-new-task" closedby="any">
+    <form method="post" id="form-new-task">
+      <header>
+        <h3>New task</h3>
+      </header>
+      <div class="vstack">
+        <div data-field>
+          <label for="nt-project">Project</label>
+          <select id="nt-project" aria-label="Project" required>
+            {% for p in all_projects %}
+            <option value="{{ p }}"{% if active_project.as_deref() == Some(p.as_str()) %} selected{% endif %}>{{ p }}</option>
+            {% endfor %}
+          </select>
+        </div>
+        <label data-field>
+          Title
+          <input type="text" id="nt-title" name="title" required>
+        </label>
+        <label data-field>
+          Description
+          <textarea id="nt-desc" name="description" rows="3"></textarea>
+        </label>
+        <div class="hstack gap-2">
+          <div data-field>
+            <label for="nt-type">Type</label>
+            <select id="nt-type" name="task_type">
+              <option value="task" selected>Task</option>
+              <option value="bug">Bug</option>
+              <option value="feature">Feature</option>
+            </select>
+          </div>
+          <div data-field>
+            <label for="nt-priority">Priority</label>
+            <select id="nt-priority" name="priority">
+              <option value="high">High</option>
+              <option value="medium" selected>Medium</option>
+              <option value="low">Low</option>
+            </select>
+          </div>
+          <div data-field>
+            <label for="nt-effort">Effort</label>
+            <select id="nt-effort" name="effort">
+              <option value="low">Low</option>
+              <option value="medium" selected>Medium</option>
+              <option value="high">High</option>
+            </select>
+          </div>
+        </div>
+        <label data-field>
+          Labels <small class="text-light">(comma-separated)</small>
+          <input type="text" name="labels" placeholder="frontend, urgent">
+        </label>
+        <label data-field>
+          Parent task ID <small class="text-light">(optional)</small>
+          <input type="text" name="parent" placeholder="td-XXXXXXX">
+        </label>
+      </div>
+      <footer>
+        <button type="button" commandfor="dlg-new-task" command="close" class="outline">Cancel</button>
+        <button type="submit">Create</button>
+      </footer>
+    </form>
+  </dialog>
+
+  <!-- New project dialog -->
+  <dialog id="dlg-new-project" closedby="any">
+    <form method="post" action="/projects">
+      <header>
+        <h3>New project</h3>
+      </header>
+      <div class="vstack">
+        <label data-field>
+          Project name
+          <input type="text" name="name" required placeholder="my-project">
+        </label>
+        <label data-field>
+          Bind path <small class="text-light">(optional directory to link)</small>
+          <input type="text" name="bind_path" placeholder="/home/user/repos/project">
+        </label>
+      </div>
+      <footer>
+        <button type="button" commandfor="dlg-new-project" command="close" class="outline">Cancel</button>
+        <button type="submit">Create</button>
+      </footer>
+    </form>
+  </dialog>
 </body>
 </html>

templates/macros.html πŸ”—

@@ -40,6 +40,7 @@
         <th scope="col"><a href="{{ sort_ctx.column_href("effort") }}">Effort{{ sort_ctx.arrow("effort") }}</a></th>
         <th scope="col"><a href="{{ sort_ctx.column_href("title") }}">Title{{ sort_ctx.arrow("title") }}</a></th>
         <th scope="col"><a href="{{ sort_ctx.column_href("created") }}">Created{{ sort_ctx.arrow("created") }}</a></th>
+        <th scope="col"><span class="sr-only">Actions</span></th>
       </tr>
     </thead>
     <tbody>
@@ -51,6 +52,13 @@
         <td>{{ t.effort }}</td>
         <td>{{ t.title }}</td>
         <td><time datetime="{{ t.created_at }}">{{ t.created_at_display }}</time></td>
+        <td>
+          {% if t.status != "closed" %}
+          <form method="post" action="/projects/{{ project_name }}/tasks/{{ t.full_id }}/done">
+            <button type="submit" class="outline small" aria-label="Mark {{ t.short_id }} done">βœ“</button>
+          </form>
+          {% endif %}
+        </td>
       </tr>
       {% endfor %}
     </tbody>

templates/task.html πŸ”—

@@ -32,40 +32,89 @@
     <p class="text-light">{{ task.task_type }} Β· {{ task.priority }} priority Β· {{ task.effort }} effort<br>Created <time datetime="{{ task.created_at }}">{{ task.created_at_display }}</time> Β· Updated <time datetime="{{ task.updated_at }}">{{ task.updated_at_display }}</time></p>
   </footer>
 
+  <div class="hstack gap-2 mt-4">
+    {% if task.status != "closed" %}
+    <form method="post" action="/projects/{{ project_name }}/tasks/{{ task.full_id }}/done">
+      <button type="submit" class="small">Mark done</button>
+    </form>
+    {% else %}
+    <form method="post" action="/projects/{{ project_name }}/tasks/{{ task.full_id }}/reopen">
+      <button type="submit" class="outline small">Reopen</button>
+    </form>
+    {% endif %}
+    <form method="post" action="/projects/{{ project_name }}/tasks/{{ task.full_id }}/delete" onsubmit="return confirm('Delete this task?')">
+      <button type="submit" class="outline small danger">Delete</button>
+    </form>
+  </div>
+
   {% if !task.labels.is_empty() %}
   <section class="mt-4" aria-label="Labels">
     <h2>Labels</h2>
     <div class="hstack gap-2">
       {% for l in task.labels %}
-      <span class="badge">{{ l }}</span>
+      <form method="post" action="/projects/{{ project_name }}/tasks/{{ task.full_id }}/labels" class="inline">
+        <input type="hidden" name="action" value="rm">
+        <input type="hidden" name="label" value="{{ l }}">
+        <button type="submit" class="badge outline small" aria-label="Remove label {{ l }}" title="Remove">{{ l }} Γ—</button>
+      </form>
       {% endfor %}
     </div>
   </section>
   {% endif %}
 
-  {% if !task.logs.is_empty() %}
+  <section class="mt-4" aria-label="Add label">
+    <form method="post" action="/projects/{{ project_name }}/tasks/{{ task.full_id }}/labels" class="hstack gap-2">
+      <input type="hidden" name="action" value="add">
+      <input type="text" name="label" placeholder="Label…" required aria-label="New label">
+      <button type="submit" class="outline small">Add label</button>
+    </form>
+  </section>
+
   <details class="mt-4">
-    <summary>Work log</summary>
+    <summary>Work log ({{ task.logs.len() }})</summary>
     {% for log in task.logs %}
     <div><time datetime="{{ log.timestamp }}">{{ log.timestamp_display }}</time> β€” {{ log.message|safe }}</div>
     {% endfor %}
+    <form method="post" action="/projects/{{ project_name }}/tasks/{{ task.full_id }}/log" class="mt-4">
+      <label for="log-message">Add log entry</label>
+      <textarea id="log-message" name="message" rows="3" required placeholder="What happened…"></textarea>
+      <button type="submit" class="outline small mt-2">Add log</button>
+    </form>
   </details>
-  {% endif %}
 </article>
 
-{% if !blockers_open.is_empty() || !blockers_resolved.is_empty() %}
 <article class="card mt-4">
   <h2>Blockers</h2>
+  {% if !blockers_open.is_empty() || !blockers_resolved.is_empty() %}
   <ul>
     {% for b in blockers_open %}
-    <li><a href="/projects/{{ project_name }}/tasks/{{ b.full_id }}"><code>{{ b.short_id }}</code></a> <span class="badge warning">open</span></li>
+    <li class="hstack items-center gap-2">
+      <a href="/projects/{{ project_name }}/tasks/{{ b.full_id }}"><code>{{ b.short_id }}</code></a> <span class="badge warning">open</span>
+      <form method="post" action="/projects/{{ project_name }}/tasks/{{ task.full_id }}/deps" class="inline">
+        <input type="hidden" name="action" value="rm">
+        <input type="hidden" name="blocker" value="{{ b.short_id }}">
+        <button type="submit" class="outline small" aria-label="Remove blocker {{ b.short_id }}">Γ—</button>
+      </form>
+    </li>
     {% endfor %}
     {% for b in blockers_resolved %}
-    <li><a href="/projects/{{ project_name }}/tasks/{{ b.full_id }}"><code>{{ b.short_id }}</code></a> <span class="badge success">resolved</span></li>
+    <li class="hstack items-center gap-2">
+      <a href="/projects/{{ project_name }}/tasks/{{ b.full_id }}"><code>{{ b.short_id }}</code></a> <span class="badge success">resolved</span>
+      <form method="post" action="/projects/{{ project_name }}/tasks/{{ task.full_id }}/deps" class="inline">
+        <input type="hidden" name="action" value="rm">
+        <input type="hidden" name="blocker" value="{{ b.short_id }}">
+        <button type="submit" class="outline small" aria-label="Remove blocker {{ b.short_id }}">Γ—</button>
+      </form>
+    </li>
     {% endfor %}
   </ul>
+  {% endif %}
+  <form method="post" action="/projects/{{ project_name }}/tasks/{{ task.full_id }}/deps" class="hstack gap-2 mt-2">
+    <input type="hidden" name="action" value="add">
+    <input type="text" name="blocker" placeholder="Task ID…" required aria-label="Blocker task ID">
+    <button type="submit" class="outline small">Add blocker</button>
+  </form>
 </article>
-{% endif %}
 
 {% if !subtasks.is_empty() %}
 <article class="card mt-4">