From 0111fc9e08aa649fef8e955af3c69e57aeef8b34 Mon Sep 17 00:00:00 2001 From: Jake Date: Sun, 26 Apr 2026 19:04:17 +0100 Subject: [PATCH] =?UTF-8?q?feat(tasks):=20B2a=20part=202=20=E2=80=94=20tas?= =?UTF-8?q?k=5Flists=20CRUD=20+=20archive=20cmds=20+=20frontend=20wire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/storage/src/database.rs | 499 +++++++++++++++++++++++++++ crates/storage/src/lib.rs | 29 +- src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/task_lists.rs | 174 ++++++++++ src-tauri/src/commands/tasks.rs | 62 +++- src-tauri/src/lib.rs | 11 + src/lib/pages/TasksPage.svelte | 176 +++++++++- src/lib/stores/page.svelte.ts | 260 +++++++++++--- src/lib/types/app.ts | 6 + 9 files changed, 1133 insertions(+), 85 deletions(-) create mode 100644 src-tauri/src/commands/task_lists.rs diff --git a/crates/storage/src/database.rs b/crates/storage/src/database.rs index 1025fd6..fcdcbe0 100644 --- a/crates/storage/src/database.rs +++ b/crates/storage/src/database.rs @@ -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> { + 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 { + 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 { + 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 { + 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> { + 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 { + 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>, +) -> Result { + 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 { + 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 = 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 = 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" + ); + } } diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index 214b1d1..053931b 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -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}; diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 504134c..153d34e 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -16,6 +16,7 @@ pub mod power; pub mod profiles; pub mod rituals; pub mod security; +pub mod task_lists; pub mod tasks; pub mod transcription; pub mod transcripts; diff --git a/src-tauri/src/commands/task_lists.rs b/src-tauri/src/commands/task_lists.rs new file mode 100644 index 0000000..0831bd2 --- /dev/null +++ b/src-tauri/src/commands/task_lists.rs @@ -0,0 +1,174 @@ +// Tauri commands wrapping kon_storage task_lists CRUD. +// Pattern mirrors tasks.rs — TaskListDto is the camelCase frontend shape, +// storage functions are aliased with db_ prefix to avoid name collisions. + +use serde::{Deserialize, Serialize}; + +use kon_storage::{ + create_task_list as db_create_task_list, delete_task_list as db_delete_task_list, + import_task_lists as db_import_task_lists, list_task_lists as db_list_task_lists, + update_task_list as db_update_task_list, ImportSummary, TaskListRow, +}; + +use crate::AppState; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TaskListDto { + pub id: String, + pub name: String, + pub built_in: bool, + pub profile_id: Option, + pub created_at: String, +} + +impl From for TaskListDto { + fn from(r: TaskListRow) -> Self { + Self { + id: r.id, + name: r.name, + built_in: r.built_in, + profile_id: r.profile_id, + created_at: r.created_at, + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateTaskListRequest { + pub id: String, + pub name: String, + #[serde(default)] + pub built_in: bool, + #[serde(default)] + pub profile_id: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateTaskListPatch { + #[serde(default)] + pub name: Option, + // Triple-Option to distinguish absent from null: + // absent -> Option::None -> "leave profile_id alone" + // null -> Some(None) -> "set profile_id to NULL" + // "abc" -> Some(Some("abc")) -> "set profile_id to 'abc'" + #[serde(default, deserialize_with = "deserialize_optional_field")] + pub profile_id: Option>, +} + +// Helper for the triple-Option pattern. See https://serde.rs/field-attrs.html +fn deserialize_optional_field<'de, T, D>( + deserializer: D, +) -> Result>, D::Error> +where + T: serde::Deserialize<'de>, + D: serde::Deserializer<'de>, +{ + Ok(Some(Option::::deserialize(deserializer)?)) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TaskListImport { + pub id: String, + pub name: String, + #[serde(default)] + pub built_in: bool, + #[serde(default)] + pub profile_id: Option, + #[serde(default)] + pub created_at: Option, // accepted but ignored — DB sets datetime('now') +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ImportSummaryDto { + pub imported: u32, + pub skipped: u32, +} + +impl From for ImportSummaryDto { + fn from(s: ImportSummary) -> Self { + Self { + imported: s.imported as u32, + skipped: s.skipped as u32, + } + } +} + +#[tauri::command] +pub async fn list_task_lists_cmd( + state: tauri::State<'_, AppState>, +) -> Result, String> { + db_list_task_lists(&state.db) + .await + .map(|rows| rows.into_iter().map(TaskListDto::from).collect()) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn create_task_list_cmd( + state: tauri::State<'_, AppState>, + request: CreateTaskListRequest, +) -> Result { + db_create_task_list( + &state.db, + &request.id, + &request.name, + request.built_in, + request.profile_id.as_deref(), + ) + .await + .map(TaskListDto::from) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn update_task_list_cmd( + state: tauri::State<'_, AppState>, + id: String, + patch: UpdateTaskListPatch, +) -> Result { + let profile_id_arg = patch.profile_id.as_ref().map(|inner| inner.as_deref()); + db_update_task_list(&state.db, &id, patch.name.as_deref(), profile_id_arg) + .await + .map(TaskListDto::from) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn delete_task_list_cmd( + state: tauri::State<'_, AppState>, + id: String, +) -> Result<(), String> { + db_delete_task_list(&state.db, &id) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn import_task_lists_cmd( + state: tauri::State<'_, AppState>, + items: Vec, +) -> Result { + let rows: Vec = items + .into_iter() + .map(|i| TaskListRow { + id: i.id, + name: i.name, + built_in: i.built_in, + profile_id: i.profile_id, + // The DB column has DEFAULT datetime('now'); this field is + // overwritten by the INSERT for the live row and is only + // used to build the in-memory TaskListRow shape passed to + // import_task_lists. Use empty string as a placeholder. + created_at: i.created_at.unwrap_or_default(), + }) + .collect(); + db_import_task_lists(&state.db, &rows) + .await + .map(ImportSummaryDto::from) + .map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/commands/tasks.rs b/src-tauri/src/commands/tasks.rs index fc4cbd4..c67e8d6 100644 --- a/src-tauri/src/commands/tasks.rs +++ b/src-tauri/src/commands/tasks.rs @@ -8,14 +8,15 @@ use uuid::Uuid; use kon_llm::prompts::FeedbackExample as LlmFeedbackExample; use kon_storage::{ + archive_inbox_older_than as db_archive_inbox_older_than, archive_task as db_archive_task, complete_subtask_and_check_parent as db_complete_subtask, complete_task as db_complete_task, delete_task as db_delete_task, get_task_by_id as db_get_task, insert_subtask as db_insert_subtask, insert_task as db_insert_task, - list_feedback_examples as db_list_feedback_examples, + list_archived_tasks as db_list_archived_tasks, list_feedback_examples as db_list_feedback_examples, list_recent_completions as db_list_recent_completions, list_subtasks as db_list_subtasks, list_tasks as db_list_tasks, set_task_energy as db_set_task_energy, - uncomplete_task as db_uncomplete_task, update_task as db_update_task, DailyCompletionCount, - FeedbackRow, FeedbackTargetType, TaskRow, + unarchive_task as db_unarchive_task, uncomplete_task as db_uncomplete_task, + update_task as db_update_task, DailyCompletionCount, FeedbackRow, FeedbackTargetType, TaskRow, }; use crate::AppState; @@ -36,6 +37,11 @@ pub struct TaskDto { pub source_transcript_id: Option, pub parent_task_id: Option, pub energy: Option, + /// B2a: archive flag mirrors `tasks.archived` (migration v16). The + /// frontend uses this to render the Archived view and to filter the + /// per-row archive button into showing "Unarchive" instead. + pub archived: bool, + pub archived_at: Option, } impl From for TaskDto { @@ -53,6 +59,8 @@ impl From for TaskDto { source_transcript_id: r.source_transcript_id, parent_task_id: r.parent_task_id, energy: r.energy, + archived: r.archived, + archived_at: r.archived_at, } } } @@ -415,3 +423,51 @@ pub async fn list_recent_completions_cmd( .await .map_err(|e| e.to_string()) } + +// --- B2a: archive commands --- + +#[tauri::command] +pub async fn list_archived_tasks_cmd( + state: tauri::State<'_, AppState>, +) -> Result, String> { + db_list_archived_tasks(&state.db) + .await + .map(|rows| rows.into_iter().map(TaskDto::from).collect()) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn archive_task_cmd( + state: tauri::State<'_, AppState>, + id: String, +) -> Result { + db_archive_task(&state.db, &id) + .await + .map(TaskDto::from) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn unarchive_task_cmd( + state: tauri::State<'_, AppState>, + id: String, +) -> Result { + db_unarchive_task(&state.db, &id) + .await + .map(TaskDto::from) + .map_err(|e| e.to_string()) +} + +/// Bulk auto-archive of stale Inbox rows. Returns rows_affected so +/// the caller can show a toast like "5 old items archived". Inbox-only +/// by design — see `archive_inbox_older_than` in storage. +#[tauri::command] +pub async fn archive_old_inbox_cmd( + state: tauri::State<'_, AppState>, + days: u32, +) -> Result { + db_archive_inbox_older_than(&state.db, days as i64) + .await + .map(|n| n as u32) + .map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b7c8b31..49c69da 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -362,6 +362,17 @@ pub fn run() { commands::tasks::list_subtasks_cmd, commands::tasks::complete_subtask_cmd, commands::tasks::list_recent_completions_cmd, + // B2a — archive surface (Inbox-only auto-sweep + per-row toggle) + commands::tasks::list_archived_tasks_cmd, + commands::tasks::archive_task_cmd, + commands::tasks::unarchive_task_cmd, + commands::tasks::archive_old_inbox_cmd, + // B2a — task_lists CRUD (replaces the localStorage source of truth) + commands::task_lists::list_task_lists_cmd, + commands::task_lists::create_task_list_cmd, + commands::task_lists::update_task_list_cmd, + commands::task_lists::delete_task_list_cmd, + commands::task_lists::import_task_lists_cmd, // HITL feedback (Phase 2 roadmap) commands::feedback::record_feedback, commands::feedback::list_feedback_examples_cmd, diff --git a/src/lib/pages/TasksPage.svelte b/src/lib/pages/TasksPage.svelte index 805feb0..71894b1 100644 --- a/src/lib/pages/TasksPage.svelte +++ b/src/lib/pages/TasksPage.svelte @@ -9,12 +9,13 @@ taskLists, addTaskList, renameTaskList, deleteTaskList, settings, saveSettings, } from "$lib/stores/page.svelte.js"; + import type { TaskDto } from "$lib/types/app"; import { recentCompletions, todayCount } from "$lib/stores/completionStats.svelte"; import WipTaskList from '$lib/components/WipTaskList.svelte'; import EmptyState from '$lib/components/EmptyState.svelte'; import CompletionSparkline from "$lib/components/CompletionSparkline.svelte"; import EnergyChip from '$lib/components/EnergyChip.svelte'; - import { SquareCheck, Search, ExternalLink, ChevronLeft, ArrowUpDown, Plus, X, ChevronRight, Zap } from 'lucide-svelte'; + import { SquareCheck, Search, ExternalLink, ChevronLeft, ArrowUpDown, Plus, X, ChevronRight, Zap, Archive, ArchiveRestore } from 'lucide-svelte'; import Card from "$lib/components/Card.svelte"; import { formatTimestamp } from "$lib/utils/time.js"; import { BUCKET_COLORS, EFFORT_LABELS, EFFORT_ORDER } from "$lib/utils/constants.js"; @@ -38,6 +39,79 @@ let editingInputEl = $state(null); let newListInputEl = $state(null); + // B2a: Archived view. The pill is hidden when there are no archived + // rows. The list is fetched on demand (and refreshed each time the + // user toggles into the view) so it doesn't block the main task load. + let showArchived = $state(false); + let archivedTasks = $state([]); + let archivedTasksCount = $state(0); + + async function refreshArchivedTasks() { + try { + const rows = await invoke("list_archived_tasks_cmd"); + archivedTasks = Array.isArray(rows) ? rows : []; + archivedTasksCount = archivedTasks.length; + } catch (err) { + console.error("list_archived_tasks_cmd failed", err); + archivedTasks = []; + archivedTasksCount = 0; + } + } + + // Initial archived count so the pill knows whether to render. + $effect(() => { + refreshArchivedTasks(); + }); + + async function archiveTaskRow(id: string) { + try { + await invoke("archive_task_cmd", { id }); + // Optimistic local removal — the backend has already moved the + // row out of list_tasks, so the next loadTasks would do this + // anyway; doing it here avoids a refetch round-trip. + const idx = tasks.findIndex((t) => t.id === id); + if (idx >= 0) tasks.splice(idx, 1); + archivedTasksCount++; + } catch (err) { + console.error("archive_task_cmd failed", err); + } + } + + async function unarchiveTaskRow(id: string) { + try { + const restored = await invoke("unarchive_task_cmd", { id }); + // Drop from the archived list so the view updates without a + // round-trip; refresh the count to stay in sync. + const aidx = archivedTasks.findIndex((t) => t.id === id); + if (aidx >= 0) { + archivedTasks = archivedTasks.filter((_, i) => i !== aidx); + archivedTasksCount = archivedTasks.length; + } + // Push the restored row into the in-memory tasks store so it + // shows up in the active bucket without a full reload. The DTO + // shape matches what mapTaskRow expects in the store; we mirror + // its defaults inline since mapTaskRow isn't exported. + tasks.unshift({ + id: restored.id, + text: restored.text ?? "", + bucket: (restored.bucket ?? "inbox") as TaskBucket, + listId: restored.listId ?? null, + effort: restored.effort ?? "", + notes: restored.notes ?? "", + done: !!restored.done, + doneAt: restored.doneAt ?? null, + createdAt: restored.createdAt ?? new Date().toISOString(), + sourceTranscriptId: restored.sourceTranscriptId ?? null, + parentTaskId: restored.parentTaskId ?? null, + energy: restored.energy ?? null, + archived: false, + archivedAt: null, + }); + } catch (err) { + console.error("unarchive_task_cmd failed", err); + } + } + const buckets: Array<{ id: "all" | TaskBucket; label: string }> = [ { id: "all", label: "All" }, { id: "inbox", label: "Inbox" }, @@ -442,11 +516,11 @@ {#each buckets as bucket} {/each} + + {#if archivedTasksCount > 0} + + {/if}
@@ -597,6 +694,46 @@
+ {#if showArchived} + + {#if archivedTasks.length === 0} + + {:else} +
+ {#each archivedTasks as task (task.id)} +
+
+

{task.text}

+
+ {#if task.archivedAt} + Archived {formatTimestamp(task.archivedAt)} + {/if} + {#if task.bucket} + · {task.bucket} + {/if} +
+
+ +
+ {/each} +
+ {/if} + {:else} - + +
+ + + + +
{/each} @@ -729,6 +880,7 @@ {/if} {/if} + {/if} diff --git a/src/lib/stores/page.svelte.ts b/src/lib/stores/page.svelte.ts index d4768d0..8f066a2 100644 --- a/src/lib/stores/page.svelte.ts +++ b/src/lib/stores/page.svelte.ts @@ -403,6 +403,8 @@ function mapTaskRow(row: TaskDto): TaskEntry { sourceTranscriptId: row.sourceTranscriptId ?? null, parentTaskId: row.parentTaskId ?? null, energy: row.energy ?? null, + archived: !!row.archived, + archivedAt: row.archivedAt ?? null, }; } @@ -426,8 +428,6 @@ if (typeof window !== "undefined" && hasTauriRuntime()) { loadTasks().catch(() => {}); } -export function saveTasks() {} - export async function addTask(task: TaskDraft) { if (!hasTauriRuntime()) return; @@ -599,6 +599,11 @@ function broadcastTasks() { } satisfies TaskChannelMessage); } +// B2a: task_lists are now persisted in SQLite (migration v1 created +// the table; the actual CRUD wiring happens via the *_task_list_cmd +// handlers). The frontend keeps the two synthetic built-in entries +// (`all`, `inbox`) in-memory so the sidebar always shows them — neither +// has a corresponding DB row. function defaultTaskLists(): TaskList[] { return [ { id: "all", name: "All Tasks", builtIn: true, createdAt: null }, @@ -606,104 +611,245 @@ function defaultTaskLists(): TaskList[] { ]; } -function loadTaskLists(): TaskList[] { - if (!canUseStorage()) return defaultTaskLists(); - return parseStoredJson(localStorage.getItem(TASK_LISTS_KEY)) ?? defaultTaskLists(); +interface TaskListDto { + id: string; + name: string; + builtIn: boolean; + profileId: string | null; + createdAt: string; } -export const taskLists = $state(loadTaskLists()); +function mapTaskListRow(row: TaskListDto): TaskList { + return { + id: row.id, + name: row.name, + builtIn: !!row.builtIn, + profileId: row.profileId ?? null, + createdAt: row.createdAt ?? null, + }; +} -export function saveTaskLists() { - if (canUseStorage()) { - try { - localStorage.setItem(TASK_LISTS_KEY, JSON.stringify(taskLists)); - } catch {} +export const taskLists = $state(defaultTaskLists()); + +async function loadTaskListsFromSqlite() { + if (!hasTauriRuntime()) return; + try { + const rows = await invoke("list_task_lists_cmd"); + const mapped = Array.isArray(rows) ? rows.map(mapTaskListRow) : []; + taskLists.length = 0; + taskLists.push(...defaultTaskLists(), ...mapped); + } catch (err) { + console.error("loadTaskListsFromSqlite failed", err); + toasts.error("Failed to load task lists", errorMessage(err)); } - broadcastTaskLists(); } -export function addTaskList(name: string, profileId: string | null = null) { - taskLists.push({ - id: crypto.randomUUID(), - name, - builtIn: false, - profileId, - createdAt: new Date().toISOString(), - }); - saveTaskLists(); +// One-time migration: pull the legacy `kon_task_lists` blob into SQLite +// via import_task_lists_cmd. Idempotent because the import command +// skips ids that already exist; we still only run it when the +// localStorage key is present so we don't pay a no-op round trip on +// every launch. The localStorage key is removed only on a successful +// import so a transient backend error doesn't lose data. +async function migrateTaskListsFromLocalStorage() { + if (!canUseStorage()) return; + const raw = localStorage.getItem(TASK_LISTS_KEY); + if (!raw) return; + + const parsed = parseStoredJson(raw); + if (!parsed || parsed.length === 0) { + // Empty or corrupt — clear and skip so we don't keep retrying. + localStorage.removeItem(TASK_LISTS_KEY); + return; + } + + // Drop the synthetic built-ins (`all`, `inbox`) — they're recreated + // in-memory each launch and have no DB row, so importing them would + // collide with the next launch's defaults. + const importable = parsed.filter((l) => l.id !== "all" && l.id !== "inbox"); + if (importable.length === 0) { + localStorage.removeItem(TASK_LISTS_KEY); + return; + } + + try { + const summary = await invoke<{ imported: number; skipped: number }>( + "import_task_lists_cmd", + { + items: importable.map((l) => ({ + id: l.id, + name: l.name, + builtIn: l.builtIn, + profileId: l.profileId ?? null, + createdAt: l.createdAt ?? null, + })), + }, + ); + // Only clear localStorage AFTER the import succeeds. + localStorage.removeItem(TASK_LISTS_KEY); + // Re-load from SQLite so the store reflects the imported rows. + await loadTaskListsFromSqlite(); + toasts.info( + "Task lists migrated", + `${summary.imported} list${summary.imported === 1 ? "" : "s"} moved to local database.`, + ); + } catch (err) { + console.error("migrateTaskListsFromLocalStorage failed", err); + toasts.error( + "Couldn't migrate task lists", + "Your data is safe — Kon will retry on next launch.", + ); + } } -export function addProfileTaskList(profileName: string) { +if (typeof window !== "undefined" && hasTauriRuntime()) { + // Sequencing matters: load first so the store reflects existing DB + // state, then run the import which is idempotent vs. that state. The + // import re-loads on success so the toasted summary matches what's + // visible. + (async () => { + await loadTaskListsFromSqlite(); + await migrateTaskListsFromLocalStorage(); + })().catch(() => {}); +} + +export async function addTaskList(name: string, profileId: string | null = null) { + if (!hasTauriRuntime()) return; + const id = crypto.randomUUID(); + try { + const row = await invoke("create_task_list_cmd", { + request: { + id, + name, + builtIn: false, + profileId, + }, + }); + taskLists.push(mapTaskListRow(row)); + broadcastTaskLists(); + } catch (err) { + toasts.error("Couldn't add list", errorMessage(err)); + } +} + +export async function addProfileTaskList(profileName: string) { const existing = taskLists.find((list) => list.profileId === profileName); if (existing) return existing.id; + if (!hasTauriRuntime()) return null; const id = crypto.randomUUID(); - taskLists.push({ - id, - name: profileName, - builtIn: false, - profileId: profileName, - createdAt: new Date().toISOString(), - }); - saveTaskLists(); - return id; + try { + const row = await invoke("create_task_list_cmd", { + request: { + id, + name: profileName, + builtIn: false, + profileId: profileName, + }, + }); + taskLists.push(mapTaskListRow(row)); + broadcastTaskLists(); + return id; + } catch (err) { + toasts.error("Couldn't add profile list", errorMessage(err)); + return null; + } } -export function removeProfileTaskList(profileName: string) { +export async function removeProfileTaskList(profileName: string) { const idx = taskLists.findIndex((list) => list.profileId === profileName); if (idx < 0) return; const listId = taskLists[idx].id; - for (const task of tasks) { - if (task.listId === listId) task.listId = null; + if (!hasTauriRuntime()) return; + try { + await invoke("delete_task_list_cmd", { id: listId }); + // delete_task_list nulls dependent tasks server-side; mirror that + // locally so the UI doesn't show stale list pointers. + for (const task of tasks) { + if (task.listId === listId) task.listId = null; + } + broadcastTasks(); + taskLists.splice(idx, 1); + broadcastTaskLists(); + } catch (err) { + toasts.error("Couldn't remove profile list", errorMessage(err)); } - saveTasks(); - broadcastTasks(); - taskLists.splice(idx, 1); - saveTaskLists(); } export function moveTaskToList(taskId: string, listId: string) { updateTask(taskId, { listId: listId === "inbox" || listId === "all" ? null : listId }); } -export function moveTaskListToProfile(listId: string, profileId: string | null) { +export async function moveTaskListToProfile(listId: string, profileId: string | null) { const list = taskLists.find((entry) => entry.id === listId); - if (list && !list.builtIn) { - list.profileId = profileId || null; - if (profileId && !list.name) list.name = profileId; - saveTaskLists(); + if (!list || list.builtIn) return; + if (!hasTauriRuntime()) return; + try { + const row = await invoke("update_task_list_cmd", { + id: listId, + patch: { + // Only patch profile_id; leave name alone. + profileId: profileId || null, + }, + }); + const idx = taskLists.findIndex((l) => l.id === listId); + if (idx >= 0) { + taskLists[idx] = mapTaskListRow(row); + broadcastTaskLists(); + } + } catch (err) { + toasts.error("Couldn't move list", errorMessage(err)); } } -export function renameTaskList(id: string, name: string) { +export async function renameTaskList(id: string, name: string) { const list = taskLists.find((entry) => entry.id === id); - if (list && !list.builtIn) { - list.name = name; - saveTaskLists(); + if (!list || list.builtIn) return; + if (!hasTauriRuntime()) return; + try { + const row = await invoke("update_task_list_cmd", { + id, + patch: { name }, + }); + const idx = taskLists.findIndex((l) => l.id === id); + if (idx >= 0) { + taskLists[idx] = mapTaskListRow(row); + broadcastTaskLists(); + } + } catch (err) { + toasts.error("Couldn't rename list", errorMessage(err)); } } -export function deleteTaskList(id: string) { +export async function deleteTaskList(id: string) { const idx = taskLists.findIndex((list) => list.id === id); if (idx < 0 || taskLists[idx].builtIn) return; - - for (const task of tasks) { - if (task.listId === id) task.listId = null; + if (!hasTauriRuntime()) return; + try { + await invoke("delete_task_list_cmd", { id }); + for (const task of tasks) { + if (task.listId === id) task.listId = null; + } + broadcastTasks(); + taskLists.splice(idx, 1); + broadcastTaskLists(); + } catch (err) { + toasts.error("Couldn't delete list", errorMessage(err)); } - saveTasks(); - broadcastTasks(); - taskLists.splice(idx, 1); - saveTaskLists(); } +// NOTE: `moveTaskList` (manual reorder) and `sortTaskLists` mutate +// the in-memory array only. Persisting display order requires a new +// `display_order` column on `task_lists`; until then, the order survives +// only for the current session. This is a known, scoped limitation +// called out in B2a part 2. export function moveTaskList(id: string, direction: number) { const idx = taskLists.findIndex((list) => list.id === id); const targetIdx = idx + direction; if (idx < 0 || targetIdx < 0 || targetIdx >= taskLists.length) return; if (taskLists[targetIdx].builtIn) return; [taskLists[idx], taskLists[targetIdx]] = [taskLists[targetIdx], taskLists[idx]]; - saveTaskLists(); + broadcastTaskLists(); } export function sortTaskLists(mode: "alpha" | "date") { @@ -720,7 +866,7 @@ export function sortTaskLists(mode: "alpha" | "date") { taskLists.length = 0; taskLists.push(...builtIn, ...custom); - saveTaskLists(); + broadcastTaskLists(); } interface TaskListChannelMessage { diff --git a/src/lib/types/app.ts b/src/lib/types/app.ts index 8fb2720..224171b 100644 --- a/src/lib/types/app.ts +++ b/src/lib/types/app.ts @@ -264,6 +264,10 @@ export interface TaskDto { sourceTranscriptId: string | null; parentTaskId: string | null; energy: EnergyLevel | null; + // B2a: archive flag (migration v16). Tasks with archived = true are + // hidden from list_tasks_cmd and surfaced through list_archived_tasks_cmd. + archived: boolean; + archivedAt: string | null; } export interface TaskEntry { @@ -279,6 +283,8 @@ export interface TaskEntry { sourceTranscriptId: string | null; parentTaskId: string | null; energy: EnergyLevel | null; + archived: boolean; + archivedAt: string | null; } export type ImplementationRuleAction =