Split webui command into modules

Amolith created

Change summary

src/cmd/webui.rs           | 1313 ----------------------------------------
src/cmd/webui/handlers.rs  |  410 ++++++++++++
src/cmd/webui/helpers.rs   |  204 ++++++
src/cmd/webui/mod.rs       |  125 +++
src/cmd/webui/mutations.rs |  379 +++++++++++
src/cmd/webui/views.rs     |  214 ++++++
6 files changed, 1,332 insertions(+), 1,313 deletions(-)

Detailed changes

src/cmd/webui.rs 🔗

@@ -1,1313 +0,0 @@
-use std::collections::HashSet;
-use std::path::Path;
-use std::sync::Arc;
-
-use anyhow::Result;
-use askama::Template;
-use axum::extract::{Path as AxumPath, Query, State};
-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;
-
-// ---------------------------------------------------------------------------
-// Sort helpers
-// ---------------------------------------------------------------------------
-
-/// Columns the task table can be sorted by.
-#[derive(Clone, Copy, PartialEq, Eq)]
-enum SortField {
-    Id,
-    Status,
-    Priority,
-    Effort,
-    Title,
-    Created,
-}
-
-impl SortField {
-    fn parse(s: &str) -> Option<Self> {
-        match s {
-            "id" => Some(Self::Id),
-            "status" => Some(Self::Status),
-            "priority" => Some(Self::Priority),
-            "effort" => Some(Self::Effort),
-            "title" => Some(Self::Title),
-            "created" => Some(Self::Created),
-            _ => None,
-        }
-    }
-
-    fn as_str(self) -> &'static str {
-        match self {
-            Self::Id => "id",
-            Self::Status => "status",
-            Self::Priority => "priority",
-            Self::Effort => "effort",
-            Self::Title => "title",
-            Self::Created => "created",
-        }
-    }
-
-    /// Sensible default direction when the user first clicks a column.
-    fn default_order(self) -> SortOrder {
-        match self {
-            // Newest first, alphabetical ascending for text fields.
-            Self::Created => SortOrder::Desc,
-            Self::Title | Self::Id => SortOrder::Asc,
-            // Highest priority/effort first; open before closed.
-            Self::Priority | Self::Effort | Self::Status => SortOrder::Asc,
-        }
-    }
-}
-
-#[derive(Clone, Copy, PartialEq, Eq)]
-enum SortOrder {
-    Asc,
-    Desc,
-}
-
-impl SortOrder {
-    fn parse(s: &str) -> Option<Self> {
-        match s {
-            "asc" => Some(Self::Asc),
-            "desc" => Some(Self::Desc),
-            _ => None,
-        }
-    }
-
-    fn as_str(self) -> &'static str {
-        match self {
-            Self::Asc => "asc",
-            Self::Desc => "desc",
-        }
-    }
-}
-
-/// Map a `Status` to a numeric value for semantic sorting.
-/// Lower values sort first in ascending order: open → in_progress → closed.
-fn status_sort_key(s: db::Status) -> i32 {
-    match s {
-        db::Status::Open => 1,
-        db::Status::InProgress => 2,
-        db::Status::Closed => 3,
-    }
-}
-
-/// Apply the chosen sort field and direction to a filtered task list.
-fn sort_tasks(tasks: &mut [&db::Task], field: SortField, order: SortOrder) {
-    tasks.sort_by(|a, b| {
-        let cmp = match field {
-            SortField::Id => a.id.as_str().cmp(b.id.as_str()),
-            SortField::Status => status_sort_key(a.status).cmp(&status_sort_key(b.status)),
-            SortField::Priority => a.priority.score().cmp(&b.priority.score()),
-            SortField::Effort => a.effort.score().cmp(&b.effort.score()),
-            SortField::Title => a
-                .title
-                .to_ascii_lowercase()
-                .cmp(&b.title.to_ascii_lowercase()),
-            SortField::Created => a.created_at.cmp(&b.created_at),
-        };
-        match order {
-            SortOrder::Asc => cmp,
-            SortOrder::Desc => cmp.reverse(),
-        }
-    });
-}
-
-/// Return a human-friendly status label (e.g. "In progress" instead of
-/// "in_progress").
-fn friendly_status(raw: &str) -> &'static str {
-    match raw {
-        "open" => "Open",
-        "in_progress" => "In progress",
-        "closed" => "Closed",
-        _ => "Open",
-    }
-}
-
-/// Format an ISO 8601 timestamp into a human-friendly form (e.g. "15 Mar 2026")
-/// for the noscript fallback.  Returns the original string unchanged on parse
-/// failure so the page still renders something sensible.
-fn friendly_date(iso: &str) -> String {
-    chrono::NaiveDateTime::parse_from_str(iso, "%Y-%m-%dT%H:%M:%SZ")
-        .map(|dt| dt.format("%-d %b %Y %H:%M").to_string())
-        .unwrap_or_else(|_| iso.to_string())
-}
-
-/// Render a markdown string to sanitised HTML.
-///
-/// Uses pulldown-cmark for parsing and ammonia for sanitisation so that
-/// untrusted user input can be displayed safely.  Images are stripped
-/// entirely — only structural/inline markup is allowed through.
-fn render_markdown(src: &str) -> String {
-    use pulldown_cmark::{html::push_html, Parser};
-
-    let parser = Parser::new(src);
-    let mut raw_html = String::new();
-    push_html(&mut raw_html, parser);
-
-    ammonia::Builder::default()
-        .rm_tags(&["img"])
-        .clean(&raw_html)
-        .to_string()
-}
-
-// ---------------------------------------------------------------------------
-// Shared state
-// ---------------------------------------------------------------------------
-
-#[derive(Clone)]
-struct AppState {
-    data_root: Arc<std::path::PathBuf>,
-}
-
-// ---------------------------------------------------------------------------
-// Template view-models
-// ---------------------------------------------------------------------------
-
-/// A project card on the root page — either healthy or failed.
-enum ProjectCard {
-    Ok {
-        name: String,
-        open: usize,
-        in_progress: usize,
-        closed: usize,
-        total: usize,
-    },
-    Err {
-        name: String,
-        error: String,
-    },
-}
-
-/// Minimal view-model for a scored task in the "Next Up" table.
-struct ScoredEntry {
-    id: String,
-    short_id: String,
-    title: String,
-    score: String,
-    status: String,
-    status_display: &'static str,
-}
-
-/// Minimal view-model for a task row in the project task table.
-struct TaskRow {
-    full_id: String,
-    short_id: String,
-    status: String,
-    status_display: &'static str,
-    priority: String,
-    effort: String,
-    title: String,
-    created_at: String,
-    created_at_display: String,
-}
-
-/// View-model for the task detail page.
-struct TaskView {
-    full_id: String,
-    short_id: String,
-    title: String,
-    description: String,
-    task_type: String,
-    status: String,
-    priority: String,
-    effort: String,
-    created_at: String,
-    created_at_display: String,
-    updated_at: String,
-    updated_at_display: String,
-    labels: Vec<String>,
-    logs: Vec<LogView>,
-}
-
-struct LogView {
-    timestamp: String,
-    timestamp_display: String,
-    message: String,
-}
-
-/// A blocker reference for the task detail page.
-struct BlockerRef {
-    full_id: String,
-    short_id: String,
-}
-
-/// Sort context passed to the task_table macro.  When present, column headers
-/// become clickable links that set sort/order query params.
-struct SortContext {
-    /// Base URL for sort links (e.g. `/projects/myproj`).
-    base_href: String,
-    /// Current sort field.
-    field: String,
-    /// Current sort order ("asc" or "desc").
-    order: String,
-    /// Query string fragment for the current filters (without sort/order/page),
-    /// suitable for appending to hrefs.
-    filter_qs: String,
-}
-
-impl SortContext {
-    /// Build the href for a column header link.  Clicking the currently-active
-    /// column toggles direction; clicking a different column uses its default.
-    fn column_href(&self, col: &str) -> String {
-        let order = if col == self.field {
-            // Toggle current direction.
-            match self.order.as_str() {
-                "asc" => "desc",
-                _ => "asc",
-            }
-        } else {
-            // Use the column's sensible default.
-            SortField::parse(col)
-                .map(|f| f.default_order().as_str())
-                .unwrap_or("asc")
-        };
-        let mut qs = self.filter_qs.clone();
-        if !qs.is_empty() {
-            qs.push('&');
-        }
-        qs.push_str(&format!("sort={col}&order={order}"));
-        format!("{}?{qs}", self.base_href)
-    }
-
-    /// Return the arrow indicator for the active column, or empty string.
-    fn arrow(&self, col: &str) -> &str {
-        if col == self.field {
-            match self.order.as_str() {
-                "asc" => " ↑",
-                "desc" => " ↓",
-                _ => "",
-            }
-        } else {
-            ""
-        }
-    }
-}
-
-// ---------------------------------------------------------------------------
-// Askama templates
-// ---------------------------------------------------------------------------
-
-#[derive(Template)]
-#[template(path = "index.html")]
-struct IndexTemplate {
-    all_projects: Vec<String>,
-    active_project: Option<String>,
-    projects: Vec<ProjectCard>,
-}
-
-#[derive(Template)]
-#[template(path = "project.html")]
-struct ProjectTemplate {
-    all_projects: Vec<String>,
-    active_project: Option<String>,
-    project_name: String,
-    stats_open: usize,
-    stats_in_progress: usize,
-    stats_closed: usize,
-    next_up: Vec<ScoredEntry>,
-    page_tasks: Vec<TaskRow>,
-    all_labels: Vec<String>,
-    filter_status: Option<String>,
-    filter_priority: Option<String>,
-    filter_effort: Option<String>,
-    filter_label: Option<String>,
-    filter_search: String,
-    page: usize,
-    total_pages: usize,
-    pagination_pages: Vec<usize>,
-    sort_ctx: SortContext,
-}
-
-impl ProjectTemplate {
-    /// Build a query-string fragment containing the current filters (no sort,
-    /// no page).  Reused by both pagination and sort helpers.
-    fn filter_qs(&self) -> String {
-        let mut parts = Vec::new();
-        if let Some(ref s) = self.filter_status {
-            parts.push(format!("status={s}"));
-        }
-        if let Some(ref p) = self.filter_priority {
-            parts.push(format!("priority={p}"));
-        }
-        if let Some(ref e) = self.filter_effort {
-            parts.push(format!("effort={e}"));
-        }
-        if let Some(ref l) = self.filter_label {
-            parts.push(format!("label={l}"));
-        }
-        if !self.filter_search.is_empty() {
-            parts.push(format!("q={}", self.filter_search));
-        }
-        parts.join("&")
-    }
-
-    /// Build a pagination link preserving current filter and sort params.
-    fn pagination_href(&self, target_page: &usize) -> String {
-        let target_page = *target_page;
-        let mut qs = self.filter_qs();
-        if !qs.is_empty() {
-            qs.push('&');
-        }
-        qs.push_str(&format!(
-            "sort={}&order={}",
-            self.sort_ctx.field, self.sort_ctx.order
-        ));
-        qs.push_str(&format!("&page={target_page}"));
-        format!("/projects/{}?{qs}", self.project_name)
-    }
-}
-
-#[derive(Template)]
-#[template(path = "task.html")]
-struct TaskTemplate {
-    all_projects: Vec<String>,
-    active_project: Option<String>,
-    project_name: String,
-    task: TaskView,
-    blockers_open: Vec<BlockerRef>,
-    blockers_resolved: Vec<BlockerRef>,
-    subtasks: Vec<TaskRow>,
-}
-
-#[derive(Template)]
-#[template(path = "error.html")]
-struct ErrorTemplate {
-    all_projects: Vec<String>,
-    active_project: Option<String>,
-    status_code: u16,
-    message: String,
-}
-
-// ---------------------------------------------------------------------------
-// Response helpers
-// ---------------------------------------------------------------------------
-
-fn render(tmpl: impl Template) -> Response {
-    match tmpl.render() {
-        Ok(html) => Html(html).into_response(),
-        Err(e) => error_response(500, &format!("template render failed: {e}"), &[]),
-    }
-}
-
-fn error_response(code: u16, msg: &str, all_projects: &[String]) -> Response {
-    let body = ErrorTemplate {
-        all_projects: all_projects.to_vec(),
-        active_project: None,
-        status_code: code,
-        message: msg.to_string(),
-    };
-    let html = body
-        .render()
-        .unwrap_or_else(|_| format!("<h1>{code}</h1><p>{msg}</p>"));
-    let status = StatusCode::from_u16(code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
-    (status, Html(html)).into_response()
-}
-
-fn list_projects_safe(root: &std::path::Path) -> Vec<String> {
-    db::list_projects_in(root).unwrap_or_default()
-}
-
-// ---------------------------------------------------------------------------
-// Route handlers
-// ---------------------------------------------------------------------------
-
-async fn index_handler(State(state): State<AppState>) -> Response {
-    let root = state.data_root.clone();
-    let result = tokio::task::spawn_blocking(move || -> Result<IndexTemplate> {
-        let projects = list_projects_safe(&root);
-        let mut cards = Vec::with_capacity(projects.len());
-
-        for name in &projects {
-            match Store::open(&root, name) {
-                Ok(store) => {
-                    let tasks = store.list_tasks()?;
-                    let open = tasks
-                        .iter()
-                        .filter(|t| t.status == db::Status::Open)
-                        .count();
-                    let in_progress = tasks
-                        .iter()
-                        .filter(|t| t.status == db::Status::InProgress)
-                        .count();
-                    let closed = tasks
-                        .iter()
-                        .filter(|t| t.status == db::Status::Closed)
-                        .count();
-                    cards.push(ProjectCard::Ok {
-                        name: name.clone(),
-                        open,
-                        in_progress,
-                        closed,
-                        total: tasks.len(),
-                    });
-                }
-                Err(e) => {
-                    cards.push(ProjectCard::Err {
-                        name: name.clone(),
-                        error: format!("{e}"),
-                    });
-                }
-            }
-        }
-
-        Ok(IndexTemplate {
-            all_projects: projects,
-            active_project: None,
-            projects: cards,
-        })
-    })
-    .await;
-
-    match result {
-        Ok(Ok(tmpl)) => render(tmpl),
-        Ok(Err(e)) => error_response(500, &format!("{e}"), &[]),
-        Err(e) => error_response(500, &format!("join error: {e}"), &[]),
-    }
-}
-
-#[derive(serde::Deserialize)]
-struct ProjectQuery {
-    status: Option<String>,
-    priority: Option<String>,
-    effort: Option<String>,
-    label: Option<String>,
-    q: Option<String>,
-    page: Option<usize>,
-    sort: Option<String>,
-    order: Option<String>,
-}
-
-async fn project_handler(
-    State(state): State<AppState>,
-    AxumPath(name): AxumPath<String>,
-    Query(mut query): Query<ProjectQuery>,
-) -> Response {
-    // Default to showing open tasks when no status filter is specified.
-    if query.status.is_none() {
-        query.status = Some("open".to_string());
-    }
-    let root = state.data_root.clone();
-    let result = tokio::task::spawn_blocking(move || -> Result<ProjectTemplate> {
-        let all_projects = list_projects_safe(&root);
-        let store = Store::open(&root, &name)?;
-        let tasks = store.list_tasks()?;
-
-        // Stats from the full unfiltered set.
-        let stats_open = tasks
-            .iter()
-            .filter(|t| t.status == db::Status::Open)
-            .count();
-        let stats_in_progress = tasks
-            .iter()
-            .filter(|t| t.status == db::Status::InProgress)
-            .count();
-        let stats_closed = tasks
-            .iter()
-            .filter(|t| t.status == db::Status::Closed)
-            .count();
-
-        // Collect distinct labels for the filter dropdown.
-        let mut label_set: HashSet<String> = HashSet::new();
-        for t in &tasks {
-            for l in &t.labels {
-                label_set.insert(l.clone());
-            }
-        }
-        let mut all_labels: Vec<String> = label_set.into_iter().collect();
-        all_labels.sort();
-
-        // Next-up scoring (top 5 open tasks).
-        let open_tasks: Vec<score::TaskInput> = tasks
-            .iter()
-            .filter(|t| t.status == db::Status::Open)
-            .map(|t| score::TaskInput {
-                id: t.id.as_str().to_string(),
-                title: t.title.clone(),
-                priority_score: t.priority.score(),
-                effort_score: t.effort.score(),
-                priority_label: db::priority_label(t.priority).to_string(),
-                effort_label: db::effort_label(t.effort).to_string(),
-            })
-            .collect();
-
-        let edges: Vec<(String, String)> = tasks
-            .iter()
-            .filter(|t| t.status == db::Status::Open)
-            .flat_map(|t| {
-                t.blockers
-                    .iter()
-                    .map(|b| (t.id.as_str().to_string(), b.as_str().to_string()))
-                    .collect::<Vec<_>>()
-            })
-            .collect();
-
-        let parents_with_open_children: HashSet<String> = tasks
-            .iter()
-            .filter(|t| t.status == db::Status::Open)
-            .filter_map(|t| t.parent.as_ref().map(|p| p.as_str().to_string()))
-            .collect();
-
-        let scored = score::rank(
-            &open_tasks,
-            &edges,
-            &parents_with_open_children,
-            score::Mode::Impact,
-            5,
-        );
-
-        let next_up: Vec<ScoredEntry> = scored
-            .into_iter()
-            .map(|s| ScoredEntry {
-                short_id: TaskId::display_id(&s.id),
-                id: s.id,
-                title: s.title,
-                score: format!("{:.2}", s.score),
-                status: "open".to_string(),
-                status_display: friendly_status("open"),
-            })
-            .collect();
-
-        // Apply filters.
-        let mut filtered: Vec<&db::Task> = tasks.iter().collect();
-
-        if let Some(ref s) = query.status {
-            if !s.is_empty() {
-                if let Ok(parsed) = db::parse_status(s) {
-                    filtered.retain(|t| t.status == parsed);
-                }
-            }
-        }
-        if let Some(ref p) = query.priority {
-            if !p.is_empty() {
-                if let Ok(parsed) = db::parse_priority(p) {
-                    filtered.retain(|t| t.priority == parsed);
-                }
-            }
-        }
-        if let Some(ref e) = query.effort {
-            if !e.is_empty() {
-                if let Ok(parsed) = db::parse_effort(e) {
-                    filtered.retain(|t| t.effort == parsed);
-                }
-            }
-        }
-        if let Some(ref l) = query.label {
-            if !l.is_empty() {
-                filtered.retain(|t| t.labels.iter().any(|x| x == l));
-            }
-        }
-        let search_term = query.q.clone().unwrap_or_default();
-        if !search_term.is_empty() {
-            let q = search_term.to_ascii_lowercase();
-            filtered.retain(|t| t.title.to_ascii_lowercase().contains(&q));
-        }
-
-        // Sort: user-selected column, or priority+created as default.
-        let sort_field = query
-            .sort
-            .as_deref()
-            .and_then(SortField::parse)
-            .unwrap_or(SortField::Priority);
-        let sort_order = query
-            .order
-            .as_deref()
-            .and_then(SortOrder::parse)
-            .unwrap_or_else(|| sort_field.default_order());
-        sort_tasks(&mut filtered, sort_field, sort_order);
-
-        // Pagination.
-        let total = filtered.len();
-        let total_pages = if total == 0 {
-            1
-        } else {
-            total.div_ceil(PAGE_SIZE)
-        };
-        let page = query.page.unwrap_or(1).clamp(1, total_pages);
-        let start = (page - 1) * PAGE_SIZE;
-        let end = (start + PAGE_SIZE).min(total);
-
-        let page_tasks: Vec<TaskRow> = filtered[start..end]
-            .iter()
-            .map(|t| {
-                let status = db::status_label(t.status).to_string();
-                TaskRow {
-                    full_id: t.id.as_str().to_string(),
-                    short_id: t.id.short(),
-                    status_display: friendly_status(&status),
-                    status,
-                    priority: db::priority_label(t.priority).to_string(),
-                    effort: db::effort_label(t.effort).to_string(),
-                    title: t.title.clone(),
-                    created_at_display: friendly_date(&t.created_at),
-                    created_at: t.created_at.clone(),
-                }
-            })
-            .collect();
-
-        // Build filter query string for sort links (excludes sort/order/page).
-        let filter_qs = {
-            let mut parts = Vec::new();
-            if let Some(ref s) = query.status {
-                parts.push(format!("status={s}"));
-            }
-            if let Some(ref p) = query.priority {
-                parts.push(format!("priority={p}"));
-            }
-            if let Some(ref e) = query.effort {
-                parts.push(format!("effort={e}"));
-            }
-            if let Some(ref l) = query.label {
-                parts.push(format!("label={l}"));
-            }
-            if !search_term.is_empty() {
-                parts.push(format!("q={search_term}"));
-            }
-            parts.join("&")
-        };
-
-        let proj_name = store.project_name().to_string();
-        let sort_ctx = SortContext {
-            base_href: format!("/projects/{proj_name}"),
-            field: sort_field.as_str().to_string(),
-            order: sort_order.as_str().to_string(),
-            filter_qs,
-        };
-
-        Ok(ProjectTemplate {
-            all_projects,
-            active_project: Some(name),
-            project_name: proj_name,
-            stats_open,
-            stats_in_progress,
-            stats_closed,
-            next_up,
-            page_tasks,
-            all_labels,
-            filter_status: query.status,
-            filter_priority: query.priority,
-            filter_effort: query.effort,
-            filter_label: query.label,
-            filter_search: search_term,
-            page,
-            total_pages,
-            pagination_pages: (1..=total_pages).collect(),
-            sort_ctx,
-        })
-    })
-    .await;
-
-    match result {
-        Ok(Ok(tmpl)) => render(tmpl),
-        Ok(Err(e)) => error_response(500, &format!("{e}"), &[]),
-        Err(e) => error_response(500, &format!("join error: {e}"), &[]),
-    }
-}
-
-async fn task_handler(
-    State(state): State<AppState>,
-    AxumPath((name, id)): AxumPath<(String, String)>,
-) -> Response {
-    let root = state.data_root.clone();
-    let result = tokio::task::spawn_blocking(move || -> Result<TaskTemplate> {
-        let all_projects = list_projects_safe(&root);
-        let store = Store::open(&root, &name)?;
-
-        let task_id = db::resolve_task_id(&store, &id, false)?;
-        let task = store
-            .get_task(&task_id, false)?
-            .ok_or_else(|| anyhow::anyhow!("task '{id}' not found"))?;
-
-        // Partition blockers.
-        let partition = db::partition_blockers(&store, &task.blockers)?;
-        let blockers_open: Vec<BlockerRef> = partition
-            .open
-            .iter()
-            .map(|b| BlockerRef {
-                full_id: b.as_str().to_string(),
-                short_id: b.short(),
-            })
-            .collect();
-        let blockers_resolved: Vec<BlockerRef> = partition
-            .resolved
-            .iter()
-            .map(|b| BlockerRef {
-                full_id: b.as_str().to_string(),
-                short_id: b.short(),
-            })
-            .collect();
-
-        // Find subtasks.
-        let all_tasks = store.list_tasks()?;
-        let subtasks: Vec<TaskRow> = all_tasks
-            .iter()
-            .filter(|t| t.parent.as_ref() == Some(&task_id))
-            .map(|t| {
-                let status = db::status_label(t.status).to_string();
-                TaskRow {
-                    full_id: t.id.as_str().to_string(),
-                    short_id: t.id.short(),
-                    status_display: friendly_status(&status),
-                    status,
-                    priority: db::priority_label(t.priority).to_string(),
-                    effort: db::effort_label(t.effort).to_string(),
-                    title: t.title.clone(),
-                    created_at_display: friendly_date(&t.created_at),
-                    created_at: t.created_at.clone(),
-                }
-            })
-            .collect();
-
-        let task_view = TaskView {
-            full_id: task.id.as_str().to_string(),
-            short_id: task.id.short(),
-            title: task.title.clone(),
-            description: render_markdown(&task.description),
-            task_type: task.task_type.clone(),
-            status: db::status_label(task.status).to_string(),
-            priority: db::priority_label(task.priority).to_string(),
-            effort: db::effort_label(task.effort).to_string(),
-            created_at_display: friendly_date(&task.created_at),
-            created_at: task.created_at.clone(),
-            updated_at_display: friendly_date(&task.updated_at),
-            updated_at: task.updated_at.clone(),
-            labels: task.labels.clone(),
-            logs: task
-                .logs
-                .iter()
-                .map(|l| LogView {
-                    timestamp_display: friendly_date(&l.timestamp),
-                    timestamp: l.timestamp.clone(),
-                    message: render_markdown(&l.message),
-                })
-                .collect(),
-        };
-
-        Ok(TaskTemplate {
-            all_projects,
-            active_project: Some(name),
-            project_name: store.project_name().to_string(),
-            task: task_view,
-            blockers_open,
-            blockers_resolved,
-            subtasks,
-        })
-    })
-    .await;
-
-    match result {
-        Ok(Ok(tmpl)) => render(tmpl),
-        Ok(Err(e)) => error_response(500, &format!("{e}"), &[]),
-        Err(e) => error_response(500, &format!("join error: {e}"), &[]),
-    }
-}
-
-// ---------------------------------------------------------------------------
-// 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>,
-    #[serde(default)]
-    redirect: 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 = form
-            .redirect
-            .filter(|r| r.starts_with('/'))
-            .unwrap_or_else(|| 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")],
-        include_bytes!("../../static/oat.min.css").as_slice(),
-    )
-}
-
-async fn static_td_css() -> impl IntoResponse {
-    (
-        [(axum::http::header::CONTENT_TYPE, "text/css; charset=utf-8")],
-        include_bytes!("../../static/td.css").as_slice(),
-    )
-}
-
-async fn static_js() -> impl IntoResponse {
-    (
-        [(
-            axum::http::header::CONTENT_TYPE,
-            "application/javascript; charset=utf-8",
-        )],
-        include_bytes!("../../static/oat.min.js").as_slice(),
-    )
-}
-
-async fn static_td_js() -> impl IntoResponse {
-    (
-        [(
-            axum::http::header::CONTENT_TYPE,
-            "application/javascript; charset=utf-8",
-        )],
-        include_bytes!("../../static/td.js").as_slice(),
-    )
-}
-
-// ---------------------------------------------------------------------------
-// Entry point
-// ---------------------------------------------------------------------------
-
-pub fn run(cwd: &Path, host: &str, port: u16, explicit_project: Option<&str>) -> Result<()> {
-    let data_root = db::data_root()?;
-    let state = AppState {
-        data_root: Arc::new(data_root),
-    };
-
-    let app = Router::new()
-        .route("/", get(index_handler))
-        .route("/projects", post(create_project_handler))
-        .route("/projects/{name}", get(project_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))
-        .route("/static/td.js", get(static_td_js))
-        .with_state(state);
-
-    let addr = format!("{host}:{port}");
-    let root_url = format!("http://{addr}");
-
-    // Resolve current project for a convenience URL.
-    let project_url = match explicit_project {
-        Some(p) => Some(format!("{root_url}/projects/{p}")),
-        None => db::try_open(cwd)
-            .ok()
-            .flatten()
-            .map(|s| format!("{root_url}/projects/{}", s.project_name())),
-    };
-
-    eprintln!("listening on {root_url}");
-    if let Some(ref url) = project_url {
-        eprintln!("project: {url}");
-    }
-
-    tokio::runtime::Builder::new_multi_thread()
-        .enable_all()
-        .build()?
-        .block_on(async {
-            let listener = tokio::net::TcpListener::bind(&addr).await?;
-            axum::serve(listener, app).await?;
-            Ok(())
-        })
-}

