Restructure webui into per-page modules

Amolith created

Change summary

src/cmd/webui/helpers.rs           | 102 ---------------
src/cmd/webui/index/mod.rs         |  97 ++++++++++++++
src/cmd/webui/index/views.rs       |  24 +++
src/cmd/webui/mod.rs               |  34 +++--
src/cmd/webui/project/mod.rs       | 211 +------------------------------
src/cmd/webui/project/mutations.rs |  34 ++--
src/cmd/webui/project/sorting.rs   | 102 +++++++++++++++
src/cmd/webui/project/views.rs     | 140 +++++++++++++++++++++
src/cmd/webui/task/mod.rs          | 112 ++++++++++++++++
src/cmd/webui/task/views.rs        |  45 ++++++
src/cmd/webui/views.rs             | 204 ------------------------------
templates/task.html                |  29 ++++
12 files changed, 596 insertions(+), 538 deletions(-)

Detailed changes

src/cmd/webui/helpers.rs 🔗

@@ -4,111 +4,9 @@ use axum::response::{Html, IntoResponse, Redirect, Response};
 use axum::Json;
 
 use crate::db;
-use crate::model::{Status, Task};
 
 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: Status) -> i32 {
-    match s {
-        Status::Open => 1,
-        Status::InProgress => 2,
-        Status::Closed => 3,
-    }
-}
-
-/// Apply the chosen sort field and direction to a filtered task list.
-pub(super) fn sort_tasks(tasks: &mut [&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 {

src/cmd/webui/index/mod.rs 🔗

@@ -0,0 +1,97 @@
+use anyhow::Result;
+use axum::extract::State;
+use axum::response::Response;
+
+use crate::db::Store;
+use crate::model::Status;
+
+use super::helpers::{error_response, list_projects_safe, render};
+use super::AppState;
+
+mod views;
+use views::{IndexTemplate, ProjectCard};
+
+/// Activity tier for sorting project cards on the index page.
+/// Lower values sort first (most active projects at the top).
+fn project_card_activity_tier(card: &ProjectCard) -> u32 {
+    match card {
+        ProjectCard::Ok {
+            in_progress, open, ..
+        } => {
+            if *in_progress > 0 {
+                0 // Has in-progress tasks
+            } else if *open > 0 {
+                1 // Has open tasks only
+            } else {
+                2 // Only closed or empty
+            }
+        }
+        ProjectCard::Err { .. } => 3, // Error state
+    }
+}
+
+pub(in crate::cmd::webui) 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 == Status::Open).count();
+                    let in_progress = tasks
+                        .iter()
+                        .filter(|t| t.status == Status::InProgress)
+                        .count();
+                    let closed = tasks.iter().filter(|t| t.status == 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}"),
+                    });
+                }
+            }
+        }
+
+        // Sort cards by activity tier, then by name alphabetically.
+        cards.sort_by(|a, b| {
+            let tier_a = project_card_activity_tier(a);
+            let tier_b = project_card_activity_tier(b);
+            match tier_a.cmp(&tier_b) {
+                std::cmp::Ordering::Equal => {
+                    let name_a = match a {
+                        ProjectCard::Ok { name, .. } | ProjectCard::Err { name, .. } => name,
+                    };
+                    let name_b = match b {
+                        ProjectCard::Ok { name, .. } | ProjectCard::Err { name, .. } => name,
+                    };
+                    name_a.cmp(name_b)
+                }
+                other => other,
+            }
+        });
+
+        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}"), &[]),
+    }
+}

src/cmd/webui/index/views.rs 🔗

@@ -0,0 +1,24 @@
+use askama::Template;
+
+/// 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,
+    },
+}
+
+#[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>,
+}

src/cmd/webui/mod.rs 🔗

@@ -1,6 +1,7 @@
-mod handlers;
 mod helpers;
-mod mutations;
+mod index;
+mod project;
+mod task;
 mod views;
 
 use std::path::Path;
@@ -59,37 +60,43 @@ pub fn run(cwd: &Path, host: &str, port: u16, explicit_project: Option<&str>) ->
     };
 
     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("/", get(index::index_handler))
