fix(web): DnD local-state overlay, onSettled list invalidation, WS self-event filter

- Board DnD: use local pendingMove state for instant card placement,
  bypassing TQ's async setQueryData notification delay
- useUpdateIssue: add list invalidation to onSettled (was only detail)
- use-realtime-sync: add isSelf check to specific issue WS handlers
  (prevents redundant cache writes for own mutations)
- Clean up debug console.logs from board-view, issues-page, mutations

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-04-08 10:25:35 +08:00
parent 99dad49052
commit 862b85e064
7 changed files with 715 additions and 99 deletions

View file

@ -36,12 +36,10 @@ export function useUpdateIssue() {
mutationFn: ({ id, ...data }: { id: string } & UpdateIssueRequest) => mutationFn: ({ id, ...data }: { id: string } & UpdateIssueRequest) =>
api.updateIssue(id, data), api.updateIssue(id, data),
onMutate: ({ 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 // cache update happens in the same tick as mutate(). Awaiting would
// yield to the event loop, letting @dnd-kit reset its visual state // yield to the event loop, letting @dnd-kit reset its visual state
// before the optimistic update lands → card flickers back briefly. // before the optimistic update lands.
// Safe because staleTime: Infinity means no background refetch is
// in-flight during normal operation.
qc.cancelQueries({ queryKey: issueKeys.list(wsId) }); qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId)); const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
const prevDetail = qc.getQueryData<Issue>(issueKeys.detail(wsId, id)); const prevDetail = qc.getQueryData<Issue>(issueKeys.detail(wsId, id));
@ -68,6 +66,7 @@ export function useUpdateIssue() {
}, },
onSettled: (_data, _err, vars) => { onSettled: (_data, _err, vars) => {
qc.invalidateQueries({ queryKey: issueKeys.detail(wsId, vars.id) }); qc.invalidateQueries({ queryKey: issueKeys.detail(wsId, vars.id) });
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
}, },
}); });
} }

View file

