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:
@@ -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;
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
Reference in New Issue
Block a user