src/cmd/webui/handlers.rs 🔗

@@ -0,0 +1,410 @@
+use std::collections::HashSet;
+
+use anyhow::Result;
+use axum::extract::{Path as AxumPath, Query, State};
+use axum::response::Response;
+
+use crate::db::{self, Store, TaskId};
+use crate::score;
+
+use super::helpers::{
+    error_response, friendly_date, friendly_status, list_projects_safe, render, render_markdown,
+    sort_tasks, SortField, SortOrder,
+};
+use super::views::{
+    BlockerRef, IndexTemplate, LogView, ProjectCard, ProjectTemplate, ScoredEntry, SortContext,
+    TaskRow, TaskTemplate, TaskView,
+};
+use super::AppState;
+
+const PAGE_SIZE: usize = 25;
+
+pub(super) async fn index_handler(State(state): State<AppState>) -> Response {
+    let root = state.data_root.clone();
+    let result = tokio::task::spawn_blocking(move || -> Result<IndexTemplate> {
+        let projects = list_projects_safe(&root);
+        let mut cards = Vec::with_capacity(projects.len());
+
+        for name in &projects {
+            match Store::open(&root, name) {
+                Ok(store) => {
+                    let tasks = store.list_tasks()?;
+                    let open = tasks
+                        .iter()
+                        .filter(|t| t.status == db::Status::Open)
+                        .count();
+                    let in_progress = tasks
+                        .iter()
+                        .filter(|t| t.status == db::Status::InProgress)
+                        .count();
+                    let closed = tasks
+                        .iter()
+                        .filter(|t| t.status == db::Status::Closed)
+                        .count();
+                    cards.push(ProjectCard::Ok {
+                        name: name.clone(),
+                        open,
+                        in_progress,
+                        closed,
+                        total: tasks.len(),
+                    });
+                }
+                Err(e) => {
+                    cards.push(ProjectCard::Err {
+                        name: name.clone(),
+                        error: format!("{e}"),
+                    });
+                }
+            }
+        }
+
+        Ok(IndexTemplate {
+            all_projects: projects,
+            active_project: None,
+            projects: cards,
+        })
+    })
+    .await;
+
+    match result {
+        Ok(Ok(tmpl)) => render(tmpl),
+        Ok(Err(e)) => error_response(500, &format!("{e}"), &[]),
+        Err(e) => error_response(500, &format!("join error: {e}"), &[]),
+    }
+}
+
+#[derive(serde::Deserialize)]
+pub(super) struct ProjectQuery {
+    status: Option<String>,
+    priority: Option<String>,
+    effort: Option<String>,
+    label: Option<String>,
+    q: Option<String>,
+    page: Option<usize>,
+    sort: Option<String>,
+    order: Option<String>,
+}
+
+pub(super) async fn project_handler(
+    State(state): State<AppState>,
+    AxumPath(name): AxumPath<String>,
+    Query(mut query): Query<ProjectQuery>,
+) -> Response {
+    // Default to showing open tasks when no status filter is specified.
+    if query.status.is_none() {
+        query.status = Some("open".to_string());
+    }
+    let root = state.data_root.clone();
+    let result = tokio::task::spawn_blocking(move || -> Result<ProjectTemplate> {
+        let all_projects = list_projects_safe(&root);
+        let store = Store::open(&root, &name)?;
+        let tasks = store.list_tasks()?;
+
+        // Stats from the full unfiltered set.
+        let stats_open = tasks
+            .iter()
+            .filter(|t| t.status == db::Status::Open)
+            .count();
+        let stats_in_progress = tasks
+            .iter()
+            .filter(|t| t.status == db::Status::InProgress)
+            .count();
+        let stats_closed = tasks
+            .iter()
+            .filter(|t| t.status == db::Status::Closed)
+            .count();
+
+        // Collect distinct labels for the filter dropdown.
+        let mut label_set: HashSet<String> = HashSet::new();
+        for t in &tasks {
+            for l in &t.labels {
+                label_set.insert(l.clone());
+            }
+        }
+        let mut all_labels: Vec<String> = label_set.into_iter().collect();
+        all_labels.sort();
+
+        // Next-up scoring (top 5 open tasks).
+        let open_tasks: Vec<score::TaskInput> = tasks
+            .iter()
+            .filter(|t| t.status == db::Status::Open)
+            .map(|t| score::TaskInput {
+                id: t.id.as_str().to_string(),
+                title: t.title.clone(),
+                priority_score: t.priority.score(),
+                effort_score: t.effort.score(),
+                priority_label: db::priority_label(t.priority).to_string(),
+                effort_label: db::effort_label(t.effort).to_string(),
+            })
+            .collect();
+
+        let edges: Vec<(String, String)> = tasks
+            .iter()
+            .filter(|t| t.status == db::Status::Open)
+            .flat_map(|t| {
+                t.blockers
+                    .iter()
+                    .map(|b| (t.id.as_str().to_string(), b.as_str().to_string()))
+                    .collect::<Vec<_>>()
+            })
+            .collect();
+
+        let parents_with_open_children: HashSet<String> = tasks
+            .iter()
+            .filter(|t| t.status == db::Status::Open)
+            .filter_map(|t| t.parent.as_ref().map(|p| p.as_str().to_string()))
+            .collect();
+
+        let scored = score::rank(
+            &open_tasks,
+            &edges,
+            &parents_with_open_children,
+            score::Mode::Impact,
+            5,
+        );
+
+        let next_up: Vec<ScoredEntry> = scored
+            .into_iter()
+            .map(|s| ScoredEntry {
+                short_id: TaskId::display_id(&s.id),
+                id: s.id,
+                title: s.title,
+                score: format!("{:.2}", s.score),
+                status: "open".to_string(),
+                status_display: friendly_status("open"),
+            })
+            .collect();
+
+        // Apply filters.
+        let mut filtered: Vec<&db::Task> = tasks.iter().collect();
+
+        if let Some(ref s) = query.status {
+            if !s.is_empty() {
+                if let Ok(parsed) = db::parse_status(s) {
+                    filtered.retain(|t| t.status == parsed);
+                }
+            }
+        }
+        if let Some(ref p) = query.priority {
+            if !p.is_empty() {
+                if let Ok(parsed) = db::parse_priority(p) {
+                    filtered.retain(|t| t.priority == parsed);
+                }
+            }
+        }
+        if let Some(ref e) = query.effort {
+            if !e.is_empty() {
+                if let Ok(parsed) = db::parse_effort(e) {
+                    filtered.retain(|t| t.effort == parsed);
+                }
+            }
+        }
+        if let Some(ref l) = query.label {
+            if !l.is_empty() {
+                filtered.retain(|t| t.labels.iter().any(|x| x == l));
+            }
+        }
+        let search_term = query.q.clone().unwrap_or_default();
+        if !search_term.is_empty() {
+            let q = search_term.to_ascii_lowercase();
+            filtered.retain(|t| t.title.to_ascii_lowercase().contains(&q));
+        }
+
+        // Sort: user-selected column, or priority+created as default.
+        let sort_field = query
+            .sort
+            .as_deref()
+            .and_then(SortField::parse)
+            .unwrap_or(SortField::Priority);
+        let sort_order = query
+            .order
+            .as_deref()
+            .and_then(SortOrder::parse)
+            .unwrap_or_else(|| sort_field.default_order());
+        sort_tasks(&mut filtered, sort_field, sort_order);
+
+        // Pagination.
+        let total = filtered.len();
+        let total_pages = if total == 0 {
+            1
+        } else {
+            total.div_ceil(PAGE_SIZE)
+        };
+        let page = query.page.unwrap_or(1).clamp(1, total_pages);
+        let start = (page - 1) * PAGE_SIZE;
+        let end = (start + PAGE_SIZE).min(total);
+
+        let page_tasks: Vec<TaskRow> = filtered[start..end]
+            .iter()
+            .map(|t| {
+                let status = db::status_label(t.status).to_string();
+                TaskRow {
+                    full_id: t.id.as_str().to_string(),
+                    short_id: t.id.short(),
+                    status_display: friendly_status(&status),
+                    status,
+                    priority: db::priority_label(t.priority).to_string(),
+                    effort: db::effort_label(t.effort).to_string(),
+                    title: t.title.clone(),
+                    created_at_display: friendly_date(&t.created_at),
+                    created_at: t.created_at.clone(),
+                }
+            })
+            .collect();
+
+        // Build filter query string for sort links (excludes sort/order/page).
+        let filter_qs = {
+            let mut parts = Vec::new();
+            if let Some(ref s) = query.status {
+                parts.push(format!("status={s}"));
+            }
+            if let Some(ref p) = query.priority {
+                parts.push(format!("priority={p}"));
+            }
+            if let Some(ref e) = query.effort {
+                parts.push(format!("effort={e}"));
+            }
+            if let Some(ref l) = query.label {
+                parts.push(format!("label={l}"));
+            }
+            if !search_term.is_empty() {
+                parts.push(format!("q={search_term}"));
+            }
+            parts.join("&")
+        };
+
+        let proj_name = store.project_name().to_string();
+        let sort_ctx = SortContext {
+            base_href: format!("/projects/{proj_name}"),
+            field: sort_field.as_str().to_string(),
+            order: sort_order.as_str().to_string(),
+            filter_qs,
+        };
+
+        Ok(ProjectTemplate {
+            all_projects,
+            active_project: Some(name),
+            project_name: proj_name,
+            stats_open,
+            stats_in_progress,
+            stats_closed,
+            next_up,
+            page_tasks,
+            all_labels,
+            filter_status: query.status,
+            filter_priority: query.priority,
+            filter_effort: query.effort,
+            filter_label: query.label,
+            filter_search: search_term,
+            page,
+            total_pages,
+            pagination_pages: (1..=total_pages).collect(),
+            sort_ctx,
+        })
+    })
+    .await;
+
+    match result {
+        Ok(Ok(tmpl)) => render(tmpl),
+        Ok(Err(e)) => error_response(500, &format!("{e}"), &[]),
+        Err(e) => error_response(500, &format!("join error: {e}"), &[]),
+    }
+}
+
+pub(super) async fn task_handler(
+    State(state): State<AppState>,
+    AxumPath((name, id)): AxumPath<(String, String)>,
+) -> Response {
+    let root = state.data_root.clone();
+    let result = tokio::task::spawn_blocking(move || -> Result<TaskTemplate> {
+        let all_projects = list_projects_safe(&root);
+        let store = Store::open(&root, &name)?;
+
+        let task_id = db::resolve_task_id(&store, &id, false)?;
+        let task = store
+            .get_task(&task_id, false)?
+            .ok_or_else(|| anyhow::anyhow!("task '{id}' not found"))?;
+
+        // Partition blockers.
+        let partition = db::partition_blockers(&store, &task.blockers)?;
+        let blockers_open: Vec<BlockerRef> = partition
+            .open
+            .iter()
+            .map(|b| BlockerRef {
+                full_id: b.as_str().to_string(),
+                short_id: b.short(),
+            })
+            .collect();
+        let blockers_resolved: Vec<BlockerRef> = partition
+            .resolved
+            .iter()
+            .map(|b| BlockerRef {
+                full_id: b.as_str().to_string(),
+                short_id: b.short(),
+            })
+            .collect();
+
+        // Find subtasks.
+        let all_tasks = store.list_tasks()?;
+        let subtasks: Vec<TaskRow> = all_tasks
+            .iter()
+            .filter(|t| t.parent.as_ref() == Some(&task_id))
+            .map(|t| {
+                let status = db::status_label(t.status).to_string();
+                TaskRow {
+                    full_id: t.id.as_str().to_string(),
+                    short_id: t.id.short(),
+                    status_display: friendly_status(&status),
+                    status,
+                    priority: db::priority_label(t.priority).to_string(),
+                    effort: db::effort_label(t.effort).to_string(),
+                    title: t.title.clone(),
+                    created_at_display: friendly_date(&t.created_at),
+                    created_at: t.created_at.clone(),
+                }
+            })
+            .collect();
+
+        let task_view = TaskView {
+            full_id: task.id.as_str().to_string(),
+            short_id: task.id.short(),
+            title: task.title.clone(),
+            description: render_markdown(&task.description),
+            task_type: task.task_type.clone(),
+            status: db::status_label(task.status).to_string(),
+            priority: db::priority_label(task.priority).to_string(),
+            effort: db::effort_label(task.effort).to_string(),
+            created_at_display: friendly_date(&task.created_at),
+            created_at: task.created_at.clone(),
+            updated_at_display: friendly_date(&task.updated_at),
+            updated_at: task.updated_at.clone(),
+            labels: task.labels.clone(),
+            logs: task
+                .logs
+                .iter()
+                .map(|l| LogView {
+                    timestamp_display: friendly_date(&l.timestamp),
+                    timestamp: l.timestamp.clone(),
+                    message: render_markdown(&l.message),
+                })
+                .collect(),
+        };
+
+        Ok(TaskTemplate {
+            all_projects,
+            active_project: Some(name),
+            project_name: store.project_name().to_string(),
+            task: task_view,
+            blockers_open,
+            blockers_resolved,
+            subtasks,
+        })
+    })
+    .await;
+
+    match result {
+        Ok(Ok(tmpl)) => render(tmpl),
+        Ok(Err(e)) => error_response(500, &format!("{e}"), &[]),
+        Err(e) => error_response(500, &format!("join error: {e}"), &[]),
+    }
+}

