From 6e663a3625ee7e6226cd8c8bb7d1b64b25444130 Mon Sep 17 00:00:00 2001 From: Jake Date: Sun, 26 Apr 2026 18:17:30 +0100 Subject: [PATCH] =?UTF-8?q?feat(tasks):=20PR=201.5=20=E2=80=94=20return=20?= =?UTF-8?q?refreshed=20subtask=20+=20parent=20from=20completion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change complete_subtask_and_check_parent to return (TaskRow, Option): 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) --- crates/storage/src/database.rs | 76 +++++++++++++++++++++++++++- src-tauri/src/commands/tasks.rs | 21 ++++++-- src/lib/components/MicroSteps.svelte | 21 +++++++- src/lib/stores/page.svelte.ts | 15 ++++++ 4 files changed, 126 insertions(+), 7 deletions(-) diff --git a/crates/storage/src/database.rs b/crates/storage/src/database.rs index 85e827a..1c0ac18 100644 --- a/crates/storage/src/database.rs +++ b/crates/storage/src/database.rs @@ -444,7 +444,15 @@ pub async fn list_subtasks(pool: &SqlitePool, parent_id: &str) -> Result 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)> { 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 = 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 diff --git a/src-tauri/src/commands/tasks.rs b/src-tauri/src/commands/tasks.rs index 00f760c..fc4cbd4 100644 --- a/src-tauri/src/commands/tasks.rs +++ b/src-tauri/src/commands/tasks.rs @@ -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, +} + #[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 { + 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 diff --git a/src/lib/components/MicroSteps.svelte b/src/lib/components/MicroSteps.svelte index f2a2f69..3efb046 100644 --- a/src/lib/components/MicroSteps.svelte +++ b/src/lib/components/MicroSteps.svelte @@ -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. diff --git a/src/lib/stores/page.svelte.ts b/src/lib/stores/page.svelte.ts index f495d24..202b6c1 100644 --- a/src/lib/stores/page.svelte.ts +++ b/src/lib/stores/page.svelte.ts @@ -432,6 +432,21 @@ function applyLocalTaskUpdate(id: string, updates: Partial) { } } +/** + * 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);