kanbango 3.1.0 → 3.3.0
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/.ai/lessons.jsonl +3 -0
- package/.ai/retro/last-run.json +1 -1
- package/.opencode/plans/action-error-recipe.md +121 -0
- package/AGENTS.md +4 -2
- package/CHANGELOG.md +27 -0
- package/LLM_AGENTS.md +1 -1
- package/README.md +11 -3
- package/agent-playbook.js +18 -9
- package/bin/kanban.js +143 -10
- package/gui-registry.js +14 -1
- package/index.html +645 -39
- package/kanban.js +474 -110
- package/mcp-server.js +253 -45
- package/package.json +1 -1
- package/tests/run.js +3 -0
package/index.html
CHANGED
|
@@ -157,16 +157,28 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
|
|
|
157
157
|
display: flex;
|
|
158
158
|
align-items: center;
|
|
159
159
|
gap: 8px;
|
|
160
|
-
padding:
|
|
160
|
+
padding: 8px 12px;
|
|
161
161
|
background: #f8fafc;
|
|
162
162
|
border-bottom: 1px solid var(--border);
|
|
163
|
-
cursor: pointer;
|
|
164
163
|
user-select: none;
|
|
165
164
|
}
|
|
166
|
-
.sl-header
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
165
|
+
.sl-header-main {
|
|
166
|
+
display: flex;
|
|
167
|
+
align-items: center;
|
|
168
|
+
gap: 8px;
|
|
169
|
+
flex: 1;
|
|
170
|
+
min-width: 0;
|
|
171
|
+
}
|
|
172
|
+
.sl-name { font-size: 13px; font-weight: 700; color: var(--text); }
|
|
173
|
+
.sl-id {
|
|
174
|
+
font-size: 10px;
|
|
175
|
+
font-weight: 700;
|
|
176
|
+
color: var(--muted);
|
|
177
|
+
background: #e2e8f0;
|
|
178
|
+
border-radius: 4px;
|
|
179
|
+
padding: 1px 6px;
|
|
180
|
+
letter-spacing: 0.3px;
|
|
181
|
+
}
|
|
170
182
|
.sl-cnt {
|
|
171
183
|
font-size: 11px;
|
|
172
184
|
color: var(--muted);
|
|
@@ -175,10 +187,232 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
|
|
|
175
187
|
padding: 1px 6px;
|
|
176
188
|
font-weight: 600;
|
|
177
189
|
}
|
|
190
|
+
.sl-status {
|
|
191
|
+
font-size: 10px;
|
|
192
|
+
font-weight: 700;
|
|
193
|
+
text-transform: uppercase;
|
|
194
|
+
letter-spacing: 0.4px;
|
|
195
|
+
border-radius: 999px;
|
|
196
|
+
padding: 2px 8px;
|
|
197
|
+
}
|
|
198
|
+
.sl-status.empty { background: #f1f5f9; color: #64748b; }
|
|
199
|
+
.sl-status.planned { background: #dbeafe; color: #1d4ed8; }
|
|
200
|
+
.sl-status.active { background: #ffedd5; color: #c2410c; }
|
|
201
|
+
.sl-status.done { background: #dcfce7; color: #15803d; }
|
|
202
|
+
.sl-status.archived { background: #f1f5f9; color: #64748b; }
|
|
178
203
|
.sl-progress {
|
|
204
|
+
font-size: 11px;
|
|
205
|
+
color: var(--muted);
|
|
206
|
+
white-space: nowrap;
|
|
207
|
+
}
|
|
208
|
+
.sl-toggles {
|
|
209
|
+
display: flex;
|
|
210
|
+
align-items: center;
|
|
211
|
+
gap: 4px;
|
|
179
212
|
margin-left: auto;
|
|
213
|
+
flex-shrink: 0;
|
|
214
|
+
}
|
|
215
|
+
.sl-toggle {
|
|
216
|
+
border: 1px solid var(--border);
|
|
217
|
+
background: #fff;
|
|
218
|
+
color: var(--muted);
|
|
219
|
+
border-radius: 6px;
|
|
180
220
|
font-size: 11px;
|
|
221
|
+
font-weight: 600;
|
|
222
|
+
padding: 3px 9px;
|
|
223
|
+
cursor: pointer;
|
|
224
|
+
font-family: inherit;
|
|
225
|
+
display: inline-flex;
|
|
226
|
+
align-items: center;
|
|
227
|
+
gap: 4px;
|
|
228
|
+
}
|
|
229
|
+
.sl-toggle:hover { background: #f1f5f9; color: var(--text); }
|
|
230
|
+
.sl-toggle.on {
|
|
231
|
+
background: #e0e7ff;
|
|
232
|
+
border-color: #c7d2fe;
|
|
233
|
+
color: #3730a3;
|
|
234
|
+
}
|
|
235
|
+
.sl-toggle .chev { font-size: 9px; transition: transform 0.15s; display: inline-block; }
|
|
236
|
+
.sl-toggle.on .chev { transform: rotate(90deg); }
|
|
237
|
+
.sl-edit-btn {
|
|
238
|
+
border: none;
|
|
239
|
+
background: #e2e8f0;
|
|
181
240
|
color: var(--muted);
|
|
241
|
+
border-radius: 6px;
|
|
242
|
+
font-size: 10px;
|
|
243
|
+
font-weight: 700;
|
|
244
|
+
padding: 4px 9px;
|
|
245
|
+
cursor: pointer;
|
|
246
|
+
font-family: inherit;
|
|
247
|
+
}
|
|
248
|
+
.sl-edit-btn:hover { background: #cbd5e1; color: var(--text); }
|
|
249
|
+
.sl-edit-btn.active { background: #c7d2fe; color: #3730a3; }
|
|
250
|
+
|
|
251
|
+
/* ── Epic detail panel (swimlane context) ── */
|
|
252
|
+
.sl-epic-shell {
|
|
253
|
+
border-bottom: 1px solid var(--border);
|
|
254
|
+
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
|
|
255
|
+
}
|
|
256
|
+
.sl-epic-detail {
|
|
257
|
+
padding: 12px 14px 14px;
|
|
258
|
+
display: flex;
|
|
259
|
+
flex-direction: column;
|
|
260
|
+
gap: 10px;
|
|
261
|
+
font-size: 12px;
|
|
262
|
+
}
|
|
263
|
+
.sl-epic-hero {
|
|
264
|
+
display: flex;
|
|
265
|
+
flex-wrap: wrap;
|
|
266
|
+
gap: 8px 12px;
|
|
267
|
+
align-items: flex-start;
|
|
268
|
+
justify-content: space-between;
|
|
269
|
+
}
|
|
270
|
+
.sl-epic-goals {
|
|
271
|
+
flex: 1;
|
|
272
|
+
min-width: 220px;
|
|
273
|
+
background: #fff;
|
|
274
|
+
border: 1px solid #e2e8f0;
|
|
275
|
+
border-left: 3px solid #6366f1;
|
|
276
|
+
border-radius: 8px;
|
|
277
|
+
padding: 10px 12px;
|
|
278
|
+
}
|
|
279
|
+
.sl-epic-goals .sl-epic-label { color: #6366f1; }
|
|
280
|
+
.sl-epic-goals .sl-epic-text {
|
|
281
|
+
font-size: 13px;
|
|
282
|
+
font-weight: 600;
|
|
283
|
+
line-height: 1.45;
|
|
284
|
+
color: #1e293b;
|
|
285
|
+
}
|
|
286
|
+
.sl-epic-rollup {
|
|
287
|
+
display: flex;
|
|
288
|
+
flex-wrap: wrap;
|
|
289
|
+
gap: 6px;
|
|
290
|
+
align-content: flex-start;
|
|
291
|
+
}
|
|
292
|
+
.sl-chip {
|
|
293
|
+
display: inline-flex;
|
|
294
|
+
align-items: center;
|
|
295
|
+
gap: 4px;
|
|
296
|
+
background: #fff;
|
|
297
|
+
border: 1px solid #e2e8f0;
|
|
298
|
+
border-radius: 999px;
|
|
299
|
+
padding: 3px 10px;
|
|
300
|
+
font-size: 11px;
|
|
301
|
+
font-weight: 600;
|
|
302
|
+
color: #475569;
|
|
303
|
+
}
|
|
304
|
+
.sl-chip strong { color: #0f172a; font-weight: 700; }
|
|
305
|
+
.sl-chip.done { border-color: #bbf7d0; background: #f0fdf4; color: #166534; }
|
|
306
|
+
.sl-chip.active { border-color: #fed7aa; background: #fff7ed; color: #c2410c; }
|
|
307
|
+
.sl-chip.planned { border-color: #bfdbfe; background: #eff6ff; color: #1d4ed8; }
|
|
308
|
+
.sl-chip.icebox { border-color: #e2e8f0; background: #f8fafc; color: #64748b; }
|
|
309
|
+
|
|
310
|
+
.sl-epic-body {
|
|
311
|
+
display: grid;
|
|
312
|
+
grid-template-columns: 1.4fr 1fr;
|
|
313
|
+
gap: 10px;
|
|
314
|
+
}
|
|
315
|
+
@media (max-width: 900px) {
|
|
316
|
+
.sl-epic-body { grid-template-columns: 1fr; }
|
|
317
|
+
}
|
|
318
|
+
.sl-epic-card {
|
|
319
|
+
background: #fff;
|
|
320
|
+
border: 1px solid #e2e8f0;
|
|
321
|
+
border-radius: 8px;
|
|
322
|
+
padding: 10px 12px;
|
|
323
|
+
min-width: 0;
|
|
324
|
+
}
|
|
325
|
+
.sl-epic-card.scope-pair {
|
|
326
|
+
display: grid;
|
|
327
|
+
grid-template-columns: 1fr 1fr;
|
|
328
|
+
gap: 10px 14px;
|
|
329
|
+
}
|
|
330
|
+
.sl-epic-label {
|
|
331
|
+
font-size: 10px;
|
|
332
|
+
font-weight: 700;
|
|
333
|
+
text-transform: uppercase;
|
|
334
|
+
letter-spacing: 0.55px;
|
|
335
|
+
color: var(--muted);
|
|
336
|
+
margin-bottom: 6px;
|
|
337
|
+
}
|
|
338
|
+
.sl-epic-text {
|
|
339
|
+
color: #334155;
|
|
340
|
+
white-space: pre-wrap;
|
|
341
|
+
line-height: 1.5;
|
|
342
|
+
word-break: break-word;
|
|
343
|
+
}
|
|
344
|
+
.sl-epic-text.empty { color: #94a3b8; font-style: italic; }
|
|
345
|
+
.sl-epic-list {
|
|
346
|
+
margin: 0;
|
|
347
|
+
padding: 0;
|
|
348
|
+
list-style: none;
|
|
349
|
+
display: flex;
|
|
350
|
+
flex-direction: column;
|
|
351
|
+
gap: 4px;
|
|
352
|
+
}
|
|
353
|
+
.sl-epic-list li {
|
|
354
|
+
position: relative;
|
|
355
|
+
padding: 4px 8px 4px 22px;
|
|
356
|
+
background: #f8fafc;
|
|
357
|
+
border-radius: 6px;
|
|
358
|
+
line-height: 1.4;
|
|
359
|
+
color: #334155;
|
|
360
|
+
}
|
|
361
|
+
.sl-epic-list li::before {
|
|
362
|
+
content: "";
|
|
363
|
+
position: absolute;
|
|
364
|
+
left: 8px;
|
|
365
|
+
top: 10px;
|
|
366
|
+
width: 6px;
|
|
367
|
+
height: 6px;
|
|
368
|
+
border-radius: 50%;
|
|
369
|
+
background: #6366f1;
|
|
370
|
+
}
|
|
371
|
+
.sl-epic-list.out li {
|
|
372
|
+
background: #f8fafc;
|
|
373
|
+
color: #94a3b8;
|
|
374
|
+
}
|
|
375
|
+
.sl-epic-list.out li::before { background: #cbd5e1; }
|
|
376
|
+
.sl-epic-notes {
|
|
377
|
+
border-style: dashed;
|
|
378
|
+
color: #64748b;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
.sl-epic-edit-grid {
|
|
382
|
+
display: grid;
|
|
383
|
+
grid-template-columns: 1fr 1fr;
|
|
384
|
+
gap: 8px 12px;
|
|
385
|
+
padding: 12px 14px 14px;
|
|
386
|
+
}
|
|
387
|
+
.sl-epic-edit-grid .span2 { grid-column: 1 / -1; }
|
|
388
|
+
.sl-epic-edit-grid label {
|
|
389
|
+
display: block;
|
|
390
|
+
font-size: 10px;
|
|
391
|
+
font-weight: 700;
|
|
392
|
+
text-transform: uppercase;
|
|
393
|
+
letter-spacing: 0.5px;
|
|
394
|
+
color: var(--muted);
|
|
395
|
+
margin-bottom: 3px;
|
|
396
|
+
}
|
|
397
|
+
.sl-epic-edit-grid input,
|
|
398
|
+
.sl-epic-edit-grid textarea {
|
|
399
|
+
width: 100%;
|
|
400
|
+
border: 1px solid var(--border);
|
|
401
|
+
border-radius: var(--radius);
|
|
402
|
+
padding: 5px 8px;
|
|
403
|
+
font-size: 12px;
|
|
404
|
+
font-family: inherit;
|
|
405
|
+
color: var(--text);
|
|
406
|
+
box-sizing: border-box;
|
|
407
|
+
resize: vertical;
|
|
408
|
+
background: #fff;
|
|
409
|
+
}
|
|
410
|
+
.sl-epic-edit-grid textarea { min-height: 52px; }
|
|
411
|
+
.sl-epic-edit-actions {
|
|
412
|
+
grid-column: 1 / -1;
|
|
413
|
+
display: flex;
|
|
414
|
+
gap: 8px;
|
|
415
|
+
justify-content: flex-end;
|
|
182
416
|
}
|
|
183
417
|
|
|
184
418
|
/* ── Swimlane body grid ── */
|
|
@@ -487,9 +721,30 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
|
|
|
487
721
|
<h1>Donna Kanban</h1>
|
|
488
722
|
<span class="hstats" id="hstats">—</span>
|
|
489
723
|
<span class="hspacer"></span>
|
|
724
|
+
<button class="btn-new" id="btn-show-archived" onclick="toggleShowArchived()" style="background:#94a3b8">Show archived</button>
|
|
725
|
+
<button class="btn-new" id="btn-new-epic" onclick="toggleNewEpicForm()" style="background:#64748b">+ New epic</button>
|
|
490
726
|
<button class="btn-new" id="btn-new" onclick="toggleNewForm()">+ New task</button>
|
|
491
727
|
</header>
|
|
492
728
|
|
|
729
|
+
<div class="new-epic-strip" id="new-epic-strip">
|
|
730
|
+
<div class="ne-field">
|
|
731
|
+
<div class="ne-label">Epic title</div>
|
|
732
|
+
<input class="ne-input ne-title" id="nx-title" placeholder="Initiative name…" autocomplete="off">
|
|
733
|
+
</div>
|
|
734
|
+
<div class="ne-field" style="flex:1;min-width:200px">
|
|
735
|
+
<div class="ne-label">Description</div>
|
|
736
|
+
<input class="ne-input" id="nx-description" placeholder="Context for agents…" autocomplete="off" style="width:100%">
|
|
737
|
+
</div>
|
|
738
|
+
<div class="ne-field" style="flex:1;min-width:160px">
|
|
739
|
+
<div class="ne-label">Goals</div>
|
|
740
|
+
<input class="ne-input" id="nx-goals" placeholder="Outcome…" autocomplete="off" style="width:100%">
|
|
741
|
+
</div>
|
|
742
|
+
<div class="ne-buttons">
|
|
743
|
+
<button class="btn-sm btn-confirm" onclick="submitNewEpic()">Add epic</button>
|
|
744
|
+
<button class="btn-sm btn-cancel" onclick="toggleNewEpicForm()">Cancel</button>
|
|
745
|
+
</div>
|
|
746
|
+
</div>
|
|
747
|
+
|
|
493
748
|
<div class="new-epic-strip" id="new-strip">
|
|
494
749
|
<div class="ne-field">
|
|
495
750
|
<div class="ne-label">Title</div>
|
|
@@ -506,7 +761,8 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
|
|
|
506
761
|
</div>
|
|
507
762
|
<div class="ne-field">
|
|
508
763
|
<div class="ne-label">Epic</div>
|
|
509
|
-
<input class="ne-input ne-group" id="ne-group" placeholder="
|
|
764
|
+
<input class="ne-input ne-group" id="ne-group" list="epic-datalist" placeholder="E001 or title…" autocomplete="off">
|
|
765
|
+
<datalist id="epic-datalist"></datalist>
|
|
510
766
|
</div>
|
|
511
767
|
<div class="ne-buttons">
|
|
512
768
|
<button class="btn-sm btn-confirm" onclick="submitNew()">Add</button>
|
|
@@ -532,23 +788,70 @@ const COL_DEFS = [
|
|
|
532
788
|
let allTasks = [];
|
|
533
789
|
let allEpicEntities = [];
|
|
534
790
|
let colCounts = {};
|
|
535
|
-
|
|
791
|
+
// Separate collapse: epic info panel vs task board (default: info open, tasks open)
|
|
792
|
+
let collapsedInfo = new Set();
|
|
793
|
+
let collapsedTasks = new Set();
|
|
536
794
|
let expanded = new Set();
|
|
537
795
|
let editingId = null;
|
|
538
796
|
let dirtyEdits = {}; // taskId -> editable task draft
|
|
797
|
+
let editingEpicId = null;
|
|
798
|
+
let dirtyEpicEdits = {}; // epicId -> draft
|
|
799
|
+
let showArchived = false;
|
|
539
800
|
|
|
540
801
|
// ── Load & render ─────────────────────────────────────────────────────────────
|
|
541
802
|
|
|
542
803
|
async function load() {
|
|
804
|
+
const q = showArchived ? "?include_archived=true" : "";
|
|
543
805
|
const [boardRes, epicsRes] = await Promise.all([
|
|
544
|
-
fetch("/api/board"),
|
|
545
|
-
fetch("/api/epics")
|
|
806
|
+
fetch("/api/board" + q),
|
|
807
|
+
fetch("/api/epics" + q)
|
|
546
808
|
]);
|
|
547
809
|
allTasks = await boardRes.json();
|
|
548
810
|
allEpicEntities = epicsRes.ok ? await epicsRes.json() : [];
|
|
811
|
+
refreshEpicDatalist();
|
|
549
812
|
render();
|
|
550
813
|
}
|
|
551
814
|
|
|
815
|
+
async function archiveEpic(epicId, archived) {
|
|
816
|
+
const action = archived ? "archive" : "unarchive";
|
|
817
|
+
await fetch(`/api/epic-entities/${enc(epicId)}/${action}`, { method: "POST" });
|
|
818
|
+
if (editingEpicId === epicId) editingEpicId = null;
|
|
819
|
+
await load();
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
async function deleteEpicEntity(epicId, title) {
|
|
823
|
+
if (!confirm(`Delete epic ${epicId} "${title}" and ALL its tasks? This cannot be undone.`)) return;
|
|
824
|
+
await fetch(`/api/epic-entities/${enc(epicId)}`, { method: "DELETE" });
|
|
825
|
+
if (editingEpicId === epicId) editingEpicId = null;
|
|
826
|
+
await load();
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
async function deleteTaskCard(taskId, title) {
|
|
830
|
+
if (!confirm(`Delete task ${taskId}: ${title}?`)) return;
|
|
831
|
+
await fetch(`/api/tasks/${enc(taskId)}`, { method: "DELETE" });
|
|
832
|
+
expanded.delete(taskId);
|
|
833
|
+
editingId = null;
|
|
834
|
+
await load();
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function toggleShowArchived() {
|
|
838
|
+
showArchived = !showArchived;
|
|
839
|
+
const btn = document.getElementById("btn-show-archived");
|
|
840
|
+
if (btn) {
|
|
841
|
+
btn.textContent = showArchived ? "Hide archived" : "Show archived";
|
|
842
|
+
btn.style.background = showArchived ? "#6366f1" : "#94a3b8";
|
|
843
|
+
}
|
|
844
|
+
load();
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function refreshEpicDatalist() {
|
|
848
|
+
const list = document.getElementById("epic-datalist");
|
|
849
|
+
if (!list) return;
|
|
850
|
+
list.innerHTML = allEpicEntities.map(e =>
|
|
851
|
+
`<option value="${escHtml(e.id)}">${escHtml(e.title)}</option>`
|
|
852
|
+
).join("");
|
|
853
|
+
}
|
|
854
|
+
|
|
552
855
|
function render() {
|
|
553
856
|
colCounts = {};
|
|
554
857
|
COL_DEFS.forEach(c => (colCounts[c.id] = 0));
|
|
@@ -557,7 +860,8 @@ function render() {
|
|
|
557
860
|
// Header stats
|
|
558
861
|
const total = allTasks.length;
|
|
559
862
|
const done = allTasks.filter(e => e.column === "done").length;
|
|
560
|
-
|
|
863
|
+
const epicN = allEpicEntities.length;
|
|
864
|
+
document.getElementById("hstats").textContent = `${done}/${total} done · ${epicN} epics`;
|
|
561
865
|
|
|
562
866
|
// Col headers
|
|
563
867
|
const hdr = document.getElementById("col-headers");
|
|
@@ -582,10 +886,15 @@ function groupTasks(tasks) {
|
|
|
582
886
|
if (!map.has(key)) map.set(key, []);
|
|
583
887
|
map.get(key).push(t);
|
|
584
888
|
});
|
|
889
|
+
// Include epics that have no tasks yet
|
|
890
|
+
allEpicEntities.forEach(e => {
|
|
891
|
+
if (!map.has(e.id)) map.set(e.id, []);
|
|
892
|
+
});
|
|
893
|
+
|
|
585
894
|
return [...map.entries()]
|
|
586
895
|
.sort(([a], [b]) => a === "—" ? 1 : b === "—" ? -1 : a.localeCompare(b, "pl"))
|
|
587
896
|
.map(([key, laneTasks]) => {
|
|
588
|
-
const entity = allEpicEntities.find(e => e.id === key);
|
|
897
|
+
const entity = allEpicEntities.find(e => e.id === key) || null;
|
|
589
898
|
const name = key === "—"
|
|
590
899
|
? "—"
|
|
591
900
|
: (entity ? entity.title : (laneTasks[0] && laneTasks[0].epic_group) || key);
|
|
@@ -593,56 +902,304 @@ function groupTasks(tasks) {
|
|
|
593
902
|
key,
|
|
594
903
|
name,
|
|
595
904
|
epicId: key === "—" ? null : key,
|
|
596
|
-
|
|
597
|
-
description: entity ? entity.description : "",
|
|
598
|
-
goals: entity ? entity.goals : "",
|
|
905
|
+
entity,
|
|
599
906
|
tasks: laneTasks
|
|
600
907
|
};
|
|
601
908
|
});
|
|
602
909
|
}
|
|
603
910
|
|
|
911
|
+
function epicListHtml(items, emptyLabel, extraClass) {
|
|
912
|
+
if (!items || items.length === 0) {
|
|
913
|
+
return `<div class="sl-epic-text empty">${escHtml(emptyLabel)}</div>`;
|
|
914
|
+
}
|
|
915
|
+
return `<ul class="sl-epic-list${extraClass ? " " + extraClass : ""}">${
|
|
916
|
+
items.map(i => `<li>${escHtml(i)}</li>`).join("")
|
|
917
|
+
}</ul>`;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function renderEpicDetailView(entity) {
|
|
921
|
+
const shell = el("div", "sl-epic-shell");
|
|
922
|
+
const panel = el("div", "sl-epic-detail");
|
|
923
|
+
const p = entity.progress || {};
|
|
924
|
+
|
|
925
|
+
const hero = el("div", "sl-epic-hero");
|
|
926
|
+
const goals = el("div", "sl-epic-goals");
|
|
927
|
+
goals.innerHTML = `<div class="sl-epic-label">Goals</div>${
|
|
928
|
+
entity.goals
|
|
929
|
+
? `<div class="sl-epic-text">${escHtml(entity.goals)}</div>`
|
|
930
|
+
: `<div class="sl-epic-text empty">No goals set</div>`
|
|
931
|
+
}`;
|
|
932
|
+
hero.appendChild(goals);
|
|
933
|
+
|
|
934
|
+
const rollup = el("div", "sl-epic-rollup");
|
|
935
|
+
rollup.innerHTML = `
|
|
936
|
+
<span class="sl-chip"><strong>${p.tasks_total || 0}</strong> total</span>
|
|
937
|
+
<span class="sl-chip active"><strong>${p.tasks_active || 0}</strong> active</span>
|
|
938
|
+
<span class="sl-chip planned"><strong>${p.tasks_planned || 0}</strong> planned</span>
|
|
939
|
+
<span class="sl-chip icebox"><strong>${p.tasks_icebox || 0}</strong> icebox</span>
|
|
940
|
+
<span class="sl-chip done"><strong>${p.tasks_done || 0}</strong> done</span>
|
|
941
|
+
`;
|
|
942
|
+
hero.appendChild(rollup);
|
|
943
|
+
panel.appendChild(hero);
|
|
944
|
+
|
|
945
|
+
const body = el("div", "sl-epic-body");
|
|
946
|
+
|
|
947
|
+
const descCard = el("div", "sl-epic-card");
|
|
948
|
+
descCard.innerHTML = `<div class="sl-epic-label">Description</div>${
|
|
949
|
+
entity.description
|
|
950
|
+
? `<div class="sl-epic-text">${escHtml(entity.description)}</div>`
|
|
951
|
+
: `<div class="sl-epic-text empty">No description yet — click Edit epic to add context for agents.</div>`
|
|
952
|
+
}`;
|
|
953
|
+
body.appendChild(descCard);
|
|
954
|
+
|
|
955
|
+
const scopeCard = el("div", "sl-epic-card scope-pair");
|
|
956
|
+
scopeCard.innerHTML = `
|
|
957
|
+
<div>
|
|
958
|
+
<div class="sl-epic-label">In scope</div>
|
|
959
|
+
${epicListHtml(entity.in_scope, "Nothing listed", "")}
|
|
960
|
+
</div>
|
|
961
|
+
<div>
|
|
962
|
+
<div class="sl-epic-label">Out of scope</div>
|
|
963
|
+
${epicListHtml(entity.out_of_scope, "Nothing listed", "out")}
|
|
964
|
+
</div>
|
|
965
|
+
`;
|
|
966
|
+
body.appendChild(scopeCard);
|
|
967
|
+
panel.appendChild(body);
|
|
968
|
+
|
|
969
|
+
if (entity.notes) {
|
|
970
|
+
const notes = el("div", "sl-epic-card sl-epic-notes");
|
|
971
|
+
notes.innerHTML = `<div class="sl-epic-label">Notes</div><div class="sl-epic-text">${escHtml(entity.notes)}</div>`;
|
|
972
|
+
panel.appendChild(notes);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
shell.appendChild(panel);
|
|
976
|
+
return shell;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
function renderEpicDetailEdit(entity) {
|
|
980
|
+
const dirty = dirtyEpicEdits[entity.id] || {
|
|
981
|
+
title: entity.title || "",
|
|
982
|
+
description: entity.description || "",
|
|
983
|
+
goals: entity.goals || "",
|
|
984
|
+
in_scope: (entity.in_scope || []).join("\n"),
|
|
985
|
+
out_of_scope: (entity.out_of_scope || []).join("\n"),
|
|
986
|
+
notes: entity.notes || ""
|
|
987
|
+
};
|
|
988
|
+
dirtyEpicEdits[entity.id] = dirty;
|
|
989
|
+
|
|
990
|
+
const shell = el("div", "sl-epic-shell");
|
|
991
|
+
shell.onclick = e => e.stopPropagation();
|
|
992
|
+
|
|
993
|
+
const grid = el("div", "sl-epic-edit-grid");
|
|
994
|
+
|
|
995
|
+
const titleWrap = el("div", "span2");
|
|
996
|
+
titleWrap.innerHTML = `<label>Title</label>`;
|
|
997
|
+
const titleInp = document.createElement("input");
|
|
998
|
+
titleInp.value = dirty.title;
|
|
999
|
+
titleInp.oninput = () => { dirty.title = titleInp.value; dirtyEpicEdits[entity.id] = dirty; };
|
|
1000
|
+
titleWrap.appendChild(titleInp);
|
|
1001
|
+
grid.appendChild(titleWrap);
|
|
1002
|
+
|
|
1003
|
+
const goalsWrap = el("div", "span2");
|
|
1004
|
+
goalsWrap.innerHTML = `<label>Goals</label>`;
|
|
1005
|
+
const goalsTa = document.createElement("textarea");
|
|
1006
|
+
goalsTa.value = dirty.goals;
|
|
1007
|
+
goalsTa.placeholder = "Outcome one-liner…";
|
|
1008
|
+
goalsTa.oninput = () => { dirty.goals = goalsTa.value; dirtyEpicEdits[entity.id] = dirty; };
|
|
1009
|
+
goalsWrap.appendChild(goalsTa);
|
|
1010
|
+
grid.appendChild(goalsWrap);
|
|
1011
|
+
|
|
1012
|
+
const descWrap = el("div", "span2");
|
|
1013
|
+
descWrap.innerHTML = `<label>Description</label>`;
|
|
1014
|
+
const descTa = document.createElement("textarea");
|
|
1015
|
+
descTa.value = dirty.description;
|
|
1016
|
+
descTa.placeholder = "Context for agents…";
|
|
1017
|
+
descTa.style.minHeight = "72px";
|
|
1018
|
+
descTa.oninput = () => { dirty.description = descTa.value; dirtyEpicEdits[entity.id] = dirty; };
|
|
1019
|
+
descWrap.appendChild(descTa);
|
|
1020
|
+
grid.appendChild(descWrap);
|
|
1021
|
+
|
|
1022
|
+
const inWrap = el("div", "");
|
|
1023
|
+
inWrap.innerHTML = `<label>In scope (one per line)</label>`;
|
|
1024
|
+
const inTa = document.createElement("textarea");
|
|
1025
|
+
inTa.value = dirty.in_scope;
|
|
1026
|
+
inTa.oninput = () => { dirty.in_scope = inTa.value; dirtyEpicEdits[entity.id] = dirty; };
|
|
1027
|
+
inWrap.appendChild(inTa);
|
|
1028
|
+
grid.appendChild(inWrap);
|
|
1029
|
+
|
|
1030
|
+
const outWrap = el("div", "");
|
|
1031
|
+
outWrap.innerHTML = `<label>Out of scope (one per line)</label>`;
|
|
1032
|
+
const outTa = document.createElement("textarea");
|
|
1033
|
+
outTa.value = dirty.out_of_scope;
|
|
1034
|
+
outTa.oninput = () => { dirty.out_of_scope = outTa.value; dirtyEpicEdits[entity.id] = dirty; };
|
|
1035
|
+
outWrap.appendChild(outTa);
|
|
1036
|
+
grid.appendChild(outWrap);
|
|
1037
|
+
|
|
1038
|
+
const notesWrap = el("div", "span2");
|
|
1039
|
+
notesWrap.innerHTML = `<label>Notes</label>`;
|
|
1040
|
+
const notesTa = document.createElement("textarea");
|
|
1041
|
+
notesTa.value = dirty.notes;
|
|
1042
|
+
notesTa.oninput = () => { dirty.notes = notesTa.value; dirtyEpicEdits[entity.id] = dirty; };
|
|
1043
|
+
notesWrap.appendChild(notesTa);
|
|
1044
|
+
grid.appendChild(notesWrap);
|
|
1045
|
+
|
|
1046
|
+
const actions = el("div", "sl-epic-edit-actions");
|
|
1047
|
+
const saveBtn = document.createElement("button");
|
|
1048
|
+
saveBtn.className = "btn-sm btn-confirm";
|
|
1049
|
+
saveBtn.textContent = "✓ Save epic";
|
|
1050
|
+
saveBtn.onclick = async e => { e.stopPropagation(); await saveEpicEdit(entity.id); };
|
|
1051
|
+
const cancelBtn = document.createElement("button");
|
|
1052
|
+
cancelBtn.className = "btn-sm btn-cancel";
|
|
1053
|
+
cancelBtn.textContent = "Cancel";
|
|
1054
|
+
cancelBtn.onclick = e => {
|
|
1055
|
+
e.stopPropagation();
|
|
1056
|
+
delete dirtyEpicEdits[entity.id];
|
|
1057
|
+
editingEpicId = null;
|
|
1058
|
+
render();
|
|
1059
|
+
};
|
|
1060
|
+
actions.appendChild(saveBtn);
|
|
1061
|
+
actions.appendChild(cancelBtn);
|
|
1062
|
+
grid.appendChild(actions);
|
|
1063
|
+
|
|
1064
|
+
shell.appendChild(grid);
|
|
1065
|
+
return shell;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
async function saveEpicEdit(epicId) {
|
|
1069
|
+
const dirty = dirtyEpicEdits[epicId];
|
|
1070
|
+
if (!dirty) { editingEpicId = null; render(); return; }
|
|
1071
|
+
const res = await fetch(`/api/epic-entities/${enc(epicId)}`, {
|
|
1072
|
+
method: "PATCH",
|
|
1073
|
+
headers: { "Content-Type": "application/json" },
|
|
1074
|
+
body: JSON.stringify({
|
|
1075
|
+
title: dirty.title.trim(),
|
|
1076
|
+
description: dirty.description,
|
|
1077
|
+
goals: dirty.goals,
|
|
1078
|
+
in_scope: splitLines(dirty.in_scope),
|
|
1079
|
+
out_of_scope: splitLines(dirty.out_of_scope),
|
|
1080
|
+
notes: dirty.notes
|
|
1081
|
+
})
|
|
1082
|
+
});
|
|
1083
|
+
if (res.ok) {
|
|
1084
|
+
delete dirtyEpicEdits[epicId];
|
|
1085
|
+
editingEpicId = null;
|
|
1086
|
+
toast("Epic saved ✓");
|
|
1087
|
+
await load();
|
|
1088
|
+
} else {
|
|
1089
|
+
toast("Epic save failed", true);
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
604
1093
|
// ── Swimlane ──────────────────────────────────────────────────────────────────
|
|
605
1094
|
|
|
606
|
-
function
|
|
607
|
-
|
|
1095
|
+
function toggleSet(set, key) {
|
|
1096
|
+
if (set.has(key)) set.delete(key);
|
|
1097
|
+
else set.add(key);
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
function makeToggleBtn(label, isOn, onClick) {
|
|
1101
|
+
const btn = document.createElement("button");
|
|
1102
|
+
btn.type = "button";
|
|
1103
|
+
btn.className = `sl-toggle${isOn ? " on" : ""}`;
|
|
1104
|
+
btn.innerHTML = `<span class="chev">▶</span>${label}`;
|
|
1105
|
+
btn.onclick = e => { e.stopPropagation(); onClick(); };
|
|
1106
|
+
return btn;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
function renderSwimlane({ key, name, epicId, entity, tasks }) {
|
|
1110
|
+
const infoOpen = entity ? !collapsedInfo.has(key) : false;
|
|
1111
|
+
const tasksOpen = !collapsedTasks.has(key);
|
|
1112
|
+
const isEditingEpic = epicId && editingEpicId === epicId;
|
|
608
1113
|
|
|
609
1114
|
const wrap = el("div", "swimlane");
|
|
610
1115
|
|
|
611
|
-
|
|
612
|
-
const hdr = el("div", `sl-header${isCollapsed ? " collapsed" : ""}`);
|
|
1116
|
+
const hdr = el("div", "sl-header");
|
|
613
1117
|
const totalTasks = tasks.reduce((s, e) => s + (e.subtasks || []).length, 0);
|
|
614
1118
|
const doneTasks = tasks.reduce((s, e) => s + (e.subtasks || []).filter(t => t.done).length, 0);
|
|
615
1119
|
const pct = totalTasks > 0 ? Math.round(doneTasks / totalTasks * 100) : null;
|
|
616
1120
|
const cardsDone = tasks.filter(t => t.column === "done").length;
|
|
617
|
-
const
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
1121
|
+
const status = entity ? (entity.status || "empty") : null;
|
|
1122
|
+
|
|
1123
|
+
const main = el("div", "sl-header-main");
|
|
1124
|
+
const titleHtml = name === "—"
|
|
1125
|
+
? "Uncategorized (no epic)"
|
|
1126
|
+
: escHtml(name);
|
|
1127
|
+
main.innerHTML = `
|
|
1128
|
+
<span class="sl-name">${titleHtml}</span>
|
|
1129
|
+
${epicId ? `<span class="sl-id">${escHtml(epicId)}</span>` : ""}
|
|
1130
|
+
${status ? `<span class="sl-status ${escHtml(status)}">${escHtml(status)}</span>` : ""}
|
|
622
1131
|
<span class="sl-cnt">${tasks.length} cards · ${cardsDone} done</span>
|
|
623
|
-
${
|
|
624
|
-
${pct !== null ? `<span class="sl-progress" style="margin-left:auto">${pct}% subtasks</span>` : ""}
|
|
1132
|
+
${pct !== null ? `<span class="sl-progress">${pct}% subtasks</span>` : ""}
|
|
625
1133
|
`;
|
|
626
|
-
hdr.
|
|
627
|
-
|
|
628
|
-
|
|
1134
|
+
hdr.appendChild(main);
|
|
1135
|
+
|
|
1136
|
+
const toggles = el("div", "sl-toggles");
|
|
1137
|
+
|
|
1138
|
+
if (entity) {
|
|
1139
|
+
toggles.appendChild(makeToggleBtn("Info", infoOpen, () => {
|
|
1140
|
+
toggleSet(collapsedInfo, key);
|
|
1141
|
+
render();
|
|
1142
|
+
}));
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
toggles.appendChild(makeToggleBtn("Tasks", tasksOpen, () => {
|
|
1146
|
+
toggleSet(collapsedTasks, key);
|
|
629
1147
|
render();
|
|
630
|
-
};
|
|
1148
|
+
}));
|
|
1149
|
+
|
|
1150
|
+
if (epicId && entity) {
|
|
1151
|
+
const editBtn = document.createElement("button");
|
|
1152
|
+
editBtn.type = "button";
|
|
1153
|
+
editBtn.className = `sl-edit-btn${isEditingEpic ? " active" : ""}`;
|
|
1154
|
+
editBtn.textContent = isEditingEpic ? "Editing…" : "Edit";
|
|
1155
|
+
editBtn.onclick = e => {
|
|
1156
|
+
e.stopPropagation();
|
|
1157
|
+
if (editingEpicId === epicId) {
|
|
1158
|
+
editingEpicId = null;
|
|
1159
|
+
delete dirtyEpicEdits[epicId];
|
|
1160
|
+
} else {
|
|
1161
|
+
editingEpicId = epicId;
|
|
1162
|
+
collapsedInfo.delete(key);
|
|
1163
|
+
}
|
|
1164
|
+
render();
|
|
1165
|
+
};
|
|
1166
|
+
toggles.appendChild(editBtn);
|
|
1167
|
+
|
|
1168
|
+
const archBtn = document.createElement("button");
|
|
1169
|
+
archBtn.type = "button";
|
|
1170
|
+
archBtn.className = "sl-edit-btn";
|
|
1171
|
+
archBtn.textContent = entity.archived ? "Unarchive" : "Archive";
|
|
1172
|
+
archBtn.onclick = e => {
|
|
1173
|
+
e.stopPropagation();
|
|
1174
|
+
archiveEpic(epicId, !entity.archived);
|
|
1175
|
+
};
|
|
1176
|
+
toggles.appendChild(archBtn);
|
|
1177
|
+
|
|
1178
|
+
const delBtn = document.createElement("button");
|
|
1179
|
+
delBtn.type = "button";
|
|
1180
|
+
delBtn.className = "sl-edit-btn";
|
|
1181
|
+
delBtn.textContent = "Delete";
|
|
1182
|
+
delBtn.title = "Delete epic and all child tasks";
|
|
1183
|
+
delBtn.onclick = e => {
|
|
1184
|
+
e.stopPropagation();
|
|
1185
|
+
deleteEpicEntity(epicId, entity.title || name);
|
|
1186
|
+
};
|
|
1187
|
+
toggles.appendChild(delBtn);
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
hdr.appendChild(toggles);
|
|
631
1191
|
wrap.appendChild(hdr);
|
|
632
1192
|
|
|
633
|
-
if (
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
wrap.appendChild(meta);
|
|
1193
|
+
if (entity && (infoOpen || isEditingEpic)) {
|
|
1194
|
+
wrap.appendChild(
|
|
1195
|
+
isEditingEpic ? renderEpicDetailEdit(entity) : renderEpicDetailView(entity)
|
|
1196
|
+
);
|
|
638
1197
|
}
|
|
639
1198
|
|
|
640
|
-
|
|
641
|
-
const body = el("div", `sl-body${isCollapsed ? " hidden" : ""}`);
|
|
1199
|
+
const body = el("div", `sl-body${tasksOpen ? "" : " hidden"}`);
|
|
642
1200
|
COL_DEFS.forEach(col => {
|
|
643
1201
|
const lane = el("div", `lane col-${col.id}`);
|
|
644
1202
|
tasks.filter(e => e.column === col.id).forEach(e => lane.appendChild(renderCard(e, col.id)));
|
|
645
|
-
// Add button only in non-done columns
|
|
646
1203
|
if (col.id !== "done") {
|
|
647
1204
|
const addBtn = document.createElement("button");
|
|
648
1205
|
addBtn.className = "lane-add";
|
|
@@ -757,6 +1314,16 @@ function renderCard(epic, colId) {
|
|
|
757
1314
|
editBtn.onclick = e => { e.stopPropagation(); startEdit(epic.id); };
|
|
758
1315
|
actions.appendChild(editBtn);
|
|
759
1316
|
|
|
1317
|
+
const delBtn = document.createElement("button");
|
|
1318
|
+
delBtn.className = "cbtn";
|
|
1319
|
+
delBtn.textContent = "✕";
|
|
1320
|
+
delBtn.title = "Delete task";
|
|
1321
|
+
delBtn.onclick = e => {
|
|
1322
|
+
e.stopPropagation();
|
|
1323
|
+
deleteTaskCard(epic.id, displayTitle);
|
|
1324
|
+
};
|
|
1325
|
+
actions.appendChild(delBtn);
|
|
1326
|
+
|
|
760
1327
|
card.appendChild(actions);
|
|
761
1328
|
|
|
762
1329
|
// Details + subtasks (expanded, view mode)
|
|
@@ -1072,9 +1639,11 @@ function showLaneAdd(lane, colId, groupName, addBtn) {
|
|
|
1072
1639
|
inp.focus();
|
|
1073
1640
|
}
|
|
1074
1641
|
|
|
1075
|
-
// ── New epic
|
|
1642
|
+
// ── New task / epic forms (header) ────────────────────────────────────────────
|
|
1076
1643
|
|
|
1077
1644
|
function toggleNewForm() {
|
|
1645
|
+
const epicStrip = document.getElementById("new-epic-strip");
|
|
1646
|
+
if (epicStrip) epicStrip.classList.remove("open");
|
|
1078
1647
|
const strip = document.getElementById("new-strip");
|
|
1079
1648
|
strip.classList.toggle("open");
|
|
1080
1649
|
if (strip.classList.contains("open")) {
|
|
@@ -1082,6 +1651,16 @@ function toggleNewForm() {
|
|
|
1082
1651
|
}
|
|
1083
1652
|
}
|
|
1084
1653
|
|
|
1654
|
+
function toggleNewEpicForm() {
|
|
1655
|
+
const taskStrip = document.getElementById("new-strip");
|
|
1656
|
+
if (taskStrip) taskStrip.classList.remove("open");
|
|
1657
|
+
const strip = document.getElementById("new-epic-strip");
|
|
1658
|
+
strip.classList.toggle("open");
|
|
1659
|
+
if (strip.classList.contains("open")) {
|
|
1660
|
+
document.getElementById("nx-title").focus();
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1085
1664
|
async function submitNew() {
|
|
1086
1665
|
const title = document.getElementById("ne-title").value.trim();
|
|
1087
1666
|
const col = document.getElementById("ne-col").value;
|
|
@@ -1104,11 +1683,38 @@ async function submitNew() {
|
|
|
1104
1683
|
}
|
|
1105
1684
|
}
|
|
1106
1685
|
|
|
1686
|
+
async function submitNewEpic() {
|
|
1687
|
+
const title = document.getElementById("nx-title").value.trim();
|
|
1688
|
+
const description = document.getElementById("nx-description").value.trim();
|
|
1689
|
+
const goals = document.getElementById("nx-goals").value.trim();
|
|
1690
|
+
if (!title) { document.getElementById("nx-title").focus(); return; }
|
|
1691
|
+
|
|
1692
|
+
const res = await fetch("/api/epics", {
|
|
1693
|
+
method: "POST",
|
|
1694
|
+
headers: { "Content-Type": "application/json" },
|
|
1695
|
+
body: JSON.stringify({ title, description, goals }),
|
|
1696
|
+
});
|
|
1697
|
+
if (res.ok) {
|
|
1698
|
+
toast("Epic added ✓");
|
|
1699
|
+
document.getElementById("nx-title").value = "";
|
|
1700
|
+
document.getElementById("nx-description").value = "";
|
|
1701
|
+
document.getElementById("nx-goals").value = "";
|
|
1702
|
+
toggleNewEpicForm();
|
|
1703
|
+
await load();
|
|
1704
|
+
} else {
|
|
1705
|
+
toast("Error!", true);
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1107
1709
|
// Key handler for new form
|
|
1108
1710
|
document.getElementById("ne-title").addEventListener("keydown", e => {
|
|
1109
1711
|
if (e.key === "Enter") submitNew();
|
|
1110
1712
|
if (e.key === "Escape") toggleNewForm();
|
|
1111
1713
|
});
|
|
1714
|
+
document.getElementById("nx-title").addEventListener("keydown", e => {
|
|
1715
|
+
if (e.key === "Enter") submitNewEpic();
|
|
1716
|
+
if (e.key === "Escape") toggleNewEpicForm();
|
|
1717
|
+
});
|
|
1112
1718
|
|
|
1113
1719
|
// ── Utils ─────────────────────────────────────────────────────────────────────
|
|
1114
1720
|
|