src/cmd/webui/helpers.rs 🔗

@@ -0,0 +1,204 @@
+use askama::Template;
+use axum::http::{HeaderMap, StatusCode};
+use axum::response::{Html, IntoResponse, Redirect, Response};
+use axum::Json;
+
+use crate::db;
+
+use super::views::ErrorTemplate;
+
+/// Columns the task table can be sorted by.
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub(super) enum SortField {
+    Id,
+    Status,
+    Priority,
+    Effort,
+    Title,
+    Created,
+}
+
+impl SortField {
+    pub(super) fn parse(s: &str) -> Option<Self> {
+        match s {
+            "id" => Some(Self::Id),
+            "status" => Some(Self::Status),
+            "priority" => Some(Self::Priority),
+            "effort" => Some(Self::Effort),
+            "title" => Some(Self::Title),
+            "created" => Some(Self::Created),
+            _ => None,
+        }
+    }
+
+    pub(super) fn as_str(self) -> &'static str {
+        match self {
+            Self::Id => "id",
+            Self::Status => "status",
+            Self::Priority => "priority",
+            Self::Effort => "effort",
+            Self::Title => "title",
+            Self::Created => "created",
+        }
+    }
+
+    /// Sensible default direction when the user first clicks a column.
+    pub(super) fn default_order(self) -> SortOrder {
+        match self {
+            // Newest first, alphabetical ascending for text fields.
+            Self::Created => SortOrder::Desc,
+            Self::Title | Self::Id => SortOrder::Asc,
+            // Highest priority/effort first; open before closed.
+            Self::Priority | Self::Effort | Self::Status => SortOrder::Asc,
+        }
+    }
+}
+
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub(super) enum SortOrder {
+    Asc,
+    Desc,
+}
+
+impl SortOrder {
+    pub(super) fn parse(s: &str) -> Option<Self> {
+        match s {
+            "asc" => Some(Self::Asc),
+            "desc" => Some(Self::Desc),
+            _ => None,
+        }
+    }
+
+    pub(super) fn as_str(self) -> &'static str {
+        match self {
+            Self::Asc => "asc",
+            Self::Desc => "desc",
+        }
+    }
+}
+
+/// Map a `Status` to a numeric value for semantic sorting.
+/// Lower values sort first in ascending order: open → in_progress → closed.
+fn status_sort_key(s: db::Status) -> i32 {
+    match s {
+        db::Status::Open => 1,
+        db::Status::InProgress => 2,
+        db::Status::Closed => 3,
+    }
+}
+
+/// Apply the chosen sort field and direction to a filtered task list.
+pub(super) fn sort_tasks(tasks: &mut [&db::Task], field: SortField, order: SortOrder) {
+    tasks.sort_by(|a, b| {
+        let cmp = match field {
+            SortField::Id => a.id.as_str().cmp(b.id.as_str()),
+            SortField::Status => status_sort_key(a.status).cmp(&status_sort_key(b.status)),
+            SortField::Priority => a.priority.score().cmp(&b.priority.score()),
+            SortField::Effort => a.effort.score().cmp(&b.effort.score()),
+            SortField::Title => a
+                .title
+                .to_ascii_lowercase()
+                .cmp(&b.title.to_ascii_lowercase()),
+            SortField::Created => a.created_at.cmp(&b.created_at),
+        };
+        match order {
+            SortOrder::Asc => cmp,
+            SortOrder::Desc => cmp.reverse(),
+        }
+    });
+}
+
+/// Return a human-friendly status label (e.g. "In progress" instead of
+/// "in_progress").
+pub(super) fn friendly_status(raw: &str) -> &'static str {
+    match raw {
+        "open" => "Open",
+        "in_progress" => "In progress",
+        "closed" => "Closed",
+        _ => "Open",
+    }
+}
+
+/// Format an ISO 8601 timestamp into a human-friendly form (e.g. "15 Mar 2026")
+/// for the noscript fallback. Returns the original string unchanged on parse
+/// failure so the page still renders something sensible.
+pub(super) fn friendly_date(iso: &str) -> String {
+    chrono::NaiveDateTime::parse_from_str(iso, "%Y-%m-%dT%H:%M:%SZ")
+        .map(|dt| dt.format("%-d %b %Y %H:%M").to_string())
+        .unwrap_or_else(|_| iso.to_string())
+}
+
+/// Render a markdown string to sanitised HTML.
+///
+/// Uses pulldown-cmark for parsing and ammonia for sanitisation so that
+/// untrusted user input can be displayed safely. Images are stripped
+/// entirely — only structural/inline markup is allowed through.
+pub(super) fn render_markdown(src: &str) -> String {
+    use pulldown_cmark::{html::push_html, Parser};
+
+    let parser = Parser::new(src);
+    let mut raw_html = String::new();
+    push_html(&mut raw_html, parser);
+
+    ammonia::Builder::default()
+        .rm_tags(&["img"])
+        .clean(&raw_html)
+        .to_string()
+}
+
+pub(super) fn render(tmpl: impl Template) -> Response {
+    match tmpl.render() {
+        Ok(html) => Html(html).into_response(),
+        Err(e) => error_response(500, &format!("template render failed: {e}"), &[]),
+    }
+}
+
+pub(super) fn error_response(code: u16, msg: &str, all_projects: &[String]) -> Response {
+    let body = ErrorTemplate {
+        all_projects: all_projects.to_vec(),
+        active_project: None,
+        status_code: code,
+        message: msg.to_string(),
+    };
+    let html = body
+        .render()
+        .unwrap_or_else(|_| format!("<h1>{code}</h1><p>{msg}</p>"));
+    let status = StatusCode::from_u16(code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
+    (status, Html(html)).into_response()
+}
+
+pub(super) fn list_projects_safe(root: &std::path::Path) -> Vec<String> {
+    db::list_projects_in(root).unwrap_or_default()
+}
+
+/// 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.
+pub(super) 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).
+pub(super) 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(), &[])
+    }
+}

