feat(tasks): B2a part 2 — task_lists CRUD + archive cmds + frontend wire

Storage: 5 task_lists CRUD functions (list/create/update/delete/
import) and 4 archive functions (list_archived_tasks/archive_task/
unarchive_task/archive_inbox_older_than). 8 new tests cover CRUD,
import idempotency, atomic-failure rollback, dependent-task null-out,
archive scope, archive_inbox_older_than Inbox-only invariant, and
unarchive round-trip.

Tauri: 9 new commands (5 task_lists + 4 archive) registered in the
invoke_handler. New file commands/task_lists.rs mirroring tasks.rs
structure. TaskDto extended with archived/archivedAt.

Frontend: loadTaskLists rewired to read from SQLite via
list_task_lists_cmd on Tauri runtime, with a one-time migration
that imports kon_task_lists localStorage into SQLite via
import_task_lists_cmd and clears localStorage only on success.
mapTaskRow handles the new archived fields. TasksPage gets an
Archived filter pill (hidden when empty) and a per-row archive
icon. moveTaskList/sortTaskLists kept local pending a future
order-column migration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-26 19:04:17 +01:00
parent 954ff22840
commit 0111fc9e08
9 changed files with 1133 additions and 85 deletions

View File

@@ -592,6 +592,253 @@ pub async fn delete_task(pool: &SqlitePool, id: &str) -> Result<()> {
Ok(())
}
// --- B2a: archive operations on tasks ----------------------------------
//
// Archive is Inbox-only at the bulk level (`archive_inbox_older_than`)
// because Today/Soon/Later reflect explicit user choices and shouldn't
// be auto-swept. The single-row `archive_task`/`unarchive_task` helpers
// are bucket-agnostic so the user can manually archive any row from the
// per-row affordance in the UI.
pub async fn list_archived_tasks(pool: &SqlitePool) -> Result<Vec<TaskRow>> {
let rows = sqlx::query(
"SELECT id, text, bucket, list_id, effort, notes, done, done_at, created_at, \
source_transcript_id, parent_task_id, energy, archived, archived_at \
FROM tasks WHERE parent_task_id IS NULL AND archived = 1 \
ORDER BY archived_at DESC",
)
.fetch_all(pool)
.await
.map_err(|e| KonError::StorageError(format!("List archived tasks failed: {e}")))?;
Ok(rows.into_iter().map(task_row_from).collect())
}
pub async fn archive_task(pool: &SqlitePool, id: &str) -> Result<TaskRow> {
sqlx::query("UPDATE tasks SET archived = 1, archived_at = datetime('now') WHERE id = ?")
.bind(id)
.execute(pool)
.await
.map_err(|e| KonError::StorageError(format!("Archive task failed: {e}")))?;
get_task_by_id(pool, id).await?.ok_or_else(|| {
KonError::StorageError(format!("archive_task: task {id} not found after update"))
})
}
pub async fn unarchive_task(pool: &SqlitePool, id: &str) -> Result<TaskRow> {
sqlx::query("UPDATE tasks SET archived = 0, archived_at = NULL WHERE id = ?")
.bind(id)
.execute(pool)
.await
.map_err(|e| KonError::StorageError(format!("Unarchive task failed: {e}")))?;
get_task_by_id(pool, id).await?.ok_or_else(|| {
KonError::StorageError(format!("unarchive_task: task {id} not found after update"))
})
}
/// Bulk auto-archive: mark Inbox rows older than `days` as archived.
///
/// Inbox-only by design — see B2a brief. The query also skips rows that
/// are already archived so a re-run is idempotent for the cleanup case.
/// Returns `rows_affected` so the caller can surface "5 old items
/// archived" telemetry.
pub async fn archive_inbox_older_than(pool: &SqlitePool, days: i64) -> Result<u64> {
let res = sqlx::query(
"UPDATE tasks SET archived = 1, archived_at = datetime('now') \
WHERE bucket = 'inbox' AND archived = 0 \
AND created_at < datetime('now', ? || ' days')",
)
.bind(format!("-{days}"))
.execute(pool)
.await
.map_err(|e| KonError::StorageError(format!("archive_inbox_older_than failed: {e}")))?;
Ok(res.rows_affected())
}
// --- B2a: task_lists CRUD ----------------------------------------------
//
// The `task_lists` table has lived in migration v1 since launch but the
// frontend was stashing user lists in localStorage. These helpers move
// the source of truth into SQLite so multi-window use, profile-aware
// list filtering, and the import migration all read the same data.
pub async fn list_task_lists(pool: &SqlitePool) -> Result<Vec<TaskListRow>> {
let rows = sqlx::query(
"SELECT id, name, built_in, profile_id, created_at \
FROM task_lists ORDER BY created_at ASC, id ASC",
)
.fetch_all(pool)
.await
.map_err(|e| KonError::StorageError(format!("List task_lists failed: {e}")))?;
Ok(rows.iter().map(task_list_row_from).collect())
}
pub async fn create_task_list(
pool: &SqlitePool,
id: &str,
name: &str,
built_in: bool,
profile_id: Option<&str>,
) -> Result<TaskListRow> {
sqlx::query(
"INSERT INTO task_lists (id, name, built_in, profile_id) VALUES (?, ?, ?, ?)",
)
.bind(id)
.bind(name)
.bind(built_in as i64)
.bind(profile_id)
.execute(pool)
.await
.map_err(|e| KonError::StorageError(format!("Create task_list failed: {e}")))?;
let row = sqlx::query(
"SELECT id, name, built_in, profile_id, created_at FROM task_lists WHERE id = ?",
)
.bind(id)
.fetch_one(pool)
.await
.map_err(|e| KonError::StorageError(format!("Refetch task_list failed: {e}")))?;
Ok(task_list_row_from(&row))
}
/// Patch-style update. `name` is COALESCE so `None` preserves the
/// existing column. `profile_id` uses the triple-Option pattern so the
/// caller can distinguish "leave alone" (`None`) from "set to NULL"
/// (`Some(None)`). `built_in` is immutable post-creation.
pub async fn update_task_list(
pool: &SqlitePool,
id: &str,
name: Option<&str>,
profile_id: Option<Option<&str>>,
) -> Result<TaskListRow> {
match profile_id {
Some(pid) => {
// Caller wants to write profile_id (possibly to NULL). Bind
// explicitly so the COALESCE-protected `name` still works.
sqlx::query(
"UPDATE task_lists SET name = COALESCE(?, name), profile_id = ? WHERE id = ?",
)
.bind(name)
.bind(pid)
.bind(id)
.execute(pool)
.await
.map_err(|e| KonError::StorageError(format!("Update task_list failed: {e}")))?;
}
None => {
sqlx::query("UPDATE task_lists SET name = COALESCE(?, name) WHERE id = ?")
.bind(name)
.bind(id)
.execute(pool)
.await
.map_err(|e| KonError::StorageError(format!("Update task_list failed: {e}")))?;
}
}
let row = sqlx::query(
"SELECT id, name, built_in, profile_id, created_at FROM task_lists WHERE id = ?",
)
.bind(id)
.fetch_optional(pool)
.await
.map_err(|e| KonError::StorageError(format!("Refetch task_list failed: {e}")))?
.ok_or_else(|| {
KonError::StorageError(format!("update_task_list: list {id} not found after update"))
})?;
Ok(task_list_row_from(&row))
}
/// Delete a list and null out the `list_id` of any tasks pointing to
/// it. Wrapped in a transaction so the two writes can't get out of
/// sync — leaving orphaned `list_id` values would surface as ghost
/// list references in the sidebar.
pub async fn delete_task_list(pool: &SqlitePool, id: &str) -> Result<()> {
let mut tx = pool
.begin()
.await
.map_err(|e| KonError::StorageError(format!("Begin transaction failed: {e}")))?;
sqlx::query("UPDATE tasks SET list_id = NULL WHERE list_id = ?")
.bind(id)
.execute(&mut *tx)
.await
.map_err(|e| KonError::StorageError(format!("Null tasks.list_id failed: {e}")))?;
sqlx::query("DELETE FROM task_lists WHERE id = ?")
.bind(id)
.execute(&mut *tx)
.await
.map_err(|e| KonError::StorageError(format!("Delete task_list failed: {e}")))?;
tx.commit()
.await
.map_err(|e| KonError::StorageError(format!("Commit transaction failed: {e}")))?;
Ok(())
}
/// Bulk import for the localStorage → SQLite migration. Single
/// transaction. Pre-existing IDs are skipped (counted in `skipped`),
/// new rows are inserted (counted in `imported`). A duplicate ID
/// *within* the input batch is a hard failure that rolls back the
/// whole transaction — we'd rather surface the corruption than
/// half-import a contradictory dataset.
pub async fn import_task_lists(
pool: &SqlitePool,
items: &[TaskListRow],
) -> Result<ImportSummary> {
let mut tx = pool
.begin()
.await
.map_err(|e| KonError::StorageError(format!("Begin transaction failed: {e}")))?;
let mut imported = 0usize;
let mut skipped = 0usize;
let mut seen_in_batch: std::collections::HashSet<String> = std::collections::HashSet::new();
for item in items {
if !seen_in_batch.insert(item.id.clone()) {
// Duplicate id within the batch — abort. The transaction
// drops on the early return, rolling back any earlier
// inserts in this batch.
return Err(KonError::StorageError(format!(
"import_task_lists: duplicate id '{}' within batch",
item.id
)));
}
let exists: Option<i64> = sqlx::query_scalar("SELECT 1 FROM task_lists WHERE id = ?")
.bind(&item.id)
.fetch_optional(&mut *tx)
.await
.map_err(|e| KonError::StorageError(format!("Check task_list exists failed: {e}")))?;
if exists.is_some() {
skipped += 1;
continue;
}
sqlx::query(
"INSERT INTO task_lists (id, name, built_in, profile_id) VALUES (?, ?, ?, ?)",
)
.bind(&item.id)
.bind(&item.name)
.bind(item.built_in as i64)
.bind(&item.profile_id)
.execute(&mut *tx)
.await
.map_err(|e| KonError::StorageError(format!("Insert task_list during import failed: {e}")))?;
imported += 1;
}
tx.commit()
.await
.map_err(|e| KonError::StorageError(format!("Commit transaction failed: {e}")))?;
Ok(ImportSummary { imported, skipped })
}
// --- Phase 8: daily completion counts -----------------------------------
//
// Drives the Tasks-page badge ("3 today") and the 7-day momentum
@@ -2646,4 +2893,256 @@ mod tests {
.unwrap();
assert_eq!(remaining, vec!["recent".to_string()]);
}
// --- B2a: task_lists CRUD + archive tests ---
#[tokio::test]
async fn task_list_crud_roundtrip() {
let pool = test_pool().await;
// Baseline before create — track what was already there so we
// can assert the delta. Migration v1 created the table empty
// but a future seed could change that; the relative assert
// protects against that.
let baseline = list_task_lists(&pool).await.unwrap();
let created =
create_task_list(&pool, "L1", "Work", false, None).await.unwrap();
assert_eq!(created.id, "L1");
assert_eq!(created.name, "Work");
assert!(!created.built_in);
assert!(created.profile_id.is_none());
assert!(!created.created_at.is_empty(), "DB default fills created_at");
let after_create = list_task_lists(&pool).await.unwrap();
assert_eq!(after_create.len(), baseline.len() + 1);
assert!(after_create.iter().any(|l| l.id == "L1"));
// Update name + set profile_id from None to Some("p1").
let updated =
update_task_list(&pool, "L1", Some("Work Renamed"), Some(Some("p1")))
.await
.unwrap();
assert_eq!(updated.name, "Work Renamed");
assert_eq!(updated.profile_id.as_deref(), Some("p1"));
// Update name only — profile_id should stay "p1".
let updated2 = update_task_list(&pool, "L1", Some("Work v3"), None)
.await
.unwrap();
assert_eq!(updated2.name, "Work v3");
assert_eq!(
updated2.profile_id.as_deref(),
Some("p1"),
"profile_id None means leave alone"
);
// Clear profile_id back to NULL via Some(None).
let updated3 = update_task_list(&pool, "L1", None, Some(None))
.await
.unwrap();
assert!(
updated3.profile_id.is_none(),
"profile_id Some(None) means SET NULL"
);
assert_eq!(updated3.name, "Work v3", "name None means leave alone");
delete_task_list(&pool, "L1").await.unwrap();
let after_delete = list_task_lists(&pool).await.unwrap();
assert_eq!(after_delete.len(), baseline.len());
assert!(after_delete.iter().all(|l| l.id != "L1"));
}
#[tokio::test]
async fn delete_task_list_nulls_dependent_tasks() {
let pool = test_pool().await;
create_task_list(&pool, "L1", "List 1", false, None)
.await
.unwrap();
insert_task(&pool, "t1", "Work item", "inbox", None, Some("L1"), None, None)
.await
.unwrap();
// Sanity: the task references the list.
let t = get_task_by_id(&pool, "t1").await.unwrap().unwrap();
assert_eq!(t.list_id.as_deref(), Some("L1"));
delete_task_list(&pool, "L1").await.unwrap();
let t = get_task_by_id(&pool, "t1").await.unwrap().unwrap();
assert!(t.list_id.is_none(), "task survives but list_id is nulled");
}
#[tokio::test]
async fn import_task_lists_is_idempotent() {
let pool = test_pool().await;
let items = vec![
TaskListRow {
id: "i1".into(),
name: "Imp 1".into(),
built_in: false,
profile_id: None,
created_at: String::new(),
},
TaskListRow {
id: "i2".into(),
name: "Imp 2".into(),
built_in: false,
profile_id: None,
created_at: String::new(),
},
TaskListRow {
id: "i3".into(),
name: "Imp 3".into(),
built_in: true,
profile_id: Some("pro".into()),
created_at: String::new(),
},
];
let summary = import_task_lists(&pool, &items).await.unwrap();
assert_eq!(summary.imported, 3);
assert_eq!(summary.skipped, 0);
let summary2 = import_task_lists(&pool, &items).await.unwrap();
assert_eq!(summary2.imported, 0);
assert_eq!(summary2.skipped, 3);
}
#[tokio::test]
async fn import_task_lists_atomic_on_failure() {
let pool = test_pool().await;
let baseline = list_task_lists(&pool).await.unwrap();
// Two items share the same id — must abort the whole batch.
let bad = vec![
TaskListRow {
id: "dup".into(),
name: "First".into(),
built_in: false,
profile_id: None,
created_at: String::new(),
},
TaskListRow {
id: "dup".into(),
name: "Second".into(),
built_in: false,
profile_id: None,
created_at: String::new(),
},
];
let res = import_task_lists(&pool, &bad).await;
assert!(res.is_err(), "duplicate id within batch must error");
let after = list_task_lists(&pool).await.unwrap();
assert_eq!(
after.len(),
baseline.len(),
"rollback: no rows from the failed batch should remain"
);
assert!(after.iter().all(|l| l.id != "dup"));
}
#[tokio::test]
async fn archive_task_excludes_from_list_tasks() {
let pool = test_pool().await;
insert_task(&pool, "t1", "Old item", "inbox", None, None, None, None)
.await
.unwrap();
let archived = archive_task(&pool, "t1").await.unwrap();
assert!(archived.archived);
assert!(archived.archived_at.is_some());
let live = list_tasks(&pool).await.unwrap();
assert!(
live.iter().all(|t| t.id != "t1"),
"archived row must be hidden from list_tasks"
);
let arch = list_archived_tasks(&pool).await.unwrap();
assert!(
arch.iter().any(|t| t.id == "t1"),
"archived row appears in list_archived_tasks"
);
let row = arch.iter().find(|t| t.id == "t1").unwrap();
assert!(row.archived);
assert!(row.archived_at.is_some());
}
#[tokio::test]
async fn unarchive_task_round_trip() {
let pool = test_pool().await;
insert_task(&pool, "t1", "Toggle", "inbox", None, None, None, None)
.await
.unwrap();
archive_task(&pool, "t1").await.unwrap();
let row = unarchive_task(&pool, "t1").await.unwrap();
assert!(!row.archived);
assert!(row.archived_at.is_none());
let live = list_tasks(&pool).await.unwrap();
assert!(live.iter().any(|t| t.id == "t1"), "row reappears in list_tasks");
}
#[tokio::test]
async fn archive_inbox_older_than_only_touches_inbox() {
let pool = test_pool().await;
insert_task(&pool, "old_inbox", "Old inbox", "inbox", None, None, None, None)
.await
.unwrap();
insert_task(&pool, "old_today", "Old today", "today", None, None, None, None)
.await
.unwrap();
// Backdate both rows by 10 days.
sqlx::query("UPDATE tasks SET created_at = datetime('now', '-10 days') WHERE id = ?")
.bind("old_inbox")
.execute(&pool)
.await
.unwrap();
sqlx::query("UPDATE tasks SET created_at = datetime('now', '-10 days') WHERE id = ?")
.bind("old_today")
.execute(&pool)
.await
.unwrap();
let affected = archive_inbox_older_than(&pool, 7).await.unwrap();
assert_eq!(affected, 1, "only the inbox row should be archived");
let arch = list_archived_tasks(&pool).await.unwrap();
let archived_ids: Vec<&str> = arch.iter().map(|t| t.id.as_str()).collect();
assert_eq!(archived_ids, vec!["old_inbox"]);
let live = list_tasks(&pool).await.unwrap();
assert!(
live.iter().any(|t| t.id == "old_today"),
"today row stays live: {live:?}"
);
}
#[tokio::test]
async fn archive_inbox_older_than_skips_already_archived() {
let pool = test_pool().await;
insert_task(&pool, "ix", "Inbox item", "inbox", None, None, None, None)
.await
.unwrap();
sqlx::query("UPDATE tasks SET created_at = datetime('now', '-10 days') WHERE id = ?")
.bind("ix")
.execute(&pool)
.await
.unwrap();
// Manually archive — sets archived = 1.
archive_task(&pool, "ix").await.unwrap();
let affected = archive_inbox_older_than(&pool, 7).await.unwrap();
assert_eq!(
affected, 0,
"WHERE archived = 0 must skip the already-archived row"
);
}
}

