diff --git a/crates/storage/src/database.rs b/crates/storage/src/database.rs index 1c0ac18..1025fd6 100644 --- a/crates/storage/src/database.rs +++ b/crates/storage/src/database.rs @@ -328,8 +328,9 @@ pub async fn insert_task( pub async fn list_tasks(pool: &SqlitePool) -> Result> { let rows = sqlx::query( "SELECT id, text, bucket, list_id, effort, notes, done, done_at, created_at, \ - source_transcript_id, parent_task_id, energy \ - FROM tasks WHERE parent_task_id IS NULL ORDER BY created_at DESC", + source_transcript_id, parent_task_id, energy, archived, archived_at \ + FROM tasks WHERE parent_task_id IS NULL AND archived = 0 \ + ORDER BY created_at DESC", ) .fetch_all(pool) .await @@ -341,7 +342,8 @@ pub async fn list_tasks(pool: &SqlitePool) -> Result> { pub async fn get_task_by_id(pool: &SqlitePool, id: &str) -> Result> { let row = sqlx::query( "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) .fetch_optional(pool) @@ -431,8 +433,8 @@ pub async fn insert_subtask( pub async fn list_subtasks(pool: &SqlitePool, parent_id: &str) -> Result> { let rows = sqlx::query( "SELECT id, text, bucket, list_id, effort, notes, done, done_at, created_at, \ - source_transcript_id, parent_task_id, energy \ - FROM tasks WHERE parent_task_id = ? ORDER BY created_at ASC", + source_transcript_id, parent_task_id, energy, archived, archived_at \ + FROM tasks WHERE parent_task_id = ? AND archived = 0 ORDER BY created_at ASC", ) .bind(parent_id) .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, \ - 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) .bind(subtask_id) @@ -872,6 +875,34 @@ pub struct TaskRow { /// migration v11). Unset is the expected normal case — the match-my- /// energy sort treats unset as Medium-equivalent. pub energy: Option, + /// 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, +} + +/// 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, + 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)] @@ -948,6 +979,18 @@ fn task_row_from(r: sqlx::sqlite::SqliteRow) -> TaskRow { source_transcript_id: r.get("source_transcript_id"), parent_task_id: r.get("parent_task_id"), energy: r.get("energy"), + archived: r.get::("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::("built_in") != 0, + profile_id: r.get("profile_id"), + created_at: r.get("created_at"), } } diff --git a/crates/storage/src/migrations.rs b/crates/storage/src/migrations.rs index 20ff74f..28f2b2c 100644 --- a/crates/storage/src/migrations.rs +++ b/crates/storage/src/migrations.rs @@ -477,6 +477,24 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[ 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. @@ -626,7 +644,7 @@ mod tests { .fetch_one(&pool) .await .unwrap(); - assert_eq!(count, 15); + assert_eq!(count, 16); sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')") .execute(&pool) @@ -645,7 +663,7 @@ mod tests { .fetch_one(&pool) .await .unwrap(); - assert_eq!(count, 15); + assert_eq!(count, 16); } #[tokio::test]