diff --git a/src/components/board/board.tsx b/src/components/board/board.tsx index 2f30e4c..40f7b28 100644 --- a/src/components/board/board.tsx +++ b/src/components/board/board.tsx @@ -1,6 +1,7 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; import { DndContext, DragOverlay, @@ -132,11 +133,27 @@ export function Board({ const activeCard = activeId ? cards.find((c) => c.id === activeId) : null; + const router = useRouter(); const busy = activeId !== null || saving; useRefreshOnFocus(busy); + /** + * The last server snapshot we adopted. + * + * Load-bearing. Without it this effect re-runs whenever `busy` flips — which + * it does at the end of every successful drag — and calls + * `setCards(initialCards)` with the snapshot we ALREADY had, because the + * server has not re-rendered yet. The optimistic move is discarded and the + * card visibly snaps back to where it started, on every single drag, while + * the database is perfectly correct. + */ + const lastSynced = useRef(initialCards); + useEffect(() => { + // Only adopt a genuinely new snapshot, never a re-run caused by `busy`. + if (initialCards === lastSynced.current) return; + lastSynced.current = initialCards; if (!busy) setCards(initialCards); }, [initialCards, busy]); @@ -202,6 +219,11 @@ export function Board({ if (!response.ok) { console.error("[board] move failed", response.status); setCards(previous); + } else { + // Pull the new server snapshot so `initialCards` reflects the move. + // Without this the optimistic state is the only record of it, and the + // next genuine refresh — a focus event, a navigation — would revert it. + router.refresh(); } } catch (error) { console.error("[board] move failed", error); diff --git a/src/components/roadmap/timeline-view.tsx b/src/components/roadmap/timeline-view.tsx index d0904af..ccb1b00 100644 --- a/src/components/roadmap/timeline-view.tsx +++ b/src/components/roadmap/timeline-view.tsx @@ -29,6 +29,8 @@ export function TimelineView({ }) { const trackRef = useRef(null); const [dragging, setDragging] = useState(null); + /** Read by the pointer handlers; `dragging` is only for the visual state. */ + const draggingRef = useRef(null); const dated = cards.filter((card) => card.targetDate); const undated = cards.filter((card) => !card.targetDate); @@ -157,7 +159,14 @@ export function TimelineView({ onPointerDown={ canReschedule ? (event) => { + event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); + // The ref, not the state, is what the pointerup + // handler reads. State updates are asynchronous, so + // a handler comparing against `dragging` can still + // see the pre-drag value and return early — the drag + // would then never commit, with no error anywhere. + draggingRef.current = item.id; setDragging(item.id); } : undefined @@ -165,8 +174,10 @@ export function TimelineView({ onPointerUp={ canReschedule ? (event) => { - if (dragging !== item.id) return; + if (draggingRef.current !== item.id) return; + draggingRef.current = null; setDragging(null); + const date = dateAt(event.clientX); if (date && date !== card.targetDate.slice(0, 10)) { onReschedule(item.id, date); @@ -174,6 +185,17 @@ export function TimelineView({ } : undefined } + onPointerCancel={ + canReschedule + ? () => { + // A cancelled pointer must clear the drag, or the + // bar stays visually held and the next click on it + // is swallowed. + draggingRef.current = null; + setDragging(null); + } + : undefined + } className={`absolute flex items-center overflow-hidden rounded-full px-2 text-[11px] ${ overdue ? "bg-accent text-accent-fg" : "bg-brand text-brand-fg" } ${canReschedule ? "cursor-ew-resize" : ""} ${