local-kanban 1.6.3

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 (64) hide show
  1. package/CHANGELOG.md +381 -0
  2. package/LICENSE +21 -0
  3. package/README.md +144 -0
  4. package/README.ru.md +144 -0
  5. package/bin/kb +306 -0
  6. package/bin/local-kanban +217 -0
  7. package/ecosystem.example.cjs +25 -0
  8. package/package.json +52 -0
  9. package/public/app.js +18 -0
  10. package/public/boot-fallback.js +3 -0
  11. package/public/drawer-sync.js +14 -0
  12. package/public/favicon-180.png +0 -0
  13. package/public/favicon-32.png +0 -0
  14. package/public/favicon-64.png +0 -0
  15. package/public/favicon.ico +0 -0
  16. package/public/favicon.svg +6 -0
  17. package/public/icon-192.png +0 -0
  18. package/public/icon-512.png +0 -0
  19. package/public/icon-maskable-192.png +0 -0
  20. package/public/icon-maskable-512.png +0 -0
  21. package/public/index.html +138 -0
  22. package/public/js/board.js +698 -0
  23. package/public/js/chaos.js +247 -0
  24. package/public/js/core.js +821 -0
  25. package/public/js/dash.js +114 -0
  26. package/public/js/drawer.js +919 -0
  27. package/public/js/horizon.js +140 -0
  28. package/public/js/init.js +208 -0
  29. package/public/js/project.js +338 -0
  30. package/public/js/settings.js +905 -0
  31. package/public/js/sidebar.js +640 -0
  32. package/public/js/sse.js +386 -0
  33. package/public/manifest.webmanifest +32 -0
  34. package/public/offline.html +39 -0
  35. package/public/style.css +1247 -0
  36. package/public/sw.js +31 -0
  37. package/scripts/raycast/open-kanban.sh +18 -0
  38. package/scripts/update.js +43 -0
  39. package/skills/deploy/SKILL.md +70 -0
  40. package/skills/kanban/SKILL.md +217 -0
  41. package/src/backup.js +111 -0
  42. package/src/bus.js +10 -0
  43. package/src/config.js +40 -0
  44. package/src/db.js +321 -0
  45. package/src/error-handler.js +11 -0
  46. package/src/file-snapshot.js +22 -0
  47. package/src/origin-guard.js +32 -0
  48. package/src/pm2-services.js +31 -0
  49. package/src/query-params.js +17 -0
  50. package/src/repo-base.js +23 -0
  51. package/src/restore.js +153 -0
  52. package/src/routes/dashboard.js +132 -0
  53. package/src/routes/events.js +30 -0
  54. package/src/routes/horizons.js +66 -0
  55. package/src/routes/projects.js +532 -0
  56. package/src/routes/skills.js +116 -0
  57. package/src/routes/system.js +285 -0
  58. package/src/routes/tasks.js +477 -0
  59. package/src/schema.sql +135 -0
  60. package/src/server.js +100 -0
  61. package/src/skills.js +119 -0
  62. package/src/sync/github.js +265 -0
  63. package/src/sync/worker.js +136 -0
  64. package/src/task-shape.js +29 -0
