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,8 +1,9 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useState, useCallback, useRef, useEffect } from 'react';
|
|
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,29 +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 } = useDragContext();
|
|
262
|
+
const { isDragging, draggedItem, activeEpicZone, activeDropZone, dragPosition, draggedCardHeight, registerEpicDropZone, unregisterEpicDropZone, getCardPositions } = useDragContext();
|
|
261
263
|
|
|
262
|
-
//
|
|
264
|
+
// Use ref for items to avoid re-registering drop zone when items change
|
|
265
|
+
const itemsRef = useRef(items);
|
|
266
|
+
itemsRef.current = items;
|
|
267
|
+
|
|
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
|
|
263
273
|
const handleEpicReorder = useCallback(async (itemId: number, pointerY: number) => {
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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));
|
|
283
|
+
|
|
284
|
+
// Use cached card positions from registry, filtered to this epic's items
|
|
285
|
+
const allPositions = getCardPositions();
|
|
286
|
+
console.log('[EpicGroup] allPositions:', allPositions.length);
|
|
287
|
+
const cardPositions = allPositions
|
|
288
|
+
.filter(pos => itemIds.has(pos.id) && pos.id !== itemId)
|
|
289
|
+
.map(pos => ({
|
|
290
|
+
id: pos.id,
|
|
291
|
+
midY: (pos.rect.top + pos.rect.bottom) / 2,
|
|
292
|
+
}));
|
|
293
|
+
console.log('[EpicGroup] filtered cardPositions:', cardPositions);
|
|
280
294
|
|
|
281
295
|
// Skip reorder if this is the only item in the epic (no other cards to reorder against)
|
|
282
296
|
if (cardPositions.length === 0) {
|
|
297
|
+
console.log('[EpicGroup] No other cards to reorder against, returning');
|
|
283
298
|
return;
|
|
284
299
|
}
|
|
285
300
|
|
|
@@ -297,18 +312,21 @@ function EpicGroup({ epicId, epicTitle, items, isInFlight = false, isDraggable =
|
|
|
297
312
|
|
|
298
313
|
// Calculate new display_order using index * 10
|
|
299
314
|
const newOrder = insertIndex * 10;
|
|
315
|
+
console.log('[EpicGroup] Calculated insertIndex:', insertIndex, 'newOrder:', newOrder, 'for itemId:', itemId);
|
|
300
316
|
|
|
301
317
|
try {
|
|
302
|
-
|
|
318
|
+
console.log('[EpicGroup] Calling onOrderChange...');
|
|
319
|
+
await onOrderChangeRef.current(itemId, newOrder);
|
|
320
|
+
console.log('[EpicGroup] onOrderChange completed successfully');
|
|
303
321
|
} catch (error) {
|
|
304
322
|
// Log error and notify user - items remain in original order since state wasn't updated
|
|
305
|
-
console.error('Failed to reorder item:', error);
|
|
323
|
+
console.error('[EpicGroup] Failed to reorder item:', error);
|
|
306
324
|
// Show user-friendly error notification
|
|
307
325
|
alert('Failed to reorder item. Please try again.');
|
|
308
326
|
}
|
|
309
|
-
}, [
|
|
327
|
+
}, [getCardPositions]);
|
|
310
328
|
|
|
311
|
-
// Register as epic drop zone
|
|
329
|
+
// Register as epic drop zone - stable registration that doesn't change with items
|
|
312
330
|
useEffect(() => {
|
|
313
331
|
if (!containerRef.current || !onEpicAssign || epicId === null) return;
|
|
314
332
|
|
|
@@ -317,13 +335,13 @@ function EpicGroup({ epicId, epicTitle, items, isInFlight = false, isDraggable =
|
|
|
317
335
|
epicId,
|
|
318
336
|
element: containerRef.current,
|
|
319
337
|
onEpicAssign,
|
|
320
|
-
onReorder:
|
|
338
|
+
onReorder: handleEpicReorder,
|
|
321
339
|
});
|
|
322
340
|
|
|
323
341
|
return () => {
|
|
324
342
|
unregisterEpicDropZone(zoneId);
|
|
325
343
|
};
|
|
326
|
-
}, [epicId, onEpicAssign,
|
|
344
|
+
}, [epicId, onEpicAssign, handleEpicReorder, registerEpicDropZone, unregisterEpicDropZone]);
|
|
327
345
|
|
|
328
346
|
// Check if this epic zone is the active drop target
|
|
329
347
|
const isActiveTarget = activeEpicZone === `epic-${epicId}`;
|
|
@@ -338,7 +356,45 @@ function EpicGroup({ epicId, epicTitle, items, isInFlight = false, isDraggable =
|
|
|
338
356
|
// Show reorder highlight when dragging within same epic (purple)
|
|
339
357
|
const showReorderHighlight = isActiveTarget && isSameEpic;
|
|
340
358
|
|
|
341
|
-
|
|
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;
|
|
342
398
|
|
|
343
399
|
return (
|
|
344
400
|
<div
|
|
@@ -348,6 +404,8 @@ function EpicGroup({ epicId, epicTitle, items, isInFlight = false, isDraggable =
|
|
|
348
404
|
? 'ring-2 ring-indigo-400 bg-indigo-100/50 dark:bg-indigo-900/30'
|
|
349
405
|
: showReorderHighlight
|
|
350
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'
|
|
351
409
|
: ''
|
|
352
410
|
}`}
|
|
353
411
|
data-epic-id={epicId}
|
|
@@ -378,11 +436,43 @@ function EpicGroup({ epicId, epicTitle, items, isInFlight = false, isDraggable =
|
|
|
378
436
|
)}
|
|
379
437
|
</div>
|
|
380
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
|
+
)}
|
|
381
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>
|
|
382
464
|
{items.map((item) => (
|
|
383
|
-
<
|
|
384
|
-
<
|
|
385
|
-
|
|
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>
|
|
386
476
|
))}
|
|
387
477
|
</div>
|
|
388
478
|
</div>
|
|
@@ -414,6 +504,81 @@ function KanbanColumn({ title, children, count }: KanbanColumnProps) {
|
|
|
414
504
|
);
|
|
415
505
|
}
|
|
416
506
|
|
|
507
|
+
// Wrapper component that handles backlog reorder with access to drag context
|
|
508
|
+
interface BacklogDropZoneWrapperProps {
|
|
509
|
+
backlog: Map<string, KanbanGroup>;
|
|
510
|
+
onStatusChange?: (id: number, newStatus: string) => Promise<void | { success: boolean; notFound?: boolean }>;
|
|
511
|
+
onOrderChange?: (id: number, newOrder: number) => Promise<void>;
|
|
512
|
+
children: React.ReactNode;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function BacklogDropZoneWrapper({ backlog, onStatusChange, onOrderChange, children }: BacklogDropZoneWrapperProps) {
|
|
516
|
+
const { getCardPositions } = useDragContext();
|
|
517
|
+
|
|
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;
|
|
523
|
+
|
|
524
|
+
// Handle reorder within backlog - calculate new display_order based on pointer Y
|
|
525
|
+
const handleBacklogReorder = useCallback(async (itemId: number, pointerY: number) => {
|
|
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
|
+
}
|
|
535
|
+
|
|
536
|
+
// Use cached card positions from registry, filtered to backlog items
|
|
537
|
+
const allPositions = getCardPositions();
|
|
538
|
+
const cardPositions = allPositions
|
|
539
|
+
.filter(pos => backlogItemIds.has(pos.id) && pos.id !== itemId)
|
|
540
|
+
.map(pos => ({
|
|
541
|
+
id: pos.id,
|
|
542
|
+
midY: (pos.rect.top + pos.rect.bottom) / 2,
|
|
543
|
+
}));
|
|
544
|
+
|
|
545
|
+
// Sort by Y position
|
|
546
|
+
cardPositions.sort((a, b) => a.midY - b.midY);
|
|
547
|
+
|
|
548
|
+
// Find insertion index based on pointer Y
|
|
549
|
+
let insertIndex = cardPositions.length;
|
|
550
|
+
for (let i = 0; i < cardPositions.length; i++) {
|
|
551
|
+
if (pointerY < cardPositions[i].midY) {
|
|
552
|
+
insertIndex = i;
|
|
553
|
+
break;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Calculate new display_order
|
|
558
|
+
// Use index * 10 to leave room for future insertions
|
|
559
|
+
const newOrder = insertIndex * 10;
|
|
560
|
+
|
|
561
|
+
await onOrderChangeRef.current(itemId, newOrder);
|
|
562
|
+
}, [getCardPositions]);
|
|
563
|
+
|
|
564
|
+
return (
|
|
565
|
+
<DropZone
|
|
566
|
+
targetStatus="backlog"
|
|
567
|
+
onDrop={async (itemId, newStatus) => {
|
|
568
|
+
if (onStatusChange) await onStatusChange(itemId, newStatus);
|
|
569
|
+
}}
|
|
570
|
+
onReorder={handleBacklogReorder}
|
|
571
|
+
allowReorder={true}
|
|
572
|
+
className="rounded-lg p-2 -m-2 min-h-[100px]"
|
|
573
|
+
highlightClassName="ring-2 ring-amber-400 bg-amber-100/50 dark:bg-amber-900/30"
|
|
574
|
+
reorderHighlightClassName="ring-2 ring-purple-400 bg-purple-100/50 dark:bg-purple-900/30"
|
|
575
|
+
data-testid="backlog-drop-zone"
|
|
576
|
+
>
|
|
577
|
+
{children}
|
|
578
|
+
</DropZone>
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
|
|
417
582
|
interface KanbanBoardProps {
|
|
418
583
|
inFlight: InFlightItem[];
|
|
419
584
|
backlog: Map<string, KanbanGroup>;
|
|
@@ -432,7 +597,6 @@ interface KanbanBoardProps {
|
|
|
432
597
|
export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChange, onOrderChange, onEpicAssign, onUndo, onRedo, canUndo, canRedo }: KanbanBoardProps) {
|
|
433
598
|
const backlogCount = inFlight.length + Array.from(backlog.values()).reduce((sum, g) => sum + g.items.length, 0);
|
|
434
599
|
const doneCount = Array.from(done.values()).reduce((sum, g) => sum + g.items.length, 0);
|
|
435
|
-
const backlogContainerRef = useRef<HTMLDivElement>(null);
|
|
436
600
|
|
|
437
601
|
// Keyboard shortcuts for undo/redo (Cmd+Z / Cmd+Shift+Z)
|
|
438
602
|
useEffect(() => {
|
|
@@ -475,59 +639,6 @@ export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChan
|
|
|
475
639
|
}
|
|
476
640
|
}
|
|
477
641
|
|
|
478
|
-
// Get flat list of all backlog items for reordering
|
|
479
|
-
const getAllBacklogItems = useCallback((): WorkItem[] => {
|
|
480
|
-
const items: WorkItem[] = [];
|
|
481
|
-
for (const group of backlog.values()) {
|
|
482
|
-
items.push(...group.items);
|
|
483
|
-
}
|
|
484
|
-
return items;
|
|
485
|
-
}, [backlog]);
|
|
486
|
-
|
|
487
|
-
// Handle reorder within backlog - calculate new display_order based on pointer Y
|
|
488
|
-
const handleBacklogReorder = useCallback(async (itemId: number, pointerY: number) => {
|
|
489
|
-
if (!onOrderChange || !backlogContainerRef.current) return;
|
|
490
|
-
|
|
491
|
-
const items = getAllBacklogItems();
|
|
492
|
-
const draggedItem = items.find(item => item.id === itemId);
|
|
493
|
-
if (!draggedItem) return;
|
|
494
|
-
|
|
495
|
-
// Find all card elements in the backlog container
|
|
496
|
-
const cardElements = backlogContainerRef.current.querySelectorAll('[data-item-id]');
|
|
497
|
-
const cardPositions: { id: number; top: number; bottom: number; midY: number }[] = [];
|
|
498
|
-
|
|
499
|
-
cardElements.forEach((el) => {
|
|
500
|
-
const id = parseInt(el.getAttribute('data-item-id') || '0', 10);
|
|
501
|
-
if (id !== itemId) {
|
|
502
|
-
const rect = el.getBoundingClientRect();
|
|
503
|
-
cardPositions.push({
|
|
504
|
-
id,
|
|
505
|
-
top: rect.top,
|
|
506
|
-
bottom: rect.bottom,
|
|
507
|
-
midY: (rect.top + rect.bottom) / 2,
|
|
508
|
-
});
|
|
509
|
-
}
|
|
510
|
-
});
|
|
511
|
-
|
|
512
|
-
// Sort by Y position
|
|
513
|
-
cardPositions.sort((a, b) => a.midY - b.midY);
|
|
514
|
-
|
|
515
|
-
// Find insertion index based on pointer Y
|
|
516
|
-
let insertIndex = cardPositions.length;
|
|
517
|
-
for (let i = 0; i < cardPositions.length; i++) {
|
|
518
|
-
if (pointerY < cardPositions[i].midY) {
|
|
519
|
-
insertIndex = i;
|
|
520
|
-
break;
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
// Calculate new display_order
|
|
525
|
-
// Use index * 10 to leave room for future insertions
|
|
526
|
-
const newOrder = insertIndex * 10;
|
|
527
|
-
|
|
528
|
-
await onOrderChange(itemId, newOrder);
|
|
529
|
-
}, [getAllBacklogItems, onOrderChange]);
|
|
530
|
-
|
|
531
642
|
// Render function for the drag overlay
|
|
532
643
|
const renderDragOverlay = useCallback((item: WorkItem) => {
|
|
533
644
|
// Find epic title if this is an in-flight item
|
|
@@ -546,7 +657,7 @@ export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChan
|
|
|
546
657
|
}, [inFlight]);
|
|
547
658
|
|
|
548
659
|
return (
|
|
549
|
-
<DragProvider renderDragOverlay={renderDragOverlay}>
|
|
660
|
+
<DragProvider renderDragOverlay={renderDragOverlay} onRemoveFromEpic={onEpicAssign}>
|
|
550
661
|
<div className="flex gap-4 overflow-x-auto h-full" data-testid="kanban-board">
|
|
551
662
|
{/* Backlog Column */}
|
|
552
663
|
<KanbanColumn title="Backlog" count={backlogCount}>
|
|
@@ -595,19 +706,12 @@ export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChan
|
|
|
595
706
|
)}
|
|
596
707
|
|
|
597
708
|
{/* Backlog Section - Drop Zone with Reordering */}
|
|
598
|
-
<
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
}}
|
|
603
|
-
onReorder={handleBacklogReorder}
|
|
604
|
-
allowReorder={true}
|
|
605
|
-
className="rounded-lg p-2 -m-2 min-h-[100px]"
|
|
606
|
-
highlightClassName="ring-2 ring-amber-400 bg-amber-100/50 dark:bg-amber-900/30"
|
|
607
|
-
reorderHighlightClassName="ring-2 ring-purple-400 bg-purple-100/50 dark:bg-purple-900/30"
|
|
608
|
-
data-testid="backlog-drop-zone"
|
|
709
|
+
<BacklogDropZoneWrapper
|
|
710
|
+
backlog={backlog}
|
|
711
|
+
onStatusChange={onStatusChange}
|
|
712
|
+
onOrderChange={onOrderChange}
|
|
609
713
|
>
|
|
610
|
-
<div
|
|
714
|
+
<div>
|
|
611
715
|
{/* Grouped Backlog Items */}
|
|
612
716
|
{Array.from(backlog.entries()).map(([key, group]) => (
|
|
613
717
|
<EpicGroup
|
|
@@ -627,7 +731,7 @@ export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChan
|
|
|
627
731
|
<p className="text-sm text-zinc-500 text-center py-4">Drop items here for backlog</p>
|
|
628
732
|
)}
|
|
629
733
|
</div>
|
|
630
|
-
</
|
|
734
|
+
</BacklogDropZoneWrapper>
|
|
631
735
|
|
|
632
736
|
{backlogCount === 0 && inFlight.length === 0 && (
|
|
633
737
|
<p className="text-sm text-zinc-500 text-center py-4">No items in backlog</p>
|
|
@@ -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/hooks/post-checkout
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
const { importAll } = require('jettypod/lib/db-import');
|
|
11
|
+
const { walCheckpoint } = require('jettypod/lib/database');
|
|
11
12
|
const { execSync } = require('child_process');
|
|
12
13
|
|
|
13
14
|
(async () => {
|
|
@@ -80,7 +81,16 @@ const { execSync } = require('child_process');
|
|
|
80
81
|
}
|
|
81
82
|
}
|
|
82
83
|
|
|
83
|
-
// SECOND:
|
|
84
|
+
// SECOND: Checkpoint WAL before importing to prevent corruption
|
|
85
|
+
// This flushes any pending writes to the main database file
|
|
86
|
+
try {
|
|
87
|
+
await walCheckpoint();
|
|
88
|
+
} catch (err) {
|
|
89
|
+
// Checkpoint failure shouldn't block - just log and continue
|
|
90
|
+
console.error('Post-checkout hook warning: WAL checkpoint failed:', err.message);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// THIRD: Import database snapshots
|
|
84
94
|
try {
|
|
85
95
|
await importAll();
|
|
86
96
|
process.exit(0);
|
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',
|
|
@@ -2801,7 +2805,8 @@ Quick commands:
|
|
|
2801
2805
|
'stable-mode': ['speed_mode_complete'],
|
|
2802
2806
|
'production-mode': ['stable_mode_complete'],
|
|
2803
2807
|
'feature-planning': [],
|
|
2804
|
-
'epic-planning': []
|
|
2808
|
+
'epic-planning': [],
|
|
2809
|
+
'chore-planning': []
|
|
2805
2810
|
};
|
|
2806
2811
|
|
|
2807
2812
|
// Validate skill name
|
package/lib/database.js
CHANGED
|
@@ -270,6 +270,36 @@ function initSchema() {
|
|
|
270
270
|
});
|
|
271
271
|
}
|
|
272
272
|
|
|
273
|
+
/**
|
|
274
|
+
* Force a WAL checkpoint to flush all pending writes to the main database file
|
|
275
|
+
* Should be called before git checkout/merge operations to prevent WAL corruption
|
|
276
|
+
* @param {sqlite3.Database} [database] - Database connection (uses singleton if not provided)
|
|
277
|
+
* @returns {Promise<{success: boolean, pagesWritten: number}>} Checkpoint result
|
|
278
|
+
*/
|
|
279
|
+
function walCheckpoint(database) {
|
|
280
|
+
const db = database || (typeof getDb === 'function' ? getDb() : null);
|
|
281
|
+
if (!db) {
|
|
282
|
+
return Promise.resolve({ success: false, pagesWritten: 0, reason: 'No database connection' });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return new Promise((resolve) => {
|
|
286
|
+
// TRUNCATE mode: checkpoint and truncate the WAL file to zero bytes
|
|
287
|
+
db.get('PRAGMA wal_checkpoint(TRUNCATE)', [], (err, row) => {
|
|
288
|
+
if (err) {
|
|
289
|
+
// Don't reject - checkpoint failure shouldn't block operations
|
|
290
|
+
resolve({ success: false, pagesWritten: 0, reason: err.message });
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// row contains: busy (0=success), log (pages in WAL), checkpointed (pages written)
|
|
295
|
+
const success = row && row.busy === 0;
|
|
296
|
+
const pagesWritten = row ? row.checkpointed : 0;
|
|
297
|
+
|
|
298
|
+
resolve({ success, pagesWritten });
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
273
303
|
/**
|
|
274
304
|
* Check database file integrity using SQLite's built-in integrity check
|
|
275
305
|
* @param {sqlite3.Database} database - Database connection to check
|
|
@@ -551,6 +581,7 @@ module.exports = {
|
|
|
551
581
|
waitForMigrations,
|
|
552
582
|
validateSchema,
|
|
553
583
|
checkIntegrity,
|
|
584
|
+
walCheckpoint,
|
|
554
585
|
validateOnStartup,
|
|
555
586
|
recoverFromSnapshots,
|
|
556
587
|
dbPath, // Deprecated: use getDbPath() for dynamic path
|