Skip to content

Commit 270b274

Browse files
Kjubikstronkclaude
andcommitted
Hold deletes behind an undo
Deleting was the only action in the app you couldn't take back — one confirm step between a misread tap and the date being gone for good, with nothing after it. The date now disappears from view immediately but the write waits six seconds behind an undo. Deliberately held in memory rather than written as a "deleted" flag: if the tab closes mid-countdown the delete simply never happens. Losing a deletion is recoverable — press delete again — while losing the date is not. A second delete while one is still pending commits the first rather than dropping it, or the earlier date would quietly reappear. The toast sits at the top with the write alert rather than at the bottom where a toast conventionally goes: the bottom-right corner already holds the tab bar, the add button and the map's locate control, and that corner has produced two collisions already. Verified both paths — undo restores the row, and letting the window pass commits the delete. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent edfb28f commit 270b274

2 files changed

Lines changed: 97 additions & 10 deletions

File tree

‎src/lib/dates.ts‎

Lines changed: 73 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useEffect, useMemo, useState } from 'react'
1+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
22
import {
33
addDoc,
44
collection,
@@ -31,7 +31,28 @@ export function useDates() {
3131
const { user } = useAuth()
3232
const membership = useCouple()
3333
const coupleId = membership.state === 'ready' ? membership.coupleId : null
34-
const [items, setItems] = useState<DateIdea[]>(PREVIEW ? SAMPLE_DATES : [])
34+
const [all, setItems] = useState<DateIdea[]>(PREVIEW ? SAMPLE_DATES : [])
35+
36+
/**
37+
* Deleting is the only thing here you can't take back, so it doesn't happen
38+
* straight away. The date disappears from view immediately, and the actual
39+
* write is held for a few seconds behind an undo.
40+
*
41+
* Deliberately held in memory, not written as a "deleted" flag: if the tab
42+
* closes mid-countdown the delete simply never happens. Losing a deletion is
43+
* recoverable — you press delete again — while losing the date is not.
44+
*/
45+
const [pendingId, setPendingId] = useState<string | null>(null)
46+
const pendingTimer = useRef<number | null>(null)
47+
48+
const items = useMemo(
49+
() => (pendingId ? all.filter((it) => it.id !== pendingId) : all),
50+
[all, pendingId],
51+
)
52+
const pendingDelete = useMemo(
53+
() => (pendingId ? (all.find((it) => it.id === pendingId) ?? null) : null),
54+
[all, pendingId],
55+
)
3556
const [loading, setLoading] = useState(!PREVIEW)
3657
const [error, setError] = useState<string | null>(null)
3758
const [writeError, setWriteError] = useState<string | null>(null)
@@ -126,15 +147,55 @@ export function useDates() {
126147
await guard(() => updateDoc(doc(store, COLLECTION, id), patch))
127148
}, [guard])
128149

129-
const remove = useCallback(async (id: string) => {
130-
if (PREVIEW) {
131-
setItems((prev) => prev.filter((it) => it.id !== id))
132-
return
150+
/** How long you get to change your mind. */
151+
const UNDO_MS = 6000
152+
153+
/** The write itself, once the undo window has closed. */
154+
const commitDelete = useCallback(
155+
async (id: string) => {
156+
if (PREVIEW) {
157+
setItems((prev) => prev.filter((it) => it.id !== id))
158+
return
159+
}
160+
const store = db
161+
if (!store) return
162+
await guard(() => deleteDoc(doc(store, COLLECTION, id)))
163+
},
164+
[guard],
165+
)
166+
167+
const clearTimer = () => {
168+
if (pendingTimer.current !== null) {
169+
window.clearTimeout(pendingTimer.current)
170+
pendingTimer.current = null
133171
}
134-
const store = db
135-
if (!store) return
136-
await guard(() => deleteDoc(doc(store, COLLECTION, id)))
137-
}, [guard])
172+
}
173+
174+
const remove = useCallback(
175+
(id: string) => {
176+
// A second delete while one is still pending commits the first rather
177+
// than dropping it — otherwise the earlier date would silently come back.
178+
setPendingId((current) => {
179+
if (current && current !== id) void commitDelete(current)
180+
return id
181+
})
182+
clearTimer()
183+
pendingTimer.current = window.setTimeout(() => {
184+
pendingTimer.current = null
185+
setPendingId(null)
186+
void commitDelete(id)
187+
}, UNDO_MS)
188+
},
189+
[commitDelete],
190+
)
191+
192+
const undoRemove = useCallback(() => {
193+
clearTimer()
194+
setPendingId(null)
195+
}, [])
196+
197+
// Unmounting leaves the date intact, matching the tab-close behaviour above.
198+
useEffect(() => clearTimer, [])
138199

139200
/** Scheduled dates bucketed by `yyyy-MM-dd`, which is what the grid needs. */
140201
const byDay = useMemo(() => {
@@ -170,5 +231,7 @@ export function useDates() {
170231
add,
171232
update,
172233
remove,
234+
pendingDelete,
235+
undoRemove,
173236
}
174237
}

‎src/screens/Home.tsx‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ export default function Home() {
2727
add,
2828
update,
2929
remove,
30+
pendingDelete,
31+
undoRemove,
3032
} = useDates()
3133

3234
const [month, setMonth] = useState(() => startOfMonth(new Date()))
@@ -312,6 +314,28 @@ export default function Home() {
312314
<div className="relative flex min-h-0 flex-1 flex-col">
313315
{body}
314316

317+
{/* Sits at the top, with the write alert, rather than at the bottom
318+
where a toast conventionally goes: the bottom-right corner already
319+
holds the tab bar, the floating add button and the map's locate
320+
control, and that corner has caused two collisions already. */}
321+
{pendingDelete && (
322+
<div
323+
role="status"
324+
className="absolute inset-x-3 top-3 z-40 flex items-center gap-3 border-[3px] border-[var(--color-ink)] bg-[var(--color-lav)] p-3 shadow-[4px_4px_0_var(--color-ink)]"
325+
>
326+
<p className="min-w-0 flex-1 truncate text-sm">
327+
Deleted &ldquo;{pendingDelete.place?.name ?? pendingDelete.title}&rdquo;.
328+
</p>
329+
<button
330+
type="button"
331+
className="pixel-btn pixel-btn-primary legend shrink-0 px-3 py-1"
332+
onClick={undoRemove}
333+
>
334+
undo
335+
</button>
336+
</div>
337+
)}
338+
315339
{/* A refused write used to just revert with no explanation. */}
316340
{writeError && (
317341
<div

0 commit comments

Comments
 (0)