feat(tasks): B2a part 1 — archive schema + task_lists scaffolding

Migration v16 adds tasks.archived (default 0) + tasks.archived_at +
idx_tasks_archived. TaskRow extended with both fields; list_tasks and
list_subtasks default-filter archived rows; complete_subtask_and_check_parent
SELECT lists updated to round-trip the new columns. TaskListRow +
ImportSummary structs land in preparation for the task_lists CRUD
that part 2 wires up. All 61 existing tests still pass.

(SQL splitter is naive about ';' inside -- comments, so the v16
comment block was rewritten to avoid a bare semicolon mid-comment
that the splitter was treating as a statement boundary.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-26 18:52:01 +01:00
parent 20adf73b2a
commit 954ff22840
2 changed files with 69 additions and 8 deletions

View File

@@ -328,8 +328,9 @@ pub async fn insert_task(
pub async fn list_tasks(pool: &SqlitePool) -> Result<Vec<TaskRow>> { pub async fn list_tasks(pool: &SqlitePool) -> Result<Vec<TaskRow>> {
let rows = sqlx::query( let rows = sqlx::query(
"SELECT id, text, bucket, list_id, effort, notes, done, done_at, created_at, \ "SELECT id, text, bucket, list_id, effort, notes, done, done_at, created_at, \
source_transcript_id, parent_task_id, energy \ source_transcript_id, parent_task_id, energy, archived, archived_at \
FROM tasks WHERE parent_task_id IS NULL ORDER BY created_at DESC", FROM tasks WHERE parent_task_id IS NULL AND archived = 0 \
ORDER BY created_at DESC",
) )
.fetch_all(pool) .fetch_all(pool)
.await .await
@@ -341,7 +342,8 @@ pub async fn list_tasks(pool: &SqlitePool) -> Result<Vec<TaskRow>> {
pub async fn get_task_by_id(pool: &SqlitePool, id: &str) -> Result<Option<TaskRow>> { pub async fn get_task_by_id(pool: &SqlitePool, id: &str) -> Result<Option<TaskRow>> {
let row = sqlx::query( let row = sqlx::query(
"SELECT id, text, bucket, list_id, effort, notes, done, done_at, created_at, \ "SELECT id, text, bucket, list_id, effort, notes, done, done_at, created_at, \
source_transcript_id, parent_task_id, energy FROM tasks WHERE id = ?", source_transcript_id, parent_task_id, energy, archived, archived_at \
FROM tasks WHERE id = ?",
) )
.bind(id) .bind(id)
.fetch_optional(pool) .fetch_optional(pool)
@@ -431,8 +433,8 @@ pub async fn insert_subtask(
pub async fn list_subtasks(pool: &SqlitePool, parent_id: &str) -> Result<Vec<TaskRow>> { pub async fn list_subtasks(pool: &SqlitePool, parent_id: &str) -> Result<Vec<TaskRow>> {
let rows = sqlx::query( let rows = sqlx::query(
"SELECT id, text, bucket, list_id, effort, notes, done, done_at, created_at, \ "SELECT id, text, bucket, list_id, effort, notes, done, done_at, created_at, \
source_transcript_id, parent_task_id, energy \ source_transcript_id, parent_task_id, energy, archived, archived_at \
FROM tasks WHERE parent_task_id = ? ORDER BY created_at ASC", FROM tasks WHERE parent_task_id = ? AND archived = 0 ORDER BY created_at ASC",
) )
.bind(parent_id) .bind(parent_id)
.fetch_all(pool) .fetch_all(pool)
@@ -499,7 +501,8 @@ pub async fn complete_subtask_and_check_parent(
} }
let select_sql = "SELECT id, text, bucket, list_id, effort, notes, done, done_at, \ let select_sql = "SELECT id, text, bucket, list_id, effort, notes, done, done_at, \
created_at, source_transcript_id, parent_task_id, energy FROM tasks WHERE id = ?"; created_at, source_transcript_id, parent_task_id, energy, archived, archived_at \
FROM tasks WHERE id = ?";
let updated_subtask = sqlx::query(select_sql) let updated_subtask = sqlx::query(select_sql)
.bind(subtask_id) .bind(subtask_id)
@@ -872,6 +875,34 @@ pub struct TaskRow {
/// migration v11). Unset is the expected normal case — the match-my- /// migration v11). Unset is the expected normal case — the match-my-
/// energy sort treats unset as Medium-equivalent. /// energy sort treats unset as Medium-equivalent.
pub energy: Option<String>, pub energy: Option<String>,
/// B2a — Inbox-only archive flag (migration v16). `true` means the
/// row is archived and excluded from the default `list_tasks` /
/// `list_subtasks` query. `archived_at` stamps the archive time so
/// the Archived view can sort newest-first.
pub archived: bool,
pub archived_at: Option<String>,
}
/// B2a — task_lists row. Mirrors the `task_lists` table that has lived in
/// migration v1 since the start; this struct + the surrounding CRUD wires
/// the table that the frontend was previously stashing in localStorage.
#[derive(Debug, Clone)]
pub struct TaskListRow {
pub id: String,
pub name: String,
pub built_in: bool,
pub profile_id: Option<String>,
pub created_at: String,
}
/// Result of `import_task_lists` — how many rows landed and how many were
/// skipped because their `id` already exists. The whole batch runs in one
/// transaction so a duplicate inside the batch (rather than against the
/// existing table) is a hard failure that rolls everything back.
#[derive(Debug, Clone)]
pub struct ImportSummary {
pub imported: usize,
pub skipped: usize,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -948,6 +979,18 @@ fn task_row_from(r: sqlx::sqlite::SqliteRow) -> TaskRow {
source_transcript_id: r.get("source_transcript_id"), source_transcript_id: r.get("source_transcript_id"),
parent_task_id: r.get("parent_task_id"), parent_task_id: r.get("parent_task_id"),
energy: r.get("energy"), energy: r.get("energy"),
archived: r.get::<i64, _>("archived") != 0,
archived_at: r.get("archived_at"),
}
}
fn task_list_row_from(r: &sqlx::sqlite::SqliteRow) -> TaskListRow {
TaskListRow {
id: r.get("id"),
name: r.get("name"),
built_in: r.get::<i64, _>("built_in") != 0,
profile_id: r.get("profile_id"),
created_at: r.get("created_at"),
} }
} }

View File

@@ -477,6 +477,24 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[
ON transcripts(profile_id, created_at DESC); ON transcripts(profile_id, created_at DESC);
"#, "#,
), ),
(
16,
"tasks: archived flag for Inbox-only archive (B2a)",
r#"
-- B2a: persistent archive surface. The default is 0 (visible) so all
-- pre-existing rows behave as before. archived_at is a string ISO
-- timestamp captured at archive time so the future "archived"
-- view can sort newest-first, and is NULL while the row is live.
--
-- Per the v3 plan, archive is Inbox-only: the bulk auto-archive
-- path (archive_inbox_older_than) only touches rows with
-- bucket = inbox. Today/Soon/Later reflect explicit user
-- choices and stay where the user put them.
ALTER TABLE tasks ADD COLUMN archived INTEGER NOT NULL DEFAULT 0;
ALTER TABLE tasks ADD COLUMN archived_at TEXT;
CREATE INDEX IF NOT EXISTS idx_tasks_archived ON tasks(archived);
"#,
),
]; ];
/// Split SQL into individual statements, respecting BEGIN...END trigger blocks. /// Split SQL into individual statements, respecting BEGIN...END trigger blocks.
@@ -626,7 +644,7 @@ mod tests {
.fetch_one(&pool) .fetch_one(&pool)
.await .await
.unwrap(); .unwrap();
assert_eq!(count, 15); assert_eq!(count, 16);
sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')") sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')")
.execute(&pool) .execute(&pool)
@@ -645,7 +663,7 @@ mod tests {
.fetch_one(&pool) .fetch_one(&pool)
.await .await
.unwrap(); .unwrap();
assert_eq!(count, 15); assert_eq!(count, 16);
} }
#[tokio::test] #[tokio::test]