jettypod 4.4.82 → 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 +136 -7
- package/apps/dashboard/components/DraggableCard.tsx +34 -2
- package/apps/dashboard/components/KanbanBoard.tsx +201 -97
- 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/hooks/post-checkout +11 -1
- package/jettypod.js +25 -20
- package/lib/database.js +31 -0
- package/lib/db-export.js +20 -17
- package/lib/db-import.js +120 -1
- package/lib/git-hooks/pre-commit +56 -16
- 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
|
|
|
@@ -22,14 +22,26 @@ interface EpicDropZoneInfo {
|
|
|
22
22
|
onReorder?: ReorderHandler;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
interface CardPosition {
|
|
26
|
+
id: number;
|
|
27
|
+
rect: DOMRect;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface InsertionPreview {
|
|
31
|
+
afterCardId: number | null; // null means insert at beginning
|
|
32
|
+
zoneId: string; // epic zone or drop zone id
|
|
33
|
+
}
|
|
34
|
+
|
|
25
35
|
interface DragContextType {
|
|
26
36
|
isDragging: boolean;
|
|
27
37
|
draggedItem: WorkItem | null;
|
|
28
38
|
activeDropZone: string | null;
|
|
29
39
|
activeEpicZone: string | null;
|
|
40
|
+
insertionPreview: InsertionPreview | null;
|
|
30
41
|
dragPosition: { x: number; y: number };
|
|
31
42
|
dragOffset: { x: number; y: number };
|
|
32
43
|
draggedCardWidth: number;
|
|
44
|
+
draggedCardHeight: number;
|
|
33
45
|
setDraggedItem: (item: WorkItem | null) => void;
|
|
34
46
|
registerDropZone: (id: string, info: DropZoneInfo) => void;
|
|
35
47
|
unregisterDropZone: (id: string) => void;
|
|
@@ -38,6 +50,9 @@ interface DragContextType {
|
|
|
38
50
|
updatePointerPosition: (x: number, y: number) => void;
|
|
39
51
|
startDrag: (item: WorkItem, cardRect: DOMRect, pointerX: number, pointerY: number) => void;
|
|
40
52
|
handleDrop: () => Promise<void>;
|
|
53
|
+
registerCardPosition: (id: number, rect: DOMRect) => void;
|
|
54
|
+
unregisterCard: (id: number) => void;
|
|
55
|
+
getCardPositions: () => CardPosition[];
|
|
41
56
|
}
|
|
42
57
|
|
|
43
58
|
const DragContext = createContext<DragContextType>({
|
|
@@ -45,9 +60,11 @@ const DragContext = createContext<DragContextType>({
|
|
|
45
60
|
draggedItem: null,
|
|
46
61
|
activeDropZone: null,
|
|
47
62
|
activeEpicZone: null,
|
|
63
|
+
insertionPreview: null,
|
|
48
64
|
dragPosition: { x: 0, y: 0 },
|
|
49
65
|
dragOffset: { x: 0, y: 0 },
|
|
50
66
|
draggedCardWidth: 0,
|
|
67
|
+
draggedCardHeight: 0,
|
|
51
68
|
setDraggedItem: () => {},
|
|
52
69
|
registerDropZone: () => {},
|
|
53
70
|
unregisterDropZone: () => {},
|
|
@@ -56,23 +73,31 @@ const DragContext = createContext<DragContextType>({
|
|
|
56
73
|
updatePointerPosition: () => {},
|
|
57
74
|
startDrag: () => {},
|
|
58
75
|
handleDrop: async () => {},
|
|
76
|
+
registerCardPosition: () => {},
|
|
77
|
+
unregisterCard: () => {},
|
|
78
|
+
getCardPositions: () => [],
|
|
59
79
|
});
|
|
60
80
|
|
|
61
81
|
interface DragProviderProps {
|
|
62
82
|
children: ReactNode;
|
|
63
83
|
renderDragOverlay?: (item: WorkItem) => ReactNode;
|
|
84
|
+
onRemoveFromEpic?: EpicAssignHandler;
|
|
64
85
|
}
|
|
65
86
|
|
|
66
|
-
export function DragProvider({ children, renderDragOverlay }: DragProviderProps) {
|
|
87
|
+
export function DragProvider({ children, renderDragOverlay, onRemoveFromEpic }: DragProviderProps) {
|
|
67
88
|
const [draggedItem, setDraggedItem] = useState<WorkItem | null>(null);
|
|
68
89
|
const [activeDropZone, setActiveDropZone] = useState<string | null>(null);
|
|
69
90
|
const [activeEpicZone, setActiveEpicZone] = useState<string | null>(null);
|
|
91
|
+
const [insertionPreview, setInsertionPreview] = useState<InsertionPreview | null>(null);
|
|
70
92
|
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
|
|
71
93
|
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
|
72
94
|
const [draggedCardWidth, setDraggedCardWidth] = useState(0);
|
|
95
|
+
const [draggedCardHeight, setDraggedCardHeight] = useState(0);
|
|
73
96
|
const dropZonesRef = useRef<Map<string, DropZoneInfo>>(new Map());
|
|
74
97
|
const epicDropZonesRef = useRef<Map<string, EpicDropZoneInfo>>(new Map());
|
|
98
|
+
const cardPositionsRef = useRef<Map<number, DOMRect>>(new Map());
|
|
75
99
|
const pointerPositionRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
|
|
100
|
+
const draggedItemRef = useRef<WorkItem | null>(null);
|
|
76
101
|
|
|
77
102
|
const registerDropZone = useCallback((id: string, info: DropZoneInfo) => {
|
|
78
103
|
dropZonesRef.current.set(id, info);
|
|
@@ -90,6 +115,25 @@ export function DragProvider({ children, renderDragOverlay }: DragProviderProps)
|
|
|
90
115
|
epicDropZonesRef.current.delete(id);
|
|
91
116
|
}, []);
|
|
92
117
|
|
|
118
|
+
const registerCardPosition = useCallback((id: number, rect: DOMRect) => {
|
|
119
|
+
console.log('[DragContext] registerCardPosition:', id, 'rect:', { top: rect.top, bottom: rect.bottom });
|
|
120
|
+
cardPositionsRef.current.set(id, rect);
|
|
121
|
+
}, []);
|
|
122
|
+
|
|
123
|
+
const unregisterCard = useCallback((id: number) => {
|
|
124
|
+
console.log('[DragContext] unregisterCard:', id);
|
|
125
|
+
cardPositionsRef.current.delete(id);
|
|
126
|
+
}, []);
|
|
127
|
+
|
|
128
|
+
const getCardPositions = useCallback((): CardPosition[] => {
|
|
129
|
+
const positions: CardPosition[] = [];
|
|
130
|
+
cardPositionsRef.current.forEach((rect, id) => {
|
|
131
|
+
positions.push({ id, rect });
|
|
132
|
+
});
|
|
133
|
+
console.log('[DragContext] getCardPositions:', positions.length, 'positions', positions.map(p => ({ id: p.id, midY: (p.rect.top + p.rect.bottom) / 2 })));
|
|
134
|
+
return positions;
|
|
135
|
+
}, []);
|
|
136
|
+
|
|
93
137
|
const updatePointerPosition = useCallback((x: number, y: number) => {
|
|
94
138
|
pointerPositionRef.current = { x, y };
|
|
95
139
|
setDragPosition({ x, y });
|
|
@@ -114,6 +158,32 @@ export function DragProvider({ children, renderDragOverlay }: DragProviderProps)
|
|
|
114
158
|
|
|
115
159
|
setActiveDropZone(foundZone);
|
|
116
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
|
+
}
|
|
117
187
|
}, []);
|
|
118
188
|
|
|
119
189
|
const startDrag = useCallback((item: WorkItem, cardRect: DOMRect, pointerX: number, pointerY: number) => {
|
|
@@ -123,52 +193,106 @@ export function DragProvider({ children, renderDragOverlay }: DragProviderProps)
|
|
|
123
193
|
setDragOffset({ x: offsetX, y: offsetY });
|
|
124
194
|
setDragPosition({ x: pointerX, y: pointerY });
|
|
125
195
|
setDraggedCardWidth(cardRect.width);
|
|
196
|
+
setDraggedCardHeight(cardRect.height);
|
|
126
197
|
setDraggedItem(item);
|
|
198
|
+
draggedItemRef.current = item;
|
|
127
199
|
}, []);
|
|
128
200
|
|
|
129
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
|
+
});
|
|
130
207
|
if (!draggedItem) return;
|
|
131
208
|
|
|
132
209
|
// Check for epic drop zone first (more specific drop target)
|
|
133
210
|
if (activeEpicZone) {
|
|
134
211
|
const epicZoneInfo = epicDropZonesRef.current.get(activeEpicZone);
|
|
212
|
+
console.log('[DragContext] epicZoneInfo:', epicZoneInfo ? { epicId: epicZoneInfo.epicId, hasOnReorder: !!epicZoneInfo.onReorder } : null);
|
|
135
213
|
if (epicZoneInfo) {
|
|
136
214
|
const currentEpicId = draggedItem.parent_id || draggedItem.epic_id;
|
|
215
|
+
console.log('[DragContext] currentEpicId:', currentEpicId, 'targetEpicId:', epicZoneInfo.epicId);
|
|
137
216
|
|
|
138
217
|
// Same epic - reorder within epic
|
|
139
218
|
if (currentEpicId === epicZoneInfo.epicId && epicZoneInfo.onReorder) {
|
|
219
|
+
console.log('[DragContext] Same epic - calling onReorder');
|
|
140
220
|
await epicZoneInfo.onReorder(draggedItem.id, pointerPositionRef.current.y);
|
|
141
221
|
return;
|
|
142
222
|
}
|
|
143
223
|
|
|
144
224
|
// Different epic - assign to new epic
|
|
145
225
|
if (currentEpicId !== epicZoneInfo.epicId) {
|
|
226
|
+
console.log('[DragContext] Different epic - calling onEpicAssign');
|
|
146
227
|
await epicZoneInfo.onEpicAssign(draggedItem.id, epicZoneInfo.epicId);
|
|
147
228
|
return;
|
|
148
229
|
}
|
|
149
230
|
}
|
|
150
231
|
}
|
|
151
232
|
|
|
152
|
-
//
|
|
153
|
-
|
|
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
|
+
}
|
|
154
246
|
|
|
155
247
|
const zoneInfo = dropZonesRef.current.get(activeDropZone);
|
|
156
|
-
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 });
|
|
157
254
|
|
|
158
255
|
// Check if this is a status change or a reorder
|
|
159
256
|
if (draggedItem.status !== zoneInfo.targetStatus) {
|
|
160
257
|
// Status change
|
|
258
|
+
console.log('[DragContext] Status change - calling onDrop');
|
|
161
259
|
await zoneInfo.onDrop(draggedItem.id, zoneInfo.targetStatus);
|
|
162
260
|
} else if (zoneInfo.onReorder) {
|
|
163
261
|
// Reorder within same status
|
|
262
|
+
console.log('[DragContext] Same status - calling onReorder');
|
|
164
263
|
await zoneInfo.onReorder(draggedItem.id, pointerPositionRef.current.y);
|
|
264
|
+
} else {
|
|
265
|
+
console.log('[DragContext] Same status but no onReorder handler');
|
|
165
266
|
}
|
|
166
|
-
}, [activeDropZone, activeEpicZone, draggedItem]);
|
|
267
|
+
}, [activeDropZone, activeEpicZone, draggedItem, onRemoveFromEpic]);
|
|
167
268
|
|
|
168
269
|
// Calculate overlay position (pointer position minus offset = card top-left)
|
|
169
270
|
const overlayX = dragPosition.x - dragOffset.x;
|
|
170
271
|
const overlayY = dragPosition.y - dragOffset.y;
|
|
171
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
|
+
|
|
172
296
|
return (
|
|
173
297
|
<DragContext.Provider
|
|
174
298
|
value={{
|
|
@@ -176,10 +300,12 @@ export function DragProvider({ children, renderDragOverlay }: DragProviderProps)
|
|
|
176
300
|
draggedItem,
|
|
177
301
|
activeDropZone,
|
|
178
302
|
activeEpicZone,
|
|
303
|
+
insertionPreview,
|
|
179
304
|
dragPosition,
|
|
180
305
|
dragOffset,
|
|
181
306
|
draggedCardWidth,
|
|
182
|
-
|
|
307
|
+
draggedCardHeight,
|
|
308
|
+
setDraggedItem: handleSetDraggedItem,
|
|
183
309
|
registerDropZone,
|
|
184
310
|
unregisterDropZone,
|
|
185
311
|
registerEpicDropZone,
|
|
@@ -187,6 +313,9 @@ export function DragProvider({ children, renderDragOverlay }: DragProviderProps)
|
|
|
187
313
|
updatePointerPosition,
|
|
188
314
|
startDrag,
|
|
189
315
|
handleDrop,
|
|
316
|
+
registerCardPosition,
|
|
317
|
+
unregisterCard,
|
|
318
|
+
getCardPositions,
|
|
190
319
|
}}
|
|
191
320
|
>
|
|
192
321
|
{children}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useRef } from 'react';
|
|
3
|
+
import { useRef, useEffect } from 'react';
|
|
4
4
|
import { motion, PanInfo } from 'framer-motion';
|
|
5
5
|
import type { WorkItem } from '@/lib/db';
|
|
6
6
|
import { useDragContext } from './DragContext';
|
|
@@ -12,10 +12,38 @@ interface DraggableCardProps {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
export function DraggableCard({ item, children, disabled = false }: DraggableCardProps) {
|
|
15
|
-
const { startDrag, setDraggedItem, updatePointerPosition, handleDrop } = useDragContext();
|
|
15
|
+
const { startDrag, setDraggedItem, updatePointerPosition, handleDrop, registerCardPosition, unregisterCard } = useDragContext();
|
|
16
16
|
const wasDraggingRef = useRef(false);
|
|
17
17
|
const cardRef = useRef<HTMLDivElement>(null);
|
|
18
18
|
|
|
19
|
+
// Register card position for optimized reorder calculations
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (disabled || !cardRef.current) return;
|
|
22
|
+
|
|
23
|
+
const updatePosition = () => {
|
|
24
|
+
if (cardRef.current) {
|
|
25
|
+
registerCardPosition(item.id, cardRef.current.getBoundingClientRect());
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Initial registration
|
|
30
|
+
updatePosition();
|
|
31
|
+
|
|
32
|
+
// Update on resize/layout changes
|
|
33
|
+
const resizeObserver = new ResizeObserver(updatePosition);
|
|
34
|
+
resizeObserver.observe(cardRef.current);
|
|
35
|
+
|
|
36
|
+
// Update on scroll (positions are viewport-relative)
|
|
37
|
+
const scrollHandler = () => updatePosition();
|
|
38
|
+
window.addEventListener('scroll', scrollHandler, true);
|
|
39
|
+
|
|
40
|
+
return () => {
|
|
41
|
+
unregisterCard(item.id);
|
|
42
|
+
resizeObserver.disconnect();
|
|
43
|
+
window.removeEventListener('scroll', scrollHandler, true);
|
|
44
|
+
};
|
|
45
|
+
}, [item.id, disabled, registerCardPosition, unregisterCard]);
|
|
46
|
+
|
|
19
47
|
if (disabled) {
|
|
20
48
|
return <>{children}</>;
|
|
21
49
|
}
|
|
@@ -55,6 +83,10 @@ export function DraggableCard({ item, children, disabled = false }: DraggableCar
|
|
|
55
83
|
whileDrag={{
|
|
56
84
|
cursor: 'grabbing',
|
|
57
85
|
opacity: 0,
|
|
86
|
+
height: 0,
|
|
87
|
+
marginBottom: 0,
|
|
88
|
+
padding: 0,
|
|
89
|
+
overflow: 'hidden',
|
|
58
90
|
}}
|
|
59
91
|
onDragStart={handleDragStart}
|
|
60
92
|
onDrag={handleDrag}
|