jettypod 4.4.73 → 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.
- package/apps/dashboard/app/api/decisions/route.ts +9 -0
- package/apps/dashboard/app/api/work/[id]/epic/route.ts +21 -0
- package/apps/dashboard/app/api/work/[id]/order/route.ts +21 -0
- package/apps/dashboard/app/page.tsx +15 -8
- package/apps/dashboard/components/DragContext.tsx +163 -0
- package/apps/dashboard/components/DraggableCard.tsx +68 -0
- package/apps/dashboard/components/DropZone.tsx +71 -0
- package/apps/dashboard/components/KanbanBoard.tsx +385 -84
- package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +60 -14
- package/apps/dashboard/components/RecentDecisionsWidget.tsx +58 -0
- package/apps/dashboard/lib/db.ts +105 -9
- package/apps/dashboard/package.json +2 -1
- package/lib/database.js +3 -1
- package/lib/migrations/017-display-order-column.js +48 -0
- package/lib/migrations/019-discovery-completed-at.js +36 -0
- package/lib/migrations/020-normalize-timestamps.js +59 -0
- package/lib/work-commands/index.js +3 -2
- package/lib/work-tracking/index.js +2 -2
- package/package.json +3 -2
- package/skills-templates/feature-planning/SKILL.md +41 -0
- package/skills-templates/simple-improvement/SKILL.md +327 -0
|
@@ -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
|
|
45
|
-
const
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
<
|
|
85
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
{
|
|
111
|
-
|
|
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=
|
|
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>
|
|
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
|
-
{
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
<
|
|
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-
|
|
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
|
|
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
|
-
<
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
269
|
-
|
|
534
|
+
)}
|
|
535
|
+
</DropZone>
|
|
270
536
|
|
|
271
537
|
{/* Divider if both sections have content */}
|
|
272
|
-
{inFlight.length > 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
|
-
{/*
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
308
|
-
|
|
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
|
}
|