+        .route(
+            "/projects",
+            post(project::mutations::create_project_handler),
+        )
+        .route("/projects/{name}", get(project::project_handler))
+        .route(
+            "/projects/{name}/tasks",
+            post(project::mutations::create_handler),
+        )
         .route(
             "/projects/{name}/tasks/{id}",
-            get(handlers::task_handler).post(mutations::update_handler),
+            get(task::task_handler).post(project::mutations::update_handler),
         )
         .route(
             "/projects/{name}/tasks/{id}/log",
-            post(mutations::log_handler),
+            post(project::mutations::log_handler),
         )
         .route(
             "/projects/{name}/tasks/{id}/done",
-            post(mutations::done_handler),
+            post(project::mutations::done_handler),
         )
         .route(
             "/projects/{name}/tasks/{id}/reopen",
-            post(mutations::reopen_handler),
+            post(project::mutations::reopen_handler),
         )
         .route(
             "/projects/{name}/tasks/{id}/labels",
-            post(mutations::label_handler),
+            post(project::mutations::label_handler),
         )
         .route(
             "/projects/{name}/tasks/{id}/deps",
-            post(mutations::dep_handler),
+            post(project::mutations::dep_handler),
         )
         .route(
             "/projects/{name}/tasks/{id}/delete",
-            post(mutations::delete_handler),
+            post(project::mutations::delete_handler),
         )
         .route("/static/oat.min.css", get(static_oat_css))
         .route("/static/td.css", get(static_td_css))
@@ -100,7 +107,6 @@ pub fn run(cwd: &Path, host: &str, port: u16, explicit_project: Option<&str>) ->
     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)

src/cmd/webui/handlers.rs → src/cmd/webui/project/mod.rs 🔗

@@ -4,110 +4,21 @@ use anyhow::Result;
 use axum::extract::{Path as AxumPath, Query, State};
 use axum::response::Response;
 
-use crate::db::{self, Store};
-use crate::model::{Effort, Priority, Status, Task, TaskId};
+use crate::db::Store;
+use crate::model::{Effort, Priority, Status, 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::helpers::{error_response, friendly_date, friendly_status, list_projects_safe, render};
 use super::AppState;
 
-const PAGE_SIZE: usize = 25;
-
-/// Activity tier for sorting project cards on the index page.
-/// Lower values sort first (most active projects at the top).
-fn project_card_activity_tier(card: &ProjectCard) -> u32 {
-    match card {
-        ProjectCard::Ok {
-            in_progress, open, ..
-        } => {
-            if *in_progress > 0 {
-                0 // Has in-progress tasks
-            } else if *open > 0 {
-                1 // Has open tasks only
-            } else {
-                2 // Only closed or empty
-            }
-        }
-        ProjectCard::Err { .. } => 3, // Error state
-    }
-}
-
-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 == Status::Open).count();
-                    let in_progress = tasks
-                        .iter()
-                        .filter(|t| t.status == Status::InProgress)
-                        .count();
-                    let closed = tasks.iter().filter(|t| t.status == 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}"),
-                    });
-                }
-            }
-        }
+pub(super) mod mutations;
+mod sorting;
+pub(super) mod views;
 
-        // Sort cards by activity tier, then by name alphabetically
-        // Tier 0: in-progress tasks (most active)
-        // Tier 1: open tasks only
-        // Tier 2: closed/empty only
-        // Tier 3: errors
-        cards.sort_by(|a, b| {
-            let tier_a = project_card_activity_tier(a);
-            let tier_b = project_card_activity_tier(b);
-            match tier_a.cmp(&tier_b) {
-                std::cmp::Ordering::Equal => {
-                    let name_a = match a {
-                        ProjectCard::Ok { name, .. } | ProjectCard::Err { name, .. } => name,
-                    };
-                    let name_b = match b {
-                        ProjectCard::Ok { name, .. } | ProjectCard::Err { name, .. } => name,
-                    };
-                    name_a.cmp(name_b)
-                }
-                other => other,
-            }
-        });
-
-        Ok(IndexTemplate {
-            all_projects: projects,
-            active_project: None,
-            projects: cards,
-        })
-    })
-    .await;
+use sorting::{SortField, SortOrder};
+use views::{ProjectTemplate, ScoredEntry, SortContext, TaskRow};
 
