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
change nkpmryzvmwxworvktxpwypkqtmvpnyou
commit 20658f0bfaeadf095fe2c39300fa24cdd1c942c7
author Alpha Chen <alpha@kejadlen.dev>
date
parent mwsmxvpq
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());