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.
@@ -0,0 +1,9 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { getRecentDecisions } from '@/lib/db';
3
+
4
+ export const dynamic = 'force-dynamic';
5
+
6
+ export async function GET() {
7
+ const decisions = getRecentDecisions(5);
8
+ return NextResponse.json(decisions);
9
+ }
@@ -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="min-h-screen bg-zinc-50 dark:bg-zinc-950">
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
- <h1 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
22
- JettyPod Dashboard
23
- </h1>
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-8">
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
+ }