jettypod 4.4.83 → 4.4.84
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/components/DragContext.tsx +105 -7
- package/apps/dashboard/components/DraggableCard.tsx +4 -0
- package/apps/dashboard/components/KanbanBoard.tsx +125 -29
- package/apps/dashboard/components/PlaceholderCard.tsx +30 -0
- package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +6 -3
- package/apps/dashboard/lib/db.ts +9 -0
- package/jettypod.js +23 -19
- package/lib/db-import.js +120 -1
- package/package.json +1 -1
- package/skills-templates/bug-planning/SKILL.md +1 -1
- package/skills-templates/chore-planning/SKILL.md +1 -1
- package/skills-templates/epic-planning/SKILL.md +1 -1
- package/skills-templates/feature-planning/SKILL.md +1 -42
- package/skills-templates/plan-routing/SKILL.md +188 -0
- package/skills-templates/simple-improvement/SKILL.md +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { createContext, useContext, useState, useCallback, useRef, ReactNode } from 'react';
|
|
3
|
+
import { createContext, useContext, useState, useCallback, useRef, useEffect, ReactNode } from 'react';
|
|
4
4
|
import { createPortal } from 'react-dom';
|
|
5
5
|
import type { WorkItem } from '@/lib/db';
|
|
6
6
|
|
|
@@ -27,14 +27,21 @@ interface CardPosition {
|
|
|
27
27
|
rect: DOMRect;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
interface InsertionPreview {
|
|
31
|
+
afterCardId: number | null; // null means insert at beginning
|
|
32
|
+
zoneId: string; // epic zone or drop zone id
|
|
33
|
+
}
|
|
34
|
+
|
|
30
35
|
interface DragContextType {
|
|
31
36
|
isDragging: boolean;
|
|
32
37
|
draggedItem: WorkItem | null;
|
|
33
38
|
activeDropZone: string | null;
|
|
34
39
|
activeEpicZone: string | null;
|
|
40
|
+
insertionPreview: InsertionPreview | null;
|
|
35
41
|
dragPosition: { x: number; y: number };
|
|
36
42
|
dragOffset: { x: number; y: number };
|
|
37
43
|
draggedCardWidth: number;
|
|
44
|
+
draggedCardHeight: number;
|
|
38
45
|
setDraggedItem: (item: WorkItem | null) => void;
|
|
39
46
|
registerDropZone: (id: string, info: DropZoneInfo) => void;
|
|
40
47
|
unregisterDropZone: (id: string) => void;
|
|
@@ -53,9 +60,11 @@ const DragContext = createContext<DragContextType>({
|
|
|
53
60
|
draggedItem: null,
|
|
54
61
|
activeDropZone: null,
|
|
55
62
|
activeEpicZone: null,
|
|
63
|
+
insertionPreview: null,
|
|
56
64
|
dragPosition: { x: 0, y: 0 },
|
|
57
65
|
dragOffset: { x: 0, y: 0 },
|
|
58
66
|
draggedCardWidth: 0,
|
|
67
|
+
draggedCardHeight: 0,
|
|
59
68
|
setDraggedItem: () => {},
|
|
60
69
|
registerDropZone: () => {},
|
|
61
70
|
unregisterDropZone: () => {},
|
|
@@ -72,19 +81,23 @@ const DragContext = createContext<DragContextType>({
|
|
|
72
81
|
interface DragProviderProps {
|
|
73
82
|
children: ReactNode;
|
|
74
83
|
renderDragOverlay?: (item: WorkItem) => ReactNode;
|
|
84
|
+
onRemoveFromEpic?: EpicAssignHandler;
|
|
75
85
|
}
|
|
76
86
|
|
|
77
|
-
export function DragProvider({ children, renderDragOverlay }: DragProviderProps) {
|
|
87
|
+
export function DragProvider({ children, renderDragOverlay, onRemoveFromEpic }: DragProviderProps) {
|
|
78
88
|
const [draggedItem, setDraggedItem] = useState<WorkItem | null>(null);
|
|
79
89
|
const [activeDropZone, setActiveDropZone] = useState<string | null>(null);
|
|
80
90
|
const [activeEpicZone, setActiveEpicZone] = useState<string | null>(null);
|
|
91
|
+
const [insertionPreview, setInsertionPreview] = useState<InsertionPreview | null>(null);
|
|
81
92
|
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
|
|
82
93
|
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
|
83
94
|
const [draggedCardWidth, setDraggedCardWidth] = useState(0);
|
|
95
|
+
const [draggedCardHeight, setDraggedCardHeight] = useState(0);
|
|
84
96
|
const dropZonesRef = useRef<Map<string, DropZoneInfo>>(new Map());
|
|
85
97
|
const epicDropZonesRef = useRef<Map<string, EpicDropZoneInfo>>(new Map());
|
|
86
98
|
const cardPositionsRef = useRef<Map<number, DOMRect>>(new Map());
|
|
87
99
|
const pointerPositionRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
|
|
100
|
+
const draggedItemRef = useRef<WorkItem | null>(null);
|
|
88
101
|
|
|
89
102
|
const registerDropZone = useCallback((id: string, info: DropZoneInfo) => {
|
|
90
103
|
dropZonesRef.current.set(id, info);
|
|
@@ -103,10 +116,12 @@ export function DragProvider({ children, renderDragOverlay }: DragProviderProps)
|
|
|
103
116
|
}, []);
|
|
104
117
|
|
|
105
118
|
const registerCardPosition = useCallback((id: number, rect: DOMRect) => {
|
|
119
|
+
console.log('[DragContext] registerCardPosition:', id, 'rect:', { top: rect.top, bottom: rect.bottom });
|
|
106
120
|
cardPositionsRef.current.set(id, rect);
|
|
107
121
|
}, []);
|
|
108
122
|
|
|
109
123
|
const unregisterCard = useCallback((id: number) => {
|
|
124
|
+
console.log('[DragContext] unregisterCard:', id);
|
|
110
125
|
cardPositionsRef.current.delete(id);
|
|
111
126
|
}, []);
|
|
112
127
|
|
|
@@ -115,6 +130,7 @@ export function DragProvider({ children, renderDragOverlay }: DragProviderProps)
|
|
|
115
130
|
cardPositionsRef.current.forEach((rect, id) => {
|
|
116
131
|
positions.push({ id, rect });
|
|
117
132
|
});
|
|
133
|
+
console.log('[DragContext] getCardPositions:', positions.length, 'positions', positions.map(p => ({ id: p.id, midY: (p.rect.top + p.rect.bottom) / 2 })));
|
|
118
134
|
return positions;
|
|
119
135
|
}, []);
|
|
120
136
|
|
|
@@ -142,6 +158,32 @@ export function DragProvider({ children, renderDragOverlay }: DragProviderProps)
|
|
|
142
158
|
|
|
143
159
|
setActiveDropZone(foundZone);
|
|
144
160
|
setActiveEpicZone(foundEpicZone);
|
|
161
|
+
|
|
162
|
+
// Calculate insertion preview based on pointer position
|
|
163
|
+
const zoneId = foundEpicZone || foundZone;
|
|
164
|
+
if (zoneId && draggedItemRef.current) {
|
|
165
|
+
const allPositions = Array.from(cardPositionsRef.current.entries())
|
|
166
|
+
.filter(([id]) => id !== draggedItemRef.current?.id)
|
|
167
|
+
.map(([id, rect]) => ({
|
|
168
|
+
id,
|
|
169
|
+
midY: (rect.top + rect.bottom) / 2,
|
|
170
|
+
}))
|
|
171
|
+
.sort((a, b) => a.midY - b.midY);
|
|
172
|
+
|
|
173
|
+
// Find which card the pointer is after
|
|
174
|
+
let afterCardId: number | null = null;
|
|
175
|
+
for (const pos of allPositions) {
|
|
176
|
+
if (y > pos.midY) {
|
|
177
|
+
afterCardId = pos.id;
|
|
178
|
+
} else {
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
setInsertionPreview({ afterCardId, zoneId });
|
|
184
|
+
} else {
|
|
185
|
+
setInsertionPreview(null);
|
|
186
|
+
}
|
|
145
187
|
}, []);
|
|
146
188
|
|
|
147
189
|
const startDrag = useCallback((item: WorkItem, cardRect: DOMRect, pointerX: number, pointerY: number) => {
|
|
@@ -151,52 +193,106 @@ export function DragProvider({ children, renderDragOverlay }: DragProviderProps)
|
|
|
151
193
|
setDragOffset({ x: offsetX, y: offsetY });
|
|
152
194
|
setDragPosition({ x: pointerX, y: pointerY });
|
|
153
195
|
setDraggedCardWidth(cardRect.width);
|
|
196
|
+
setDraggedCardHeight(cardRect.height);
|
|
154
197
|
setDraggedItem(item);
|
|
198
|
+
draggedItemRef.current = item;
|
|
155
199
|
}, []);
|
|
156
200
|
|
|
157
201
|
const handleDrop = useCallback(async () => {
|
|
202
|
+
console.log('[DragContext] handleDrop called:', {
|
|
203
|
+
draggedItem: draggedItem ? { id: draggedItem.id, parent_id: draggedItem.parent_id, epic_id: draggedItem.epic_id } : null,
|
|
204
|
+
activeEpicZone,
|
|
205
|
+
activeDropZone
|
|
206
|
+
});
|
|
158
207
|
if (!draggedItem) return;
|
|
159
208
|
|
|
160
209
|
// Check for epic drop zone first (more specific drop target)
|
|
161
210
|
if (activeEpicZone) {
|
|
162
211
|
const epicZoneInfo = epicDropZonesRef.current.get(activeEpicZone);
|
|
212
|
+
console.log('[DragContext] epicZoneInfo:', epicZoneInfo ? { epicId: epicZoneInfo.epicId, hasOnReorder: !!epicZoneInfo.onReorder } : null);
|
|
163
213
|
if (epicZoneInfo) {
|
|
164
214
|
const currentEpicId = draggedItem.parent_id || draggedItem.epic_id;
|
|
215
|
+
console.log('[DragContext] currentEpicId:', currentEpicId, 'targetEpicId:', epicZoneInfo.epicId);
|
|
165
216
|
|
|
166
217
|
// Same epic - reorder within epic
|
|
167
218
|
if (currentEpicId === epicZoneInfo.epicId && epicZoneInfo.onReorder) {
|
|
219
|
+
console.log('[DragContext] Same epic - calling onReorder');
|
|
168
220
|
await epicZoneInfo.onReorder(draggedItem.id, pointerPositionRef.current.y);
|
|
169
221
|
return;
|
|
170
222
|
}
|
|
171
223
|
|
|
172
224
|
// Different epic - assign to new epic
|
|
173
225
|
if (currentEpicId !== epicZoneInfo.epicId) {
|
|
226
|
+
console.log('[DragContext] Different epic - calling onEpicAssign');
|
|
174
227
|
await epicZoneInfo.onEpicAssign(draggedItem.id, epicZoneInfo.epicId);
|
|
175
228
|
return;
|
|
176
229
|
}
|
|
177
230
|
}
|
|
178
231
|
}
|
|
179
232
|
|
|
180
|
-
//
|
|
181
|
-
|
|
233
|
+
// Check if card is being removed from an epic (dropped outside all epic zones)
|
|
234
|
+
const currentEpicId = draggedItem.parent_id || draggedItem.epic_id;
|
|
235
|
+
if (!activeEpicZone && currentEpicId && onRemoveFromEpic) {
|
|
236
|
+
console.log('[DragContext] Removing from epic - card has epic_id but dropped outside epic zones');
|
|
237
|
+
await onRemoveFromEpic(draggedItem.id, null);
|
|
238
|
+
// Continue to also handle status change if needed
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Handle status drop zone
|
|
242
|
+
if (!activeDropZone) {
|
|
243
|
+
console.log('[DragContext] No activeDropZone, returning');
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
182
246
|
|
|
183
247
|
const zoneInfo = dropZonesRef.current.get(activeDropZone);
|
|
184
|
-
if (!zoneInfo)
|
|
248
|
+
if (!zoneInfo) {
|
|
249
|
+
console.log('[DragContext] No zoneInfo for activeDropZone:', activeDropZone);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
console.log('[DragContext] Status drop zone:', { targetStatus: zoneInfo.targetStatus, itemStatus: draggedItem.status, hasOnReorder: !!zoneInfo.onReorder });
|
|
185
254
|
|
|
186
255
|
// Check if this is a status change or a reorder
|
|
187
256
|
if (draggedItem.status !== zoneInfo.targetStatus) {
|
|
188
257
|
// Status change
|
|
258
|
+
console.log('[DragContext] Status change - calling onDrop');
|
|
189
259
|
await zoneInfo.onDrop(draggedItem.id, zoneInfo.targetStatus);
|
|
190
260
|
} else if (zoneInfo.onReorder) {
|
|
191
261
|
// Reorder within same status
|
|
262
|
+
console.log('[DragContext] Same status - calling onReorder');
|
|
192
263
|
await zoneInfo.onReorder(draggedItem.id, pointerPositionRef.current.y);
|
|
264
|
+
} else {
|
|
265
|
+
console.log('[DragContext] Same status but no onReorder handler');
|
|
193
266
|
}
|
|
194
|
-
}, [activeDropZone, activeEpicZone, draggedItem]);
|
|
267
|
+
}, [activeDropZone, activeEpicZone, draggedItem, onRemoveFromEpic]);
|
|
195
268
|
|
|
196
269
|
// Calculate overlay position (pointer position minus offset = card top-left)
|
|
197
270
|
const overlayX = dragPosition.x - dragOffset.x;
|
|
198
271
|
const overlayY = dragPosition.y - dragOffset.y;
|
|
199
272
|
|
|
273
|
+
// Wrap setDraggedItem to also clear ref and preview
|
|
274
|
+
const handleSetDraggedItem = useCallback((item: WorkItem | null) => {
|
|
275
|
+
setDraggedItem(item);
|
|
276
|
+
draggedItemRef.current = item;
|
|
277
|
+
if (!item) {
|
|
278
|
+
setInsertionPreview(null);
|
|
279
|
+
}
|
|
280
|
+
}, []);
|
|
281
|
+
|
|
282
|
+
// Cancel drag on Escape key
|
|
283
|
+
useEffect(() => {
|
|
284
|
+
if (!draggedItem) return;
|
|
285
|
+
|
|
286
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
287
|
+
if (e.key === 'Escape') {
|
|
288
|
+
handleSetDraggedItem(null);
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
window.addEventListener('keydown', handleKeyDown);
|
|
293
|
+
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
294
|
+
}, [draggedItem, handleSetDraggedItem]);
|
|
295
|
+
|
|
200
296
|
return (
|
|
201
297
|
<DragContext.Provider
|
|
202
298
|
value={{
|
|
@@ -204,10 +300,12 @@ export function DragProvider({ children, renderDragOverlay }: DragProviderProps)
|
|
|
204
300
|
draggedItem,
|
|
205
301
|
activeDropZone,
|
|
206
302
|
activeEpicZone,
|
|
303
|
+
insertionPreview,
|
|
207
304
|
dragPosition,
|
|
208
305
|
dragOffset,
|
|
209
306
|
draggedCardWidth,
|
|
210
|
-
|
|
307
|
+
draggedCardHeight,
|
|
308
|
+
setDraggedItem: handleSetDraggedItem,
|
|
211
309
|
registerDropZone,
|
|
212
310
|
unregisterDropZone,
|
|
213
311
|
registerEpicDropZone,
|
|
@@ -83,6 +83,10 @@ export function DraggableCard({ item, children, disabled = false }: DraggableCar
|
|
|
83
83
|
whileDrag={{
|
|
84
84
|
cursor: 'grabbing',
|
|
85
85
|
opacity: 0,
|
|
86
|
+
height: 0,
|
|
87
|
+
marginBottom: 0,
|
|
88
|
+
padding: 0,
|
|
89
|
+
overflow: 'hidden',
|
|
86
90
|
}}
|
|
87
91
|
onDragStart={handleDragStart}
|
|
88
92
|
onDrag={handleDrag}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
|
4
4
|
import Link from 'next/link';
|
|
5
5
|
import { useRouter } from 'next/navigation';
|
|
6
|
+
import { AnimatePresence } from 'framer-motion';
|
|
6
7
|
import type { WorkItem, InFlightItem, KanbanGroup } from '@/lib/db';
|
|
7
8
|
import type { UndoAction } from '@/lib/undoStack';
|
|
8
9
|
import { EditableTitle } from './EditableTitle';
|
|
@@ -10,6 +11,7 @@ import { CardMenu } from './CardMenu';
|
|
|
10
11
|
import { DragProvider, useDragContext } from './DragContext';
|
|
11
12
|
import { DraggableCard } from './DraggableCard';
|
|
12
13
|
import { DropZone } from './DropZone';
|
|
14
|
+
import { PlaceholderCard } from './PlaceholderCard';
|
|
13
15
|
|
|
14
16
|
const typeIcons: Record<string, string> = {
|
|
15
17
|
epic: '🎯',
|
|
@@ -257,26 +259,42 @@ interface EpicGroupProps {
|
|
|
257
259
|
|
|
258
260
|
function EpicGroup({ epicId, epicTitle, items, isInFlight = false, isDraggable = true, onTitleSave, onStatusChange, onEpicAssign, onOrderChange }: EpicGroupProps) {
|
|
259
261
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
260
|
-
const { isDragging, draggedItem, activeEpicZone, registerEpicDropZone, unregisterEpicDropZone, getCardPositions } = useDragContext();
|
|
262
|
+
const { isDragging, draggedItem, activeEpicZone, activeDropZone, dragPosition, draggedCardHeight, registerEpicDropZone, unregisterEpicDropZone, getCardPositions } = useDragContext();
|
|
261
263
|
|
|
262
|
-
//
|
|
263
|
-
const
|
|
264
|
+
// Use ref for items to avoid re-registering drop zone when items change
|
|
265
|
+
const itemsRef = useRef(items);
|
|
266
|
+
itemsRef.current = items;
|
|
264
267
|
|
|
265
|
-
//
|
|
268
|
+
// Use ref for callbacks to keep drop zone registration stable
|
|
269
|
+
const onOrderChangeRef = useRef(onOrderChange);
|
|
270
|
+
onOrderChangeRef.current = onOrderChange;
|
|
271
|
+
|
|
272
|
+
// Stable reorder handler that reads from refs
|
|
266
273
|
const handleEpicReorder = useCallback(async (itemId: number, pointerY: number) => {
|
|
267
|
-
|
|
274
|
+
console.log('[EpicGroup] handleEpicReorder called:', { itemId, pointerY, epicId });
|
|
275
|
+
if (!onOrderChangeRef.current) {
|
|
276
|
+
console.log('[EpicGroup] No onOrderChange, returning');
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Get current item IDs from ref
|
|
281
|
+
const itemIds = new Set(itemsRef.current.map(item => item.id));
|
|
282
|
+
console.log('[EpicGroup] itemIds in this epic:', Array.from(itemIds));
|
|
268
283
|
|
|
269
284
|
// Use cached card positions from registry, filtered to this epic's items
|
|
270
285
|
const allPositions = getCardPositions();
|
|
286
|
+
console.log('[EpicGroup] allPositions:', allPositions.length);
|
|
271
287
|
const cardPositions = allPositions
|
|
272
288
|
.filter(pos => itemIds.has(pos.id) && pos.id !== itemId)
|
|
273
289
|
.map(pos => ({
|
|
274
290
|
id: pos.id,
|
|
275
291
|
midY: (pos.rect.top + pos.rect.bottom) / 2,
|
|
276
292
|
}));
|
|
293
|
+
console.log('[EpicGroup] filtered cardPositions:', cardPositions);
|
|
277
294
|
|
|
278
295
|
// Skip reorder if this is the only item in the epic (no other cards to reorder against)
|
|
279
296
|
if (cardPositions.length === 0) {
|
|
297
|
+
console.log('[EpicGroup] No other cards to reorder against, returning');
|
|
280
298
|
return;
|
|
281
299
|
}
|
|
282
300
|
|
|
@@ -294,18 +312,21 @@ function EpicGroup({ epicId, epicTitle, items, isInFlight = false, isDraggable =
|
|
|
294
312
|
|
|
295
313
|
// Calculate new display_order using index * 10
|
|
296
314
|
const newOrder = insertIndex * 10;
|
|
315
|
+
console.log('[EpicGroup] Calculated insertIndex:', insertIndex, 'newOrder:', newOrder, 'for itemId:', itemId);
|
|
297
316
|
|
|
298
317
|
try {
|
|
299
|
-
|
|
318
|
+
console.log('[EpicGroup] Calling onOrderChange...');
|
|
319
|
+
await onOrderChangeRef.current(itemId, newOrder);
|
|
320
|
+
console.log('[EpicGroup] onOrderChange completed successfully');
|
|
300
321
|
} catch (error) {
|
|
301
322
|
// Log error and notify user - items remain in original order since state wasn't updated
|
|
302
|
-
console.error('Failed to reorder item:', error);
|
|
323
|
+
console.error('[EpicGroup] Failed to reorder item:', error);
|
|
303
324
|
// Show user-friendly error notification
|
|
304
325
|
alert('Failed to reorder item. Please try again.');
|
|
305
326
|
}
|
|
306
|
-
}, [
|
|
327
|
+
}, [getCardPositions]);
|
|
307
328
|
|
|
308
|
-
// Register as epic drop zone
|
|
329
|
+
// Register as epic drop zone - stable registration that doesn't change with items
|
|
309
330
|
useEffect(() => {
|
|
310
331
|
if (!containerRef.current || !onEpicAssign || epicId === null) return;
|
|
311
332
|
|
|
@@ -314,13 +335,13 @@ function EpicGroup({ epicId, epicTitle, items, isInFlight = false, isDraggable =
|
|
|
314
335
|
epicId,
|
|
315
336
|
element: containerRef.current,
|
|
316
337
|
onEpicAssign,
|
|
317
|
-
onReorder:
|
|
338
|
+
onReorder: handleEpicReorder,
|
|
318
339
|
});
|
|
319
340
|
|
|
320
341
|
return () => {
|
|
321
342
|
unregisterEpicDropZone(zoneId);
|
|
322
343
|
};
|
|
323
|
-
}, [epicId, onEpicAssign,
|
|
344
|
+
}, [epicId, onEpicAssign, handleEpicReorder, registerEpicDropZone, unregisterEpicDropZone]);
|
|
324
345
|
|
|
325
346
|
// Check if this epic zone is the active drop target
|
|
326
347
|
const isActiveTarget = activeEpicZone === `epic-${epicId}`;
|
|
@@ -335,7 +356,45 @@ function EpicGroup({ epicId, epicTitle, items, isInFlight = false, isDraggable =
|
|
|
335
356
|
// Show reorder highlight when dragging within same epic (purple)
|
|
336
357
|
const showReorderHighlight = isActiveTarget && isSameEpic;
|
|
337
358
|
|
|
338
|
-
|
|
359
|
+
// For ungrouped section (epicId === null)
|
|
360
|
+
const isUngroupedSection = epicId === null;
|
|
361
|
+
// Check if cursor is over this ungrouped section (not over any epic zone, but over backlog drop zone)
|
|
362
|
+
const isOverUngroupedSection = isUngroupedSection && !activeEpicZone && activeDropZone;
|
|
363
|
+
|
|
364
|
+
// Render the ungrouped zone when dragging from an epic (provides drop target), but only highlight when cursor is over it
|
|
365
|
+
const shouldRenderUngroupedZone = isUngroupedSection && isDragging && draggedItemEpicId !== null;
|
|
366
|
+
const showRemoveFromEpicZone = isOverUngroupedSection && isDragging && draggedItemEpicId !== null;
|
|
367
|
+
|
|
368
|
+
// Show reorder for ungrouped section when dragging an ungrouped card and cursor is over it
|
|
369
|
+
const showUngroupedReorder = isOverUngroupedSection && isDragging && draggedItemEpicId === null;
|
|
370
|
+
|
|
371
|
+
// Calculate insertion preview for this group - only for the active zone
|
|
372
|
+
const showPreview = (showReorderHighlight || showRemoveFromEpicZone || showHighlight || showUngroupedReorder) && draggedItem;
|
|
373
|
+
let insertAfterItemId: number | null | undefined = undefined; // undefined = no preview, null = at beginning
|
|
374
|
+
|
|
375
|
+
if (showPreview && draggedItem) {
|
|
376
|
+
const allPositions = getCardPositions();
|
|
377
|
+
const itemIds = new Set(items.map(item => item.id));
|
|
378
|
+
const groupPositions = allPositions
|
|
379
|
+
.filter(pos => itemIds.has(pos.id) && pos.id !== draggedItem.id)
|
|
380
|
+
.map(pos => ({
|
|
381
|
+
id: pos.id,
|
|
382
|
+
midY: (pos.rect.top + pos.rect.bottom) / 2,
|
|
383
|
+
}))
|
|
384
|
+
.sort((a, b) => a.midY - b.midY);
|
|
385
|
+
|
|
386
|
+
// Find which card the pointer is after
|
|
387
|
+
insertAfterItemId = null; // Default to beginning
|
|
388
|
+
for (const pos of groupPositions) {
|
|
389
|
+
if (dragPosition.y > pos.midY) {
|
|
390
|
+
insertAfterItemId = pos.id;
|
|
391
|
+
} else {
|
|
392
|
+
break;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (items.length === 0 && !showHighlight && !showReorderHighlight && !shouldRenderUngroupedZone) return null;
|
|
339
398
|
|
|
340
399
|
return (
|
|
341
400
|
<div
|
|
@@ -345,6 +404,8 @@ function EpicGroup({ epicId, epicTitle, items, isInFlight = false, isDraggable =
|
|
|
345
404
|
? 'ring-2 ring-indigo-400 bg-indigo-100/50 dark:bg-indigo-900/30'
|
|
346
405
|
: showReorderHighlight
|
|
347
406
|
? 'ring-2 ring-purple-400 bg-purple-100/50 dark:bg-purple-900/30'
|
|
407
|
+
: showRemoveFromEpicZone
|
|
408
|
+
? 'ring-2 ring-orange-400 bg-orange-100/50 dark:bg-orange-900/30'
|
|
348
409
|
: ''
|
|
349
410
|
}`}
|
|
350
411
|
data-epic-id={epicId}
|
|
@@ -375,11 +436,43 @@ function EpicGroup({ epicId, epicTitle, items, isInFlight = false, isDraggable =
|
|
|
375
436
|
)}
|
|
376
437
|
</div>
|
|
377
438
|
)}
|
|
439
|
+
{/* Ungrouped section header - shown when dragging from epic */}
|
|
440
|
+
{isUngroupedSection && showRemoveFromEpicZone && items.length === 0 && (
|
|
441
|
+
<div className="flex items-center gap-2 py-3">
|
|
442
|
+
<span className="text-xs font-medium text-orange-600 dark:text-orange-400">
|
|
443
|
+
Drop here to remove from epic
|
|
444
|
+
</span>
|
|
445
|
+
</div>
|
|
446
|
+
)}
|
|
447
|
+
{isUngroupedSection && items.length > 0 && (
|
|
448
|
+
<div className="flex items-center gap-2 mb-2">
|
|
449
|
+
<span className="text-xs font-medium text-zinc-400 dark:text-zinc-500">Ungrouped</span>
|
|
450
|
+
{showRemoveFromEpicZone && (
|
|
451
|
+
<span className="text-xs px-1.5 py-0.5 rounded bg-orange-100 text-orange-700 dark:bg-orange-900/50 dark:text-orange-300">
|
|
452
|
+
drop to remove from epic
|
|
453
|
+
</span>
|
|
454
|
+
)}
|
|
455
|
+
</div>
|
|
456
|
+
)}
|
|
378
457
|
<div className="space-y-2">
|
|
458
|
+
{/* Placeholder at the beginning (insertAfterItemId === null) */}
|
|
459
|
+
<AnimatePresence>
|
|
460
|
+
{insertAfterItemId === null && (
|
|
461
|
+
<PlaceholderCard key="placeholder-start" height={draggedCardHeight} />
|
|
462
|
+
)}
|
|
463
|
+
</AnimatePresence>
|
|
379
464
|
{items.map((item) => (
|
|
380
|
-
<
|
|
381
|
-
<
|
|
382
|
-
|
|
465
|
+
<div key={item.id}>
|
|
466
|
+
<DraggableCard item={item} disabled={!isDraggable}>
|
|
467
|
+
<KanbanCard item={item} onTitleSave={onTitleSave} onStatusChange={onStatusChange} />
|
|
468
|
+
</DraggableCard>
|
|
469
|
+
{/* Placeholder after this card */}
|
|
470
|
+
<AnimatePresence>
|
|
471
|
+
{insertAfterItemId === item.id && (
|
|
472
|
+
<PlaceholderCard key={`placeholder-${item.id}`} height={draggedCardHeight} />
|
|
473
|
+
)}
|
|
474
|
+
</AnimatePresence>
|
|
475
|
+
</div>
|
|
383
476
|
))}
|
|
384
477
|
</div>
|
|
385
478
|
</div>
|
|
@@ -422,20 +515,23 @@ interface BacklogDropZoneWrapperProps {
|
|
|
422
515
|
function BacklogDropZoneWrapper({ backlog, onStatusChange, onOrderChange, children }: BacklogDropZoneWrapperProps) {
|
|
423
516
|
const { getCardPositions } = useDragContext();
|
|
424
517
|
|
|
425
|
-
//
|
|
426
|
-
const
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
ids.add(item.id);
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
return ids;
|
|
434
|
-
}, [backlog]);
|
|
518
|
+
// Use refs to keep handler stable
|
|
519
|
+
const backlogRef = useRef(backlog);
|
|
520
|
+
backlogRef.current = backlog;
|
|
521
|
+
const onOrderChangeRef = useRef(onOrderChange);
|
|
522
|
+
onOrderChangeRef.current = onOrderChange;
|
|
435
523
|
|
|
436
524
|
// Handle reorder within backlog - calculate new display_order based on pointer Y
|
|
437
525
|
const handleBacklogReorder = useCallback(async (itemId: number, pointerY: number) => {
|
|
438
|
-
if (!
|
|
526
|
+
if (!onOrderChangeRef.current) return;
|
|
527
|
+
|
|
528
|
+
// Get current backlog item IDs from ref
|
|
529
|
+
const backlogItemIds = new Set<number>();
|
|
530
|
+
for (const group of backlogRef.current.values()) {
|
|
531
|
+
for (const item of group.items) {
|
|
532
|
+
backlogItemIds.add(item.id);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
439
535
|
|
|
440
536
|
// Use cached card positions from registry, filtered to backlog items
|
|
441
537
|
const allPositions = getCardPositions();
|
|
@@ -462,8 +558,8 @@ function BacklogDropZoneWrapper({ backlog, onStatusChange, onOrderChange, childr
|
|
|
462
558
|
// Use index * 10 to leave room for future insertions
|
|
463
559
|
const newOrder = insertIndex * 10;
|
|
464
560
|
|
|
465
|
-
await
|
|
466
|
-
}, [
|
|
561
|
+
await onOrderChangeRef.current(itemId, newOrder);
|
|
562
|
+
}, [getCardPositions]);
|
|
467
563
|
|
|
468
564
|
return (
|
|
469
565
|
<DropZone
|
|
@@ -561,7 +657,7 @@ export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChan
|
|
|
561
657
|
}, [inFlight]);
|
|
562
658
|
|
|
563
659
|
return (
|
|
564
|
-
<DragProvider renderDragOverlay={renderDragOverlay}>
|
|
660
|
+
<DragProvider renderDragOverlay={renderDragOverlay} onRemoveFromEpic={onEpicAssign}>
|
|
565
661
|
<div className="flex gap-4 overflow-x-auto h-full" data-testid="kanban-board">
|
|
566
662
|
{/* Backlog Column */}
|
|
567
663
|
<KanbanColumn title="Backlog" count={backlogCount}>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { motion } from 'framer-motion';
|
|
4
|
+
|
|
5
|
+
interface PlaceholderCardProps {
|
|
6
|
+
height: number;
|
|
7
|
+
minHeight?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const DEFAULT_MIN_HEIGHT = 40;
|
|
11
|
+
|
|
12
|
+
export function PlaceholderCard({ height, minHeight = DEFAULT_MIN_HEIGHT }: PlaceholderCardProps) {
|
|
13
|
+
const effectiveHeight = Math.max(height, minHeight);
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<motion.div
|
|
17
|
+
data-testid="drag-placeholder"
|
|
18
|
+
initial={{ opacity: 0, height: 0 }}
|
|
19
|
+
animate={{ opacity: 0.6, height: effectiveHeight }}
|
|
20
|
+
exit={{ opacity: 0, height: 0 }}
|
|
21
|
+
transition={{ duration: 0.2, ease: 'easeOut' }}
|
|
22
|
+
style={{
|
|
23
|
+
borderRadius: 8,
|
|
24
|
+
border: '2px dashed rgba(100, 116, 139, 0.4)',
|
|
25
|
+
backgroundColor: 'rgba(100, 116, 139, 0.1)',
|
|
26
|
+
marginBottom: 8,
|
|
27
|
+
}}
|
|
28
|
+
/>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -146,15 +146,18 @@ function RealTimeKanbanContent({ initialData, initialDecisions }: RealTimeKanban
|
|
|
146
146
|
}, [refreshData, data, undoStack]);
|
|
147
147
|
|
|
148
148
|
const handleOrderChange = useCallback(async (id: number, newOrder: number) => {
|
|
149
|
+
console.log('[RealTimeKanban] handleOrderChange called:', { id, newOrder });
|
|
149
150
|
try {
|
|
150
|
-
await fetch(`/api/work/${id}/order`, {
|
|
151
|
+
const response = await fetch(`/api/work/${id}/order`, {
|
|
151
152
|
method: 'PATCH',
|
|
152
153
|
headers: { 'Content-Type': 'application/json' },
|
|
153
154
|
body: JSON.stringify({ display_order: newOrder }),
|
|
154
155
|
});
|
|
156
|
+
console.log('[RealTimeKanban] Order API response:', response.status, await response.clone().json());
|
|
155
157
|
await refreshData();
|
|
156
|
-
|
|
157
|
-
|
|
158
|
+
console.log('[RealTimeKanban] Data refreshed after order change');
|
|
159
|
+
} catch (error) {
|
|
160
|
+
console.error('[RealTimeKanban] Order change failed:', error);
|
|
158
161
|
}
|
|
159
162
|
}, [refreshData]);
|
|
160
163
|
|
package/apps/dashboard/lib/db.ts
CHANGED
|
@@ -337,6 +337,15 @@ export function getKanbanData(doneLimit: number = 50): KanbanData {
|
|
|
337
337
|
}
|
|
338
338
|
}
|
|
339
339
|
|
|
340
|
+
// Always ensure an ungrouped entry exists in backlog for drag-drop target
|
|
341
|
+
if (!backlogGroups.has('ungrouped')) {
|
|
342
|
+
backlogGroups.set('ungrouped', {
|
|
343
|
+
epicId: null,
|
|
344
|
+
epicTitle: null,
|
|
345
|
+
items: []
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
340
349
|
// Sort done items by completed_at DESC (newest first) within each group
|
|
341
350
|
for (const [, group] of doneGroups) {
|
|
342
351
|
group.items.sort((a, b) => {
|
package/jettypod.js
CHANGED
|
@@ -2158,38 +2158,42 @@ switch (command) {
|
|
|
2158
2158
|
return null;
|
|
2159
2159
|
};
|
|
2160
2160
|
|
|
2161
|
+
// Kill any stale dashboard processes on ports 3456-3465 BEFORE finding port
|
|
2162
|
+
// This ensures we start fresh on 3456 instead of hopping to higher ports
|
|
2163
|
+
try {
|
|
2164
|
+
const { execSync } = require('child_process');
|
|
2165
|
+
for (let port = BASE_PORT; port < BASE_PORT + 10; port++) {
|
|
2166
|
+
try {
|
|
2167
|
+
const result = execSync(`lsof -ti :${port}`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] });
|
|
2168
|
+
const pids = result.trim().split('\n').filter(p => p);
|
|
2169
|
+
for (const pid of pids) {
|
|
2170
|
+
execSync(`kill -9 ${pid}`, { stdio: 'ignore' });
|
|
2171
|
+
}
|
|
2172
|
+
} catch (e) { /* no process on this port */ }
|
|
2173
|
+
}
|
|
2174
|
+
} catch (e) { /* ignore cleanup errors */ }
|
|
2175
|
+
|
|
2161
2176
|
const availablePort = await findAvailablePort(BASE_PORT);
|
|
2162
2177
|
|
|
2163
2178
|
if (availablePort) {
|
|
2164
2179
|
// Check if dashboard dependencies are installed (lazy install on first use)
|
|
2165
2180
|
const dashboardNodeModules = path.join(dashboardPath, 'node_modules');
|
|
2166
|
-
const dashboardNextBuild = path.join(dashboardPath, '.next');
|
|
2167
2181
|
|
|
2168
|
-
if (!fs.existsSync(dashboardNodeModules)
|
|
2169
|
-
console.log('
|
|
2182
|
+
if (!fs.existsSync(dashboardNodeModules)) {
|
|
2183
|
+
console.log('Installing dashboard dependencies...');
|
|
2170
2184
|
|
|
2171
2185
|
try {
|
|
2172
|
-
// Install dependencies
|
|
2173
|
-
console.log(' Installing dependencies...');
|
|
2174
2186
|
const { execSync } = require('child_process');
|
|
2175
2187
|
execSync('npm install', {
|
|
2176
2188
|
cwd: dashboardPath,
|
|
2177
2189
|
stdio: 'inherit'
|
|
2178
2190
|
});
|
|
2179
|
-
|
|
2180
|
-
// Build the dashboard
|
|
2181
|
-
console.log(' Building dashboard...');
|
|
2182
|
-
execSync('npm run build', {
|
|
2183
|
-
cwd: dashboardPath,
|
|
2184
|
-
stdio: 'inherit'
|
|
2185
|
-
});
|
|
2186
|
-
|
|
2187
|
-
console.log('✅ Dashboard setup complete!');
|
|
2191
|
+
console.log('✅ Dependencies installed');
|
|
2188
2192
|
console.log('');
|
|
2189
2193
|
} catch (err) {
|
|
2190
|
-
console.error('❌ Failed to
|
|
2194
|
+
console.error('❌ Failed to install dependencies:', err.message);
|
|
2191
2195
|
console.error(' Try running manually:');
|
|
2192
|
-
console.error(` cd ${dashboardPath} && npm install
|
|
2196
|
+
console.error(` cd ${dashboardPath} && npm install`);
|
|
2193
2197
|
break;
|
|
2194
2198
|
}
|
|
2195
2199
|
}
|
|
@@ -2211,9 +2215,9 @@ switch (command) {
|
|
|
2211
2215
|
});
|
|
2212
2216
|
wsProcess.unref();
|
|
2213
2217
|
|
|
2214
|
-
// Start dashboard in
|
|
2215
|
-
console.log('🚀 Starting dashboard...');
|
|
2216
|
-
const dashboardProcess = spawn('npm', ['run', '
|
|
2218
|
+
// Start dashboard in dev mode for live reload
|
|
2219
|
+
console.log('🚀 Starting dashboard (dev mode)...');
|
|
2220
|
+
const dashboardProcess = spawn('npm', ['run', 'dev', '--', '-p', String(availablePort)], {
|
|
2217
2221
|
cwd: dashboardPath,
|
|
2218
2222
|
detached: true,
|
|
2219
2223
|
stdio: 'ignore',
|
package/lib/db-import.js
CHANGED
|
@@ -2,6 +2,109 @@ const fs = require('fs');
|
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const { getDb, getJettypodDir } = require('./database');
|
|
4
4
|
|
|
5
|
+
// Valid values for work_items fields
|
|
6
|
+
const VALID_TYPES = ['epic', 'feature', 'chore', 'bug'];
|
|
7
|
+
const VALID_STATUSES = ['backlog', 'todo', 'in_progress', 'done', 'cancelled'];
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Validate a single work item against the schema
|
|
11
|
+
* @param {Object} item - Work item to validate
|
|
12
|
+
* @param {number} index - Index in array for error messages
|
|
13
|
+
* @returns {{valid: boolean, errors: string[]}}
|
|
14
|
+
*/
|
|
15
|
+
function validateWorkItem(item, index) {
|
|
16
|
+
const errors = [];
|
|
17
|
+
|
|
18
|
+
if (typeof item !== 'object' || item === null) {
|
|
19
|
+
return { valid: false, errors: [`Item ${index}: must be an object`] };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Required: id must be a number
|
|
23
|
+
if (item.id === undefined || item.id === null) {
|
|
24
|
+
errors.push(`Item ${index}: missing required field 'id'`);
|
|
25
|
+
} else if (typeof item.id !== 'number' || !Number.isInteger(item.id)) {
|
|
26
|
+
errors.push(`Item ${index}: 'id' must be an integer`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Required: type must be a valid string
|
|
30
|
+
if (item.type === undefined || item.type === null) {
|
|
31
|
+
errors.push(`Item ${index}: missing required field 'type'`);
|
|
32
|
+
} else if (typeof item.type !== 'string') {
|
|
33
|
+
errors.push(`Item ${index}: 'type' must be a string`);
|
|
34
|
+
} else if (!VALID_TYPES.includes(item.type)) {
|
|
35
|
+
errors.push(`Item ${index}: 'type' must be one of: ${VALID_TYPES.join(', ')}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Required: title must be a string
|
|
39
|
+
if (item.title === undefined || item.title === null) {
|
|
40
|
+
errors.push(`Item ${index}: missing required field 'title'`);
|
|
41
|
+
} else if (typeof item.title !== 'string') {
|
|
42
|
+
errors.push(`Item ${index}: 'title' must be a string`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Optional: status must be valid if present
|
|
46
|
+
if (item.status !== undefined && item.status !== null) {
|
|
47
|
+
if (typeof item.status !== 'string') {
|
|
48
|
+
errors.push(`Item ${index}: 'status' must be a string`);
|
|
49
|
+
} else if (!VALID_STATUSES.includes(item.status)) {
|
|
50
|
+
errors.push(`Item ${index}: 'status' must be one of: ${VALID_STATUSES.join(', ')}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Optional: parent_id must be integer if present
|
|
55
|
+
if (item.parent_id !== undefined && item.parent_id !== null) {
|
|
56
|
+
if (typeof item.parent_id !== 'number' || !Number.isInteger(item.parent_id)) {
|
|
57
|
+
errors.push(`Item ${index}: 'parent_id' must be an integer`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Optional: epic_id must be integer if present
|
|
62
|
+
if (item.epic_id !== undefined && item.epic_id !== null) {
|
|
63
|
+
if (typeof item.epic_id !== 'number' || !Number.isInteger(item.epic_id)) {
|
|
64
|
+
errors.push(`Item ${index}: 'epic_id' must be an integer`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { valid: errors.length === 0, errors };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Validate work.json data structure and content
|
|
73
|
+
* @param {Object} data - Parsed JSON data
|
|
74
|
+
* @returns {{valid: boolean, errors: string[]}}
|
|
75
|
+
*/
|
|
76
|
+
function validateWorkJson(data) {
|
|
77
|
+
const errors = [];
|
|
78
|
+
|
|
79
|
+
if (typeof data !== 'object' || data === null) {
|
|
80
|
+
return { valid: false, errors: ['Data must be an object'] };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// work_items must be present and an array
|
|
84
|
+
if (!data.work_items) {
|
|
85
|
+
return { valid: false, errors: ['Missing required field: work_items'] };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!Array.isArray(data.work_items)) {
|
|
89
|
+
return { valid: false, errors: ['work_items must be an array'] };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Validate each work item
|
|
93
|
+
data.work_items.forEach((item, index) => {
|
|
94
|
+
const result = validateWorkItem(item, index);
|
|
95
|
+
errors.push(...result.errors);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Check for duplicate IDs
|
|
99
|
+
const ids = data.work_items.map(item => item.id).filter(id => id !== undefined);
|
|
100
|
+
const duplicates = ids.filter((id, index) => ids.indexOf(id) !== index);
|
|
101
|
+
if (duplicates.length > 0) {
|
|
102
|
+
errors.push(`Duplicate work item IDs found: ${[...new Set(duplicates)].join(', ')}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return { valid: errors.length === 0, errors };
|
|
106
|
+
}
|
|
107
|
+
|
|
5
108
|
/**
|
|
6
109
|
* Import tables from JSON structure into a database
|
|
7
110
|
* @param {sqlite3.Database} db - Database connection
|
|
@@ -86,6 +189,18 @@ async function importWorkDb() {
|
|
|
86
189
|
const jsonContent = fs.readFileSync(jsonPath, 'utf8');
|
|
87
190
|
const data = JSON.parse(jsonContent);
|
|
88
191
|
|
|
192
|
+
// Validate schema before importing
|
|
193
|
+
const validation = validateWorkJson(data);
|
|
194
|
+
if (!validation.valid) {
|
|
195
|
+
console.error('Post-checkout hook warning: work.json schema validation failed');
|
|
196
|
+
validation.errors.slice(0, 5).forEach(err => console.error(` - ${err}`));
|
|
197
|
+
if (validation.errors.length > 5) {
|
|
198
|
+
console.error(` ... and ${validation.errors.length - 5} more errors`);
|
|
199
|
+
}
|
|
200
|
+
console.error(' Database will remain in previous state');
|
|
201
|
+
return jsonPath;
|
|
202
|
+
}
|
|
203
|
+
|
|
89
204
|
const db = getDb();
|
|
90
205
|
await importTables(db, data);
|
|
91
206
|
|
|
@@ -189,5 +304,9 @@ async function importAll() {
|
|
|
189
304
|
module.exports = {
|
|
190
305
|
importWorkDb,
|
|
191
306
|
importDatabaseDb,
|
|
192
|
-
importAll
|
|
307
|
+
importAll,
|
|
308
|
+
validateWorkJson,
|
|
309
|
+
validateWorkItem,
|
|
310
|
+
VALID_TYPES,
|
|
311
|
+
VALID_STATUSES
|
|
193
312
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: bug-planning
|
|
3
|
-
description: Guide structured bug investigation with symptom capture, hypothesis testing, and root cause identification.
|
|
3
|
+
description: Guide structured bug investigation with symptom capture, hypothesis testing, and root cause identification. Invoked by plan-routing when user reports a bug, mentions unexpected behavior, or describes something broken. (project)
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Bug Planning Skill
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: chore-planning
|
|
3
|
-
description: Guide standalone chore planning with automatic type classification and routing to chore-mode.
|
|
3
|
+
description: Guide standalone chore planning with automatic type classification and routing to chore-mode. Invoked by plan-routing for substantial technical work - refactoring, infrastructure, migrations, or enhancements where the implementation approach is obvious. (project)
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Chore Planning Skill
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: epic-planning
|
|
3
|
-
description: Guide epic planning with feature brainstorming and optional architectural decision prototyping.
|
|
3
|
+
description: Guide epic planning with feature brainstorming and optional architectural decision prototyping. Invoked by plan-routing for large multi-feature initiatives that need to be broken down into features. (project)
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Epic Planning Skill
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: feature-planning
|
|
3
|
-
description: Guide feature planning with UX approach exploration and BDD scenario generation.
|
|
3
|
+
description: Guide feature planning with UX approach exploration and BDD scenario generation. Invoked by plan-routing when implementing NEW user-facing behavior that requires UX decisions (multiple valid approaches to explore). (project)
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Feature Planning Skill
|
|
@@ -63,49 +63,8 @@ jettypod workflow start feature-planning <feature-id>
|
|
|
63
63
|
|
|
64
64
|
This creates an execution record for session resume.
|
|
65
65
|
|
|
66
|
-
### Step 1B: Route Simple Improvements
|
|
67
|
-
|
|
68
|
-
**CRITICAL:** Before proceeding with full feature planning, determine if this is a simple improvement.
|
|
69
|
-
|
|
70
|
-
**Ask the user:**
|
|
71
|
-
|
|
72
|
-
```
|
|
73
|
-
Is this:
|
|
74
|
-
1. **Simple improvement** - A basic enhancement to existing functionality (e.g., copy change, styling tweak, minor behavior adjustment)
|
|
75
|
-
2. **New functionality** - Adding new capabilities, even if small
|
|
76
|
-
|
|
77
|
-
Simple improvements skip the full feature planning workflow and use a lightweight process.
|
|
78
|
-
```
|
|
79
|
-
|
|
80
|
-
**⚡ WAIT for user response.**
|
|
81
|
-
|
|
82
|
-
**If user says "simple improvement" (or 1):**
|
|
83
|
-
|
|
84
|
-
Display:
|
|
85
|
-
```
|
|
86
|
-
This is a simple improvement. Routing to the lightweight simple-improvement workflow...
|
|
87
|
-
```
|
|
88
|
-
|
|
89
|
-
**Then IMMEDIATELY invoke simple-improvement using the Skill tool:**
|
|
90
|
-
```
|
|
91
|
-
Use the Skill tool with skill: "simple-improvement"
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
**Feature-planning skill ends here for simple improvements.** The simple-improvement skill takes over.
|
|
95
|
-
|
|
96
|
-
---
|
|
97
|
-
|
|
98
|
-
**If user says "new functionality" (or 2):**
|
|
99
|
-
|
|
100
|
-
Display:
|
|
101
|
-
```
|
|
102
|
-
Got it - this is new functionality. Let's explore the best approach...
|
|
103
|
-
```
|
|
104
|
-
|
|
105
66
|
**Proceed to Step 2** (or Step 3 if this is a standalone feature with no parent epic).
|
|
106
67
|
|
|
107
|
-
---
|
|
108
|
-
|
|
109
68
|
### Step 2: Check Epic Architectural Decisions
|
|
110
69
|
|
|
111
70
|
**Skip this step and proceed to Step 3 if `PARENT_EPIC_ID` from Step 1 is null (standalone feature).**
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: plan-routing
|
|
3
|
+
description: Route work requests to the appropriate planning skill. Use when user describes work they want to do - "build X", "fix Y", "add Z", "plan W". Analyzes intent and complexity to route to bug-planning, chore-planning, feature-planning, epic-planning, or simple-improvement. (project)
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Plan Routing Skill
|
|
7
|
+
|
|
8
|
+
Routes user work requests to the correct planning workflow with minimal friction.
|
|
9
|
+
|
|
10
|
+
## Instructions
|
|
11
|
+
|
|
12
|
+
When this skill is activated, you are helping route a user's work request to the appropriate planning skill. Follow this structured approach:
|
|
13
|
+
|
|
14
|
+
### Step 1: Extract Intent Signals
|
|
15
|
+
|
|
16
|
+
From the user's request, identify work type and complexity signals:
|
|
17
|
+
|
|
18
|
+
**Work type signals:**
|
|
19
|
+
| Signal Words | Likely Route |
|
|
20
|
+
|--------------|--------------|
|
|
21
|
+
| fix, bug, broken, crash, error, not working, regression | bug-planning |
|
|
22
|
+
| refactor, rename, move, clean up, upgrade, migrate, infrastructure | chore-planning |
|
|
23
|
+
| add, build, create, implement, new feature, capability, workflow | feature-planning |
|
|
24
|
+
| epic, initiative, project, roadmap, multi-feature | epic-planning |
|
|
25
|
+
| tweak, change, update, adjust + small scope | simple-improvement |
|
|
26
|
+
|
|
27
|
+
**Complexity signals:**
|
|
28
|
+
| Signal | Indicates |
|
|
29
|
+
|--------|-----------|
|
|
30
|
+
| "quick", "small", "just", "simple", "tweak" | Lower complexity |
|
|
31
|
+
| "feature", "workflow", "experience", "redesign" | Higher complexity |
|
|
32
|
+
| References specific file/function | Lower complexity |
|
|
33
|
+
| Describes user-facing behavior change | Higher complexity |
|
|
34
|
+
|
|
35
|
+
### Step 2: Gather Context (Silent - No Questions)
|
|
36
|
+
|
|
37
|
+
Before routing, quickly probe the codebase for context. **Do NOT ask the user for this information.**
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
# Check for related existing code
|
|
41
|
+
jettypod impact "<key-term-from-request>"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
# Check for existing work items
|
|
46
|
+
jettypod backlog | grep -i "<key-term>"
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
**Assess from results:**
|
|
50
|
+
- Existing code? → Likely modification vs creation
|
|
51
|
+
- Related work items? → May already be planned
|
|
52
|
+
- How many files affected? → Complexity indicator
|
|
53
|
+
|
|
54
|
+
### Step 3: Route with Stated Assumption
|
|
55
|
+
|
|
56
|
+
**DO NOT ASK a routing question.** State your routing decision with reasoning.
|
|
57
|
+
|
|
58
|
+
## Routing Decision Tree
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
User request
|
|
62
|
+
│
|
|
63
|
+
▼
|
|
64
|
+
Contains bug/fix/broken/error signals?
|
|
65
|
+
├─► Yes → bug-planning
|
|
66
|
+
│
|
|
67
|
+
▼
|
|
68
|
+
Describes large multi-feature initiative?
|
|
69
|
+
├─► Yes → epic-planning
|
|
70
|
+
│
|
|
71
|
+
▼
|
|
72
|
+
Does this need UX exploration?
|
|
73
|
+
(Multiple valid approaches? User-facing behavior with design choices?)
|
|
74
|
+
├─► Yes → feature-planning
|
|
75
|
+
│
|
|
76
|
+
▼
|
|
77
|
+
Is this substantial technical work?
|
|
78
|
+
(Refactoring, infrastructure, migrations, multi-file changes)
|
|
79
|
+
├─► Yes → chore-planning
|
|
80
|
+
│
|
|
81
|
+
▼
|
|
82
|
+
Is this a simple, obvious change?
|
|
83
|
+
(Copy, styling, config, minor behavior tweak, one clear approach)
|
|
84
|
+
└─► Yes → simple-improvement
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Route Definitions
|
|
88
|
+
|
|
89
|
+
| Route | When to Use | Progression |
|
|
90
|
+
|-------|-------------|-------------|
|
|
91
|
+
| **bug-planning** | Something is broken/not working as expected | Investigation → fix |
|
|
92
|
+
| **epic-planning** | Large initiative spanning multiple features | Break down → plan features |
|
|
93
|
+
| **feature-planning** | New user-facing behavior with UX decisions to make | UX exploration → BDD → speed → stable → production |
|
|
94
|
+
| **chore-planning** | Substantial technical work, clear implementation | speed → stable → production |
|
|
95
|
+
| **simple-improvement** | Obvious change, no UX decisions, small scope | Direct implementation |
|
|
96
|
+
|
|
97
|
+
## Routing Examples
|
|
98
|
+
|
|
99
|
+
**→ bug-planning**
|
|
100
|
+
- "The login button doesn't work on mobile"
|
|
101
|
+
- "Getting a crash when I save"
|
|
102
|
+
- "Users are seeing a 500 error"
|
|
103
|
+
|
|
104
|
+
**→ epic-planning**
|
|
105
|
+
- "We need a full authentication system"
|
|
106
|
+
- "Build out the reporting dashboard"
|
|
107
|
+
- "Plan the v2 API migration"
|
|
108
|
+
|
|
109
|
+
**→ feature-planning**
|
|
110
|
+
- "Add drag-and-drop reordering for cards"
|
|
111
|
+
- "Implement user notifications"
|
|
112
|
+
- "Build a search feature"
|
|
113
|
+
|
|
114
|
+
*(Multiple valid UX approaches exist)*
|
|
115
|
+
|
|
116
|
+
**→ chore-planning**
|
|
117
|
+
- "Refactor the auth module to use the new pattern"
|
|
118
|
+
- "Migrate from Moment.js to date-fns"
|
|
119
|
+
- "Add TypeScript to the utils folder"
|
|
120
|
+
- "Set up CI/CD pipeline"
|
|
121
|
+
|
|
122
|
+
*(Technical work, obvious approach, but substantial)*
|
|
123
|
+
|
|
124
|
+
**→ simple-improvement**
|
|
125
|
+
- "Change the button text from 'Submit' to 'Save'"
|
|
126
|
+
- "Make the error message more descriptive"
|
|
127
|
+
- "Add a loading spinner to the save button"
|
|
128
|
+
- "Change the header color to blue"
|
|
129
|
+
- "Add a tooltip to the settings icon"
|
|
130
|
+
|
|
131
|
+
*(One obvious implementation, no UX exploration needed, just do it)*
|
|
132
|
+
|
|
133
|
+
## Stating Your Routing Decision
|
|
134
|
+
|
|
135
|
+
**Bug:**
|
|
136
|
+
```
|
|
137
|
+
Sounds like a bug - [X] isn't working as expected. Let me help you investigate.
|
|
138
|
+
```
|
|
139
|
+
Then invoke bug-planning skill.
|
|
140
|
+
|
|
141
|
+
**Epic:**
|
|
142
|
+
```
|
|
143
|
+
This is a larger initiative with multiple features. Let's break it down.
|
|
144
|
+
```
|
|
145
|
+
Then invoke epic-planning skill.
|
|
146
|
+
|
|
147
|
+
**Feature:**
|
|
148
|
+
```
|
|
149
|
+
This adds new user-facing behavior with some design choices to explore. Let me suggest a few approaches.
|
|
150
|
+
```
|
|
151
|
+
Then invoke feature-planning skill.
|
|
152
|
+
|
|
153
|
+
**Chore:**
|
|
154
|
+
```
|
|
155
|
+
This is technical work with a clear implementation path. Let me help you plan it out.
|
|
156
|
+
```
|
|
157
|
+
Then invoke chore-planning skill.
|
|
158
|
+
|
|
159
|
+
**Simple improvement:**
|
|
160
|
+
```
|
|
161
|
+
This is a straightforward change. Let me implement it.
|
|
162
|
+
```
|
|
163
|
+
Then invoke simple-improvement skill.
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
**If genuinely ambiguous (rare - should be <20% of cases):**
|
|
168
|
+
```
|
|
169
|
+
I could approach "[brief description]" as:
|
|
170
|
+
- A **simple improvement** - just implement the obvious solution
|
|
171
|
+
- A **feature** - explore a few UX approaches first
|
|
172
|
+
|
|
173
|
+
Which feels right?
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Only ask when you truly cannot decide based on signals and context.
|
|
177
|
+
|
|
178
|
+
### Step 4: Invoke Target Skill
|
|
179
|
+
|
|
180
|
+
After stating your routing decision, immediately invoke the appropriate skill using the Skill tool:
|
|
181
|
+
- `bug-planning`
|
|
182
|
+
- `chore-planning`
|
|
183
|
+
- `feature-planning`
|
|
184
|
+
- `epic-planning`
|
|
185
|
+
- `simple-improvement`
|
|
186
|
+
|
|
187
|
+
**This skill ends after invocation.** The target skill takes over.
|
|
188
|
+
# Stable mode: Verified ambiguous request handling exists
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: simple-improvement
|
|
3
|
-
description: Guide implementation of simple improvements to existing functionality.
|
|
3
|
+
description: Guide implementation of simple improvements to existing functionality. Invoked by plan-routing for straightforward changes like copy changes, styling tweaks, or minor behavior adjustments where the implementation is obvious. (project)
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Simple Improvement Skill
|