src/cmd/webui/mod.rs 🔗

@@ -0,0 +1,125 @@
+mod handlers;
+mod helpers;
+mod mutations;
+mod views;
+
+use std::path::Path;
+use std::sync::Arc;
+
+use anyhow::Result;
+use axum::response::IntoResponse;
+use axum::routing::{get, post};
+use axum::Router;
+
+use crate::db;
+
+#[derive(Clone)]
+struct AppState {
+    data_root: Arc<std::path::PathBuf>,
+}
+
+async fn static_oat_css() -> impl IntoResponse {
+    (
+        [(axum::http::header::CONTENT_TYPE, "text/css; charset=utf-8")],
+        include_bytes!("../../../static/oat.min.css").as_slice(),
+    )
+}
+
+async fn static_td_css() -> impl IntoResponse {
+    (
+        [(axum::http::header::CONTENT_TYPE, "text/css; charset=utf-8")],
+        include_bytes!("../../../static/td.css").as_slice(),
+    )
+}
+
+async fn static_js() -> impl IntoResponse {
+    (
+        [(
+            axum::http::header::CONTENT_TYPE,
+            "application/javascript; charset=utf-8",
+        )],
+        include_bytes!("../../../static/oat.min.js").as_slice(),
+    )
+}
+
+async fn static_td_js() -> impl IntoResponse {
+    (
+        [(
+            axum::http::header::CONTENT_TYPE,
+            "application/javascript; charset=utf-8",
+        )],
+        include_bytes!("../../../static/td.js").as_slice(),
+    )
+}
+
+pub fn run(cwd: &Path, host: &str, port: u16, explicit_project: Option<&str>) -> Result<()> {
+    let data_root = db::data_root()?;
+    let state = AppState {
+        data_root: Arc::new(data_root),
+    };
+
+    let app = Router::new()
+        .route("/", get(handlers::index_handler))
+        .route("/projects", post(mutations::create_project_handler))
+        .route("/projects/{name}", get(handlers::project_handler))
+        .route("/projects/{name}/tasks", post(mutations::create_handler))
+        .route(
+            "/projects/{name}/tasks/{id}",
+            get(handlers::task_handler).post(mutations::update_handler),
+        )
+        .route(
+            "/projects/{name}/tasks/{id}/log",
+            post(mutations::log_handler),
+        )
+        .route(
+            "/projects/{name}/tasks/{id}/done",
+            post(mutations::done_handler),
+        )
+        .route(
+            "/projects/{name}/tasks/{id}/reopen",
+            post(mutations::reopen_handler),
+        )
+        .route(
+            "/projects/{name}/tasks/{id}/labels",
+            post(mutations::label_handler),
+        )
+        .route(
+            "/projects/{name}/tasks/{id}/deps",
+            post(mutations::dep_handler),
+        )
+        .route(
+            "/projects/{name}/tasks/{id}/delete",
+            post(mutations::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))
+        .route("/static/td.js", get(static_td_js))
+        .with_state(state);
+
+    let addr = format!("{host}:{port}");
+    let root_url = format!("http://{addr}");
+
+    // Resolve current project for a convenience URL.
+    let project_url = match explicit_project {
+        Some(p) => Some(format!("{root_url}/projects/{p}")),
+        None => db::try_open(cwd)
+            .ok()
+            .flatten()
+            .map(|s| format!("{root_url}/projects/{}", s.project_name())),
+    };
+
+    eprintln!("listening on {root_url}");
+    if let Some(ref url) = project_url {
+        eprintln!("project: {url}");
+    }
+
+    tokio::runtime::Builder::new_multi_thread()
+        .enable_all()
+        .build()?
+        .block_on(async {
+            let listener = tokio::net::TcpListener::bind(&addr).await?;
+            axum::serve(listener, app).await?;
+            Ok(())
+        })
+}

