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

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

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

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

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

View File

@@ -592,6 +592,253 @@ pub async fn delete_task(pool: &SqlitePool, id: &str) -> Result<()> {
Ok(()) 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"
);
}
} }

View File

@@ -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};

View File

@@ -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;

View 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())
}

View File

@@ -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())
}

View File

@@ -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,

View File

@@ -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">&middot; {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>

View File

@@ -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 {

View File

@@ -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 =