Add type column and filter to CLI list and web project tables

Amolith created

Change summary

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(-)

Detailed changes

src/cli.rs πŸ”—

@@ -73,6 +73,10 @@ pub enum Command {
         #[arg(short, long)]
         label: Option<String>,
 
+        /// Filter by type (e.g. task, bug, feature)
+        #[arg(long = "type")]
+        task_type: Option<String>,
+
         /// Show all tasks including closed
         #[arg(short, long)]
         all: bool,

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<Priority>,
-    effort: Option<Effort>,
-    label: Option<&str>,
-    all: bool,
-    json: bool,
-) -> Result<()> {
+pub struct Opts<'a> {
+    pub status: Option<&'a str>,
+    pub priority: Option<Priority>,
+    pub effort: Option<Effort>,
+    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),

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 } => {

src/cmd/webui/project/mod.rs πŸ”—

@@ -31,6 +31,8 @@ pub(super) struct ProjectQuery {
     ip_priority: Option<String>,
     ip_effort: Option<String>,
     ip_label: Option<String>,
+    #[serde(rename = "ip_type")]
+    ip_task_type: Option<String>,
     ip_q: Option<String>,
     ip_page: Option<usize>,
     ip_sort: Option<String>,
@@ -40,6 +42,8 @@ pub(super) struct ProjectQuery {
     open_priority: Option<String>,
     open_effort: Option<String>,
     open_label: Option<String>,
+    #[serde(rename = "open_type")]
+    open_task_type: Option<String>,
     open_q: Option<String>,
     open_page: Option<usize>,
     open_sort: Option<String>,
@@ -49,6 +53,8 @@ pub(super) struct ProjectQuery {
     closed_priority: Option<String>,
     closed_effort: Option<String>,
     closed_label: Option<String>,
+    #[serde(rename = "closed_type")]
+    closed_task_type: Option<String>,
     closed_q: Option<String>,
     closed_page: Option<usize>,
     closed_sort: Option<String>,
@@ -60,6 +66,7 @@ struct SectionQuery {
     priority: Option<String>,
     effort: Option<String>,
     label: Option<String>,
+    task_type: Option<String>,
     q: Option<String>,
     page: Option<usize>,
     sort: Option<String>,
@@ -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,

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

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<String>,
     pub(in crate::cmd::webui) filter_effort: Option<String>,
     pub(in crate::cmd::webui) filter_label: Option<String>,
+    pub(in crate::cmd::webui) filter_type: Option<String>,
     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));
         }

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(),

templates/macros.html πŸ”—

@@ -27,6 +27,15 @@
         {% endfor %}
       </select>
     </div>
+    <div data-field>
+      <label for="{{ section.sort_ctx.prefix }}filter-type">Type</label>
+      <select id="{{ section.sort_ctx.prefix }}filter-type" name="{{ section.sort_ctx.prefix }}type" aria-label="Filter by type">
+        <option value="">All</option>
+        <option value="task"{% if section.filter_type.as_deref() == Some("task") %} selected{% endif %}>Task</option>
+        <option value="bug"{% if section.filter_type.as_deref() == Some("bug") %} selected{% endif %}>Bug</option>
+        <option value="feature"{% if section.filter_type.as_deref() == Some("feature") %} selected{% endif %}>Feature</option>
+      </select>
+    </div>
     <div data-field>
       <label for="{{ section.sort_ctx.prefix }}filter-search">Search</label>
       <input type="text" id="{{ section.sort_ctx.prefix }}filter-search" name="{{ section.sort_ctx.prefix }}q" value="{{ section.filter_search }}" placeholder="Search titles…" aria-label="Search tasks by title">
@@ -55,6 +64,7 @@
       <thead>
         <tr>
           <th scope="col"><a href="{{ section.sort_ctx.column_href("id") }}">ID{{ section.sort_ctx.arrow("id") }}</a></th>
+          <th scope="col"><a href="{{ section.sort_ctx.column_href("type") }}">Type{{ section.sort_ctx.arrow("type") }}</a></th>
           <th scope="col"><a href="{{ section.sort_ctx.column_href("priority") }}">Priority{{ section.sort_ctx.arrow("priority") }}</a></th>
           <th scope="col"><a href="{{ section.sort_ctx.column_href("effort") }}">Effort{{ section.sort_ctx.arrow("effort") }}</a></th>
           <th scope="col"><a href="{{ section.sort_ctx.column_href("title") }}">Title{{ section.sort_ctx.arrow("title") }}</a></th>
@@ -66,6 +76,7 @@
         {% for t in section.tasks %}
         <tr>
           <td><a href="/projects/{{ project_name }}/tasks/{{ t.full_id }}"><code>{{ t.short_id }}</code></a></td>
+          <td>{{ t.task_type }}</td>
           <td>{{ t.priority }}</td>
           <td>{{ t.effort }}</td>
           <td>{{ t.title }}</td>

templates/task.html πŸ”—

@@ -166,6 +166,7 @@
         <tr>
           <th scope="col">ID</th>
           <th scope="col">Status</th>
+          <th scope="col">Type</th>
           <th scope="col">Priority</th>
           <th scope="col">Effort</th>
           <th scope="col">Title</th>
@@ -177,6 +178,7 @@
         <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.task_type }}</td>
           <td>{{ t.priority }}</td>
           <td>{{ t.effort }}</td>
           <td>{{ t.title }}</td>

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();