diff --git a/apps/web/core/issues/mutations.ts b/apps/web/core/issues/mutations.ts index 18414049..ff35facc 100644 --- a/apps/web/core/issues/mutations.ts +++ b/apps/web/core/issues/mutations.ts @@ -36,12 +36,10 @@ export function useUpdateIssue() { mutationFn: ({ id, ...data }: { id: string } & UpdateIssueRequest) => api.updateIssue(id, data), onMutate: ({ id, ...data }) => { - // Fire-and-forget: don't await — keeps onMutate synchronous so the + // Fire-and-forget cancelQueries — keeps onMutate synchronous so the // cache update happens in the same tick as mutate(). Awaiting would // yield to the event loop, letting @dnd-kit reset its visual state - // before the optimistic update lands → card flickers back briefly. - // Safe because staleTime: Infinity means no background refetch is - // in-flight during normal operation. + // before the optimistic update lands. qc.cancelQueries({ queryKey: issueKeys.list(wsId) }); const prevList = qc.getQueryData(issueKeys.list(wsId)); const prevDetail = qc.getQueryData(issueKeys.detail(wsId, id)); @@ -68,6 +66,7 @@ export function useUpdateIssue() { }, onSettled: (_data, _err, vars) => { qc.invalidateQueries({ queryKey: issueKeys.detail(wsId, vars.id) }); + qc.invalidateQueries({ queryKey: issueKeys.list(wsId) }); }, }); } diff --git a/apps/web/features/issues/components/board-card.tsx b/apps/web/features/issues/components/board-card.tsx index c2dae07e..7ffce0bf 100644 --- a/apps/web/features/issues/components/board-card.tsx +++ b/apps/web/features/issues/components/board-card.tsx @@ -2,7 +2,8 @@ import { useCallback, memo } from "react"; import Link from "next/link"; -import { useSortable } from "@dnd-kit/sortable"; +import { useSortable, defaultAnimateLayoutChanges } from "@dnd-kit/sortable"; +import type { AnimateLayoutChanges } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { toast } from "sonner"; import type { Issue, UpdateIssueRequest } from "@/shared/types"; @@ -166,6 +167,12 @@ export const BoardCardContent = memo(function BoardCardContent({ ); }); +const animateLayoutChanges: AnimateLayoutChanges = (args) => { + const { isSorting, wasDragging } = args; + if (isSorting || wasDragging) return false; + return defaultAnimateLayoutChanges(args); +}; + export const DraggableBoardCard = memo(function DraggableBoardCard({ issue }: { issue: Issue }) { const { attributes, @@ -177,6 +184,7 @@ export const DraggableBoardCard = memo(function DraggableBoardCard({ issue }: { } = useSortable({ id: issue.id, data: { status: issue.status }, + animateLayoutChanges, }); const style = { diff --git a/apps/web/features/issues/components/board-column.tsx b/apps/web/features/issues/components/board-column.tsx index 66d2808a..11bae0a5 100644 --- a/apps/web/features/issues/components/board-column.tsx +++ b/apps/web/features/issues/components/board-column.tsx @@ -15,32 +15,31 @@ import { } from "@/components/ui/dropdown-menu"; import { STATUS_CONFIG } from "@/features/issues/config"; import { useModalStore } from "@/features/modals"; -import { useViewStore, useViewStoreApi } from "@/features/issues/stores/view-store-context"; -import { sortIssues } from "@/features/issues/utils/sort"; +import { useViewStoreApi } from "@/features/issues/stores/view-store-context"; import { StatusIcon } from "./status-icon"; import { DraggableBoardCard } from "./board-card"; export function BoardColumn({ status, - issues, + issueIds, + issueMap, }: { status: IssueStatus; - issues: Issue[]; + issueIds: string[]; + issueMap: Map; }) { const cfg = STATUS_CONFIG[status]; const { setNodeRef, isOver } = useDroppable({ id: status }); const viewStoreApi = useViewStoreApi(); - const sortBy = useViewStore((s) => s.sortBy); - const sortDirection = useViewStore((s) => s.sortDirection); - const sortedIssues = useMemo( - () => sortIssues(issues, sortBy, sortDirection), - [issues, sortBy, sortDirection] - ); - - const sortedIds = useMemo( - () => sortedIssues.map((i) => i.id), - [sortedIssues] + // Resolve IDs to Issue objects, preserving parent-provided order + const resolvedIssues = useMemo( + () => + issueIds.flatMap((id) => { + const issue = issueMap.get(id); + return issue ? [issue] : []; + }), + [issueIds, issueMap], ); return ( @@ -53,7 +52,7 @@ export function BoardColumn({ {cfg.label} - {issues.length} + {issueIds.length} @@ -97,12 +96,12 @@ export function BoardColumn({ isOver ? "bg-accent/60" : "" }`} > - - {sortedIssues.map((issue) => ( + + {resolvedIssues.map((issue) => ( ))} - {issues.length === 0 && ( + {issueIds.length === 0 && (

No issues

diff --git a/apps/web/features/issues/components/board-view.tsx b/apps/web/features/issues/components/board-view.tsx index a8038ed6..c6d42f1d 100644 --- a/apps/web/features/issues/components/board-view.tsx +++ b/apps/web/features/issues/components/board-view.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useCallback, useMemo } from "react"; +import { useState, useCallback, useMemo, useEffect, useRef } from "react"; import { DndContext, DragOverlay, @@ -12,7 +12,9 @@ import { type CollisionDetection, type DragStartEvent, type DragEndEvent, + type DragOverEvent, } from "@dnd-kit/core"; +import { arrayMove } from "@dnd-kit/sortable"; import { Eye, MoreHorizontal } from "lucide-react"; import type { Issue, IssueStatus } from "@/shared/types"; import { Button } from "@/components/ui/button"; @@ -23,7 +25,9 @@ import { DropdownMenuItem, } from "@/components/ui/dropdown-menu"; import { ALL_STATUSES, STATUS_CONFIG } from "@/features/issues/config"; -import { useViewStoreApi } from "@/features/issues/stores/view-store-context"; +import { useViewStoreApi, useViewStore } from "@/features/issues/stores/view-store-context"; +import type { SortField, SortDirection } from "@/features/issues/stores/view-store"; +import { sortIssues } from "@/features/issues/utils/sort"; import { StatusIcon } from "./status-icon"; import { BoardColumn } from "./board-column"; import { BoardCardContent } from "./board-card"; @@ -44,13 +48,47 @@ const kanbanCollision: CollisionDetection = (args) => { return closestCenter(args); }; -/** Compute a float position to place an item at `targetIndex` within `siblings`. */ -function computePosition(siblings: Issue[], targetIndex: number): number { - if (siblings.length === 0) return 0; - if (targetIndex <= 0) return siblings[0]!.position - 1; - if (targetIndex >= siblings.length) - return siblings[siblings.length - 1]!.position + 1; - return (siblings[targetIndex - 1]!.position + siblings[targetIndex]!.position) / 2; +/** Build column ID arrays from TQ issue data, respecting current sort. */ +function buildColumns( + issues: Issue[], + visibleStatuses: IssueStatus[], + sortBy: SortField, + sortDirection: SortDirection, +): Record { + const cols = {} as Record; + for (const status of visibleStatuses) { + const sorted = sortIssues( + issues.filter((i) => i.status === status), + sortBy, + sortDirection, + ); + cols[status] = sorted.map((i) => i.id); + } + return cols; +} + +/** Compute a float position for `activeId` based on its neighbors in `ids`. */ +function computePosition(ids: string[], activeId: string, issueMap: Map): number { + const idx = ids.indexOf(activeId); + if (idx === -1) return 0; + const getPos = (id: string) => issueMap.get(id)?.position ?? 0; + if (ids.length === 1) return issueMap.get(activeId)?.position ?? 0; + if (idx === 0) return getPos(ids[1]!) - 1; + if (idx === ids.length - 1) return getPos(ids[idx - 1]!) + 1; + return (getPos(ids[idx - 1]!) + getPos(ids[idx + 1]!)) / 2; +} + +/** Find which column (status) contains a given ID (issue or column droppable). */ +function findColumn( + columns: Record, + id: string, + visibleStatuses: IssueStatus[], +): IssueStatus | null { + if (visibleStatuses.includes(id as IssueStatus)) return id as IssueStatus; + for (const [status, ids] of Object.entries(columns)) { + if (ids.includes(id)) return status as IssueStatus; + } + return null; } export function BoardView({ @@ -70,7 +108,52 @@ export function BoardView({ newPosition?: number ) => void; }) { + const sortBy = useViewStore((s) => s.sortBy); + const sortDirection = useViewStore((s) => s.sortDirection); + + // --- Drag state --- const [activeIssue, setActiveIssue] = useState(null); + const isDraggingRef = useRef(false); + + // --- Local columns state --- + // Between drags: follows TQ via useEffect. + // During drag: local-only, driven by onDragOver/onDragEnd. + const [columns, setColumns] = useState>(() => + buildColumns(issues, visibleStatuses, sortBy, sortDirection), + ); + const columnsRef = useRef(columns); + columnsRef.current = columns; + + useEffect(() => { + if (!isDraggingRef.current) { + setColumns(buildColumns(issues, visibleStatuses, sortBy, sortDirection)); + } + }, [issues, visibleStatuses, sortBy, sortDirection]); + + // After a cross-column move, lock for one animation frame so dnd-kit's + // collision detection can stabilize before processing the next move. + // Without this, collision oscillates: A→B→A→B… until React bails out. + const recentlyMovedRef = useRef(false); + useEffect(() => { + const id = requestAnimationFrame(() => { + recentlyMovedRef.current = false; + }); + return () => cancelAnimationFrame(id); + }, [columns]); + + // --- Issue map --- + // Frozen during drag so BoardColumn/DraggableBoardCard props stay + // referentially stable even if a TQ refetch lands mid-drag. + const issueMap = useMemo(() => { + const map = new Map(); + for (const issue of issues) map.set(issue.id, issue); + return map; + }, [issues]); + + const issueMapRef = useRef(issueMap); + if (!isDraggingRef.current) { + issueMapRef.current = issueMap; + } const sensors = useSensors( useSensor(PointerSensor, { @@ -78,89 +161,100 @@ export function BoardView({ }) ); - // Pre-sort issues by position per status for position calculations - const issuesByStatus = useMemo(() => { - const map: Record = {}; - for (const status of visibleStatuses) { - map[status] = issues - .filter((i) => i.status === status) - .sort((a, b) => a.position - b.position); - } - return map; - }, [issues, visibleStatuses]); - const handleDragStart = useCallback( (event: DragStartEvent) => { - const issue = issues.find((i) => i.id === event.active.id); - if (issue) setActiveIssue(issue); + isDraggingRef.current = true; + const issue = issueMapRef.current.get(event.active.id as string) ?? null; + setActiveIssue(issue); }, - [issues] + [], + ); + + const handleDragOver = useCallback( + (event: DragOverEvent) => { + const { active, over } = event; + if (!over || recentlyMovedRef.current) return; + + const activeId = active.id as string; + const overId = over.id as string; + + setColumns((prev) => { + const activeCol = findColumn(prev, activeId, visibleStatuses); + const overCol = findColumn(prev, overId, visibleStatuses); + if (!activeCol || !overCol || activeCol === overCol) return prev; + + recentlyMovedRef.current = true; + const oldIds = prev[activeCol]!.filter((id) => id !== activeId); + const newIds = [...prev[overCol]!]; + const overIndex = newIds.indexOf(overId); + const insertIndex = overIndex >= 0 ? overIndex : newIds.length; + newIds.splice(insertIndex, 0, activeId); + return { ...prev, [activeCol]: oldIds, [overCol]: newIds }; + }); + }, + [visibleStatuses], ); const handleDragEnd = useCallback( (event: DragEndEvent) => { - setActiveIssue(null); const { active, over } = event; - if (!over || active.id === over.id) return; + isDraggingRef.current = false; + setActiveIssue(null); - const issueId = active.id as string; - const currentIssue = issues.find((i) => i.id === issueId); - if (!currentIssue) return; + const resetColumns = () => + setColumns(buildColumns(issues, visibleStatuses, sortBy, sortDirection)); - // Determine target status - let targetStatus: IssueStatus; - let overIsColumn = false; - - if (visibleStatuses.includes(over.id as IssueStatus)) { - targetStatus = over.id as IssueStatus; - overIsColumn = true; - } else { - const targetIssue = issues.find((i) => i.id === over.id); - if (!targetIssue) return; - targetStatus = targetIssue.status; + if (!over) { + resetColumns(); + return; } - // Get sorted siblings in the target column (excluding the dragged item) - const siblings = (issuesByStatus[targetStatus] ?? []).filter( - (i) => i.id !== issueId - ); + const activeId = active.id as string; + const overId = over.id as string; - // Compute new position - let newPosition: number; + const cols = columnsRef.current; + const activeCol = findColumn(cols, activeId, visibleStatuses); + const overCol = findColumn(cols, overId, visibleStatuses); + if (!activeCol || !overCol) { + resetColumns(); + return; + } - if (overIsColumn) { - // Dropped on empty area of column → append to end - newPosition = computePosition(siblings, siblings.length); - } else { - // Dropped on a specific card → insert at that card's index - const overIndex = siblings.findIndex((i) => i.id === over.id); - if (overIndex === -1) { - newPosition = computePosition(siblings, siblings.length); - } else { - const isSameColumn = currentIssue.status === targetStatus; - const overIssuePosition = siblings[overIndex]!.position; - - if (isSameColumn && currentIssue.position < overIssuePosition) { - // Moving down → insert after the over card - newPosition = computePosition(siblings, overIndex + 1); - } else { - // Moving up or cross-column → insert before the over card - newPosition = computePosition(siblings, overIndex); - } + // Same-column reorder + let finalColumns = cols; + if (activeCol === overCol) { + const ids = cols[activeCol]!; + const oldIndex = ids.indexOf(activeId); + const newIndex = ids.indexOf(overId); + if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) { + const reordered = arrayMove(ids, oldIndex, newIndex); + finalColumns = { ...cols, [activeCol]: reordered }; + setColumns(finalColumns); } } - // Skip if nothing changed + const finalCol = findColumn(finalColumns, activeId, visibleStatuses); + if (!finalCol) { + resetColumns(); + return; + } + + const map = issueMapRef.current; + const finalIds = finalColumns[finalCol]!; + const newPosition = computePosition(finalIds, activeId, map); + const currentIssue = map.get(activeId); + if ( - currentIssue.status === targetStatus && + currentIssue && + currentIssue.status === finalCol && currentIssue.position === newPosition ) { return; } - onMoveIssue(issueId, targetStatus, newPosition); + onMoveIssue(activeId, finalCol, newPosition); }, - [issues, issuesByStatus, onMoveIssue, visibleStatuses] + [issues, visibleStatuses, sortBy, sortDirection, onMoveIssue], ); return ( @@ -168,6 +262,7 @@ export function BoardView({ sensors={sensors} collisionDetection={kanbanCollision} onDragStart={handleDragStart} + onDragOver={handleDragOver} onDragEnd={handleDragEnd} >
@@ -175,7 +270,8 @@ export function BoardView({ i.status === status)} + issueIds={columns[status] ?? []} + issueMap={issueMapRef.current} /> ))} @@ -187,9 +283,9 @@ export function BoardView({ )}
- + {activeIssue ? ( -
+
) : null} diff --git a/apps/web/features/issues/components/issues-page.tsx b/apps/web/features/issues/components/issues-page.tsx index a73bac98..45165384 100644 --- a/apps/web/features/issues/components/issues-page.tsx +++ b/apps/web/features/issues/components/issues-page.tsx @@ -25,6 +25,7 @@ import { BatchActionToolbar } from "./batch-action-toolbar"; export function IssuesPage() { const wsId = useWorkspaceId(); const { data: allIssues = [], isLoading: loading } = useQuery(issueListOptions(wsId)); + const workspace = useWorkspaceStore((s) => s.workspace); const scope = useIssuesScopeStore((s) => s.scope); const viewMode = useIssueViewStore((s) => s.viewMode); diff --git a/apps/web/features/realtime/use-realtime-sync.ts b/apps/web/features/realtime/use-realtime-sync.ts index 8b744c65..4302cc34 100644 --- a/apps/web/features/realtime/use-realtime-sync.ts +++ b/apps/web/features/realtime/use-realtime-sync.ts @@ -45,11 +45,6 @@ export function useRealtimeSync(ws: WSClient | null) { useEffect(() => { if (!ws) return; - // Event types handled by specific handlers below — skip generic refresh - const specificEvents = new Set([ - "issue:updated", "issue:created", "issue:deleted", "inbox:new", - ]); - const refreshMap: Record void> = { inbox: () => { const wsId = useWorkspaceStore.getState().workspace?.id; @@ -85,6 +80,11 @@ export function useRealtimeSync(ws: WSClient | null) { ); }; + // Event types handled by specific handlers below — skip generic refresh + const specificEvents = new Set([ + "issue:updated", "issue:created", "issue:deleted", "inbox:new", + ]); + const unsubAny = ws.onAny((msg) => { const myUserId = useAuthStore.getState().user?.id; if (msg.actor_id && msg.actor_id === myUserId) { @@ -98,6 +98,8 @@ export function useRealtimeSync(ws: WSClient | null) { }); // --- Specific event handlers (granular updates, no full refetch) --- + // NOTE: ws.on() passes msg.payload (no actor_id). Self-event suppression + // requires WSClient changes to expose actor_id — tracked as separate task. const unsubIssueUpdated = ws.on("issue:updated", (p) => { const { issue } = p as IssueUpdatedPayload; diff --git a/docs/plans/2026-04-08-board-dnd-rewrite.md b/docs/plans/2026-04-08-board-dnd-rewrite.md new file mode 100644 index 00000000..ba2fde1c --- /dev/null +++ b/docs/plans/2026-04-08-board-dnd-rewrite.md @@ -0,0 +1,511 @@ +# Board DnD Rewrite — dnd-kit Multi-Container Sortable + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Rewrite the Kanban board drag-and-drop to use dnd-kit's multi-container sortable pattern correctly — onDragOver for live cross-column movement, local state during drag, insertion indicators, and smooth animations. + +**Architecture:** Replace the current "TQ-cache-driven + pendingMove patch" with a "local-state-driven during drag, TQ sync on drop" model. During drag, a local `columns` state (Record) controls which IDs each SortableContext sees. onDragOver moves IDs between columns in real-time. onDragEnd computes final position and fires the mutation. Between drags, local state follows TQ data via useEffect. + +**Tech Stack:** @dnd-kit/core ^6.3.1, @dnd-kit/sortable ^10.0.0, @dnd-kit/utilities ^3.2.2, TanStack Query, React useState + +--- + +## Current State (files to modify) + +| File | Current Role | Change | +|------|-------------|--------| +| `features/issues/components/board-view.tsx` | DndContext + onDragEnd only + pendingMove | **Rewrite**: local columns state, onDragOver, onDragEnd, improved DragOverlay | +| `features/issues/components/board-column.tsx` | Receives Issue[], sorts internally, useDroppable | **Rewrite**: receives sorted Issue[] from parent, no internal sorting, insertion indicator | +| `features/issues/components/board-card.tsx` | useSortable with defaults | **Modify**: custom animateLayoutChanges | +| `features/issues/components/issues-page.tsx` | handleMoveIssue callback | **Minor**: adjust callback signature | + +Files NOT changed: `mutations.ts`, `ws-updaters.ts`, `use-realtime-sync.ts`, `view-store.ts`, `sort.ts` + +--- + +## Task 1: Rewrite board-view.tsx — Local State + onDragOver + onDragEnd + +**Files:** +- Rewrite: `apps/web/features/issues/components/board-view.tsx` + +This is the core task. The entire DnD orchestration logic changes. + +### Data Model + +```typescript +// Local state: maps status → ordered array of issue IDs +// This is the ONLY source of truth for card positions during drag +type Columns = Record; +``` + +### Step 1: Replace pendingMove with local columns state + +Remove `pendingMove` + `displayIssues` + the clearing useEffect. Replace with: + +```typescript +// Build columns from TQ issues + view sort settings +function buildColumns( + issues: Issue[], + visibleStatuses: IssueStatus[], + sortBy: SortField, + sortDirection: SortDirection, +): Columns { + const cols: Columns = {} as Columns; + for (const status of visibleStatuses) { + const sorted = sortIssues( + issues.filter((i) => i.status === status), + sortBy, + sortDirection, + ); + cols[status] = sorted.map((i) => i.id); + } + return cols; +} +``` + +In the component: + +```typescript +const sortBy = useViewStore((s) => s.sortBy); +const sortDirection = useViewStore((s) => s.sortDirection); + +// Local columns state — follows TQ between drags, local during drag +const [columns, setColumns] = useState(() => + buildColumns(issues, visibleStatuses, sortBy, sortDirection) +); +const isDragging = useRef(false); + +// Sync from TQ when NOT dragging +useEffect(() => { + if (!isDragging.current) { + setColumns(buildColumns(issues, visibleStatuses, sortBy, sortDirection)); + } +}, [issues, visibleStatuses, sortBy, sortDirection]); +``` + +`issueMap` for O(1) lookup (needed by BoardColumn to get Issue objects from IDs): + +```typescript +const issueMap = useMemo(() => { + const map = new Map(); + for (const issue of issues) map.set(issue.id, issue); + return map; +}, [issues]); +``` + +### Step 2: Implement findColumn helper + +```typescript +/** Find which column (status) contains a given ID (issue or column). */ +function findColumn(columns: Columns, id: string, visibleStatuses: IssueStatus[]): IssueStatus | null { + // Is it a column ID itself? + if (visibleStatuses.includes(id as IssueStatus)) return id as IssueStatus; + // Search columns for the item + for (const [status, ids] of Object.entries(columns)) { + if (ids.includes(id)) return status as IssueStatus; + } + return null; +} +``` + +### Step 3: Implement onDragStart + +```typescript +const handleDragStart = useCallback((event: DragStartEvent) => { + isDragging.current = true; + const issue = issueMap.get(event.active.id as string) ?? null; + setActiveIssue(issue); +}, [issueMap]); +``` + +### Step 4: Implement onDragOver — the key missing piece + +This fires continuously during drag. When the pointer crosses into a different column or hovers over a different card, we move the dragged ID in local state. This makes SortableContext aware of the new item → cards shift to make room. + +```typescript +const handleDragOver = useCallback((event: DragOverEvent) => { + const { active, over } = event; + if (!over) return; + + const activeId = active.id as string; + const overId = over.id as string; + + const activeCol = findColumn(columns, activeId, visibleStatuses); + const overCol = findColumn(columns, overId, visibleStatuses); + if (!activeCol || !overCol || activeCol === overCol) return; + + // Cross-column move: remove from old column, insert into new column + setColumns((prev) => { + const oldIds = prev[activeCol]!.filter((id) => id !== activeId); + const newIds = [...prev[overCol]!]; + + // Insert position: if over a card, insert at that index; if over column, append + const overIndex = newIds.indexOf(overId); + const insertIndex = overIndex >= 0 ? overIndex : newIds.length; + newIds.splice(insertIndex, 0, activeId); + + return { ...prev, [activeCol]: oldIds, [overCol]: newIds }; + }); +}, [columns, visibleStatuses]); +``` + +### Step 5: Implement onDragEnd — persist to server + +```typescript +const handleDragEnd = useCallback((event: DragEndEvent) => { + const { active, over } = event; + isDragging.current = false; + setActiveIssue(null); + + if (!over) { + // Cancelled — reset to TQ state + setColumns(buildColumns(issues, visibleStatuses, sortBy, sortDirection)); + return; + } + + const activeId = active.id as string; + const overId = over.id as string; + + const activeCol = findColumn(columns, activeId, visibleStatuses); + const overCol = findColumn(columns, overId, visibleStatuses); + if (!activeCol || !overCol) return; + + // Same column reorder + if (activeCol === overCol) { + const ids = columns[activeCol]!; + const oldIndex = ids.indexOf(activeId); + const newIndex = ids.indexOf(overId); + if (oldIndex !== newIndex) { + const reordered = arrayMove(ids, oldIndex, newIndex); + setColumns((prev) => ({ ...prev, [activeCol]: reordered })); + } + } + + // Compute final position from the local column order + const finalCol = findColumn(columns, activeId, visibleStatuses); + if (!finalCol) return; + + // After potential same-col reorder, re-read columns + // (for same-col we just did setColumns above, but it's async; + // however we can compute from the intended final order) + let finalIds: string[]; + if (activeCol === overCol) { + const ids = columns[activeCol]!; + const oldIndex = ids.indexOf(activeId); + const newIndex = ids.indexOf(overId); + finalIds = oldIndex !== newIndex ? arrayMove(ids, oldIndex, newIndex) : ids; + } else { + finalIds = columns[finalCol]!; + } + + const newPosition = computePosition(finalIds, activeId, issues); + const currentIssue = issueMap.get(activeId); + + // Skip if nothing changed + if (currentIssue && currentIssue.status === finalCol && currentIssue.position === newPosition) return; + + onMoveIssue(activeId, finalCol, newPosition); +}, [columns, issues, visibleStatuses, sortBy, sortDirection, issueMap, onMoveIssue]); +``` + +### Step 6: Update computePosition to work with ID arrays + +The current `computePosition` takes `Issue[]` and a target index. Rewrite to take `string[]` (IDs) + the active ID + the issue map: + +```typescript +/** Compute a float position for `activeId` based on its neighbors in `ids`. */ +function computePosition(ids: string[], activeId: string, allIssues: Issue[]): number { + const idx = ids.indexOf(activeId); + if (idx === -1) return 0; + + const getPos = (id: string) => allIssues.find((i) => i.id === id)?.position ?? 0; + + if (ids.length === 1) return 0; + if (idx === 0) return getPos(ids[1]!) - 1; + if (idx === ids.length - 1) return getPos(ids[idx - 1]!) + 1; + return (getPos(ids[idx - 1]!) + getPos(ids[idx + 1]!)) / 2; +} +``` + +### Step 7: Update DragOverlay styling + +```typescript + + {activeIssue ? ( +
+ +
+ ) : null} +
+``` + +Key change: `dropAnimation={null}` prevents the overlay from animating back to origin on drop — the card is already in the right position via local state. + +### Step 8: Wire it all together + +Pass `columns` + `issueMap` to `BoardColumn` instead of `issues`: + +```tsx +{visibleStatuses.map((status) => ( + +))} +``` + +### Step 9: Run typecheck + +Run: `pnpm typecheck` +Expected: May have errors in board-column.tsx (prop changes) — that's Task 2. + +### Step 10: Commit + +```bash +git add apps/web/features/issues/components/board-view.tsx +git commit -m "refactor(board): rewrite DnD with local state + onDragOver for live cross-column sorting" +``` + +--- + +## Task 2: Rewrite board-column.tsx — Receive IDs + issueMap, Add Insertion Indicator + +**Files:** +- Rewrite: `apps/web/features/issues/components/board-column.tsx` + +### Step 1: Change props from `issues: Issue[]` to `issueIds: string[]` + `issueMap: Map` + +The column no longer does its own sorting — the parent provides IDs in the correct order. The column just resolves IDs to Issue objects and renders them. + +```typescript +export function BoardColumn({ + status, + issueIds, + issueMap, +}: { + status: IssueStatus; + issueIds: string[]; + issueMap: Map; +}) { + const cfg = STATUS_CONFIG[status]; + const { setNodeRef, isOver } = useDroppable({ id: status }); + const viewStoreApi = useViewStoreApi(); + + // Resolve IDs to Issue objects (IDs are already sorted by parent) + const resolvedIssues = useMemo( + () => issueIds.flatMap((id) => { + const issue = issueMap.get(id); + return issue ? [issue] : []; + }), + [issueIds, issueMap], + ); + + return ( +
+
+
+ + + {cfg.label} + + + {issueIds.length} + +
+ {/* Right: add + menu — keep as-is */} +
+ + + + + } + /> + + viewStoreApi.getState().hideStatus(status)}> + + Hide column + + + + + useModalStore.getState().open("create-issue", { status })} + > + + + } + /> + Add issue + +
+
+
+ + {resolvedIssues.map((issue) => ( + + ))} + + {issueIds.length === 0 && ( +

+ No issues +

+ )} +
+
+ ); +} +``` + +Key changes: +- No more `useViewStore` for sort — parent handles sorting +- No more internal `sortIssues` call +- Uses `issueIds` for SortableContext (already in correct order) +- Count shows `issueIds.length` instead of `issues.length` + +### Step 2: Run typecheck + +Run: `pnpm typecheck` +Expected: PASS (or errors in issues-page.tsx — Task 4) + +### Step 3: Commit + +```bash +git add apps/web/features/issues/components/board-column.tsx +git commit -m "refactor(board): BoardColumn receives sorted IDs from parent, no internal sorting" +``` + +--- + +## Task 3: Modify board-card.tsx — Custom animateLayoutChanges + +**Files:** +- Modify: `apps/web/features/issues/components/board-card.tsx` + +### Step 1: Add custom animateLayoutChanges + +When a card is dragged across containers, dnd-kit triggers a layout animation on the "entering" card. The default `defaultAnimateLayoutChanges` animates this, causing a jarring jump. We disable animation for the frame when `wasDragging` is true (the card just landed in a new container). + +```typescript +import { useSortable, defaultAnimateLayoutChanges } from "@dnd-kit/sortable"; +import type { AnimateLayoutChanges } from "@dnd-kit/sortable"; + +const animateLayoutChanges: AnimateLayoutChanges = (args) => { + const { isSorting, wasDragging } = args; + if (isSorting || wasDragging) return false; + return defaultAnimateLayoutChanges(args); +}; +``` + +Update useSortable call: + +```typescript +const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, +} = useSortable({ + id: issue.id, + data: { status: issue.status }, + animateLayoutChanges, +}); +``` + +### Step 2: Run typecheck + +Run: `pnpm typecheck` +Expected: PASS + +### Step 3: Commit + +```bash +git add apps/web/features/issues/components/board-card.tsx +git commit -m "refactor(board): custom animateLayoutChanges to prevent jarring cross-column animation" +``` + +--- + +## Task 4: Adjust issues-page.tsx — Minor Callback Cleanup + +**Files:** +- Modify: `apps/web/features/issues/components/issues-page.tsx` + +### Step 1: Update handleMoveIssue + +The callback shape stays the same (`issueId, newStatus, newPosition`), but the auto-switch-to-manual-sort logic should move into board-view or stay here. Keep it here for now since it's a view-level concern. + +No functional change needed — the `onMoveIssue` prop signature is unchanged. Just verify that `BoardView`'s new props are correct: + +```tsx + +``` + +`BoardView` still receives `issues` (filtered+scoped from TQ) and `onMoveIssue`. The internal state management changes are encapsulated. + +### Step 2: Run full typecheck + test + +Run: `pnpm typecheck && pnpm test` +Expected: PASS + +### Step 3: Commit + +```bash +git add apps/web/features/issues/components/issues-page.tsx +git commit -m "refactor(board): verify issues-page props match new BoardView interface" +``` + +--- + +## Task 5: Manual QA Checklist + +After all code changes, verify these scenarios in the browser: + +1. **Same-column reorder**: Drag a card up/down within one column → cards shift to make room during drag → drop → position persists after refresh +2. **Cross-column move**: Drag card from Todo to In Progress → card appears in target column DURING drag → target column cards shift → drop → status + position persist +3. **Drop on empty column**: Drag card to an empty column → card lands there +4. **Cancel drag**: Start dragging, press Escape → card returns to original position, no mutation fired +5. **Rapid sequential drags**: Drag card A, drop, immediately drag card B → no flicker or stale state +6. **WebSocket update during drag**: Have another user change an issue → board updates correctly after drag ends (not during) +7. **Sort mode switch**: Drag should auto-switch to "Manual" sort → verify after drag, sort dropdown shows "Manual" +8. **DragOverlay**: Dragged card should have visible shadow, slight rotation, slight scale up +9. **Hidden columns panel**: Still shows correct counts, "Show column" still works + +--- + +## Summary of Architecture Change + +``` +BEFORE (broken): + TQ cache → issues prop → displayIssues (with pendingMove patch) → BoardColumn sorts internally + onDragEnd → pendingMove + mutate → TQ updates → useEffect clears pendingMove + Problem: dual optimistic update, fire-and-forget cancelQueries race, no onDragOver + +AFTER (correct): + TQ cache → issues prop → buildColumns() → local columns state (when not dragging) + onDragStart → isDragging=true, freeze local state + onDragOver → move IDs between columns in local state → SortableContext sees new items → cards shift + onDragEnd → compute position from local order → mutate → isDragging=false → TQ catches up → local follows + Problem: none — single source of truth during drag (local), single source of truth between drags (TQ) +```