feat(storage): extend task layer for subtasks — get_task_by_id, insert_subtask, list_subtasks, complete_subtask_and_check_parent

This commit is contained in:
2026-04-19 10:34:59 +01:00
parent fa20cb313a
commit 35efed53e5
2 changed files with 140 additions and 6 deletions

View File

@@ -286,7 +286,7 @@ pub async fn insert_task(
pub async fn list_tasks(pool: &SqlitePool) -> Result<Vec<TaskRow>> { pub async fn list_tasks(pool: &SqlitePool) -> Result<Vec<TaskRow>> {
let rows = 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) .fetch_all(pool)
.await .await
.map_err(|e| KonError::StorageError(format!("List tasks failed: {e}")))?; .map_err(|e| KonError::StorageError(format!("List tasks failed: {e}")))?;
@@ -303,10 +303,115 @@ pub async fn list_tasks(pool: &SqlitePool) -> Result<Vec<TaskRow>> {
done_at: r.get("done_at"), done_at: r.get("done_at"),
created_at: r.get("created_at"), created_at: r.get("created_at"),
source_transcript_id: r.get("source_transcript_id"), source_transcript_id: r.get("source_transcript_id"),
parent_task_id: r.get("parent_task_id"),
}) })
.collect()) .collect())
} }
pub async fn get_task_by_id(pool: &SqlitePool, id: &str) -> Result<Option<TaskRow>> {
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<Vec<TaskRow>> {
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<String> = 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<()> { pub async fn complete_task(pool: &SqlitePool, id: &str) -> Result<()> {
sqlx::query("UPDATE tasks SET done = 1, done_at = datetime('now') WHERE id = ?") sqlx::query("UPDATE tasks SET done = 1, done_at = datetime('now') WHERE id = ?")
.bind(id) .bind(id)
@@ -379,6 +484,7 @@ pub struct TaskRow {
pub done_at: Option<String>, pub done_at: Option<String>,
pub created_at: String, pub created_at: String,
pub source_transcript_id: Option<String>, pub source_transcript_id: Option<String>,
pub parent_task_id: Option<String>,
} }
fn transcript_row_from(r: &sqlx::sqlite::SqliteRow) -> TranscriptRow { fn transcript_row_from(r: &sqlx::sqlite::SqliteRow) -> TranscriptRow {
@@ -552,6 +658,33 @@ mod tests {
assert!(tasks.is_empty()); 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] #[tokio::test]
async fn settings_crud_roundtrip() { async fn settings_crud_roundtrip() {
let pool = test_pool().await; let pool = test_pool().await;

View File

@@ -3,10 +3,11 @@ pub mod file_storage;
pub mod migrations; pub mod migrations;
pub use database::{ pub use database::{
add_dictionary_entry, complete_task, count_transcripts, delete_dictionary_entry, add_dictionary_entry, complete_subtask_and_check_parent, complete_task, count_transcripts,
delete_task, delete_transcript, get_setting, get_transcript, init, insert_task, delete_dictionary_entry, delete_task, delete_transcript, get_setting, get_task_by_id,
insert_transcript, list_dictionary, list_recent_errors, list_tasks, list_transcripts, get_transcript, init, insert_subtask, insert_task, insert_transcript, list_dictionary,
list_transcripts_paged, log_error, search_transcripts, set_setting, update_transcript, list_recent_errors, list_subtasks, list_tasks, list_transcripts, list_transcripts_paged,
DictionaryEntry, ErrorLogRow, InsertTranscriptParams, TaskRow, TranscriptRow, 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}; pub use file_storage::{app_data_dir, crashes_dir, database_path, logs_dir, recordings_dir};