fix: harden frontend — toggle race guard, fresh debounce snapshot, error surfacing

Add toggling guard to TaskItem preventing double-toggle from rapid clicks
or swipes. Fix debounced save in TaskDetailView to read task at timeout
time instead of capturing a stale snapshot at call time. Remove unused
_unlistenFs variable. Surface reorder_task and watch_workspace errors
instead of silently swallowing them.
This commit is contained in:
Tristan Michael 2026-04-02 08:58:54 -07:00
parent 3c11539f02
commit 7fde1a09f9
3 changed files with 10 additions and 10 deletions

View file

@ -34,9 +34,8 @@
function debouncedSave(fields: Partial<Task>) { function debouncedSave(fields: Partial<Task>) {
clearTimeout(saveTimer); clearTimeout(saveTimer);
const snapshot = { ...task };
saveTimer = setTimeout(() => { saveTimer = setTimeout(() => {
app.updateTask({ ...snapshot, ...fields, updated_at: new Date().toISOString() }); app.updateTask({ ...task, ...fields, updated_at: new Date().toISOString() });
}, 400); }, 400);
} }

View file

@ -19,6 +19,7 @@
let isCompleted = $derived(task.status === "completed"); let isCompleted = $derived(task.status === "completed");
let justChecked = $state(false); let justChecked = $state(false);
let toggling = $state(false);
$effect(() => { $effect(() => {
const _ = task.status; const _ = task.status;
@ -35,6 +36,8 @@
async function handleToggle(e: MouseEvent) { async function handleToggle(e: MouseEvent) {
e.stopPropagation(); e.stopPropagation();
if (toggling) return;
toggling = true;
justChecked = true; justChecked = true;
await new Promise((r) => setTimeout(r, 300)); await new Promise((r) => setTimeout(r, 300));
transitioning = true; transitioning = true;
@ -42,6 +45,7 @@
await new Promise((r) => setTimeout(r, 200)); await new Promise((r) => setTimeout(r, 200));
justChecked = false; justChecked = false;
await app.toggleTask(task.id); await app.toggleTask(task.id);
toggling = false;
} }
function handleTouchStart(e: TouchEvent) { function handleTouchStart(e: TouchEvent) {
@ -57,14 +61,15 @@
} }
function handleTouchEnd() { function handleTouchEnd() {
if (Math.abs(swipeX) > 100) { if (Math.abs(swipeX) > 100 && !toggling) {
swipeX = 0; swipeX = 0;
swiping = false; swiping = false;
toggling = true;
justChecked = true; justChecked = true;
setTimeout(() => { setTimeout(() => {
transitioning = true; transitioning = true;
animateInIds.add(task.id); animateInIds.add(task.id);
setTimeout(() => { justChecked = false; app.toggleTask(task.id); }, 200); setTimeout(() => { justChecked = false; app.toggleTask(task.id).finally(() => { toggling = false; }); }, 200);
}, 300); }, 300);
return; return;
} }

View file

@ -9,12 +9,8 @@ import type {
} from "../types"; } from "../types";
// Listen for file system changes from the backend watcher. // Listen for file system changes from the backend watcher.
// Store the unlisten function so it can be cleaned up if needed.
let _unlistenFs: (() => void) | null = null;
listen("fs-changed", () => { listen("fs-changed", () => {
loadLists(); loadLists();
}).then((unlisten) => {
_unlistenFs = unlisten;
}); });
// ── Reactive state ─────────────────────────────────────────────────── // ── Reactive state ───────────────────────────────────────────────────
@ -98,7 +94,7 @@ async function switchWorkspace(name: string) {
activeListId = null; activeListId = null;
await loadLists(); await loadLists();
const ws = config?.workspaces[name]; const ws = config?.workspaces[name];
if (ws) invoke("watch_workspace", { path: ws.path }).catch(() => {}); if (ws) invoke("watch_workspace", { path: ws.path }).catch((e) => console.warn("File watcher failed:", e));
error = null; error = null;
} catch (e) { } catch (e) {
error = String(e); error = String(e);
@ -200,7 +196,7 @@ async function toggleTask(taskId: string) {
// Move to top of list locally, then persist order in background // Move to top of list locally, then persist order in background
if (updated.status === "backlog") { if (updated.status === "backlog") {
tasks = [updated, ...tasks.filter((t) => t.id !== taskId)]; tasks = [updated, ...tasks.filter((t) => t.id !== taskId)];
invoke("reorder_task", { listId: activeListId, taskId, newPosition: 0 }).catch(() => {}); invoke("reorder_task", { listId: activeListId, taskId, newPosition: 0 }).catch((e) => { error = String(e); });
} else { } else {
tasks = tasks.map((t) => (t.id === taskId ? updated : t)); tasks = tasks.map((t) => (t.id === taskId ? updated : t));
} }