From 3775c7643c801aa7fbaefdc9562a2913de351c27 Mon Sep 17 00:00:00 2001 From: Amolith Date: Wed, 18 Mar 2026 23:20:27 -0600 Subject: [PATCH] Add type column and filter to CLI list and web project tables --- src/cli.rs | 4 ++++ src/cmd/list.rs | 38 ++++++++++++++++++-------------- src/cmd/mod.rs | 16 +++++++++----- src/cmd/webui/project/mod.rs | 22 ++++++++++++++++++ src/cmd/webui/project/sorting.rs | 6 ++++- src/cmd/webui/project/views.rs | 5 +++++ src/cmd/webui/task/mod.rs | 1 + templates/macros.html | 11 +++++++++ templates/task.html | 2 ++ tests/cli_list_show.rs | 26 ++++++++++++++++++++++ 10 files changed, 108 insertions(+), 23 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index b7916dc8d840678e64f460b87d7e4d174a389c49..777c60ea0da12207137be3d57b0d2a31d9f152ff 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -73,6 +73,10 @@ pub enum Command { #[arg(short, long)] label: Option, + /// Filter by type (e.g. task, bug, feature) + #[arg(long = "type")] + task_type: Option, + /// Show all tasks including closed #[arg(short, long)] all: bool, diff --git a/src/cmd/list.rs b/src/cmd/list.rs index 9a6f5723f2d88f8cf5ceefbd8c3c219d285745f9..e5cda01a65430f9adefcbf0b73df2d4c9f48ed01 100644 --- a/src/cmd/list.rs +++ b/src/cmd/list.rs @@ -7,38 +7,43 @@ use crate::color::{cell_bold, cell_effort, cell_priority, cell_status, stdout_us use crate::db; use crate::model::{Effort, Priority, Status}; -pub fn run( - root: &Path, - status: Option<&str>, - priority: Option, - effort: Option, - label: Option<&str>, - all: bool, - json: bool, -) -> Result<()> { +pub struct Opts<'a> { + pub status: Option<&'a str>, + pub priority: Option, + pub effort: Option, + pub label: Option<&'a str>, + pub task_type: Option<&'a str>, + pub all: bool, + pub json: bool, +} + +pub fn run(root: &Path, opts: Opts) -> Result<()> { let store = db::open(root)?; let mut tasks = store.list_tasks()?; - if let Some(s) = status { + if let Some(s) = opts.status { let parsed = Status::parse(s)?; tasks.retain(|t| t.status == parsed); - } else if !all { + } else if !opts.all { // By default, show open and in-progress tasks but not closed. tasks.retain(|t| t.status == Status::Open || t.status == Status::InProgress); } - if let Some(p) = priority { + if let Some(p) = opts.priority { tasks.retain(|t| t.priority == p); } - if let Some(e) = effort { + if let Some(e) = opts.effort { tasks.retain(|t| t.effort == e); } - if let Some(l) = label { + if let Some(l) = opts.label { tasks.retain(|t| t.labels.iter().any(|x| x == l)); } + if let Some(tt) = opts.task_type { + tasks.retain(|t| t.task_type == tt); + } tasks.sort_by_key(|t| (t.priority.score(), t.created_at.clone())); - if json { + if opts.json { // Keep list JSON lean: include scheduling fields but not full work-log history. let mut value = serde_json::to_value(&tasks)?; if let Some(items) = value.as_array_mut() { @@ -53,11 +58,12 @@ pub fn run( let use_color = stdout_use_color(); let mut table = Table::new(); table.load_preset(NOTHING); - table.set_header(vec!["ID", "STATUS", "PRIORITY", "EFFORT", "TITLE"]); + table.set_header(vec!["ID", "STATUS", "TYPE", "PRIORITY", "EFFORT", "TITLE"]); for t in &tasks { table.add_row(vec![ cell_bold(&t.id, use_color), cell_status(format!("[{}]", t.status.as_str()), t.status, use_color), + Cell::new(&t.task_type), cell_priority(t.priority.as_str(), t.priority, use_color), cell_effort(t.effort.as_str(), t.effort, use_color), Cell::new(&t.title), diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 7e12927197c29cbc0c0ff4e08a8ddc76bc18534a..95336a531390e0f26e42fe4dd29edae615bc0b5d 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -66,6 +66,7 @@ pub fn dispatch(cli: &Cli) -> Result<()> { priority, effort, label, + task_type, all, } => { let root = require_root()?; @@ -73,12 +74,15 @@ pub fn dispatch(cli: &Cli) -> Result<()> { let eff = effort.as_deref().map(Effort::parse).transpose()?; list::run( &root, - status.as_deref(), - pri, - eff, - label.as_deref(), - *all, - cli.json, + list::Opts { + status: status.as_deref(), + priority: pri, + effort: eff, + label: label.as_deref(), + task_type: task_type.as_deref(), + all: *all, + json: cli.json, + }, ) } Command::Show { id } => { diff --git a/src/cmd/webui/project/mod.rs b/src/cmd/webui/project/mod.rs index f053699b17271ce0771b6d33b2a7d4601b629102..d047e3efabdaafedbb35ed34779c99b73a6e41f9 100644 --- a/src/cmd/webui/project/mod.rs +++ b/src/cmd/webui/project/mod.rs @@ -31,6 +31,8 @@ pub(super) struct ProjectQuery { ip_priority: Option, ip_effort: Option, ip_label: Option, + #[serde(rename = "ip_type")] + ip_task_type: Option, ip_q: Option, ip_page: Option, ip_sort: Option, @@ -40,6 +42,8 @@ pub(super) struct ProjectQuery { open_priority: Option, open_effort: Option, open_label: Option, + #[serde(rename = "open_type")] + open_task_type: Option, open_q: Option, open_page: Option, open_sort: Option, @@ -49,6 +53,8 @@ pub(super) struct ProjectQuery { closed_priority: Option, closed_effort: Option, closed_label: Option, + #[serde(rename = "closed_type")] + closed_task_type: Option, closed_q: Option, closed_page: Option, closed_sort: Option, @@ -60,6 +66,7 @@ struct SectionQuery { priority: Option, effort: Option, label: Option, + task_type: Option, q: Option, page: Option, sort: Option, @@ -73,6 +80,7 @@ impl SectionQuery { self.priority.is_some() || self.effort.is_some() || self.label.is_some() + || self.task_type.is_some() || self.q.as_deref().is_some_and(|s| !s.is_empty()) || self.page.is_some_and(|p| p > 1) || self.sort.is_some() @@ -110,6 +118,9 @@ fn build_preserve_qs(query: &ProjectQuery, exclude_prefix: &str) -> String { if let Some(ref l) = sq.label { parts.push(format!("{prefix}label={l}")); } + if let Some(ref tt) = sq.task_type { + parts.push(format!("{prefix}type={tt}")); + } let search = sq.q.unwrap_or_default(); if !search.is_empty() { parts.push(format!("{prefix}q={search}")); @@ -148,6 +159,7 @@ fn extract_section(query: &ProjectQuery, prefix: &str) -> SectionQuery { priority: query.ip_priority.clone(), effort: query.ip_effort.clone(), label: query.ip_label.clone(), + task_type: query.ip_task_type.clone(), q: query.ip_q.clone(), page: query.ip_page, sort: query.ip_sort.clone(), @@ -157,6 +169,7 @@ fn extract_section(query: &ProjectQuery, prefix: &str) -> SectionQuery { priority: query.open_priority.clone(), effort: query.open_effort.clone(), label: query.open_label.clone(), + task_type: query.open_task_type.clone(), q: query.open_q.clone(), page: query.open_page, sort: query.open_sort.clone(), @@ -166,6 +179,7 @@ fn extract_section(query: &ProjectQuery, prefix: &str) -> SectionQuery { priority: query.closed_priority.clone(), effort: query.closed_effort.clone(), label: query.closed_label.clone(), + task_type: query.closed_task_type.clone(), q: query.closed_q.clone(), page: query.closed_page, sort: query.closed_sort.clone(), @@ -175,6 +189,7 @@ fn extract_section(query: &ProjectQuery, prefix: &str) -> SectionQuery { priority: None, effort: None, label: None, + task_type: None, q: None, page: None, sort: None, @@ -220,6 +235,11 @@ fn build_section( filtered.retain(|t| t.labels.iter().any(|x| x == l)); } } + if let Some(ref tt) = sq.task_type { + if !tt.is_empty() { + filtered.retain(|t| t.task_type == *tt); + } + } let search_term = sq.q.clone().unwrap_or_default(); if !search_term.is_empty() { let q = search_term.to_ascii_lowercase(); @@ -260,6 +280,7 @@ fn build_section( short_id: t.id.short(), status_display: friendly_status(&s), status: s, + task_type: t.task_type.clone(), priority: t.priority.as_str().to_string(), effort: t.effort.as_str().to_string(), title: t.title.clone(), @@ -286,6 +307,7 @@ fn build_section( filter_priority: sq.priority.clone(), filter_effort: sq.effort.clone(), filter_label: sq.label.clone(), + filter_type: sq.task_type.clone(), filter_search: search_term, page, total_pages, diff --git a/src/cmd/webui/project/sorting.rs b/src/cmd/webui/project/sorting.rs index 07ddcb92b76d811e6d10a8c77757de4a34be1290..e99493829ba11bfe4fc324695719fa4b10fec611 100644 --- a/src/cmd/webui/project/sorting.rs +++ b/src/cmd/webui/project/sorting.rs @@ -5,6 +5,7 @@ use crate::model::Task; pub(in crate::cmd::webui) enum SortField { Id, Status, + Type, Priority, Effort, Title, @@ -16,6 +17,7 @@ impl SortField { match s { "id" => Some(Self::Id), "status" => Some(Self::Status), + "type" => Some(Self::Type), "priority" => Some(Self::Priority), "effort" => Some(Self::Effort), "title" => Some(Self::Title), @@ -28,6 +30,7 @@ impl SortField { match self { Self::Id => "id", Self::Status => "status", + Self::Type => "type", Self::Priority => "priority", Self::Effort => "effort", Self::Title => "title", @@ -40,7 +43,7 @@ impl SortField { match self { // Newest first, alphabetical ascending for text fields. Self::Created => SortOrder::Desc, - Self::Title | Self::Id => SortOrder::Asc, + Self::Title | Self::Id | Self::Type => SortOrder::Asc, // Highest priority/effort first; open before closed. Self::Priority | Self::Effort | Self::Status => SortOrder::Asc, } @@ -86,6 +89,7 @@ pub(in crate::cmd::webui) fn sort_tasks(tasks: &mut [&Task], field: SortField, o 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::Type => a.task_type.cmp(&b.task_type), SortField::Priority => a.priority.score().cmp(&b.priority.score()), SortField::Effort => a.effort.score().cmp(&b.effort.score()), SortField::Title => a diff --git a/src/cmd/webui/project/views.rs b/src/cmd/webui/project/views.rs index 747d84607fb92c7268cd807b0e775a4de52130c2..f8b1c9bc8ed027ef02ead43c5a9054c49a314f5c 100644 --- a/src/cmd/webui/project/views.rs +++ b/src/cmd/webui/project/views.rs @@ -22,6 +22,7 @@ pub(in crate::cmd::webui) struct TaskRow { 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) task_type: String, pub(in crate::cmd::webui) priority: String, pub(in crate::cmd::webui) effort: String, pub(in crate::cmd::webui) title: String, @@ -104,6 +105,7 @@ pub(in crate::cmd::webui) struct SectionState { pub(in crate::cmd::webui) filter_priority: Option, pub(in crate::cmd::webui) filter_effort: Option, pub(in crate::cmd::webui) filter_label: Option, + pub(in crate::cmd::webui) filter_type: Option, pub(in crate::cmd::webui) filter_search: String, /// Current page number (1-indexed). pub(in crate::cmd::webui) page: usize, @@ -129,6 +131,9 @@ impl SectionState { if let Some(ref l) = self.filter_label { parts.push(format!("{prefix}label={l}")); } + if let Some(ref tt) = self.filter_type { + parts.push(format!("{prefix}type={tt}")); + } if !self.filter_search.is_empty() { parts.push(format!("{prefix}q={}", self.filter_search)); } diff --git a/src/cmd/webui/task/mod.rs b/src/cmd/webui/task/mod.rs index cd91412f6ee2ac0d8c3cbe884cfa06747898cd1c..f2c08f8c1dde850de789d346d46dd75664b42ffe 100644 --- a/src/cmd/webui/task/mod.rs +++ b/src/cmd/webui/task/mod.rs @@ -58,6 +58,7 @@ pub(in crate::cmd::webui) async fn task_handler( short_id: t.id.short(), status_display: friendly_status(&status), status, + task_type: t.task_type.clone(), priority: t.priority.as_str().to_string(), effort: t.effort.as_str().to_string(), title: t.title.clone(), diff --git a/templates/macros.html b/templates/macros.html index f2f8b45473aa359092b3f223ad3ce9ecbc618fb5..48541078c480e0de6eb1771321e4a4d1e22f0ced 100644 --- a/templates/macros.html +++ b/templates/macros.html @@ -27,6 +27,15 @@ {% endfor %} +
+ + +
@@ -55,6 +64,7 @@ ID{{ section.sort_ctx.arrow("id") }} + Type{{ section.sort_ctx.arrow("type") }} Priority{{ section.sort_ctx.arrow("priority") }} Effort{{ section.sort_ctx.arrow("effort") }} Title{{ section.sort_ctx.arrow("title") }} @@ -66,6 +76,7 @@ {% for t in section.tasks %} {{ t.short_id }} + {{ t.task_type }} {{ t.priority }} {{ t.effort }} {{ t.title }} diff --git a/templates/task.html b/templates/task.html index acc386588ba6cc31b886cefc15d238bd5fed7b94..38706e0fdeba2a14fbfea32357bfbb9afda68528 100644 --- a/templates/task.html +++ b/templates/task.html @@ -166,6 +166,7 @@ ID Status + Type Priority Effort Title @@ -177,6 +178,7 @@ {{ t.short_id }} {{ t.status }} + {{ t.task_type }} {{ t.priority }} {{ t.effort }} {{ t.title }} diff --git a/tests/cli_list_show.rs b/tests/cli_list_show.rs index ec7150280743fc22e64ed6408796f282eb5914e6..62838d62f9c2e5979905f7d92529a951a170a58c 100644 --- a/tests/cli_list_show.rs +++ b/tests/cli_list_show.rs @@ -134,6 +134,32 @@ fn list_filter_by_priority() { assert_eq!(tasks[0]["title"].as_str().unwrap(), "High prio"); } +#[test] +fn list_filter_by_type() { + let tmp = init_tmp(); + + td(&tmp) + .args(["create", "A task", "--type", "task"]) + .current_dir(&tmp) + .assert() + .success(); + td(&tmp) + .args(["create", "A bug", "--type", "bug"]) + .current_dir(&tmp) + .assert() + .success(); + + let out = td(&tmp) + .args(["--json", "list", "--type", "bug"]) + .current_dir(&tmp) + .output() + .unwrap(); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + let tasks = v.as_array().unwrap(); + assert_eq!(tasks.len(), 1); + assert_eq!(tasks[0]["title"].as_str().unwrap(), "A bug"); +} + #[test] fn list_filter_by_label() { let tmp = init_tmp();