add done_at timestamp for ordering done tasks
New done_at column on tasks with a CHECK constraint ensuring it is
non-null iff state = 'done'. Migration recreates the table to
backfill existing done tasks with their updated_at value.
- create sets done_at when initial state is done
- edit sets/clears done_at on state transitions
- done task listings order by done_at instead of position
- task show displays done_at when present
diff --git a/migrations/010_add_done_at.sql b/migrations/010_add_done_at.sql
new file mode 100644
index 0000000..fce9541
--- /dev/null
+++ b/migrations/010_add_done_at.sql
@@ -0,0 +1,30 @@
+-- Add done_at timestamp. Non-null iff state = 'done'.
+-- Requires table recreation to backfill existing done tasks atomically
+-- with the CHECK constraint.
+
+PRAGMA foreign_keys = OFF;
+
+CREATE TABLE tasks_new (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ key TEXT NOT NULL UNIQUE,
+ backlog_id INTEGER NOT NULL REFERENCES backlogs(id) ON DELETE CASCADE,
+ title TEXT NOT NULL,
+ description TEXT,
+ state TEXT NOT NULL DEFAULT 'icebox',
+ position TEXT NOT NULL,
+ archived INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
+ done_at TEXT,
+ CHECK ((state = 'done') = (done_at IS NOT NULL))
+);
+
+INSERT INTO tasks_new (id, key, backlog_id, title, description, state, position, archived, created_at, updated_at, done_at)
+SELECT id, key, backlog_id, title, description, state, position, archived, created_at, updated_at,
+ CASE WHEN state = 'done' THEN updated_at ELSE NULL END
+FROM tasks;
+
+DROP TABLE tasks;
+ALTER TABLE tasks_new RENAME TO tasks;
+
+PRAGMA foreign_keys = ON;
diff --git a/src/bin/ranger/commands/task.rs b/src/bin/ranger/commands/task.rs
index 69080bc..78444d1 100644
--- a/src/bin/ranger/commands/task.rs
+++ b/src/bin/ranger/commands/task.rs
@@ -417,4 +417,7 @@ fn print_task_detail(t: &Task, prefixes: &HashMap<String, usize>) {
}
println!("Created: {}", t.created_at);
println!("Updated: {}", t.updated_at);
+ if let Some(done_at) = &t.done_at {
+ println!("Done: {}", done_at);
+ }
}
diff --git a/src/models.rs b/src/models.rs
index 29c1db2..1ea0a12 100644
--- a/src/models.rs
+++ b/src/models.rs
@@ -108,6 +108,7 @@ pub struct Task {
pub archived: bool,
pub created_at: Timestamp,
pub updated_at: Timestamp,
+ pub done_at: Option<Timestamp>,
}
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
diff --git a/src/ops/task.rs b/src/ops/task.rs
index 7c18f16..b0d1f4c 100644
--- a/src/ops/task.rs
+++ b/src/ops/task.rs
@@ -6,7 +6,7 @@ use crate::position;
use sqlx::sqlite::SqliteConnection;
use std::collections::HashMap;
-const TASK_COLUMNS: &str = "tasks.id, tasks.key, tasks.backlog_id, tasks.title, tasks.description, tasks.state, tasks.position, tasks.archived, tasks.created_at, tasks.updated_at";
+const TASK_COLUMNS: &str = "tasks.id, tasks.key, tasks.backlog_id, tasks.title, tasks.description, tasks.state, tasks.position, tasks.archived, tasks.created_at, tasks.updated_at, tasks.done_at";
pub struct CreateTask<'a> {
pub title: &'a str,
@@ -33,10 +33,17 @@ pub async fn create(
let new_pos = position::between(last_pos.as_deref().unwrap_or(""), "");
+ let done_at = if state == State::Done {
+ Some("strftime('%Y-%m-%dT%H:%M:%SZ', 'now')")
+ } else {
+ None
+ };
+
let query = format!(
- "INSERT INTO tasks (key, backlog_id, title, description, state, position) \
- VALUES (?, ?, ?, ?, ?, ?) \
- RETURNING {TASK_COLUMNS}"
+ "INSERT INTO tasks (key, backlog_id, title, description, state, position, done_at) \
+ VALUES (?, ?, ?, ?, ?, ?, {}) \
+ RETURNING {TASK_COLUMNS}",
+ done_at.unwrap_or("NULL")
);
let task = sqlx::query_as::<_, Task>(&query)
@@ -77,9 +84,15 @@ pub async fn list(
""
};
- let order_clause = match ordering {
- Ordering::Position => " ORDER BY position",
- Ordering::Dag => " ORDER BY id",
+ let is_done_only = filter.state.as_ref() == Some(&State::Done);
+ let order_clause = if is_done_only {
+ // Done tasks order by completion time (most recent last)
+ " ORDER BY done_at"
+ } else {
+ match ordering {
+ Ordering::Position => " ORDER BY position",
+ Ordering::Dag => " ORDER BY id",
+ }
};
let mut tasks = if let Some(state) = &filter.state {
@@ -208,7 +221,16 @@ pub async fn edit(
.fetch_one(&mut *conn)
.await?;
- sqlx::query("UPDATE tasks SET state = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?")
+ let done_at_expr = if *new_state == State::Done {
+ "strftime('%Y-%m-%dT%H:%M:%SZ', 'now')"
+ } else {
+ "NULL"
+ };
+ let sql = format!(
+ "UPDATE tasks SET state = ?, done_at = {done_at_expr}, \
+ updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?"
+ );
+ sqlx::query(&sql)
.bind(new_state.as_str())
.bind(task_id)
.execute(&mut *conn)
@@ -2445,4 +2467,204 @@ mod tests {
assert_eq!(tasks[1].id, t3.id);
assert_eq!(tasks[2].id, t2.id);
}
+
+ // ---- done_at tests ----
+
+ #[tokio::test]
+ async fn create_with_done_state_sets_done_at() {
+ let pool = test_pool().await;
+ let mut conn = pool.acquire().await.unwrap();
+ let bl = backlog::create(&mut conn, "Test").await.unwrap();
+
+ let task = create(
+ &mut conn,
+ CreateTask {
+ title: "Done task",
+ backlog_id: bl.id,
+ state: Some(State::Done),
+ description: None,
+ },
+ )
+ .await
+ .unwrap();
+
+ assert!(task.done_at.is_some(), "done task should have done_at set");
+ }
+
+ #[tokio::test]
+ async fn create_with_non_done_state_has_no_done_at() {
+ let pool = test_pool().await;
+ let mut conn = pool.acquire().await.unwrap();
+ let bl = backlog::create(&mut conn, "Test").await.unwrap();
+
+ let task = create(
+ &mut conn,
+ CreateTask {
+ title: "Queued task",
+ backlog_id: bl.id,
+ state: Some(State::Queued),
+ description: None,
+ },
+ )
+ .await
+ .unwrap();
+
+ assert!(
+ task.done_at.is_none(),
+ "non-done task should not have done_at"
+ );
+ }
+
+ #[tokio::test]
+ async fn edit_to_done_sets_done_at() {
+ let pool = test_pool().await;
+ let mut conn = pool.acquire().await.unwrap();
+ let bl = backlog::create(&mut conn, "Test").await.unwrap();
+
+ let task = create(
+ &mut conn,
+ CreateTask {
+ title: "Task",
+ backlog_id: bl.id,
+ state: Some(State::Queued),
+ description: None,
+ },
+ )
+ .await
+ .unwrap();
+ assert!(task.done_at.is_none());
+
+ let updated = edit(
+ &mut conn,
+ task.id,
+ None,
+ None,
+ Some(State::Done),
+ Ordering::Position,
+ )
+ .await
+ .unwrap();
+ assert!(
+ updated.done_at.is_some(),
+ "should set done_at on transition to done"
+ );
+ }
+
+ #[tokio::test]
+ async fn edit_from_done_clears_done_at() {
+ let pool = test_pool().await;
+ let mut conn = pool.acquire().await.unwrap();
+ let bl = backlog::create(&mut conn, "Test").await.unwrap();
+
+ let task = create(
+ &mut conn,
+ CreateTask {
+ title: "Task",
+ backlog_id: bl.id,
+ state: Some(State::Done),
+ description: None,
+ },
+ )
+ .await
+ .unwrap();
+ assert!(task.done_at.is_some());
+
+ let updated = edit(
+ &mut conn,
+ task.id,
+ None,
+ None,
+ Some(State::Queued),
+ Ordering::Position,
+ )
+ .await
+ .unwrap();
+ assert!(
+ updated.done_at.is_none(),
+ "should clear done_at on transition away from done"
+ );
+ }
+
+ #[tokio::test]
+ async fn done_tasks_ordered_by_done_at() {
+ let pool = test_pool().await;
+ let mut conn = pool.acquire().await.unwrap();
+ let bl = backlog::create(&mut conn, "Test").await.unwrap();
+
+ // Create three queued tasks
+ let t1 = create(
+ &mut conn,
+ CreateTask {
+ title: "First done",
+ backlog_id: bl.id,
+ state: Some(State::Queued),
+ description: None,
+ },
+ )
+ .await
+ .unwrap();
+ let t2 = create(
+ &mut conn,
+ CreateTask {
+ title: "Second done",
+ backlog_id: bl.id,
+ state: Some(State::Queued),
+ description: None,
+ },
+ )
+ .await
+ .unwrap();
+ let t3 = create(
+ &mut conn,
+ CreateTask {
+ title: "Third done",
+ backlog_id: bl.id,
+ state: Some(State::Queued),
+ description: None,
+ },
+ )
+ .await
+ .unwrap();
+
+ // Mark them done in a specific order: t3, t1, t2
+ // Use direct SQL to set distinct done_at timestamps
+ sqlx::query(
+ "UPDATE tasks SET state = 'done', done_at = '2026-01-01T00:00:00Z' WHERE id = ?",
+ )
+ .bind(t3.id)
+ .execute(&mut *conn)
+ .await
+ .unwrap();
+ sqlx::query(
+ "UPDATE tasks SET state = 'done', done_at = '2026-01-02T00:00:00Z' WHERE id = ?",
+ )
+ .bind(t1.id)
+ .execute(&mut *conn)
+ .await
+ .unwrap();
+ sqlx::query(
+ "UPDATE tasks SET state = 'done', done_at = '2026-01-03T00:00:00Z' WHERE id = ?",
+ )
+ .bind(t2.id)
+ .execute(&mut *conn)
+ .await
+ .unwrap();
+
+ let done = list(
+ &mut conn,
+ bl.id,
+ &ListFilter {
+ state: Some(State::Done),
+ ..Default::default()
+ },
+ Ordering::Position,
+ )
+ .await
+ .unwrap();
+
+ assert_eq!(done.len(), 3);
+ assert_eq!(done[0].id, t3.id, "earliest done_at first");
+ assert_eq!(done[1].id, t1.id);
+ assert_eq!(done[2].id, t2.id, "latest done_at last");
+ }
}
diff --git a/tests/cli.rs b/tests/cli.rs
index df2a893..d238423 100644
--- a/tests/cli.rs
+++ b/tests/cli.rs
@@ -285,6 +285,30 @@ fn full_workflow() {
.unwrap();
assert!(output.status.success());
+ // Show done task — should include done_at timestamp
+ let output = ranger(db_path)
+ .args(["task", "show", &t1_key[..4]])
+ .output()
+ .unwrap();
+ assert!(output.status.success());
+ let stdout = String::from_utf8(output.stdout).unwrap();
+ assert!(
+ stdout.contains("Done:"),
+ "task show should display done_at for done tasks"
+ );
+
+ // JSON detail includes done_at
+ let output = ranger(db_path)
+ .args(["task", "show", &t1_key[..4], "--json"])
+ .output()
+ .unwrap();
+ assert!(output.status.success());
+ let detail: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
+ assert!(
+ detail["task"]["done_at"].is_string(),
+ "JSON should include done_at for done tasks"
+ );
+
// Backlog show hides done tasks by default
let output = ranger(db_path).args(["backlog", "show"]).output().unwrap();
assert!(output.status.success());