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.
|
/// 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.
|
/// 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
|
let mut tx = pool
|
||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
@@ -463,6 +471,7 @@ pub async fn complete_subtask_and_check_parent(pool: &SqlitePool, subtask_id: &s
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| KonError::StorageError(format!("Get parent_task_id failed: {e}")))?;
|
.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 {
|
if let Some(pid) = parent_id {
|
||||||
let pending: i64 =
|
let pending: i64 =
|
||||||
sqlx::query_scalar("SELECT COUNT(*) FROM tasks WHERE parent_task_id = ? AND done = 0")
|
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)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| KonError::StorageError(format!("Auto-complete parent failed: {e}")))?;
|
.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()
|
tx.commit()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| KonError::StorageError(format!("Commit transaction failed: {e}")))?;
|
.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<()> {
|
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]
|
#[tokio::test]
|
||||||
async fn uncomplete_subtask_reopens_auto_completed_parent() {
|
async fn uncomplete_subtask_reopens_auto_completed_parent() {
|
||||||
// Regression for the 2026-04-22 review MAJOR on
|
// 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())
|
.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]
|
#[tauri::command]
|
||||||
pub async fn complete_subtask_cmd(
|
pub async fn complete_subtask_cmd(
|
||||||
state: tauri::State<'_, AppState>,
|
state: tauri::State<'_, AppState>,
|
||||||
subtask_id: String,
|
subtask_id: String,
|
||||||
) -> Result<(), String> {
|
) -> Result<CompleteSubtaskResult, String> {
|
||||||
db_complete_subtask(&state.db, &subtask_id)
|
let (subtask_row, parent_row) = db_complete_subtask(&state.db, &subtask_id)
|
||||||
.await
|
.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
|
/// Phase 8: daily completion counts for the Tasks-page badge and the
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { ListTree, Check, Timer, Loader2, ThumbsUp, ThumbsDown, Pencil } from 'lucide-svelte';
|
import { ListTree, Check, Timer, Loader2, ThumbsUp, ThumbsDown, Pencil } from 'lucide-svelte';
|
||||||
import { profilesStore } from '$lib/stores/profiles.svelte.ts';
|
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';
|
import SpeakerButton from '$lib/components/SpeakerButton.svelte';
|
||||||
|
|
||||||
let { parentTaskId, parentTaskText = '', reduceMotion = false } = $props();
|
let { parentTaskId, parentTaskText = '', reduceMotion = false } = $props();
|
||||||
@@ -71,9 +73,24 @@
|
|||||||
|
|
||||||
async function checkStep(subtaskId: string) {
|
async function checkStep(subtaskId: string) {
|
||||||
try {
|
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);
|
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
|
// Phase 6 nudge-bus signal. Any completed step clears the
|
||||||
// micro-step-idle timer for this parent task — the breakdown
|
// micro-step-idle timer for this parent task — the breakdown
|
||||||
// is demonstrably getting worked, no nudge needed.
|
// 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) {
|
export async function updateTask(id: string, updates: TaskUpdate) {
|
||||||
if (!hasTauriRuntime()) {
|
if (!hasTauriRuntime()) {
|
||||||
applyLocalTaskUpdate(id, updates as Partial<TaskEntry>);
|
applyLocalTaskUpdate(id, updates as Partial<TaskEntry>);
|
||||||
|
|||||||
Reference in New Issue
Block a user