Detailed changes
@@ -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)?);
@@ -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)
-}
@@ -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);
}
@@ -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();
@@ -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)?);
@@ -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);
}
@@ -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);
- }
- }
-}
@@ -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();
@@ -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))
@@ -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;
@@ -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);
+ }
+ }
+}
@@ -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);
+}
@@ -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(() => {
@@ -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>
@@ -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>
@@ -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">