@@ -0,0 +1,698 @@
1
+
2
+ import { $, ALL, ALL_STATUSES, DONE_LIMIT, LABEL_COLORS, LABEL_SELECTABLE, PRI_ICON, PRI_LEVELS, api, esc, ic, noteUnsaved, seg, state, tr } from './core.js';
3
+ import { markKbCursor, openCardMenu, openDrawer, openDrawerNew, setKbCursor, statusOptionList } from './drawer.js';
4
+ import { getSetting, plural } from './settings.js';
5
+ import { styledAlert, styledConfirm } from './sidebar.js';
6
+ import { copyBacklogPrompt, copyTodoPrompt, refresh } from './sse.js';
7
+
8
+ function matchesSearch(t) {
9
+ if (!state.search) return true;
10
+ if (state.searchApplied === state.search) return true;
11
+ const q = state.search.toLowerCase();
12
+ return t.title.toLowerCase().includes(q) || t.key.toLowerCase().includes(q)
13
+ || (t.preview || '').toLowerCase().includes(q) || t.labels.some((l) => l.includes(q));
14
+ }
15
+
16
+ let prevCardIds = new Set();
17
+ let prevRenderSlug = null;
18
+ const CARD_EXIT_MAX = 10;
19
+ export let scrollToNewCardId = null;
20
+ export function setScrollToNewCardId(id) { scrollToNewCardId = id; }
21
+ function ensureCardFxLayer() {
22
+ let l = document.getElementById('card-fx');
23
+ if (!l) { l = document.createElement('div'); l.id = 'card-fx'; document.body.appendChild(l); }
24
+ return l;
25
+ }
26
+
27
+ export function renderBoard() {
28
+ const board = $('board');
29
+ const animate = prevCardIds.size > 0 && prevRenderSlug === state.slug;
30
+ const oldCards = new Map();
31
+ if (animate) board.querySelectorAll('.card').forEach((c) => oldCards.set(Number(c.dataset.id), { rect: c.getBoundingClientRect(), node: c }));
32
+ const sameBoard = prevRenderSlug === state.slug;
33
+ const scrollByStatus = new Map();
34
+ board.querySelectorAll('.col-body').forEach((b) => { if (b.dataset.status) scrollByStatus.set(b.dataset.status, b.scrollTop); });
35
+ const boardScrollLeft = board.scrollLeft;
36
+ board.innerHTML = '';
37
+ const labels = Object.fromEntries(ALL_STATUSES);
38
+ const columns = ALL_STATUSES.map(([k]) => k)
39
+ .filter((k) => k !== 'cancelled' || state.tasks.some((t) => t.status === 'cancelled'));
40
+ for (const key of columns) {
41
+ let tasks = state.tasks
42
+ .filter((t) => t.status === key && matchesSearch(t))
43
+ .sort((a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0) || b.priority - a.priority || a.position - b.position || a.id - b.id);
44
+ if (key === 'done' || key === 'cancelled') {
45
+ tasks = tasks.sort((a, b) => b.id - a.id).slice(0, DONE_LIMIT)
46
+ .sort((a, b) => b.priority - a.priority || b.id - a.id);
47
+ }
48
+
49
+ const col = document.createElement('div');
50
+ col.className = 'col';
51
+ const head = document.createElement('div');
52
+ head.className = 'col-head';
53
+ head.innerHTML = `<span class="dot s-${key}"></span>${labels[key]} <span class="n">${tasks.length}</span>`;
54
+ if (CREATABLE.has(key)) {
55
+ const add = document.createElement('button');
56
+ add.className = 'btn-icon col-add';
57
+ add.textContent = '+';
58
+ add.title = `${tr('New task in')} «${labels[key]}»`;
59
+ add.onclick = () => openDrawerNew(key);
60
+ head.appendChild(add);
61
+ }
62
+ if (key === 'todo' && tasks.length) {
63
+ const copy = document.createElement('button');
64
+ copy.className = 'btn-icon col-copy';
65
+ copy.innerHTML = ic('copy', 13);
66
+ copy.title = tr('Copy the tasks as a job for Claude');
67
+ copy.onclick = (e) => { e.stopPropagation(); copyTodoPrompt(tasks, copy); };
68
+ head.appendChild(copy);
69
+ }
70
+ if (key === 'backlog' && tasks.length) {
71
+ const copy = document.createElement('button');
72
+ copy.className = 'btn-icon col-copy';
73
+ copy.innerHTML = ic('copy', 13);
74
+ copy.title = tr('Copy the backlog as a grooming job for Claude');
75
+ copy.onclick = (e) => { e.stopPropagation(); copyBacklogPrompt(tasks, copy); };
76
+ head.appendChild(copy);
77
+ }
78
+ col.appendChild(head);
79
+
80
+ const body = document.createElement('div');
81
+ body.className = 'col-body';
82
+ body.dataset.status = key;
83
+ body.ondragover = (e) => {
84
+ const dt = dragId != null ? state.tasks.find((x) => x.id === dragId) : null;
85
+ if (WORKING.has(key) && (!dt || !taskIsNoclaude(dt))) return;
86
+ const group = dragGroupIds && dragGroupIds.length > 1;
87
+ if (!group && dt && moveBlocked(dt.status, key, { manual: taskIsNoclaude(dt) })) return;
88
+ e.preventDefault();
89
+ body.classList.add('dragover');
90
+ if (dragId == null) return;
91
+ const after = dragAfterCard(body, e.clientY, dragId);
92
+ if (dropPh.parentNode !== body || dropPh.nextSibling !== after) body.insertBefore(dropPh, after);
93
+ };
94
+ body.ondragleave = (e) => { if (!body.contains(e.relatedTarget)) body.classList.remove('dragover'); };
95
+ body.ondrop = (e) => onDrop(e, key, body);
96
+ for (const t of tasks) body.appendChild(cardEl(t));
97
+ if (CREATABLE.has(key)) {
98
+ const ghost = document.createElement('div');
99
+ ghost.className = 'col-add-ghost';
100
+ ghost.textContent = tr('+ add task');
101
+ ghost.title = `${tr('New task in')} «${labels[key]}»`;
102
+ ghost.onclick = () => openDrawerNew(key);
103
+ body.appendChild(ghost);
104
+ }
105
+ col.appendChild(body);
106
+ board.appendChild(col);
107
+ }
108
+ board.insertAdjacentHTML('beforeend', '<div class="board-spacer"></div>');
109
+
110
+ const newIds = new Set([...board.querySelectorAll('.card')].map((c) => Number(c.dataset.id)));
111
+ if (animate) {
112
+ board.querySelectorAll('.card').forEach((c) => { if (!prevCardIds.has(Number(c.dataset.id))) c.classList.add('card-enter'); });
113
+ const leaving = [...oldCards.keys()].filter((id) => !newIds.has(id));
114
+ if (leaving.length <= CARD_EXIT_MAX) {
115
+ for (const id of leaving) {
116
+ const info = oldCards.get(id);
117
+ const clone = info.node.cloneNode(true);
118
+ clone.className = 'card card-exit';
119
+ Object.assign(clone.style, { position: 'fixed', left: `${info.rect.left}px`, top: `${info.rect.top}px`, width: `${info.rect.width}px`, margin: '0' });
120
+ ensureCardFxLayer().appendChild(clone);
121
+ setTimeout(() => clone.remove(), 260);
122
+ }
123
+ }
124
+ }
125
+ oldCards.clear();
126
+ prevCardIds = newIds;
127
+ prevRenderSlug = state.slug;
128
+
129
+ if (sameBoard) {
130
+ board.scrollLeft = boardScrollLeft;
131
+ board.querySelectorAll('.col-body').forEach((b) => {
132
+ const s = scrollByStatus.get(b.dataset.status);
133
+ if (s) b.scrollTop = s;
134
+ });
135
+ }
136
+
137
+ scheduleDrawLinks();
138
+ markKbCursor();
139
+ markSelection();
140
+ if (scrollToNewCardId != null) {
141
+ $('board').querySelector(`.card[data-id="${scrollToNewCardId}"]`)?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
142
+ }
143
+ }
144
+
145
+ let marqueeEl = null;
146
+ let marqueeStart = null;
147
+ let marqueeLast = null;
148
+ let marqueeTimer = null;
149
+ let marqueeScrollPos = null;
150
+ function marqueeTrackScroll(el) {
151
+ if (!marqueeScrollPos || !marqueeStart) return;
152
+ const rec = marqueeScrollPos.get(el);
153
+ if (!rec) { marqueeScrollPos.set(el, { top: el.scrollTop, left: el.scrollLeft }); return; }
154
+ marqueeStart.y -= el.scrollTop - rec.top;
155
+ marqueeStart.x -= el.scrollLeft - rec.left;
156
+ rec.top = el.scrollTop;
157
+ rec.left = el.scrollLeft;
158
+ marqueeApply();
159
+ }
160
+ function marqueeApply() {
161
+ if (!marqueeStart || !marqueeLast) return;
162
+ const cx = marqueeLast.x;
163
+ const cy = marqueeLast.y;
164
+ const dx = cx - marqueeStart.x;
165
+ const dy = cy - marqueeStart.y;
166
+ if (!marqueeEl) {
167
+ if (Math.abs(dx) < 4 && Math.abs(dy) < 4) return;
168
+ marqueeEl = document.createElement('div');
169
+ marqueeEl.className = 'marquee';
170
+ document.body.appendChild(marqueeEl);
171
+ }
172
+ const x = Math.min(cx, marqueeStart.x);
173
+ const y = Math.min(cy, marqueeStart.y);
174
+ const w = Math.abs(dx);
175
+ const h = Math.abs(dy);
176
+ Object.assign(marqueeEl.style, { left: `${x}px`, top: `${y}px`, width: `${w}px`, height: `${h}px` });
177
+ selectedIds.clear();
178
+ document.querySelectorAll('#board .card').forEach((c) => {
179
+ const r = c.getBoundingClientRect();
180
+ const hit = !(r.right < x || r.left > x + w || r.bottom < y || r.top > y + h);
181
+ c.classList.toggle('multi-selected', hit);
182
+ if (hit) selectedIds.add(Number(c.dataset.id));
183
+ });
184
+ }
185
+ function marqueeMove(e) {
186
+ if (!marqueeStart) return;
187
+ marqueeLast = { x: e.clientX, y: e.clientY };
188
+ marqueeApply();
189
+ }
190
+ function marqueeAutoScroll() {
191
+ if (!marqueeEl || !marqueeLast) return;
192
+ const EDGE = 48;
193
+ const MAX = 28;
194
+ const speed = (over) => Math.min(MAX, over / 2 + 3);
195
+ const { x, y } = marqueeLast;
196
+ const colBody = [...document.querySelectorAll('#board .col-body')]
197
+ .find((b) => { const r = b.getBoundingClientRect(); return x >= r.left && x <= r.right; });
198
+ if (colBody) {
199
+ const r = colBody.getBoundingClientRect();
200
+ const dy = y > r.bottom - EDGE ? speed(y - (r.bottom - EDGE))
201
+ : y < r.top + EDGE ? -speed((r.top + EDGE) - y) : 0;
202
+ if (dy) { colBody.scrollTop += dy; marqueeTrackScroll(colBody); }
203
+ }
204
+ const board = $('board');
205
+ const br = board.getBoundingClientRect();
206
+ const dxs = x > br.right - EDGE ? speed(x - (br.right - EDGE))
207
+ : x < br.left + EDGE ? -speed((br.left + EDGE) - x) : 0;
208
+ if (dxs) { board.scrollLeft += dxs; marqueeTrackScroll(board); }
209
+ }
210
+ function marqueeOnScroll(e) {
211
+ const el = e.target;
212
+ if (!(el instanceof Element)) return;
213
+ if (el.id === 'board' || el.classList.contains('col-body')) marqueeTrackScroll(el);
214
+ }
215
+ function marqueeUp() {
216
+ if (marqueeEl) { marqueeEl.remove(); marqueeEl = null; }
217
+ marqueeStart = null;
218
+ marqueeLast = null;
219
+ marqueeScrollPos = null;
220
+ clearInterval(marqueeTimer);
221
+ marqueeTimer = null;
222
+ document.removeEventListener('mousemove', marqueeMove);
223
+ document.removeEventListener('mouseup', marqueeUp);
224
+ document.removeEventListener('scroll', marqueeOnScroll, true);
225
+ updateMultiPanel();
226
+ }
227
+ export function initMarquee() {
228
+ $('board').addEventListener('mousedown', (e) => {
229
+ if (e.button !== 0) return;
230
+ if (e.target.closest('.card, button, a, input, textarea, .kbsel')) return;
231
+ clearSelection();
232
+ marqueeStart = { x: e.clientX, y: e.clientY };
233
+ marqueeLast = { x: e.clientX, y: e.clientY };
234
+ const board = $('board');
235
+ marqueeScrollPos = new Map([[board, { top: board.scrollTop, left: board.scrollLeft }]]);
236
+ board.querySelectorAll('.col-body').forEach((b) => marqueeScrollPos.set(b, { top: b.scrollTop, left: b.scrollLeft }));
237
+ if (!marqueeTimer) marqueeTimer = setInterval(marqueeAutoScroll, 30);
238
+ document.addEventListener('mousemove', marqueeMove);
239
+ document.addEventListener('mouseup', marqueeUp);
240
+ document.addEventListener('scroll', marqueeOnScroll, true);
241
+ });
242
+ }
243
+
244
+ let linkRaf = null;
245
+ export function scheduleDrawLinks() {
246
+ if (linkRaf) return;
247
+ linkRaf = requestAnimationFrame(() => { linkRaf = null; drawLinks(); });
248
+ }
249
+
250
+ export function drawLinks() {
251
+ const svg = $('link-lines');
252
+ const board = $('board');
253
+ if (!svg || !board) return;
254
+ const br = board.getBoundingClientRect();
255
+ const mr = board.parentElement.getBoundingClientRect();
256
+ svg.style.left = `${br.left - mr.left}px`;
257
+ svg.style.top = `${br.top - mr.top}px`;
258
+ svg.style.width = `${br.width}px`;
259
+ svg.style.height = `${br.height}px`;
260
+ svg.setAttribute('viewBox', `0 0 ${br.width} ${br.height}`);
261
+
262
+ const seen = new Set();
263
+ const offScreen = {};
264
+ const linesOn = getSetting('linkLines') !== false;
265
+ const cardEls = new Map();
266
+ for (const el of board.querySelectorAll('.card')) cardEls.set(Number(el.dataset.id), el);
267
+ let html = '';
268
+ for (const t of state.tasks) {
269
+ for (const other of (t.linked_ids || [])) {
270
+ const pair = t.id < other ? `${t.id}.${other}` : `${other}.${t.id}`;
271
+ if (seen.has(pair)) continue;
272
+ seen.add(pair);
273
+ const elA = cardEls.get(t.id);
274
+ const elB = cardEls.get(other);
275
+ if (!linesOn || !elA || !elB) {
276
+ if (elA) offScreen[t.id] = (offScreen[t.id] || 0) + 1;
277
+ if (elB) offScreen[other] = (offScreen[other] || 0) + 1;
278
+ continue;
279
+ }
280
+ const ra = elA.getBoundingClientRect();
281
+ const rb = elB.getBoundingClientRect();
282
+ const acx = (ra.left + ra.right) / 2 - br.left;
283
+ const bcx = (rb.left + rb.right) / 2 - br.left;
284
+ let ax, ay, bx, by, c1x, c1y, c2x, c2y;
285
+ if (Math.abs(acx - bcx) < 70) {
286
+ const aUp = ra.top < rb.top, up = aUp ? ra : rb, lo = aUp ? rb : ra;
287
+ const edge = Math.min(up.left, lo.left) - br.left;
288
+ ax = edge; ay = (up.top + up.bottom) / 2 - br.top;
289
+ bx = edge; by = (lo.top + lo.bottom) / 2 - br.top;
290
+ const bow = Math.max(12, Math.min(34, (by - ay) * 0.09));
291
+ const mx = Math.max(2, edge - bow);
292
+ c1x = mx; c1y = ay + (by - ay) * 0.25;
293
+ c2x = mx; c2y = by - (by - ay) * 0.25;
294
+ } else {
295
+ ax = acx; ay = ra.bottom - br.top;
296
+ bx = bcx; by = rb.bottom - br.top;
297
+ const k = Math.max(22, Math.min(70, Math.abs(bx - ax) * 0.3));
298
+ c1x = ax; c1y = ay + k; c2x = bx; c2y = by + k;
299
+ }
300
+ const d = `M ${ax} ${ay} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${bx} ${by}`;
301
+ html += `<path class="link-line" d="${d}"/>`;
302
+ }
303
+ }
304
+ svg.innerHTML = html;
305
+
306
+ for (const el of board.querySelectorAll('.card-link')) {
307
+ const card = el.closest('.card');
308
+ const n = card ? (offScreen[Number(card.dataset.id)] || 0) : 0;
309
+ if (n > 0) { el.classList.remove('hidden'); el.innerHTML = `${ic('link', 12)}${n > 1 ? ' ' + n : ''}`; }
310
+ else el.classList.add('hidden');
311
+ }
312
+ }
313
+
314
+ export function labelChip(name) {
315
+ const color = LABEL_COLORS[name] || '#9CA3AF';
316
+ return `<span class="lbl"><span class="lbl-dot" style="background:${color}"></span>${esc(name)}</span>`;
317
+ }
318
+
319
+ const DRAG_LOCKED = new Set(['prep', 'doing', 'deploy', 'done']);
320
+ export const WORKING = new Set(['prep', 'doing', 'deploy']);
321
+
322
+ export function taskIsNoclaude(t) { return Array.isArray(t?.labels) && t.labels.includes('noclaude'); }
323
+ const CREATABLE = new Set(['backlog', 'todo']);
324
+ const NO_DIRECT_FROM = new Set(['backlog', 'todo']);
325
+ const NO_DIRECT_TO = new Set(['review', 'done']);
326
+
327
+ export function moveBlocked(from, to, { manual = false, isNew = false } = {}) {
328
+ if (isNew) return CREATABLE.has(to) ? null : tr('new tasks go to “Backlog” or “To do” only');
329
+ if (WORKING.has(to) && to !== from && !manual) return tr('working columns are moved by Claude only');
330
+ if (!manual && NO_DIRECT_FROM.has(from) && NO_DIRECT_TO.has(to)) return tr('it has to go through work first');
331
+ return null;
332
+ }
333
+ export function canMoveTo(from, to, task) { return !moveBlocked(from, to, { manual: taskIsNoclaude(task) }); }
334
+
335
+ let dragId = null;
336
+ const dropPh = document.createElement('div');
337
+ dropPh.className = 'drop-ph';
338
+
339
+ export const selectedIds = new Set();
340
+ let dragGroupIds = null;
341
+ export function clearSelection() {
342
+ selectedIds.clear();
343
+ document.querySelectorAll('.card.multi-selected').forEach((c) => c.classList.remove('multi-selected'));
344
+ updateMultiPanel();
345
+ }
346
+ function markSelection() {
347
+ document.querySelectorAll('#board .card').forEach((c) =>
348
+ c.classList.toggle('multi-selected', selectedIds.has(Number(c.dataset.id))));
349
+ updateMultiPanel();
350
+ }
351
+
352
+ function selectedTaskList() {
353
+ return [...selectedIds].map((id) => state.tasks.find((t) => t.id === id)).filter(Boolean);
354
+ }
355
+ function multiActive() {
356
+ return selectedIds.size >= 1;
357
+ }
358
+ function restoreDrawerId() {
359
+ if (state.drawerKey) {
360
+ const t = state.tasks.find((x) => x.key === state.drawerKey);
361
+ const proj = state.projects.find((x) => x.slug === t?.project);
362
+ $('drawer-id').textContent = t ? `${t.key} · ${proj?.name || t.project}` : state.drawerKey;
363
+ } else $('drawer-id').textContent = '';
364
+ }
365
+ function updateMultiPanel() {
366
+ const on = multiActive();
367
+ const drawer = $('drawer');
368
+ const was = drawer.classList.contains('is-multi');
369
+ if (on) {
370
+ drawer.classList.remove('hidden', 'is-empty');
371
+ drawer.classList.add('is-multi');
372
+ renderMultiPanel();
373
+ if (!was) scheduleDrawLinks();
374
+ return;
375
+ }
376
+ if (!was) return;
377
+ drawer.classList.remove('is-multi');
378
+ if (state.drawerKey) restoreDrawerId();
379
+ else if (getSetting('keepDrawer')) { drawer.classList.add('is-empty'); $('drawer-id').textContent = ''; }
380
+ else drawer.classList.add('hidden');
381
+ scheduleDrawLinks();
382
+ }
383
+ function renderMultiPanel() {
384
+ const tasks = selectedTaskList();
385
+ const n = tasks.length;
386
+ const word = plural(n, 'task', 'tasks');
387
+ $('drawer-id').textContent = '';
388
+ const box = $('drawer-multi');
389
+ const tile = (act, glyph, label, extra = '') => `<button class="multi-tile${extra}" data-act="${act}"><span class="mt-ico">${glyph}</span><span class="mt-lab">${label}</span></button>`;
390
+ box.innerHTML = `<div class="multi-head">${tr('Selected')}: <b>${n}</b> ${word}</div>`
391
+ + '<div class="multi-grid">'
392
+ + tile('status', ic('status', 15), tr('Status'))
393
+ + tile('priority', ic('flag', 15), tr('Priority'))
394
+ + tile('label', ic('tag', 15), tr('Label'))
395
+ + tile('pin', ic('pin', 15), tr('Pin'))
396
+ + (n >= 2 ? tile('link', ic('link', 15), tr('Link')) : '')
397
+ + tile('dup', ic('duplicate', 15), tr('Duplicate'))
398
+ + tile('del', ic('trash', 15), tr('Delete'), ' danger')
399
+ + '</div>'
400
+ + `<button class="multi-clear" data-act="clear">${tr('Clear selection')}</button>`;
401
+ box.querySelectorAll('[data-act]').forEach((b) => { b.onclick = () => multiAction(b.dataset.act, b); });
402
+ }
403
+ async function multiPatchEach(bodyFor) {
404
+ const tasks = selectedTaskList();
405
+ for (const t of tasks) { try { await api('PATCH', `/api/tasks/${seg(t.key)}`, bodyFor(t), { quiet: true }); } catch { } }
406
+ await refresh();
407
+ }
408
+ function renderMultiSub(title, optsHTML, onPick) {
409
+ const box = $('drawer-multi');
410
+ box.innerHTML = `<div class="multi-head"><button class="multi-back" data-back="1">${tr('‹ Back')}</button>${esc(title)}</div><div class="multi-sub">${optsHTML}</div>`;
411
+ box.querySelector('[data-back]').onclick = () => renderMultiPanel();
412
+ box.querySelectorAll('.cm-opt').forEach((o) => { o.onclick = () => onPick(o); });
413
+ }
414
+ export async function multiAction(act, btn) {
415
+ const tasks = selectedTaskList();
416
+ if (!tasks.length) return;
417
+ if (act === 'clear') { clearSelection(); return; }
418
+ if (act === 'status') {
419
+ const opts = ALL_STATUSES.map(([v, lab]) => {
420
+ const dis = tasks.every((t) => moveBlocked(t.status, v, { manual: taskIsNoclaude(t) }));
421
+ return `<div class="cm-opt${dis ? ' disabled' : ''}" data-v="${v}"><span class="dot s-${v}"></span>${lab}</div>`;
422
+ }).join('');
423
+ renderMultiSub(tr('Move to status'), opts, async (o) => {
424
+ if (o.classList.contains('disabled')) return;
425
+ await multiPatchEach(() => ({ status: o.dataset.v }));
426
+ renderMultiPanel();
427
+ });
428
+ return;
429
+ }
430
+ if (act === 'priority') {
431
+ const opts = PRI_LEVELS.map(([v, lab]) => `<div class="cm-opt" data-v="${v}">${lab}</div>`).join('');
432
+ renderMultiSub(tr('Priority'), opts, async (o) => { await multiPatchEach(() => ({ priority: Number(o.dataset.v) })); renderMultiPanel(); });
433
+ return;
434
+ }
435
+ if (act === 'label') {
436
+ const opts = LABEL_SELECTABLE.map((name) =>
437
+ `<div class="cm-opt lbl-row" data-name="${name}"><span class="lbl-dot" style="width:9px;height:9px;border-radius:50%;background:${LABEL_COLORS[name]}"></span>${esc(name)}</div>`).join('');
438
+ renderMultiSub(tr('Add a label to all'), opts, async (o) => {
439
+ const name = o.dataset.name;
440
+ await multiPatchEach((t) => ({ labels: [...new Set([...(t.labels || []), name])] }));
441
+ renderMultiPanel();
442
+ });
443
+ return;
444
+ }
445
+ if (act === 'pin') {
446
+ const allPinned = tasks.every((t) => t.pinned);
447
+ await multiPatchEach(() => ({ pinned: allPinned ? 0 : 1 }));
448
+ renderMultiPanel();
449
+ return;
450
+ }
451
+ if (act === 'link') {
452
+ if (tasks.length < 2) return;
453
+ const [head, ...rest] = tasks;
454
+ for (const t of rest) { try { await api('POST', `/api/tasks/${seg(head.key)}/links`, { key: t.key, rel: 'related' }, { quiet: true }); } catch { } }
455
+ await refresh();
456
+ renderMultiPanel();
457
+ return;
458
+ }
459
+ if (act === 'dup') {
460
+ for (const t of tasks) { try { await api('POST', `/api/tasks/${seg(t.key)}/duplicate`, undefined, { quiet: true }); } catch { } }
461
+ clearSelection();
462
+ await refresh();
463
+ return;
464
+ }
465
+ if (act === 'del') {
466
+ if (!await styledConfirm(`${tr('Delete')} ${tasks.length} ${plural(tasks.length, 'task to delete', 'tasks')} ${tr('permanently?')}`, { okLabel: tr('Delete'), danger: true })) return;
467
+ for (const t of tasks) { try { await api('DELETE', `/api/tasks/${seg(t.key)}`); } catch { } }
468
+ clearSelection();
469
+ await refresh();
470
+ }
471
+ }
472
+
473
+ function dragAfterCard(body, y, draggedId) {
474
+ const cards = [...body.querySelectorAll('.card')].filter((c) => Number(c.dataset.id) !== draggedId);
475
+ return cards.find((c) => y < c.getBoundingClientRect().top + c.offsetHeight / 2) || null;
476
+ }
477
+
478
+ export function fmtWork(sec) {
479
+ if (sec >= 3600) return `${Math.floor(sec / 3600)}${tr('h')} ${String(Math.floor((sec % 3600) / 60)).padStart(2, '0')}${tr('m')}`;
480
+ return `${Math.floor(sec / 60)}:${String(Math.floor(sec % 60)).padStart(2, '0')}`;
481
+ }
482
+
483
+ function workTimer(t) {
484
+ const running = !!t.work_started_at && WORKING.has(t.status);
485
+ if (!t.work_seconds && !running) return '';
486
+ const started = running ? new Date(t.work_started_at + 'Z').getTime() : 0;
487
+ const cut = t.work_truncated > 0;
488
+ const title = cut ? `${tr('time spent')} — ${tr('capped: the task sat in a working column too long')}` : tr('time spent');
489
+ return `<span class="timer${running ? ' running' : ''}" data-base="${t.work_seconds}" data-started="${started}" title="${esc(title)}">${ic('timer', 12)} ${cut ? '≈' : ''}${fmtWork(t.work_seconds + (running ? (Date.now() - started) / 1000 : 0))}</span>`;
490
+ }
491
+
492
+ const STALE_MS = 2 * 3600 * 1000;
493
+ function staleHours(t) {
494
+ if (!WORKING.has(t.status)) return 0;
495
+ const idle = Date.now() - new Date(t.updated_at + 'Z').getTime();
496
+ return idle > STALE_MS ? Math.floor(idle / 3600000) : 0;
497
+ }
498
+
499
+ const ORDERING = ['status', 'priority', 'position', 'pinned'];
500
+ export function patchCard(before, after) {
501
+ const el = $('board')?.querySelector(`.card[data-id="${after.id}"]`);
502
+ if (!el || !el.parentNode) return false;
503
+ if (ORDERING.some((k) => before[k] !== after[k])) return false;
504
+ el.replaceWith(cardEl(after));
505
+ markKbCursor();
506
+ markSelection();
507
+ scheduleDrawLinks();
508
+ return true;
509
+ }
510
+
511
+ function cardEl(t) {
512
+ const stale = staleHours(t);
513
+ const el = document.createElement('div');
514
+ el.className = 'card' + (WORKING.has(t.status) ? (stale ? ' stale' : ' working') : '')
515
+ + (state.drawerKey === t.key ? ' selected' : '');
516
+ el.draggable = taskIsNoclaude(t) || !DRAG_LOCKED.has(t.status);
517
+ el.dataset.id = t.id;
518
+ const pri = PRI_ICON[t.priority];
519
+ const linkedKeys = (t.linked_ids || []).map((id) => state.tasks.find((x) => x.id === id)?.key).filter(Boolean);
520
+ el.innerHTML = `
521
+ ${t.pinned ? `<span class="card-pin" title="${esc(tr('needs attention'))}">${ic('pin', 12)}</span>` : ''}
522
+ <div class="card-key">${t.key}${state.slug === ALL ? ` <span class="card-proj">· ${esc(t.project)}</span>` : ''}</div>
523
+ ${t.title
524
+ ? `<div class="card-title">${esc(t.title)}</div>`
525
+ : (t.preview ? '' : `<div class="card-title card-title-empty">${tr('untitled')}</div>`)}
526
+ ${t.preview ? `<div class="card-preview">${esc(t.preview)}</div>` : ''}
527
+ <div class="card-foot">
528
+ ${workTimer(t)}
529
+ ${stale ? `<span class="chip-stale" title="${tr('no movement — the chat is probably closed')}">${tr('stuck')} ${stale}${tr('h')}</span>` : ''}
530
+ ${pri ? `<span class="pri ${pri[1]}" title="${esc(tr('priority:'))} ${pri[2]}">${pri[0]}</span>` : ''}
531
+ ${t.labels.map(labelChip).join('')}
532
+ ${linkedKeys.length ? `<span class="card-link hidden" title="${esc(tr('linked to:'))} ${esc(linkedKeys.join(', '))}">${ic('link', 12)}</span>` : ''}
533
+ ${t.attachments_n ? `<span class="card-attach" title="${esc(tr('attachments:'))} ${t.attachments_n}">${ic('attach', 12)} ${t.attachments_n}</span>` : ''}
534
+ ${t.comments_n ? `<span class="card-comments">${ic('comment', 12)} ${t.comments_n}</span>` : ''}
535
+ </div>`;
536
+ el.onclick = () => { setKbCursor(t.id, false); openDrawer(t.key); };
537
+ el.oncontextmenu = (e) => openCardMenu(e, t);
538
+ if (el.draggable) {
539
+ el.ondragstart = (e) => {
540
+ if (!selectedIds.has(t.id)) clearSelection();
541
+ dragGroupIds = (selectedIds.has(t.id) && selectedIds.size > 1) ? [...selectedIds] : null;
542
+ e.dataTransfer.setData('text/plain', t.id);
543
+ e.dataTransfer.effectAllowed = 'move';
544
+ dragId = t.id;
545
+ document.body.classList.add('dragging-board');
546
+ dropPh.style.height = `${el.offsetHeight}px`;
547
+ const r = el.getBoundingClientRect();
548
+ const ghost = el.cloneNode(true);
549
+ ghost.classList.remove('selected', 'just-dropped', 'multi-selected');
550
+ ghost.classList.add('drag-ghost');
551
+ ghost.style.width = `${el.offsetWidth}px`;
552
+ if (dragGroupIds) ghost.insertAdjacentHTML('beforeend', `<span class="drag-count">${dragGroupIds.length}</span>`);
553
+ document.body.appendChild(ghost);
554
+ e.dataTransfer.setDragImage(ghost, e.clientX - r.left, e.clientY - r.top);
555
+ setTimeout(() => ghost.remove(), 0);
556
+ setTimeout(() => {
557
+ if (dragGroupIds) dragGroupIds.forEach((id) => $('board').querySelector(`.card[data-id="${id}"]`)?.classList.add('dragging'));
558
+ else el.classList.add('dragging');
559
+ }, 0);
560
+ };
561
+ el.ondragend = () => {
562
+ document.querySelectorAll('.card.dragging').forEach((c) => c.classList.remove('dragging'));
563
+ dropPh.remove();
564
+ document.querySelectorAll('.col-body.dragover').forEach((b) => b.classList.remove('dragover'));
565
+ document.body.classList.remove('dragging-board');
566
+ dragId = null;
567
+ dragGroupIds = null;
568
+ };
569
+ }
570
+ return el;
571
+ }
572
+
573
+ setInterval(() => {
574
+ for (const el of document.querySelectorAll('.timer.running')) {
575
+ const base = Number(el.dataset.base);
576
+ const started = Number(el.dataset.started);
577
+ el.innerHTML = `${ic('timer', 12)} ${esc(fmtWork(base + (Date.now() - started) / 1000))}`;
578
+ }
579
+ }, 1000);
580
+
581
+ async function onDrop(e, status, body) {
582
+ e.preventDefault();
583
+ body.classList.remove('dragover');
584
+ const id = Number(e.dataTransfer.getData('text/plain'));
585
+ const t = state.tasks.find((x) => x.id === id);
586
+ if (!t) return;
587
+ if (dragGroupIds && dragGroupIds.length > 1) return onDropGroup(e, status, body);
588
+ if (moveBlocked(t.status, status, { manual: taskIsNoclaude(t) })) return;
589
+
590
+ const cards = [...body.querySelectorAll('.card')].filter((c) => Number(c.dataset.id) !== id);
591
+ const y = e.clientY;
592
+ let idx = cards.findIndex((c) => y < c.getBoundingClientRect().top + c.offsetHeight / 2);
593
+ if (idx === -1) idx = cards.length;
594
+ const colTasks = state.tasks
595
+ .filter((x) => x.status === status && x.id !== id)
596
+ .sort((a, b) => b.priority - a.priority || a.position - b.position || a.id - b.id);
597
+ const prev = colTasks[idx - 1]?.position ?? 0;
598
+ const next = colTasks[idx]?.position ?? prev + 2;
599
+ const position = (prev + next) / 2;
600
+
601
+ const card = $('board').querySelector(`.card[data-id="${id}"]`);
602
+ if (card) {
603
+ body.insertBefore(card, dropPh);
604
+ card.classList.remove('dragging');
605
+ card.classList.add('just-dropped');
606
+ card.addEventListener('animationend', () => card.classList.remove('just-dropped'), { once: true });
607
+ }
608
+ dropPh.remove();
609
+ try {
610
+ await api('PATCH', `/api/tasks/${seg(id)}`, { status, position });
611
+ } catch (e) {
612
+ renderBoard();
613
+ if (e?.offline) noteUnsaved(() => api('PATCH', `/api/tasks/${seg(id)}`, { status, position }).then(refresh));
614
+ return;
615
+ }
616
+ await refresh();
617
+ }
618
+
619
+ async function onDropGroup(e, status, body) {
620
+ const ids = dragGroupIds;
621
+ const movable = ids
622
+ .map((id) => state.tasks.find((x) => x.id === id))
623
+ .filter(Boolean)
624
+ .filter((t) => !DRAG_LOCKED.has(t.status))
625
+ .filter((t) => !moveBlocked(t.status, status, { manual: taskIsNoclaude(t) }));
626
+ const refused = ids.map((id) => state.tasks.find((x) => x.id === id)).filter(Boolean)
627
+ .filter((t) => !movable.includes(t));
628
+ if (refused.length) {
629
+ const why = refused.map((t) => moveBlocked(t.status, status, { manual: taskIsNoclaude(t) }))
630
+ .find(Boolean) || tr('Claude moves these columns itself');
631
+ styledAlert(`${refused.length} ${plural(refused.length, 'task', 'tasks')}: ${why}`);
632
+ }
633
+ if (!movable.length) { dropPh.remove(); return; }
634
+
635
+ const draggedSet = new Set(movable.map((t) => t.id));
636
+ const cards = [...body.querySelectorAll('.card')].filter((c) => !draggedSet.has(Number(c.dataset.id)));
637
+ const y = e.clientY;
638
+ let idx = cards.findIndex((c) => y < c.getBoundingClientRect().top + c.offsetHeight / 2);
639
+ if (idx === -1) idx = cards.length;
640
+ const colTasks = state.tasks
641
+ .filter((x) => x.status === status && !draggedSet.has(x.id))
642
+ .sort((a, b) => b.priority - a.priority || a.position - b.position || a.id - b.id);
643
+ const prev = colTasks[idx - 1]?.position ?? 0;
644
+ const next = colTasks[idx]?.position ?? prev + movable.length + 1;
645
+ const step = (next - prev) / (movable.length + 1);
646
+
647
+ dropPh.remove();
648
+ for (let i = 0; i < movable.length; i++) {
649
+ await api('PATCH', `/api/tasks/${seg(movable[i].id)}`, { status, position: prev + step * (i + 1) });
650
+ }
651
+ clearSelection();
652
+ await refresh();
653
+ }
654
+
655
+ export function buildSelect(host, opts) {
656
+ host.classList.add('kbsel');
657
+ host.dataset.value = opts.value == null ? '' : String(opts.value);
658
+ const cur = () => opts.options.find((o) => String(o.value) === host.dataset.value);
659
+ const optHTML = (o) => `${o.dot ? `<span class="dot s-${o.dot}"></span>` : ''}${esc(o.label)}`;
660
+ const searchHTML = opts.search ? `<div class="kbsel-search"><input type="text" class="kbsel-search-input" placeholder="${tr('search boards…')}"></div>` : '';
661
+ host.innerHTML = `<button type="button" class="kbsel-btn"><span class="kbsel-val"></span><span class="kbsel-chev">▾</span></button>`
662
+ + `<div class="kbsel-list">${searchHTML}${opts.options.map((o) =>
663
+ `<div class="kbsel-opt${o.rightHTML ? ' has-r' : ''}${host.dataset.value === String(o.value) ? ' active' : ''}${o.disabled ? ' disabled' : ''}" data-v="${esc(String(o.value))}">${optHTML(o)}${o.rightHTML ? `<span class="kbsel-opt-r">${o.rightHTML}</span>` : ''}</div>`).join('')}</div>`;
664
+ const valBox = host.querySelector('.kbsel-val');
665
+ const renderBtn = () => { const c = cur(); valBox.innerHTML = c ? optHTML(c) : esc(opts.placeholder || ''); };
666
+ renderBtn();
667
+ const list = host.querySelector('.kbsel-list');
668
+ function filterOpts(q) {
669
+ const qq = q.trim().toLowerCase();
670
+ list.querySelectorAll('.kbsel-opt').forEach((o) => o.classList.toggle('hidden', !!qq && !o.textContent.toLowerCase().includes(qq)));
671
+ }
672
+ const close = () => { host.classList.remove('open'); document.removeEventListener('click', outside); };
673
+ function outside(e) { if (!host.contains(e.target)) close(); }
674
+ host.querySelector('.kbsel-btn').onclick = (e) => {
675
+ e.stopPropagation();
676
+ if (host.classList.contains('open')) { close(); return; }
677
+ document.querySelectorAll('.kbsel.open').forEach((el) => { if (el !== host) el.classList.remove('open'); });
678
+ host.classList.add('open');
679
+ if (opts.search) { const si = host.querySelector('.kbsel-search-input'); si.value = ''; filterOpts(''); setTimeout(() => si.focus(), 0); }
680
+ setTimeout(() => document.addEventListener('click', outside), 0);
681
+ };
682
+ if (opts.search) {
683
+ const si = host.querySelector('.kbsel-search-input');
684
+ si.oninput = () => filterOpts(si.value);
685
+ si.onclick = (e) => e.stopPropagation();
686
+ }
687
+ list.querySelectorAll('.kbsel-opt:not(.disabled)').forEach((o) => {
688
+ o.onclick = (e) => {
689
+ e.stopPropagation();
690
+ host.dataset.value = o.dataset.v;
691
+ list.querySelectorAll('.kbsel-opt').forEach((x) => x.classList.toggle('active', x === o));
692
+ renderBtn(); close();
693
+ opts.onChange?.(o.dataset.v);
694
+ };
695
+ });
696
+ }
697
+ export function selVal(host) { return (typeof host === 'string' ? $(host) : host).dataset.value; }
698
+ export function closeOpenSelect() { document.querySelectorAll('.kbsel.open').forEach((el) => el.classList.remove('open')); }