src/cmd/webui/mutations.rs 🔗

@@ -0,0 +1,379 @@
+use anyhow::Result;
+use axum::extract::{Path as AxumPath, State};
+use axum::http::HeaderMap;
+use axum::response::Response;
+use axum::Form;
+
+use crate::db::{self, Store, TaskId};
+use crate::ops;
+
+use super::helpers::{mutation_error, mutation_response};
+use super::AppState;
+
+#[derive(serde::Deserialize)]
+pub(super) struct ProjectForm {
+    name: String,
+    #[serde(default)]
+    bind_path: String,
+}
+
+#[derive(serde::Deserialize)]
+pub(super) 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)]
+pub(super) 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>,
+    #[serde(default)]
+    redirect: Option<String>,
+}
+
+#[derive(serde::Deserialize)]
+pub(super) struct LogForm {
+    message: String,
+}
+
+#[derive(serde::Deserialize)]
+pub(super) struct LabelForm {
+    /// "add" or "rm"
+    action: String,
+    label: String,
+}
+
+#[derive(serde::Deserialize)]
+pub(super) struct DepForm {
+    /// "add" or "rm"
+    action: String,
+    blocker: String,
+}
+
+pub(super) async fn create_project_handler(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Form(form): Form<ProjectForm>,
+) -> 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()),
+    }
+}
+
+pub(super) 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()),
+    }
+}
+
+pub(super) 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 = form
+            .redirect
+            .filter(|r| r.starts_with('/'))
+            .unwrap_or_else(|| 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()),
+    }
+}
+
+pub(super) 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()),
+    }
+}
+
+pub(super) 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()),
+    }
+}
+
+pub(super) 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()),
+    }
+}
+
+pub(super) 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()),
+    }
+}
+
+pub(super) 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()),
+    }
+}
+
+pub(super) 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()),
+    }
+}

