diff --git a/crates/storage/src/database.rs b/crates/storage/src/database.rs index 06de39f..4501934 100644 --- a/crates/storage/src/database.rs +++ b/crates/storage/src/database.rs @@ -286,7 +286,7 @@ pub async fn insert_task( 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 FROM tasks ORDER BY created_at DESC") + 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}")))?; @@ -303,10 +303,115 @@ pub async fn list_tasks(pool: &SqlitePool) -> Result> { done_at: r.get("done_at"), created_at: r.get("created_at"), source_transcript_id: r.get("source_transcript_id"), + parent_task_id: r.get("parent_task_id"), }) .collect()) } +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 = ?" + ) + .bind(id) + .fetch_optional(pool) + .await + .map_err(|e| KonError::StorageError(format!("Get task failed: {e}")))?; + + Ok(row.map(|r| TaskRow { + id: r.get("id"), + text: r.get("text"), + bucket: r.get("bucket"), + list_id: r.get("list_id"), + effort: r.get("effort"), + done: r.get("done"), + done_at: r.get("done_at"), + created_at: r.get("created_at"), + source_transcript_id: r.get("source_transcript_id"), + parent_task_id: r.get("parent_task_id"), + })) +} + +pub async fn insert_subtask( + pool: &SqlitePool, + id: &str, + text: &str, + parent_task_id: &str, +) -> Result<()> { + sqlx::query( + "INSERT INTO tasks (id, text, bucket, parent_task_id) VALUES (?, ?, 'inbox', ?)" + ) + .bind(id) + .bind(text) + .bind(parent_task_id) + .execute(pool) + .await + .map_err(|e| KonError::StorageError(format!("Insert subtask failed: {e}")))?; + Ok(()) +} + +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, \ + source_transcript_id, parent_task_id \ + FROM tasks WHERE parent_task_id = ? ORDER BY created_at ASC" + ) + .bind(parent_id) + .fetch_all(pool) + .await + .map_err(|e| KonError::StorageError(format!("List subtasks failed: {e}")))?; + + Ok(rows.into_iter().map(|r| TaskRow { + id: r.get("id"), + text: r.get("text"), + bucket: r.get("bucket"), + list_id: r.get("list_id"), + effort: r.get("effort"), + done: r.get("done"), + done_at: r.get("done_at"), + created_at: r.get("created_at"), + source_transcript_id: r.get("source_transcript_id"), + parent_task_id: r.get("parent_task_id"), + }).collect()) +} + +/// Mark a subtask done. If all siblings are now done, auto-complete the parent. +pub async fn complete_subtask_and_check_parent(pool: &SqlitePool, subtask_id: &str) -> Result<()> { + sqlx::query("UPDATE tasks SET done = 1, done_at = datetime('now') WHERE id = ?") + .bind(subtask_id) + .execute(pool) + .await + .map_err(|e| KonError::StorageError(format!("Complete subtask failed: {e}")))?; + + let parent_id: Option = sqlx::query_scalar( + "SELECT parent_task_id FROM tasks WHERE id = ?" + ) + .bind(subtask_id) + .fetch_one(pool) + .await + .map_err(|e| KonError::StorageError(format!("Get parent_task_id failed: {e}")))?; + + let Some(pid) = parent_id else { return Ok(()); }; + + let pending: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM tasks WHERE parent_task_id = ? AND done = 0" + ) + .bind(&pid) + .fetch_one(pool) + .await + .map_err(|e| KonError::StorageError(format!("Count pending subtasks failed: {e}")))?; + + if pending == 0 { + sqlx::query("UPDATE tasks SET done = 1, done_at = datetime('now') WHERE id = ?") + .bind(&pid) + .execute(pool) + .await + .map_err(|e| KonError::StorageError(format!("Auto-complete parent failed: {e}")))?; + } + + Ok(()) +} + pub async fn complete_task(pool: &SqlitePool, id: &str) -> Result<()> { sqlx::query("UPDATE tasks SET done = 1, done_at = datetime('now') WHERE id = ?") .bind(id) @@ -379,6 +484,7 @@ pub struct TaskRow { pub done_at: Option, pub created_at: String, pub source_transcript_id: Option, + pub parent_task_id: Option, } fn transcript_row_from(r: &sqlx::sqlite::SqliteRow) -> TranscriptRow { @@ -552,6 +658,33 @@ mod tests { assert!(tasks.is_empty()); } + #[tokio::test] + async fn subtask_crud_roundtrip() { + let pool = test_pool().await; + insert_task(&pool, "p1", "Write report", "inbox", None).await.unwrap(); + insert_subtask(&pool, "s1", "Open document", "p1").await.unwrap(); + insert_subtask(&pool, "s2", "Write introduction", "p1").await.unwrap(); + + // list_tasks must exclude subtasks + let top_level = list_tasks(&pool).await.unwrap(); + assert_eq!(top_level.len(), 1); + assert_eq!(top_level[0].id, "p1"); + + // list_subtasks returns both children + let subs = list_subtasks(&pool, "p1").await.unwrap(); + assert_eq!(subs.len(), 2); + + // completing s1 should NOT auto-complete parent (s2 still pending) + complete_subtask_and_check_parent(&pool, "s1").await.unwrap(); + let parent = get_task_by_id(&pool, "p1").await.unwrap().unwrap(); + assert!(!parent.done, "parent should not be done yet"); + + // completing s2 SHOULD auto-complete parent + complete_subtask_and_check_parent(&pool, "s2").await.unwrap(); + let parent = get_task_by_id(&pool, "p1").await.unwrap().unwrap(); + assert!(parent.done, "parent should auto-complete when all children done"); + } + #[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 b4ed5be..e17f398 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -3,10 +3,11 @@ pub mod file_storage; pub mod migrations; pub use database::{ - add_dictionary_entry, complete_task, count_transcripts, delete_dictionary_entry, - delete_task, delete_transcript, get_setting, get_transcript, init, insert_task, - insert_transcript, list_dictionary, list_recent_errors, list_tasks, list_transcripts, - list_transcripts_paged, log_error, search_transcripts, set_setting, update_transcript, - DictionaryEntry, ErrorLogRow, InsertTranscriptParams, TaskRow, TranscriptRow, + add_dictionary_entry, complete_subtask_and_check_parent, complete_task, count_transcripts, + delete_dictionary_entry, delete_task, delete_transcript, get_setting, get_task_by_id, + 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, }; pub use file_storage::{app_data_dir, crashes_dir, database_path, logs_dir, recordings_dir};