diff --git a/src/cmd/webui/helpers.rs b/src/cmd/webui/helpers.rs index 6052492f0f45db2040c8fd74928e051c64a323a5..d6ba4acc7f41c3bd3ac32445fff582ed2f963304 100644 --- a/src/cmd/webui/helpers.rs +++ b/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 { - 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 { - 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 { diff --git a/src/cmd/webui/index/mod.rs b/src/cmd/webui/index/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..8439c8a8099b8156e2fa2733005632fbb590af3f --- /dev/null +++ b/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) -> Response { + let root = state.data_root.clone(); + let result = tokio::task::spawn_blocking(move || -> Result { + 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}"), &[]), + } +} diff --git a/src/cmd/webui/index/views.rs b/src/cmd/webui/index/views.rs new file mode 100644 index 0000000000000000000000000000000000000000..36fe3f65318cf69bbfcf4fa596e81b918ed7db62 --- /dev/null +++ b/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, + pub(super) active_project: Option, + pub(super) projects: Vec, +} diff --git a/src/cmd/webui/mod.rs b/src/cmd/webui/mod.rs index a83a98d31d7b372082fb98e66ac40f8d1da38b55..0a97485e240c343b86145ea05b7e7fd6d602fceb 100644 --- a/src/cmd/webui/mod.rs +++ b/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) diff --git a/src/cmd/webui/handlers.rs b/src/cmd/webui/project/mod.rs similarity index 52% rename from src/cmd/webui/handlers.rs rename to src/cmd/webui/project/mod.rs index b5606c26385b017c73d1c8f61d244d80f44d43ff..4180ef7bc0382db42565e53a6a4a1b74311942ad 100644 --- a/src/cmd/webui/handlers.rs +++ b/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) -> Response { - let root = state.data_root.clone(); - let result = tokio::task::spawn_blocking(move || -> Result { - 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, } -pub(super) async fn project_handler( +pub(in crate::cmd::webui) async fn project_handler( State(state): State, AxumPath(name): AxumPath, Query(mut query): Query, @@ -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, - AxumPath((name, id)): AxumPath<(String, String)>, -) -> Response { - let root = state.data_root.clone(); - let result = tokio::task::spawn_blocking(move || -> Result { - 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 = partition - .open - .iter() - .map(|b| BlockerRef { - full_id: b.as_str().to_string(), - short_id: b.short(), - }) - .collect(); - let blockers_resolved: Vec = 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 = 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}"), &[]), - } -} diff --git a/src/cmd/webui/mutations.rs b/src/cmd/webui/project/mutations.rs similarity index 93% rename from src/cmd/webui/mutations.rs rename to src/cmd/webui/project/mutations.rs index 73244186f2b3908dafc14b94c1d76eb53f626f3b..b84bf25e80eb52d48daf56320b38efb4d2ec19c4 100644 --- a/src/cmd/webui/mutations.rs +++ b/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, #[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, headers: HeaderMap, Form(form): Form, @@ -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, AxumPath(name): AxumPath, 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, 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, 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, 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, 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, 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, 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, AxumPath((name, id)): AxumPath<(String, String)>, headers: HeaderMap, diff --git a/src/cmd/webui/project/sorting.rs b/src/cmd/webui/project/sorting.rs new file mode 100644 index 0000000000000000000000000000000000000000..07ddcb92b76d811e6d10a8c77757de4a34be1290 --- /dev/null +++ b/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 { + 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 { + 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(), + } + }); +} diff --git a/src/cmd/webui/project/views.rs b/src/cmd/webui/project/views.rs new file mode 100644 index 0000000000000000000000000000000000000000..3e097a43a97a5b3efb5bb9a0a3c1ee32bb00eca1 --- /dev/null +++ b/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, + pub(super) active_project: Option, + 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, + pub(super) page_tasks: Vec, + pub(super) all_labels: Vec, + pub(super) filter_status: Option, + pub(super) filter_priority: Option, + pub(super) filter_effort: Option, + pub(super) filter_label: Option, + pub(super) filter_search: String, + pub(super) page: usize, + pub(super) total_pages: usize, + pub(super) pagination_pages: Vec, + 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) + } +} diff --git a/src/cmd/webui/task/mod.rs b/src/cmd/webui/task/mod.rs new file mode 100644 index 0000000000000000000000000000000000000000..6258ddd8f5863d76fc01117c35d8e2851e6443eb --- /dev/null +++ b/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, + AxumPath((name, id)): AxumPath<(String, String)>, +) -> Response { + let root = state.data_root.clone(); + let result = tokio::task::spawn_blocking(move || -> Result { + 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 = partition + .open + .iter() + .map(|b| BlockerRef { + full_id: b.as_str().to_string(), + short_id: b.short(), + }) + .collect(); + let blockers_resolved: Vec = 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 = 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}"), &[]), + } +} diff --git a/src/cmd/webui/task/views.rs b/src/cmd/webui/task/views.rs new file mode 100644 index 0000000000000000000000000000000000000000..99a1671044276949eb81909457915e22e12e6af6 --- /dev/null +++ b/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, + pub(in crate::cmd::webui) logs: Vec, +} + +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, + pub(super) active_project: Option, + pub(super) project_name: String, + pub(super) task: TaskView, + pub(super) blockers_open: Vec, + pub(super) blockers_resolved: Vec, + pub(super) subtasks: Vec, +} diff --git a/src/cmd/webui/views.rs b/src/cmd/webui/views.rs index c3bb9110c1d439ba9d1e43dc9f8fb4091549d9c5..d11bdeaf935325042e17946975d37cb9a05ab28e 100644 --- a/src/cmd/webui/views.rs +++ b/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, - pub(super) logs: Vec, -} - -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, - pub(super) active_project: Option, - pub(super) projects: Vec, -} - -#[derive(Template)] -#[template(path = "project.html")] -pub(super) struct ProjectTemplate { - pub(super) all_projects: Vec, - pub(super) active_project: Option, - 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, - pub(super) page_tasks: Vec, - pub(super) all_labels: Vec, - pub(super) filter_status: Option, - pub(super) filter_priority: Option, - pub(super) filter_effort: Option, - pub(super) filter_label: Option, - pub(super) filter_search: String, - pub(super) page: usize, - pub(super) total_pages: usize, - pub(super) pagination_pages: Vec, - 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, - pub(super) active_project: Option, - pub(super) project_name: String, - pub(super) task: TaskView, - pub(super) blockers_open: Vec, - pub(super) blockers_resolved: Vec, - pub(super) subtasks: Vec, -} - #[derive(Template)] #[template(path = "error.html")] pub(super) struct ErrorTemplate { diff --git a/templates/task.html b/templates/task.html index ad0484f4f1f29fed94862591eb627944f5130f82..efcebad66317cb75af87a0e07237c75c74df7bf4 100644 --- a/templates/task.html +++ b/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() %}

Subtasks

- {% call macros::task_table(project_name, subtasks, "Subtasks") %}{% endcall %} +
+ + + + + + + + + + + + + + {% for t in subtasks %} + + + + + + + + + {% endfor %} + +
Subtasks
IDStatusPriorityEffortTitleCreated
{{ t.short_id }}{{ t.status }}{{ t.priority }}{{ t.effort }}{{ t.title }}
+
{% endif %} {% endblock %}