feat(task): undo toast for completing — mis-taps are one tap to take back

completeTask returns an undo closure that restores status + sort slot and
trashes the occurrence a repeat spawned. One global snackbar (5s,
replace-on-new) mounted in the root layout; every checkbox and Clarify's
save-as-done go through completeWithUndo. bun tests for the store.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YE5FmDZmFnWxH6RXCZLcjj
This commit is contained in:
marcuspaico
2026-09-02 18:07:00 +10:00
parent dc49854072
commit 125fb7df35
10 changed files with 240 additions and 21 deletions

View File

@@ -136,6 +136,11 @@ export function createTask(data: Partial<Task> & { title: string }): Task {
}
export function updateTask(id: string, patch: Partial<Task>) {
applyTaskUpdate(id, patch);
}
/** Applies a patch; returns the next occurrence if completing spawned one. */
function applyTaskUpdate(id: string, patch: Partial<Task>): Task | null {
const before = state.tasks.find((t) => t.id === id);
setState({
tasks: state.tasks.map((t) => {
@@ -152,11 +157,28 @@ export function updateTask(id: string, patch: Partial<Task>) {
// done row later leaves the spawn in place — it's one tap to trash, versus
// silently deleting a row the user may have edited.
if (before && before.repeat && before.status !== "done" && patch.status === "done") {
spawnNextOccurrence(before);
return spawnNextOccurrence(before);
}
return null;
}
function spawnNextOccurrence(t: Task) {
/**
* Complete a task and hand back an undo. Undo puts the task back where it was
* (same status and sort slot) and trashes the occurrence a repeat spawned, so
* a mis-tap leaves no trace. Returns null when there's nothing to complete.
*/
export function completeTask(id: string, revertTo?: Task["status"]): (() => void) | null {
const before = state.tasks.find((t) => t.id === id);
if (!before || before.status === "done") return null;
const spawned = applyTaskUpdate(id, { status: "done" });
const back = revertTo ?? before.status;
return () => {
updateTask(id, { status: back });
if (spawned) trashTask(spawned.id);
};
}
function spawnNextOccurrence(t: Task): Task {
const today = todayIso();
const due = t.due_date ? nextOccurrence(t.due_date, t.repeat!, today) : null;
// Keep the same lead time between defer and due (e.g. "surfaces 3 days before").
@@ -168,7 +190,7 @@ function spawnNextOccurrence(t: Task) {
: t.defer_date
? nextOccurrence(t.defer_date, t.repeat!, today)
: null;
createTask({
return createTask({
title: t.title,
notes: t.notes,
status: t.status,