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:
2026-04-26 18:17:30 +01:00
parent 0a8cb55447
commit 6e663a3625
4 changed files with 126 additions and 7 deletions

View File

@@ -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