agkan 2.4.0 → 2.6.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.
Files changed (53) hide show
  1. package/dist/board/server.d.ts +2 -1
  2. package/dist/board/server.d.ts.map +1 -1
  3. package/dist/board/server.js +514 -22
  4. package/dist/board/server.js.map +1 -1
  5. package/dist/cli/commands/comment/add.d.ts +6 -0
  6. package/dist/cli/commands/comment/add.d.ts.map +1 -0
  7. package/dist/cli/commands/comment/add.js +73 -0
  8. package/dist/cli/commands/comment/add.js.map +1 -0
  9. package/dist/cli/commands/comment/delete.d.ts +6 -0
  10. package/dist/cli/commands/comment/delete.d.ts.map +1 -0
  11. package/dist/cli/commands/comment/delete.js +57 -0
  12. package/dist/cli/commands/comment/delete.js.map +1 -0
  13. package/dist/cli/commands/comment/list.d.ts +6 -0
  14. package/dist/cli/commands/comment/list.d.ts.map +1 -0
  15. package/dist/cli/commands/comment/list.js +72 -0
  16. package/dist/cli/commands/comment/list.js.map +1 -0
  17. package/dist/cli/commands/task/find.d.ts.map +1 -1
  18. package/dist/cli/commands/task/find.js +31 -15
  19. package/dist/cli/commands/task/find.js.map +1 -1
  20. package/dist/cli/commands/task/get.d.ts.map +1 -1
  21. package/dist/cli/commands/task/get.js +23 -0
  22. package/dist/cli/commands/task/get.js.map +1 -1
  23. package/dist/cli/commands/task/list.d.ts.map +1 -1
  24. package/dist/cli/commands/task/list.js +27 -12
  25. package/dist/cli/commands/task/list.js.map +1 -1
  26. package/dist/cli/index.js +8 -0
  27. package/dist/cli/index.js.map +1 -1
  28. package/dist/db/schema.d.ts.map +1 -1
  29. package/dist/db/schema.js +19 -0
  30. package/dist/db/schema.js.map +1 -1
  31. package/dist/models/TaskComment.d.ts +21 -0
  32. package/dist/models/TaskComment.d.ts.map +1 -0
  33. package/dist/models/TaskComment.js +3 -0
  34. package/dist/models/TaskComment.js.map +1 -0
  35. package/dist/models/index.d.ts +1 -0
  36. package/dist/models/index.d.ts.map +1 -1
  37. package/dist/services/CommentService.d.ts +48 -0
  38. package/dist/services/CommentService.d.ts.map +1 -0
  39. package/dist/services/CommentService.js +120 -0
  40. package/dist/services/CommentService.js.map +1 -0
  41. package/dist/services/TaskService.d.ts +2 -1
  42. package/dist/services/TaskService.d.ts.map +1 -1
  43. package/dist/services/TaskService.js +10 -2
  44. package/dist/services/TaskService.js.map +1 -1
  45. package/dist/services/index.d.ts +1 -0
  46. package/dist/services/index.d.ts.map +1 -1
  47. package/dist/services/index.js +3 -1
  48. package/dist/services/index.js.map +1 -1
  49. package/dist/utils/input-validators.d.ts +7 -0
  50. package/dist/utils/input-validators.d.ts.map +1 -1
  51. package/dist/utils/input-validators.js +30 -0
  52. package/dist/utils/input-validators.js.map +1 -1
  53. package/package.json +1 -1
@@ -6,6 +6,7 @@ const hono_1 = require("hono");
6
6
  const node_server_1 = require("@hono/node-server");
7
7
  const TaskService_1 = require("../services/TaskService");
8
8
  const TaskTagService_1 = require("../services/TaskTagService");
9
+ const TagService_1 = require("../services/TagService");
9
10
  const MetadataService_1 = require("../services/MetadataService");
10
11
  const models_1 = require("../models");
11
12
  const connection_1 = require("../db/connection");
