Auto-reposition tasks on state change
Moving up (toward done) places at end of target state group.
Moving down (toward icebox) places at beginning. Skips
repositioning when explicit --before/--after flags are provided.
Assisted-by: Claude Opus 4.6 via pi
diff --git a/src/models.rs b/src/models.rs
index 6b385bb..a080c97 100644
--- a/src/models.rs
+++ b/src/models.rs
@@ -21,6 +21,16 @@ impl State {
State::Done => "done",
}
}
+
+ /// Numeric rank following the natural flow: icebox(0) → queued(1) → in_progress(2) → done(3).
+ pub fn rank(&self) -> u8 {
+ match self {
+ State::Icebox => 0,
+ State::Queued => 1,
+ State::InProgress => 2,
+ State::Done => 3,
+ }
+ }
}
#[derive(Debug, thiserror::Error)]
diff --git a/src/ops/task.rs b/src/ops/task.rs
index 49f8bc9..546a2ad 100644
--- a/src/ops/task.rs
+++ b/src/ops/task.rs
@@ -131,12 +131,22 @@ pub async fn edit(
.execute(&mut *conn)
.await?;
}
- if let Some(state) = &state {
+ if let Some(new_state) = &state {
+ // Fetch the current state to determine direction
+ let old_state: State = sqlx::query_scalar("SELECT state FROM tasks WHERE id = ?")
+ .bind(task_id)
+ .fetch_one(&mut *conn)
+ .await?;
+
sqlx::query("UPDATE tasks SET state = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?")
- .bind(state.as_str())
+ .bind(new_state.as_str())
.bind(task_id)
.execute(&mut *conn)
.await?;
+
+ if old_state != *new_state {
+ reposition_for_state_change(&mut *conn, task_id, &old_state, new_state).await?;
+ }
}
let query = format!("SELECT {TASK_COLUMNS} FROM tasks WHERE id = ?");
@@ -147,6 +157,116 @@ pub async fn edit(
Ok(task)
}
+/// Reposition a task when its state changes.
+///
+/// Moving up (toward done): place at end of target state group.
+/// Moving down (toward icebox): place at beginning of target state group.
+async fn reposition_for_state_change(
+ conn: &mut SqliteConnection,
+ task_id: i64,
+ old_state: &State,
+ new_state: &State,
+) -> Result<(), RangerError> {
+ let backlog_id: i64 = sqlx::query_scalar("SELECT backlog_id FROM tasks WHERE id = ?")
+ .bind(task_id)
+ .fetch_one(&mut *conn)
+ .await?;
+
+ let moving_up = new_state.rank() > old_state.rank();
+
+ let new_pos = if moving_up {
+ // End of target state group: after the last task with this state
+ let last_in_state: Option<String> = sqlx::query_scalar(
+ "SELECT position FROM tasks \
+ WHERE backlog_id = ? AND state = ? AND id != ? \
+ ORDER BY position DESC LIMIT 1",
+ )
+ .bind(backlog_id)
+ .bind(new_state.as_str())
+ .bind(task_id)
+ .fetch_optional(&mut *conn)
+ .await?;
+
+ match last_in_state {
+ Some(last) => {
+ let next: Option<String> = sqlx::query_scalar(
+ "SELECT position FROM tasks \
+ WHERE backlog_id = ? AND id != ? AND position > ? \
+ ORDER BY position ASC LIMIT 1",
+ )
+ .bind(backlog_id)
+ .bind(task_id)
+ .bind(&last)
+ .fetch_optional(&mut *conn)
+ .await?;
+ position::between(&last, next.as_deref().unwrap_or(""))
+ }
+ None => {
+ // No other tasks in this state — place at end of backlog
+ let last_pos: Option<String> = sqlx::query_scalar(
+ "SELECT position FROM tasks \
+ WHERE backlog_id = ? AND id != ? \
+ ORDER BY position DESC LIMIT 1",
+ )
+ .bind(backlog_id)
+ .bind(task_id)
+ .fetch_optional(&mut *conn)
+ .await?;
+ position::between(last_pos.as_deref().unwrap_or(""), "")
+ }
+ }
+ } else {
+ // Beginning of target state group: before the first task with this state
+ let first_in_state: Option<String> = sqlx::query_scalar(
+ "SELECT position FROM tasks \
+ WHERE backlog_id = ? AND state = ? AND id != ? \
+ ORDER BY position ASC LIMIT 1",
+ )
+ .bind(backlog_id)
+ .bind(new_state.as_str())
+ .bind(task_id)
+ .fetch_optional(&mut *conn)
+ .await?;
+
+ match first_in_state {
+ Some(first) => {
+ let prev: Option<String> = sqlx::query_scalar(
+ "SELECT position FROM tasks \
+ WHERE backlog_id = ? AND id != ? AND position < ? \
+ ORDER BY position DESC LIMIT 1",
+ )
+ .bind(backlog_id)
+ .bind(task_id)
+ .bind(&first)
+ .fetch_optional(&mut *conn)
+ .await?;
+ position::between(prev.as_deref().unwrap_or(""), &first)
+ }
+ None => {
+ // No other tasks in this state — place at beginning of backlog
+ let first_pos: Option<String> = sqlx::query_scalar(
+ "SELECT position FROM tasks \
+ WHERE backlog_id = ? AND id != ? \
+ ORDER BY position ASC LIMIT 1",
+ )
+ .bind(backlog_id)
+ .bind(task_id)
+ .fetch_optional(&mut *conn)
+ .await?;
+ position::between("", first_pos.as_deref().unwrap_or(""))
+ }
+ }
+ };
+
+ sqlx::query("UPDATE tasks SET position = ? WHERE id = ?")
+ .bind(&new_pos)
+ .bind(task_id)
+ .execute(&mut *conn)
+ .await?;
+
+ Ok(())
+}
+
pub async fn move_task(
conn: &mut SqliteConnection,
task_id: i64,
@@ -818,4 +938,228 @@ mod tests {
assert_eq!(tasks[0].id, t2.id);
assert_eq!(tasks[1].id, t1.id);
}
+
+ #[tokio::test]
+ async fn state_change_up_places_at_end_of_target_group() {
+ let pool = test_pool().await;
+ let mut conn = pool.acquire().await.unwrap();
+ let bl = backlog::create(&mut conn, "Test").await.unwrap();
+
+ // Create two done tasks and one queued task
+ let d1 = create(
+ &mut conn,
+ CreateTask {
+ title: "Done 1",
+ backlog_id: bl.id,
+ state: Some(State::Done),
+ parent_id: None,
+ description: None,
+ },
+ )
+ .await
+ .unwrap();
+ let d2 = create(
+ &mut conn,
+ CreateTask {
+ title: "Done 2",
+ backlog_id: bl.id,
+ state: Some(State::Done),
+ parent_id: None,
+ description: None,
+ },
+ )
+ .await
+ .unwrap();
+ let q1 = create(
+ &mut conn,
+ CreateTask {
+ title: "Queued 1",
+ backlog_id: bl.id,
+ state: Some(State::Queued),
+ parent_id: None,
+ description: None,
+ },
+ )
+ .await
+ .unwrap();
+
+ // Move queued task to done — should land after Done 2
+ let updated = edit(&mut conn, q1.id, None, None, Some(State::Done))
+ .await
+ .unwrap();
+ assert_eq!(updated.state, State::Done);
+
+ let done = list(&mut conn, bl.id, Some(State::Done)).await.unwrap();
+ assert_eq!(done.len(), 3);
+ assert_eq!(done[0].id, d1.id);
+ assert_eq!(done[1].id, d2.id);
+ assert_eq!(
+ done[2].id, q1.id,
+ "newly done task should be at end of done group"
+ );
+ }
+
+ #[tokio::test]
+ async fn state_change_down_places_at_beginning_of_target_group() {
+ let pool = test_pool().await;
+ let mut conn = pool.acquire().await.unwrap();
+ let bl = backlog::create(&mut conn, "Test").await.unwrap();
+
+ // Create two queued tasks and one in_progress task
+ let q1 = create(
+ &mut conn,
+ CreateTask {
+ title: "Queued 1",
+ backlog_id: bl.id,
+ state: Some(State::Queued),
+ parent_id: None,
+ description: None,
+ },
+ )
+ .await
+ .unwrap();
+ let q2 = create(
+ &mut conn,
+ CreateTask {
+ title: "Queued 2",
+ backlog_id: bl.id,
+ state: Some(State::Queued),
+ parent_id: None,
+ description: None,
+ },
+ )
+ .await
+ .unwrap();
+ let ip = create(
+ &mut conn,
+ CreateTask {
+ title: "In Progress",
+ backlog_id: bl.id,
+ state: Some(State::InProgress),
+ parent_id: None,
+ description: None,
+ },
+ )
+ .await
+ .unwrap();
+
+ // Move in_progress task to queued — should land before Queued 1
+ let updated = edit(&mut conn, ip.id, None, None, Some(State::Queued))
+ .await
+ .unwrap();
+ assert_eq!(updated.state, State::Queued);
+
+ let queued = list(&mut conn, bl.id, Some(State::Queued)).await.unwrap();
+ assert_eq!(queued.len(), 3);
+ assert_eq!(
+ queued[0].id, ip.id,
+ "demoted task should be at beginning of queued group"
+ );
+ assert_eq!(queued[1].id, q1.id);
+ assert_eq!(queued[2].id, q2.id);
+ }
+
+ #[tokio::test]
+ async fn state_change_same_state_does_not_reposition() {
+ 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: "First",
+ backlog_id: bl.id,
+ state: Some(State::Queued),
+ parent_id: None,
+ description: None,
+ },
+ )
+ .await
+ .unwrap();
+ let t2 = create(
+ &mut conn,
+ CreateTask {
+ title: "Second",
+ backlog_id: bl.id,
+ state: Some(State::Queued),
+ parent_id: None,
+ description: None,
+ },
+ )
+ .await
+ .unwrap();
+
+ let original_pos = t1.position.clone();
+
+ // Edit to same state — position should not change
+ let updated = edit(&mut conn, t1.id, None, None, Some(State::Queued))
+ .await
+ .unwrap();
+ assert_eq!(updated.position, original_pos);
+
+ let queued = list(&mut conn, bl.id, Some(State::Queued)).await.unwrap();
+ assert_eq!(queued[0].id, t1.id);
+ assert_eq!(queued[1].id, t2.id);
+ }
+
+ #[tokio::test]
+ async fn state_change_up_to_empty_group() {
+ 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: "Queued task",
+ backlog_id: bl.id,
+ state: Some(State::Queued),
+ parent_id: None,
+ description: None,
+ },
+ )
+ .await
+ .unwrap();
+
+ // Move to done (empty group) — should succeed
+ let updated = edit(&mut conn, t1.id, None, None, Some(State::Done))
+ .await
+ .unwrap();
+ assert_eq!(updated.state, State::Done);
+
+ let done = list(&mut conn, bl.id, Some(State::Done)).await.unwrap();
+ assert_eq!(done.len(), 1);
+ assert_eq!(done[0].id, t1.id);
+ }
+
+ #[tokio::test]
+ async fn state_change_down_to_empty_group() {
+ 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: "In progress task",
+ backlog_id: bl.id,
+ state: Some(State::InProgress),
+ parent_id: None,
+ description: None,
+ },
+ )
+ .await
+ .unwrap();
+
+ // Move to icebox (empty group) — should succeed
+ let updated = edit(&mut conn, t1.id, None, None, Some(State::Icebox))
+ .await
+ .unwrap();
+ assert_eq!(updated.state, State::Icebox);
+
+ let icebox = list(&mut conn, bl.id, Some(State::Icebox)).await.unwrap();
+ assert_eq!(icebox.len(), 1);
+ assert_eq!(icebox[0].id, t1.id);
+ }
}