jettypod 4.4.74 → 4.4.75
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/app/api/decisions/route.ts +9 -0
- package/apps/dashboard/app/api/work/[id]/epic/route.ts +21 -0
- package/apps/dashboard/app/api/work/[id]/order/route.ts +21 -0
- package/apps/dashboard/app/page.tsx +15 -8
- package/apps/dashboard/components/DragContext.tsx +163 -0
- package/apps/dashboard/components/DraggableCard.tsx +68 -0
- package/apps/dashboard/components/DropZone.tsx +71 -0
- package/apps/dashboard/components/KanbanBoard.tsx +385 -84
- package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +60 -14
- package/apps/dashboard/components/RecentDecisionsWidget.tsx +58 -0
- package/apps/dashboard/lib/db.ts +105 -9
- package/apps/dashboard/package.json +2 -1
- package/lib/database.js +3 -1
- package/lib/migrations/017-display-order-column.js +48 -0
- package/lib/migrations/019-discovery-completed-at.js +36 -0
- package/lib/migrations/020-normalize-timestamps.js +59 -0
- package/lib/work-commands/index.js +3 -2
- package/lib/work-tracking/index.js +2 -2
- package/package.json +1 -1
- package/skills-templates/feature-planning/SKILL.md +41 -0
- package/skills-templates/simple-improvement/SKILL.md +327 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { updateWorkItemEpic } from '@/lib/db';
|
|
3
|
+
|
|
4
|
+
export async function PATCH(
|
|
5
|
+
request: NextRequest,
|
|
6
|
+
{ params }: { params: Promise<{ id: string }> }
|
|
7
|
+
) {
|
|
8
|
+
const { id } = await params;
|
|
9
|
+
const workItemId = parseInt(id, 10);
|
|
10
|
+
|
|
11
|
+
const body = await request.json();
|
|
12
|
+
const { epic_id } = body;
|
|
13
|
+
|
|
14
|
+
const updated = updateWorkItemEpic(workItemId, epic_id);
|
|
15
|
+
|
|
16
|
+
if (updated) {
|
|
17
|
+
return NextResponse.json({ success: true, id: workItemId, epic_id });
|
|
18
|
+
} else {
|
|
19
|
+
return NextResponse.json({ success: false, error: 'Work item not found or cannot be assigned to epic' }, { status: 404 });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { updateWorkItemOrder } from '@/lib/db';
|
|
3
|
+
|
|
4
|
+
export async function PATCH(
|
|
5
|
+
request: NextRequest,
|
|
6
|
+
{ params }: { params: Promise<{ id: string }> }
|
|
7
|
+
) {
|
|
8
|
+
const { id } = await params;
|
|
9
|
+
const workItemId = parseInt(id, 10);
|
|
10
|
+
|
|
11
|
+
const body = await request.json();
|
|
12
|
+
const { display_order } = body;
|
|
13
|
+
|
|
14
|
+
const updated = updateWorkItemOrder(workItemId, display_order);
|
|
15
|
+
|
|
16
|
+
if (updated) {
|
|
17
|
+
return NextResponse.json({ success: true, id: workItemId, display_order });
|
|
18
|
+
} else {
|
|
19
|
+
return NextResponse.json({ success: false, error: 'Work item not found' }, { status: 404 });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getKanbanData } from '@/lib/db';
|
|
1
|
+
import { getKanbanData, getProjectName, getRecentDecisions } from '@/lib/db';
|
|
2
2
|
import { RealTimeKanbanWrapper } from '@/components/RealTimeKanbanWrapper';
|
|
3
3
|
|
|
4
4
|
// Force dynamic rendering - database is only available at runtime
|
|
@@ -6,6 +6,8 @@ export const dynamic = 'force-dynamic';
|
|
|
6
6
|
|
|
7
7
|
export default function Home() {
|
|
8
8
|
const data = getKanbanData();
|
|
9
|
+
const projectName = getProjectName();
|
|
10
|
+
const decisions = getRecentDecisions(5);
|
|
9
11
|
|
|
10
12
|
// Serialize Map data for client component
|
|
11
13
|
const serializedData = {
|
|
@@ -15,16 +17,21 @@ export default function Home() {
|
|
|
15
17
|
};
|
|
16
18
|
|
|
17
19
|
return (
|
|
18
|
-
<div className="
|
|
19
|
-
<header className="border-b border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900">
|
|
20
|
+
<div className="h-screen flex flex-col bg-zinc-50 dark:bg-zinc-950">
|
|
21
|
+
<header className="sticky top-0 z-10 border-b border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 flex-shrink-0">
|
|
20
22
|
<div className="max-w-6xl mx-auto px-4 py-4">
|
|
21
|
-
<
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
<div className="flex items-center gap-3">
|
|
24
|
+
<h1 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
|
|
25
|
+
JettyPod Dashboard
|
|
26
|
+
</h1>
|
|
27
|
+
<span className="px-2.5 py-1 text-sm bg-zinc-100 text-zinc-600 rounded-full border border-zinc-200 dark:bg-zinc-800 dark:text-zinc-400 dark:border-zinc-700">
|
|
28
|
+
{projectName}
|
|
29
|
+
</span>
|
|
30
|
+
</div>
|
|
24
31
|
</div>
|
|
25
32
|
</header>
|
|
26
|
-
<main className="max-w-6xl mx-auto px-4 py-
|
|
27
|
-
<RealTimeKanbanWrapper initialData={serializedData} />
|
|
33
|
+
<main className="flex-1 overflow-hidden max-w-6xl w-full mx-auto px-4 py-4">
|
|
34
|
+
<RealTimeKanbanWrapper initialData={serializedData} initialDecisions={decisions} />
|
|
28
35
|
</main>
|
|
29
36
|
</div>
|
|
30
37
|
);
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, useState, useCallback, useRef, ReactNode } from 'react';
|
|
4
|
+
import type { WorkItem } from '@/lib/db';
|
|
5
|
+
|
|
6
|
+
type DropHandler = (itemId: number, newStatus: string) => Promise<void>;
|
|
7
|
+
type ReorderHandler = (itemId: number, pointerY: number) => Promise<void>;
|
|
8
|
+
type EpicAssignHandler = (itemId: number, epicId: number | null) => Promise<void>;
|
|
9
|
+
|
|
10
|
+
interface DropZoneInfo {
|
|
11
|
+
targetStatus: string;
|
|
12
|
+
element: HTMLElement;
|
|
13
|
+
onDrop: DropHandler;
|
|
14
|
+
onReorder?: ReorderHandler;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface EpicDropZoneInfo {
|
|
18
|
+
epicId: number | null;
|
|
19
|
+
element: HTMLElement;
|
|
20
|
+
onEpicAssign: EpicAssignHandler;
|
|
21
|
+
onReorder?: ReorderHandler;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface DragContextType {
|
|
25
|
+
isDragging: boolean;
|
|
26
|
+
draggedItem: WorkItem | null;
|
|
27
|
+
activeDropZone: string | null;
|
|
28
|
+
activeEpicZone: string | null;
|
|
29
|
+
setDraggedItem: (item: WorkItem | null) => void;
|
|
30
|
+
registerDropZone: (id: string, info: DropZoneInfo) => void;
|
|
31
|
+
unregisterDropZone: (id: string) => void;
|
|
32
|
+
registerEpicDropZone: (id: string, info: EpicDropZoneInfo) => void;
|
|
33
|
+
unregisterEpicDropZone: (id: string) => void;
|
|
34
|
+
updatePointerPosition: (x: number, y: number) => void;
|
|
35
|
+
handleDrop: () => Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const DragContext = createContext<DragContextType>({
|
|
39
|
+
isDragging: false,
|
|
40
|
+
draggedItem: null,
|
|
41
|
+
activeDropZone: null,
|
|
42
|
+
activeEpicZone: null,
|
|
43
|
+
setDraggedItem: () => {},
|
|
44
|
+
registerDropZone: () => {},
|
|
45
|
+
unregisterDropZone: () => {},
|
|
46
|
+
registerEpicDropZone: () => {},
|
|
47
|
+
unregisterEpicDropZone: () => {},
|
|
48
|
+
updatePointerPosition: () => {},
|
|
49
|
+
handleDrop: async () => {},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
export function DragProvider({ children }: { children: ReactNode }) {
|
|
53
|
+
const [draggedItem, setDraggedItem] = useState<WorkItem | null>(null);
|
|
54
|
+
const [activeDropZone, setActiveDropZone] = useState<string | null>(null);
|
|
55
|
+
const [activeEpicZone, setActiveEpicZone] = useState<string | null>(null);
|
|
56
|
+
const dropZonesRef = useRef<Map<string, DropZoneInfo>>(new Map());
|
|
57
|
+
const epicDropZonesRef = useRef<Map<string, EpicDropZoneInfo>>(new Map());
|
|
58
|
+
const pointerPositionRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
|
|
59
|
+
|
|
60
|
+
const registerDropZone = useCallback((id: string, info: DropZoneInfo) => {
|
|
61
|
+
dropZonesRef.current.set(id, info);
|
|
62
|
+
}, []);
|
|
63
|
+
|
|
64
|
+
const unregisterDropZone = useCallback((id: string) => {
|
|
65
|
+
dropZonesRef.current.delete(id);
|
|
66
|
+
}, []);
|
|
67
|
+
|
|
68
|
+
const registerEpicDropZone = useCallback((id: string, info: EpicDropZoneInfo) => {
|
|
69
|
+
epicDropZonesRef.current.set(id, info);
|
|
70
|
+
}, []);
|
|
71
|
+
|
|
72
|
+
const unregisterEpicDropZone = useCallback((id: string) => {
|
|
73
|
+
epicDropZonesRef.current.delete(id);
|
|
74
|
+
}, []);
|
|
75
|
+
|
|
76
|
+
const updatePointerPosition = useCallback((x: number, y: number) => {
|
|
77
|
+
pointerPositionRef.current = { x, y };
|
|
78
|
+
let foundZone: string | null = null;
|
|
79
|
+
let foundEpicZone: string | null = null;
|
|
80
|
+
|
|
81
|
+
// Check status drop zones
|
|
82
|
+
dropZonesRef.current.forEach((info, id) => {
|
|
83
|
+
const rect = info.element.getBoundingClientRect();
|
|
84
|
+
if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {
|
|
85
|
+
foundZone = id;
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Check epic drop zones (more specific, so check these to potentially override)
|
|
90
|
+
epicDropZonesRef.current.forEach((info, id) => {
|
|
91
|
+
const rect = info.element.getBoundingClientRect();
|
|
92
|
+
if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {
|
|
93
|
+
foundEpicZone = id;
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
setActiveDropZone(foundZone);
|
|
98
|
+
setActiveEpicZone(foundEpicZone);
|
|
99
|
+
}, []);
|
|
100
|
+
|
|
101
|
+
const handleDrop = useCallback(async () => {
|
|
102
|
+
if (!draggedItem) return;
|
|
103
|
+
|
|
104
|
+
// Check for epic drop zone first (more specific drop target)
|
|
105
|
+
if (activeEpicZone) {
|
|
106
|
+
const epicZoneInfo = epicDropZonesRef.current.get(activeEpicZone);
|
|
107
|
+
if (epicZoneInfo) {
|
|
108
|
+
const currentEpicId = draggedItem.parent_id || draggedItem.epic_id;
|
|
109
|
+
|
|
110
|
+
// Same epic - reorder within epic
|
|
111
|
+
if (currentEpicId === epicZoneInfo.epicId && epicZoneInfo.onReorder) {
|
|
112
|
+
await epicZoneInfo.onReorder(draggedItem.id, pointerPositionRef.current.y);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Different epic - assign to new epic
|
|
117
|
+
if (currentEpicId !== epicZoneInfo.epicId) {
|
|
118
|
+
await epicZoneInfo.onEpicAssign(draggedItem.id, epicZoneInfo.epicId);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Otherwise handle status drop zone
|
|
125
|
+
if (!activeDropZone) return;
|
|
126
|
+
|
|
127
|
+
const zoneInfo = dropZonesRef.current.get(activeDropZone);
|
|
128
|
+
if (!zoneInfo) return;
|
|
129
|
+
|
|
130
|
+
// Check if this is a status change or a reorder
|
|
131
|
+
if (draggedItem.status !== zoneInfo.targetStatus) {
|
|
132
|
+
// Status change
|
|
133
|
+
await zoneInfo.onDrop(draggedItem.id, zoneInfo.targetStatus);
|
|
134
|
+
} else if (zoneInfo.onReorder) {
|
|
135
|
+
// Reorder within same status
|
|
136
|
+
await zoneInfo.onReorder(draggedItem.id, pointerPositionRef.current.y);
|
|
137
|
+
}
|
|
138
|
+
}, [activeDropZone, activeEpicZone, draggedItem]);
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<DragContext.Provider
|
|
142
|
+
value={{
|
|
143
|
+
isDragging: draggedItem !== null,
|
|
144
|
+
draggedItem,
|
|
145
|
+
activeDropZone,
|
|
146
|
+
activeEpicZone,
|
|
147
|
+
setDraggedItem,
|
|
148
|
+
registerDropZone,
|
|
149
|
+
unregisterDropZone,
|
|
150
|
+
registerEpicDropZone,
|
|
151
|
+
unregisterEpicDropZone,
|
|
152
|
+
updatePointerPosition,
|
|
153
|
+
handleDrop,
|
|
154
|
+
}}
|
|
155
|
+
>
|
|
156
|
+
{children}
|
|
157
|
+
</DragContext.Provider>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function useDragContext() {
|
|
162
|
+
return useContext(DragContext);
|
|
163
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useRef } from 'react';
|
|
4
|
+
import { motion, PanInfo } from 'framer-motion';
|
|
5
|
+
import type { WorkItem } from '@/lib/db';
|
|
6
|
+
import { useDragContext } from './DragContext';
|
|
7
|
+
|
|
8
|
+
interface DraggableCardProps {
|
|
9
|
+
item: WorkItem;
|
|
10
|
+
children: React.ReactNode;
|
|
11
|
+
disabled?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function DraggableCard({ item, children, disabled = false }: DraggableCardProps) {
|
|
15
|
+
const { setDraggedItem, updatePointerPosition, handleDrop } = useDragContext();
|
|
16
|
+
const wasDraggingRef = useRef(false);
|
|
17
|
+
|
|
18
|
+
if (disabled) {
|
|
19
|
+
return <>{children}</>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const handleDragStart = () => {
|
|
23
|
+
wasDraggingRef.current = true;
|
|
24
|
+
setDraggedItem(item);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const handleDrag = (_event: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
|
|
28
|
+
updatePointerPosition(info.point.x, info.point.y);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const handleDragEnd = async () => {
|
|
32
|
+
await handleDrop();
|
|
33
|
+
setDraggedItem(null);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// Prevent click from firing after drag release
|
|
37
|
+
const handleClickCapture = (e: React.MouseEvent) => {
|
|
38
|
+
if (wasDraggingRef.current) {
|
|
39
|
+
e.stopPropagation();
|
|
40
|
+
e.preventDefault();
|
|
41
|
+
wasDraggingRef.current = false;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<motion.div
|
|
47
|
+
drag
|
|
48
|
+
dragSnapToOrigin
|
|
49
|
+
dragElastic={0.1}
|
|
50
|
+
whileDrag={{
|
|
51
|
+
scale: 1.02,
|
|
52
|
+
boxShadow: '0 10px 30px rgba(0, 0, 0, 0.2)',
|
|
53
|
+
zIndex: 50,
|
|
54
|
+
cursor: 'grabbing',
|
|
55
|
+
}}
|
|
56
|
+
onDragStart={handleDragStart}
|
|
57
|
+
onDrag={handleDrag}
|
|
58
|
+
onDragEnd={handleDragEnd}
|
|
59
|
+
onClickCapture={handleClickCapture}
|
|
60
|
+
className="cursor-grab active:cursor-grabbing"
|
|
61
|
+
style={{ touchAction: 'none' }}
|
|
62
|
+
data-draggable="true"
|
|
63
|
+
data-item-id={item.id}
|
|
64
|
+
>
|
|
65
|
+
{children}
|
|
66
|
+
</motion.div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useRef, useEffect, useId } from 'react';
|
|
4
|
+
import { useDragContext } from './DragContext';
|
|
5
|
+
|
|
6
|
+
interface DropZoneProps {
|
|
7
|
+
targetStatus: 'in_progress' | 'backlog' | 'done';
|
|
8
|
+
onDrop: (itemId: number, newStatus: string) => Promise<void>;
|
|
9
|
+
onReorder?: (itemId: number, pointerY: number) => Promise<void>;
|
|
10
|
+
allowReorder?: boolean;
|
|
11
|
+
children: React.ReactNode;
|
|
12
|
+
className?: string;
|
|
13
|
+
highlightClassName?: string;
|
|
14
|
+
reorderHighlightClassName?: string;
|
|
15
|
+
'data-testid'?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function DropZone({
|
|
19
|
+
targetStatus,
|
|
20
|
+
onDrop,
|
|
21
|
+
onReorder,
|
|
22
|
+
allowReorder = false,
|
|
23
|
+
children,
|
|
24
|
+
className = '',
|
|
25
|
+
highlightClassName = 'ring-2 ring-blue-400 bg-blue-50/50 dark:bg-blue-900/20',
|
|
26
|
+
reorderHighlightClassName = 'ring-2 ring-amber-400 bg-amber-50/50 dark:bg-amber-900/20',
|
|
27
|
+
'data-testid': testId,
|
|
28
|
+
}: DropZoneProps) {
|
|
29
|
+
const { isDragging, draggedItem, activeDropZone, activeEpicZone, registerDropZone, unregisterDropZone } = useDragContext();
|
|
30
|
+
const dropZoneRef = useRef<HTMLDivElement>(null);
|
|
31
|
+
const zoneId = useId();
|
|
32
|
+
|
|
33
|
+
// Register this drop zone on mount
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (dropZoneRef.current) {
|
|
36
|
+
registerDropZone(zoneId, {
|
|
37
|
+
targetStatus,
|
|
38
|
+
element: dropZoneRef.current,
|
|
39
|
+
onDrop,
|
|
40
|
+
onReorder: allowReorder ? onReorder : undefined,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return () => {
|
|
45
|
+
unregisterDropZone(zoneId);
|
|
46
|
+
};
|
|
47
|
+
}, [zoneId, targetStatus, onDrop, onReorder, allowReorder, registerDropZone, unregisterDropZone]);
|
|
48
|
+
|
|
49
|
+
// Check if this is a valid drop target
|
|
50
|
+
const isStatusChange = isDragging && draggedItem && draggedItem.status !== targetStatus;
|
|
51
|
+
const isReorder = isDragging && draggedItem && draggedItem.status === targetStatus && allowReorder;
|
|
52
|
+
const isValidTarget = isStatusChange || isReorder;
|
|
53
|
+
// Suppress highlight when a more specific target (epic zone) is active
|
|
54
|
+
const isActive = activeDropZone === zoneId && !activeEpicZone;
|
|
55
|
+
|
|
56
|
+
// Use different highlight for reorder vs status change
|
|
57
|
+
const activeHighlight = isReorder ? reorderHighlightClassName : highlightClassName;
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<div
|
|
61
|
+
ref={dropZoneRef}
|
|
62
|
+
className={`${className} ${isValidTarget && isActive ? activeHighlight : ''} transition-all duration-200`}
|
|
63
|
+
data-testid={testId}
|
|
64
|
+
data-drop-zone={targetStatus}
|
|
65
|
+
data-is-active={isActive}
|
|
66
|
+
data-is-reorder={isReorder}
|
|
67
|
+
>
|
|
68
|
+
{children}
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|