@@ -58,7 +59,7 @@ const BOARD_STYLES = `
58
59
  header { background: #1e293b; color: white; padding: 12px 20px; display: flex; align-items: center; justify-content: space-between; }
59
60
  header h1 { font-size: 18px; font-weight: 700; }
60
61
  .board-title { font-size: 14px; font-weight: 400; opacity: 0.75; }
61
- .board-container { display: flex; width: 100%; height: calc(100vh - 48px); gap: 0; }
62
+ .board-container { display: flex; width: 100%; height: calc(100vh - 92px); gap: 0; }
62
63
  .board { display: flex; gap: 12px; padding: 16px; overflow-x: auto; flex: 1; align-items: flex-start; min-width: 0; }
63
64
  .board.with-panel { padding-right: 0; }
64
65
  .column { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; width: 240px; flex-shrink: 0; display: flex; flex-direction: column; border-top: 3px solid transparent; }
@@ -103,7 +104,7 @@ const BOARD_STYLES = `
103
104
  .modal-actions button.primary:hover { background: #2563eb; }
104
105
  .toast { position: fixed; bottom: 20px; right: 20px; background: #ef4444; color: white; padding: 10px 16px; border-radius: 6px; font-size: 13px; opacity: 0; transition: opacity 0.3s; pointer-events: none; }
105
106
  .toast.show { opacity: 1; }
106
- .detail-panel { position: relative; width: 0; height: calc(100vh - 48px); background: white; box-shadow: none; border-left: 0 solid #e2e8f0; display: flex; flex-direction: column; max-width: 800px; overflow: hidden; transition: width 0.25s ease; }
107
+ .detail-panel { position: relative; width: 0; height: calc(100vh - 92px); background: white; box-shadow: none; border-left: 0 solid #e2e8f0; display: flex; flex-direction: column; max-width: 800px; overflow: hidden; transition: width 0.25s ease; }
107
108
  .detail-panel-resize-handle { position: absolute; top: 0; left: 0; width: 6px; height: 100%; cursor: col-resize; z-index: 10; background: transparent; }
108
109
  .detail-panel-resize-handle:hover, .detail-panel-resize-handle.dragging { background: rgba(59,130,246,0.3); }
109
110
  .detail-panel.open { width: 400px; min-width: 280px; border-left-width: 1px; }
@@ -111,8 +112,9 @@ const BOARD_STYLES = `
111
112
  .detail-panel-header h2 { font-size: 16px; font-weight: 700; color: #1e293b; margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
112
113
  .detail-panel-close { background: none; border: none; font-size: 20px; color: #64748b; cursor: pointer; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; border-radius: 4px; flex-shrink: 0; }
113
114
  .detail-panel-close:hover { background: #f1f5f9; color: #1e293b; }
114
- .detail-panel-body { flex: 1; overflow-y: auto; padding: 20px; min-width: 0; }
115
+ .detail-panel-body { flex: 1; overflow-y: auto; padding: 20px; min-width: 0; display: flex; flex-direction: column; }
115
116
  .detail-field { margin-bottom: 16px; word-wrap: break-word; }
117
+ .description-field-wrapper { flex: 1; display: flex; flex-direction: column; min-height: 0; margin-bottom: 0; }
116
118
  .detail-field-label { font-size: 11px; font-weight: 700; text-transform: uppercase; color: #94a3b8; margin-bottom: 4px; letter-spacing: 0.05em; }
117
119
  .detail-field-value { font-size: 13px; color: #1e293b; line-height: 1.5; }
118
120
  .detail-field-value.empty { color: #94a3b8; font-style: italic; }
@@ -129,8 +131,47 @@ const BOARD_STYLES = `
129
131
  .detail-edit-input:focus { outline: none; border-color: #3b82f6; box-shadow: 0 0 0 2px rgba(59,130,246,0.2); }
130
132
  .detail-edit-textarea { width: 100%; border: 1px solid #e2e8f0; border-radius: 6px; padding: 7px 10px; font-size: 13px; font-family: inherit; resize: vertical; min-height: 240px; background: white; color: #1e293b; }
131
133
  .detail-edit-textarea:focus { outline: none; border-color: #3b82f6; box-shadow: 0 0 0 2px rgba(59,130,246,0.2); }
134
+ .description-field-wrapper .detail-edit-textarea { flex: 1; resize: none; min-height: 0; }
132
135
  .detail-edit-select { width: 100%; border: 1px solid #e2e8f0; border-radius: 6px; padding: 7px 10px; font-size: 13px; font-family: inherit; background: white; color: #1e293b; }
133
- .detail-edit-select:focus { outline: none; border-color: #3b82f6; box-shadow: 0 0 0 2px rgba(59,130,246,0.2); }`;
136
+ .detail-edit-select:focus { outline: none; border-color: #3b82f6; box-shadow: 0 0 0 2px rgba(59,130,246,0.2); }
137
+ .tag-select-wrapper { position: relative; }
138
+ .tag-select-control { border: 1px solid #e2e8f0; border-radius: 6px; padding: 4px 8px; min-height: 36px; cursor: text; display: flex; flex-wrap: wrap; gap: 4px; align-items: center; background: white; }
139
+ .tag-select-control:focus-within { border-color: #3b82f6; box-shadow: 0 0 0 2px rgba(59,130,246,0.2); }
140
+ .tag-pill { background: #e0f2fe; color: #0369a1; font-size: 11px; font-weight: 600; padding: 2px 4px 2px 8px; border-radius: 10px; display: inline-flex; align-items: center; gap: 3px; }
141
+ .tag-pill-remove { background: none; border: none; color: #0369a1; cursor: pointer; font-size: 13px; line-height: 1; padding: 0 2px; display: inline-flex; align-items: center; border-radius: 50%; }
142
+ .tag-pill-remove:hover { color: #dc2626; background: rgba(220,38,38,0.1); }
143
+ .tag-select-input { border: none; outline: none; font-size: 12px; font-family: inherit; min-width: 80px; flex: 1; background: transparent; padding: 2px 0; color: #1e293b; }
144
+ .tag-select-input::placeholder { color: #94a3b8; }
145
+ .tag-select-dropdown { position: absolute; top: calc(100% + 2px); left: 0; right: 0; background: white; border: 1px solid #e2e8f0; border-radius: 6px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); z-index: 100; max-height: 180px; overflow-y: auto; display: none; }
146
+ .tag-select-dropdown.open { display: block; }
147
+ .tag-select-option { padding: 6px 10px; font-size: 12px; cursor: pointer; color: #1e293b; }
148
+ .tag-select-option:hover, .tag-select-option.focused { background: #eff6ff; color: #0369a1; }
149
+ .tag-select-no-options { padding: 6px 10px; font-size: 12px; color: #94a3b8; font-style: italic; }
150
+ .filter-bar { display: flex; align-items: center; gap: 16px; height: 44px; background: #f8fafc; border-bottom: 1px solid #e2e8f0; padding: 0 16px; flex-shrink: 0; overflow-x: auto; }
151
+ .filter-group { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
152
+ .filter-label { font-size: 10px; font-weight: 700; text-transform: uppercase; color: #94a3b8; letter-spacing: 0.05em; white-space: nowrap; }
153
+ .filter-priority-btn { border: 1px solid #e2e8f0; background: white; border-radius: 4px; padding: 2px 8px; font-size: 11px; font-weight: 600; cursor: pointer; text-transform: uppercase; color: #64748b; }
154
+ .filter-priority-btn:hover { background: #f1f5f9; }
155
+ .filter-priority-btn.active[data-priority="critical"] { background: #fee2e2; color: #dc2626; border-color: #fca5a5; }
156
+ .filter-priority-btn.active[data-priority="high"] { background: #fee2e2; color: #dc2626; border-color: #fca5a5; }
157
+ .filter-priority-btn.active[data-priority="medium"] { background: #fef9c3; color: #ca8a04; border-color: #fde047; }
158
+ .filter-priority-btn.active[data-priority="low"] { background: #dcfce7; color: #16a34a; border-color: #86efac; }
159
+ .filter-assignee-input { border: 1px solid #e2e8f0; border-radius: 4px; padding: 3px 8px; font-size: 12px; font-family: inherit; background: white; color: #1e293b; width: 120px; }
160
+ .filter-assignee-input:focus { outline: none; border-color: #3b82f6; box-shadow: 0 0 0 2px rgba(59,130,246,0.2); }
161
+ .filter-tag-pill { background: #e0f2fe; color: #0369a1; font-size: 11px; font-weight: 600; padding: 2px 4px 2px 8px; border-radius: 10px; display: inline-flex; align-items: center; gap: 3px; flex-shrink: 0; }
162
+ .filter-tag-pill-remove { background: none; border: none; color: #0369a1; cursor: pointer; font-size: 13px; line-height: 1; padding: 0 2px; display: inline-flex; align-items: center; border-radius: 50%; }
163
+ .filter-tag-pill-remove:hover { color: #dc2626; background: rgba(220,38,38,0.1); }
164
+ .filter-tag-dropdown-wrapper { flex-shrink: 0; }
165
+ .filter-tag-add-btn { border: 1px dashed #cbd5e1; background: white; border-radius: 4px; padding: 2px 8px; font-size: 11px; color: #64748b; cursor: pointer; white-space: nowrap; }
166
+ .filter-tag-add-btn:hover { background: #f1f5f9; border-color: #94a3b8; }
167
+ .filter-tag-dropdown { position: fixed; background: white; border: 1px solid #e2e8f0; border-radius: 6px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); z-index: 200; max-height: 180px; overflow-y: auto; display: none; min-width: 140px; }
168
+ .filter-tag-dropdown.open { display: block; }
169
+ .filter-tag-dropdown-option { padding: 6px 10px; font-size: 12px; cursor: pointer; color: #1e293b; white-space: nowrap; }
170
+ .filter-tag-dropdown-option:hover { background: #eff6ff; color: #0369a1; }
171
+ .filter-tag-dropdown-empty { padding: 6px 10px; font-size: 12px; color: #94a3b8; font-style: italic; }
172
+ .filter-clear-btn { border: 1px solid #e2e8f0; background: white; border-radius: 4px; padding: 2px 10px; font-size: 11px; font-weight: 600; cursor: pointer; color: #64748b; display: none; flex-shrink: 0; margin-left: auto; }
173
+ .filter-clear-btn:hover { background: #fee2e2; border-color: #fca5a5; color: #dc2626; }
174
+ .filter-clear-btn.visible { display: block; }`;
134
175
  const BOARD_SCRIPT = `
135
176
  let draggedCard = null;
136
177
  let sourceBody = null;
@@ -364,6 +405,183 @@ const BOARD_SCRIPT = `
364
405
  const statusLabels = ${JSON.stringify(STATUS_LABELS)};
365
406
  const allPriorities = ${JSON.stringify(models_1.PRIORITIES)};
366
407
 
408
+ let allAvailableTags = [];
409
+
410
+ async function loadAllTags() {
411
+ try {
412
+ const res = await fetch('/api/tags');
413
+ if (!res.ok) return;
414
+ const data = await res.json();
415
+ allAvailableTags = data.tags || [];
416
+ } catch {
417
+ // Ignore errors loading tags
418
+ }
419
+ }
420
+
421
+ function renderTagsSection(currentTags) {
422
+ const container = document.getElementById('detail-tags-container');
423
+ if (!container) return;
424
+
425
+ container.innerHTML = '<div class="tag-select-wrapper"><div class="tag-select-control" id="tag-select-control"></div><div class="tag-select-dropdown" id="tag-select-dropdown"></div></div>';
426
+
427
+ const control = document.getElementById('tag-select-control');
428
+ const dropdown = document.getElementById('tag-select-dropdown');
429
+ let focusedOptionIndex = -1;
430
+ let inputValue = '';
431
+
432
+ function getFilteredTags() {
433
+ const currentTagIds = new Set(currentTags.map(t => t.id));
434
+ const available = allAvailableTags.filter(t => !currentTagIds.has(t.id));
435
+ if (!inputValue.trim()) return available;
436
+ const q = inputValue.toLowerCase();
437
+ return available.filter(t => t.name.toLowerCase().includes(q));
438
+ }
439
+
440
+ const input = document.createElement('input');
441
+ input.className = 'tag-select-input';
442
+ input.type = 'text';
443
+ input.autocomplete = 'off';
444
+ control.appendChild(input);
445
+
446
+ function renderPills() {
447
+ control.querySelectorAll('.tag-pill').forEach(p => p.remove());
448
+ currentTags.forEach(t => {
449
+ const pill = document.createElement('span');
450
+ pill.className = 'tag-pill';
451
+ pill.dataset.tagId = t.id;
452
+ const label = document.createTextNode(t.name);
453
+ const removeBtn = document.createElement('button');
454
+ removeBtn.className = 'tag-pill-remove';
455
+ removeBtn.title = 'Remove tag';
456
+ removeBtn.setAttribute('data-tag-id', t.id);
457
+ removeBtn.innerHTML = '&times;';
458
+ removeBtn.addEventListener('click', async e => {
459
+ e.stopPropagation();
460
+ try {
461
+ const res = await fetch('/api/tasks/' + detailTaskId + '/tags/' + t.id, { method: 'DELETE' });
462
+ if (!res.ok) throw new Error('Server error');
463
+ const idx = currentTags.findIndex(x => String(x.id) === String(t.id));
464
+ if (idx !== -1) currentTags.splice(idx, 1);
465
+ renderPills();
466
+ renderDropdown();
467
+ } catch {
468
+ showToast('Failed to remove tag');
469
+ }
470
+ });
471
+ pill.appendChild(label);
472
+ pill.appendChild(removeBtn);
473
+ control.insertBefore(pill, input);
474
+ });
475
+ input.placeholder = currentTags.length === 0 ? 'Add tags...' : '';
476
+ }
477
+
478
+ function renderDropdown() {
479
+ const filtered = getFilteredTags();
480
+ dropdown.innerHTML = '';
481
+ focusedOptionIndex = -1;
482
+ if (filtered.length === 0) {
483
+ const noOpt = document.createElement('div');
484
+ noOpt.className = 'tag-select-no-options';
485
+ noOpt.textContent = inputValue ? 'No matching tags' : 'No tags available';
486
+ dropdown.appendChild(noOpt);
487
+ } else {
488
+ filtered.forEach((t, i) => {
489
+ const opt = document.createElement('div');
490
+ opt.className = 'tag-select-option';
491
+ opt.dataset.tagId = t.id;
492
+ opt.textContent = t.name;
493
+ opt.addEventListener('mouseover', () => setFocusedOption(i));
494
+ opt.addEventListener('mousedown', async e => {
495
+ e.preventDefault();
496
+ await addTag(t.id);
497
+ });
498
+ dropdown.appendChild(opt);
499
+ });
500
+ }
501
+ }
502
+
503
+ function setFocusedOption(index) {
504
+ const opts = dropdown.querySelectorAll('.tag-select-option');
505
+ opts.forEach((o, i) => o.classList.toggle('focused', i === index));
506
+ focusedOptionIndex = index;
507
+ }
508
+
509
+ function openDropdown() {
510
+ renderDropdown();
511
+ dropdown.classList.add('open');
512
+ }
513
+
514
+ function closeDropdown() {
515
+ dropdown.classList.remove('open');
516
+ focusedOptionIndex = -1;
517
+ }
518
+
519
+ async function addTag(tagId) {
520
+ try {
521
+ const res = await fetch('/api/tasks/' + detailTaskId + '/tags', {
522
+ method: 'POST',
523
+ headers: { 'Content-Type': 'application/json' },
524
+ body: JSON.stringify({ tagId: Number(tagId) })
525
+ });
526
+ if (!res.ok) throw new Error('Server error');
527
+ const tag = allAvailableTags.find(t => String(t.id) === String(tagId));
528
+ if (tag) currentTags.push(tag);
529
+ input.value = '';
530
+ inputValue = '';
531
+ renderPills();
532
+ renderDropdown();
533
+ } catch {
534
+ showToast('Failed to add tag');
535
+ }
536
+ }
537
+
538
+ control.addEventListener('click', () => input.focus());
539
+
540
+ input.addEventListener('focus', () => openDropdown());
541
+
542
+ input.addEventListener('blur', () => setTimeout(() => closeDropdown(), 150));
543
+
544
+ input.addEventListener('input', () => {
545
+ inputValue = input.value;
546
+ renderDropdown();
547
+ if (!dropdown.classList.contains('open')) openDropdown();
548
+ });
549
+
550
+ input.addEventListener('keydown', async e => {
551
+ const filtered = getFilteredTags();
552
+ const opts = dropdown.querySelectorAll('.tag-select-option');
553
+ if (e.key === 'ArrowDown') {
554
+ e.preventDefault();
555
+ setFocusedOption(Math.min(focusedOptionIndex + 1, opts.length - 1));
556
+ } else if (e.key === 'ArrowUp') {
557
+ e.preventDefault();
558
+ setFocusedOption(Math.max(focusedOptionIndex - 1, 0));
559
+ } else if (e.key === 'Enter') {
560
+ e.preventDefault();
561
+ if (focusedOptionIndex >= 0 && filtered[focusedOptionIndex]) {
562
+ await addTag(filtered[focusedOptionIndex].id);
563
+ }
564
+ } else if (e.key === 'Escape') {
565
+ closeDropdown();
566
+ input.blur();
567
+ } else if (e.key === 'Backspace' && input.value === '' && currentTags.length > 0) {
568
+ e.preventDefault();
569
+ const last = currentTags[currentTags.length - 1];
570
+ try {
571
+ const res = await fetch('/api/tasks/' + detailTaskId + '/tags/' + last.id, { method: 'DELETE' });
572
+ if (!res.ok) throw new Error('Server error');
573
+ currentTags.splice(currentTags.length - 1, 1);
574
+ renderPills();
575
+ renderDropdown();
576
+ } catch {
577
+ showToast('Failed to remove tag');
578
+ }
579
+ }
580
+ });
581
+
582
+ renderPills();
583
+ }
584
+
367
585
  function renderDetailPanel(data) {
368
586
  const task = data.task;
369
587
  const tags = data.tags || [];
@@ -403,21 +621,12 @@ const BOARD_SCRIPT = `
403
621
  html += '</select>';
404
622
  html += '</div>';
405
623
 
406
- // Body (editable)
624
+ // Tags (editable)
407
625
  html += '<div class="detail-field">';
408
- html += '<div class="detail-field-label">Description</div>';
409
- html += '<textarea id="detail-edit-body" class="detail-edit-textarea">' + escapeHtmlClient(task.body || '') + '</textarea>';
626
+ html += '<div class="detail-field-label">Tags</div>';
627
+ html += '<div id="detail-tags-container"></div>';
410
628
  html += '</div>';
411
629
 
412
- // Tags (read-only)
413
- if (tags.length > 0) {
414
- html += '<div class="detail-field">';
415
- html += '<div class="detail-field-label">Tags</div>';
416
- html += '<div class="detail-field-value detail-tags">';
417
- tags.forEach(t => { html += '<span class="tag">' + escapeHtmlClient(t.name) + '</span>'; });
418
- html += '</div></div>';
419
- }
420
-
421
630
  // Metadata table (read-only, non-priority)
422
631
  const otherMeta = metadata.filter(m => m.key !== 'priority');
423
632
  if (otherMeta.length > 0) {
@@ -440,7 +649,16 @@ const BOARD_SCRIPT = `
440
649
  html += '<div class="detail-field-value">' + escapeHtmlClient(task.updated_at) + '</div>';
441
650
  html += '</div>';
442
651
 
652
+ // Body (editable)
653
+ html += '<div class="detail-field description-field-wrapper">';
654
+ html += '<div class="detail-field-label">Description</div>';
655
+ html += '<textarea id="detail-edit-body" class="detail-edit-textarea">' + escapeHtmlClient(task.body || '') + '</textarea>';
656
+ html += '</div>';
657
+
443
658
  detailPanelBody.innerHTML = html;
659
+
660
+ // Render tags section after DOM update
661
+ loadAllTags().then(() => renderTagsSection([...tags]));
444
662
  }
445
663
 
446
664
  function escapeHtmlClient(str) {
@@ -497,11 +715,30 @@ const BOARD_SCRIPT = `
497
715
  });
498
716
  });
499
717
 
718
+ // Filter state (defined before refreshBoardCards so it can use them)
719
+ let activeFilters = { tagIds: [], priorities: [], assignee: '' };
720
+
721
+ function buildFilterParams() {
722
+ const params = new URLSearchParams();
723
+ if (activeFilters.priorities.length > 0) {
724
+ params.set('priority', activeFilters.priorities.join(','));
725
+ }
726
+ if (activeFilters.tagIds.length > 0) {
727
+ params.set('tags', activeFilters.tagIds.join(','));
728
+ }
729
+ if (activeFilters.assignee) {
730
+ params.set('assignee', activeFilters.assignee);
731
+ }
732
+ return params;
733
+ }
734
+
500
735
  // Board polling: reload when updated_at changes (skip during drag)
501
736
  let lastUpdatedAt = null;
502
737
  async function refreshBoardCards() {
738
+ const filterParams = buildFilterParams();
739
+ const url = '/api/board/cards' + (filterParams.toString() ? '?' + filterParams.toString() : '');
503
740
  try {
504
- const res = await fetch('/api/board/cards');
741
+ const res = await fetch(url);
505
742
  if (!res.ok) return;
506
743
  const data = await res.json();
507
744
  const columns = data.columns;
@@ -543,6 +780,31 @@ const BOARD_SCRIPT = `
543
780
  });
544
781
  });
545
782
  });
783
+ // If detail panel is open, refresh its content if the task was updated
784
+ if (detailTaskId !== null) {
785
+ const editableFields = ['detail-edit-title', 'detail-edit-body', 'detail-edit-status', 'detail-edit-priority'];
786
+ const isEditing = editableFields.some(id => document.activeElement && document.activeElement.id === id);
787
+ if (isEditing) {
788
+ const warning = document.getElementById('detail-panel-update-warning');
789
+ if (!warning) {
790
+ const warningEl = document.createElement('div');
791
+ warningEl.id = 'detail-panel-update-warning';
792
+ warningEl.style.cssText = 'color: red; font-size: 0.85em; padding: 4px 8px; background: #fff0f0; border: 1px solid #ffcccc; border-radius: 4px; margin-bottom: 8px;';
793
+ warningEl.textContent = 'This task has been updated in the database. Save or discard your changes to see the latest version.';
794
+ detailPanelBody.insertBefore(warningEl, detailPanelBody.firstChild);
795
+ }
796
+ } else {
797
+ try {
798
+ const taskRes = await fetch('/api/tasks/' + detailTaskId);
799
+ if (taskRes.ok) {
800
+ const taskData = await taskRes.json();
801
+ renderDetailPanel(taskData);
802
+ }
803
+ } catch {
804
+ // Ignore network errors during detail panel refresh
805
+ }
806
+ }
807
+ }
546
808
  } catch {
547
809
  // Ignore network errors during card refresh
548
810
  }
@@ -568,8 +830,163 @@ const BOARD_SCRIPT = `
568
830
  // Ignore network errors during polling
569
831
  }
570
832
  }
571
- setInterval(pollBoardUpdates, 10000);
572
- pollBoardUpdates();`;
833
+ setInterval(pollBoardUpdates, 5000);
834
+ pollBoardUpdates();
835
+
836
+ function isFiltersActive() {
837
+ return activeFilters.priorities.length > 0 || activeFilters.tagIds.length > 0 || activeFilters.assignee !== '';
838
+ }
839
+
840
+ function applyFilters() {
841
+ const clearBtn = document.getElementById('filter-clear');
842
+ if (clearBtn) {
843
+ if (isFiltersActive()) {
844
+ clearBtn.classList.add('visible');
845
+ } else {
846
+ clearBtn.classList.remove('visible');
847
+ }
848
+ }
849
+ refreshBoardCards();
850
+ }
851
+
852
+ function renderFilterTagPills() {
853
+ const container = document.getElementById('filter-tags-control');
854
+ if (!container) return;
855
+ // Remove existing pills
856
+ container.querySelectorAll('.filter-tag-pill').forEach(p => p.remove());
857
+ // Add pills for active tag filters
858
+ activeFilters.tagIds.forEach(tagId => {
859
+ const tag = allAvailableTags.find(t => t.id === tagId);
860
+ if (!tag) return;
861
+ const pill = document.createElement('span');
862
+ pill.className = 'filter-tag-pill';
863
+ const label = document.createTextNode(tag.name);
864
+ const removeBtn = document.createElement('button');
865
+ removeBtn.className = 'filter-tag-pill-remove';
866
+ removeBtn.title = 'Remove tag filter';
867
+ removeBtn.innerHTML = '&times;';
868
+ removeBtn.addEventListener('click', () => {
869
+ const idx = activeFilters.tagIds.indexOf(tagId);
870
+ if (idx !== -1) activeFilters.tagIds.splice(idx, 1);
871
+ renderFilterTagPills();
872
+ applyFilters();
873
+ });
874
+ pill.appendChild(label);
875
+ pill.appendChild(removeBtn);
876
+ container.insertBefore(pill, container.querySelector('.filter-tag-dropdown-wrapper'));
877
+ });
878
+ }
879
+
880
+ function initFilterBar() {
881
+ // Priority toggle buttons
882
+ document.querySelectorAll('.filter-priority-btn').forEach(btn => {
883
+ btn.addEventListener('click', () => {
884
+ const priority = btn.dataset.priority;
885
+ const idx = activeFilters.priorities.indexOf(priority);
886
+ if (idx === -1) {
887
+ activeFilters.priorities.push(priority);
888
+ btn.classList.add('active');
889
+ } else {
890
+ activeFilters.priorities.splice(idx, 1);
891
+ btn.classList.remove('active');
892
+ }
893
+ applyFilters();
894
+ });
895
+ });
896
+
897
+ // Assignee input with debounce
898
+ const assigneeInput = document.getElementById('filter-assignee');
899
+ let assigneeTimer = null;
900
+ if (assigneeInput) {
901
+ assigneeInput.addEventListener('input', () => {
902
+ clearTimeout(assigneeTimer);
903
+ assigneeTimer = setTimeout(() => {
904
+ activeFilters.assignee = assigneeInput.value.trim();
905
+ applyFilters();
906
+ }, 300);
907
+ });
908
+ }
909
+
910
+ // Clear button
911
+ const clearBtn = document.getElementById('filter-clear');
912
+ if (clearBtn) {
913
+ clearBtn.addEventListener('click', () => {
914
+ activeFilters.tagIds = [];
915
+ activeFilters.priorities = [];
916
+ activeFilters.assignee = '';
917
+ document.querySelectorAll('.filter-priority-btn').forEach(btn => btn.classList.remove('active'));
918
+ if (assigneeInput) assigneeInput.value = '';
919
+ renderFilterTagPills();
920
+ applyFilters();
921
+ });
922
+ }
923
+
924
+ // Tag filter dropdown
925
+ const tagsControl = document.getElementById('filter-tags-control');
926
+ if (tagsControl) {
927
+ const dropdownWrapper = document.createElement('div');
928
+ dropdownWrapper.className = 'filter-tag-dropdown-wrapper';
929
+
930
+ const addBtn = document.createElement('button');
931
+ addBtn.className = 'filter-tag-add-btn';
932
+ addBtn.textContent = '+ Tag';
933
+
934
+ const dropdown = document.createElement('div');
935
+ dropdown.className = 'filter-tag-dropdown';
936
+
937
+ dropdownWrapper.appendChild(addBtn);
938
+ dropdownWrapper.appendChild(dropdown);
939
+ tagsControl.appendChild(dropdownWrapper);
940
+
941
+ function renderTagDropdown() {
942
+ dropdown.innerHTML = '';
943
+ const available = allAvailableTags.filter(t => !activeFilters.tagIds.includes(t.id));
944
+ if (available.length === 0) {
945
+ const empty = document.createElement('div');
946
+ empty.className = 'filter-tag-dropdown-empty';
947
+ empty.textContent = 'No tags available';
948
+ dropdown.appendChild(empty);
949
+ } else {
950
+ available.forEach(tag => {
951
+ const opt = document.createElement('div');
952
+ opt.className = 'filter-tag-dropdown-option';
953
+ opt.textContent = tag.name;
954
+ opt.addEventListener('mousedown', (e) => {
955
+ e.preventDefault();
956
+ activeFilters.tagIds.push(tag.id);
957
+ dropdown.classList.remove('open');
958
+ renderFilterTagPills();
959
+ applyFilters();
960
+ });
961
+ dropdown.appendChild(opt);
962
+ });
963
+ }
964
+ }
965
+
966
+ addBtn.addEventListener('click', () => {
967
+ if (dropdown.classList.contains('open')) {
968
+ dropdown.classList.remove('open');
969
+ } else {
970
+ renderTagDropdown();
971
+ const rect = addBtn.getBoundingClientRect();
972
+ dropdown.style.top = (rect.bottom + 2) + 'px';
973
+ dropdown.style.left = rect.left + 'px';
974
+ dropdown.classList.add('open');
975
+ }
976
+ });
977
+
978
+ document.addEventListener('click', (e) => {
979
+ if (!dropdownWrapper.contains(e.target)) {
980
+ dropdown.classList.remove('open');
981
+ }
982
+ });
983
+ }
984
+ }
985
+
986
+ // Initialize filter bar after tags are loaded
987
+ loadAllTags().then(() => {
988
+ initFilterBar();
989
+ });`;
573
990
  function renderColumn(status, tasks, tagMap) {
574
991
  const color = STATUS_COLORS[status];
575
992
  const label = STATUS_LABELS[status];
@@ -629,6 +1046,24 @@ function renderBoard(tasksByStatus, tagMap, boardTitle) {
629
1046
  </head>
630
1047
  <body>
631
1048
  <header><h1>agkan board</h1>${titleHtml}</header>
1049
+ <div class="filter-bar" id="filter-bar">
1050
+ <div class="filter-group">
1051
+ <span class="filter-label">Priority</span>
1052
+ <button class="filter-priority-btn" data-priority="critical">critical</button>
1053
+ <button class="filter-priority-btn" data-priority="high">high</button>
1054
+ <button class="filter-priority-btn" data-priority="medium">medium</button>
1055
+ <button class="filter-priority-btn" data-priority="low">low</button>
1056
+ </div>
1057
+ <div class="filter-group">
1058
+ <span class="filter-label">Tags</span>
1059
+ <div id="filter-tags-control" style="display:flex;align-items:center;gap:4px;flex-wrap:nowrap;"></div>
1060
+ </div>
1061
+ <div class="filter-group">
1062
+ <span class="filter-label">Assignee</span>
1063
+ <input type="text" id="filter-assignee" class="filter-assignee-input" placeholder="Filter by assignee">
1064
+ </div>
1065
+ <button class="filter-clear-btn" id="filter-clear">Clear filters</button>
1066
+ </div>
632
1067
  <div class="board-container">
633
1068
  <div class="board">${columns}</div>${BOARD_BODY_STATIC}
634
1069
  </div>
@@ -694,7 +1129,7 @@ function getBoardUpdatedAt(database) {
694
1129
  return null;
695
1130
  return `${baseRow.max_updated_at}|${tagsRow.max_created_at}|${tagsRow.count}`;
696
1131
  }
697
- function registerTaskApiRoutes(app, { ts, tts, ms }) {
1132
+ function registerTaskApiRoutes(app, { ts, tts, tags, ms }) {
698
1133
  app.get('/api/tasks', (c) => c.json({ tasks: ts.listTasks({}, 'id', 'asc') }));
699
1134
  app.post('/api/tasks', async (c) => {
700
1135
  const body = await c.req.json();
@@ -735,6 +1170,34 @@ function registerTaskApiRoutes(app, { ts, tts, ms }) {
735
1170
  ts.deleteTask(id);
736
1171
  return c.json({ success: true });
737
1172
  });
1173
+ app.get('/api/tags', (c) => c.json({ tags: tags.listTags() }));
1174
+ app.post('/api/tasks/:id/tags', async (c) => {
1175
+ const id = Number(c.req.param('id'));
1176
+ if (isNaN(id))
1177
+ return c.json({ error: 'Invalid task id' }, 400);
1178
+ const body = await c.req.json();
1179
+ if (body.tagId === undefined || body.tagId === null)
1180
+ return c.json({ error: 'tagId is required' }, 400);
1181
+ const tagId = Number(body.tagId);
1182
+ if (!ts.getTask(id))
1183
+ return c.json({ error: 'Task not found' }, 404);
1184
+ if (!tags.getTag(tagId))
1185
+ return c.json({ error: 'Tag not found' }, 404);
1186
+ tts.addTagToTask({ task_id: id, tag_id: tagId });
1187
+ return c.json({ success: true }, 201);
1188
+ });
1189
+ app.delete('/api/tasks/:id/tags/:tagId', (c) => {
1190
+ const id = Number(c.req.param('id'));
1191
+ if (isNaN(id))
1192
+ return c.json({ error: 'Invalid task id' }, 400);
1193
+ const tagId = Number(c.req.param('tagId'));
1194
+ if (isNaN(tagId))
1195
+ return c.json({ error: 'Invalid tag id' }, 400);
1196
+ const removed = tts.removeTagFromTask(id, tagId);
1197
+ if (!removed)
1198
+ return c.json({ error: 'Tag not attached to task' }, 404);
1199
+ return c.json({ success: true });
1200
+ });
738
1201
  }
739
1202
  function buildBoardCardsPayload(tasksByStatus, tagMap) {
740
1203
  return STATUSES.map((status) => {
@@ -743,6 +1206,29 @@ function buildBoardCardsPayload(tasksByStatus, tagMap) {
743
1206
  return { status, html, count: tasks.length };
744
1207
  });
745
1208
  }
1209
+ function parseBoardCardFilters(query) {
1210
+ const filters = {};
1211
+ if (query.tags) {
1212
+ const tagIds = query.tags
1213
+ .split(',')
1214
+ .map((s) => Number(s.trim()))
1215
+ .filter((n) => !isNaN(n) && n > 0);
1216
+ if (tagIds.length > 0)
1217
+ filters.tagIds = tagIds;
1218
+ }
1219
+ if (query.priority) {
1220
+ const priorities = query.priority
1221
+ .split(',')
1222
+ .map((s) => s.trim())
1223
+ .filter((s) => s.length > 0);
1224
+ if (priorities.length > 0)
1225
+ filters.priority = priorities;
1226
+ }
1227
+ if (query.assignee && query.assignee.trim()) {
1228
+ filters.assignees = query.assignee.trim();
1229
+ }
1230
+ return filters;
1231
+ }
746
1232
  function registerBoardRoutes(app, services) {
747
1233
  const { ts, tts, database, boardTitle } = services;
748
1234
  app.get('/', (c) => {
@@ -751,17 +1237,23 @@ function registerBoardRoutes(app, services) {
751
1237
  });
752
1238
  app.get('/api/board/updated-at', (c) => c.json({ updatedAt: getBoardUpdatedAt(database) }));
753
1239
  app.get('/api/board/cards', (c) => {
754
- const tasksByStatus = buildTasksByStatus(ts.listTasks({}, 'id', 'asc'));
1240
+ const filters = parseBoardCardFilters({
1241
+ tags: c.req.query('tags'),
1242
+ priority: c.req.query('priority'),
1243
+ assignee: c.req.query('assignee'),
1244
+ });
1245
+ const tasksByStatus = buildTasksByStatus(ts.listTasks(filters, 'id', 'asc'));
755
1246
  const columns = buildBoardCardsPayload(tasksByStatus, tts.getAllTaskTags());
756
1247
  return c.json({ columns });
757
1248
  });
758
1249
  registerTaskApiRoutes(app, services);
759
1250
  }
760
- function createBoardApp(taskService, taskTagService, metadataService, db, boardTitle) {
1251
+ function createBoardApp(taskService, taskTagService, metadataService, db, boardTitle, tagService) {
761
1252
  const app = new hono_1.Hono();
762
1253
  const services = {
763
1254
  ts: taskService ?? new TaskService_1.TaskService(),
764
1255
  tts: taskTagService ?? new TaskTagService_1.TaskTagService(),
1256
+ tags: tagService ?? new TagService_1.TagService(),
765
1257
  ms: metadataService ?? new MetadataService_1.MetadataService(),
766
1258
  database: db ?? (0, connection_1.getDatabase)(),
767
1259
  boardTitle,