jettypod 4.4.74 → 4.4.75

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,14 @@
1
1
  'use client';
2
2
 
3
- import { useState } from 'react';
3
+ import { useState, useCallback, useRef, useEffect } from 'react';
4
4
  import Link from 'next/link';
5
5
  import { useRouter } from 'next/navigation';
6
6
  import type { WorkItem, InFlightItem, KanbanGroup } from '@/lib/db';
7
7
  import { EditableTitle } from './EditableTitle';
8
8
  import { CardMenu } from './CardMenu';
9
+ import { DragProvider, useDragContext } from './DragContext';
10
+ import { DraggableCard } from './DraggableCard';
11
+ import { DropZone } from './DropZone';
9
12
 
10
13
  const typeIcons: Record<string, string> = {
11
14
  epic: '🎯',
@@ -29,20 +32,74 @@ function getModeLabel(item: WorkItem): string {
29
32
  return base;
30
33
  }
31
34
 
35
+ // Copy icon SVG
36
+ function CopyIcon({ className }: { className?: string }) {
37
+ return (
38
+ <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
39
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
40
+ </svg>
41
+ );
42
+ }
43
+
44
+ // Checkmark icon SVG
45
+ function CheckIcon({ className }: { className?: string }) {
46
+ return (
47
+ <svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
48
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
49
+ </svg>
50
+ );
51
+ }
52
+
53
+ // Copyable work item ID component
54
+ function CopyableId({ id, title, type }: { id: number; title: string; type: string }) {
55
+ const [copied, setCopied] = useState(false);
56
+
57
+ const handleCopy = async (e: React.MouseEvent) => {
58
+ e.stopPropagation(); // Prevent card navigation
59
+ // Template literal preserves special characters and handles empty titles correctly
60
+ const text = `#${id} ${title} (${type})`;
61
+ try {
62
+ await navigator.clipboard.writeText(text);
63
+ setCopied(true);
64
+ setTimeout(() => setCopied(false), 1500);
65
+ } catch {
66
+ // Silently fail - clipboard API unavailable or permission denied
67
+ }
68
+ };
69
+
70
+ return (
71
+ <button
72
+ onClick={handleCopy}
73
+ className="flex items-center gap-1 text-xs text-zinc-400 font-mono px-1 py-0.5 -mx-1 rounded cursor-pointer hover:bg-zinc-100 dark:hover:bg-zinc-700 active:scale-95 transition-all"
74
+ title={`Copy: #${id} ${title} (${type})`}
75
+ >
76
+ <span>#{id}</span>
77
+ {copied ? (
78
+ <CheckIcon className="w-3 h-3 text-green-500" />
79
+ ) : (
80
+ <CopyIcon className="w-3 h-3 text-zinc-300 dark:text-zinc-500" />
81
+ )}
82
+ </button>
83
+ );
84
+ }
85
+
32
86
  interface KanbanCardProps {
33
87
  item: WorkItem;
34
88
  epicTitle?: string | null;
35
89
  showEpic?: boolean;
90
+ isInFlight?: boolean;
36
91
  onTitleSave?: (id: number, newTitle: string) => Promise<void>;
37
92
  onStatusChange?: (id: number, newStatus: string) => Promise<void>;
38
93
  }
39
94
 
40
- function KanbanCard({ item, epicTitle, showEpic = false, onTitleSave, onStatusChange }: KanbanCardProps) {
95
+ function KanbanCard({ item, epicTitle, showEpic = false, isInFlight = false, onTitleSave, onStatusChange }: KanbanCardProps) {
41
96
  const [expanded, setExpanded] = useState(false);
42
97
  const router = useRouter();
43
98
 
44
- // Calculate incomplete chores - only show section if there are incomplete ones
45
- const incompleteChores = item.chores?.filter(c => c.status !== 'done') || [];
99
+ // Calculate chores for expandable section
100
+ const allChores = item.chores || [];
101
+ const incompleteChores = allChores.filter(c => c.status !== 'done');
102
+ const hasChores = allChores.length > 0;
46
103
  const hasIncompleteChores = incompleteChores.length > 0;
47
104
 
48
105
  const handleCardClick = () => {
@@ -63,13 +120,19 @@ function KanbanCard({ item, epicTitle, showEpic = false, onTitleSave, onStatusCh
63
120
 
64
121
  const isDone = item.status === 'done';
65
122
 
123
+ const getCardStyles = () => {
124
+ if (isDone) {
125
+ return 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800 hover:border-green-300 dark:hover:border-green-700';
126
+ }
127
+ if (isInFlight) {
128
+ return 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800 hover:border-blue-300 dark:hover:border-blue-700';
129
+ }
130
+ return 'bg-white dark:bg-zinc-800 border-zinc-200 dark:border-zinc-700 hover:border-zinc-300 dark:hover:border-zinc-600';
131
+ };
132
+
66
133
  return (
67
134
  <div
68
- className={`rounded-lg border hover:shadow-sm transition-all ${
69
- isDone
70
- ? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800 hover:border-green-300 dark:hover:border-green-700'
71
- : 'bg-white dark:bg-zinc-800 border-zinc-200 dark:border-zinc-700 hover:border-zinc-300 dark:hover:border-zinc-600'
72
- }`}
135
+ className={`rounded-lg border hover:shadow-sm transition-all ${getCardStyles()}`}
73
136
  data-testid={`kanban-card-${item.id}`}>
74
137
  <div
75
138
  onClick={handleCardClick}
@@ -78,19 +141,32 @@ function KanbanCard({ item, epicTitle, showEpic = false, onTitleSave, onStatusCh
78
141
  <div className="flex items-start gap-2">
79
142
  <span className="text-sm flex-shrink-0">{typeIcons[item.type] || '📄'}</span>
80
143
  <div className="flex-1 min-w-0">
81
- <div className="flex items-center gap-2 mb-1 flex-wrap">
82
- <span className="text-xs text-zinc-400 font-mono">#{item.id}</span>
83
- {item.mode && modeLabels[item.mode] && (
84
- <span className={`text-xs px-1.5 py-0.5 rounded ${modeLabels[item.mode].color}`}>
85
- {getModeLabel(item)}
144
+ {isDone ? (
145
+ /* Compact layout for done cards: ID and title inline, no mode badge */
146
+ <div className="flex items-start gap-2">
147
+ <CopyableId id={item.id} title={item.title} type={item.type} />
148
+ <span className="text-sm font-medium text-zinc-700 dark:text-zinc-300">
149
+ {item.title || <span className="text-zinc-400 italic">(Untitled)</span>}
86
150
  </span>
87
- )}
88
- </div>
89
- <EditableTitle
90
- title={item.title}
91
- itemId={item.id}
92
- onSave={handleTitleSave}
93
- />
151
+ </div>
152
+ ) : (
153
+ /* Standard layout: ID + mode badge on line 1, title below */
154
+ <>
155
+ <div className="flex items-center gap-2 mb-1 flex-wrap">
156
+ <CopyableId id={item.id} title={item.title} type={item.type} />
157
+ {item.mode && modeLabels[item.mode] && (
158
+ <span className={`text-xs px-1.5 py-0.5 rounded ${modeLabels[item.mode].color}`}>
159
+ {getModeLabel(item)}
160
+ </span>
161
+ )}
162
+ </div>
163
+ <EditableTitle
164
+ title={item.title}
165
+ itemId={item.id}
166
+ onSave={handleTitleSave}
167
+ />
168
+ </>
169
+ )}
94
170
  {showEpic && epicTitle && (
95
171
  <p className="text-xs text-zinc-500 dark:text-zinc-400 mt-1.5 flex items-center gap-1">
96
172
  <span>🎯</span>
@@ -107,19 +183,28 @@ function KanbanCard({ item, epicTitle, showEpic = false, onTitleSave, onStatusCh
107
183
  )}
108
184
  </div>
109
185
  </div>
110
- {hasIncompleteChores && (
111
- <div className="border-t border-zinc-200 dark:border-zinc-700">
186
+ {/* Show expandable chores section for features with chores */}
187
+ {hasChores && (
188
+ <div className={`border-t ${isDone ? 'border-green-200 dark:border-green-800' : 'border-zinc-200 dark:border-zinc-700'}`}>
112
189
  <button
113
190
  onClick={() => setExpanded(!expanded)}
114
- className="w-full px-3 py-1.5 flex items-center gap-1.5 text-xs text-zinc-600 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700/50 transition-colors"
191
+ className={`w-full px-3 py-1.5 flex items-center gap-1.5 text-xs transition-colors ${
192
+ isDone
193
+ ? 'text-green-700 dark:text-green-400 hover:bg-green-100 dark:hover:bg-green-900/30'
194
+ : 'text-zinc-600 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700/50'
195
+ }`}
115
196
  >
116
197
  <span>{expanded ? '▼' : '▶'}</span>
117
198
  <span>🔧</span>
118
- <span>{incompleteChores.length} chore{incompleteChores.length !== 1 ? 's' : ''} left</span>
199
+ <span>
200
+ {isDone
201
+ ? `${allChores.length === 0 ? 'no' : allChores.length} chore${allChores.length !== 1 ? 's' : ''}`
202
+ : `${incompleteChores.length === 0 ? 'no' : incompleteChores.length}${item.mode ? ` ${item.mode} mode` : ''} chore${incompleteChores.length !== 1 ? 's' : ''} left`}
203
+ </span>
119
204
  </button>
120
205
  {expanded && (
121
206
  <div className="px-3 pb-2 space-y-1">
122
- {item.chores!.map((chore) => {
207
+ {allChores.map((chore) => {
123
208
  const isComplete = chore.status === 'done';
124
209
  return (
125
210
  <Link
@@ -133,14 +218,14 @@ function KanbanCard({ item, epicTitle, showEpic = false, onTitleSave, onStatusCh
133
218
  >
134
219
  <div className="flex items-center gap-2">
135
220
  <span className={`font-mono ${isComplete ? 'text-zinc-500' : 'text-zinc-400'}`}>#{chore.id}</span>
136
- {chore.mode && modeLabels[chore.mode] && (
221
+ {!isDone && chore.mode && modeLabels[chore.mode] && (
137
222
  <span className={`px-1 py-0.5 rounded text-[10px] ${modeLabels[chore.mode].color}`}>
138
223
  {getModeLabel(chore)}
139
224
  </span>
140
225
  )}
141
226
  <span className={`truncate ${
142
227
  isComplete
143
- ? 'text-zinc-500 line-through'
228
+ ? 'text-zinc-500'
144
229
  : 'text-zinc-700 dark:text-zinc-300'
145
230
  }`}>
146
231
  {chore.title || <span className="text-zinc-400 italic">(Untitled)</span>}
@@ -162,15 +247,110 @@ interface EpicGroupProps {
162
247
  epicTitle: string | null;
163
248
  items: WorkItem[];
164
249
  isInFlight?: boolean;
250
+ isDraggable?: boolean;
165
251
  onTitleSave?: (id: number, newTitle: string) => Promise<void>;
166
252
  onStatusChange?: (id: number, newStatus: string) => Promise<void>;
253
+ onEpicAssign?: (id: number, epicId: number | null) => Promise<void>;
254
+ onOrderChange?: (id: number, newOrder: number) => Promise<void>;
167
255
  }
168
256
 
169
- function EpicGroup({ epicId, epicTitle, items, isInFlight = false, onTitleSave, onStatusChange }: EpicGroupProps) {
170
- if (items.length === 0) return null;
257
+ function EpicGroup({ epicId, epicTitle, items, isInFlight = false, isDraggable = true, onTitleSave, onStatusChange, onEpicAssign, onOrderChange }: EpicGroupProps) {
258
+ const containerRef = useRef<HTMLDivElement>(null);
259
+ const { isDragging, draggedItem, activeEpicZone, registerEpicDropZone, unregisterEpicDropZone } = useDragContext();
260
+
261
+ // Handle reorder within this epic - calculate new display_order based on pointer Y
262
+ const handleEpicReorder = useCallback(async (itemId: number, pointerY: number) => {
263
+ if (!onOrderChange || !containerRef.current) return;
264
+
265
+ // Find all card elements in this epic container
266
+ const cardElements = containerRef.current.querySelectorAll('[data-item-id]');
267
+ const cardPositions: { id: number; midY: number }[] = [];
268
+
269
+ cardElements.forEach((el) => {
270
+ const id = parseInt(el.getAttribute('data-item-id') || '0', 10);
271
+ if (id !== itemId) {
272
+ const rect = el.getBoundingClientRect();
273
+ cardPositions.push({
274
+ id,
275
+ midY: (rect.top + rect.bottom) / 2,
276
+ });
277
+ }
278
+ });
279
+
280
+ // Skip reorder if this is the only item in the epic (no other cards to reorder against)
281
+ if (cardPositions.length === 0) {
282
+ return;
283
+ }
284
+
285
+ // Sort by Y position
286
+ cardPositions.sort((a, b) => a.midY - b.midY);
287
+
288
+ // Find insertion index based on pointer Y
289
+ let insertIndex = cardPositions.length;
290
+ for (let i = 0; i < cardPositions.length; i++) {
291
+ if (pointerY < cardPositions[i].midY) {
292
+ insertIndex = i;
293
+ break;
294
+ }
295
+ }
296
+
297
+ // Calculate new display_order using index * 10
298
+ const newOrder = insertIndex * 10;
299
+
300
+ try {
301
+ await onOrderChange(itemId, newOrder);
302
+ } catch (error) {
303
+ // Log error and notify user - items remain in original order since state wasn't updated
304
+ console.error('Failed to reorder item:', error);
305
+ // Show user-friendly error notification
306
+ alert('Failed to reorder item. Please try again.');
307
+ }
308
+ }, [onOrderChange]);
309
+
310
+ // Register as epic drop zone
311
+ useEffect(() => {
312
+ if (!containerRef.current || !onEpicAssign || epicId === null) return;
313
+
314
+ const zoneId = `epic-${epicId}`;
315
+ registerEpicDropZone(zoneId, {
316
+ epicId,
317
+ element: containerRef.current,
318
+ onEpicAssign,
319
+ onReorder: onOrderChange ? handleEpicReorder : undefined,
320
+ });
321
+
322
+ return () => {
323
+ unregisterEpicDropZone(zoneId);
324
+ };
325
+ }, [epicId, onEpicAssign, onOrderChange, handleEpicReorder, registerEpicDropZone, unregisterEpicDropZone]);
326
+
327
+ // Check if this epic zone is the active drop target
328
+ const isActiveTarget = activeEpicZone === `epic-${epicId}`;
329
+
330
+ // Check if the dragged item is from a different epic or same epic
331
+ const draggedItemEpicId = draggedItem ? (draggedItem.parent_id || draggedItem.epic_id) : null;
332
+ const isDifferentEpic = isDragging && draggedItem && draggedItemEpicId !== epicId;
333
+ const isSameEpic = isDragging && draggedItem && draggedItemEpicId === epicId;
334
+
335
+ // Show highlight when dragging an item from different epic over this group (indigo)
336
+ const showHighlight = isActiveTarget && isDifferentEpic;
337
+ // Show reorder highlight when dragging within same epic (purple)
338
+ const showReorderHighlight = isActiveTarget && isSameEpic;
339
+
340
+ if (items.length === 0 && !showHighlight && !showReorderHighlight) return null;
171
341
 
172
342
  return (
173
- <div className="mb-4">
343
+ <div
344
+ ref={containerRef}
345
+ className={`mb-4 p-2 -mx-2 rounded-lg transition-all ${
346
+ showHighlight
347
+ ? 'ring-2 ring-indigo-400 bg-indigo-100/50 dark:bg-indigo-900/30'
348
+ : showReorderHighlight
349
+ ? 'ring-2 ring-purple-400 bg-purple-100/50 dark:bg-purple-900/30'
350
+ : ''
351
+ }`}
352
+ data-epic-id={epicId}
353
+ >
174
354
  {epicTitle && (
175
355
  <div className="flex items-center gap-2 mb-2">
176
356
  <Link
@@ -185,11 +365,23 @@ function EpicGroup({ epicId, epicTitle, items, isInFlight = false, onTitleSave,
185
365
  in flight
186
366
  </span>
187
367
  )}
368
+ {showHighlight && (
369
+ <span className="text-xs px-1.5 py-0.5 rounded bg-indigo-100 text-indigo-700 dark:bg-indigo-900/50 dark:text-indigo-300">
370
+ drop to assign
371
+ </span>
372
+ )}
373
+ {showReorderHighlight && (
374
+ <span className="text-xs px-1.5 py-0.5 rounded bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300">
375
+ reorder
376
+ </span>
377
+ )}
188
378
  </div>
189
379
  )}
190
380
  <div className="space-y-2">
191
381
  {items.map((item) => (
192
- <KanbanCard key={item.id} item={item} onTitleSave={onTitleSave} onStatusChange={onStatusChange} />
382
+ <DraggableCard key={item.id} item={item} disabled={!isDraggable}>
383
+ <KanbanCard item={item} onTitleSave={onTitleSave} onStatusChange={onStatusChange} />
384
+ </DraggableCard>
193
385
  ))}
194
386
  </div>
195
387
  </div>
@@ -205,15 +397,15 @@ interface KanbanColumnProps {
205
397
  function KanbanColumn({ title, children, count }: KanbanColumnProps) {
206
398
  const testId = title.toLowerCase().replace(/\s+/g, '-') + '-column';
207
399
  return (
208
- <div className="flex-1 min-w-[300px] max-w-[400px]" data-testid={testId}>
209
- <div className="bg-zinc-100 dark:bg-zinc-900 rounded-lg p-3 h-full">
210
- <div className="flex items-center justify-between mb-3">
400
+ <div className="flex-1 min-w-[300px] max-w-[400px] flex flex-col min-h-0" data-testid={testId}>
401
+ <div className="bg-zinc-100 dark:bg-zinc-900 rounded-lg p-3 flex flex-col flex-1 min-h-0">
402
+ <div className="flex items-center justify-between mb-3 flex-shrink-0">
211
403
  <h2 className="font-semibold text-zinc-900 dark:text-zinc-100">{title}</h2>
212
404
  <span className="text-xs bg-zinc-200 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 px-2 py-0.5 rounded-full">
213
405
  {count}
214
406
  </span>
215
407
  </div>
216
- <div className="overflow-y-auto max-h-[calc(100vh-180px)]">
408
+ <div className="overflow-y-auto flex-1 min-h-0">
217
409
  {children}
218
410
  </div>
219
411
  </div>
@@ -227,11 +419,14 @@ interface KanbanBoardProps {
227
419
  done: Map<string, KanbanGroup>;
228
420
  onTitleSave?: (id: number, newTitle: string) => Promise<void>;
229
421
  onStatusChange?: (id: number, newStatus: string) => Promise<void>;
422
+ onOrderChange?: (id: number, newOrder: number) => Promise<void>;
423
+ onEpicAssign?: (id: number, epicId: number | null) => Promise<void>;
230
424
  }
231
425
 
232
- export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChange }: KanbanBoardProps) {
426
+ export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChange, onOrderChange, onEpicAssign }: KanbanBoardProps) {
233
427
  const backlogCount = inFlight.length + Array.from(backlog.values()).reduce((sum, g) => sum + g.items.length, 0);
234
428
  const doneCount = Array.from(done.values()).reduce((sum, g) => sum + g.items.length, 0);
429
+ const backlogContainerRef = useRef<HTMLDivElement>(null);
235
430
 
236
431
  // Build a set of epic IDs that have in-flight items
237
432
  const inFlightEpicIds = new Set<number>();
@@ -242,72 +437,178 @@ export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChan
242
437
  }
243
438
  }
244
439
 
440
+ // Get flat list of all backlog items for reordering
441
+ const getAllBacklogItems = useCallback((): WorkItem[] => {
442
+ const items: WorkItem[] = [];
443
+ for (const group of backlog.values()) {
444
+ items.push(...group.items);
445
+ }
446
+ return items;
447
+ }, [backlog]);
448
+
449
+ // Handle reorder within backlog - calculate new display_order based on pointer Y
450
+ const handleBacklogReorder = useCallback(async (itemId: number, pointerY: number) => {
451
+ if (!onOrderChange || !backlogContainerRef.current) return;
452
+
453
+ const items = getAllBacklogItems();
454
+ const draggedItem = items.find(item => item.id === itemId);
455
+ if (!draggedItem) return;
456
+
457
+ // Find all card elements in the backlog container
458
+ const cardElements = backlogContainerRef.current.querySelectorAll('[data-item-id]');
459
+ const cardPositions: { id: number; top: number; bottom: number; midY: number }[] = [];
460
+
461
+ cardElements.forEach((el) => {
462
+ const id = parseInt(el.getAttribute('data-item-id') || '0', 10);
463
+ if (id !== itemId) {
464
+ const rect = el.getBoundingClientRect();
465
+ cardPositions.push({
466
+ id,
467
+ top: rect.top,
468
+ bottom: rect.bottom,
469
+ midY: (rect.top + rect.bottom) / 2,
470
+ });
471
+ }
472
+ });
473
+
474
+ // Sort by Y position
475
+ cardPositions.sort((a, b) => a.midY - b.midY);
476
+
477
+ // Find insertion index based on pointer Y
478
+ let insertIndex = cardPositions.length;
479
+ for (let i = 0; i < cardPositions.length; i++) {
480
+ if (pointerY < cardPositions[i].midY) {
481
+ insertIndex = i;
482
+ break;
483
+ }
484
+ }
485
+
486
+ // Calculate new display_order
487
+ // Use index * 10 to leave room for future insertions
488
+ const newOrder = insertIndex * 10;
489
+
490
+ await onOrderChange(itemId, newOrder);
491
+ }, [getAllBacklogItems, onOrderChange]);
492
+
245
493
  return (
246
- <div className="flex gap-4 overflow-x-auto pb-4" data-testid="kanban-board">
494
+ <DragProvider>
495
+ <div className="flex gap-4 overflow-x-auto h-full" data-testid="kanban-board">
247
496
  {/* Backlog Column */}
248
497
  <KanbanColumn title="Backlog" count={backlogCount}>
249
- {/* In Flight Section */}
250
- {inFlight.length > 0 && (
251
- <div className="mb-4" data-testid="in-flight-section">
252
- <div className="flex items-center gap-1.5 text-xs font-medium text-blue-600 dark:text-blue-400 mb-2">
253
- <span>🔥</span>
254
- <span>In Flight</span>
498
+ {/* In Flight Section - Drop Zone */}
499
+ <DropZone
500
+ targetStatus="in_progress"
501
+ onDrop={async (itemId, newStatus) => {
502
+ if (onStatusChange) await onStatusChange(itemId, newStatus);
503
+ }}
504
+ className="rounded-lg mb-4 p-2 -m-2"
505
+ highlightClassName="ring-2 ring-blue-400 bg-blue-100/50 dark:bg-blue-900/30"
506
+ data-testid="in-flight-drop-zone"
507
+ >
508
+ {inFlight.length > 0 ? (
509
+ <div data-testid="in-flight-section">
510
+ <div className="flex items-center gap-1.5 text-xs font-medium text-blue-600 dark:text-blue-400 mb-2">
511
+ <span>🔥</span>
512
+ <span>In Flight</span>
513
+ </div>
514
+ <div className="space-y-2">
515
+ {inFlight.map((item) => (
516
+ <DraggableCard key={item.id} item={item}>
517
+ <KanbanCard
518
+ item={item}
519
+ epicTitle={item.epicTitle}
520
+ showEpic={true}
521
+ isInFlight={true}
522
+ onTitleSave={onTitleSave}
523
+ onStatusChange={onStatusChange}
524
+ />
525
+ </DraggableCard>
526
+ ))}
527
+ </div>
255
528
  </div>
256
- <div className="space-y-2">
257
- {inFlight.map((item) => (
258
- <KanbanCard
259
- key={item.id}
260
- item={item}
261
- epicTitle={item.epicTitle}
262
- showEpic={true}
263
- onTitleSave={onTitleSave}
264
- onStatusChange={onStatusChange}
265
- />
266
- ))}
529
+ ) : (
530
+ <div className="flex items-center gap-1.5 text-xs font-medium text-zinc-400 dark:text-zinc-500 py-2">
531
+ <span>🔥</span>
532
+ <span>Drop here to start work</span>
267
533
  </div>
268
- </div>
269
- )}
534
+ )}
535
+ </DropZone>
270
536
 
271
537
  {/* Divider if both sections have content */}
272
- {inFlight.length > 0 && backlog.size > 0 && (
538
+ {(inFlight.length > 0 || backlog.size > 0) && (
273
539
  <hr className="border-zinc-300 dark:border-zinc-700 my-4" />
274
540
  )}
275
541
 
276
- {/* Grouped Backlog Items */}
277
- {Array.from(backlog.entries()).map(([key, group]) => (
278
- <EpicGroup
279
- key={key}
280
- epicId={group.epicId}
281
- epicTitle={group.epicTitle}
282
- items={group.items}
283
- isInFlight={group.epicId ? inFlightEpicIds.has(group.epicId) : false}
284
- onTitleSave={onTitleSave}
285
- onStatusChange={onStatusChange}
286
- />
287
- ))}
542
+ {/* Backlog Section - Drop Zone with Reordering */}
543
+ <DropZone
544
+ targetStatus="backlog"
545
+ onDrop={async (itemId, newStatus) => {
546
+ if (onStatusChange) await onStatusChange(itemId, newStatus);
547
+ }}
548
+ onReorder={handleBacklogReorder}
549
+ allowReorder={true}
550
+ className="rounded-lg p-2 -m-2 min-h-[100px]"
551
+ highlightClassName="ring-2 ring-amber-400 bg-amber-100/50 dark:bg-amber-900/30"
552
+ reorderHighlightClassName="ring-2 ring-purple-400 bg-purple-100/50 dark:bg-purple-900/30"
553
+ data-testid="backlog-drop-zone"
554
+ >
555
+ <div ref={backlogContainerRef}>
556
+ {/* Grouped Backlog Items */}
557
+ {Array.from(backlog.entries()).map(([key, group]) => (
558
+ <EpicGroup
559
+ key={key}
560
+ epicId={group.epicId}
561
+ epicTitle={group.epicTitle}
562
+ items={group.items}
563
+ isInFlight={group.epicId ? inFlightEpicIds.has(group.epicId) : false}
564
+ onTitleSave={onTitleSave}
565
+ onStatusChange={onStatusChange}
566
+ onEpicAssign={onEpicAssign}
567
+ onOrderChange={onOrderChange}
568
+ />
569
+ ))}
570
+
571
+ {backlog.size === 0 && (
572
+ <p className="text-sm text-zinc-500 text-center py-4">Drop items here for backlog</p>
573
+ )}
574
+ </div>
575
+ </DropZone>
288
576
 
289
- {backlogCount === 0 && (
577
+ {backlogCount === 0 && inFlight.length === 0 && (
290
578
  <p className="text-sm text-zinc-500 text-center py-4">No items in backlog</p>
291
579
  )}
292
580
  </KanbanColumn>
293
581
 
294
582
  {/* Done Column */}
295
583
  <KanbanColumn title="Done" count={doneCount}>
296
- {Array.from(done.entries()).map(([key, group]) => (
297
- <EpicGroup
298
- key={key}
299
- epicId={group.epicId}
300
- epicTitle={group.epicTitle}
301
- items={group.items}
302
- onTitleSave={onTitleSave}
303
- onStatusChange={onStatusChange}
304
- />
305
- ))}
584
+ <DropZone
585
+ targetStatus="done"
586
+ onDrop={async (itemId, newStatus) => {
587
+ if (onStatusChange) await onStatusChange(itemId, newStatus);
588
+ }}
589
+ className="rounded-lg p-2 -m-2 min-h-[100px]"
590
+ highlightClassName="ring-2 ring-green-400 bg-green-100/50 dark:bg-green-900/30"
591
+ data-testid="done-drop-zone"
592
+ >
593
+ {Array.from(done.entries()).map(([key, group]) => (
594
+ <EpicGroup
595
+ key={key}
596
+ epicId={group.epicId}
597
+ epicTitle={group.epicTitle}
598
+ items={group.items}
599
+ isDraggable={false}
600
+ onTitleSave={onTitleSave}
601
+ onStatusChange={onStatusChange}
602
+ onEpicAssign={onEpicAssign}
603
+ />
604
+ ))}
306
605
 
307
- {doneCount === 0 && (
308
- <p className="text-sm text-zinc-500 text-center py-4">No completed items</p>
309
- )}
606
+ {doneCount === 0 && (
607
+ <p className="text-sm text-zinc-500 text-center py-4">Drop here to mark complete</p>
608
+ )}
609
+ </DropZone>
310
610
  </KanbanColumn>
311
611
  </div>
612
+ </DragProvider>
312
613
  );
313
614
  }