View File

@@ -7,18 +7,21 @@ pub mod migrations;
pub const DEFAULT_PROFILE_ID: &str = "00000000-0000-0000-0000-000000000001";
pub use database::{
add_profile_term, complete_subtask_and_check_parent, complete_task, count_transcripts,
create_profile, delete_implementation_rule, delete_profile, delete_profile_term, delete_task,
delete_transcript, get_implementation_rule, get_profile, get_setting, get_task_by_id,
get_transcript, init, init_readonly, insert_implementation_rule, insert_subtask, insert_task,
insert_transcript, list_feedback_examples, list_implementation_rules, list_profile_terms,
list_profiles, list_recent_completions, list_recent_errors, list_subtasks, list_tasks,
list_transcripts, list_transcripts_paged, log_error, mark_implementation_rule_fired,
prune_error_log, record_feedback, search_transcripts, set_implementation_rule_enabled,
set_setting,
set_task_energy, uncomplete_task, update_profile, update_task, update_transcript,
update_transcript_meta, DailyCompletionCount, ErrorLogRow, FeedbackRow, FeedbackTargetType,
ImplementationRuleRow, InsertTranscriptParams, ProfileRow, ProfileTermRow,
RecordFeedbackParams, TaskRow, TranscriptRow,
add_profile_term, archive_inbox_older_than, archive_task,
complete_subtask_and_check_parent, complete_task, count_transcripts,
create_profile, create_task_list, delete_implementation_rule, delete_profile,
delete_profile_term, delete_task, delete_task_list, delete_transcript,
get_implementation_rule, get_profile, get_setting, get_task_by_id, get_transcript,
import_task_lists, init, init_readonly, insert_implementation_rule, insert_subtask,
insert_task, insert_transcript, list_archived_tasks, list_feedback_examples,
list_implementation_rules, list_profile_terms, list_profiles, list_recent_completions,
list_recent_errors, list_subtasks, list_task_lists, list_tasks, list_transcripts,
list_transcripts_paged, log_error, mark_implementation_rule_fired, prune_error_log,
record_feedback, search_transcripts, set_implementation_rule_enabled, set_setting,
set_task_energy, unarchive_task, uncomplete_task, update_profile, update_task,
update_task_list, update_transcript, update_transcript_meta, DailyCompletionCount,
ErrorLogRow, FeedbackRow, FeedbackTargetType, ImplementationRuleRow, ImportSummary,
InsertTranscriptParams, ProfileRow, ProfileTermRow, RecordFeedbackParams, TaskListRow,
TaskRow, TranscriptRow,
};
pub use file_storage::{app_data_dir, crashes_dir, database_path, logs_dir, recordings_dir};