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
change msmyprskuzkzppylwykzmnmztoqzqooz
commit 3f6d4923b69c5639d8d3794ad22184a5c33ebffe
author Alpha Chen <alpha@kejadlen.dev>
date
parent yorloszq
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);
 }