kanbango 2.1.0 → 2.5.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 +0 -0
- package/.ai/retro/close.json +1 -0
- package/.ai/retro/last-run.json +1 -0
- package/AGENTS.md +7 -7
- package/API.md +20 -12
- package/CHANGELOG.md +39 -0
- package/LLM_AGENTS.md +170 -177
- package/README.md +31 -14
- package/bin/kanban.js +101 -26
- package/index.html +162 -85
- package/index.js +2 -0
- package/kan2.md +76 -0
- package/kanban.js +306 -34
- package/mcp-server.js +403 -185
- package/package.json +1 -1
- package/plan.js +159 -0
- package/planv2.md +17 -7
- package/tests/run.js +1 -0
package/bin/kanban.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
const kanban = require('../kanban.js');
|
|
4
|
+
const plan = require('../plan.js');
|
|
4
5
|
const http = require('http');
|
|
5
6
|
const fs = require('fs');
|
|
6
7
|
const path = require('path');
|
|
@@ -9,7 +10,7 @@ const BACKLOG = path.join(process.cwd(), 'backlog');
|
|
|
9
10
|
const COLS = kanban.COLS;
|
|
10
11
|
|
|
11
12
|
function shortId(taskId) {
|
|
12
|
-
const match = taskId.match(/^(
|
|
13
|
+
const match = taskId.match(/^(?:[A-Z]+-)?(\d+)/);
|
|
13
14
|
return match ? match[1] : taskId;
|
|
14
15
|
}
|
|
15
16
|
|
|
@@ -222,21 +223,6 @@ async function cliAdd(title, column, epicGroup) {
|
|
|
222
223
|
}
|
|
223
224
|
}
|
|
224
225
|
|
|
225
|
-
async function cliToggle(taskId, idx) {
|
|
226
|
-
const success = await kanban.doToggle(taskId, idx);
|
|
227
|
-
if (!success) {
|
|
228
|
-
console.error(`✗ Nie znaleziono: ${taskId} subtask ${idx}`);
|
|
229
|
-
process.exit(1);
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
const task = await kanban.getTask(taskId);
|
|
233
|
-
if (idx < task.subtasks.length) {
|
|
234
|
-
const subtask = task.subtasks[idx];
|
|
235
|
-
const mark = subtask.done ? '✓' : '○';
|
|
236
|
-
console.log(` [${idx}] ${mark} ${subtask.text}`);
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
226
|
async function serveWeb(port) {
|
|
241
227
|
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf-8');
|
|
242
228
|
|
|
@@ -262,7 +248,10 @@ async function serveWeb(port) {
|
|
|
262
248
|
const task = await kanban.doCreate(body.title || '', body.column || 'planned', body.epic_group || '—', {
|
|
263
249
|
description: body.description,
|
|
264
250
|
specs: body.specs,
|
|
251
|
+
in_scope: body.in_scope,
|
|
252
|
+
out_of_scope: body.out_of_scope,
|
|
265
253
|
acceptance_criteria: body.acceptance_criteria,
|
|
254
|
+
test_cases: body.test_cases,
|
|
266
255
|
subtasks: body.subtasks,
|
|
267
256
|
notes: body.notes
|
|
268
257
|
});
|
|
@@ -312,10 +301,12 @@ async function serveWeb(port) {
|
|
|
312
301
|
const patch = body.patch ? { ...body.patch } : {};
|
|
313
302
|
|
|
314
303
|
if (body.title !== undefined) patch.title = body.title;
|
|
315
|
-
if (body.tasks !== undefined) patch.subtasks = body.tasks;
|
|
316
304
|
if (body.description !== undefined) patch.description = body.description;
|
|
317
305
|
if (body.specs !== undefined) patch.specs = body.specs;
|
|
306
|
+
if (body.in_scope !== undefined) patch.in_scope = body.in_scope;
|
|
307
|
+
if (body.out_of_scope !== undefined) patch.out_of_scope = body.out_of_scope;
|
|
318
308
|
if (body.acceptance_criteria !== undefined) patch.acceptance_criteria = body.acceptance_criteria;
|
|
309
|
+
if (body.test_cases !== undefined) patch.test_cases = body.test_cases;
|
|
319
310
|
if (body.subtasks !== undefined) patch.subtasks = body.subtasks;
|
|
320
311
|
if (body.notes !== undefined) patch.notes = body.notes;
|
|
321
312
|
if (body.epic_group !== undefined) patch.epic_group = body.epic_group;
|
|
@@ -332,11 +323,66 @@ async function serveWeb(port) {
|
|
|
332
323
|
}
|
|
333
324
|
});
|
|
334
325
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
326
|
+
const MAX_ATTEMPTS = 10;
|
|
327
|
+
|
|
328
|
+
function listenOnce(server, port) {
|
|
329
|
+
return new Promise((resolve, reject) => {
|
|
330
|
+
function onError(err) {
|
|
331
|
+
server.removeListener('listening', onListening);
|
|
332
|
+
reject(err);
|
|
333
|
+
}
|
|
334
|
+
function onListening() {
|
|
335
|
+
server.removeListener('error', onError);
|
|
336
|
+
resolve();
|
|
337
|
+
}
|
|
338
|
+
server.once('error', onError);
|
|
339
|
+
server.once('listening', onListening);
|
|
340
|
+
server.listen(port, 'localhost');
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
|
345
|
+
try {
|
|
346
|
+
await listenOnce(server, port + attempt);
|
|
347
|
+
break;
|
|
348
|
+
} catch (err) {
|
|
349
|
+
if (err.code !== 'EADDRINUSE') throw err;
|
|
350
|
+
console.log(`Port ${port + attempt} zajęty, próbuję ${port + attempt + 1}…`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (!server.listening) {
|
|
355
|
+
console.log(`Porty ${port}–${port + MAX_ATTEMPTS - 1} zajęte, próbuję losowy port…`);
|
|
356
|
+
await listenOnce(server, 0);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const actualPort = server.address().port;
|
|
360
|
+
const portInfo = await kanban.writeGuiPortFile({ port: actualPort, pid: process.pid });
|
|
361
|
+
|
|
362
|
+
async function cleanupGuiPortFile() {
|
|
363
|
+
try {
|
|
364
|
+
await kanban.clearGuiPortFile({ pid: process.pid });
|
|
365
|
+
} catch {
|
|
366
|
+
// best-effort cleanup
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
server.on('close', () => {
|
|
371
|
+
cleanupGuiPortFile();
|
|
339
372
|
});
|
|
373
|
+
|
|
374
|
+
process.once('SIGINT', async () => {
|
|
375
|
+
await cleanupGuiPortFile();
|
|
376
|
+
server.close(() => process.exit(0));
|
|
377
|
+
});
|
|
378
|
+
process.once('SIGTERM', async () => {
|
|
379
|
+
await cleanupGuiPortFile();
|
|
380
|
+
server.close(() => process.exit(0));
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
console.log(`\x1b[1;32m→ Kanban GUI: ${portInfo.url}\x1b[0m`);
|
|
384
|
+
console.log(` Backlog: ${BACKLOG}`);
|
|
385
|
+
console.log(' Ctrl+C żeby zamknąć');
|
|
340
386
|
}
|
|
341
387
|
|
|
342
388
|
function readBody(req) {
|
|
@@ -379,13 +425,42 @@ async function cliMigrate(dryRun) {
|
|
|
379
425
|
}
|
|
380
426
|
}
|
|
381
427
|
|
|
428
|
+
function parseJsonPayload(value) {
|
|
429
|
+
try {
|
|
430
|
+
return JSON.parse(value || '{}');
|
|
431
|
+
} catch (error) {
|
|
432
|
+
throw kanban.createKanbanError('INVALID_JSON', 'Payload is not valid JSON',
|
|
433
|
+
'Pass a JSON object after --json', { reason: error.message }, false, 400);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async function cliPlan(action, payload) {
|
|
438
|
+
try {
|
|
439
|
+
const handlers = {
|
|
440
|
+
create: plan.create,
|
|
441
|
+
advance: plan.advance,
|
|
442
|
+
evidence: plan.evidence,
|
|
443
|
+
done: plan.done,
|
|
444
|
+
status: (input) => plan.status(input.task_id)
|
|
445
|
+
};
|
|
446
|
+
if (!handlers[action]) throw kanban.createKanbanError('INVALID_PLAN_ACTION', `Unknown plan action: ${action}`,
|
|
447
|
+
'Use create, advance, evidence, done, or status', { action }, false, 400);
|
|
448
|
+
console.log(JSON.stringify(await handlers[action](payload)));
|
|
449
|
+
} catch (error) {
|
|
450
|
+
console.log(JSON.stringify({ ok: false, task_id: payload.task_id || null, subtasks: [], error: {
|
|
451
|
+
code: error.code || 'INTERNAL_ERROR', message: error.message, hint: error.hint || '', details: error.details || {}
|
|
452
|
+
} }));
|
|
453
|
+
process.exitCode = 1;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
382
457
|
async function main() {
|
|
383
458
|
const args = process.argv.slice(2);
|
|
384
459
|
const cmd = args[0];
|
|
385
460
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
461
|
+
if (!cmd || cmd === 'serve') {
|
|
462
|
+
const port = kanban.resolvePreferredGuiPort(args[1]);
|
|
463
|
+
await serveWeb(port);
|
|
389
464
|
} else if (cmd === 'init') {
|
|
390
465
|
await cliInit();
|
|
391
466
|
} else if (cmd === 'mcp-init') {
|
|
@@ -448,8 +523,8 @@ async function main() {
|
|
|
448
523
|
}
|
|
449
524
|
|
|
450
525
|
await cliAdd(args[1], column, epicGroup);
|
|
451
|
-
} else if (cmd === '
|
|
452
|
-
await
|
|
526
|
+
} else if (cmd === 'plan' && args[1] && args[2] === '--json') {
|
|
527
|
+
await cliPlan(args[1], parseJsonPayload(args[3]));
|
|
453
528
|
} else {
|
|
454
529
|
console.error('Unknown command:', cmd);
|
|
455
530
|
process.exit(1);
|
package/index.html
CHANGED
|
@@ -71,6 +71,15 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
|
|
|
71
71
|
flex-shrink: 0;
|
|
72
72
|
}
|
|
73
73
|
.new-epic-strip.open { display: flex; }
|
|
74
|
+
.ne-field { display: flex; flex-direction: column; gap: 3px; }
|
|
75
|
+
.ne-label {
|
|
76
|
+
font-size: 10px;
|
|
77
|
+
font-weight: 700;
|
|
78
|
+
text-transform: uppercase;
|
|
79
|
+
letter-spacing: 0.6px;
|
|
80
|
+
color: var(--muted);
|
|
81
|
+
}
|
|
82
|
+
.ne-buttons { display: flex; gap: 8px; align-items: flex-end; }
|
|
74
83
|
.ne-input {
|
|
75
84
|
border: 1px solid var(--border);
|
|
76
85
|
border-radius: var(--radius);
|
|
@@ -378,6 +387,20 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
|
|
|
378
387
|
line-height: 1.45;
|
|
379
388
|
color: var(--text);
|
|
380
389
|
}
|
|
390
|
+
.detail-section.out-of-scope {
|
|
391
|
+
border-left: 3px solid #e8b4b4;
|
|
392
|
+
padding-left: 8px;
|
|
393
|
+
}
|
|
394
|
+
.detail-section.out-of-scope .detail-label {
|
|
395
|
+
color: #a86a6a;
|
|
396
|
+
}
|
|
397
|
+
.detail-section.out-of-scope .detail-list {
|
|
398
|
+
color: #7a5a5a;
|
|
399
|
+
}
|
|
400
|
+
.edit-block.out-of-scope {
|
|
401
|
+
border-color: #e8c4c4;
|
|
402
|
+
background: #fffbfb;
|
|
403
|
+
}
|
|
381
404
|
.edit-meta {
|
|
382
405
|
display: flex;
|
|
383
406
|
flex-direction: column;
|
|
@@ -396,6 +419,13 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
|
|
|
396
419
|
flex-direction: column;
|
|
397
420
|
gap: 6px;
|
|
398
421
|
}
|
|
422
|
+
.lane-add-label {
|
|
423
|
+
font-size: 10px;
|
|
424
|
+
font-weight: 700;
|
|
425
|
+
text-transform: uppercase;
|
|
426
|
+
letter-spacing: 0.6px;
|
|
427
|
+
color: var(--muted);
|
|
428
|
+
}
|
|
399
429
|
.lane-add-title {
|
|
400
430
|
width: 100%;
|
|
401
431
|
border: 1px solid var(--planned);
|
|
@@ -457,20 +487,31 @@ header h1 { font-size: 14px; font-weight: 700; letter-spacing: -0.2px; }
|
|
|
457
487
|
<h1>Donna Kanban</h1>
|
|
458
488
|
<span class="hstats" id="hstats">—</span>
|
|
459
489
|
<span class="hspacer"></span>
|
|
460
|
-
<button class="btn-new" id="btn-new" onclick="toggleNewForm()">+
|
|
490
|
+
<button class="btn-new" id="btn-new" onclick="toggleNewForm()">+ New task</button>
|
|
461
491
|
</header>
|
|
462
492
|
|
|
463
493
|
<div class="new-epic-strip" id="new-strip">
|
|
464
|
-
<
|
|
465
|
-
|
|
466
|
-
<
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
<
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
494
|
+
<div class="ne-field">
|
|
495
|
+
<div class="ne-label">Title</div>
|
|
496
|
+
<input class="ne-input ne-title" id="ne-title" placeholder="Task name…" autocomplete="off">
|
|
497
|
+
</div>
|
|
498
|
+
<div class="ne-field">
|
|
499
|
+
<div class="ne-label">Column</div>
|
|
500
|
+
<select class="ne-input ne-col" id="ne-col">
|
|
501
|
+
<option value="icebox">🧊 Icebox</option>
|
|
502
|
+
<option value="planned" selected>📦 Planned</option>
|
|
503
|
+
<option value="active">🔥 Active</option>
|
|
504
|
+
<option value="done">✅ Done</option>
|
|
505
|
+
</select>
|
|
506
|
+
</div>
|
|
507
|
+
<div class="ne-field">
|
|
508
|
+
<div class="ne-label">Epic</div>
|
|
509
|
+
<input class="ne-input ne-group" id="ne-group" placeholder="Epic group (optional)…" autocomplete="off">
|
|
510
|
+
</div>
|
|
511
|
+
<div class="ne-buttons">
|
|
512
|
+
<button class="btn-sm btn-confirm" onclick="submitNew()">Add</button>
|
|
513
|
+
<button class="btn-sm btn-cancel" onclick="toggleNewForm()">Cancel</button>
|
|
514
|
+
</div>
|
|
474
515
|
</div>
|
|
475
516
|
|
|
476
517
|
<div id="board-wrap">
|
|
@@ -511,7 +552,7 @@ function render() {
|
|
|
511
552
|
// Header stats
|
|
512
553
|
const total = allEpics.length;
|
|
513
554
|
const done = allEpics.filter(e => e.column === "done").length;
|
|
514
|
-
document.getElementById("hstats").textContent = `${done}/${total}
|
|
555
|
+
document.getElementById("hstats").textContent = `${done}/${total} done`;
|
|
515
556
|
|
|
516
557
|
// Col headers
|
|
517
558
|
const hdr = document.getElementById("col-headers");
|
|
@@ -551,15 +592,15 @@ function renderSwimlane({ name, epics }) {
|
|
|
551
592
|
|
|
552
593
|
// Header
|
|
553
594
|
const hdr = el("div", `sl-header${isCollapsed ? " collapsed" : ""}`);
|
|
554
|
-
const totalTasks = epics.reduce((s, e) => s + e.
|
|
555
|
-
const doneTasks = epics.reduce((s, e) => s + e.
|
|
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);
|
|
556
597
|
const pct = totalTasks > 0 ? Math.round(doneTasks / totalTasks * 100) : null;
|
|
557
598
|
|
|
558
599
|
hdr.innerHTML = `
|
|
559
600
|
<span class="sl-arrow">▼</span>
|
|
560
|
-
<span class="sl-name">${escHtml(name === "—" ? "
|
|
601
|
+
<span class="sl-name">${escHtml(name === "—" ? "Uncategorized (no epic)" : name)}</span>
|
|
561
602
|
<span class="sl-cnt">${epics.length}</span>
|
|
562
|
-
${pct !== null ? `<span class="sl-progress" style="margin-left:auto">${pct}%
|
|
603
|
+
${pct !== null ? `<span class="sl-progress" style="margin-left:auto">${pct}% tasks done</span>` : ""}
|
|
563
604
|
`;
|
|
564
605
|
hdr.onclick = () => {
|
|
565
606
|
if (collapsed.has(name)) collapsed.delete(name);
|
|
@@ -577,7 +618,7 @@ function renderSwimlane({ name, epics }) {
|
|
|
577
618
|
if (col.id !== "done") {
|
|
578
619
|
const addBtn = document.createElement("button");
|
|
579
620
|
addBtn.className = "lane-add";
|
|
580
|
-
addBtn.textContent = "+
|
|
621
|
+
addBtn.textContent = "+ Add here";
|
|
581
622
|
addBtn.onclick = e => { e.stopPropagation(); showLaneAdd(lane, col.id, name, addBtn); };
|
|
582
623
|
lane.appendChild(addBtn);
|
|
583
624
|
}
|
|
@@ -613,8 +654,8 @@ function renderCard(epic, colId) {
|
|
|
613
654
|
const card = el("div", `card col-${colId}${isExpanded ? " open" : ""}${isEditing ? " editing" : ""}`);
|
|
614
655
|
card.dataset.id = epic.id;
|
|
615
656
|
|
|
616
|
-
const totalT = epic.
|
|
617
|
-
const doneT = epic.
|
|
657
|
+
const totalT = epic.subtasks.length;
|
|
658
|
+
const doneT = epic.subtasks.filter(t => t.done).length;
|
|
618
659
|
const pct = totalT > 0 ? Math.round(doneT / totalT * 100) : 0;
|
|
619
660
|
const shortId = (epic.id.match(/^PI-\d+/) || [epic.id])[0];
|
|
620
661
|
const displayTitle = epic.title.replace(/^[\w.-]+:\s*/, "");
|
|
@@ -646,7 +687,7 @@ function renderCard(epic, colId) {
|
|
|
646
687
|
// For icebox show just a count badge
|
|
647
688
|
const badge = el("span", "prog-txt");
|
|
648
689
|
badge.style.cssText = "display:inline-block;margin-top:5px;background:rgba(100,116,139,0.12);border-radius:4px;padding:1px 6px;";
|
|
649
|
-
badge.textContent = `${totalT}
|
|
690
|
+
badge.textContent = `${totalT} subtasks`;
|
|
650
691
|
card.appendChild(badge);
|
|
651
692
|
}
|
|
652
693
|
|
|
@@ -673,57 +714,57 @@ function renderCard(epic, colId) {
|
|
|
673
714
|
actions.appendChild(rb);
|
|
674
715
|
}
|
|
675
716
|
|
|
676
|
-
// Expand
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
actions.appendChild(expBtn);
|
|
683
|
-
}
|
|
717
|
+
// Expand details button
|
|
718
|
+
const expBtn = document.createElement("button");
|
|
719
|
+
expBtn.className = "cbtn";
|
|
720
|
+
expBtn.textContent = isExpanded ? "▲" : "▼ Details";
|
|
721
|
+
expBtn.onclick = e => { e.stopPropagation(); toggleExpand(epic.id); };
|
|
722
|
+
actions.appendChild(expBtn);
|
|
684
723
|
|
|
685
724
|
// Edit button
|
|
686
725
|
const editBtn = document.createElement("button");
|
|
687
726
|
editBtn.className = "cbtn edit-btn";
|
|
688
727
|
editBtn.textContent = "✎";
|
|
689
|
-
editBtn.title = "
|
|
728
|
+
editBtn.title = "Edit";
|
|
690
729
|
editBtn.onclick = e => { e.stopPropagation(); startEdit(epic.id); };
|
|
691
730
|
actions.appendChild(editBtn);
|
|
692
731
|
|
|
693
732
|
card.appendChild(actions);
|
|
694
733
|
|
|
695
|
-
//
|
|
696
|
-
if (isExpanded
|
|
697
|
-
const lbl = el("div", "subtask-label");
|
|
698
|
-
lbl.textContent = "Subtaski";
|
|
699
|
-
card.appendChild(lbl);
|
|
700
|
-
|
|
734
|
+
// Details + subtasks (expanded, view mode)
|
|
735
|
+
if (isExpanded) {
|
|
701
736
|
appendDetailSections(card, epic);
|
|
702
737
|
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
738
|
+
if (totalT > 0) {
|
|
739
|
+
const lbl = el("div", "subtask-label");
|
|
740
|
+
lbl.textContent = "Subtasks";
|
|
741
|
+
card.appendChild(lbl);
|
|
742
|
+
|
|
743
|
+
const list = el("div", "tasks-list");
|
|
744
|
+
epic.subtasks.forEach((task, idx) => {
|
|
745
|
+
const row = el("div", `task-row${task.done ? " done" : ""}`);
|
|
746
|
+
const cbId = `v-${epic.id}-${idx}`;
|
|
747
|
+
row.innerHTML = `
|
|
748
|
+
<input type="checkbox" id="${cbId}" ${task.done ? "checked" : ""}>
|
|
749
|
+
<div class="task-copy">
|
|
750
|
+
<label for="${cbId}">${escHtml(task.text)}</label>
|
|
751
|
+
${task.description ? `<div class="task-desc">${escHtml(task.description)}</div>` : ""}
|
|
752
|
+
</div>
|
|
753
|
+
`;
|
|
754
|
+
row.querySelector("input").onchange = async function() {
|
|
755
|
+
this.disabled = true;
|
|
756
|
+
await fetch(`/api/epics/${enc(epic.id)}/tasks/${idx}`, { method: "PATCH" });
|
|
757
|
+
await load();
|
|
758
|
+
};
|
|
759
|
+
list.appendChild(row);
|
|
760
|
+
});
|
|
761
|
+
card.appendChild(list);
|
|
762
|
+
}
|
|
722
763
|
}
|
|
723
764
|
|
|
724
765
|
card.onclick = e => {
|
|
725
766
|
if (e.target.tagName === "BUTTON" || e.target.tagName === "INPUT" || e.target.tagName === "LABEL") return;
|
|
726
|
-
|
|
767
|
+
toggleExpand(epic.id);
|
|
727
768
|
};
|
|
728
769
|
}
|
|
729
770
|
|
|
@@ -736,9 +777,12 @@ function renderEditMode(card, epic, colId, shortId) {
|
|
|
736
777
|
title: displayTitle,
|
|
737
778
|
description: epic.description || "",
|
|
738
779
|
specs: epic.specs || "",
|
|
780
|
+
in_scope: (epic.in_scope || []).join("\n"),
|
|
781
|
+
out_of_scope: (epic.out_of_scope || []).join("\n"),
|
|
739
782
|
acceptance_criteria: (epic.acceptance_criteria || []).join("\n"),
|
|
783
|
+
test_cases: (epic.test_cases || []).join("\n"),
|
|
740
784
|
notes: epic.notes || "",
|
|
741
|
-
tasks: epic.
|
|
785
|
+
tasks: epic.subtasks.map(t => ({ ...t }))
|
|
742
786
|
};
|
|
743
787
|
dirtyEdits[epic.id] = dirty;
|
|
744
788
|
|
|
@@ -756,28 +800,40 @@ function renderEditMode(card, epic, colId, shortId) {
|
|
|
756
800
|
card.appendChild(titleInput);
|
|
757
801
|
|
|
758
802
|
const meta = el("div", "edit-meta");
|
|
759
|
-
meta.appendChild(renderEditBlock("
|
|
803
|
+
meta.appendChild(renderEditBlock("Description", dirty.description, value => {
|
|
760
804
|
dirty.description = value;
|
|
761
805
|
dirtyEdits[epic.id] = dirty;
|
|
762
|
-
}, "
|
|
806
|
+
}, "Context and implementation plan..."));
|
|
763
807
|
meta.appendChild(renderEditBlock("Specs", dirty.specs, value => {
|
|
764
808
|
dirty.specs = value;
|
|
765
809
|
dirtyEdits[epic.id] = dirty;
|
|
766
|
-
}, "
|
|
810
|
+
}, "APIs, constraints, edge cases..."));
|
|
811
|
+
meta.appendChild(renderEditBlock("In Scope", dirty.in_scope, value => {
|
|
812
|
+
dirty.in_scope = value;
|
|
813
|
+
dirtyEdits[epic.id] = dirty;
|
|
814
|
+
}, "What this task covers (one item per line)"));
|
|
815
|
+
meta.appendChild(renderEditBlock("Out of Scope", dirty.out_of_scope, value => {
|
|
816
|
+
dirty.out_of_scope = value;
|
|
817
|
+
dirtyEdits[epic.id] = dirty;
|
|
818
|
+
}, "What is explicitly not included (one item per line)", "out-of-scope"));
|
|
767
819
|
meta.appendChild(renderEditBlock("Acceptance Criteria", dirty.acceptance_criteria, value => {
|
|
768
820
|
dirty.acceptance_criteria = value;
|
|
769
821
|
dirtyEdits[epic.id] = dirty;
|
|
770
|
-
}, "
|
|
822
|
+
}, "One condition per line"));
|
|
823
|
+
meta.appendChild(renderEditBlock("Test Cases", dirty.test_cases, value => {
|
|
824
|
+
dirty.test_cases = value;
|
|
825
|
+
dirtyEdits[epic.id] = dirty;
|
|
826
|
+
}, "One scenario per line"));
|
|
771
827
|
meta.appendChild(renderEditBlock("Notes", dirty.notes, value => {
|
|
772
828
|
dirty.notes = value;
|
|
773
829
|
dirtyEdits[epic.id] = dirty;
|
|
774
|
-
}, "
|
|
830
|
+
}, "Optional notes"));
|
|
775
831
|
card.appendChild(meta);
|
|
776
832
|
|
|
777
833
|
// Subtasks section
|
|
778
834
|
const editTasks = el("div", "edit-tasks");
|
|
779
835
|
const subtaskLbl = el("div", "subtask-label");
|
|
780
|
-
subtaskLbl.textContent = "
|
|
836
|
+
subtaskLbl.textContent = "Subtasks";
|
|
781
837
|
editTasks.appendChild(subtaskLbl);
|
|
782
838
|
|
|
783
839
|
const taskRows = el("div");
|
|
@@ -808,14 +864,14 @@ function renderEditMode(card, epic, colId, shortId) {
|
|
|
808
864
|
|
|
809
865
|
const desc = document.createElement("textarea");
|
|
810
866
|
desc.className = "edit-task-desc";
|
|
811
|
-
desc.placeholder = "
|
|
867
|
+
desc.placeholder = "Subtask description...";
|
|
812
868
|
desc.value = task.description || "";
|
|
813
869
|
desc.oninput = () => { dirty.tasks[idx].description = desc.value; dirtyEdits[epic.id] = dirty; };
|
|
814
870
|
|
|
815
871
|
const delBtn = document.createElement("button");
|
|
816
872
|
delBtn.className = "del-task";
|
|
817
873
|
delBtn.textContent = "×";
|
|
818
|
-
delBtn.title = "
|
|
874
|
+
delBtn.title = "Remove subtask";
|
|
819
875
|
delBtn.onclick = () => { dirty.tasks.splice(idx, 1); dirtyEdits[epic.id] = dirty; refreshRows(); };
|
|
820
876
|
|
|
821
877
|
fields.appendChild(inp);
|
|
@@ -830,7 +886,7 @@ function renderEditMode(card, epic, colId, shortId) {
|
|
|
830
886
|
// Add subtask input
|
|
831
887
|
const addSubtaskInput = document.createElement("input");
|
|
832
888
|
addSubtaskInput.className = "add-task-inp";
|
|
833
|
-
addSubtaskInput.placeholder = "+
|
|
889
|
+
addSubtaskInput.placeholder = "+ New subtask… (Enter)";
|
|
834
890
|
addSubtaskInput.onkeydown = e => {
|
|
835
891
|
if (e.key === "Enter" && addSubtaskInput.value.trim()) {
|
|
836
892
|
dirty.tasks.push({ done: false, text: addSubtaskInput.value.trim(), description: "" });
|
|
@@ -846,12 +902,12 @@ function renderEditMode(card, epic, colId, shortId) {
|
|
|
846
902
|
const footer = el("div", "edit-footer");
|
|
847
903
|
const saveBtn = document.createElement("button");
|
|
848
904
|
saveBtn.className = "btn-sm btn-confirm";
|
|
849
|
-
saveBtn.textContent = "✓
|
|
905
|
+
saveBtn.textContent = "✓ Save";
|
|
850
906
|
saveBtn.onclick = async e => { e.stopPropagation(); await saveEdit(epic.id); };
|
|
851
907
|
|
|
852
|
-
|
|
908
|
+
const cancelBtn = document.createElement("button");
|
|
853
909
|
cancelBtn.className = "btn-sm btn-cancel";
|
|
854
|
-
cancelBtn.textContent = "
|
|
910
|
+
cancelBtn.textContent = "Cancel";
|
|
855
911
|
cancelBtn.onclick = e => {
|
|
856
912
|
e.stopPropagation(); delete dirtyEdits[epic.id]; editingId = null; render();
|
|
857
913
|
};
|
|
@@ -881,9 +937,12 @@ async function saveEdit(epicId) {
|
|
|
881
937
|
if (dirty.title !== origTitle) body.title = dirty.title;
|
|
882
938
|
body.description = dirty.description;
|
|
883
939
|
body.specs = dirty.specs;
|
|
940
|
+
body.in_scope = splitLines(dirty.in_scope);
|
|
941
|
+
body.out_of_scope = splitLines(dirty.out_of_scope);
|
|
884
942
|
body.acceptance_criteria = splitLines(dirty.acceptance_criteria);
|
|
943
|
+
body.test_cases = splitLines(dirty.test_cases);
|
|
885
944
|
body.notes = dirty.notes;
|
|
886
|
-
body.
|
|
945
|
+
body.subtasks = dirty.tasks.filter(t => t.text.trim()).map((task, idx) => ({
|
|
887
946
|
id: task.id || `st-${idx + 1}`,
|
|
888
947
|
text: task.text.trim(),
|
|
889
948
|
done: Boolean(task.done),
|
|
@@ -897,8 +956,8 @@ async function saveEdit(epicId) {
|
|
|
897
956
|
body: JSON.stringify(body),
|
|
898
957
|
});
|
|
899
958
|
if (!res.ok) throw new Error();
|
|
900
|
-
toast("
|
|
901
|
-
} catch { toast("
|
|
959
|
+
toast("Saved ✓");
|
|
960
|
+
} catch { toast("Save error!", true); }
|
|
902
961
|
delete dirtyEdits[epicId];
|
|
903
962
|
editingId = null;
|
|
904
963
|
await load();
|
|
@@ -917,22 +976,31 @@ function showLaneAdd(lane, colId, groupName, addBtn) {
|
|
|
917
976
|
if (existing) { existing.remove(); return; }
|
|
918
977
|
|
|
919
978
|
const wrap = el("div", "lane-addinp");
|
|
979
|
+
|
|
980
|
+
const titleLabel = el("div", "lane-add-label");
|
|
981
|
+
titleLabel.textContent = "Task title";
|
|
982
|
+
wrap.appendChild(titleLabel);
|
|
983
|
+
|
|
920
984
|
const inp = document.createElement("input");
|
|
921
985
|
inp.className = "lane-add-title";
|
|
922
|
-
inp.placeholder = "
|
|
986
|
+
inp.placeholder = "New task name…";
|
|
923
987
|
inp.autocomplete = "off";
|
|
924
988
|
|
|
989
|
+
const descLabel = el("div", "lane-add-label");
|
|
990
|
+
descLabel.textContent = "Description";
|
|
991
|
+
wrap.appendChild(descLabel);
|
|
992
|
+
|
|
925
993
|
const description = document.createElement("textarea");
|
|
926
994
|
description.className = "lane-add-textarea";
|
|
927
|
-
description.placeholder = "
|
|
995
|
+
description.placeholder = "Task description (optional)...";
|
|
928
996
|
|
|
929
997
|
const actions = el("div", "lane-add-actions");
|
|
930
998
|
const confirmBtn = document.createElement("button");
|
|
931
999
|
confirmBtn.className = "btn-sm btn-confirm";
|
|
932
|
-
confirmBtn.textContent = "
|
|
1000
|
+
confirmBtn.textContent = "Add";
|
|
933
1001
|
const cancelBtn = document.createElement("button");
|
|
934
1002
|
cancelBtn.className = "btn-sm btn-cancel";
|
|
935
|
-
cancelBtn.textContent = "
|
|
1003
|
+
cancelBtn.textContent = "Cancel";
|
|
936
1004
|
|
|
937
1005
|
const doAdd = async () => {
|
|
938
1006
|
const title = inp.value.trim();
|
|
@@ -950,11 +1018,11 @@ function showLaneAdd(lane, colId, groupName, addBtn) {
|
|
|
950
1018
|
}),
|
|
951
1019
|
});
|
|
952
1020
|
if (res.ok) {
|
|
953
|
-
toast("
|
|
1021
|
+
toast("Added ✓");
|
|
954
1022
|
wrap.remove();
|
|
955
1023
|
await load();
|
|
956
1024
|
} else {
|
|
957
|
-
toast("
|
|
1025
|
+
toast("Error!", true);
|
|
958
1026
|
inp.disabled = false;
|
|
959
1027
|
description.disabled = false;
|
|
960
1028
|
inp.focus();
|
|
@@ -996,13 +1064,13 @@ async function submitNew() {
|
|
|
996
1064
|
body: JSON.stringify({ title, column: col, epic_group: group || "—" }),
|
|
997
1065
|
});
|
|
998
1066
|
if (res.ok) {
|
|
999
|
-
toast("
|
|
1067
|
+
toast("Task added ✓");
|
|
1000
1068
|
document.getElementById("ne-title").value = "";
|
|
1001
1069
|
document.getElementById("ne-group").value = "";
|
|
1002
1070
|
toggleNewForm();
|
|
1003
1071
|
await load();
|
|
1004
1072
|
} else {
|
|
1005
|
-
toast("
|
|
1073
|
+
toast("Error!", true);
|
|
1006
1074
|
}
|
|
1007
1075
|
}
|
|
1008
1076
|
|
|
@@ -1024,14 +1092,23 @@ function splitLines(value) {
|
|
|
1024
1092
|
}
|
|
1025
1093
|
function appendDetailSections(card, epic) {
|
|
1026
1094
|
if (epic.description) {
|
|
1027
|
-
card.appendChild(renderDetailSection("
|
|
1095
|
+
card.appendChild(renderDetailSection("Description", epic.description));
|
|
1028
1096
|
}
|
|
1029
1097
|
if (epic.specs) {
|
|
1030
1098
|
card.appendChild(renderDetailSection("Specs", epic.specs));
|
|
1031
1099
|
}
|
|
1100
|
+
if (epic.in_scope && epic.in_scope.length > 0) {
|
|
1101
|
+
card.appendChild(renderDetailListSection("In Scope", epic.in_scope));
|
|
1102
|
+
}
|
|
1103
|
+
if (epic.out_of_scope && epic.out_of_scope.length > 0) {
|
|
1104
|
+
card.appendChild(renderDetailListSection("Out of Scope", epic.out_of_scope, "out-of-scope"));
|
|
1105
|
+
}
|
|
1032
1106
|
if (epic.acceptance_criteria && epic.acceptance_criteria.length > 0) {
|
|
1033
1107
|
card.appendChild(renderDetailListSection("Acceptance Criteria", epic.acceptance_criteria));
|
|
1034
1108
|
}
|
|
1109
|
+
if (epic.test_cases && epic.test_cases.length > 0) {
|
|
1110
|
+
card.appendChild(renderDetailListSection("Test Cases", epic.test_cases));
|
|
1111
|
+
}
|
|
1035
1112
|
if (epic.notes) {
|
|
1036
1113
|
card.appendChild(renderDetailSection("Notes", epic.notes));
|
|
1037
1114
|
}
|
|
@@ -1046,8 +1123,8 @@ function renderDetailSection(label, text) {
|
|
|
1046
1123
|
wrap.appendChild(textEl);
|
|
1047
1124
|
return wrap;
|
|
1048
1125
|
}
|
|
1049
|
-
function renderDetailListSection(label, items) {
|
|
1050
|
-
const wrap = el("div", "detail-section");
|
|
1126
|
+
function renderDetailListSection(label, items, extraClass) {
|
|
1127
|
+
const wrap = el("div", extraClass ? `detail-section ${extraClass}` : "detail-section");
|
|
1051
1128
|
const labelEl = el("div", "detail-label");
|
|
1052
1129
|
labelEl.textContent = label;
|
|
1053
1130
|
const list = el("ul", "detail-list");
|
|
@@ -1060,12 +1137,12 @@ function renderDetailListSection(label, items) {
|
|
|
1060
1137
|
wrap.appendChild(list);
|
|
1061
1138
|
return wrap;
|
|
1062
1139
|
}
|
|
1063
|
-
function renderEditBlock(label, value, onInput, placeholder) {
|
|
1064
|
-
const wrap = el("div");
|
|
1140
|
+
function renderEditBlock(label, value, onInput, placeholder, extraClass) {
|
|
1141
|
+
const wrap = el("div", extraClass ? `detail-section ${extraClass}` : null);
|
|
1065
1142
|
const labelEl = el("div", "detail-label");
|
|
1066
1143
|
labelEl.textContent = label;
|
|
1067
1144
|
const textarea = document.createElement("textarea");
|
|
1068
|
-
textarea.className = "edit-block";
|
|
1145
|
+
textarea.className = extraClass ? `edit-block ${extraClass}` : "edit-block";
|
|
1069
1146
|
textarea.value = value;
|
|
1070
1147
|
textarea.placeholder = placeholder;
|
|
1071
1148
|
textarea.oninput = () => onInput(textarea.value);
|