Add archived flag on tasks
Archived tasks are hidden from list output by default and shown
with --archived. Archiving is orthogonal to state. Introduces
ListFilter struct to consolidate query parameters for task listing.
Assisted-by: Claude Opus 4.6 via pi
diff --git a/migrations/006_add_archived.sql b/migrations/006_add_archived.sql
new file mode 100644
index 0000000..deedf75
--- /dev/null
+++ b/migrations/006_add_archived.sql
@@ -0,0 +1 @@
+ALTER TABLE tasks ADD COLUMN archived INTEGER NOT NULL DEFAULT 0;
diff --git a/src/bin/ranger/commands/backlog.rs b/src/bin/ranger/commands/backlog.rs
index 5b54561..2d151b9 100644
--- a/src/bin/ranger/commands/backlog.rs
+++ b/src/bin/ranger/commands/backlog.rs
@@ -4,6 +4,7 @@ use ranger::db::SqlitePool;
use ranger::key;
use ranger::models::{Backlog, State};
use ranger::ops;
+use ranger::ops::task::ListFilter;
use crate::output;
@@ -56,7 +57,11 @@ pub async fn run(pool: &SqlitePool, command: BacklogCommands, json: bool) -> Res
if json {
let mut state_groups = serde_json::Map::new();
for state in [State::Done, State::InProgress, State::Queued, State::Icebox] {
- let tasks = ops::task::list(&mut conn, backlog.id, Some(state.clone())).await?;
+ let filter = ListFilter {
+ state: Some(state.clone()),
+ ..Default::default()
+ };
+ let tasks = ops::task::list(&mut conn, backlog.id, &filter).await?;
if !tasks.is_empty() {
state_groups
.insert(state.to_string(), serde_json::to_value(&tasks).unwrap());
@@ -74,7 +79,11 @@ pub async fn run(pool: &SqlitePool, command: BacklogCommands, json: bool) -> Res
print_backlog_detail(&backlog);
for state in [State::Done, State::InProgress, State::Queued, State::Icebox] {
- let tasks = ops::task::list(&mut conn, backlog.id, Some(state.clone())).await?;
+ let filter = ListFilter {
+ state: Some(state.clone()),
+ ..Default::default()
+ };
+ let tasks = ops::task::list(&mut conn, backlog.id, &filter).await?;
if !tasks.is_empty() {
println!("\n[{}]", state);
for t in &tasks {
diff --git a/src/bin/ranger/commands/task.rs b/src/bin/ranger/commands/task.rs
index 892618a..01487c6 100644
--- a/src/bin/ranger/commands/task.rs
+++ b/src/bin/ranger/commands/task.rs
@@ -6,7 +6,7 @@ use ranger::db::{SqliteConnection, SqlitePool};
use ranger::key;
use ranger::models::{State, Task};
use ranger::ops;
-use ranger::ops::task::Placement;
+use ranger::ops::task::{ListFilter, Placement};
use crate::output;
@@ -89,6 +89,9 @@ pub enum TaskCommands {
/// Filter by state
#[arg(long)]
state: Option<String>,
+ /// Include archived tasks
+ #[arg(long)]
+ archived: bool,
},
/// Show task details
#[command(visible_alias = "s")]
@@ -128,6 +131,18 @@ pub enum TaskCommands {
/// Task key or prefix
key: String,
},
+
+ /// Archive a task
+ Archive {
+ /// Task key or prefix
+ key: String,
+ },
+
+ /// Unarchive a task
+ Unarchive {
+ /// Task key or prefix
+ key: String,
+ },
}
pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result<()> {
@@ -174,15 +189,22 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
let prefixes = key::unique_prefix_lengths(&all_keys);
output::print(&task, json, |t| print_task(t, &prefixes));
}
- TaskCommands::List { backlog, state } => {
+ TaskCommands::List {
+ backlog,
+ state,
+ archived,
+ } => {
let mut conn = pool.acquire().await?;
- let state = state.map(|s| s.parse::<State>()).transpose()?;
+ let filter = ListFilter {
+ state: state.map(|s| s.parse::<State>()).transpose()?,
+ include_archived: archived,
+ };
if let Some(backlog_name) = &backlog {
let bl = ops::backlog::get_by_name(&mut conn, backlog_name).await?;
let backlog_keys = ops::task::keys_for_backlog(&mut conn, bl.id).await?;
let prefixes = key::unique_prefix_lengths(&backlog_keys);
- let tasks = ops::task::list(&mut conn, bl.id, state).await?;
+ let tasks = ops::task::list(&mut conn, bl.id, &filter).await?;
output::print_list(&tasks, json, |t| print_task(t, &prefixes));
} else {
// List all tasks (no backlog filter)
@@ -191,7 +213,7 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
let backlogs = ops::backlog::list(&mut conn).await?;
let mut all_tasks = Vec::new();
for bl in &backlogs {
- let tasks = ops::task::list(&mut conn, bl.id, state.clone()).await?;
+ let tasks = ops::task::list(&mut conn, bl.id, &filter).await?;
for t in tasks {
if !all_tasks.iter().any(|at: &Task| at.id == t.id) {
all_tasks.push(t);
@@ -286,6 +308,34 @@ pub async fn run(pool: &SqlitePool, command: TaskCommands, json: bool) -> Result
task.title
);
}
+ TaskCommands::Archive { key } => {
+ let mut conn = pool.acquire().await?;
+ let task = ops::task::get_by_key_prefix(&mut conn, &key).await?;
+ let updated = ops::task::set_archived(&mut conn, task.id, true).await?;
+ let all_keys = ops::task::all_keys(&mut conn).await?;
+ let prefixes = key::unique_prefix_lengths(&all_keys);
+ output::print(&updated, json, |t| {
+ println!(
+ "Archived {} {}",
+ output::format_key_from_map(&t.key, &prefixes),
+ t.title
+ );
+ });
+ }
+ TaskCommands::Unarchive { key } => {
+ let mut conn = pool.acquire().await?;
+ let task = ops::task::get_by_key_prefix(&mut conn, &key).await?;
+ let updated = ops::task::set_archived(&mut conn, task.id, false).await?;
+ let all_keys = ops::task::all_keys(&mut conn).await?;
+ let prefixes = key::unique_prefix_lengths(&all_keys);
+ output::print(&updated, json, |t| {
+ println!(
+ "Unarchived {} {}",
+ output::format_key_from_map(&t.key, &prefixes),
+ t.title
+ );
+ });
+ }
}
Ok(())
}
@@ -303,6 +353,9 @@ fn print_task_detail(t: &Task, prefixes: &HashMap<String, usize>) {
println!("Key: {}", output::format_key_from_map(&t.key, prefixes));
println!("Title: {}", t.title);
println!("State: {}", t.state);
+ if t.archived {
+ println!("Archived: yes");
+ }
if let Some(desc) = &t.description {
println!("Desc: {}", desc);
}
diff --git a/src/models.rs b/src/models.rs
index b1f83c1..38aa54e 100644
--- a/src/models.rs
+++ b/src/models.rs
@@ -96,6 +96,7 @@ pub struct Task {
pub description: Option<String>,
pub state: State,
pub position: String,
+ pub archived: bool,
pub created_at: Timestamp,
pub updated_at: Timestamp,
}
diff --git a/src/ops/task.rs b/src/ops/task.rs
index d059ace..5addaf9 100644
--- a/src/ops/task.rs
+++ b/src/ops/task.rs
@@ -4,8 +4,7 @@ use crate::models::{State, Task};
use crate::position;
use sqlx::sqlite::SqliteConnection;
-const TASK_COLUMNS: &str =
- "id, key, backlog_id, parent_id, title, description, state, position, created_at, updated_at";
+const TASK_COLUMNS: &str = "id, key, backlog_id, parent_id, title, description, state, position, archived, created_at, updated_at";
pub struct CreateTask<'a> {
pub title: &'a str,
@@ -53,15 +52,26 @@ pub async fn create(
Ok(task)
}
+#[derive(Default)]
+pub struct ListFilter {
+ pub state: Option<State>,
+ pub include_archived: bool,
+}
+
pub async fn list(
conn: &mut SqliteConnection,
backlog_id: i64,
- state_filter: Option<State>,
+ filter: &ListFilter,
) -> Result<Vec<Task>, RangerError> {
- let tasks = if let Some(state) = state_filter {
+ let archived_clause = if filter.include_archived {
+ ""
+ } else {
+ " AND archived = 0"
+ };
+ let tasks = if let Some(state) = &filter.state {
let query = format!(
"SELECT {TASK_COLUMNS} FROM tasks \
- WHERE backlog_id = ? AND state = ? \
+ WHERE backlog_id = ? AND state = ?{archived_clause} \
ORDER BY position"
);
sqlx::query_as::<_, Task>(&query)
@@ -72,7 +82,7 @@ pub async fn list(
} else {
let query = format!(
"SELECT {TASK_COLUMNS} FROM tasks \
- WHERE backlog_id = ? \
+ WHERE backlog_id = ?{archived_clause} \
ORDER BY position"
);
sqlx::query_as::<_, Task>(&query)
@@ -176,6 +186,23 @@ pub async fn edit(
Ok(task)
}
+pub async fn set_archived(
+ conn: &mut SqliteConnection,
+ task_id: i64,
+ archived: bool,
+) -> Result<Task, RangerError> {
+ let query = format!(
+ "UPDATE tasks SET archived = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') \
+ WHERE id = ? RETURNING {TASK_COLUMNS}"
+ );
+ let task = sqlx::query_as::<_, Task>(&query)
+ .bind(archived)
+ .bind(task_id)
+ .fetch_one(&mut *conn)
+ .await?;
+ Ok(task)
+}
+
pub enum Placement<'a> {
Before(&'a Task),
After(&'a Task),
@@ -395,7 +422,15 @@ async fn set_position(
}
pub async fn rebalance(conn: &mut SqliteConnection, backlog_id: i64) -> Result<usize, RangerError> {
- let tasks = list(&mut *conn, backlog_id, None).await?;
+ let tasks = list(
+ &mut *conn,
+ backlog_id,
+ &ListFilter {
+ include_archived: true,
+ ..Default::default()
+ },
+ )
+ .await?;
let positions = position::spread(tasks.len());
for (task, pos) in tasks.iter().zip(&positions) {
@@ -494,7 +529,9 @@ mod tests {
.await
.unwrap();
- let tasks = list(&mut conn, bl.id, None).await.unwrap();
+ let tasks = list(&mut conn, bl.id, &ListFilter::default())
+ .await
+ .unwrap();
assert_eq!(tasks.len(), 3);
assert_eq!(tasks[0].id, t1.id);
assert_eq!(tasks[1].id, t2.id);
@@ -531,11 +568,29 @@ mod tests {
.await
.unwrap();
- let icebox = list(&mut conn, bl.id, Some(State::Icebox)).await.unwrap();
+ let icebox = list(
+ &mut conn,
+ bl.id,
+ &ListFilter {
+ state: Some(State::Icebox),
+ ..Default::default()
+ },
+ )
+ .await
+ .unwrap();
assert_eq!(icebox.len(), 1);
assert_eq!(icebox[0].title, "Icebox task");
- let queued = list(&mut conn, bl.id, Some(State::Queued)).await.unwrap();
+ let queued = list(
+ &mut conn,
+ bl.id,
+ &ListFilter {
+ state: Some(State::Queued),
+ ..Default::default()
+ },
+ )
+ .await
+ .unwrap();
assert_eq!(queued.len(), 1);
assert_eq!(queued[0].title, "Queued task");
}
@@ -618,7 +673,9 @@ mod tests {
let result = get_by_key_prefix(&mut conn, &task.key).await;
assert!(result.is_err());
- let tasks = list(&mut conn, bl.id, None).await.unwrap();
+ let tasks = list(&mut conn, bl.id, &ListFilter::default())
+ .await
+ .unwrap();
assert_eq!(tasks.len(), 0);
}
@@ -727,7 +784,9 @@ mod tests {
.await
.unwrap();
- let tasks = list(&mut conn, bl.id, None).await.unwrap();
+ let tasks = list(&mut conn, bl.id, &ListFilter::default())
+ .await
+ .unwrap();
assert_eq!(tasks[0].id, t2.id);
assert_eq!(tasks[1].id, t1.id);
}
@@ -778,7 +837,9 @@ mod tests {
.await
.unwrap();
- let tasks = list(&mut conn, bl.id, None).await.unwrap();
+ let tasks = list(&mut conn, bl.id, &ListFilter::default())
+ .await
+ .unwrap();
assert_eq!(tasks[0].id, t3.id);
assert_eq!(tasks[1].id, t1.id);
assert_eq!(tasks[2].id, t2.id);
@@ -830,7 +891,9 @@ mod tests {
.await
.unwrap();
- let tasks = list(&mut conn, bl.id, None).await.unwrap();
+ let tasks = list(&mut conn, bl.id, &ListFilter::default())
+ .await
+ .unwrap();
assert_eq!(tasks[0].id, t2.id);
assert_eq!(tasks[1].id, t3.id);
assert_eq!(tasks[2].id, t1.id);
@@ -889,7 +952,9 @@ mod tests {
.await
.unwrap();
- let tasks = list(&mut conn, bl.id, None).await.unwrap();
+ let tasks = list(&mut conn, bl.id, &ListFilter::default())
+ .await
+ .unwrap();
assert_eq!(tasks[0].id, t1.id);
assert_eq!(tasks[1].id, t3.id);
assert_eq!(tasks[2].id, t2.id);
@@ -982,7 +1047,16 @@ mod tests {
.unwrap();
assert_eq!(updated.state, State::Done);
- let done = list(&mut conn, bl.id, Some(State::Done)).await.unwrap();
+ let done = list(
+ &mut conn,
+ bl.id,
+ &ListFilter {
+ state: Some(State::Done),
+ ..Default::default()
+ },
+ )
+ .await
+ .unwrap();
assert_eq!(done.len(), 3);
assert_eq!(done[0].id, d1.id);
assert_eq!(done[1].id, d2.id);
@@ -1042,7 +1116,16 @@ mod tests {
.unwrap();
assert_eq!(updated.state, State::Queued);
- let queued = list(&mut conn, bl.id, Some(State::Queued)).await.unwrap();
+ let queued = list(
+ &mut conn,
+ bl.id,
+ &ListFilter {
+ state: Some(State::Queued),
+ ..Default::default()
+ },
+ )
+ .await
+ .unwrap();
assert_eq!(queued.len(), 3);
assert_eq!(
queued[0].id, ip.id,
@@ -1091,7 +1174,16 @@ mod tests {
.unwrap();
assert_eq!(updated.position, original_pos);
- let queued = list(&mut conn, bl.id, Some(State::Queued)).await.unwrap();
+ let queued = list(
+ &mut conn,
+ bl.id,
+ &ListFilter {
+ state: Some(State::Queued),
+ ..Default::default()
+ },
+ )
+ .await
+ .unwrap();
assert_eq!(queued[0].id, t1.id);
assert_eq!(queued[1].id, t2.id);
}
@@ -1121,7 +1213,16 @@ mod tests {
.unwrap();
assert_eq!(updated.state, State::Done);
- let done = list(&mut conn, bl.id, Some(State::Done)).await.unwrap();
+ let done = list(
+ &mut conn,
+ bl.id,
+ &ListFilter {
+ state: Some(State::Done),
+ ..Default::default()
+ },
+ )
+ .await
+ .unwrap();
assert_eq!(done.len(), 1);
assert_eq!(done[0].id, t1.id);
}
@@ -1151,7 +1252,16 @@ mod tests {
.unwrap();
assert_eq!(updated.state, State::Icebox);
- let icebox = list(&mut conn, bl.id, Some(State::Icebox)).await.unwrap();
+ let icebox = list(
+ &mut conn,
+ bl.id,
+ &ListFilter {
+ state: Some(State::Icebox),
+ ..Default::default()
+ },
+ )
+ .await
+ .unwrap();
assert_eq!(icebox.len(), 1);
assert_eq!(icebox[0].id, t1.id);
}
@@ -1178,7 +1288,7 @@ mod tests {
.unwrap();
}
- let before: Vec<String> = list(&mut conn, bl.id, None)
+ let before: Vec<String> = list(&mut conn, bl.id, &ListFilter::default())
.await
.unwrap()
.iter()
@@ -1188,7 +1298,9 @@ mod tests {
let count = rebalance(&mut conn, bl.id).await.unwrap();
assert_eq!(count, 3);
- let after = list(&mut conn, bl.id, None).await.unwrap();
+ let after = list(&mut conn, bl.id, &ListFilter::default())
+ .await
+ .unwrap();
let after_positions: Vec<String> = after.iter().map(|t| t.position.clone()).collect();
// Order preserved
@@ -1256,4 +1368,69 @@ mod tests {
let global_keys = all_keys(&mut conn).await.unwrap();
assert_eq!(global_keys.len(), 2);
}
+
+ #[tokio::test]
+ async fn set_archived_and_filter() {
+ let pool = test_pool().await;
+ let mut conn = pool.acquire().await.unwrap();
+ let bl = backlog::create(&mut conn, "Test").await.unwrap();
+
+ let t1 = create(
+ &mut conn,
+ CreateTask {
+ title: "Keep",
+ backlog_id: bl.id,
+ state: None,
+ parent_id: None,
+ description: None,
+ },
+ )
+ .await
+ .unwrap();
+ let t2 = create(
+ &mut conn,
+ CreateTask {
+ title: "Archive me",
+ backlog_id: bl.id,
+ state: None,
+ parent_id: None,
+ description: None,
+ },
+ )
+ .await
+ .unwrap();
+
+ // Archive t2
+ let archived = set_archived(&mut conn, t2.id, true).await.unwrap();
+ assert!(archived.archived);
+
+ // Default list excludes archived
+ let visible = list(&mut conn, bl.id, &ListFilter::default())
+ .await
+ .unwrap();
+ assert_eq!(visible.len(), 1);
+ assert_eq!(visible[0].key, t1.key);
+
+ // include_archived shows all
+ let all = list(
+ &mut conn,
+ bl.id,
+ &ListFilter {
+ include_archived: true,
+ ..Default::default()
+ },
+ )
+ .await
+ .unwrap();
+ assert_eq!(all.len(), 2);
+
+ // Unarchive
+ let restored = set_archived(&mut conn, t2.id, false).await.unwrap();
+ assert!(!restored.archived);
+
+ let visible = list(&mut conn, bl.id, &ListFilter::default())
+ .await
+ .unwrap();
+ assert_eq!(visible.len(), 2);
+ }
}
diff --git a/tests/cli.rs b/tests/cli.rs
index a9e2aff..37cc970 100644
--- a/tests/cli.rs
+++ b/tests/cli.rs
@@ -208,4 +208,46 @@ fn full_workflow() {
titles.iter().position(|t| t.contains("Fourth")).unwrap()
< titles.iter().position(|t| *t == "Third task").unwrap()
);
+
+ // Archive a task
+ let output = ranger(db_path)
+ .args(["task", "archive", &t1_key[..4]])
+ .output()
+ .unwrap();
+ assert!(output.status.success());
+ let stdout = String::from_utf8(output.stdout).unwrap();
+ assert!(stdout.contains("Archived"));
+
+ // Archived task hidden from default list
+ let output = ranger(db_path)
+ .args(["task", "list", "--json"])
+ .output()
+ .unwrap();
+ let tasks: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
+ assert_eq!(tasks.as_array().unwrap().len(), 2);
+
+ // Visible with --archived
+ let output = ranger(db_path)
+ .args(["task", "list", "--json", "--archived"])
+ .output()
+ .unwrap();
+ let tasks: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
+ assert_eq!(tasks.as_array().unwrap().len(), 3);
+
+ // Unarchive
+ let output = ranger(db_path)
+ .args(["task", "unarchive", &t1_key[..4]])
+ .output()
+ .unwrap();
+ assert!(output.status.success());
+ let stdout = String::from_utf8(output.stdout).unwrap();
+ assert!(stdout.contains("Unarchived"));
+
+ // Back in default list
+ let output = ranger(db_path)
+ .args(["task", "list", "--json"])
+ .output()
+ .unwrap();
+ let tasks: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
+ assert_eq!(tasks.as_array().unwrap().len(), 3);
}