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

63
app/src/lib/store.test.ts Normal file
View File

@@ -0,0 +1,63 @@
// Store unit tests — run with `bun test src/lib`. Expo/native modules are
// mocked so the local-first mutations run in plain Bun.
import { beforeEach, describe, expect, mock, test } from "bun:test";
const storage = new Map<string, string>();
mock.module("@react-native-async-storage/async-storage", () => ({
default: {
getItem: async (k: string) => storage.get(k) ?? null,
setItem: async (k: string, v: string) => void storage.set(k, v),
},
}));
let seq = 0;
mock.module("expo-crypto", () => ({
randomUUID: () => `00000000-0000-4000-8000-${String(++seq).padStart(12, "0")}`,
}));
mock.module("./api", () => ({ api: {} }));
mock.module("./config", () => ({ getConfig: async () => ({ token: "" }) }));
const store = await import("./store");
beforeEach(async () => {
storage.clear();
await store.hydrate();
});
const find = (id: string) => store.getState().tasks.find((t) => t.id === id)!;
describe("completeTask", () => {
test("marks done and undo restores the original status in place", () => {
const a = store.createTask({ title: "a", status: "waiting", sort_order: 5 });
const undo = store.completeTask(a.id);
expect(find(a.id).status).toBe("done");
expect(find(a.id).completed_at).not.toBeNull();
undo!();
expect(find(a.id).status).toBe("waiting");
expect(find(a.id).completed_at).toBeNull();
expect(find(a.id).sort_order).toBe(5);
});
test("revertTo overrides the status undo returns to", () => {
const a = store.createTask({ title: "a", status: "next" });
store.completeTask(a.id, "someday")!();
expect(find(a.id).status).toBe("someday");
});
test("undo of a repeating task trashes the spawned occurrence", () => {
const a = store.createTask({ title: "rent", repeat: "monthly", due_date: "2026-09-01" });
const known = new Set(store.getState().tasks.map((t) => t.id));
const undo = store.completeTask(a.id)!;
const spawned = store.getState().tasks.filter((t) => !known.has(t.id));
expect(spawned).toHaveLength(1);
expect(spawned[0].status).toBe("next");
undo();
expect(find(a.id).status).toBe("next");
expect(find(spawned[0].id).status).toBe("trashed");
});
test("returns null for an already-done or unknown task", () => {
const a = store.createTask({ title: "a", status: "done" });
expect(store.completeTask(a.id)).toBeNull();
expect(store.completeTask("nope")).toBeNull();
});
});