jettypod 4.4.74 → 4.4.76
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/update-command/index.js +14 -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
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState, useCallback } from 'react';
|
|
4
4
|
import { KanbanBoard } from './KanbanBoard';
|
|
5
|
+
import { RecentDecisionsWidget } from './RecentDecisionsWidget';
|
|
5
6
|
import { useWebSocket, type WebSocketMessage } from '../hooks/useWebSocket';
|
|
6
|
-
import type { InFlightItem, KanbanGroup } from '@/lib/db';
|
|
7
|
+
import type { InFlightItem, KanbanGroup, Decision } from '@/lib/db';
|
|
7
8
|
|
|
8
9
|
interface KanbanData {
|
|
9
10
|
inFlight: InFlightItem[];
|
|
@@ -17,24 +18,34 @@ interface RealTimeKanbanWrapperProps {
|
|
|
17
18
|
backlog: [string, KanbanGroup][];
|
|
18
19
|
done: [string, KanbanGroup][];
|
|
19
20
|
};
|
|
21
|
+
initialDecisions: Decision[];
|
|
20
22
|
}
|
|
21
23
|
|
|
22
|
-
export function RealTimeKanbanWrapper({ initialData }: RealTimeKanbanWrapperProps) {
|
|
24
|
+
export function RealTimeKanbanWrapper({ initialData, initialDecisions }: RealTimeKanbanWrapperProps) {
|
|
23
25
|
const [data, setData] = useState<KanbanData>(() => ({
|
|
24
26
|
inFlight: initialData.inFlight,
|
|
25
27
|
backlog: new Map(initialData.backlog),
|
|
26
28
|
done: new Map(initialData.done),
|
|
27
29
|
}));
|
|
30
|
+
const [decisions, setDecisions] = useState<Decision[]>(initialDecisions);
|
|
28
31
|
const [statusError, setStatusError] = useState<string | null>(null);
|
|
29
32
|
|
|
30
33
|
const refreshData = useCallback(async () => {
|
|
31
|
-
const
|
|
32
|
-
|
|
34
|
+
const [kanbanResponse, decisionsResponse] = await Promise.all([
|
|
35
|
+
fetch('/api/kanban'),
|
|
36
|
+
fetch('/api/decisions').catch(() => null),
|
|
37
|
+
]);
|
|
38
|
+
const newData = await kanbanResponse.json();
|
|
33
39
|
setData({
|
|
34
40
|
inFlight: newData.inFlight,
|
|
35
41
|
backlog: new Map(newData.backlog),
|
|
36
42
|
done: new Map(newData.done),
|
|
37
43
|
});
|
|
44
|
+
// Silently retain previous decisions if API fails
|
|
45
|
+
if (decisionsResponse?.ok) {
|
|
46
|
+
const newDecisions = await decisionsResponse.json();
|
|
47
|
+
setDecisions(newDecisions);
|
|
48
|
+
}
|
|
38
49
|
}, []);
|
|
39
50
|
|
|
40
51
|
const handleMessage = useCallback((message: WebSocketMessage) => {
|
|
@@ -75,6 +86,32 @@ export function RealTimeKanbanWrapper({ initialData }: RealTimeKanbanWrapperProp
|
|
|
75
86
|
}
|
|
76
87
|
}, [refreshData]);
|
|
77
88
|
|
|
89
|
+
const handleOrderChange = useCallback(async (id: number, newOrder: number) => {
|
|
90
|
+
try {
|
|
91
|
+
await fetch(`/api/work/${id}/order`, {
|
|
92
|
+
method: 'PATCH',
|
|
93
|
+
headers: { 'Content-Type': 'application/json' },
|
|
94
|
+
body: JSON.stringify({ display_order: newOrder }),
|
|
95
|
+
});
|
|
96
|
+
await refreshData();
|
|
97
|
+
} catch {
|
|
98
|
+
// Silently fail order changes
|
|
99
|
+
}
|
|
100
|
+
}, [refreshData]);
|
|
101
|
+
|
|
102
|
+
const handleEpicAssign = useCallback(async (id: number, epicId: number | null) => {
|
|
103
|
+
try {
|
|
104
|
+
await fetch(`/api/work/${id}/epic`, {
|
|
105
|
+
method: 'PATCH',
|
|
106
|
+
headers: { 'Content-Type': 'application/json' },
|
|
107
|
+
body: JSON.stringify({ epic_id: epicId }),
|
|
108
|
+
});
|
|
109
|
+
await refreshData();
|
|
110
|
+
} catch {
|
|
111
|
+
// Silently fail epic assignment
|
|
112
|
+
}
|
|
113
|
+
}, [refreshData]);
|
|
114
|
+
|
|
78
115
|
const wsUrl = typeof window !== 'undefined'
|
|
79
116
|
? `ws://${window.location.hostname}:8080`
|
|
80
117
|
: 'ws://localhost:8080';
|
|
@@ -88,11 +125,11 @@ export function RealTimeKanbanWrapper({ initialData }: RealTimeKanbanWrapperProp
|
|
|
88
125
|
const connectionStatus = isConnected ? 'connected' : isReconnecting ? 'reconnecting' : 'disconnected';
|
|
89
126
|
|
|
90
127
|
return (
|
|
91
|
-
<div>
|
|
128
|
+
<div className="h-full flex flex-col">
|
|
92
129
|
{/* Status Error Display */}
|
|
93
130
|
{statusError && (
|
|
94
131
|
<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"
|
|
132
|
+
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 flex-shrink-0"
|
|
96
133
|
data-testid="status-error"
|
|
97
134
|
>
|
|
98
135
|
<span>{statusError}</span>
|
|
@@ -106,7 +143,7 @@ export function RealTimeKanbanWrapper({ initialData }: RealTimeKanbanWrapperProp
|
|
|
106
143
|
</div>
|
|
107
144
|
)}
|
|
108
145
|
{/* Connection Status Indicator */}
|
|
109
|
-
<div className="mb-4 flex items-center gap-2" data-testid="connection-status">
|
|
146
|
+
<div className="mb-4 flex items-center gap-2 flex-shrink-0" data-testid="connection-status">
|
|
110
147
|
<span
|
|
111
148
|
className={`w-2 h-2 rounded-full ${
|
|
112
149
|
connectionStatus === 'connected'
|
|
@@ -124,13 +161,22 @@ export function RealTimeKanbanWrapper({ initialData }: RealTimeKanbanWrapperProp
|
|
|
124
161
|
: 'Disconnected'}
|
|
125
162
|
</span>
|
|
126
163
|
</div>
|
|
127
|
-
<
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
164
|
+
<div className="flex-1 min-h-0 flex gap-4">
|
|
165
|
+
<div className="flex-1 min-w-0">
|
|
166
|
+
<KanbanBoard
|
|
167
|
+
inFlight={data.inFlight}
|
|
168
|
+
backlog={data.backlog}
|
|
169
|
+
done={data.done}
|
|
170
|
+
onTitleSave={handleTitleSave}
|
|
171
|
+
onStatusChange={handleStatusChange}
|
|
172
|
+
onOrderChange={handleOrderChange}
|
|
173
|
+
onEpicAssign={handleEpicAssign}
|
|
174
|
+
/>
|
|
175
|
+
</div>
|
|
176
|
+
<div className="w-60 flex-shrink-0">
|
|
177
|
+
<RecentDecisionsWidget decisions={decisions} />
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
134
180
|
</div>
|
|
135
181
|
);
|
|
136
182
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { Decision } from '@/lib/db';
|
|
4
|
+
|
|
5
|
+
interface RecentDecisionsWidgetProps {
|
|
6
|
+
decisions: Decision[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function formatRelativeDate(dateString: string): string {
|
|
10
|
+
const date = new Date(dateString);
|
|
11
|
+
const now = new Date();
|
|
12
|
+
const diffMs = now.getTime() - date.getTime();
|
|
13
|
+
const diffMins = Math.floor(diffMs / 60000);
|
|
14
|
+
const diffHours = Math.floor(diffMs / 3600000);
|
|
15
|
+
const diffDays = Math.floor(diffMs / 86400000);
|
|
16
|
+
|
|
17
|
+
if (diffMins < 1) return 'just now';
|
|
18
|
+
if (diffMins < 60) return `${diffMins}m ago`;
|
|
19
|
+
if (diffHours < 24) return `${diffHours}h ago`;
|
|
20
|
+
if (diffDays < 7) return `${diffDays}d ago`;
|
|
21
|
+
return date.toLocaleDateString();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function RecentDecisionsWidget({ decisions }: RecentDecisionsWidgetProps) {
|
|
25
|
+
if (decisions.length === 0) {
|
|
26
|
+
return (
|
|
27
|
+
<div className="bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700 p-4">
|
|
28
|
+
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 mb-3">
|
|
29
|
+
Recent Decisions
|
|
30
|
+
</h3>
|
|
31
|
+
<p className="text-sm text-zinc-500 dark:text-zinc-400">No decisions yet</p>
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div className="bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700 p-4">
|
|
38
|
+
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 mb-3">
|
|
39
|
+
Recent Decisions
|
|
40
|
+
</h3>
|
|
41
|
+
<div className="space-y-3">
|
|
42
|
+
{decisions.map((decision) => (
|
|
43
|
+
<div key={decision.id} className="flex gap-3">
|
|
44
|
+
<div className="w-1 bg-zinc-300 dark:bg-zinc-600 rounded-full flex-shrink-0" />
|
|
45
|
+
<div className="flex-1 min-w-0">
|
|
46
|
+
<p className="text-sm text-zinc-900 dark:text-zinc-100 line-clamp-2">
|
|
47
|
+
{decision.decision}
|
|
48
|
+
</p>
|
|
49
|
+
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-1">
|
|
50
|
+
{decision.aspect || 'General'} · #{decision.work_item_id} · {formatRelativeDate(decision.created_at)}
|
|
51
|
+
</p>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
))}
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
}
|
package/apps/dashboard/lib/db.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import Database from 'better-sqlite3';
|
|
5
5
|
import path from 'path';
|
|
6
|
+
import fs from 'fs';
|
|
6
7
|
import { execSync } from 'child_process';
|
|
7
8
|
|
|
8
9
|
// Types matching JettyPod schema
|
|
@@ -19,6 +20,7 @@ export interface WorkItem {
|
|
|
19
20
|
phase: string | null;
|
|
20
21
|
completed_at: string | null;
|
|
21
22
|
created_at: string;
|
|
23
|
+
display_order: number | null;
|
|
22
24
|
children?: WorkItem[];
|
|
23
25
|
chores?: WorkItem[];
|
|
24
26
|
current_step?: number | null;
|
|
@@ -52,6 +54,16 @@ function getDbPath(): string {
|
|
|
52
54
|
return path.join(projectRoot, '.jettypod', 'work.db');
|
|
53
55
|
}
|
|
54
56
|
|
|
57
|
+
export function getProjectName(): string {
|
|
58
|
+
const projectRoot = getProjectRoot();
|
|
59
|
+
const configPath = path.join(projectRoot, '.jettypod', 'config.json');
|
|
60
|
+
if (fs.existsSync(configPath)) {
|
|
61
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
62
|
+
return config.name || 'Untitled Project';
|
|
63
|
+
}
|
|
64
|
+
return 'Untitled Project';
|
|
65
|
+
}
|
|
66
|
+
|
|
55
67
|
function getDb(): Database.Database {
|
|
56
68
|
const dbPath = getDbPath();
|
|
57
69
|
return new Database(dbPath, { readonly: true });
|
|
@@ -123,6 +135,40 @@ export function getDecisionsForWorkItem(workItemId: number): Decision[] {
|
|
|
123
135
|
}
|
|
124
136
|
}
|
|
125
137
|
|
|
138
|
+
export function getRecentDecisions(limit: number = 5): Decision[] {
|
|
139
|
+
const db = getDb();
|
|
140
|
+
try {
|
|
141
|
+
// Combine epic architectural decisions and feature UX decisions
|
|
142
|
+
const decisions = db.prepare(`
|
|
143
|
+
SELECT id, work_item_id, aspect, decision, rationale, created_at
|
|
144
|
+
FROM (
|
|
145
|
+
-- Epic architectural decisions from discovery_decisions table
|
|
146
|
+
SELECT id, work_item_id, aspect, decision, rationale, created_at
|
|
147
|
+
FROM discovery_decisions
|
|
148
|
+
|
|
149
|
+
UNION ALL
|
|
150
|
+
|
|
151
|
+
-- Feature UX decisions from work_items table
|
|
152
|
+
-- Use discovery_completed_at when available, fallback to created_at for older records
|
|
153
|
+
SELECT
|
|
154
|
+
id * -1 as id,
|
|
155
|
+
id as work_item_id,
|
|
156
|
+
'UX Approach' as aspect,
|
|
157
|
+
discovery_winner as decision,
|
|
158
|
+
discovery_rationale as rationale,
|
|
159
|
+
COALESCE(discovery_completed_at, created_at) as created_at
|
|
160
|
+
FROM work_items
|
|
161
|
+
WHERE discovery_winner IS NOT NULL
|
|
162
|
+
)
|
|
163
|
+
ORDER BY created_at DESC
|
|
164
|
+
LIMIT ?
|
|
165
|
+
`).all(limit) as Decision[];
|
|
166
|
+
return decisions;
|
|
167
|
+
} finally {
|
|
168
|
+
db.close();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
126
172
|
// Build a tree structure from flat work items
|
|
127
173
|
export function buildWorkItemTree(items: WorkItem[]): WorkItem[] {
|
|
128
174
|
const itemMap = new Map<number, WorkItem>();
|
|
@@ -239,7 +285,7 @@ export function getKanbanData(doneLimit: number = 50): KanbanData {
|
|
|
239
285
|
// - Bugs if they exist
|
|
240
286
|
const allItems = db.prepare(`
|
|
241
287
|
SELECT w.id, w.type, w.title, w.description, w.status, w.parent_id, w.epic_id,
|
|
242
|
-
w.branch_name, w.mode, w.phase, w.completed_at, w.created_at,
|
|
288
|
+
w.branch_name, w.mode, w.phase, w.completed_at, w.created_at, w.display_order,
|
|
243
289
|
p.type as parent_type,
|
|
244
290
|
wc.current_step, wc.total_steps
|
|
245
291
|
FROM work_items w
|
|
@@ -247,7 +293,7 @@ export function getKanbanData(doneLimit: number = 50): KanbanData {
|
|
|
247
293
|
LEFT JOIN workflow_checkpoints wc ON wc.work_item_id = w.id
|
|
248
294
|
WHERE w.type IN ('feature', 'chore', 'bug')
|
|
249
295
|
AND (w.parent_id IS NULL OR p.type = 'epic')
|
|
250
|
-
ORDER BY w.id
|
|
296
|
+
ORDER BY COALESCE(w.display_order, w.id)
|
|
251
297
|
`).all() as (WorkItem & { parent_type: string | null })[];
|
|
252
298
|
|
|
253
299
|
const inFlight: InFlightItem[] = [];
|
|
@@ -291,21 +337,46 @@ export function getKanbanData(doneLimit: number = 50): KanbanData {
|
|
|
291
337
|
}
|
|
292
338
|
}
|
|
293
339
|
|
|
294
|
-
//
|
|
340
|
+
// Sort done items by completed_at DESC (newest first) within each group
|
|
341
|
+
for (const [, group] of doneGroups) {
|
|
342
|
+
group.items.sort((a, b) => {
|
|
343
|
+
const dateA = a.completed_at ? new Date(a.completed_at).getTime() : 0;
|
|
344
|
+
const dateB = b.completed_at ? new Date(b.completed_at).getTime() : 0;
|
|
345
|
+
return dateB - dateA; // DESC order
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Sort epic groups by most recent completion date (newest first)
|
|
350
|
+
const sortedDoneGroups = new Map(
|
|
351
|
+
Array.from(doneGroups.entries()).sort(([, groupA], [, groupB]) => {
|
|
352
|
+
const mostRecentA = groupA.items[0]?.completed_at
|
|
353
|
+
? new Date(groupA.items[0].completed_at).getTime()
|
|
354
|
+
: 0;
|
|
355
|
+
const mostRecentB = groupB.items[0]?.completed_at
|
|
356
|
+
? new Date(groupB.items[0].completed_at).getTime()
|
|
357
|
+
: 0;
|
|
358
|
+
return mostRecentB - mostRecentA; // DESC order
|
|
359
|
+
})
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
// Limit done items - build new Map to avoid modifying during iteration
|
|
363
|
+
const limitedDoneGroups = new Map<string, KanbanGroup>();
|
|
295
364
|
let doneCount = 0;
|
|
296
|
-
for (const [key, group] of
|
|
365
|
+
for (const [key, group] of sortedDoneGroups) {
|
|
297
366
|
if (doneCount >= doneLimit) {
|
|
298
|
-
|
|
299
|
-
continue;
|
|
367
|
+
break;
|
|
300
368
|
}
|
|
301
369
|
const remaining = doneLimit - doneCount;
|
|
302
370
|
if (group.items.length > remaining) {
|
|
303
|
-
|
|
371
|
+
limitedDoneGroups.set(key, { ...group, items: group.items.slice(0, remaining) });
|
|
372
|
+
doneCount += remaining;
|
|
373
|
+
} else {
|
|
374
|
+
limitedDoneGroups.set(key, group);
|
|
375
|
+
doneCount += group.items.length;
|
|
304
376
|
}
|
|
305
|
-
doneCount += group.items.length;
|
|
306
377
|
}
|
|
307
378
|
|
|
308
|
-
return { inFlight, backlog: backlogGroups, done:
|
|
379
|
+
return { inFlight, backlog: backlogGroups, done: limitedDoneGroups };
|
|
309
380
|
} finally {
|
|
310
381
|
db.close();
|
|
311
382
|
}
|
|
@@ -335,3 +406,28 @@ export function updateWorkItemStatus(id: number, status: string): boolean {
|
|
|
335
406
|
db.close();
|
|
336
407
|
}
|
|
337
408
|
}
|
|
409
|
+
|
|
410
|
+
export function updateWorkItemOrder(id: number, displayOrder: number): boolean {
|
|
411
|
+
const db = getWriteDb();
|
|
412
|
+
try {
|
|
413
|
+
const result = db.prepare(`
|
|
414
|
+
UPDATE work_items SET display_order = ? WHERE id = ?
|
|
415
|
+
`).run(displayOrder, id);
|
|
416
|
+
return result.changes > 0;
|
|
417
|
+
} finally {
|
|
418
|
+
db.close();
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export function updateWorkItemEpic(id: number, epicId: number | null): boolean {
|
|
423
|
+
const db = getWriteDb();
|
|
424
|
+
try {
|
|
425
|
+
// Update parent_id for features/chores to assign them to an epic
|
|
426
|
+
const result = db.prepare(`
|
|
427
|
+
UPDATE work_items SET parent_id = ? WHERE id = ? AND type IN ('feature', 'chore', 'bug')
|
|
428
|
+
`).run(epicId, id);
|
|
429
|
+
return result.changes > 0;
|
|
430
|
+
} finally {
|
|
431
|
+
db.close();
|
|
432
|
+
}
|
|
433
|
+
}
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"better-sqlite3": "^12.5.0",
|
|
13
13
|
"class-variance-authority": "^0.7.1",
|
|
14
14
|
"clsx": "^2.1.1",
|
|
15
|
+
"framer-motion": "^11.15.0",
|
|
15
16
|
"lucide-react": "^0.555.0",
|
|
16
17
|
"next": "16.0.6",
|
|
17
18
|
"react": "19.2.0",
|
|
@@ -28,6 +29,6 @@
|
|
|
28
29
|
"eslint-config-next": "16.0.6",
|
|
29
30
|
"tailwindcss": "^4",
|
|
30
31
|
"tw-animate-css": "^1.4.0",
|
|
31
|
-
"typescript": "
|
|
32
|
+
"typescript": "5.9.3"
|
|
32
33
|
}
|
|
33
34
|
}
|
package/lib/database.js
CHANGED
|
@@ -250,6 +250,7 @@ function initSchema() {
|
|
|
250
250
|
db.run(`ALTER TABLE work_items ADD COLUMN needs_discovery INTEGER DEFAULT 0`, handleAlterError('needs_discovery'));
|
|
251
251
|
db.run(`ALTER TABLE work_items ADD COLUMN worktree_path TEXT`, handleAlterError('worktree_path'));
|
|
252
252
|
db.run(`ALTER TABLE work_items ADD COLUMN discovery_rationale TEXT`, handleAlterError('discovery_rationale'));
|
|
253
|
+
db.run(`ALTER TABLE work_items ADD COLUMN display_order INTEGER`, handleAlterError('display_order'));
|
|
253
254
|
db.run(`ALTER TABLE work_items ADD COLUMN architectural_decision TEXT`, async (err) => {
|
|
254
255
|
handleAlterError('architectural_decision')(err);
|
|
255
256
|
// Run data migrations after all schema operations complete (skip in test environments)
|
|
@@ -279,7 +280,8 @@ function validateSchema(database) {
|
|
|
279
280
|
'id', 'type', 'title', 'description', 'status', 'parent_id', 'epic_id',
|
|
280
281
|
'branch_name', 'file_paths', 'commit_sha', 'mode', 'current', 'phase',
|
|
281
282
|
'prototype_files', 'discovery_winner', 'discovery_rationale', 'scenario_file',
|
|
282
|
-
'completed_at', 'created_at', 'needs_discovery', 'worktree_path', 'architectural_decision'
|
|
283
|
+
'completed_at', 'created_at', 'needs_discovery', 'worktree_path', 'architectural_decision',
|
|
284
|
+
'display_order'
|
|
283
285
|
];
|
|
284
286
|
|
|
285
287
|
return new Promise((resolve, reject) => {
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration: Add display_order column to work_items table
|
|
3
|
+
*
|
|
4
|
+
* Purpose: Enable custom ordering of work items in the Kanban board.
|
|
5
|
+
* When items are dragged and dropped, their display_order is updated
|
|
6
|
+
* to maintain the user's preferred ordering.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
module.exports = {
|
|
10
|
+
id: '017-display-order-column',
|
|
11
|
+
description: 'Add display_order column to work_items for Kanban board ordering',
|
|
12
|
+
|
|
13
|
+
async up(db) {
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
db.run(`
|
|
16
|
+
ALTER TABLE work_items
|
|
17
|
+
ADD COLUMN display_order INTEGER DEFAULT NULL
|
|
18
|
+
`, (err) => {
|
|
19
|
+
if (err) {
|
|
20
|
+
// Column might already exist from a partial migration
|
|
21
|
+
if (err.message && err.message.includes('duplicate column')) {
|
|
22
|
+
return resolve();
|
|
23
|
+
}
|
|
24
|
+
return reject(err);
|
|
25
|
+
}
|
|
26
|
+
resolve();
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
async down(db) {
|
|
32
|
+
// SQLite doesn't support DROP COLUMN in older versions
|
|
33
|
+
// For newer SQLite (3.35+), we can use DROP COLUMN directly
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
db.run(`
|
|
36
|
+
ALTER TABLE work_items DROP COLUMN display_order
|
|
37
|
+
`, (err) => {
|
|
38
|
+
if (err) {
|
|
39
|
+
// If DROP COLUMN isn't supported, we'd need table recreation
|
|
40
|
+
// but for this simple nullable column, it's safe to leave
|
|
41
|
+
console.warn('Could not drop display_order column:', err.message);
|
|
42
|
+
return resolve();
|
|
43
|
+
}
|
|
44
|
+
resolve();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration: Add discovery_completed_at column to work_items table
|
|
3
|
+
*
|
|
4
|
+
* Purpose: Track when feature UX decisions are actually made (via work implement),
|
|
5
|
+
* rather than using created_at which is when the work item was created.
|
|
6
|
+
* This enables proper chronological sorting in the decisions widget.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
module.exports = {
|
|
10
|
+
id: '019-discovery-completed-at',
|
|
11
|
+
description: 'Add discovery_completed_at column to work_items for accurate decision timestamps',
|
|
12
|
+
|
|
13
|
+
async up(db) {
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
db.run(`
|
|
16
|
+
ALTER TABLE work_items
|
|
17
|
+
ADD COLUMN discovery_completed_at TEXT
|
|
18
|
+
`, (err) => {
|
|
19
|
+
if (err) {
|
|
20
|
+
// Column might already exist from a partial migration
|
|
21
|
+
if (err.message && err.message.includes('duplicate column')) {
|
|
22
|
+
return resolve();
|
|
23
|
+
}
|
|
24
|
+
return reject(err);
|
|
25
|
+
}
|
|
26
|
+
resolve();
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
async down(db) {
|
|
32
|
+
// SQLite doesn't support DROP COLUMN in older versions
|
|
33
|
+
// For simplicity, we'll leave the column (it's nullable and harmless)
|
|
34
|
+
return Promise.resolve();
|
|
35
|
+
}
|
|
36
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration: Normalize completed_at timestamps to ISO format
|
|
3
|
+
*
|
|
4
|
+
* Purpose: Convert space-separated timestamps (e.g., "2025-12-12 21:22:40")
|
|
5
|
+
* to ISO format (e.g., "2025-12-12T21:22:40.000Z") for consistent sorting.
|
|
6
|
+
* Old timestamps were written by SQLite datetime('now') as local time.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
module.exports = {
|
|
10
|
+
id: '020-normalize-timestamps',
|
|
11
|
+
description: 'Normalize completed_at timestamps to ISO format',
|
|
12
|
+
|
|
13
|
+
async up(db) {
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
// Find all rows with space-separated timestamps (not ISO format)
|
|
16
|
+
db.all(
|
|
17
|
+
`SELECT id, completed_at FROM work_items
|
|
18
|
+
WHERE completed_at IS NOT NULL
|
|
19
|
+
AND completed_at NOT LIKE '%T%'`,
|
|
20
|
+
(err, rows) => {
|
|
21
|
+
if (err) return reject(err);
|
|
22
|
+
if (!rows || rows.length === 0) return resolve();
|
|
23
|
+
|
|
24
|
+
let pending = rows.length;
|
|
25
|
+
let hasError = false;
|
|
26
|
+
|
|
27
|
+
for (const row of rows) {
|
|
28
|
+
if (hasError) continue;
|
|
29
|
+
|
|
30
|
+
// Parse as local time (JS default for space-separated format)
|
|
31
|
+
// Then convert to ISO string (UTC)
|
|
32
|
+
const localDate = new Date(row.completed_at);
|
|
33
|
+
const isoTimestamp = localDate.toISOString();
|
|
34
|
+
|
|
35
|
+
db.run(
|
|
36
|
+
`UPDATE work_items SET completed_at = ? WHERE id = ?`,
|
|
37
|
+
[isoTimestamp, row.id],
|
|
38
|
+
(updateErr) => {
|
|
39
|
+
if (updateErr && !hasError) {
|
|
40
|
+
hasError = true;
|
|
41
|
+
return reject(updateErr);
|
|
42
|
+
}
|
|
43
|
+
pending--;
|
|
44
|
+
if (pending === 0 && !hasError) {
|
|
45
|
+
resolve();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
async down(db) {
|
|
56
|
+
// No practical way to reverse this - timestamps are still valid
|
|
57
|
+
return Promise.resolve();
|
|
58
|
+
}
|
|
59
|
+
};
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
const { execSync } = require('child_process');
|
|
2
2
|
const https = require('https');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
3
5
|
const packageJson = require('../../package.json');
|
|
4
6
|
|
|
5
7
|
/**
|
|
@@ -83,6 +85,18 @@ function updateJettyPod(version = 'latest') {
|
|
|
83
85
|
stdio: 'inherit'
|
|
84
86
|
});
|
|
85
87
|
|
|
88
|
+
// Clear dashboard .next folder to force rebuild with new assets
|
|
89
|
+
try {
|
|
90
|
+
const globalRoot = execSync('npm root -g', { encoding: 'utf-8' }).trim();
|
|
91
|
+
const dashboardNextPath = path.join(globalRoot, 'jettypod', 'apps', 'dashboard', '.next');
|
|
92
|
+
if (fs.existsSync(dashboardNextPath)) {
|
|
93
|
+
fs.rmSync(dashboardNextPath, { recursive: true, force: true });
|
|
94
|
+
console.log('🧹 Cleared dashboard cache');
|
|
95
|
+
}
|
|
96
|
+
} catch {
|
|
97
|
+
// Non-fatal - dashboard will still work, just might use old cache
|
|
98
|
+
}
|
|
99
|
+
|
|
86
100
|
return true;
|
|
87
101
|
} catch (err) {
|
|
88
102
|
console.log('');
|
|
@@ -1726,10 +1726,11 @@ async function mergeWork(options = {}) {
|
|
|
1726
1726
|
} else {
|
|
1727
1727
|
// Chore worktree: mark as done (existing behavior)
|
|
1728
1728
|
console.log('Marking chore as done...');
|
|
1729
|
+
const completedAt = new Date().toISOString();
|
|
1729
1730
|
await new Promise((resolve, reject) => {
|
|
1730
1731
|
db.run(
|
|
1731
|
-
`UPDATE work_items SET status = 'done', completed_at =
|
|
1732
|
-
[currentWork.id],
|
|
1732
|
+
`UPDATE work_items SET status = 'done', completed_at = ? WHERE id = ?`,
|
|
1733
|
+
[completedAt, currentWork.id],
|
|
1733
1734
|
(err) => {
|
|
1734
1735
|
if (err) return reject(err);
|
|
1735
1736
|
resolve();
|
|
@@ -2173,10 +2173,10 @@ async function main() {
|
|
|
2173
2173
|
if (isTransition) {
|
|
2174
2174
|
// When transitioning to implementation, ensure status is 'todo' (ready for work)
|
|
2175
2175
|
// This prevents status from being left as NULL or in a discovery-related state
|
|
2176
|
-
updateSql = `UPDATE work_items SET phase = 'implementation', mode = 'speed', status = 'todo', scenario_file = ?, prototype_files = ?, discovery_winner = ?, discovery_rationale =
|
|
2176
|
+
updateSql = `UPDATE work_items SET phase = 'implementation', mode = 'speed', status = 'todo', scenario_file = ?, prototype_files = ?, discovery_winner = ?, discovery_rationale = ?, discovery_completed_at = datetime('now') WHERE id = ?`;
|
|
2177
2177
|
updateParams = [scenarioFileValue, prototypeFilesValue, winnerValue, rationaleValue, featureId];
|
|
2178
2178
|
} else {
|
|
2179
|
-
updateSql = `UPDATE work_items SET prototype_files = ?, discovery_winner = ?, discovery_rationale =
|
|
2179
|
+
updateSql = `UPDATE work_items SET prototype_files = ?, discovery_winner = ?, discovery_rationale = ?, discovery_completed_at = datetime('now') WHERE id = ?`;
|
|
2180
2180
|
updateParams = [prototypeFilesValue, winnerValue, rationaleValue, featureId];
|
|
2181
2181
|
}
|
|
2182
2182
|
|
package/package.json
CHANGED
|
@@ -63,8 +63,49 @@ jettypod workflow start feature-planning <feature-id>
|
|
|
63
63
|
|
|
64
64
|
This creates an execution record for session resume.
|
|
65
65
|
|
|
66
|
+
### Step 1B: Route Simple Improvements
|
|
67
|
+
|
|
68
|
+
**CRITICAL:** Before proceeding with full feature planning, determine if this is a simple improvement.
|
|
69
|
+
|
|
70
|
+
**Ask the user:**
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
Is this:
|
|
74
|
+
1. **Simple improvement** - A basic enhancement to existing functionality (e.g., copy change, styling tweak, minor behavior adjustment)
|
|
75
|
+
2. **New functionality** - Adding new capabilities, even if small
|
|
76
|
+
|
|
77
|
+
Simple improvements skip the full feature planning workflow and use a lightweight process.
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
**⚡ WAIT for user response.**
|
|
81
|
+
|
|
82
|
+
**If user says "simple improvement" (or 1):**
|
|
83
|
+
|
|
84
|
+
Display:
|
|
85
|
+
```
|
|
86
|
+
This is a simple improvement. Routing to the lightweight simple-improvement workflow...
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
**Then IMMEDIATELY invoke simple-improvement using the Skill tool:**
|
|
90
|
+
```
|
|
91
|
+
Use the Skill tool with skill: "simple-improvement"
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
**Feature-planning skill ends here for simple improvements.** The simple-improvement skill takes over.
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
**If user says "new functionality" (or 2):**
|
|
99
|
+
|
|
100
|
+
Display:
|
|
101
|
+
```
|
|
102
|
+
Got it - this is new functionality. Let's explore the best approach...
|
|
103
|
+
```
|
|
104
|
+
|
|
66
105
|
**Proceed to Step 2** (or Step 3 if this is a standalone feature with no parent epic).
|
|
67
106
|
|
|
107
|
+
---
|
|
108
|
+
|
|
68
109
|
### Step 2: Check Epic Architectural Decisions
|
|
69
110
|
|
|
70
111
|
**Skip this step and proceed to Step 3 if `PARENT_EPIC_ID` from Step 1 is null (standalone feature).**
|