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/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: 7px 12px;
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:hover { background: #f1f5f9; }
167
- .sl-arrow { font-size: 10px; color: var(--muted); transition: transform 0.15s; }
168
- .sl-header.collapsed .sl-arrow { transform: rotate(-90deg); }
169
- .sl-name { font-size: 12px; font-weight: 600; color: var(--text); }
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="Epic group (optional)…" autocomplete="off">
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 allEpics = [];
788
+ let allTasks = [];
789
+ let allEpicEntities = [];
533
790
  let colCounts = {};
534
- let collapsed = new Set();
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 = {}; // epicId -> editable task draft
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 res = await fetch("/api/board");
543
- allEpics = await res.json();
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
- allEpics.forEach(e => { if (colCounts[e.column] !== undefined) colCounts[e.column]++; });
858
+ allTasks.forEach(e => { if (colCounts[e.column] !== undefined) colCounts[e.column]++; });
551
859
 
552
860
  // Header stats
553
- const total = allEpics.length;
554
- const done = allEpics.filter(e => e.column === "done").length;
555
- document.getElementById("hstats").textContent = `${done}/${total} done`;
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
- groupEpics(allEpics).forEach(g => sl.appendChild(renderSwimlane(g)));
879
+ groupTasks(allTasks).forEach(g => sl.appendChild(renderSwimlane(g)));
571
880
  }
572
881
 
573
- function groupEpics(epics) {
882
+ function groupTasks(tasks) {
574
883
  const map = new Map();
575
- epics.forEach(e => {
576
- const g = e.epic_group || "—";
577
- if (!map.has(g)) map.set(g, []);
578
- map.get(g).push(e);
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
- // Sort: known phases first, "—" last
893
+
581
894
  return [...map.entries()]
582
895
  .sort(([a], [b]) => a === "—" ? 1 : b === "—" ? -1 : a.localeCompare(b, "pl"))
583
- .map(([name, epics]) => ({ name, epics }));
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 renderSwimlane({ name, epics }) {
589
- const isCollapsed = collapsed.has(name);
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
- // Header
594
- const hdr = el("div", `sl-header${isCollapsed ? " collapsed" : ""}`);
595
- const totalTasks = epics.reduce((s, e) => s + e.subtasks.length, 0);
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
- hdr.innerHTML = `
600
- <span class="sl-arrow">▼</span>
601
- <span class="sl-name">${escHtml(name === "" ? "Uncategorized (no epic)" : name)}</span>
602
- <span class="sl-cnt">${epics.length}</span>
603
- ${pct !== null ? `<span class="sl-progress" style="margin-left:auto">${pct}% tasks done</span>` : ""}
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.onclick = () => {
606
- if (collapsed.has(name)) collapsed.delete(name);
607
- else collapsed.add(name);
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
- // Body
613
- const body = el("div", `sl-body${isCollapsed ? " hidden" : ""}`);
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
- epics.filter(e => e.column === col.id).forEach(e => lane.appendChild(renderCard(e, col.id)));
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 = allEpics.find(e => e.id === epicId);
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 res = await fetch("/api/epics", {
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
- epic_group: groupName === "—" ? "" : groupName,
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 form (header) ────────────────────────────────────────────────────
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/epics", {
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, epic_group: group || "—" }),
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