Detailed changes
@@ -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,
@@ -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),
@@ -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 } => {
@@ -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,
@@ -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
@@ -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));
}
@@ -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(),
@@ -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>
@@ -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>
@@ -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();