feat(tasks): PR 1.5 — return refreshed subtask + parent from completion
Change complete_subtask_and_check_parent to return
(TaskRow, Option<TaskRow>): the refreshed subtask, plus the parent when
the cascade auto-completed it. Both rows are read inside the same
transaction so the frontend never observes partial state.
Wrap the cmd in a CompleteSubtaskResult { updatedSubtask,
autoCompletedParent } DTO. MicroSteps now applies the refreshed subtask
locally and, when present, calls a new replaceTaskFromDto helper on the
Tasks store so the parent's done/doneAt/badge counts update without a
follow-up list_tasks_cmd round-trip.
Test: complete_subtask_returns_updated_subtask_and_optional_parent
covers both paths (sibling-pending → None, last-sibling → Some(parent)).
All 61 kon-storage tests pass; svelte-check clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -444,7 +444,15 @@ pub async fn list_subtasks(pool: &SqlitePool, parent_id: &str) -> Result<Vec<Tas
|
||||
|
||||
/// Mark a subtask done. If all siblings are now done, auto-complete the parent.
|
||||
/// Runs in a transaction so concurrent completions see consistent sibling counts.
|
||||
pub async fn complete_subtask_and_check_parent(pool: &SqlitePool, subtask_id: &str) -> Result<()> {
|
||||
///
|
||||
/// Returns the refreshed subtask row plus the refreshed parent row when the
|
||||
/// parent auto-completed in the same transaction. The caller (the Tauri cmd
|
||||
/// wrapping this) hands both to the frontend so the Tasks store can swap in
|
||||
/// new state without a follow-up `list_tasks_cmd`.
|
||||
pub async fn complete_subtask_and_check_parent(
|
||||
pool: &SqlitePool,
|
||||
subtask_id: &str,
|
||||
) -> Result<(TaskRow, Option<TaskRow>)> {
|
||||
let mut tx = pool
|
||||
.begin()
|
||||
.await
|
||||
@@ -463,6 +471,7 @@ pub async fn complete_subtask_and_check_parent(pool: &SqlitePool, subtask_id: &s
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Get parent_task_id failed: {e}")))?;
|
||||
|
||||
let mut auto_completed_parent_id: Option<String> = None;
|
||||
if let Some(pid) = parent_id {
|
||||
let pending: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM tasks WHERE parent_task_id = ? AND done = 0")
|
||||
@@ -485,14 +494,38 @@ pub async fn complete_subtask_and_check_parent(pool: &SqlitePool, subtask_id: &s
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Auto-complete parent failed: {e}")))?;
|
||||
auto_completed_parent_id = Some(pid);
|
||||
}
|
||||
}
|
||||
|
||||
let select_sql = "SELECT id, text, bucket, list_id, effort, notes, done, done_at, \
|
||||
created_at, source_transcript_id, parent_task_id, energy FROM tasks WHERE id = ?";
|
||||
|
||||
let updated_subtask = sqlx::query(select_sql)
|
||||
.bind(subtask_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map(task_row_from)
|
||||
.map_err(|e| KonError::StorageError(format!("Refetch subtask failed: {e}")))?;
|
||||
|
||||
let auto_completed_parent = if let Some(pid) = auto_completed_parent_id {
|
||||
Some(
|
||||
sqlx::query(select_sql)
|
||||
.bind(&pid)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map(task_row_from)
|
||||
.map_err(|e| KonError::StorageError(format!("Refetch parent failed: {e}")))?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Commit transaction failed: {e}")))?;
|
||||
|
||||
Ok(())
|
||||
Ok((updated_subtask, auto_completed_parent))
|
||||
}
|
||||
|
||||
pub async fn complete_task(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||
@@ -1534,6 +1567,45 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn complete_subtask_returns_updated_subtask_and_optional_parent() {
|
||||
// PR 1.5: the cmd previously returned (). The frontend needs the
|
||||
// refreshed subtask row (so its rendered `done` matches DB truth)
|
||||
// and, when sibling completion auto-completes the parent, the
|
||||
// refreshed parent row (so the Tasks store can replace it without
|
||||
// a full re-fetch). Both rows must be read inside the same
|
||||
// transaction so we never observe partial state.
|
||||
let pool = test_pool().await;
|
||||
insert_task(&pool, "p1", "Ship release", "inbox", None, None, None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
insert_subtask(&pool, "s1", "Write notes", "p1")
|
||||
.await
|
||||
.unwrap();
|
||||
insert_subtask(&pool, "s2", "Tag release", "p1")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// First completion: parent stays open because s2 is still pending.
|
||||
let (s1_row, parent_after_s1) =
|
||||
complete_subtask_and_check_parent(&pool, "s1").await.unwrap();
|
||||
assert_eq!(s1_row.id, "s1");
|
||||
assert!(s1_row.done, "returned subtask must reflect post-update done");
|
||||
assert!(
|
||||
parent_after_s1.is_none(),
|
||||
"parent must be None when siblings still pending"
|
||||
);
|
||||
|
||||
// Second completion: parent auto-completes; we must get back its row.
|
||||
let (s2_row, parent_after_s2) =
|
||||
complete_subtask_and_check_parent(&pool, "s2").await.unwrap();
|
||||
assert_eq!(s2_row.id, "s2");
|
||||
assert!(s2_row.done);
|
||||
let parent = parent_after_s2.expect("parent must auto-complete when last sibling done");
|
||||
assert_eq!(parent.id, "p1");
|
||||
assert!(parent.done, "returned parent must reflect post-update done");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn uncomplete_subtask_reopens_auto_completed_parent() {
|
||||
// Regression for the 2026-04-22 review MAJOR on
|
||||
|
||||
Reference in New Issue
Block a user