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