kanbango 3.0.2 → 3.2.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 +1 -0
- package/.ai/retro/last-run.json +1 -1
- package/AGENTS.md +6 -1
- package/CHANGELOG.md +36 -0
- package/LLM_AGENTS.md +1 -1
- package/README.md +26 -4
- package/agent-playbook.js +22 -6
- package/bin/kanban.js +287 -23
- package/gui-registry.js +14 -1
- package/index.html +687 -51
- package/kanban.js +677 -7
- package/mcp-server.js +239 -32
- package/package.json +1 -1
- package/tests/run.js +2 -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;
|
|
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;
|
|
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;
|
|
180
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;
|
|
181
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>
|
|
@@ -529,30 +785,83 @@ const COL_DEFS = [
|
|
|
529
785
|
{ id: "done", label: "✅ Done", icon: "✅" },
|
|
530
786
|
];
|
|
531
787
|
|
|
532
|
-
let
|
|
788
|
+
let allTasks = [];
|
|
789
|
+
let allEpicEntities = [];
|
|
533
790
|
let colCounts = {};
|
|
534
|
-
|
|
791
|
+
// Separate collapse: epic info panel vs task board (default: info open, tasks open)
|
|
792
|
+
let collapsedInfo = new Set();
|
|
793
|
+
let collapsedTasks = new Set();
|
|
535
794
|
let expanded = new Set();
|
|
536
795
|
let editingId = null;
|
|
537
|
-
let dirtyEdits = {}; //
|
|
796
|
+
let dirtyEdits = {}; // taskId -> editable task draft
|
|
797
|
+
let editingEpicId = null;
|
|
798
|
+
let dirtyEpicEdits = {}; // epicId -> draft
|
|
799
|
+
let showArchived = false;
|
|
538
800
|
|
|
539
801
|
// ── Load & render ─────────────────────────────────────────────────────────────
|
|
540
802
|
|
|
541
803
|
async function load() {
|
|
542
|
-
const
|
|
543
|
-
|
|
804
|
+
const q = showArchived ? "?include_archived=true" : "";
|
|
805
|
+
const [boardRes, epicsRes] = await Promise.all([
|
|
806
|
+
fetch("/api/board" + q),
|
|
807
|
+
fetch("/api/epics" + q)
|
|
808
|
+
]);
|
|
809
|
+
allTasks = await boardRes.json();
|
|
810
|
+
allEpicEntities = epicsRes.ok ? await epicsRes.json() : [];
|
|
811
|
+
refreshEpicDatalist();
|
|
544
812
|
render();
|
|
545
813
|
}
|
|
546
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
|
+
|
|
547
855
|
function render() {
|
|
548
856
|
colCounts = {};
|
|
549
857
|
COL_DEFS.forEach(c => (colCounts[c.id] = 0));
|
|
550
|
-
|
|
858
|
+
allTasks.forEach(e => { if (colCounts[e.column] !== undefined) colCounts[e.column]++; });
|
|
551
859
|
|
|
552
860
|
// Header stats
|
|
553
|
-
const total =
|
|
554
|
-
const done =
|
|
555
|
-
|
|
861
|
+
const total = allTasks.length;
|
|
862
|
+
const done = allTasks.filter(e => e.column === "done").length;
|
|
863
|
+
const epicN = allEpicEntities.length;
|
|
864
|
+
document.getElementById("hstats").textContent = `${done}/${total} done · ${epicN} epics`;
|
|
556
865
|
|
|
557
866
|
// Col headers
|
|
558
867
|
const hdr = document.getElementById("col-headers");
|
|
@@ -567,59 +876,335 @@ function render() {
|
|
|
567
876
|
// Swimlanes
|
|
568
877
|
const sl = document.getElementById("swimlanes");
|
|
569
878
|
sl.innerHTML = "";
|
|
570
|
-
|
|
879
|
+
groupTasks(allTasks).forEach(g => sl.appendChild(renderSwimlane(g)));
|
|
571
880
|
}
|
|
572
881
|
|
|
573
|
-
function
|
|
882
|
+
function groupTasks(tasks) {
|
|
574
883
|
const map = new Map();
|
|
575
|
-
|
|
576
|
-
const
|
|
577
|
-
if (!map.has(
|
|
578
|
-
map.get(
|
|
884
|
+
tasks.forEach(t => {
|
|
885
|
+
const key = t.epic_id || "—";
|
|
886
|
+
if (!map.has(key)) map.set(key, []);
|
|
887
|
+
map.get(key).push(t);
|
|
888
|
+
});
|
|
889
|
+
// Include epics that have no tasks yet
|
|
890
|
+
allEpicEntities.forEach(e => {
|
|
891
|
+
if (!map.has(e.id)) map.set(e.id, []);
|
|
579
892
|
});
|
|
580
|
-
|
|
893
|
+
|
|
581
894
|
return [...map.entries()]
|
|
582
895
|
.sort(([a], [b]) => a === "—" ? 1 : b === "—" ? -1 : a.localeCompare(b, "pl"))
|
|
583
|
-
.map(([
|
|
896
|
+
.map(([key, laneTasks]) => {
|
|
897
|
+
const entity = allEpicEntities.find(e => e.id === key) || null;
|
|
898
|
+
const name = key === "—"
|
|
899
|
+
? "—"
|
|
900
|
+
: (entity ? entity.title : (laneTasks[0] && laneTasks[0].epic_group) || key);
|
|
901
|
+
return {
|
|
902
|
+
key,
|
|
903
|
+
name,
|
|
904
|
+
epicId: key === "—" ? null : key,
|
|
905
|
+
entity,
|
|
906
|
+
tasks: laneTasks
|
|
907
|
+
};
|
|
908
|
+
});
|
|
909
|
+
}
|
|
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
|
+
}
|
|
584
1091
|
}
|
|
585
1092
|
|
|
586
1093
|
// ── Swimlane ──────────────────────────────────────────────────────────────────
|
|
587
1094
|
|
|
588
|
-
function
|
|
589
|
-
|
|
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;
|
|
590
1113
|
|
|
591
1114
|
const wrap = el("div", "swimlane");
|
|
592
1115
|
|
|
593
|
-
|
|
594
|
-
const
|
|
595
|
-
const
|
|
596
|
-
const doneTasks = epics.reduce((s, e) => s + e.subtasks.filter(t => t.done).length, 0);
|
|
1116
|
+
const hdr = el("div", "sl-header");
|
|
1117
|
+
const totalTasks = tasks.reduce((s, e) => s + (e.subtasks || []).length, 0);
|
|
1118
|
+
const doneTasks = tasks.reduce((s, e) => s + (e.subtasks || []).filter(t => t.done).length, 0);
|
|
597
1119
|
const pct = totalTasks > 0 ? Math.round(doneTasks / totalTasks * 100) : null;
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
1120
|
+
const cardsDone = tasks.filter(t => t.column === "done").length;
|
|
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>` : ""}
|
|
1131
|
+
<span class="sl-cnt">${tasks.length} cards · ${cardsDone} done</span>
|
|
1132
|
+
${pct !== null ? `<span class="sl-progress">${pct}% subtasks</span>` : ""}
|
|
604
1133
|
`;
|
|
605
|
-
hdr.
|
|
606
|
-
|
|
607
|
-
|
|
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);
|
|
608
1147
|
render();
|
|
609
|
-
};
|
|
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);
|
|
610
1191
|
wrap.appendChild(hdr);
|
|
611
1192
|
|
|
612
|
-
|
|
613
|
-
|
|
1193
|
+
if (entity && (infoOpen || isEditingEpic)) {
|
|
1194
|
+
wrap.appendChild(
|
|
1195
|
+
isEditingEpic ? renderEpicDetailEdit(entity) : renderEpicDetailView(entity)
|
|
1196
|
+
);
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
const body = el("div", `sl-body${tasksOpen ? "" : " hidden"}`);
|
|
614
1200
|
COL_DEFS.forEach(col => {
|
|
615
1201
|
const lane = el("div", `lane col-${col.id}`);
|
|
616
|
-
|
|
617
|
-
// Add button only in non-done columns
|
|
1202
|
+
tasks.filter(e => e.column === col.id).forEach(e => lane.appendChild(renderCard(e, col.id)));
|
|
618
1203
|
if (col.id !== "done") {
|
|
619
1204
|
const addBtn = document.createElement("button");
|
|
620
1205
|
addBtn.className = "lane-add";
|
|
621
1206
|
addBtn.textContent = "+ Add here";
|
|
622
|
-
addBtn.onclick = e => { e.stopPropagation(); showLaneAdd(lane, col.id, name, addBtn); };
|
|
1207
|
+
addBtn.onclick = e => { e.stopPropagation(); showLaneAdd(lane, col.id, epicId || name, addBtn); };
|
|
623
1208
|
lane.appendChild(addBtn);
|
|
624
1209
|
}
|
|
625
1210
|
body.appendChild(lane);
|
|
@@ -729,6 +1314,16 @@ function renderCard(epic, colId) {
|
|
|
729
1314
|
editBtn.onclick = e => { e.stopPropagation(); startEdit(epic.id); };
|
|
730
1315
|
actions.appendChild(editBtn);
|
|
731
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
|
+
|
|
732
1327
|
card.appendChild(actions);
|
|
733
1328
|
|
|
734
1329
|
// Details + subtasks (expanded, view mode)
|
|
@@ -931,7 +1526,7 @@ async function saveEdit(epicId) {
|
|
|
931
1526
|
if (!dirty) { editingId = null; render(); return; }
|
|
932
1527
|
|
|
933
1528
|
const body = {};
|
|
934
|
-
const epic =
|
|
1529
|
+
const epic = allTasks.find(e => e.id === epicId);
|
|
935
1530
|
if (epic) {
|
|
936
1531
|
const origTitle = epic.title.replace(/^[\w.-]+:\s*/, "");
|
|
937
1532
|
if (dirty.title !== origTitle) body.title = dirty.title;
|
|
@@ -1007,13 +1602,15 @@ function showLaneAdd(lane, colId, groupName, addBtn) {
|
|
|
1007
1602
|
if (!title) return;
|
|
1008
1603
|
inp.disabled = true;
|
|
1009
1604
|
description.disabled = true;
|
|
1010
|
-
const
|
|
1605
|
+
const epicRef = groupName === "—" ? "" : groupName;
|
|
1606
|
+
const res = await fetch("/api/tasks", {
|
|
1011
1607
|
method: "POST",
|
|
1012
1608
|
headers: { "Content-Type": "application/json" },
|
|
1013
1609
|
body: JSON.stringify({
|
|
1014
1610
|
title,
|
|
1015
1611
|
column: colId,
|
|
1016
|
-
|
|
1612
|
+
epic: epicRef,
|
|
1613
|
+
epic_id: epicRef && /^E\d+/i.test(epicRef) ? epicRef : undefined,
|
|
1017
1614
|
description: description.value.trim()
|
|
1018
1615
|
}),
|
|
1019
1616
|
});
|
|
@@ -1042,9 +1639,11 @@ function showLaneAdd(lane, colId, groupName, addBtn) {
|
|
|
1042
1639
|
inp.focus();
|
|
1043
1640
|
}
|
|
1044
1641
|
|
|
1045
|
-
// ── New epic
|
|
1642
|
+
// ── New task / epic forms (header) ────────────────────────────────────────────
|
|
1046
1643
|
|
|
1047
1644
|
function toggleNewForm() {
|
|
1645
|
+
const epicStrip = document.getElementById("new-epic-strip");
|
|
1646
|
+
if (epicStrip) epicStrip.classList.remove("open");
|
|
1048
1647
|
const strip = document.getElementById("new-strip");
|
|
1049
1648
|
strip.classList.toggle("open");
|
|
1050
1649
|
if (strip.classList.contains("open")) {
|
|
@@ -1052,16 +1651,26 @@ function toggleNewForm() {
|
|
|
1052
1651
|
}
|
|
1053
1652
|
}
|
|
1054
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
|
+
|
|
1055
1664
|
async function submitNew() {
|
|
1056
1665
|
const title = document.getElementById("ne-title").value.trim();
|
|
1057
1666
|
const col = document.getElementById("ne-col").value;
|
|
1058
1667
|
const group = document.getElementById("ne-group").value.trim();
|
|
1059
1668
|
if (!title) { document.getElementById("ne-title").focus(); return; }
|
|
1060
1669
|
|
|
1061
|
-
const res = await fetch("/api/
|
|
1670
|
+
const res = await fetch("/api/tasks", {
|
|
1062
1671
|
method: "POST",
|
|
1063
1672
|
headers: { "Content-Type": "application/json" },
|
|
1064
|
-
body: JSON.stringify({ title, column: col,
|
|
1673
|
+
body: JSON.stringify({ title, column: col, epic: group || "—" }),
|
|
1065
1674
|
});
|
|
1066
1675
|
if (res.ok) {
|
|
1067
1676
|
toast("Task added ✓");
|
|
@@ -1074,11 +1683,38 @@ async function submitNew() {
|
|
|
1074
1683
|
}
|
|
1075
1684
|
}
|
|
1076
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
|
+
|
|
1077
1709
|
// Key handler for new form
|
|
1078
1710
|
document.getElementById("ne-title").addEventListener("keydown", e => {
|
|
1079
1711
|
if (e.key === "Enter") submitNew();
|
|
1080
1712
|
if (e.key === "Escape") toggleNewForm();
|
|
1081
1713
|
});
|
|
1714
|
+
document.getElementById("nx-title").addEventListener("keydown", e => {
|
|
1715
|
+
if (e.key === "Enter") submitNewEpic();
|
|
1716
|
+
if (e.key === "Escape") toggleNewEpicForm();
|
|
1717
|
+
});
|
|
1082
1718
|
|
|
1083
1719
|
// ── Utils ─────────────────────────────────────────────────────────────────────
|
|
1084
1720
|
|