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.
@@ -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
- // Handle reorder within this epic - calculate new display_order based on pointer Y
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
- if (!onOrderChange || !containerRef.current) return;
265
-
266
- // Find all card elements in this epic container
267
- const cardElements = containerRef.current.querySelectorAll('[data-item-id]');
268
- const cardPositions: { id: number; midY: number }[] = [];
269
-
270
- cardElements.forEach((el) => {
271
- const id = parseInt(el.getAttribute('data-item-id') || '0', 10);
272
- if (id !== itemId) {
273
- const rect = el.getBoundingClientRect();
274
- cardPositions.push({
275
- id,
276
- midY: (rect.top + rect.bottom) / 2,
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
- await onOrderChange(itemId, newOrder);
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
- }, [onOrderChange]);
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: onOrderChange ? handleEpicReorder : undefined,
338
+ onReorder: handleEpicReorder,
321
339
  });
322
340
 
323
341
  return () => {
324
342
  unregisterEpicDropZone(zoneId);
325
343
  };
326
- }, [epicId, onEpicAssign, onOrderChange, handleEpicReorder, registerEpicDropZone, unregisterEpicDropZone]);
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
- if (items.length === 0 && !showHighlight && !showReorderHighlight) return null;
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
- <DraggableCard key={item.id} item={item} disabled={!isDraggable}>
384
- <KanbanCard item={item} onTitleSave={onTitleSave} onStatusChange={onStatusChange} />
385
- </DraggableCard>
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
- <DropZone
599
- targetStatus="backlog"
600
- onDrop={async (itemId, newStatus) => {
601
- if (onStatusChange) await onStatusChange(itemId, newStatus);
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 ref={backlogContainerRef}>
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
- </DropZone>
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
- } catch {
157
- // Silently fail order changes
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
 
@@ -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) => {
@@ -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: Import database snapshots
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) || !fs.existsSync(dashboardNextBuild)) {
2169
- console.log('Setting up dashboard for first use...');
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 set up dashboard:', err.message);
2194
+ console.error('❌ Failed to install dependencies:', err.message);
2191
2195
  console.error(' Try running manually:');
2192
- console.error(` cd ${dashboardPath} && npm install && npm run build`);
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 background with project path
2215
- console.log('🚀 Starting dashboard...');
2216
- const dashboardProcess = spawn('npm', ['run', 'start', '--', '-p', String(availablePort)], {
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