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.
@@ -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 hasChores = item.chores && item.chores.length > 0;
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 className="bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700 hover:border-zinc-300 dark:hover:border-zinc-600 hover:shadow-sm transition-all">
41
- <Link
42
- href={`/work/${item.id}`}
43
- className="block p-3"
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
- <p className="text-sm text-zinc-900 dark:text-zinc-100 leading-snug">
57
- {item.title}
58
- </p>
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
- </Link>
68
- {hasChores && (
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>{item.chores!.length} chore{item.chores!.length !== 1 ? 's' : ''}</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
- <Link
82
- key={chore.id}
83
- href={`/work/${chore.id}`}
84
- className="block py-1 px-2 text-xs rounded hover:bg-zinc-100 dark:hover:bg-zinc-700 transition-colors"
85
- >
86
- <div className="flex items-center gap-2">
87
- <span className="text-zinc-400 font-mono">#{chore.id}</span>
88
- {chore.mode && modeLabels[chore.mode] && (
89
- <span className={`px-1 py-0.5 rounded text-[10px] ${modeLabels[chore.mode].color}`}>
90
- {getModeLabel(chore)}
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
- <span className="text-zinc-700 dark:text-zinc-300 truncate">
94
- {chore.title || <span className="text-zinc-400 italic">(Untitled)</span>}
95
- </span>
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
+ &times;
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
  );
@@ -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
- const currentBranch = inputRef || getCurrentRef(cwd);
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