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

View File

@@ -378,14 +378,29 @@ pub async fn list_subtasks_cmd(
.map_err(|e| e.to_string())
}
/// Return shape for `complete_subtask_cmd`. The frontend uses
/// `updated_subtask` to refresh the row in MicroSteps and, when present,
/// uses `auto_completed_parent` to swap the parent in the Tasks store
/// without a round-trip refetch (PR 1.5).
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CompleteSubtaskResult {
pub updated_subtask: TaskDto,
pub auto_completed_parent: Option<TaskDto>,
}
#[tauri::command]
pub async fn complete_subtask_cmd(
state: tauri::State<'_, AppState>,
subtask_id: String,
) -> Result<(), String> {
db_complete_subtask(&state.db, &subtask_id)
) -> Result<CompleteSubtaskResult, String> {
let (subtask_row, parent_row) = db_complete_subtask(&state.db, &subtask_id)
.await
.map_err(|e| e.to_string())
.map_err(|e| e.to_string())?;
Ok(CompleteSubtaskResult {
updated_subtask: TaskDto::from(subtask_row),
auto_completed_parent: parent_row.map(TaskDto::from),
})
}
/// Phase 8: daily completion counts for the Tasks-page badge and the

View File

@@ -2,6 +2,8 @@
import { invoke } from '@tauri-apps/api/core';
import { ListTree, Check, Timer, Loader2, ThumbsUp, ThumbsDown, Pencil } from 'lucide-svelte';
import { profilesStore } from '$lib/stores/profiles.svelte.ts';
import { replaceTaskFromDto } from '$lib/stores/page.svelte.js';
import type { TaskDto } from '$lib/types/app.ts';
import SpeakerButton from '$lib/components/SpeakerButton.svelte';
let { parentTaskId, parentTaskText = '', reduceMotion = false } = $props();
@@ -71,9 +73,24 @@
async function checkStep(subtaskId: string) {
try {
await invoke('complete_subtask_cmd', { subtaskId });
const result = await invoke<{
updatedSubtask: TaskDto;
autoCompletedParent: TaskDto | null;
}>('complete_subtask_cmd', { subtaskId });
const idx = subtasks.findIndex(s => s.id === subtaskId);
if (idx >= 0) subtasks[idx] = { ...subtasks[idx], done: true };
if (idx >= 0) {
subtasks[idx] = {
...subtasks[idx],
done: result.updatedSubtask.done,
text: result.updatedSubtask.text,
};
}
// PR 1.5: when this completion auto-completed the parent, swap the
// refreshed parent into the Tasks store so its done state, doneAt,
// and badge counts pick up immediately — no full refetch needed.
if (result.autoCompletedParent) {
replaceTaskFromDto(result.autoCompletedParent);
}
// Phase 6 nudge-bus signal. Any completed step clears the
// micro-step-idle timer for this parent task — the breakdown
// is demonstrably getting worked, no nudge needed.

View File

@@ -432,6 +432,21 @@ function applyLocalTaskUpdate(id: string, updates: Partial<TaskEntry>) {
}
}
/**
* PR 1.5: replace an in-store task with a fresh DTO from the backend
* (e.g. the auto-completed parent returned by `complete_subtask_cmd`).
* No-op if the task is not in the store — that's expected when a parent
* has already been pruned client-side. Broadcasts on success so other
* windows pick up the new state.
*/
export function replaceTaskFromDto(row: TaskDto) {
const idx = tasks.findIndex((task) => task.id === row.id);
if (idx >= 0) {
tasks[idx] = mapTaskRow(row);
broadcastTasks();
}
}
export async function updateTask(id: string, updates: TaskUpdate) {
if (!hasTauriRuntime()) {
applyLocalTaskUpdate(id, updates as Partial<TaskEntry>);