diff --git a/src/app/api/habits/[id]/log/route.ts b/src/app/api/habits/[id]/log/route.ts index 12f1e35..db57bdb 100644 --- a/src/app/api/habits/[id]/log/route.ts +++ b/src/app/api/habits/[id]/log/route.ts @@ -28,14 +28,12 @@ export async function POST( } const date = startOfDay(new Date(parsed.data.date)); - // Toggle: if log exists, remove it; otherwise create it - const existing = await HabitLog.findOne({ - habitId: id, - date, - }); + // Atomic toggle: findOneAndDelete returns the doc if it existed, null otherwise. + // This eliminates the race condition where two concurrent requests both see no + // existing log and both create one. + const deleted = await HabitLog.findOneAndDelete({ habitId: id, date }); - if (existing) { - await HabitLog.findByIdAndDelete(existing._id); + if (deleted) { return NextResponse.json({ toggled: false }); } diff --git a/src/hooks/use-undo-delete.ts b/src/hooks/use-undo-delete.ts new file mode 100644 index 0000000..c5935ad --- /dev/null +++ b/src/hooks/use-undo-delete.ts @@ -0,0 +1,52 @@ +import { toast } from "sonner"; + +const UNDO_DELAY_MS = 4000; + +/** + * Returns a `deleteWithUndo` function that: + * 1. Removes the item from UI immediately (optimistic) + * 2. Shows a "Deleted — Undo" toast for 4 seconds + * 3. If the user clicks Undo → restores the item, no API call made + * 4. If the timer expires → calls onConfirmDelete() to hit the server + */ +export function useUndoDelete() { + function deleteWithUndo({ + label = "Entry deleted", + onRemoveFromUI, + onRestoreToUI, + onConfirmDelete, + }: { + label?: string; + onRemoveFromUI: () => void; + onRestoreToUI: () => void; + onConfirmDelete: () => Promise; + }) { + onRemoveFromUI(); + + let undone = false; + + const timerId = setTimeout(async () => { + if (undone) return; + try { + await onConfirmDelete(); + } catch { + toast.error("Failed to delete — please try again"); + onRestoreToUI(); + } + }, UNDO_DELAY_MS); + + toast(label, { + duration: UNDO_DELAY_MS, + action: { + label: "Undo", + onClick: () => { + undone = true; + clearTimeout(timerId); + onRestoreToUI(); + }, + }, + }); + } + + return { deleteWithUndo }; +}