@ -2,7 +2,8 @@
import { useCallback, memo } from "react"; import { useCallback, memo } from "react";
import Link from "next/link"; 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 { CSS } from "@dnd-kit/utilities";
import { toast } from "sonner"; import { toast } from "sonner";
import type { Issue, UpdateIssueRequest } from "@/shared/types"; 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 }) { export const DraggableBoardCard = memo(function DraggableBoardCard({ issue }: { issue: Issue }) {
const { const {
attributes, attributes,
@ -177,6 +184,7 @@ export const DraggableBoardCard = memo(function DraggableBoardCard({ issue }: {
} = useSortable({ } = useSortable({
id: issue.id, id: issue.id,
data: { status: issue.status }, data: { status: issue.status },
animateLayoutChanges,
}); });
const style = { const style = {

View file

@ -15,32 +15,31 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { STATUS_CONFIG } from "@/features/issues/config"; import { STATUS_CONFIG } from "@/features/issues/config";
import { useModalStore } from "@/features/modals"; import { useModalStore } from "@/features/modals";
import { useViewStore, useViewStoreApi } from "@/features/issues/stores/view-store-context"; import { useViewStoreApi } from "@/features/issues/stores/view-store-context";
import { sortIssues } from "@/features/issues/utils/sort";
import { StatusIcon } from "./status-icon"; import { StatusIcon } from "./status-icon";
import { DraggableBoardCard } from "./board-card"; import { DraggableBoardCard } from "./board-card";
export function BoardColumn({ export function BoardColumn({
status, status,
issues, issueIds,
issueMap,
}: { }: {
status: IssueStatus; status: IssueStatus;
issues: Issue[]; issueIds: string[];
issueMap: Map<string, Issue>;
}) { }) {
const cfg = STATUS_CONFIG[status]; const cfg = STATUS_CONFIG[status];
const { setNodeRef, isOver } = useDroppable({ id: status }); const { setNodeRef, isOver } = useDroppable({ id: status });
const viewStoreApi = useViewStoreApi(); const viewStoreApi = useViewStoreApi();
const sortBy = useViewStore((s) => s.sortBy);
const sortDirection = useViewStore((s) => s.sortDirection);
const sortedIssues = useMemo( // Resolve IDs to Issue objects, preserving parent-provided order
() => sortIssues(issues, sortBy, sortDirection), const resolvedIssues = useMemo(
[issues, sortBy, sortDirection] () =>
); issueIds.flatMap((id) => {
const issue = issueMap.get(id);
const sortedIds = useMemo( return issue ? [issue] : [];
() => sortedIssues.map((i) => i.id), }),
[sortedIssues] [issueIds, issueMap],
); );
return ( return (
@ -53,7 +52,7 @@ export function BoardColumn({
{cfg.label} {cfg.label}
</span> </span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{issues.length} {issueIds.length}
</span> </span>
</div> </div>
@ -97,12 +96,12 @@ export function BoardColumn({
isOver ? "bg-accent/60" : "" isOver ? "bg-accent/60" : ""
}`} }`}
> >
<SortableContext items={sortedIds} strategy={verticalListSortingStrategy}> <SortableContext items={issueIds} strategy={verticalListSortingStrategy}>
{sortedIssues.map((issue) => ( {resolvedIssues.map((issue) => (
<DraggableBoardCard key={issue.id} issue={issue} /> <DraggableBoardCard key={issue.id} issue={issue} />
))} ))}
</SortableContext> </SortableContext>
{issues.length === 0 && ( {issueIds.length === 0 && (
<p className="py-8 text-center text-xs text-muted-foreground"> <p className="py-8 text-center text-xs text-muted-foreground">
No issues No issues
</p> </p>

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useCallback, useMemo } from "react"; import { useState, useCallback, useMemo, useEffect, useRef } from "react";
import { import {
DndContext, DndContext,
DragOverlay, DragOverlay,
@ -12,7 +12,9 @@ import {
type CollisionDetection, type CollisionDetection,
type DragStartEvent, type DragStartEvent,
type DragEndEvent, type DragEndEvent,
type DragOverEvent,
} from "@dnd-kit/core"; } from "@dnd-kit/core";
import { arrayMove } from "@dnd-kit/sortable";
import { Eye, MoreHorizontal } from "lucide-react"; import { Eye, MoreHorizontal } from "lucide-react";
import type { Issue, IssueStatus } from "@/shared/types"; import type { Issue, IssueStatus } from "@/shared/types";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -23,7 +25,9 @@ import {
DropdownMenuItem, DropdownMenuItem,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { ALL_STATUSES, STATUS_CONFIG } from "@/features/issues/config"; 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 { StatusIcon } from "./status-icon";
import { BoardColumn } from "./board-column"; import { BoardColumn } from "./board-column";
import { BoardCardContent } from "./board-card"; import { BoardCardContent } from "./board-card";
@ -44,13 +48,47 @@ const kanbanCollision: CollisionDetection = (args) => {
return closestCenter(args); return closestCenter(args);
}; };
/** Compute a float position to place an item at `targetIndex` within `siblings`. */ /** Build column ID arrays from TQ issue data, respecting current sort. */
function computePosition(siblings: Issue[], targetIndex: number): number { function buildColumns(
if (siblings.length === 0) return 0; issues: Issue[],
if (targetIndex <= 0) return siblings[0]!.position - 1; visibleStatuses: IssueStatus[],
if (targetIndex >= siblings.length) sortBy: SortField,
return siblings[siblings.length - 1]!.position + 1; sortDirection: SortDirection,
return (siblings[targetIndex - 1]!.position + siblings[targetIndex]!.position) / 2; ): Record<IssueStatus, string[]> {
const cols = {} as Record<IssueStatus, string[]>;
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<string, Issue>): 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<IssueStatus, string[]>,
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({ export function BoardView({
@ -70,7 +108,52 @@ export function BoardView({
newPosition?: number newPosition?: number
) => void; ) => void;
}) { }) {
const sortBy = useViewStore((s) => s.sortBy);
const sortDirection = useViewStore((s) => s.sortDirection);
// --- Drag state ---
const [activeIssue, setActiveIssue] = useState<Issue | null>(null); const [activeIssue, setActiveIssue] = useState<Issue | null>(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<Record<IssueStatus, string[]>>(() =>
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<string, Issue>();
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( const sensors = useSensors(
useSensor(PointerSensor, { 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<string, Issue[]> = {};
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( const handleDragStart = useCallback(
(event: DragStartEvent) => { (event: DragStartEvent) => {
const issue = issues.find((i) => i.id === event.active.id); isDraggingRef.current = true;
if (issue) setActiveIssue(issue); 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( const handleDragEnd = useCallback(
(event: DragEndEvent) => { (event: DragEndEvent) => {
setActiveIssue(null);
const { active, over } = event; const { active, over } = event;
if (!over || active.id === over.id) return; isDraggingRef.current = false;
setActiveIssue(null);
const issueId = active.id as string; const resetColumns = () =>
const currentIssue = issues.find((i) => i.id === issueId); setColumns(buildColumns(issues, visibleStatuses, sortBy, sortDirection));
if (!currentIssue) return;
// Determine target status if (!over) {
let targetStatus: IssueStatus; resetColumns();
let overIsColumn = false; return;
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;
} }
// Get sorted siblings in the target column (excluding the dragged item) const activeId = active.id as string;
const siblings = (issuesByStatus[targetStatus] ?? []).filter( const overId = over.id as string;
(i) => i.id !== issueId
);
// Compute new position const cols = columnsRef.current;
let newPosition: number; const activeCol = findColumn(cols, activeId, visibleStatuses);
const overCol = findColumn(cols, overId, visibleStatuses);
if (overIsColumn) { if (!activeCol || !overCol) {
// Dropped on empty area of column → append to end resetColumns();
newPosition = computePosition(siblings, siblings.length); return;
} 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);
}
}
} }
// Skip if nothing changed // 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);
}
}
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 ( if (
currentIssue.status === targetStatus && currentIssue &&
currentIssue.status === finalCol &&
currentIssue.position === newPosition currentIssue.position === newPosition
) { ) {
return; return;
} }
onMoveIssue(issueId, targetStatus, newPosition); onMoveIssue(activeId, finalCol, newPosition);
}, },
[issues, issuesByStatus, onMoveIssue, visibleStatuses] [issues, visibleStatuses, sortBy, sortDirection, onMoveIssue],
); );
return ( return (
@ -168,6 +262,7 @@ export function BoardView({
sensors={sensors} sensors={sensors}
collisionDetection={kanbanCollision} collisionDetection={kanbanCollision}
onDragStart={handleDragStart} onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
> >
<div className="flex flex-1 min-h-0 gap-4 overflow-x-auto p-4"> <div className="flex flex-1 min-h-0 gap-4 overflow-x-auto p-4">
@ -175,7 +270,8 @@ export function BoardView({
<BoardColumn <BoardColumn
key={status} key={status}
status={status} status={status}
issues={issues.filter((i) => i.status === status)} issueIds={columns[status] ?? []}
issueMap={issueMapRef.current}
/> />
))} ))}
@ -187,9 +283,9 @@ export function BoardView({
)} )}
</div> </div>
<DragOverlay> <DragOverlay dropAnimation={null}>
{activeIssue ? ( {activeIssue ? (
<div className="w-[280px] rotate-1 cursor-grabbing opacity-95 shadow-md"> <div className="w-[280px] rotate-2 scale-105 cursor-grabbing opacity-90 shadow-lg shadow-black/10">
<BoardCardContent issue={activeIssue} /> <BoardCardContent issue={activeIssue} />
</div> </div>
) : null} ) : null}

View file

@ -25,6 +25,7 @@ import { BatchActionToolbar } from "./batch-action-toolbar";
export function IssuesPage() { export function IssuesPage() {
const wsId = useWorkspaceId(); const wsId = useWorkspaceId();
const { data: allIssues = [], isLoading: loading } = useQuery(issueListOptions(wsId)); const { data: allIssues = [], isLoading: loading } = useQuery(issueListOptions(wsId));
const workspace = useWorkspaceStore((s) => s.workspace); const workspace = useWorkspaceStore((s) => s.workspace);
const scope = useIssuesScopeStore((s) => s.scope); const scope = useIssuesScopeStore((s) => s.scope);
const viewMode = useIssueViewStore((s) => s.viewMode); const viewMode = useIssueViewStore((s) => s.viewMode);

View file

@ -45,11 +45,6 @@ export function useRealtimeSync(ws: WSClient | null) {
useEffect(() => { useEffect(() => {
if (!ws) return; 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<string, () => void> = { const refreshMap: Record<string, () => void> = {
inbox: () => { inbox: () => {
const wsId = useWorkspaceStore.getState().workspace?.id; 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 unsubAny = ws.onAny((msg) => {
const myUserId = useAuthStore.getState().user?.id; const myUserId = useAuthStore.getState().user?.id;
if (msg.actor_id && msg.actor_id === myUserId) { 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) --- // --- 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 unsubIssueUpdated = ws.on("issue:updated", (p) => {
const { issue } = p as IssueUpdatedPayload; const { issue } = p as IssueUpdatedPayload;

View file

@ -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<IssueStatus, string[]>) 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<IssueStatus, string[]>;
```
### 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<Columns>(() =>
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<string, Issue>();
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
<DragOverlay dropAnimation={null}>
{activeIssue ? (
<div className="w-[280px] rotate-2 scale-105 cursor-grabbing opacity-90 shadow-lg shadow-black/10">
<BoardCardContent issue={activeIssue} />
</div>
) : null}
</DragOverlay>
```
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) => (
<BoardColumn
key={status}
status={status}
issueIds={columns[status] ?? []}
issueMap={issueMap}
/>
))}
```
### 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<string, Issue>`
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<string, Issue>;
}) {
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 (
<div className={`flex w-[280px] shrink-0 flex-col rounded-xl ${cfg.columnBg} p-2`}>
<div className="mb-2 flex items-center justify-between px-1.5">
<div className="flex items-center gap-2">
<span className={`inline-flex items-center gap-1.5 rounded px-2 py-0.5 text-xs font-semibold ${cfg.badgeBg} ${cfg.badgeText}`}>
<StatusIcon status={status} className="h-3 w-3" inheritColor />
{cfg.label}
</span>
<span className="text-xs text-muted-foreground">
{issueIds.length}
</span>
</div>
{/* Right: add + menu — keep as-is */}
<div className="flex items-center gap-1">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon-sm" className="rounded-full text-muted-foreground">
<MoreHorizontal className="size-3.5" />
</Button>
}
/>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => viewStoreApi.getState().hideStatus(status)}>
<EyeOff className="size-3.5" />
Hide column
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="rounded-full text-muted-foreground"
onClick={() => useModalStore.getState().open("create-issue", { status })}
>
<Plus className="size-3.5" />
</Button>
}
/>
<TooltipContent>Add issue</TooltipContent>
</Tooltip>
</div>
</div>
<div
ref={setNodeRef}
className={`min-h-[200px] flex-1 space-y-2 overflow-y-auto rounded-lg p-1 transition-colors ${
isOver ? "bg-accent/60" : ""
}`}
>
<SortableContext items={issueIds} strategy={verticalListSortingStrategy}>
{resolvedIssues.map((issue) => (
<DraggableBoardCard key={issue.id} issue={issue} />
))}
</SortableContext>
{issueIds.length === 0 && (
<p className="py-8 text-center text-xs text-muted-foreground">
No issues
</p>
)}
</div>
</div>
);
}
```
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
issues={issues}
allIssues={scopedIssues}
visibleStatuses={visibleStatuses}
hiddenStatuses={hiddenStatuses}
onMoveIssue={handleMoveIssue}
/>
```
`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)
```