diff --git a/crates/storage/src/database.rs b/crates/storage/src/database.rs index fe1bcae..4129a03 100644 --- a/crates/storage/src/database.rs +++ b/crates/storage/src/database.rs @@ -271,30 +271,43 @@ pub async fn delete_dictionary_entry(pool: &SqlitePool, id: i64) -> Result<()> { // --- Task CRUD --- +/// Insert a task. `list_id` and `effort` are nullable (schema predates their +/// UI surfacing); `notes` defaults to '' at the column level. Callers that +/// want to set metadata at creation time pass `Some(...)`; omit for defaults. pub async fn insert_task( pool: &SqlitePool, id: &str, text: &str, bucket: &str, source_transcript_id: Option<&str>, + list_id: Option<&str>, + effort: Option<&str>, ) -> Result<()> { - sqlx::query("INSERT INTO tasks (id, text, bucket, source_transcript_id) VALUES (?, ?, ?, ?)") - .bind(id) - .bind(text) - .bind(bucket) - .bind(source_transcript_id) - .execute(pool) - .await - .map_err(|e| KonError::StorageError(format!("Insert task failed: {e}")))?; + sqlx::query( + "INSERT INTO tasks (id, text, bucket, source_transcript_id, list_id, effort) \ + VALUES (?, ?, ?, ?, ?, ?)", + ) + .bind(id) + .bind(text) + .bind(bucket) + .bind(source_transcript_id) + .bind(list_id) + .bind(effort) + .execute(pool) + .await + .map_err(|e| KonError::StorageError(format!("Insert task failed: {e}")))?; Ok(()) } pub async fn list_tasks(pool: &SqlitePool) -> Result> { - let rows = - sqlx::query("SELECT id, text, bucket, list_id, effort, done, done_at, created_at, source_transcript_id, parent_task_id FROM tasks WHERE parent_task_id IS NULL ORDER BY created_at DESC") - .fetch_all(pool) - .await - .map_err(|e| KonError::StorageError(format!("List tasks failed: {e}")))?; + let rows = sqlx::query( + "SELECT id, text, bucket, list_id, effort, notes, done, done_at, created_at, \ + source_transcript_id, parent_task_id \ + FROM tasks WHERE parent_task_id IS NULL ORDER BY created_at DESC", + ) + .fetch_all(pool) + .await + .map_err(|e| KonError::StorageError(format!("List tasks failed: {e}")))?; Ok(rows .into_iter() @@ -304,8 +317,8 @@ pub async fn list_tasks(pool: &SqlitePool) -> Result> { pub async fn get_task_by_id(pool: &SqlitePool, id: &str) -> Result> { let row = sqlx::query( - "SELECT id, text, bucket, list_id, effort, done, done_at, created_at, \ - source_transcript_id, parent_task_id FROM tasks WHERE id = ?" + "SELECT id, text, bucket, list_id, effort, notes, done, done_at, created_at, \ + source_transcript_id, parent_task_id FROM tasks WHERE id = ?", ) .bind(id) .fetch_optional(pool) @@ -315,6 +328,46 @@ pub async fn get_task_by_id(pool: &SqlitePool, id: &str) -> Result, + bucket: Option<&str>, + list_id: Option<&str>, + effort: Option<&str>, + notes: Option<&str>, +) -> Result { + sqlx::query( + "UPDATE tasks SET \ + text = COALESCE(?, text), \ + bucket = COALESCE(?, bucket), \ + list_id = COALESCE(?, list_id), \ + effort = COALESCE(?, effort), \ + notes = COALESCE(?, notes) \ + WHERE id = ?", + ) + .bind(text) + .bind(bucket) + .bind(list_id) + .bind(effort) + .bind(notes) + .bind(id) + .execute(pool) + .await + .map_err(|e| KonError::StorageError(format!("Update task failed: {e}")))?; + + get_task_by_id(pool, id) + .await? + .ok_or_else(|| KonError::StorageError(format!("update_task: task {id} not found after update"))) +} + pub async fn insert_subtask( pool: &SqlitePool, id: &str, @@ -335,9 +388,9 @@ pub async fn insert_subtask( pub async fn list_subtasks(pool: &SqlitePool, parent_id: &str) -> Result> { let rows = sqlx::query( - "SELECT id, text, bucket, list_id, effort, done, done_at, created_at, \ + "SELECT id, text, bucket, list_id, effort, notes, done, done_at, created_at, \ source_transcript_id, parent_task_id \ - FROM tasks WHERE parent_task_id = ? ORDER BY created_at ASC" + FROM tasks WHERE parent_task_id = ? ORDER BY created_at ASC", ) .bind(parent_id) .fetch_all(pool) @@ -468,6 +521,7 @@ pub struct TaskRow { pub bucket: String, pub list_id: Option, pub effort: Option, + pub notes: String, pub done: bool, pub done_at: Option, pub created_at: String, @@ -503,6 +557,7 @@ fn task_row_from(r: sqlx::sqlite::SqliteRow) -> TaskRow { bucket: r.get("bucket"), list_id: r.get("list_id"), effort: r.get("effort"), + notes: r.get("notes"), done: r.get("done"), done_at: r.get("done_at"), created_at: r.get("created_at"), @@ -643,7 +698,7 @@ mod tests { async fn task_crud_roundtrip() { let pool = test_pool().await; - insert_task(&pool, "task1", "Buy groceries", "today", None) + insert_task(&pool, "task1", "Buy groceries", "today", None, None, None) .await .unwrap(); @@ -664,7 +719,7 @@ mod tests { #[tokio::test] async fn subtask_crud_roundtrip() { let pool = test_pool().await; - insert_task(&pool, "p1", "Write report", "inbox", None).await.unwrap(); + insert_task(&pool, "p1", "Write report", "inbox", None, None, None).await.unwrap(); insert_subtask(&pool, "s1", "Open document", "p1").await.unwrap(); insert_subtask(&pool, "s2", "Write introduction", "p1").await.unwrap(); @@ -688,6 +743,55 @@ mod tests { assert!(parent.done, "parent should auto-complete when all children done"); } + #[tokio::test] + async fn update_task_overwrites_provided_fields() { + // Task 2.6 — happy path: insert, update bucket + effort, read back. + let pool = test_pool().await; + insert_task(&pool, "u1", "Draft post", "inbox", None, None, None) + .await + .unwrap(); + + let row = update_task( + &pool, + "u1", + None, + Some("today"), + None, + Some("15m"), + None, + ) + .await + .unwrap(); + assert_eq!(row.bucket, "today"); + assert_eq!(row.effort.as_deref(), Some("15m")); + assert_eq!(row.text, "Draft post", "text must be unchanged"); + assert_eq!(row.notes, "", "notes default must survive"); + } + + #[tokio::test] + async fn update_task_partial_leaves_others_unchanged() { + // Task 2.6 — partial update: only notes changes; text/bucket intact. + let pool = test_pool().await; + insert_task(&pool, "u2", "Prep slides", "today", None, None, Some("30m")) + .await + .unwrap(); + + let row = update_task(&pool, "u2", None, None, None, None, Some("remember venue wifi")) + .await + .unwrap(); + assert_eq!(row.text, "Prep slides"); + assert_eq!(row.bucket, "today"); + assert_eq!(row.effort.as_deref(), Some("30m")); + assert_eq!(row.notes, "remember venue wifi"); + } + + #[tokio::test] + async fn update_task_missing_id_errors() { + let pool = test_pool().await; + let res = update_task(&pool, "missing", Some("x"), None, None, None, None).await; + assert!(res.is_err(), "update_task must error when id does not exist"); + } + #[tokio::test] async fn settings_crud_roundtrip() { let pool = test_pool().await; diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index 23b132a..d533177 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -7,7 +7,7 @@ pub use database::{ delete_dictionary_entry, delete_task, delete_transcript, get_setting, get_task_by_id, uncomplete_task, get_transcript, init, insert_subtask, insert_task, insert_transcript, list_dictionary, list_recent_errors, list_subtasks, list_tasks, list_transcripts, list_transcripts_paged, - log_error, search_transcripts, set_setting, update_transcript, DictionaryEntry, ErrorLogRow, - InsertTranscriptParams, TaskRow, TranscriptRow, + log_error, search_transcripts, set_setting, update_task, update_transcript, DictionaryEntry, + ErrorLogRow, InsertTranscriptParams, TaskRow, TranscriptRow, }; pub use file_storage::{app_data_dir, crashes_dir, database_path, logs_dir, recordings_dir}; diff --git a/crates/storage/src/migrations.rs b/crates/storage/src/migrations.rs index cf362b5..a3cfa85 100644 --- a/crates/storage/src/migrations.rs +++ b/crates/storage/src/migrations.rs @@ -112,6 +112,9 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[ ALTER TABLE tasks ADD COLUMN parent_task_id TEXT REFERENCES tasks(id) ON DELETE CASCADE; CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_task_id) "#), + (4, "tasks_meta: notes column for persisted free-text", r#" + ALTER TABLE tasks ADD COLUMN notes TEXT NOT NULL DEFAULT '' + "#), ]; /// Split SQL into individual statements, respecting BEGIN...END trigger blocks. @@ -197,6 +200,7 @@ pub async fn run_migrations(pool: &SqlitePool) -> Result<()> { mod tests { use super::*; use sqlx::sqlite::SqlitePoolOptions; + use sqlx::Row; #[tokio::test] async fn test_migrations_run_on_empty_db() { @@ -211,7 +215,7 @@ mod tests { .fetch_one(&pool) .await .unwrap(); - assert_eq!(count, 3); + assert_eq!(count, 4); sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')") .execute(&pool) @@ -233,7 +237,35 @@ mod tests { .fetch_one(&pool) .await .unwrap(); - assert_eq!(count, 3); + assert_eq!(count, 4); + } + + #[tokio::test] + async fn migration_tasks_meta_adds_columns() { + // Task 2.6 — verify list_id / effort / notes are all present on the + // tasks table after migrations run. list_id and effort have been + // present since v1 (nullable); notes is added by v4. + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("pool"); + run_migrations(&pool).await.expect("migrate"); + + let info = sqlx::query("PRAGMA table_info(tasks)") + .fetch_all(&pool) + .await + .unwrap(); + let names: Vec = info + .iter() + .map(|r| r.get::("name")) + .collect(); + for col in ["list_id", "effort", "notes"] { + assert!( + names.contains(&col.to_string()), + "tasks must have {col}; got {names:?}" + ); + } } #[tokio::test] diff --git a/src-tauri/src/commands/tasks.rs b/src-tauri/src/commands/tasks.rs index 18c170f..f6e3007 100644 --- a/src-tauri/src/commands/tasks.rs +++ b/src-tauri/src/commands/tasks.rs @@ -16,6 +16,7 @@ use kon_storage::{ list_subtasks as db_list_subtasks, list_tasks as db_list_tasks, uncomplete_task as db_uncomplete_task, + update_task as db_update_task, TaskRow, }; @@ -30,6 +31,7 @@ pub struct TaskDto { pub bucket: String, pub list_id: Option, pub effort: Option, + pub notes: String, pub done: bool, pub done_at: Option, pub created_at: String, @@ -45,6 +47,7 @@ impl From for TaskDto { bucket: r.bucket, list_id: r.list_id, effort: r.effort, + notes: r.notes, done: r.done, done_at: r.done_at, created_at: r.created_at, @@ -60,7 +63,12 @@ pub struct CreateTaskRequest { pub id: String, pub text: String, pub bucket: String, + #[serde(default)] pub source_transcript_id: Option, + #[serde(default)] + pub list_id: Option, + #[serde(default)] + pub effort: Option, } #[tauri::command] @@ -74,6 +82,8 @@ pub async fn create_task_cmd( &request.text, &request.bucket, request.source_transcript_id.as_deref(), + request.list_id.as_deref(), + request.effort.as_deref(), ) .await .map_err(|e| e.to_string())?; @@ -87,6 +97,46 @@ pub async fn create_task_cmd( .ok_or_else(|| format!("Task {} not found after insert", request.id)) } +/// Patch-shaped update. Any field omitted (or explicit `null`) leaves the +/// column untouched via `COALESCE` in the storage layer. Matches +/// `update_transcript`'s partial-update philosophy. `done` / `doneAt` are +/// intentionally absent — those flow through `complete_task_cmd` / +/// `uncomplete_task_cmd` to stamp the server-side timestamp. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateTaskRequest { + #[serde(default)] + pub text: Option, + #[serde(default)] + pub bucket: Option, + #[serde(default)] + pub list_id: Option, + #[serde(default)] + pub effort: Option, + #[serde(default)] + pub notes: Option, +} + +#[tauri::command] +pub async fn update_task_cmd( + state: tauri::State<'_, AppState>, + id: String, + patch: UpdateTaskRequest, +) -> Result { + let row = db_update_task( + &state.db, + &id, + patch.text.as_deref(), + patch.bucket.as_deref(), + patch.list_id.as_deref(), + patch.effort.as_deref(), + patch.notes.as_deref(), + ) + .await + .map_err(|e| e.to_string())?; + Ok(TaskDto::from(row)) +} + #[tauri::command] pub async fn list_tasks_cmd( state: tauri::State<'_, AppState>, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e92b319..bb62233 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -258,6 +258,7 @@ pub fn run() { // Tasks (canonical SQLite-backed task CRUD) commands::tasks::create_task_cmd, commands::tasks::list_tasks_cmd, + commands::tasks::update_task_cmd, commands::tasks::complete_task_cmd, commands::tasks::delete_task_cmd, commands::tasks::uncomplete_task_cmd, diff --git a/src/lib/stores/page.svelte.js b/src/lib/stores/page.svelte.js index bf0a8fe..d8fb972 100644 --- a/src/lib/stores/page.svelte.js +++ b/src/lib/stores/page.svelte.js @@ -244,6 +244,7 @@ function mapTaskRow(row) { bucket: row.bucket ?? "inbox", listId: row.listId ?? null, effort: row.effort ?? "", + notes: row.notes ?? "", done: !!row.done, doneAt: row.doneAt ?? null, createdAt: row.createdAt ?? new Date().toISOString(), @@ -294,6 +295,11 @@ export async function addTask(task) { text: task.text, bucket: task.bucket || "inbox", sourceTranscriptId: task.sourceTranscriptId || null, + // Task 2.6: forward list_id / effort so the SQLite row carries the + // metadata callers pass in. Callers that omit these get server + // defaults (NULL for list_id/effort, '' for notes at the column). + listId: task.listId ?? null, + effort: task.effort ?? null, }, }); tasks.unshift(mapTaskRow(row)); @@ -303,7 +309,16 @@ export async function addTask(task) { } } -export function updateTask(id, updates) { +/** + * Apply a partial update to an in-memory task and broadcast the change. + * Used by `completeTask` / `uncompleteTask` after their dedicated server + * commands succeed. Not exported — external callers go through `updateTask` + * which persists via `update_task_cmd`. + * + * @param {string} id + * @param {Record} updates + */ +function applyLocalTaskUpdate(id, updates) { const idx = tasks.findIndex((t) => t.id === id); if (idx >= 0) { Object.assign(tasks[idx], updates); @@ -311,6 +326,47 @@ export function updateTask(id, updates) { } } +/** + * Persist a patch to an existing task and refresh the in-memory copy with + * the server's canonical row. `updates` may contain any of + * `{ text, bucket, listId, effort, notes }`; omitted fields are preserved + * server-side via COALESCE. `done` / `doneAt` are ignored by the server's + * UpdateTaskRequest — use `completeTask` / `uncompleteTask` for those. + * + * Closes the Task 2.6 gap: before this, updateTask was a session-only + * mutation, so bucket / effort / listId edits vanished on restart. + * + * @param {string} id + * @param {Record} updates + */ +export async function updateTask(id, updates) { + if (!hasTauriRuntime()) { + // Browser preview: fall back to the local-only path so the UI still + // reflects edits during Vite dev runs outside the Tauri shell. + applyLocalTaskUpdate(id, updates); + return; + } + try { + const row = await invoke("update_task_cmd", { + id, + patch: { + text: updates.text ?? null, + bucket: updates.bucket ?? null, + listId: updates.listId ?? null, + effort: updates.effort ?? null, + notes: updates.notes ?? null, + }, + }); + const idx = tasks.findIndex((t) => t.id === id); + if (idx >= 0) { + tasks[idx] = mapTaskRow(row); + broadcastTasks(); + } + } catch (err) { + toasts.error("Couldn't update task", err?.message ?? String(err)); + } +} + export async function deleteTask(id) { if (!hasTauriRuntime()) return; try { @@ -329,7 +385,10 @@ export async function completeTask(id) { if (!hasTauriRuntime()) return; try { await invoke("complete_task_cmd", { id }); - updateTask(id, { done: true, doneAt: new Date().toISOString() }); + // Local-only mirror: complete_task_cmd already stamped done/done_at + // server-side, so we just mirror the state here. Going through the + // persisting `updateTask` path would be a wasted round-trip. + applyLocalTaskUpdate(id, { done: true, doneAt: new Date().toISOString() }); } catch (err) { toasts.error("Couldn't complete task", err?.message ?? String(err)); } @@ -339,7 +398,7 @@ export async function uncompleteTask(id) { if (!hasTauriRuntime()) return; try { await invoke("uncomplete_task_cmd", { id }); - updateTask(id, { done: false, doneAt: null }); + applyLocalTaskUpdate(id, { done: false, doneAt: null }); } catch (err) { toasts.error("Couldn't uncomplete task", err?.message ?? String(err)); }