jettypod 4.4.79 → 4.4.80
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 +92 -1
- package/apps/dashboard/components/DraggableCard.tsx +13 -6
- package/apps/dashboard/components/KanbanBoard.tsx +60 -5
- package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +144 -5
- package/apps/dashboard/components/Toast.tsx +80 -0
- package/apps/dashboard/lib/undoStack.ts +141 -0
- package/apps/dashboard/tsconfig.tsbuildinfo +1 -0
- package/jettypod.js +17 -0
- package/lib/database.js +55 -0
- package/package.json +1 -1
- package/skills-templates/bug-mode/SKILL.md +75 -87
- package/skills-templates/bug-planning/SKILL.md +21 -117
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { createContext, useContext, useState, useCallback, useRef, ReactNode } from 'react';
|
|
4
|
+
import { createPortal } from 'react-dom';
|
|
4
5
|
import type { WorkItem } from '@/lib/db';
|
|
5
6
|
|
|
6
7
|
type DropHandler = (itemId: number, newStatus: string) => Promise<void>;
|
|
@@ -26,12 +27,16 @@ interface DragContextType {
|
|
|
26
27
|
draggedItem: WorkItem | null;
|
|
27
28
|
activeDropZone: string | null;
|
|
28
29
|
activeEpicZone: string | null;
|
|
30
|
+
dragPosition: { x: number; y: number };
|
|
31
|
+
dragOffset: { x: number; y: number };
|
|
32
|
+
draggedCardWidth: number;
|
|
29
33
|
setDraggedItem: (item: WorkItem | null) => void;
|
|
30
34
|
registerDropZone: (id: string, info: DropZoneInfo) => void;
|
|
31
35
|
unregisterDropZone: (id: string) => void;
|
|
32
36
|
registerEpicDropZone: (id: string, info: EpicDropZoneInfo) => void;
|
|
33
37
|
unregisterEpicDropZone: (id: string) => void;
|
|
34
38
|
updatePointerPosition: (x: number, y: number) => void;
|
|
39
|
+
startDrag: (item: WorkItem, cardRect: DOMRect, pointerX: number, pointerY: number) => void;
|
|
35
40
|
handleDrop: () => Promise<void>;
|
|
36
41
|
}
|
|
37
42
|
|
|
@@ -40,19 +45,31 @@ const DragContext = createContext<DragContextType>({
|
|
|
40
45
|
draggedItem: null,
|
|
41
46
|
activeDropZone: null,
|
|
42
47
|
activeEpicZone: null,
|
|
48
|
+
dragPosition: { x: 0, y: 0 },
|
|
49
|
+
dragOffset: { x: 0, y: 0 },
|
|
50
|
+
draggedCardWidth: 0,
|
|
43
51
|
setDraggedItem: () => {},
|
|
44
52
|
registerDropZone: () => {},
|
|
45
53
|
unregisterDropZone: () => {},
|
|
46
54
|
registerEpicDropZone: () => {},
|
|
47
55
|
unregisterEpicDropZone: () => {},
|
|
48
56
|
updatePointerPosition: () => {},
|
|
57
|
+
startDrag: () => {},
|
|
49
58
|
handleDrop: async () => {},
|
|
50
59
|
});
|
|
51
60
|
|
|
52
|
-
|
|
61
|
+
interface DragProviderProps {
|
|
62
|
+
children: ReactNode;
|
|
63
|
+
renderDragOverlay?: (item: WorkItem) => ReactNode;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function DragProvider({ children, renderDragOverlay }: DragProviderProps) {
|
|
53
67
|
const [draggedItem, setDraggedItem] = useState<WorkItem | null>(null);
|
|
54
68
|
const [activeDropZone, setActiveDropZone] = useState<string | null>(null);
|
|
55
69
|
const [activeEpicZone, setActiveEpicZone] = useState<string | null>(null);
|
|
70
|
+
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
|
|
71
|
+
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
|
72
|
+
const [draggedCardWidth, setDraggedCardWidth] = useState(0);
|
|
56
73
|
const dropZonesRef = useRef<Map<string, DropZoneInfo>>(new Map());
|
|
57
74
|
const epicDropZonesRef = useRef<Map<string, EpicDropZoneInfo>>(new Map());
|
|
58
75
|
const pointerPositionRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
|
|
@@ -75,6 +92,7 @@ export function DragProvider({ children }: { children: ReactNode }) {
|
|
|
75
92
|
|
|
76
93
|
const updatePointerPosition = useCallback((x: number, y: number) => {
|
|
77
94
|
pointerPositionRef.current = { x, y };
|
|
95
|
+
setDragPosition({ x, y });
|
|
78
96
|
let foundZone: string | null = null;
|
|
79
97
|
let foundEpicZone: string | null = null;
|
|
80
98
|
|
|
@@ -98,6 +116,18 @@ export function DragProvider({ children }: { children: ReactNode }) {
|
|
|
98
116
|
setActiveEpicZone(foundEpicZone);
|
|
99
117
|
}, []);
|
|
100
118
|
|
|
119
|
+
const startDrag = useCallback((item: WorkItem, cardRect: DOMRect, pointerX: number, pointerY: number) => {
|
|
120
|
+
console.log('[DragContext] startDrag called', { item: item.id, cardRect, pointerX, pointerY });
|
|
121
|
+
// Calculate offset from pointer to card's top-left corner
|
|
122
|
+
const offsetX = pointerX - cardRect.left;
|
|
123
|
+
const offsetY = pointerY - cardRect.top;
|
|
124
|
+
console.log('[DragContext] Setting drag state', { offsetX, offsetY, width: cardRect.width });
|
|
125
|
+
setDragOffset({ x: offsetX, y: offsetY });
|
|
126
|
+
setDragPosition({ x: pointerX, y: pointerY });
|
|
127
|
+
setDraggedCardWidth(cardRect.width);
|
|
128
|
+
setDraggedItem(item);
|
|
129
|
+
}, []);
|
|
130
|
+
|
|
101
131
|
const handleDrop = useCallback(async () => {
|
|
102
132
|
if (!draggedItem) return;
|
|
103
133
|
|
|
@@ -137,6 +167,20 @@ export function DragProvider({ children }: { children: ReactNode }) {
|
|
|
137
167
|
}
|
|
138
168
|
}, [activeDropZone, activeEpicZone, draggedItem]);
|
|
139
169
|
|
|
170
|
+
// Calculate overlay position (pointer position minus offset = card top-left)
|
|
171
|
+
const overlayX = dragPosition.x - dragOffset.x;
|
|
172
|
+
const overlayY = dragPosition.y - dragOffset.y;
|
|
173
|
+
|
|
174
|
+
// Debug: log portal render conditions
|
|
175
|
+
console.log('[DragContext] Portal render check', {
|
|
176
|
+
hasDraggedItem: !!draggedItem,
|
|
177
|
+
hasRenderOverlay: !!renderDragOverlay,
|
|
178
|
+
hasDocument: typeof document !== 'undefined',
|
|
179
|
+
overlayX,
|
|
180
|
+
overlayY,
|
|
181
|
+
draggedCardWidth,
|
|
182
|
+
});
|
|
183
|
+
|
|
140
184
|
return (
|
|
141
185
|
<DragContext.Provider
|
|
142
186
|
value={{
|
|
@@ -144,16 +188,63 @@ export function DragProvider({ children }: { children: ReactNode }) {
|
|
|
144
188
|
draggedItem,
|
|
145
189
|
activeDropZone,
|
|
146
190
|
activeEpicZone,
|
|
191
|
+
dragPosition,
|
|
192
|
+
dragOffset,
|
|
193
|
+
draggedCardWidth,
|
|
147
194
|
setDraggedItem,
|
|
148
195
|
registerDropZone,
|
|
149
196
|
unregisterDropZone,
|
|
150
197
|
registerEpicDropZone,
|
|
151
198
|
unregisterEpicDropZone,
|
|
152
199
|
updatePointerPosition,
|
|
200
|
+
startDrag,
|
|
153
201
|
handleDrop,
|
|
154
202
|
}}
|
|
155
203
|
>
|
|
156
204
|
{children}
|
|
205
|
+
{/* Drag overlay - rendered via portal to escape overflow containers */}
|
|
206
|
+
{draggedItem && renderDragOverlay && typeof document !== 'undefined' &&
|
|
207
|
+
createPortal(
|
|
208
|
+
<div
|
|
209
|
+
style={{
|
|
210
|
+
position: 'fixed',
|
|
211
|
+
left: overlayX,
|
|
212
|
+
top: overlayY,
|
|
213
|
+
width: draggedCardWidth,
|
|
214
|
+
zIndex: 9999,
|
|
215
|
+
pointerEvents: 'none',
|
|
216
|
+
transform: 'scale(1.02)',
|
|
217
|
+
boxShadow: '0 10px 30px rgba(0, 0, 0, 0.2)',
|
|
218
|
+
border: '4px solid red',
|
|
219
|
+
background: 'rgba(255, 0, 0, 0.1)',
|
|
220
|
+
}}
|
|
221
|
+
>
|
|
222
|
+
{renderDragOverlay(draggedItem)}
|
|
223
|
+
</div>,
|
|
224
|
+
document.body
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
{/* TEST: Always-visible portal to verify createPortal works */}
|
|
228
|
+
{typeof document !== 'undefined' &&
|
|
229
|
+
createPortal(
|
|
230
|
+
<div
|
|
231
|
+
style={{
|
|
232
|
+
position: 'fixed',
|
|
233
|
+
bottom: 20,
|
|
234
|
+
right: 20,
|
|
235
|
+
padding: '10px 20px',
|
|
236
|
+
background: 'green',
|
|
237
|
+
color: 'white',
|
|
238
|
+
fontWeight: 'bold',
|
|
239
|
+
zIndex: 9999,
|
|
240
|
+
borderRadius: 8,
|
|
241
|
+
}}
|
|
242
|
+
>
|
|
243
|
+
Portal Works! Dragging: {draggedItem ? `#${draggedItem.id}` : 'none'}
|
|
244
|
+
</div>,
|
|
245
|
+
document.body
|
|
246
|
+
)
|
|
247
|
+
}
|
|
157
248
|
</DragContext.Provider>
|
|
158
249
|
);
|
|
159
250
|
}
|
|
@@ -12,16 +12,24 @@ interface DraggableCardProps {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
export function DraggableCard({ item, children, disabled = false }: DraggableCardProps) {
|
|
15
|
-
const { setDraggedItem, updatePointerPosition, handleDrop } = useDragContext();
|
|
15
|
+
const { startDrag, setDraggedItem, updatePointerPosition, handleDrop } = useDragContext();
|
|
16
16
|
const wasDraggingRef = useRef(false);
|
|
17
|
+
const cardRef = useRef<HTMLDivElement>(null);
|
|
17
18
|
|
|
18
19
|
if (disabled) {
|
|
19
20
|
return <>{children}</>;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
const handleDragStart = () => {
|
|
23
|
+
const handleDragStart = (_event: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
|
|
24
|
+
console.log('[DraggableCard] handleDragStart fired', { itemId: item.id, point: info.point });
|
|
23
25
|
wasDraggingRef.current = true;
|
|
24
|
-
|
|
26
|
+
if (cardRef.current) {
|
|
27
|
+
const rect = cardRef.current.getBoundingClientRect();
|
|
28
|
+
console.log('[DraggableCard] Card rect:', rect);
|
|
29
|
+
startDrag(item, rect, info.point.x, info.point.y);
|
|
30
|
+
} else {
|
|
31
|
+
console.warn('[DraggableCard] cardRef.current is null!');
|
|
32
|
+
}
|
|
25
33
|
};
|
|
26
34
|
|
|
27
35
|
const handleDrag = (_event: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
|
|
@@ -44,14 +52,13 @@ export function DraggableCard({ item, children, disabled = false }: DraggableCar
|
|
|
44
52
|
|
|
45
53
|
return (
|
|
46
54
|
<motion.div
|
|
55
|
+
ref={cardRef}
|
|
47
56
|
drag
|
|
48
57
|
dragSnapToOrigin
|
|
49
58
|
dragElastic={0.1}
|
|
50
59
|
whileDrag={{
|
|
51
|
-
scale: 1.02,
|
|
52
|
-
boxShadow: '0 10px 30px rgba(0, 0, 0, 0.2)',
|
|
53
|
-
zIndex: 50,
|
|
54
60
|
cursor: 'grabbing',
|
|
61
|
+
opacity: 0,
|
|
55
62
|
}}
|
|
56
63
|
onDragStart={handleDragStart}
|
|
57
64
|
onDrag={handleDrag}
|
|
@@ -4,6 +4,7 @@ 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
|
+
import type { UndoAction } from '@/lib/undoStack';
|
|
7
8
|
import { EditableTitle } from './EditableTitle';
|
|
8
9
|
import { CardMenu } from './CardMenu';
|
|
9
10
|
import { DragProvider, useDragContext } from './DragContext';
|
|
@@ -89,7 +90,7 @@ interface KanbanCardProps {
|
|
|
89
90
|
showEpic?: boolean;
|
|
90
91
|
isInFlight?: boolean;
|
|
91
92
|
onTitleSave?: (id: number, newTitle: string) => Promise<void>;
|
|
92
|
-
onStatusChange?: (id: number, newStatus: string) => Promise<void>;
|
|
93
|
+
onStatusChange?: (id: number, newStatus: string) => Promise<void | { success: boolean; notFound?: boolean }>;
|
|
93
94
|
}
|
|
94
95
|
|
|
95
96
|
function KanbanCard({ item, epicTitle, showEpic = false, isInFlight = false, onTitleSave, onStatusChange }: KanbanCardProps) {
|
|
@@ -249,7 +250,7 @@ interface EpicGroupProps {
|
|
|
249
250
|
isInFlight?: boolean;
|
|
250
251
|
isDraggable?: boolean;
|
|
251
252
|
onTitleSave?: (id: number, newTitle: string) => Promise<void>;
|
|
252
|
-
onStatusChange?: (id: number, newStatus: string) => Promise<void>;
|
|
253
|
+
onStatusChange?: (id: number, newStatus: string) => Promise<void | { success: boolean; notFound?: boolean }>;
|
|
253
254
|
onEpicAssign?: (id: number, epicId: number | null) => Promise<void>;
|
|
254
255
|
onOrderChange?: (id: number, newOrder: number) => Promise<void>;
|
|
255
256
|
}
|
|
@@ -418,16 +419,53 @@ interface KanbanBoardProps {
|
|
|
418
419
|
backlog: Map<string, KanbanGroup>;
|
|
419
420
|
done: Map<string, KanbanGroup>;
|
|
420
421
|
onTitleSave?: (id: number, newTitle: string) => Promise<void>;
|
|
421
|
-
onStatusChange?: (id: number, newStatus: string) => Promise<void>;
|
|
422
|
+
onStatusChange?: (id: number, newStatus: string) => Promise<void | { success: boolean; notFound?: boolean }>;
|
|
422
423
|
onOrderChange?: (id: number, newOrder: number) => Promise<void>;
|
|
423
424
|
onEpicAssign?: (id: number, epicId: number | null) => Promise<void>;
|
|
425
|
+
// Undo/redo support
|
|
426
|
+
onUndo?: () => Promise<UndoAction | null>;
|
|
427
|
+
onRedo?: () => Promise<UndoAction | null>;
|
|
428
|
+
canUndo?: boolean;
|
|
429
|
+
canRedo?: boolean;
|
|
424
430
|
}
|
|
425
431
|
|
|
426
|
-
export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChange, onOrderChange, onEpicAssign }: KanbanBoardProps) {
|
|
432
|
+
export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChange, onOrderChange, onEpicAssign, onUndo, onRedo, canUndo, canRedo }: KanbanBoardProps) {
|
|
427
433
|
const backlogCount = inFlight.length + Array.from(backlog.values()).reduce((sum, g) => sum + g.items.length, 0);
|
|
428
434
|
const doneCount = Array.from(done.values()).reduce((sum, g) => sum + g.items.length, 0);
|
|
429
435
|
const backlogContainerRef = useRef<HTMLDivElement>(null);
|
|
430
436
|
|
|
437
|
+
// Keyboard shortcuts for undo/redo (Cmd+Z / Cmd+Shift+Z)
|
|
438
|
+
useEffect(() => {
|
|
439
|
+
const handleKeyDown = async (e: KeyboardEvent) => {
|
|
440
|
+
// Only handle if Cmd (Mac) or Ctrl (Windows/Linux) is pressed
|
|
441
|
+
const isMod = e.metaKey || e.ctrlKey;
|
|
442
|
+
if (!isMod || e.key.toLowerCase() !== 'z') return;
|
|
443
|
+
|
|
444
|
+
// Don't intercept if user is typing in an input field
|
|
445
|
+
const target = e.target as HTMLElement;
|
|
446
|
+
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
e.preventDefault();
|
|
451
|
+
|
|
452
|
+
if (e.shiftKey) {
|
|
453
|
+
// Cmd+Shift+Z = Redo
|
|
454
|
+
if (onRedo && canRedo) {
|
|
455
|
+
await onRedo();
|
|
456
|
+
}
|
|
457
|
+
} else {
|
|
458
|
+
// Cmd+Z = Undo
|
|
459
|
+
if (onUndo && canUndo) {
|
|
460
|
+
await onUndo();
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
466
|
+
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
467
|
+
}, [onUndo, onRedo, canUndo, canRedo]);
|
|
468
|
+
|
|
431
469
|
// Build a set of epic IDs that have in-flight items
|
|
432
470
|
const inFlightEpicIds = new Set<number>();
|
|
433
471
|
for (const item of inFlight) {
|
|
@@ -490,8 +528,25 @@ export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChan
|
|
|
490
528
|
await onOrderChange(itemId, newOrder);
|
|
491
529
|
}, [getAllBacklogItems, onOrderChange]);
|
|
492
530
|
|
|
531
|
+
// Render function for the drag overlay
|
|
532
|
+
const renderDragOverlay = useCallback((item: WorkItem) => {
|
|
533
|
+
// Find epic title if this is an in-flight item
|
|
534
|
+
const inFlightItem = inFlight.find(i => i.id === item.id);
|
|
535
|
+
const epicTitle = inFlightItem?.epicTitle || null;
|
|
536
|
+
const isInFlightCard = inFlightItem !== undefined;
|
|
537
|
+
|
|
538
|
+
return (
|
|
539
|
+
<KanbanCard
|
|
540
|
+
item={item}
|
|
541
|
+
epicTitle={epicTitle}
|
|
542
|
+
showEpic={isInFlightCard}
|
|
543
|
+
isInFlight={isInFlightCard}
|
|
544
|
+
/>
|
|
545
|
+
);
|
|
546
|
+
}, [inFlight]);
|
|
547
|
+
|
|
493
548
|
return (
|
|
494
|
-
<DragProvider>
|
|
549
|
+
<DragProvider renderDragOverlay={renderDragOverlay}>
|
|
495
550
|
<div className="flex gap-4 overflow-x-auto h-full" data-testid="kanban-board">
|
|
496
551
|
{/* Backlog Column */}
|
|
497
552
|
<KanbanColumn title="Backlog" count={backlogCount}>
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useState, useCallback } from 'react';
|
|
3
|
+
import { useState, useCallback, useMemo } from 'react';
|
|
4
4
|
import { KanbanBoard } from './KanbanBoard';
|
|
5
5
|
import { RecentDecisionsWidget } from './RecentDecisionsWidget';
|
|
6
|
+
import { ToastProvider, useToast } from './Toast';
|
|
6
7
|
import { useWebSocket, type WebSocketMessage } from '../hooks/useWebSocket';
|
|
7
8
|
import type { InFlightItem, KanbanGroup, Decision } from '@/lib/db';
|
|
9
|
+
import { UndoStack, type UndoAction } from '@/lib/undoStack';
|
|
8
10
|
|
|
9
11
|
interface KanbanData {
|
|
10
12
|
inFlight: InFlightItem[];
|
|
@@ -21,7 +23,37 @@ interface RealTimeKanbanWrapperProps {
|
|
|
21
23
|
initialDecisions: Decision[];
|
|
22
24
|
}
|
|
23
25
|
|
|
26
|
+
// Helper to find item by ID in the kanban data
|
|
27
|
+
function findItemById(data: KanbanData, id: number): { item: InFlightItem; status: string } | null {
|
|
28
|
+
// Check in-flight (status: in_progress)
|
|
29
|
+
const inFlightItem = data.inFlight.find(item => item.id === id);
|
|
30
|
+
if (inFlightItem) return { item: inFlightItem, status: 'in_progress' };
|
|
31
|
+
|
|
32
|
+
// Check backlog groups
|
|
33
|
+
for (const group of data.backlog.values()) {
|
|
34
|
+
const backlogItem = group.items.find(item => item.id === id);
|
|
35
|
+
if (backlogItem) return { item: backlogItem as InFlightItem, status: 'backlog' };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Check done groups
|
|
39
|
+
for (const group of data.done.values()) {
|
|
40
|
+
const doneItem = group.items.find(item => item.id === id);
|
|
41
|
+
if (doneItem) return { item: doneItem as InFlightItem, status: 'done' };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
24
47
|
export function RealTimeKanbanWrapper({ initialData, initialDecisions }: RealTimeKanbanWrapperProps) {
|
|
48
|
+
return (
|
|
49
|
+
<ToastProvider>
|
|
50
|
+
<RealTimeKanbanContent initialData={initialData} initialDecisions={initialDecisions} />
|
|
51
|
+
</ToastProvider>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function RealTimeKanbanContent({ initialData, initialDecisions }: RealTimeKanbanWrapperProps) {
|
|
56
|
+
const { showToast } = useToast();
|
|
25
57
|
const [data, setData] = useState<KanbanData>(() => ({
|
|
26
58
|
inFlight: initialData.inFlight,
|
|
27
59
|
backlog: new Map(initialData.backlog),
|
|
@@ -30,6 +62,10 @@ export function RealTimeKanbanWrapper({ initialData, initialDecisions }: RealTim
|
|
|
30
62
|
const [decisions, setDecisions] = useState<Decision[]>(initialDecisions);
|
|
31
63
|
const [statusError, setStatusError] = useState<string | null>(null);
|
|
32
64
|
|
|
65
|
+
// Undo/redo stack - created once per component instance
|
|
66
|
+
const [undoStack] = useState(() => new UndoStack());
|
|
67
|
+
const [undoRedoVersion, setUndoRedoVersion] = useState(0); // Force re-render on stack changes
|
|
68
|
+
|
|
33
69
|
const refreshData = useCallback(async () => {
|
|
34
70
|
const [kanbanResponse, decisionsResponse] = await Promise.all([
|
|
35
71
|
fetch('/api/kanban'),
|
|
@@ -63,8 +99,14 @@ export function RealTimeKanbanWrapper({ initialData, initialDecisions }: RealTim
|
|
|
63
99
|
await refreshData();
|
|
64
100
|
}, [refreshData]);
|
|
65
101
|
|
|
66
|
-
const handleStatusChange = useCallback(async (id: number, newStatus: string) => {
|
|
102
|
+
const handleStatusChange = useCallback(async (id: number, newStatus: string, skipUndo = false): Promise<{ success: boolean; notFound?: boolean }> => {
|
|
67
103
|
setStatusError(null);
|
|
104
|
+
|
|
105
|
+
// Find the item to get its current status and title before the change
|
|
106
|
+
const found = findItemById(data, id);
|
|
107
|
+
const previousStatus = found?.status;
|
|
108
|
+
const itemTitle = found?.item.title ?? `Item #${id}`;
|
|
109
|
+
|
|
68
110
|
try {
|
|
69
111
|
const response = await fetch(`/api/work/${id}/status`, {
|
|
70
112
|
method: 'PATCH',
|
|
@@ -74,17 +116,34 @@ export function RealTimeKanbanWrapper({ initialData, initialDecisions }: RealTim
|
|
|
74
116
|
if (!response.ok) {
|
|
75
117
|
if (response.status === 404) {
|
|
76
118
|
setStatusError('Item no longer exists');
|
|
119
|
+
await refreshData();
|
|
120
|
+
return { success: false, notFound: true };
|
|
77
121
|
} else {
|
|
78
122
|
setStatusError('Failed to update status');
|
|
123
|
+
await refreshData();
|
|
124
|
+
return { success: false };
|
|
79
125
|
}
|
|
80
|
-
await refreshData();
|
|
81
|
-
return;
|
|
82
126
|
}
|
|
127
|
+
|
|
128
|
+
// Push to undo stack if this is a user-initiated change (not undo/redo)
|
|
129
|
+
if (!skipUndo && previousStatus && previousStatus !== newStatus) {
|
|
130
|
+
undoStack.push({
|
|
131
|
+
type: 'status_change',
|
|
132
|
+
itemId: id,
|
|
133
|
+
itemTitle,
|
|
134
|
+
before: previousStatus,
|
|
135
|
+
after: newStatus,
|
|
136
|
+
});
|
|
137
|
+
setUndoRedoVersion(v => v + 1); // Trigger re-render
|
|
138
|
+
}
|
|
139
|
+
|
|
83
140
|
await refreshData();
|
|
141
|
+
return { success: true };
|
|
84
142
|
} catch {
|
|
85
143
|
setStatusError('Failed to update status');
|
|
144
|
+
return { success: false };
|
|
86
145
|
}
|
|
87
|
-
}, [refreshData]);
|
|
146
|
+
}, [refreshData, data, undoStack]);
|
|
88
147
|
|
|
89
148
|
const handleOrderChange = useCallback(async (id: number, newOrder: number) => {
|
|
90
149
|
try {
|
|
@@ -112,6 +171,82 @@ export function RealTimeKanbanWrapper({ initialData, initialDecisions }: RealTim
|
|
|
112
171
|
}
|
|
113
172
|
}, [refreshData]);
|
|
114
173
|
|
|
174
|
+
// Helper to format status for display
|
|
175
|
+
const formatStatus = (status: string): string => {
|
|
176
|
+
switch (status) {
|
|
177
|
+
case 'in_progress': return 'In Flight';
|
|
178
|
+
case 'backlog': return 'Backlog';
|
|
179
|
+
case 'done': return 'Done';
|
|
180
|
+
default: return status;
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
// Undo the most recent status change
|
|
185
|
+
const handleUndo = useCallback(async (): Promise<UndoAction | null> => {
|
|
186
|
+
const action = undoStack.undo();
|
|
187
|
+
if (!action) return null;
|
|
188
|
+
|
|
189
|
+
// Revert the status change (skipUndo=true to prevent adding to undo stack)
|
|
190
|
+
const result = await handleStatusChange(action.itemId, action.before, true);
|
|
191
|
+
setUndoRedoVersion(v => v + 1);
|
|
192
|
+
|
|
193
|
+
if (result.notFound) {
|
|
194
|
+
// Item was deleted - show error toast (statusError already set by handleStatusChange)
|
|
195
|
+
// The action is already removed from undo stack and won't be in redo stack
|
|
196
|
+
// because we didn't push it back - just leave it removed
|
|
197
|
+
showToast(`Item no longer exists: "${action.itemTitle}" was deleted`, 'error');
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (!result.success) {
|
|
202
|
+
// Other error - push action back to undo stack so user can retry
|
|
203
|
+
undoStack.push({
|
|
204
|
+
type: action.type,
|
|
205
|
+
itemId: action.itemId,
|
|
206
|
+
itemTitle: action.itemTitle,
|
|
207
|
+
before: action.after, // Swap because we want to retry undoing
|
|
208
|
+
after: action.before,
|
|
209
|
+
});
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Show success toast notification
|
|
214
|
+
showToast(`Undone: "${action.itemTitle}" moved back to ${formatStatus(action.before)}`);
|
|
215
|
+
|
|
216
|
+
return action;
|
|
217
|
+
}, [undoStack, handleStatusChange, showToast]);
|
|
218
|
+
|
|
219
|
+
// Redo the most recently undone status change
|
|
220
|
+
const handleRedo = useCallback(async (): Promise<UndoAction | null> => {
|
|
221
|
+
const action = undoStack.redo();
|
|
222
|
+
if (!action) return null;
|
|
223
|
+
|
|
224
|
+
// Re-apply the status change (skipUndo=true to prevent adding to undo stack)
|
|
225
|
+
const result = await handleStatusChange(action.itemId, action.after, true);
|
|
226
|
+
setUndoRedoVersion(v => v + 1);
|
|
227
|
+
|
|
228
|
+
if (result.notFound) {
|
|
229
|
+
// Item was deleted - show error toast
|
|
230
|
+
showToast(`Item no longer exists: "${action.itemTitle}" was deleted`, 'error');
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (!result.success) {
|
|
235
|
+
// Other error - push action back to redo stack so user can retry
|
|
236
|
+
// Note: This is a simplification; in production you might want more sophisticated retry logic
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Show success toast notification
|
|
241
|
+
showToast(`Redone: "${action.itemTitle}" moved to ${formatStatus(action.after)}`);
|
|
242
|
+
|
|
243
|
+
return action;
|
|
244
|
+
}, [undoStack, handleStatusChange, showToast]);
|
|
245
|
+
|
|
246
|
+
// Expose undo stack state
|
|
247
|
+
const canUndo = undoStack.canUndo();
|
|
248
|
+
const canRedo = undoStack.canRedo();
|
|
249
|
+
|
|
115
250
|
const wsUrl = typeof window !== 'undefined'
|
|
116
251
|
? `ws://${window.location.hostname}:8080`
|
|
117
252
|
: 'ws://localhost:8080';
|
|
@@ -171,6 +306,10 @@ export function RealTimeKanbanWrapper({ initialData, initialDecisions }: RealTim
|
|
|
171
306
|
onStatusChange={handleStatusChange}
|
|
172
307
|
onOrderChange={handleOrderChange}
|
|
173
308
|
onEpicAssign={handleEpicAssign}
|
|
309
|
+
onUndo={handleUndo}
|
|
310
|
+
onRedo={handleRedo}
|
|
311
|
+
canUndo={canUndo}
|
|
312
|
+
canRedo={canRedo}
|
|
174
313
|
/>
|
|
175
314
|
</div>
|
|
176
315
|
<div className="w-60 flex-shrink-0">
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
|
|
4
|
+
import { AnimatePresence, motion } from 'framer-motion';
|
|
5
|
+
|
|
6
|
+
interface Toast {
|
|
7
|
+
id: string;
|
|
8
|
+
message: string;
|
|
9
|
+
type?: 'info' | 'success' | 'error';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface ToastContextValue {
|
|
13
|
+
showToast: (message: string, type?: Toast['type']) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const ToastContext = createContext<ToastContextValue | null>(null);
|
|
17
|
+
|
|
18
|
+
export function useToast() {
|
|
19
|
+
const context = useContext(ToastContext);
|
|
20
|
+
if (!context) {
|
|
21
|
+
throw new Error('useToast must be used within a ToastProvider');
|
|
22
|
+
}
|
|
23
|
+
return context;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const TOAST_DURATION = 3000;
|
|
27
|
+
|
|
28
|
+
export function ToastProvider({ children }: { children: ReactNode }) {
|
|
29
|
+
const [toasts, setToasts] = useState<Toast[]>([]);
|
|
30
|
+
|
|
31
|
+
const showToast = useCallback((message: string, type: Toast['type'] = 'info') => {
|
|
32
|
+
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
33
|
+
setToasts(prev => [...prev, { id, message, type }]);
|
|
34
|
+
|
|
35
|
+
// Auto-dismiss after duration
|
|
36
|
+
setTimeout(() => {
|
|
37
|
+
setToasts(prev => prev.filter(t => t.id !== id));
|
|
38
|
+
}, TOAST_DURATION);
|
|
39
|
+
}, []);
|
|
40
|
+
|
|
41
|
+
const dismissToast = useCallback((id: string) => {
|
|
42
|
+
setToasts(prev => prev.filter(t => t.id !== id));
|
|
43
|
+
}, []);
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<ToastContext.Provider value={{ showToast }}>
|
|
47
|
+
{children}
|
|
48
|
+
<ToastContainer toasts={toasts} onDismiss={dismissToast} />
|
|
49
|
+
</ToastContext.Provider>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function ToastContainer({ toasts, onDismiss }: { toasts: Toast[]; onDismiss: (id: string) => void }) {
|
|
54
|
+
return (
|
|
55
|
+
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2" data-testid="toast-container">
|
|
56
|
+
<AnimatePresence mode="popLayout">
|
|
57
|
+
{toasts.map(toast => (
|
|
58
|
+
<motion.div
|
|
59
|
+
key={toast.id}
|
|
60
|
+
initial={{ opacity: 0, y: 20, scale: 0.95 }}
|
|
61
|
+
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
62
|
+
exit={{ opacity: 0, y: -10, scale: 0.95 }}
|
|
63
|
+
transition={{ duration: 0.2 }}
|
|
64
|
+
className={`px-4 py-2 rounded-lg shadow-lg text-sm font-medium cursor-pointer ${
|
|
65
|
+
toast.type === 'success'
|
|
66
|
+
? 'bg-green-600 text-white'
|
|
67
|
+
: toast.type === 'error'
|
|
68
|
+
? 'bg-red-600 text-white'
|
|
69
|
+
: 'bg-zinc-800 text-white dark:bg-zinc-700'
|
|
70
|
+
}`}
|
|
71
|
+
onClick={() => onDismiss(toast.id)}
|
|
72
|
+
data-testid="toast"
|
|
73
|
+
>
|
|
74
|
+
{toast.message}
|
|
75
|
+
</motion.div>
|
|
76
|
+
))}
|
|
77
|
+
</AnimatePresence>
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|