jettypod 4.4.70 → 4.4.71
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/work/[id]/status/route.ts +21 -0
- package/apps/dashboard/app/api/work/[id]/title/route.ts +21 -0
- package/apps/dashboard/components/CardMenu.tsx +120 -0
- package/apps/dashboard/components/EditableTitle.tsx +102 -0
- package/apps/dashboard/components/KanbanBoard.tsx +97 -38
- package/apps/dashboard/components/RealTimeKanbanWrapper.tsx +51 -0
- package/apps/dashboard/lib/db.ts +30 -0
- package/claude-hooks/global-guardrails.js +29 -1
- package/docs/jetty-development-guide/shapeup.md +0 -0
- package/jettypod.js +14 -7
- package/lib/work-commands/index.js +62 -15
- package/lib/work-tracking/index.js +17 -0
- package/package.json +1 -1
- package/skills-templates/bug-mode/SKILL.md +392 -0
- package/skills-templates/bug-planning/SKILL.md +387 -0
- package/skills-templates/speed-mode/SKILL.md +29 -2
- package/skills-templates/stable-mode/SKILL.md +25 -2
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { updateWorkItemStatus } 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 { status } = body;
|
|
13
|
+
|
|
14
|
+
const updated = updateWorkItemStatus(workItemId, status);
|
|
15
|
+
|
|
16
|
+
if (updated) {
|
|
17
|
+
return NextResponse.json({ success: true, id: workItemId, status });
|
|
18
|
+
} else {
|
|
19
|
+
return NextResponse.json({ success: false, error: 'Work item not found' }, { status: 404 });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { updateWorkItemTitle } 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 { title } = body;
|
|
13
|
+
|
|
14
|
+
const updated = updateWorkItemTitle(workItemId, title);
|
|
15
|
+
|
|
16
|
+
if (updated) {
|
|
17
|
+
return NextResponse.json({ success: true, id: workItemId, title });
|
|
18
|
+
} else {
|
|
19
|
+
return NextResponse.json({ success: false, error: 'Work item not found' }, { status: 404 });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useRef, useEffect } from 'react';
|
|
4
|
+
|
|
5
|
+
interface CardMenuProps {
|
|
6
|
+
itemId: number;
|
|
7
|
+
currentStatus: string;
|
|
8
|
+
onStatusChange: (id: number, newStatus: string) => Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function CardMenu({ itemId, currentStatus, onStatusChange }: CardMenuProps) {
|
|
12
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
13
|
+
const [error, setError] = useState<string | null>(null);
|
|
14
|
+
const menuRef = useRef<HTMLDivElement>(null);
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
function handleClickOutside(event: MouseEvent) {
|
|
18
|
+
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
|
19
|
+
setIsOpen(false);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (isOpen) {
|
|
24
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
25
|
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
26
|
+
}
|
|
27
|
+
}, [isOpen]);
|
|
28
|
+
|
|
29
|
+
const handleMenuClick = (e: React.MouseEvent) => {
|
|
30
|
+
e.stopPropagation();
|
|
31
|
+
setIsOpen(!isOpen);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const handleAction = async (e: React.MouseEvent, newStatus: string) => {
|
|
35
|
+
e.stopPropagation();
|
|
36
|
+
setIsOpen(false);
|
|
37
|
+
setError(null);
|
|
38
|
+
try {
|
|
39
|
+
await onStatusChange(itemId, newStatus);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
setError(err instanceof Error ? err.message : 'Failed to update status');
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div className="relative" ref={menuRef} data-testid="card-menu">
|
|
47
|
+
<button
|
|
48
|
+
onClick={handleMenuClick}
|
|
49
|
+
className="p-1 rounded hover:bg-zinc-200 dark:hover:bg-zinc-600 transition-colors"
|
|
50
|
+
aria-label="Card menu"
|
|
51
|
+
data-testid="menu-button"
|
|
52
|
+
>
|
|
53
|
+
<svg
|
|
54
|
+
className="w-4 h-4 text-zinc-500 dark:text-zinc-400"
|
|
55
|
+
fill="currentColor"
|
|
56
|
+
viewBox="0 0 20 20"
|
|
57
|
+
>
|
|
58
|
+
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
|
|
59
|
+
</svg>
|
|
60
|
+
</button>
|
|
61
|
+
|
|
62
|
+
{error && (
|
|
63
|
+
<div
|
|
64
|
+
className="absolute right-0 top-full mt-1 z-10 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-xs px-2 py-1 rounded border border-red-200 dark:border-red-800"
|
|
65
|
+
data-testid="error-message"
|
|
66
|
+
>
|
|
67
|
+
{error}
|
|
68
|
+
</div>
|
|
69
|
+
)}
|
|
70
|
+
|
|
71
|
+
{isOpen && (
|
|
72
|
+
<div
|
|
73
|
+
className="absolute right-0 top-full mt-1 z-10 bg-white dark:bg-zinc-800 rounded-lg shadow-lg border border-zinc-200 dark:border-zinc-700 py-1 min-w-[140px]"
|
|
74
|
+
data-testid="status-dropdown"
|
|
75
|
+
>
|
|
76
|
+
{(currentStatus === 'backlog' || currentStatus === 'cancelled') && (
|
|
77
|
+
<button
|
|
78
|
+
onClick={(e) => handleAction(e, 'in_progress')}
|
|
79
|
+
className="w-full px-3 py-2 text-left text-sm text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center gap-2"
|
|
80
|
+
data-testid="start-button"
|
|
81
|
+
>
|
|
82
|
+
<span className="text-blue-500">▶</span>
|
|
83
|
+
Start
|
|
84
|
+
</button>
|
|
85
|
+
)}
|
|
86
|
+
{currentStatus !== 'done' && (
|
|
87
|
+
<button
|
|
88
|
+
onClick={(e) => handleAction(e, 'done')}
|
|
89
|
+
className="w-full px-3 py-2 text-left text-sm text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center gap-2"
|
|
90
|
+
data-testid="mark-done-button"
|
|
91
|
+
>
|
|
92
|
+
<span className="text-green-500">✓</span>
|
|
93
|
+
Mark as Done
|
|
94
|
+
</button>
|
|
95
|
+
)}
|
|
96
|
+
{(currentStatus === 'in_progress' || currentStatus === 'done') && (
|
|
97
|
+
<button
|
|
98
|
+
onClick={(e) => handleAction(e, 'backlog')}
|
|
99
|
+
className="w-full px-3 py-2 text-left text-sm text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center gap-2"
|
|
100
|
+
data-testid="unstart-button"
|
|
101
|
+
>
|
|
102
|
+
<span className="text-amber-500">↩</span>
|
|
103
|
+
Unstart
|
|
104
|
+
</button>
|
|
105
|
+
)}
|
|
106
|
+
{currentStatus !== 'cancelled' && (
|
|
107
|
+
<button
|
|
108
|
+
onClick={(e) => handleAction(e, 'cancelled')}
|
|
109
|
+
className="w-full px-3 py-2 text-left text-sm text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center gap-2"
|
|
110
|
+
data-testid="cancel-button"
|
|
111
|
+
>
|
|
112
|
+
<span className="text-red-500">✕</span>
|
|
113
|
+
Cancel
|
|
114
|
+
</button>
|
|
115
|
+
)}
|
|
116
|
+
</div>
|
|
117
|
+
)}
|
|
118
|
+
</div>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useRef, useEffect } from 'react';
|
|
4
|
+
|
|
5
|
+
interface EditableTitleProps {
|
|
6
|
+
title: string;
|
|
7
|
+
itemId: number;
|
|
8
|
+
onSave: (id: number, newTitle: string) => Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function EditableTitle({ title, itemId, onSave }: EditableTitleProps) {
|
|
12
|
+
const [isEditing, setIsEditing] = useState(false);
|
|
13
|
+
const [editValue, setEditValue] = useState(title);
|
|
14
|
+
const [error, setError] = useState<string | null>(null);
|
|
15
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (isEditing && inputRef.current) {
|
|
19
|
+
inputRef.current.focus();
|
|
20
|
+
inputRef.current.select();
|
|
21
|
+
}
|
|
22
|
+
}, [isEditing]);
|
|
23
|
+
|
|
24
|
+
const handleClick = (e: React.MouseEvent) => {
|
|
25
|
+
e.preventDefault();
|
|
26
|
+
e.stopPropagation();
|
|
27
|
+
setEditValue(title);
|
|
28
|
+
setError(null);
|
|
29
|
+
setIsEditing(true);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const handleSave = async () => {
|
|
33
|
+
if (editValue.trim() === '') {
|
|
34
|
+
setError('Title cannot be empty');
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
setError(null);
|
|
38
|
+
if (editValue !== title) {
|
|
39
|
+
try {
|
|
40
|
+
await onSave(itemId, editValue);
|
|
41
|
+
} catch (err) {
|
|
42
|
+
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
43
|
+
setError(`Failed to save: ${message}`);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
setIsEditing(false);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const handleCancel = () => {
|
|
51
|
+
setEditValue(title);
|
|
52
|
+
setError(null);
|
|
53
|
+
setIsEditing(false);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
57
|
+
if (e.key === 'Enter') {
|
|
58
|
+
e.preventDefault();
|
|
59
|
+
handleSave();
|
|
60
|
+
} else if (e.key === 'Escape') {
|
|
61
|
+
e.preventDefault();
|
|
62
|
+
handleCancel();
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const handleBlur = () => {
|
|
67
|
+
handleSave();
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
if (isEditing) {
|
|
71
|
+
return (
|
|
72
|
+
<div className="w-full">
|
|
73
|
+
<input
|
|
74
|
+
ref={inputRef}
|
|
75
|
+
type="text"
|
|
76
|
+
value={editValue}
|
|
77
|
+
onChange={(e) => setEditValue(e.target.value)}
|
|
78
|
+
onKeyDown={handleKeyDown}
|
|
79
|
+
onBlur={handleBlur}
|
|
80
|
+
onClick={(e) => e.stopPropagation()}
|
|
81
|
+
className={`text-sm text-zinc-900 dark:text-zinc-100 leading-snug w-full bg-white dark:bg-zinc-700 border rounded px-1 py-0.5 focus:outline-none focus:ring-2 ${
|
|
82
|
+
error
|
|
83
|
+
? 'border-red-500 focus:ring-red-500'
|
|
84
|
+
: 'border-zinc-300 dark:border-zinc-600 focus:ring-blue-500'
|
|
85
|
+
}`}
|
|
86
|
+
/>
|
|
87
|
+
{error && (
|
|
88
|
+
<p className="text-xs text-red-500 mt-0.5">{error}</p>
|
|
89
|
+
)}
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<p
|
|
96
|
+
onClick={handleClick}
|
|
97
|
+
className="text-sm text-zinc-900 dark:text-zinc-100 leading-snug cursor-pointer hover:bg-zinc-100 dark:hover:bg-zinc-700 rounded px-1 py-0.5 -mx-1 -my-0.5"
|
|
98
|
+
>
|
|
99
|
+
{title}
|
|
100
|
+
</p>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState } from 'react';
|
|
4
4
|
import Link from 'next/link';
|
|
5
|
+
import { useRouter } from 'next/navigation';
|
|
5
6
|
import type { WorkItem, InFlightItem, KanbanGroup } from '@/lib/db';
|
|
7
|
+
import { EditableTitle } from './EditableTitle';
|
|
8
|
+
import { CardMenu } from './CardMenu';
|
|
6
9
|
|
|
7
10
|
const typeIcons: Record<string, string> = {
|
|
8
11
|
epic: '🎯',
|
|
@@ -30,17 +33,47 @@ interface KanbanCardProps {
|
|
|
30
33
|
item: WorkItem;
|
|
31
34
|
epicTitle?: string | null;
|
|
32
35
|
showEpic?: boolean;
|
|
36
|
+
onTitleSave?: (id: number, newTitle: string) => Promise<void>;
|
|
37
|
+
onStatusChange?: (id: number, newStatus: string) => Promise<void>;
|
|
33
38
|
}
|
|
34
39
|
|
|
35
|
-
function KanbanCard({ item, epicTitle, showEpic = false }: KanbanCardProps) {
|
|
40
|
+
function KanbanCard({ item, epicTitle, showEpic = false, onTitleSave, onStatusChange }: KanbanCardProps) {
|
|
36
41
|
const [expanded, setExpanded] = useState(false);
|
|
37
|
-
const
|
|
42
|
+
const router = useRouter();
|
|
43
|
+
|
|
44
|
+
// Calculate incomplete chores - only show section if there are incomplete ones
|
|
45
|
+
const incompleteChores = item.chores?.filter(c => c.status !== 'done') || [];
|
|
46
|
+
const hasIncompleteChores = incompleteChores.length > 0;
|
|
47
|
+
|
|
48
|
+
const handleCardClick = () => {
|
|
49
|
+
router.push(`/work/${item.id}`);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const handleTitleSave = async (id: number, newTitle: string) => {
|
|
53
|
+
if (onTitleSave) {
|
|
54
|
+
await onTitleSave(id, newTitle);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const handleStatusChange = async (id: number, newStatus: string) => {
|
|
59
|
+
if (onStatusChange) {
|
|
60
|
+
await onStatusChange(id, newStatus);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const isDone = item.status === 'done';
|
|
38
65
|
|
|
39
66
|
return (
|
|
40
|
-
<div
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
67
|
+
<div
|
|
68
|
+
className={`rounded-lg border hover:shadow-sm transition-all ${
|
|
69
|
+
isDone
|
|
70
|
+
? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800 hover:border-green-300 dark:hover:border-green-700'
|
|
71
|
+
: 'bg-white dark:bg-zinc-800 border-zinc-200 dark:border-zinc-700 hover:border-zinc-300 dark:hover:border-zinc-600'
|
|
72
|
+
}`}
|
|
73
|
+
data-testid={`kanban-card-${item.id}`}>
|
|
74
|
+
<div
|
|
75
|
+
onClick={handleCardClick}
|
|
76
|
+
className="block p-3 cursor-pointer"
|
|
44
77
|
>
|
|
45
78
|
<div className="flex items-start gap-2">
|
|
46
79
|
<span className="text-sm flex-shrink-0">{typeIcons[item.type] || '📄'}</span>
|
|
@@ -53,9 +86,11 @@ function KanbanCard({ item, epicTitle, showEpic = false }: KanbanCardProps) {
|
|
|
53
86
|
</span>
|
|
54
87
|
)}
|
|
55
88
|
</div>
|
|
56
|
-
<
|
|
57
|
-
{item.title}
|
|
58
|
-
|
|
89
|
+
<EditableTitle
|
|
90
|
+
title={item.title}
|
|
91
|
+
itemId={item.id}
|
|
92
|
+
onSave={handleTitleSave}
|
|
93
|
+
/>
|
|
59
94
|
{showEpic && epicTitle && (
|
|
60
95
|
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-1.5 flex items-center gap-1">
|
|
61
96
|
<span>🎯</span>
|
|
@@ -63,9 +98,16 @@ function KanbanCard({ item, epicTitle, showEpic = false }: KanbanCardProps) {
|
|
|
63
98
|
</p>
|
|
64
99
|
)}
|
|
65
100
|
</div>
|
|
101
|
+
{onStatusChange && (
|
|
102
|
+
<CardMenu
|
|
103
|
+
itemId={item.id}
|
|
104
|
+
currentStatus={item.status}
|
|
105
|
+
onStatusChange={handleStatusChange}
|
|
106
|
+
/>
|
|
107
|
+
)}
|
|
66
108
|
</div>
|
|
67
|
-
</
|
|
68
|
-
{
|
|
109
|
+
</div>
|
|
110
|
+
{hasIncompleteChores && (
|
|
69
111
|
<div className="border-t border-zinc-200 dark:border-zinc-700">
|
|
70
112
|
<button
|
|
71
113
|
onClick={() => setExpanded(!expanded)}
|
|
@@ -73,29 +115,40 @@ function KanbanCard({ item, epicTitle, showEpic = false }: KanbanCardProps) {
|
|
|
73
115
|
>
|
|
74
116
|
<span>{expanded ? '▼' : '▶'}</span>
|
|
75
117
|
<span>🔧</span>
|
|
76
|
-
<span>{
|
|
118
|
+
<span>{incompleteChores.length} chore{incompleteChores.length !== 1 ? 's' : ''} left</span>
|
|
77
119
|
</button>
|
|
78
120
|
{expanded && (
|
|
79
121
|
<div className="px-3 pb-2 space-y-1">
|
|
80
|
-
{item.chores!.map((chore) =>
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
122
|
+
{item.chores!.map((chore) => {
|
|
123
|
+
const isComplete = chore.status === 'done';
|
|
124
|
+
return (
|
|
125
|
+
<Link
|
|
126
|
+
key={chore.id}
|
|
127
|
+
href={`/work/${chore.id}`}
|
|
128
|
+
className={`block py-1 px-2 text-xs rounded transition-colors ${
|
|
129
|
+
isComplete
|
|
130
|
+
? 'bg-green-100 dark:bg-green-900/30 border border-green-200 dark:border-green-800/50'
|
|
131
|
+
: 'hover:bg-zinc-100 dark:hover:bg-zinc-700'
|
|
132
|
+
}`}
|
|
133
|
+
>
|
|
134
|
+
<div className="flex items-center gap-2">
|
|
135
|
+
<span className={`font-mono ${isComplete ? 'text-zinc-500' : 'text-zinc-400'}`}>#{chore.id}</span>
|
|
136
|
+
{chore.mode && modeLabels[chore.mode] && (
|
|
137
|
+
<span className={`px-1 py-0.5 rounded text-[10px] ${modeLabels[chore.mode].color}`}>
|
|
138
|
+
{getModeLabel(chore)}
|
|
139
|
+
</span>
|
|
140
|
+
)}
|
|
141
|
+
<span className={`truncate ${
|
|
142
|
+
isComplete
|
|
143
|
+
? 'text-zinc-500 line-through'
|
|
144
|
+
: 'text-zinc-700 dark:text-zinc-300'
|
|
145
|
+
}`}>
|
|
146
|
+
{chore.title || <span className="text-zinc-400 italic">(Untitled)</span>}
|
|
91
147
|
</span>
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
</div>
|
|
97
|
-
</Link>
|
|
98
|
-
))}
|
|
148
|
+
</div>
|
|
149
|
+
</Link>
|
|
150
|
+
);
|
|
151
|
+
})}
|
|
99
152
|
</div>
|
|
100
153
|
)}
|
|
101
154
|
</div>
|
|
@@ -109,14 +162,16 @@ interface EpicGroupProps {
|
|
|
109
162
|
epicTitle: string | null;
|
|
110
163
|
items: WorkItem[];
|
|
111
164
|
isInFlight?: boolean;
|
|
165
|
+
onTitleSave?: (id: number, newTitle: string) => Promise<void>;
|
|
166
|
+
onStatusChange?: (id: number, newStatus: string) => Promise<void>;
|
|
112
167
|
}
|
|
113
168
|
|
|
114
|
-
function EpicGroup({ epicId, epicTitle, items, isInFlight = false }: EpicGroupProps) {
|
|
169
|
+
function EpicGroup({ epicId, epicTitle, items, isInFlight = false, onTitleSave, onStatusChange }: EpicGroupProps) {
|
|
115
170
|
if (items.length === 0) return null;
|
|
116
171
|
|
|
117
172
|
return (
|
|
118
173
|
<div className="mb-4">
|
|
119
|
-
{epicTitle
|
|
174
|
+
{epicTitle && (
|
|
120
175
|
<div className="flex items-center gap-2 mb-2">
|
|
121
176
|
<Link
|
|
122
177
|
href={`/work/${epicId}`}
|
|
@@ -131,14 +186,10 @@ function EpicGroup({ epicId, epicTitle, items, isInFlight = false }: EpicGroupPr
|
|
|
131
186
|
</span>
|
|
132
187
|
)}
|
|
133
188
|
</div>
|
|
134
|
-
) : (
|
|
135
|
-
<div className="text-xs font-medium text-zinc-400 dark:text-zinc-500 mb-2">
|
|
136
|
-
Ungrouped
|
|
137
|
-
</div>
|
|
138
189
|
)}
|
|
139
190
|
<div className="space-y-2">
|
|
140
191
|
{items.map((item) => (
|
|
141
|
-
<KanbanCard key={item.id} item={item} />
|
|
192
|
+
<KanbanCard key={item.id} item={item} onTitleSave={onTitleSave} onStatusChange={onStatusChange} />
|
|
142
193
|
))}
|
|
143
194
|
</div>
|
|
144
195
|
</div>
|
|
@@ -174,9 +225,11 @@ interface KanbanBoardProps {
|
|
|
174
225
|
inFlight: InFlightItem[];
|
|
175
226
|
backlog: Map<string, KanbanGroup>;
|
|
176
227
|
done: Map<string, KanbanGroup>;
|
|
228
|
+
onTitleSave?: (id: number, newTitle: string) => Promise<void>;
|
|
229
|
+
onStatusChange?: (id: number, newStatus: string) => Promise<void>;
|
|
177
230
|
}
|
|
178
231
|
|
|
179
|
-
export function KanbanBoard({ inFlight, backlog, done }: KanbanBoardProps) {
|
|
232
|
+
export function KanbanBoard({ inFlight, backlog, done, onTitleSave, onStatusChange }: KanbanBoardProps) {
|
|
180
233
|
const backlogCount = inFlight.length + Array.from(backlog.values()).reduce((sum, g) => sum + g.items.length, 0);
|
|
181
234
|
const doneCount = Array.from(done.values()).reduce((sum, g) => sum + g.items.length, 0);
|
|
182
235
|
|
|
@@ -207,6 +260,8 @@ export function KanbanBoard({ inFlight, backlog, done }: KanbanBoardProps) {
|
|
|
207
260
|
item={item}
|
|
208
261
|
epicTitle={item.epicTitle}
|
|
209
262
|
showEpic={true}
|
|
263
|
+
onTitleSave={onTitleSave}
|
|
264
|
+
onStatusChange={onStatusChange}
|
|
210
265
|
/>
|
|
211
266
|
))}
|
|
212
267
|
</div>
|
|
@@ -226,6 +281,8 @@ export function KanbanBoard({ inFlight, backlog, done }: KanbanBoardProps) {
|
|
|
226
281
|
epicTitle={group.epicTitle}
|
|
227
282
|
items={group.items}
|
|
228
283
|
isInFlight={group.epicId ? inFlightEpicIds.has(group.epicId) : false}
|
|
284
|
+
onTitleSave={onTitleSave}
|
|
285
|
+
onStatusChange={onStatusChange}
|
|
229
286
|
/>
|
|
230
287
|
))}
|
|
231
288
|
|
|
@@ -242,6 +299,8 @@ export function KanbanBoard({ inFlight, backlog, done }: KanbanBoardProps) {
|
|
|
242
299
|
epicId={group.epicId}
|
|
243
300
|
epicTitle={group.epicTitle}
|
|
244
301
|
items={group.items}
|
|
302
|
+
onTitleSave={onTitleSave}
|
|
303
|
+
onStatusChange={onStatusChange}
|
|
245
304
|
/>
|
|
246
305
|
))}
|
|
247
306
|
|
|
@@ -25,6 +25,7 @@ export function RealTimeKanbanWrapper({ initialData }: RealTimeKanbanWrapperProp
|
|
|
25
25
|
backlog: new Map(initialData.backlog),
|
|
26
26
|
done: new Map(initialData.done),
|
|
27
27
|
}));
|
|
28
|
+
const [statusError, setStatusError] = useState<string | null>(null);
|
|
28
29
|
|
|
29
30
|
const refreshData = useCallback(async () => {
|
|
30
31
|
const response = await fetch('/api/kanban');
|
|
@@ -42,6 +43,38 @@ export function RealTimeKanbanWrapper({ initialData }: RealTimeKanbanWrapperProp
|
|
|
42
43
|
}
|
|
43
44
|
}, [refreshData]);
|
|
44
45
|
|
|
46
|
+
const handleTitleSave = useCallback(async (id: number, newTitle: string) => {
|
|
47
|
+
await fetch(`/api/work/${id}/title`, {
|
|
48
|
+
method: 'PATCH',
|
|
49
|
+
headers: { 'Content-Type': 'application/json' },
|
|
50
|
+
body: JSON.stringify({ title: newTitle }),
|
|
51
|
+
});
|
|
52
|
+
await refreshData();
|
|
53
|
+
}, [refreshData]);
|
|
54
|
+
|
|
55
|
+
const handleStatusChange = useCallback(async (id: number, newStatus: string) => {
|
|
56
|
+
setStatusError(null);
|
|
57
|
+
try {
|
|
58
|
+
const response = await fetch(`/api/work/${id}/status`, {
|
|
59
|
+
method: 'PATCH',
|
|
60
|
+
headers: { 'Content-Type': 'application/json' },
|
|
61
|
+
body: JSON.stringify({ status: newStatus }),
|
|
62
|
+
});
|
|
63
|
+
if (!response.ok) {
|
|
64
|
+
if (response.status === 404) {
|
|
65
|
+
setStatusError('Item no longer exists');
|
|
66
|
+
} else {
|
|
67
|
+
setStatusError('Failed to update status');
|
|
68
|
+
}
|
|
69
|
+
await refreshData();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
await refreshData();
|
|
73
|
+
} catch {
|
|
74
|
+
setStatusError('Failed to update status');
|
|
75
|
+
}
|
|
76
|
+
}, [refreshData]);
|
|
77
|
+
|
|
45
78
|
const wsUrl = typeof window !== 'undefined'
|
|
46
79
|
? `ws://${window.location.hostname}:8080`
|
|
47
80
|
: 'ws://localhost:8080';
|
|
@@ -56,6 +89,22 @@ export function RealTimeKanbanWrapper({ initialData }: RealTimeKanbanWrapperProp
|
|
|
56
89
|
|
|
57
90
|
return (
|
|
58
91
|
<div>
|
|
92
|
+
{/* Status Error Display */}
|
|
93
|
+
{statusError && (
|
|
94
|
+
<div
|
|
95
|
+
className="mb-4 p-3 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 rounded-lg flex items-center justify-between"
|
|
96
|
+
data-testid="status-error"
|
|
97
|
+
>
|
|
98
|
+
<span>{statusError}</span>
|
|
99
|
+
<button
|
|
100
|
+
onClick={() => setStatusError(null)}
|
|
101
|
+
className="text-red-500 hover:text-red-700 dark:hover:text-red-200"
|
|
102
|
+
aria-label="Dismiss error"
|
|
103
|
+
>
|
|
104
|
+
×
|
|
105
|
+
</button>
|
|
106
|
+
</div>
|
|
107
|
+
)}
|
|
59
108
|
{/* Connection Status Indicator */}
|
|
60
109
|
<div className="mb-4 flex items-center gap-2" data-testid="connection-status">
|
|
61
110
|
<span
|
|
@@ -79,6 +128,8 @@ export function RealTimeKanbanWrapper({ initialData }: RealTimeKanbanWrapperProp
|
|
|
79
128
|
inFlight={data.inFlight}
|
|
80
129
|
backlog={data.backlog}
|
|
81
130
|
done={data.done}
|
|
131
|
+
onTitleSave={handleTitleSave}
|
|
132
|
+
onStatusChange={handleStatusChange}
|
|
82
133
|
/>
|
|
83
134
|
</div>
|
|
84
135
|
);
|
package/apps/dashboard/lib/db.ts
CHANGED
|
@@ -57,6 +57,11 @@ function getDb(): Database.Database {
|
|
|
57
57
|
return new Database(dbPath, { readonly: true });
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
function getWriteDb(): Database.Database {
|
|
61
|
+
const dbPath = getDbPath();
|
|
62
|
+
return new Database(dbPath);
|
|
63
|
+
}
|
|
64
|
+
|
|
60
65
|
export function getAllWorkItems(): WorkItem[] {
|
|
61
66
|
const db = getDb();
|
|
62
67
|
try {
|
|
@@ -305,3 +310,28 @@ export function getKanbanData(doneLimit: number = 50): KanbanData {
|
|
|
305
310
|
db.close();
|
|
306
311
|
}
|
|
307
312
|
}
|
|
313
|
+
|
|
314
|
+
export function updateWorkItemTitle(id: number, title: string): boolean {
|
|
315
|
+
const db = getWriteDb();
|
|
316
|
+
try {
|
|
317
|
+
const result = db.prepare(`
|
|
318
|
+
UPDATE work_items SET title = ? WHERE id = ?
|
|
319
|
+
`).run(title, id);
|
|
320
|
+
return result.changes > 0;
|
|
321
|
+
} finally {
|
|
322
|
+
db.close();
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export function updateWorkItemStatus(id: number, status: string): boolean {
|
|
327
|
+
const db = getWriteDb();
|
|
328
|
+
try {
|
|
329
|
+
const completedAt = status === 'done' ? new Date().toISOString() : null;
|
|
330
|
+
const result = db.prepare(`
|
|
331
|
+
UPDATE work_items SET status = ?, completed_at = ? WHERE id = ?
|
|
332
|
+
`).run(status, completedAt, id);
|
|
333
|
+
return result.changes > 0;
|
|
334
|
+
} finally {
|
|
335
|
+
db.close();
|
|
336
|
+
}
|
|
337
|
+
}
|
|
@@ -114,11 +114,39 @@ function stripHeredocContent(command) {
|
|
|
114
114
|
return command.replace(/<<-?['"]?(\w+)['"]?[\s\S]*?\n\s*\1\b/g, '<<HEREDOC_STRIPPED');
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Extract target directory from command that changes directory context.
|
|
119
|
+
* Parses 'cd <path> &&' and 'git -C <path>' patterns.
|
|
120
|
+
* @param {string} command - The bash command to parse
|
|
121
|
+
* @returns {string|null} - The target directory path or null if not found
|
|
122
|
+
*/
|
|
123
|
+
function extractTargetDirectory(command) {
|
|
124
|
+
// Match 'cd <path> &&' pattern (handles quoted and unquoted paths)
|
|
125
|
+
const cdMatch = command.match(/^cd\s+["']?([^"'&\s]+)["']?\s*&&/);
|
|
126
|
+
if (cdMatch) {
|
|
127
|
+
return cdMatch[1];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Match 'git -C <path>' pattern
|
|
131
|
+
const gitCMatch = command.match(/git\s+-C\s+["']?([^"'\s]+)["']?/);
|
|
132
|
+
if (gitCMatch) {
|
|
133
|
+
return gitCMatch[1];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
117
139
|
/**
|
|
118
140
|
* Evaluate Bash command against global rules
|
|
119
141
|
*/
|
|
120
142
|
function evaluateBashCommand(command, inputRef, cwd) {
|
|
121
|
-
|
|
143
|
+
// Check if command targets a different directory (cd path && ... or git -C path)
|
|
144
|
+
const targetDir = extractTargetDirectory(command);
|
|
145
|
+
const effectiveCwd = targetDir ? path.resolve(cwd || process.cwd(), targetDir) : cwd;
|
|
146
|
+
// If command explicitly targets a different directory, get branch from THERE, not shell's cwd
|
|
147
|
+
// Fall back to inputRef/cwd if target doesn't exist or isn't a git repo
|
|
148
|
+
const targetBranch = targetDir ? getCurrentRef(effectiveCwd) : null;
|
|
149
|
+
const currentBranch = targetBranch || inputRef || getCurrentRef(cwd);
|
|
122
150
|
// Strip heredoc content to avoid false positives on user-provided text
|
|
123
151
|
const strippedCommand = stripHeredocContent(command);
|
|
124
152
|
// BLOCKED: Force push
|
|
File without changes
|