src/cmd/webui/views.rs 🔗

@@ -0,0 +1,214 @@
+use askama::Template;
+
+use super::helpers::SortField;
+
+/// A project card on the root page — either healthy or failed.
+pub(super) enum ProjectCard {
+    Ok {
+        name: String,
+        open: usize,
+        in_progress: usize,
+        closed: usize,
+        total: usize,
+    },
+    Err {
+        name: String,
+        error: String,
+    },
+}
+
+/// Minimal view-model for a scored task in the "Next Up" table.
+pub(super) struct ScoredEntry {
+    pub(super) id: String,
+    pub(super) short_id: String,
+    pub(super) title: String,
+    pub(super) score: String,
+    pub(super) status: String,
+    pub(super) status_display: &'static str,
+}
+
+/// Minimal view-model for a task row in the project task table.
+pub(super) struct TaskRow {
+    pub(super) full_id: String,
+    pub(super) short_id: String,
+    pub(super) status: String,
+    pub(super) status_display: &'static str,
+    pub(super) priority: String,
+    pub(super) effort: String,
+    pub(super) title: String,
+    pub(super) created_at: String,
+    pub(super) created_at_display: String,
+}
+
+/// View-model for the task detail page.
+pub(super) struct TaskView {
+    pub(super) full_id: String,
+    pub(super) short_id: String,
+    pub(super) title: String,
+    pub(super) description: String,
+    pub(super) task_type: String,
+    pub(super) status: String,
+    pub(super) priority: String,
+    pub(super) effort: String,
+    pub(super) created_at: String,
+    pub(super) created_at_display: String,
+    pub(super) updated_at: String,
+    pub(super) updated_at_display: String,
+    pub(super) labels: Vec<String>,
+    pub(super) logs: Vec<LogView>,
+}
+
+pub(super) struct LogView {
+    pub(super) timestamp: String,
+    pub(super) timestamp_display: String,
+    pub(super) message: String,
+}
+
+/// A blocker reference for the task detail page.
+pub(super) struct BlockerRef {
+    pub(super) full_id: String,
+    pub(super) short_id: String,
+}
+
+/// Sort context passed to the task_table macro. When present, column headers
+/// become clickable links that set sort/order query params.
+pub(super) struct SortContext {
+    /// Base URL for sort links (e.g. `/projects/myproj`).
+    pub(super) base_href: String,
+    /// Current sort field.
+    pub(super) field: String,
+    /// Current sort order ("asc" or "desc").
+    pub(super) order: String,
+    /// Query string fragment for the current filters (without sort/order/page),
+    /// suitable for appending to hrefs.
+    pub(super) filter_qs: String,
+}
+
+impl SortContext {
+    /// Build the href for a column header link. Clicking the currently-active
+    /// column toggles direction; clicking a different column uses its default.
+    fn column_href(&self, col: &str) -> String {
+        let order = if col == self.field {
+            // Toggle current direction.
+            match self.order.as_str() {
+                "asc" => "desc",
+                _ => "asc",
+            }
+        } else {
+            // Use the column's sensible default.
+            SortField::parse(col)
+                .map(|f| f.default_order().as_str())
+                .unwrap_or("asc")
+        };
+        let mut qs = self.filter_qs.clone();
+        if !qs.is_empty() {
+            qs.push('&');
+        }
+        qs.push_str(&format!("sort={col}&order={order}"));
+        format!("{}?{qs}", self.base_href)
+    }
+
+    /// Return the arrow indicator for the active column, or empty string.
+    fn arrow(&self, col: &str) -> &str {
+        if col == self.field {
+            match self.order.as_str() {
+                "asc" => " ↑",
+                "desc" => " ↓",
+                _ => "",
+            }
+        } else {
+            ""
+        }
+    }
+}
+
+#[derive(Template)]
+#[template(path = "index.html")]
+pub(super) struct IndexTemplate {
+    pub(super) all_projects: Vec<String>,
+    pub(super) active_project: Option<String>,
+    pub(super) projects: Vec<ProjectCard>,
+}
+
+#[derive(Template)]
+#[template(path = "project.html")]
+pub(super) struct ProjectTemplate {
+    pub(super) all_projects: Vec<String>,
+    pub(super) active_project: Option<String>,
+    pub(super) project_name: String,
+    pub(super) stats_open: usize,
+    pub(super) stats_in_progress: usize,
+    pub(super) stats_closed: usize,
+    pub(super) next_up: Vec<ScoredEntry>,
+    pub(super) page_tasks: Vec<TaskRow>,
+    pub(super) all_labels: Vec<String>,
+    pub(super) filter_status: Option<String>,
+    pub(super) filter_priority: Option<String>,
+    pub(super) filter_effort: Option<String>,
+    pub(super) filter_label: Option<String>,
+    pub(super) filter_search: String,
+    pub(super) page: usize,
+    pub(super) total_pages: usize,
+    pub(super) pagination_pages: Vec<usize>,
+    pub(super) sort_ctx: SortContext,
+}
+
+impl ProjectTemplate {
+    /// Build a query-string fragment containing the current filters (no sort,
+    /// no page). Reused by both pagination and sort helpers.
+    fn filter_qs(&self) -> String {
+        let mut parts = Vec::new();
+        if let Some(ref s) = self.filter_status {
+            parts.push(format!("status={s}"));
+        }
+        if let Some(ref p) = self.filter_priority {
+            parts.push(format!("priority={p}"));
+        }
+        if let Some(ref e) = self.filter_effort {
+            parts.push(format!("effort={e}"));
+        }
+        if let Some(ref l) = self.filter_label {
+            parts.push(format!("label={l}"));
+        }
+        if !self.filter_search.is_empty() {
+            parts.push(format!("q={}", self.filter_search));
+        }
+        parts.join("&")
+    }
+
+    /// Build a pagination link preserving current filter and sort params.
+    fn pagination_href(&self, target_page: &usize) -> String {
+        let target_page = *target_page;
+        let mut qs = self.filter_qs();
+        if !qs.is_empty() {
+            qs.push('&');
+        }
+        qs.push_str(&format!(
+            "sort={}&order={}",
+            self.sort_ctx.field, self.sort_ctx.order
+        ));
+        qs.push_str(&format!("&page={target_page}"));
+        format!("/projects/{}?{qs}", self.project_name)
+    }
+}
+
+#[derive(Template)]
+#[template(path = "task.html")]
+pub(super) struct TaskTemplate {
+    pub(super) all_projects: Vec<String>,
+    pub(super) active_project: Option<String>,
+    pub(super) project_name: String,
+    pub(super) task: TaskView,
+    pub(super) blockers_open: Vec<BlockerRef>,
+    pub(super) blockers_resolved: Vec<BlockerRef>,
+    pub(super) subtasks: Vec<TaskRow>,
+}
+
+#[derive(Template)]
+#[template(path = "error.html")]
+pub(super) struct ErrorTemplate {
+    pub(super) all_projects: Vec<String>,
+    pub(super) active_project: Option<String>,
+    pub(super) status_code: u16,
+    pub(super) message: String,
+}