-    match result {
-        Ok(Ok(tmpl)) => render(tmpl),
-        Ok(Err(e)) => error_response(500, &format!("{e}"), &[]),
-        Err(e) => error_response(500, &format!("join error: {e}"), &[]),
-    }
-}
+const PAGE_SIZE: usize = 25;
 
 #[derive(serde::Deserialize)]
 pub(super) struct ProjectQuery {
@@ -121,7 +32,7 @@ pub(super) struct ProjectQuery {
     order: Option<String>,
 }
 
-pub(super) async fn project_handler(
+pub(in crate::cmd::webui) async fn project_handler(
     State(state): State<AppState>,
     AxumPath(name): AxumPath<String>,
     Query(mut query): Query<ProjectQuery>,
@@ -206,7 +117,7 @@ pub(super) async fn project_handler(
             .collect();
 
         // Apply filters.
-        let mut filtered: Vec<&Task> = tasks.iter().collect();
+        let mut filtered: Vec<&crate::model::Task> = tasks.iter().collect();
 
         if let Some(ref s) = query.status {
             if !s.is_empty() {
@@ -251,7 +162,7 @@ pub(super) async fn project_handler(
             .as_deref()
             .and_then(SortOrder::parse)
             .unwrap_or_else(|| sort_field.default_order());
-        sort_tasks(&mut filtered, sort_field, sort_order);
+        sorting::sort_tasks(&mut filtered, sort_field, sort_order);
 
         // Pagination.
         let total = filtered.len();
@@ -340,101 +251,3 @@ pub(super) async fn project_handler(
         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 = t.status.as_str().to_string();
-                TaskRow {
-                    full_id: t.id.as_str().to_string(),
-                    short_id: t.id.short(),
-                    status_display: friendly_status(&status),
-                    status,
-                    priority: t.priority.as_str().to_string(),
-                    effort: t.effort.as_str().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: task.status.as_str().to_string(),
-            priority: task.priority.as_str().to_string(),
-            effort: task.effort.as_str().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/mutations.rs → src/cmd/webui/project/mutations.rs 🔗

@@ -8,18 +8,18 @@ use crate::db::{self, Store};
 use crate::model::{Effort, LogEntry, Priority, Status, Task, TaskId};
 use crate::ops;
 
-use super::helpers::{mutation_error, mutation_response};
-use super::AppState;
+use super::super::helpers::{mutation_error, mutation_response};
+use super::super::AppState;
 
 #[derive(serde::Deserialize)]
-pub(super) struct ProjectForm {
+pub(in crate::cmd::webui) struct ProjectForm {
     name: String,
     #[serde(default)]
     bind_path: String,
 }
 
 #[derive(serde::Deserialize)]
-pub(super) struct CreateForm {
+pub(in crate::cmd::webui) struct CreateForm {
     title: String,
     #[serde(default)]
     description: String,
@@ -48,7 +48,7 @@ fn default_effort() -> String {
 }
 
 #[derive(serde::Deserialize)]
-pub(super) struct UpdateForm {
+pub(in crate::cmd::webui) struct UpdateForm {
     #[serde(default)]
     title: Option<String>,
     #[serde(default)]
@@ -64,25 +64,25 @@ pub(super) struct UpdateForm {
 }
 
 #[derive(serde::Deserialize)]
-pub(super) struct LogForm {
+pub(in crate::cmd::webui) struct LogForm {
     message: String,
 }
 
 #[derive(serde::Deserialize)]
-pub(super) struct LabelForm {
+pub(in crate::cmd::webui) struct LabelForm {
     /// "add" or "rm"
     action: String,
     label: String,
 }
 
 #[derive(serde::Deserialize)]
-pub(super) struct DepForm {
+pub(in crate::cmd::webui) struct DepForm {
     /// "add" or "rm"
     action: String,
     blocker: String,
 }
 
-pub(super) async fn create_project_handler(
+pub(in crate::cmd::webui) async fn create_project_handler(
     State(state): State<AppState>,
     headers: HeaderMap,
     Form(form): Form<ProjectForm>,
@@ -109,7 +109,7 @@ pub(super) async fn create_project_handler(
     }
 }
 
-pub(super) async fn create_handler(
+pub(in crate::cmd::webui) async fn create_handler(
     State(state): State<AppState>,
     AxumPath(name): AxumPath<String>,
     headers: HeaderMap,
@@ -162,7 +162,7 @@ pub(super) async fn create_handler(
     }
 }
 
-pub(super) async fn update_handler(
+pub(in crate::cmd::webui) async fn update_handler(
     State(state): State<AppState>,
     AxumPath((name, id)): AxumPath<(String, String)>,
     headers: HeaderMap,
@@ -219,7 +219,7 @@ pub(super) async fn update_handler(
     }
 }
 
-pub(super) async fn log_handler(
+pub(in crate::cmd::webui) async fn log_handler(
     State(state): State<AppState>,
     AxumPath((name, id)): AxumPath<(String, String)>,
     headers: HeaderMap,
@@ -246,7 +246,7 @@ pub(super) async fn log_handler(
     }
 }
 
-pub(super) async fn done_handler(
+pub(in crate::cmd::webui) async fn done_handler(
     State(state): State<AppState>,
     AxumPath((name, id)): AxumPath<(String, String)>,
     headers: HeaderMap,
@@ -272,7 +272,7 @@ pub(super) async fn done_handler(
     }
 }
 
-pub(super) async fn reopen_handler(
+pub(in crate::cmd::webui) async fn reopen_handler(
     State(state): State<AppState>,
     AxumPath((name, id)): AxumPath<(String, String)>,
     headers: HeaderMap,
@@ -298,7 +298,7 @@ pub(super) async fn reopen_handler(
     }
 }
 
-pub(super) async fn label_handler(
+pub(in crate::cmd::webui) async fn label_handler(
     State(state): State<AppState>,
     AxumPath((name, id)): AxumPath<(String, String)>,
     headers: HeaderMap,
@@ -324,7 +324,7 @@ pub(super) async fn label_handler(
     }
 }
 
-pub(super) async fn dep_handler(
+pub(in crate::cmd::webui) async fn dep_handler(
     State(state): State<AppState>,
     AxumPath((name, id)): AxumPath<(String, String)>,
     headers: HeaderMap,
@@ -351,7 +351,7 @@ pub(super) async fn dep_handler(
     }
 }
 
-pub(super) async fn delete_handler(
+pub(in crate::cmd::webui) async fn delete_handler(
     State(state): State<AppState>,
     AxumPath((name, id)): AxumPath<(String, String)>,
     headers: HeaderMap,

src/cmd/webui/project/sorting.rs 🔗

@@ -0,0 +1,102 @@
+use crate::model::Task;
+
+/// Columns the task table can be sorted by.
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub(in crate::cmd::webui) enum SortField {
+    Id,
+    Status,
+    Priority,
+    Effort,
+    Title,
+    Created,
+}
+
+impl SortField {
+    pub(in crate::cmd::webui) 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(in crate::cmd::webui) 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(in crate::cmd::webui) 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(in crate::cmd::webui) enum SortOrder {
+    Asc,
+    Desc,
+}
+
+impl SortOrder {
+    pub(in crate::cmd::webui) fn parse(s: &str) -> Option<Self> {
+        match s {
+            "asc" => Some(Self::Asc),
+            "desc" => Some(Self::Desc),
+            _ => None,
+        }
+    }
+
+    pub(in crate::cmd::webui) 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: crate::model::Status) -> i32 {
+    match s {
+        crate::model::Status::Open => 1,
+        crate::model::Status::InProgress => 2,
+        crate::model::Status::Closed => 3,
+    }
+}
+
+/// Apply the chosen sort field and direction to a filtered task list.
+pub(in crate::cmd::webui) fn sort_tasks(tasks: &mut [&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(),
+        }
+    });
+}

src/cmd/webui/project/views.rs 🔗

@@ -0,0 +1,140 @@
+use askama::Template;
+
+use super::sorting::SortField;
+
+/// Minimal view-model for a scored task in the "Next Up" table.
+pub(in crate::cmd::webui) struct ScoredEntry {
+    pub(in crate::cmd::webui) id: String,
+    pub(in crate::cmd::webui) short_id: String,
+    pub(in crate::cmd::webui) title: String,
+    pub(in crate::cmd::webui) score: String,
+    pub(in crate::cmd::webui) status: String,
+    pub(in crate::cmd::webui) status_display: &'static str,
+}
+
+/// Minimal view-model for a task row in the project task table.
+pub(in crate::cmd::webui) struct TaskRow {
+    pub(in crate::cmd::webui) full_id: String,
+    pub(in crate::cmd::webui) short_id: String,
+    pub(in crate::cmd::webui) status: String,
+    pub(in crate::cmd::webui) status_display: &'static str,
+    pub(in crate::cmd::webui) priority: String,
+    pub(in crate::cmd::webui) effort: String,
+    pub(in crate::cmd::webui) title: String,
+    pub(in crate::cmd::webui) created_at: String,
+    pub(in crate::cmd::webui) created_at_display: String,
+}
+
+/// Sort context passed to the task_table macro. When present, column headers
+/// become clickable links that set sort/order query params.
+pub(in crate::cmd::webui) struct SortContext {
+    /// Base URL for sort links (e.g. `/projects/myproj`).
+    pub(in crate::cmd::webui) base_href: String,
+    /// Current sort field.
+    pub(in crate::cmd::webui) field: String,
+    /// Current sort order ("asc" or "desc").
+    pub(in crate::cmd::webui) order: String,
+    /// Query string fragment for the current filters (without sort/order/page),
+    /// suitable for appending to hrefs.
+    pub(in crate::cmd::webui) 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 = "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)
+    }
+}

src/cmd/webui/task/mod.rs 🔗

@@ -0,0 +1,112 @@
+use anyhow::Result;
+use axum::extract::{Path as AxumPath, State};
+use axum::response::Response;
+
+use crate::db::{self, Store};
+
+use super::helpers::{
+    error_response, friendly_date, friendly_status, list_projects_safe, render, render_markdown,
+};
+use super::project::views::TaskRow;
+use super::AppState;
+
+mod views;
+use views::{BlockerRef, LogView, TaskTemplate, TaskView};
+
+pub(in crate::cmd::webui) 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 = t.status.as_str().to_string();
+                TaskRow {
+                    full_id: t.id.as_str().to_string(),
+                    short_id: t.id.short(),
+                    status_display: friendly_status(&status),
+                    status,
+                    priority: t.priority.as_str().to_string(),
+                    effort: t.effort.as_str().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: task.status.as_str().to_string(),
+            priority: task.priority.as_str().to_string(),
+            effort: task.effort.as_str().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/task/views.rs 🔗

@@ -0,0 +1,45 @@
+use askama::Template;
+
+use crate::cmd::webui::project::views::TaskRow;
+
+/// View-model for the task detail page.
+pub(in crate::cmd::webui) struct TaskView {
+    pub(in crate::cmd::webui) full_id: String,
+    pub(in crate::cmd::webui) short_id: String,
+    pub(in crate::cmd::webui) title: String,
+    pub(in crate::cmd::webui) description: String,
+    pub(in crate::cmd::webui) task_type: String,
+    pub(in crate::cmd::webui) status: String,
+    pub(in crate::cmd::webui) priority: String,
+    pub(in crate::cmd::webui) effort: String,
+    pub(in crate::cmd::webui) created_at: String,
+    pub(in crate::cmd::webui) created_at_display: String,
+    pub(in crate::cmd::webui) updated_at: String,
+    pub(in crate::cmd::webui) updated_at_display: String,
+    pub(in crate::cmd::webui) labels: Vec<String>,
+    pub(in crate::cmd::webui) logs: Vec<LogView>,
+}
+
+pub(in crate::cmd::webui) struct LogView {
+    pub(in crate::cmd::webui) timestamp: String,
+    pub(in crate::cmd::webui) timestamp_display: String,
+    pub(in crate::cmd::webui) message: String,
+}
+
+/// A blocker reference for the task detail page.
+pub(in crate::cmd::webui) struct BlockerRef {
+    pub(in crate::cmd::webui) full_id: String,
+    pub(in crate::cmd::webui) short_id: String,
+}
+
+#[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>,
+}

src/cmd/webui/views.rs 🔗

@@ -1,209 +1,5 @@
 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 {

templates/task.html 🔗

@@ -1,5 +1,4 @@
 {% extends "base.html" %}
-{% import "macros.html" as macros %}
 
 {% block title %}{{ task.title }} — td{% endblock %}
 
@@ -152,7 +151,33 @@
 {% if !subtasks.is_empty() %}
 <article class="card mt-4">
   <h2>Subtasks</h2>
-  {% call macros::task_table(project_name, subtasks, "Subtasks") %}{% endcall %}
+  <div class="table">
+    <table>
+      <caption class="sr-only">Subtasks</caption>
+      <thead>
+        <tr>
+          <th scope="col">ID</th>
+          <th scope="col">Status</th>
+          <th scope="col">Priority</th>
+          <th scope="col">Effort</th>
+          <th scope="col">Title</th>
+          <th scope="col">Created</th>
+        </tr>
+      </thead>
+      <tbody>
+        {% for t in subtasks %}
+        <tr>
+          <td><a href="/projects/{{ project_name }}/tasks/{{ t.full_id }}"><code>{{ t.short_id }}</code></a></td>
+          <td><span class="badge{% if t.status == "open" %} success{% elif t.status == "in_progress" %} warning{% endif %}">{{ t.status }}</span></td>
+          <td>{{ t.priority }}</td>
+          <td>{{ t.effort }}</td>
+          <td>{{ t.title }}</td>
+          <td><time datetime="{{ t.created_at }}">{{ t.created_at_display }}</time></td>
+        </tr>
+        {% endfor %}
+      </tbody>
+    </table>
+  </div>
 </article>
 {% endif %}
 {% endblock %}