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:
@@ -592,6 +592,253 @@ pub async fn delete_task(pool: &SqlitePool, id: &str) -> Result<()> {
|
|||||||
Ok(())
|
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 -----------------------------------
|
// --- Phase 8: daily completion counts -----------------------------------
|
||||||
//
|
//
|
||||||
// Drives the Tasks-page badge ("3 today") and the 7-day momentum
|
// Drives the Tasks-page badge ("3 today") and the 7-day momentum
|
||||||
@@ -2646,4 +2893,256 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(remaining, vec!["recent".to_string()]);
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,18 +7,21 @@ pub mod migrations;
|
|||||||
pub const DEFAULT_PROFILE_ID: &str = "00000000-0000-0000-0000-000000000001";
|
pub const DEFAULT_PROFILE_ID: &str = "00000000-0000-0000-0000-000000000001";
|
||||||
|
|
||||||
pub use database::{
|
pub use database::{
|
||||||
add_profile_term, complete_subtask_and_check_parent, complete_task, count_transcripts,
|
add_profile_term, archive_inbox_older_than, archive_task,
|
||||||
create_profile, delete_implementation_rule, delete_profile, delete_profile_term, delete_task,
|
complete_subtask_and_check_parent, complete_task, count_transcripts,
|
||||||
delete_transcript, get_implementation_rule, get_profile, get_setting, get_task_by_id,
|
create_profile, create_task_list, delete_implementation_rule, delete_profile,
|
||||||
get_transcript, init, init_readonly, insert_implementation_rule, insert_subtask, insert_task,
|
delete_profile_term, delete_task, delete_task_list, delete_transcript,
|
||||||
insert_transcript, list_feedback_examples, list_implementation_rules, list_profile_terms,
|
get_implementation_rule, get_profile, get_setting, get_task_by_id, get_transcript,
|
||||||
list_profiles, list_recent_completions, list_recent_errors, list_subtasks, list_tasks,
|
import_task_lists, init, init_readonly, insert_implementation_rule, insert_subtask,
|
||||||
list_transcripts, list_transcripts_paged, log_error, mark_implementation_rule_fired,
|
insert_task, insert_transcript, list_archived_tasks, list_feedback_examples,
|
||||||
prune_error_log, record_feedback, search_transcripts, set_implementation_rule_enabled,
|
list_implementation_rules, list_profile_terms, list_profiles, list_recent_completions,
|
||||||
set_setting,
|
list_recent_errors, list_subtasks, list_task_lists, list_tasks, list_transcripts,
|
||||||
set_task_energy, uncomplete_task, update_profile, update_task, update_transcript,
|
list_transcripts_paged, log_error, mark_implementation_rule_fired, prune_error_log,
|
||||||
update_transcript_meta, DailyCompletionCount, ErrorLogRow, FeedbackRow, FeedbackTargetType,
|
record_feedback, search_transcripts, set_implementation_rule_enabled, set_setting,
|
||||||
ImplementationRuleRow, InsertTranscriptParams, ProfileRow, ProfileTermRow,
|
set_task_energy, unarchive_task, uncomplete_task, update_profile, update_task,
|
||||||
RecordFeedbackParams, TaskRow, TranscriptRow,
|
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};
|
pub use file_storage::{app_data_dir, crashes_dir, database_path, logs_dir, recordings_dir};
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ pub mod power;
|
|||||||
pub mod profiles;
|
pub mod profiles;
|
||||||
pub mod rituals;
|
pub mod rituals;
|
||||||
pub mod security;
|
pub mod security;
|
||||||
|
pub mod task_lists;
|
||||||
pub mod tasks;
|
pub mod tasks;
|
||||||
pub mod transcription;
|
pub mod transcription;
|
||||||
pub mod transcripts;
|
pub mod transcripts;
|
||||||
|
|||||||
174
src-tauri/src/commands/task_lists.rs
Normal file
174
src-tauri/src/commands/task_lists.rs
Normal file
@@ -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<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<TaskListRow> 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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct UpdateTaskListPatch {
|
||||||
|
#[serde(default)]
|
||||||
|
pub name: Option<String>,
|
||||||
|
// 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<Option<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper for the triple-Option pattern. See https://serde.rs/field-attrs.html
|
||||||
|
fn deserialize_optional_field<'de, T, D>(
|
||||||
|
deserializer: D,
|
||||||
|
) -> Result<Option<Option<T>>, D::Error>
|
||||||
|
where
|
||||||
|
T: serde::Deserialize<'de>,
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
Ok(Some(Option::<T>::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<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_at: Option<String>, // 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<ImportSummary> 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<Vec<TaskListDto>, 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<TaskListDto, String> {
|
||||||
|
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<TaskListDto, String> {
|
||||||
|
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<TaskListImport>,
|
||||||
|
) -> Result<ImportSummaryDto, String> {
|
||||||
|
let rows: Vec<TaskListRow> = 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())
|
||||||
|
}
|
||||||
@@ -8,14 +8,15 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use kon_llm::prompts::FeedbackExample as LlmFeedbackExample;
|
use kon_llm::prompts::FeedbackExample as LlmFeedbackExample;
|
||||||
use kon_storage::{
|
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,
|
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,
|
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,
|
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_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,
|
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,
|
unarchive_task as db_unarchive_task, uncomplete_task as db_uncomplete_task,
|
||||||
FeedbackRow, FeedbackTargetType, TaskRow,
|
update_task as db_update_task, DailyCompletionCount, FeedbackRow, FeedbackTargetType, TaskRow,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
@@ -36,6 +37,11 @@ pub struct TaskDto {
|
|||||||
pub source_transcript_id: Option<String>,
|
pub source_transcript_id: Option<String>,
|
||||||
pub parent_task_id: Option<String>,
|
pub parent_task_id: Option<String>,
|
||||||
pub energy: Option<String>,
|
pub energy: Option<String>,
|
||||||
|
/// 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<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<TaskRow> for TaskDto {
|
impl From<TaskRow> for TaskDto {
|
||||||
@@ -53,6 +59,8 @@ impl From<TaskRow> for TaskDto {
|
|||||||
source_transcript_id: r.source_transcript_id,
|
source_transcript_id: r.source_transcript_id,
|
||||||
parent_task_id: r.parent_task_id,
|
parent_task_id: r.parent_task_id,
|
||||||
energy: r.energy,
|
energy: r.energy,
|
||||||
|
archived: r.archived,
|
||||||
|
archived_at: r.archived_at,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -415,3 +423,51 @@ pub async fn list_recent_completions_cmd(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- B2a: archive commands ---
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_archived_tasks_cmd(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
) -> Result<Vec<TaskDto>, 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<TaskDto, String> {
|
||||||
|
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<TaskDto, String> {
|
||||||
|
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<u32, String> {
|
||||||
|
db_archive_inbox_older_than(&state.db, days as i64)
|
||||||
|
.await
|
||||||
|
.map(|n| n as u32)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|||||||
@@ -362,6 +362,17 @@ pub fn run() {
|
|||||||
commands::tasks::list_subtasks_cmd,
|
commands::tasks::list_subtasks_cmd,
|
||||||
commands::tasks::complete_subtask_cmd,
|
commands::tasks::complete_subtask_cmd,
|
||||||
commands::tasks::list_recent_completions_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)
|
// HITL feedback (Phase 2 roadmap)
|
||||||
commands::feedback::record_feedback,
|
commands::feedback::record_feedback,
|
||||||
commands::feedback::list_feedback_examples_cmd,
|
commands::feedback::list_feedback_examples_cmd,
|
||||||
|
|||||||
@@ -9,12 +9,13 @@
|
|||||||
taskLists, addTaskList, renameTaskList, deleteTaskList,
|
taskLists, addTaskList, renameTaskList, deleteTaskList,
|
||||||
settings, saveSettings,
|
settings, saveSettings,
|
||||||
} from "$lib/stores/page.svelte.js";
|
} from "$lib/stores/page.svelte.js";
|
||||||
|
import type { TaskDto } from "$lib/types/app";
|
||||||
import { recentCompletions, todayCount } from "$lib/stores/completionStats.svelte";
|
import { recentCompletions, todayCount } from "$lib/stores/completionStats.svelte";
|
||||||
import WipTaskList from '$lib/components/WipTaskList.svelte';
|
import WipTaskList from '$lib/components/WipTaskList.svelte';
|
||||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||||
import CompletionSparkline from "$lib/components/CompletionSparkline.svelte";
|
import CompletionSparkline from "$lib/components/CompletionSparkline.svelte";
|
||||||
import EnergyChip from '$lib/components/EnergyChip.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 Card from "$lib/components/Card.svelte";
|
||||||
import { formatTimestamp } from "$lib/utils/time.js";
|
import { formatTimestamp } from "$lib/utils/time.js";
|
||||||
import { BUCKET_COLORS, EFFORT_LABELS, EFFORT_ORDER } from "$lib/utils/constants.js";
|
import { BUCKET_COLORS, EFFORT_LABELS, EFFORT_ORDER } from "$lib/utils/constants.js";
|
||||||
@@ -38,6 +39,79 @@
|
|||||||
let editingInputEl = $state<HTMLInputElement | null>(null);
|
let editingInputEl = $state<HTMLInputElement | null>(null);
|
||||||
let newListInputEl = $state<HTMLInputElement | null>(null);
|
let newListInputEl = $state<HTMLInputElement | null>(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<TaskDto[]>([]);
|
||||||
|
let archivedTasksCount = $state(0);
|
||||||
|
|
||||||
|
async function refreshArchivedTasks() {
|
||||||
|
try {
|
||||||
|
const rows = await invoke<TaskDto[]>("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<TaskDto>("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 }> = [
|
const buckets: Array<{ id: "all" | TaskBucket; label: string }> = [
|
||||||
{ id: "all", label: "All" },
|
{ id: "all", label: "All" },
|
||||||
{ id: "inbox", label: "Inbox" },
|
{ id: "inbox", label: "Inbox" },
|
||||||
@@ -442,11 +516,11 @@
|
|||||||
{#each buckets as bucket}
|
{#each buckets as bucket}
|
||||||
<button
|
<button
|
||||||
class="flex items-center gap-1.5 btn-md rounded-lg
|
class="flex items-center gap-1.5 btn-md rounded-lg
|
||||||
{activeBucket === bucket.id
|
{activeBucket === bucket.id && !showArchived
|
||||||
? 'bg-nav-active text-text font-medium'
|
? 'bg-nav-active text-text font-medium'
|
||||||
: 'text-text-secondary hover:bg-hover hover:text-text'}"
|
: 'text-text-secondary hover:bg-hover hover:text-text'}"
|
||||||
style="transition-duration: var(--duration-ui)"
|
style="transition-duration: var(--duration-ui)"
|
||||||
onclick={() => activeBucket = bucket.id}
|
onclick={() => { showArchived = false; activeBucket = bucket.id; }}
|
||||||
>
|
>
|
||||||
{bucket.label}
|
{bucket.label}
|
||||||
{#if bucketCounts[bucket.id] > 0}
|
{#if bucketCounts[bucket.id] > 0}
|
||||||
@@ -456,6 +530,29 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
|
<!-- B2a: Archived pill. Hidden until at least one row is archived
|
||||||
|
so we don't add visual noise for users who never use the
|
||||||
|
feature. Toggling refreshes the list to catch other-window
|
||||||
|
archive activity. -->
|
||||||
|
{#if archivedTasksCount > 0}
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-1.5 btn-md rounded-lg
|
||||||
|
{showArchived
|
||||||
|
? 'bg-nav-active text-text font-medium'
|
||||||
|
: 'text-text-secondary hover:bg-hover hover:text-text'}"
|
||||||
|
style="transition-duration: var(--duration-ui)"
|
||||||
|
onclick={async () => {
|
||||||
|
showArchived = !showArchived;
|
||||||
|
if (showArchived) await refreshArchivedTasks();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Archive size={12} aria-hidden="true" />
|
||||||
|
Archived
|
||||||
|
<span class="text-[10px] px-1.5 py-0.5 rounded-full bg-bg-elevated text-text-tertiary">
|
||||||
|
{archivedTasksCount}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
@@ -597,6 +694,46 @@
|
|||||||
|
|
||||||
<!-- Task list -->
|
<!-- Task list -->
|
||||||
<div class="flex-1 overflow-y-auto min-h-0">
|
<div class="flex-1 overflow-y-auto min-h-0">
|
||||||
|
{#if showArchived}
|
||||||
|
<!-- B2a: Archived view. Read-only-ish list with an Unarchive
|
||||||
|
control per row. Hides the Now lane / completed sections
|
||||||
|
entirely so the surface stays focused. -->
|
||||||
|
{#if archivedTasks.length === 0}
|
||||||
|
<EmptyState
|
||||||
|
icon={Archive}
|
||||||
|
message="No archived tasks. Use the archive button on a task to hide it without losing it."
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
{#each archivedTasks as task (task.id)}
|
||||||
|
<div class="group flex items-start gap-3 p-3 rounded-xl bg-bg-card border border-border-subtle hover:border-border opacity-80 hover:opacity-100"
|
||||||
|
style="transition-duration: var(--duration-ui)">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-[13px] text-text leading-relaxed">{task.text}</p>
|
||||||
|
<div class="flex items-center gap-2 mt-1.5">
|
||||||
|
{#if task.archivedAt}
|
||||||
|
<span class="text-[10px] text-text-tertiary">Archived {formatTimestamp(task.archivedAt)}</span>
|
||||||
|
{/if}
|
||||||
|
{#if task.bucket}
|
||||||
|
<span class="text-[10px] text-text-tertiary">· {task.bucket}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
aria-label="Unarchive task"
|
||||||
|
title="Restore to its bucket"
|
||||||
|
class="mt-0.5 flex items-center gap-1 px-2 py-1 rounded text-[11px] text-text-secondary hover:text-text hover:bg-hover"
|
||||||
|
style="transition: background var(--duration-ui)"
|
||||||
|
onclick={() => unarchiveTaskRow(task.id)}
|
||||||
|
>
|
||||||
|
<ArchiveRestore size={14} aria-hidden="true" />
|
||||||
|
Unarchive
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
<!-- PR 1.2: Now lane sits above the bucket list and shares its
|
<!-- PR 1.2: Now lane sits above the bucket list and shares its
|
||||||
filtered set. The bucket list excludes whatever IDs the lane
|
filtered set. The bucket list excludes whatever IDs the lane
|
||||||
rendered, so a task is never shown in both. The lane is only
|
rendered, so a task is never shown in both. The lane is only
|
||||||
@@ -672,6 +809,19 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Row controls (PR 1.3 baseline-opacity affordances). -->
|
||||||
|
<div class="flex items-start gap-1">
|
||||||
|
<!-- B2a: per-row archive. Lower contrast than delete
|
||||||
|
because archive is the calmer, recoverable action. -->
|
||||||
|
<button
|
||||||
|
aria-label="Archive task"
|
||||||
|
title="Archive (hide from list, recoverable)"
|
||||||
|
class="mt-0.5 text-text-tertiary hover:text-text-secondary opacity-30 group-hover:opacity-100 focus-visible:opacity-100"
|
||||||
|
style="transition: opacity var(--duration-ui)"
|
||||||
|
onclick={() => archiveTaskRow(task.id)}
|
||||||
|
>
|
||||||
|
<Archive size={14} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
<!-- Delete -->
|
<!-- Delete -->
|
||||||
<button
|
<button
|
||||||
aria-label="Delete task"
|
aria-label="Delete task"
|
||||||
@@ -682,6 +832,7 @@
|
|||||||
<X size={16} aria-hidden="true" />
|
<X size={16} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -729,6 +880,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -403,6 +403,8 @@ function mapTaskRow(row: TaskDto): TaskEntry {
|
|||||||
sourceTranscriptId: row.sourceTranscriptId ?? null,
|
sourceTranscriptId: row.sourceTranscriptId ?? null,
|
||||||
parentTaskId: row.parentTaskId ?? null,
|
parentTaskId: row.parentTaskId ?? null,
|
||||||
energy: row.energy ?? null,
|
energy: row.energy ?? null,
|
||||||
|
archived: !!row.archived,
|
||||||
|
archivedAt: row.archivedAt ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,8 +428,6 @@ if (typeof window !== "undefined" && hasTauriRuntime()) {
|
|||||||
loadTasks().catch(() => {});
|
loadTasks().catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveTasks() {}
|
|
||||||
|
|
||||||
export async function addTask(task: TaskDraft) {
|
export async function addTask(task: TaskDraft) {
|
||||||
if (!hasTauriRuntime()) return;
|
if (!hasTauriRuntime()) return;
|
||||||
|
|
||||||
@@ -599,6 +599,11 @@ function broadcastTasks() {
|
|||||||
} satisfies TaskChannelMessage);
|
} 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[] {
|
function defaultTaskLists(): TaskList[] {
|
||||||
return [
|
return [
|
||||||
{ id: "all", name: "All Tasks", builtIn: true, createdAt: null },
|
{ id: "all", name: "All Tasks", builtIn: true, createdAt: null },
|
||||||
@@ -606,104 +611,245 @@ function defaultTaskLists(): TaskList[] {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadTaskLists(): TaskList[] {
|
interface TaskListDto {
|
||||||
if (!canUseStorage()) return defaultTaskLists();
|
id: string;
|
||||||
return parseStoredJson<TaskList[]>(localStorage.getItem(TASK_LISTS_KEY)) ?? defaultTaskLists();
|
name: string;
|
||||||
|
builtIn: boolean;
|
||||||
|
profileId: string | null;
|
||||||
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const taskLists = $state<TaskList[]>(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() {
|
export const taskLists = $state<TaskList[]>(defaultTaskLists());
|
||||||
if (canUseStorage()) {
|
|
||||||
|
async function loadTaskListsFromSqlite() {
|
||||||
|
if (!hasTauriRuntime()) return;
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(TASK_LISTS_KEY, JSON.stringify(taskLists));
|
const rows = await invoke<TaskListDto[]>("list_task_lists_cmd");
|
||||||
} catch {}
|
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) {
|
// One-time migration: pull the legacy `kon_task_lists` blob into SQLite
|
||||||
taskLists.push({
|
// via import_task_lists_cmd. Idempotent because the import command
|
||||||
id: crypto.randomUUID(),
|
// 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<TaskList[]>(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.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<TaskListDto>("create_task_list_cmd", {
|
||||||
|
request: {
|
||||||
|
id,
|
||||||
name,
|
name,
|
||||||
builtIn: false,
|
builtIn: false,
|
||||||
profileId,
|
profileId,
|
||||||
createdAt: new Date().toISOString(),
|
},
|
||||||
});
|
});
|
||||||
saveTaskLists();
|
taskLists.push(mapTaskListRow(row));
|
||||||
|
broadcastTaskLists();
|
||||||
|
} catch (err) {
|
||||||
|
toasts.error("Couldn't add list", errorMessage(err));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addProfileTaskList(profileName: string) {
|
export async function addProfileTaskList(profileName: string) {
|
||||||
const existing = taskLists.find((list) => list.profileId === profileName);
|
const existing = taskLists.find((list) => list.profileId === profileName);
|
||||||
if (existing) return existing.id;
|
if (existing) return existing.id;
|
||||||
|
|
||||||
|
if (!hasTauriRuntime()) return null;
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
taskLists.push({
|
try {
|
||||||
|
const row = await invoke<TaskListDto>("create_task_list_cmd", {
|
||||||
|
request: {
|
||||||
id,
|
id,
|
||||||
name: profileName,
|
name: profileName,
|
||||||
builtIn: false,
|
builtIn: false,
|
||||||
profileId: profileName,
|
profileId: profileName,
|
||||||
createdAt: new Date().toISOString(),
|
},
|
||||||
});
|
});
|
||||||
saveTaskLists();
|
taskLists.push(mapTaskListRow(row));
|
||||||
|
broadcastTaskLists();
|
||||||
return id;
|
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);
|
const idx = taskLists.findIndex((list) => list.profileId === profileName);
|
||||||
if (idx < 0) return;
|
if (idx < 0) return;
|
||||||
|
|
||||||
const listId = taskLists[idx].id;
|
const listId = taskLists[idx].id;
|
||||||
|
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) {
|
for (const task of tasks) {
|
||||||
if (task.listId === listId) task.listId = null;
|
if (task.listId === listId) task.listId = null;
|
||||||
}
|
}
|
||||||
saveTasks();
|
|
||||||
broadcastTasks();
|
broadcastTasks();
|
||||||
taskLists.splice(idx, 1);
|
taskLists.splice(idx, 1);
|
||||||
saveTaskLists();
|
broadcastTaskLists();
|
||||||
|
} catch (err) {
|
||||||
|
toasts.error("Couldn't remove profile list", errorMessage(err));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function moveTaskToList(taskId: string, listId: string) {
|
export function moveTaskToList(taskId: string, listId: string) {
|
||||||
updateTask(taskId, { listId: listId === "inbox" || listId === "all" ? null : listId });
|
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);
|
const list = taskLists.find((entry) => entry.id === listId);
|
||||||
if (list && !list.builtIn) {
|
if (!list || list.builtIn) return;
|
||||||
list.profileId = profileId || null;
|
if (!hasTauriRuntime()) return;
|
||||||
if (profileId && !list.name) list.name = profileId;
|
try {
|
||||||
saveTaskLists();
|
const row = await invoke<TaskListDto>("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);
|
const list = taskLists.find((entry) => entry.id === id);
|
||||||
if (list && !list.builtIn) {
|
if (!list || list.builtIn) return;
|
||||||
list.name = name;
|
if (!hasTauriRuntime()) return;
|
||||||
saveTaskLists();
|
try {
|
||||||
|
const row = await invoke<TaskListDto>("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);
|
const idx = taskLists.findIndex((list) => list.id === id);
|
||||||
if (idx < 0 || taskLists[idx].builtIn) return;
|
if (idx < 0 || taskLists[idx].builtIn) return;
|
||||||
|
if (!hasTauriRuntime()) return;
|
||||||
|
try {
|
||||||
|
await invoke("delete_task_list_cmd", { id });
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
if (task.listId === id) task.listId = null;
|
if (task.listId === id) task.listId = null;
|
||||||
}
|
}
|
||||||
saveTasks();
|
|
||||||
broadcastTasks();
|
broadcastTasks();
|
||||||
taskLists.splice(idx, 1);
|
taskLists.splice(idx, 1);
|
||||||
saveTaskLists();
|
broadcastTaskLists();
|
||||||
|
} catch (err) {
|
||||||
|
toasts.error("Couldn't delete list", errorMessage(err));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
export function moveTaskList(id: string, direction: number) {
|
||||||
const idx = taskLists.findIndex((list) => list.id === id);
|
const idx = taskLists.findIndex((list) => list.id === id);
|
||||||
const targetIdx = idx + direction;
|
const targetIdx = idx + direction;
|
||||||
if (idx < 0 || targetIdx < 0 || targetIdx >= taskLists.length) return;
|
if (idx < 0 || targetIdx < 0 || targetIdx >= taskLists.length) return;
|
||||||
if (taskLists[targetIdx].builtIn) return;
|
if (taskLists[targetIdx].builtIn) return;
|
||||||
[taskLists[idx], taskLists[targetIdx]] = [taskLists[targetIdx], taskLists[idx]];
|
[taskLists[idx], taskLists[targetIdx]] = [taskLists[targetIdx], taskLists[idx]];
|
||||||
saveTaskLists();
|
broadcastTaskLists();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sortTaskLists(mode: "alpha" | "date") {
|
export function sortTaskLists(mode: "alpha" | "date") {
|
||||||
@@ -720,7 +866,7 @@ export function sortTaskLists(mode: "alpha" | "date") {
|
|||||||
|
|
||||||
taskLists.length = 0;
|
taskLists.length = 0;
|
||||||
taskLists.push(...builtIn, ...custom);
|
taskLists.push(...builtIn, ...custom);
|
||||||
saveTaskLists();
|
broadcastTaskLists();
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TaskListChannelMessage {
|
interface TaskListChannelMessage {
|
||||||
|
|||||||
@@ -264,6 +264,10 @@ export interface TaskDto {
|
|||||||
sourceTranscriptId: string | null;
|
sourceTranscriptId: string | null;
|
||||||
parentTaskId: string | null;
|
parentTaskId: string | null;
|
||||||
energy: EnergyLevel | 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 {
|
export interface TaskEntry {
|
||||||
@@ -279,6 +283,8 @@ export interface TaskEntry {
|
|||||||
sourceTranscriptId: string | null;
|
sourceTranscriptId: string | null;
|
||||||
parentTaskId: string | null;
|
parentTaskId: string | null;
|
||||||
energy: EnergyLevel | null;
|
energy: EnergyLevel | null;
|
||||||
|
archived: boolean;
|
||||||
|
archivedAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ImplementationRuleAction =
|
export type ImplementationRuleAction =
|
||||||
|
|||||||
Reference in New Issue
Block a user