local-kanban 1.14.0 → 1.15.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/CHANGELOG.md +28 -0
- package/bin/kb +4 -2
- package/package.json +1 -1
- package/public/js/board.js +92 -25
- package/public/js/core.js +6 -0
- package/public/js/drawer.js +4 -3
- package/public/js/init.js +1 -1
- package/public/js/order.js +37 -0
- package/public/style.css +8 -1
- package/skills/kanban/SKILL.md +14 -2
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,34 @@ version it goes out as and opens an empty one again. Released sections are histo
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [1.15.0]
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **A column can be sorted by task number.** Every column with more than one card has a sort button
|
|
16
|
+
in its header: by priority and your own order (as before), by number with the newest first, or by
|
|
17
|
+
number with the oldest first. Pinned cards stay on top either way. The choice is remembered per
|
|
18
|
+
board and column in this browser and changes only what you see: the order `kb take` follows stays
|
|
19
|
+
the same. While a column is sorted by number its cards cannot be reordered by hand, and a card
|
|
20
|
+
dragged in from another column lands where its number puts it.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- **`kb link` refuses a link type it does not know, whatever the word.** `kb link A B constructor` (or
|
|
25
|
+
`toString`) was taken for a valid type, because every object answers for those names, and the
|
|
26
|
+
board quietly made the link `related`. Now it is refused with exit code 2 before anything is
|
|
27
|
+
written, like any other unknown word.
|
|
28
|
+
|
|
29
|
+
## [1.14.1]
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- **An `ask` task goes back to To do when the discussion ends without the work.** The skill told
|
|
34
|
+
Claude to ask and wait, but not what comes after: a task that was only discussed went to Review,
|
|
35
|
+
where a bare proposal reads as delivered, or stayed in Prep, a column the human cannot move a card
|
|
36
|
+
out of. Now Claude leaves the outcome of the discussion as a note and moves the card back to To do;
|
|
37
|
+
Review is only for work that was actually done.
|
|
38
|
+
|
|
11
39
|
## [1.14.0]
|
|
12
40
|
|
|
13
41
|
### Added
|
package/bin/kb
CHANGED
|
@@ -309,10 +309,12 @@ const commands = {
|
|
|
309
309
|
|
|
310
310
|
// kb link A B [child|parent|related] — `child`: B becomes a sub-task of A, `parent`: B becomes
|
|
311
311
|
// A's parent. Without a third word the link is plain `related`, as it always was. A pair holds
|
|
312
|
-
// one link, so linking again with another word re-types it.
|
|
312
|
+
// one link, so linking again with another word re-types it. The word is looked up as an OWN key:
|
|
313
|
+
// `REL[rel]` also answers for `constructor`, `toString` or `__proto__` (they come from the
|
|
314
|
+
// prototype), and such a word went to the board, which quietly made the link `related`.
|
|
313
315
|
async link() {
|
|
314
316
|
const [id, other, rel = 'related'] = args;
|
|
315
|
-
if (!id || !other || !REL
|
|
317
|
+
if (!id || !other || !Object.hasOwn(REL, rel)) die('kb link <key> <key> [child|parent|related] (child: the second task becomes a sub-task of the first)');
|
|
316
318
|
const links = await api('POST', `/api/tasks/${id}/links`, { key: other, rel });
|
|
317
319
|
console.log(linksLine(id, links));
|
|
318
320
|
},
|
package/package.json
CHANGED
package/public/js/board.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
|
|
2
2
|
import { $, ALL, ALL_STATUSES, api, DONE_LIMIT, esc, ic, LABEL_COLORS, LABEL_SELECTABLE, noteUnsaved, popoverPlacement, PRI_ICON, PRI_LEVELS, seg, state, tr } from './core.js';
|
|
3
3
|
import { markKbCursor, openCardMenu, openDrawer, openDrawerNew, setKbCursor, statusOptionList } from './drawer.js';
|
|
4
|
+
import { columnComparator, DEFAULT_SORT, orderColumn, slotBefore, sortMode } from './order.js';
|
|
4
5
|
import { getSetting, plural } from './settings.js';
|
|
5
|
-
import { styledAlert, styledConfirm } from './sidebar.js';
|
|
6
|
+
import { popLayer, pushLayer, styledAlert, styledConfirm } from './sidebar.js';
|
|
6
7
|
import { copyBacklogPrompt, copyTodoPrompt, refresh } from './sse.js';
|
|
7
8
|
|
|
8
9
|
function matchesSearch(t) {
|
|
@@ -38,19 +39,22 @@ export function renderBoard() {
|
|
|
38
39
|
const columns = ALL_STATUSES.map(([k]) => k)
|
|
39
40
|
.filter((k) => k !== 'cancelled' || state.tasks.some((t) => t.status === 'cancelled'));
|
|
40
41
|
for (const key of columns) {
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
}
|
|
42
|
+
const mode = colSort(key);
|
|
43
|
+
const tasks = orderColumn(state.tasks.filter((t) => t.status === key && matchesSearch(t)), mode, colOrderOpts(key));
|
|
48
44
|
|
|
49
45
|
const col = document.createElement('div');
|
|
50
46
|
col.className = 'col';
|
|
51
47
|
const head = document.createElement('div');
|
|
52
48
|
head.className = 'col-head';
|
|
53
49
|
head.innerHTML = `<span class="dot s-${key}"></span>${labels[key]} <span class="n">${tasks.length}</span>`;
|
|
50
|
+
if (tasks.length > 1 || mode !== DEFAULT_SORT) {
|
|
51
|
+
const sort = document.createElement('button');
|
|
52
|
+
sort.className = `btn-icon col-sort${mode !== DEFAULT_SORT ? ' on' : ''}`;
|
|
53
|
+
sort.innerHTML = ic('sort', 13);
|
|
54
|
+
sort.title = mode === DEFAULT_SORT ? tr('Sort the column') : tr(SORT_LABELS[mode]);
|
|
55
|
+
sort.onclick = (e) => { e.stopPropagation(); openColSortMenu(sort, key); };
|
|
56
|
+
head.appendChild(sort);
|
|
57
|
+
}
|
|
54
58
|
if (CREATABLE.has(key)) {
|
|
55
59
|
const add = document.createElement('button');
|
|
56
60
|
add.className = 'btn-icon col-add';
|
|
@@ -88,7 +92,7 @@ export function renderBoard() {
|
|
|
88
92
|
e.preventDefault();
|
|
89
93
|
body.classList.add('dragover');
|
|
90
94
|
if (dragId == null) return;
|
|
91
|
-
const after = dragAfterCard(body, e.clientY, dragId);
|
|
95
|
+
const after = colSort(key) === DEFAULT_SORT ? dragAfterCard(body, e.clientY, dragId) : sortedSlotCard(body, key, dt);
|
|
92
96
|
if (dropPh.parentNode !== body || dropPh.nextSibling !== after) body.insertBefore(dropPh, after);
|
|
93
97
|
};
|
|
94
98
|
body.ondragleave = (e) => { if (!body.contains(e.relatedTarget)) body.classList.remove('dragover'); };
|
|
@@ -470,6 +474,66 @@ export async function multiAction(act, btn) {
|
|
|
470
474
|
}
|
|
471
475
|
}
|
|
472
476
|
|
|
477
|
+
const SORT_KEY = 'kb.ui.colSort';
|
|
478
|
+
const SORT_LABELS = {
|
|
479
|
+
manual: 'By priority, then by hand',
|
|
480
|
+
'num-desc': 'By number, newest first',
|
|
481
|
+
'num-asc': 'By number, oldest first',
|
|
482
|
+
};
|
|
483
|
+
function readColSorts() {
|
|
484
|
+
try {
|
|
485
|
+
const v = JSON.parse(localStorage.getItem(SORT_KEY));
|
|
486
|
+
return v && typeof v === 'object' && !Array.isArray(v) ? v : {};
|
|
487
|
+
} catch { return {}; }
|
|
488
|
+
}
|
|
489
|
+
export function colSort(status) { return sortMode(readColSorts()[`${state.slug}:${status}`]); }
|
|
490
|
+
function setColSort(status, mode) {
|
|
491
|
+
const all = readColSorts();
|
|
492
|
+
const k = `${state.slug}:${status}`;
|
|
493
|
+
if (sortMode(mode) === DEFAULT_SORT) delete all[k]; else all[k] = mode;
|
|
494
|
+
try { localStorage.setItem(SORT_KEY, JSON.stringify(all)); } catch { }
|
|
495
|
+
renderBoard();
|
|
496
|
+
}
|
|
497
|
+
function colOrderOpts(status) {
|
|
498
|
+
return { mixed: state.slug === ALL, closed: status === 'done' || status === 'cancelled', limit: DONE_LIMIT };
|
|
499
|
+
}
|
|
500
|
+
function sortedSlotCard(body, status, t) {
|
|
501
|
+
const id = slotBefore(state.tasks.filter((x) => x.status === status && matchesSearch(x)), t, colSort(status), colOrderOpts(status));
|
|
502
|
+
return id == null ? null : body.querySelector(`.card[data-id="${id}"]`);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function closeColSortMenu() {
|
|
506
|
+
popLayer('col-sort-menu');
|
|
507
|
+
$('col-sort-menu')?.remove();
|
|
508
|
+
document.removeEventListener('click', colSortOutside);
|
|
509
|
+
}
|
|
510
|
+
function colSortOutside(e) { if (!e.target.closest('#col-sort-menu')) closeColSortMenu(); }
|
|
511
|
+
function openColSortMenu(anchor, status) {
|
|
512
|
+
const was = $('col-sort-menu')?.dataset.status;
|
|
513
|
+
closeColSortMenu();
|
|
514
|
+
if (was === status) return;
|
|
515
|
+
pushLayer('col-sort-menu', closeColSortMenu);
|
|
516
|
+
const menu = document.createElement('div');
|
|
517
|
+
menu.className = 'drawer-menu-pop col-sort-menu';
|
|
518
|
+
menu.id = 'col-sort-menu';
|
|
519
|
+
menu.dataset.status = status;
|
|
520
|
+
const cur = colSort(status);
|
|
521
|
+
menu.innerHTML = Object.entries(SORT_LABELS).map(([v, label]) =>
|
|
522
|
+
`<div class="dm-item${v === cur ? ' on' : ''}" data-v="${v}">${esc(tr(label))}${v === cur ? '<span class="dm-chev">✓</span>' : ''}</div>`).join('')
|
|
523
|
+
+ `<div class="dm-sep"></div><div class="dm-hint">${esc(tr('Sorted by number, cards cannot be reordered by hand'))}</div>`;
|
|
524
|
+
document.body.appendChild(menu);
|
|
525
|
+
const r = anchor.getBoundingClientRect();
|
|
526
|
+
let left = r.left;
|
|
527
|
+
if (left + menu.offsetWidth > window.innerWidth - 8) left = window.innerWidth - menu.offsetWidth - 8;
|
|
528
|
+
menu.style.left = `${Math.max(8, left)}px`;
|
|
529
|
+
menu.style.top = `${r.bottom + 4}px`;
|
|
530
|
+
menu.querySelectorAll('.dm-item').forEach((el) => {
|
|
531
|
+
el.onclick = () => { closeColSortMenu(); if (el.dataset.v !== cur) setColSort(status, el.dataset.v); };
|
|
532
|
+
});
|
|
533
|
+
requestAnimationFrame(() => menu.classList.add('open'));
|
|
534
|
+
document.addEventListener('click', colSortOutside);
|
|
535
|
+
}
|
|
536
|
+
|
|
473
537
|
function dragAfterCard(body, y, draggedId) {
|
|
474
538
|
const cards = [...body.querySelectorAll('.card')].filter((c) => Number(c.dataset.id) !== draggedId);
|
|
475
539
|
return cards.find((c) => y < c.getBoundingClientRect().top + c.offsetHeight / 2) || null;
|
|
@@ -587,16 +651,19 @@ async function onDrop(e, status, body) {
|
|
|
587
651
|
if (dragGroupIds && dragGroupIds.length > 1) return onDropGroup(e, status, body);
|
|
588
652
|
if (moveBlocked(t.status, status, { manual: taskIsNoclaude(t) })) return;
|
|
589
653
|
|
|
590
|
-
const
|
|
591
|
-
|
|
592
|
-
let
|
|
593
|
-
if (
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
.
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
654
|
+
const fixedOrder = colSort(status) !== DEFAULT_SORT;
|
|
655
|
+
if (fixedOrder && t.status === status) { dropPh.remove(); return; }
|
|
656
|
+
let patch = { status };
|
|
657
|
+
if (!fixedOrder) {
|
|
658
|
+
const cards = [...body.querySelectorAll('.card')].filter((c) => Number(c.dataset.id) !== id);
|
|
659
|
+
const y = e.clientY;
|
|
660
|
+
let idx = cards.findIndex((c) => y < c.getBoundingClientRect().top + c.offsetHeight / 2);
|
|
661
|
+
if (idx === -1) idx = cards.length;
|
|
662
|
+
const colTasks = state.tasks.filter((x) => x.status === status && x.id !== id).sort(columnComparator(DEFAULT_SORT));
|
|
663
|
+
const prev = colTasks[idx - 1]?.position ?? 0;
|
|
664
|
+
const next = colTasks[idx]?.position ?? prev + 2;
|
|
665
|
+
patch = { status, position: (prev + next) / 2 };
|
|
666
|
+
}
|
|
600
667
|
|
|
601
668
|
const card = $('board').querySelector(`.card[data-id="${id}"]`);
|
|
602
669
|
if (card) {
|
|
@@ -607,10 +674,10 @@ async function onDrop(e, status, body) {
|
|
|
607
674
|
}
|
|
608
675
|
dropPh.remove();
|
|
609
676
|
try {
|
|
610
|
-
await api('PATCH', `/api/tasks/${seg(id)}`,
|
|
677
|
+
await api('PATCH', `/api/tasks/${seg(id)}`, patch);
|
|
611
678
|
} catch (e) {
|
|
612
679
|
renderBoard();
|
|
613
|
-
if (e?.offline) noteUnsaved(() => api('PATCH', `/api/tasks/${seg(id)}`,
|
|
680
|
+
if (e?.offline) noteUnsaved(() => api('PATCH', `/api/tasks/${seg(id)}`, patch).then(refresh));
|
|
614
681
|
return;
|
|
615
682
|
}
|
|
616
683
|
await refresh();
|
|
@@ -637,16 +704,16 @@ async function onDropGroup(e, status, body) {
|
|
|
637
704
|
const y = e.clientY;
|
|
638
705
|
let idx = cards.findIndex((c) => y < c.getBoundingClientRect().top + c.offsetHeight / 2);
|
|
639
706
|
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);
|
|
707
|
+
const colTasks = state.tasks.filter((x) => x.status === status && !draggedSet.has(x.id)).sort(columnComparator(DEFAULT_SORT));
|
|
643
708
|
const prev = colTasks[idx - 1]?.position ?? 0;
|
|
644
709
|
const next = colTasks[idx]?.position ?? prev + movable.length + 1;
|
|
645
710
|
const step = (next - prev) / (movable.length + 1);
|
|
711
|
+
const fixedOrder = colSort(status) !== DEFAULT_SORT;
|
|
646
712
|
|
|
647
713
|
dropPh.remove();
|
|
648
714
|
for (let i = 0; i < movable.length; i++) {
|
|
649
|
-
|
|
715
|
+
if (fixedOrder && movable[i].status === status) continue;
|
|
716
|
+
await api('PATCH', `/api/tasks/${seg(movable[i].id)}`, fixedOrder ? { status } : { status, position: prev + step * (i + 1) });
|
|
650
717
|
}
|
|
651
718
|
clearSelection();
|
|
652
719
|
await refresh();
|
package/public/js/core.js
CHANGED
|
@@ -13,6 +13,7 @@ const ICONS = {
|
|
|
13
13
|
warn: 'M8 2.6 14.4 13.4H1.6L8 2.6ZM8 6.6v3M8 11.4v.1',
|
|
14
14
|
trash: 'M2.8 4.4h10.4M6.4 4.4V2.9h3.2v1.5M4.2 4.4l.6 8.7a1 1 0 0 0 1 .9h4.4a1 1 0 0 0 1-.9l.6-8.7M6.7 6.9v4.6M9.3 6.9v4.6',
|
|
15
15
|
plus: 'M8 3.4v9.2M3.4 8h9.2',
|
|
16
|
+
sort: 'M4.8 2.8v10.4M4.8 13.2 2.4 10.8M4.8 13.2l2.4-2.4M11.2 13.2V2.8M11.2 2.8 8.8 5.2M11.2 2.8l2.4 2.4',
|
|
16
17
|
download: 'M8 2.6v7.8M5 7.4 8 10.4l3-3M2.8 12.6v.8a1 1 0 0 0 1 1h8.4a1 1 0 0 0 1-1v-.8',
|
|
17
18
|
duplicate: 'M3.4 10.6H2.6a1 1 0 0 1-1-1V2.6a1 1 0 0 1 1-1h7a1 1 0 0 1 1 1v.8M6.4 5.4h7a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1h-7a1 1 0 0 1-1-1v-7a1 1 0 0 1 1-1Z',
|
|
18
19
|
person: 'M8 8.4a2.6 2.6 0 1 0 0-5.2 2.6 2.6 0 0 0 0 5.2ZM3.2 14a4.8 4.8 0 0 1 9.6 0',
|
|
@@ -111,6 +112,9 @@ const I18N_RU = {
|
|
|
111
112
|
'built-in section — cannot be deleted': 'служебный раздел — удалить нельзя',
|
|
112
113
|
'built-in section — the name cannot be changed': 'служебный раздел — имя менять нельзя',
|
|
113
114
|
'Busiest around': 'Жарче всего около',
|
|
115
|
+
'By number, newest first': 'По номеру: сначала новые',
|
|
116
|
+
'By number, oldest first': 'По номеру: сначала старые',
|
|
117
|
+
'By priority, then by hand': 'По приоритету, затем вручную',
|
|
114
118
|
'Calendar': 'Календарь',
|
|
115
119
|
'Cancel': 'Отмена',
|
|
116
120
|
'Cancel task': 'Отменить задачу',
|
|
@@ -582,6 +586,8 @@ const I18N_RU = {
|
|
|
582
586
|
'Setup via Claude': 'Настройка через Claude',
|
|
583
587
|
'Skip': 'Пропустить',
|
|
584
588
|
'Skip — work locally': 'Пропустить — работать локально',
|
|
589
|
+
'Sort the column': 'Сортировка колонки',
|
|
590
|
+
'Sorted by number, cards cannot be reordered by hand': 'При сортировке по номеру карточки вручную не переставить',
|
|
585
591
|
'Sound diagnostics': 'Диагностика звука',
|
|
586
592
|
'Sound for Done': 'Звук на «Готово»',
|
|
587
593
|
'Sound for Review': 'Звук на «Проверяю»',
|
package/public/js/drawer.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
|
|
2
|
-
import { WORKING, buildSelect, canMoveTo, clearSelection, labelChip, moveBlocked, scheduleDrawLinks, selVal, selectedIds, setScrollToNewCardId, taskIsNoclaude } from './board.js';
|
|
2
|
+
import { WORKING, buildSelect, canMoveTo, clearSelection, colSort, labelChip, moveBlocked, scheduleDrawLinks, selVal, selectedIds, setScrollToNewCardId, taskIsNoclaude } from './board.js';
|
|
3
3
|
import { $, ALL, ALL_STATUSES, DASH, LABEL_COLORS, LABEL_SELECTABLE, PRI_LEVELS, SETTINGS, TITLE_LABEL_WORDS, api, apiBlob, esc, ghSyncOn, ic, noteUnsaved, seg, state, tr } from './core.js';
|
|
4
|
+
import { columnComparator, DEFAULT_SORT } from './order.js';
|
|
4
5
|
import { getSetting } from './settings.js';
|
|
5
6
|
import { currentProject, overlayLayer, popLayer, pushLayer, selectProject, styledAlert, styledConfirm } from './sidebar.js';
|
|
6
7
|
import { autoGrow, copyText, refresh, relTime, renderTimeline } from './sse.js';
|
|
@@ -393,12 +394,12 @@ function syncDrawerToCursor() {
|
|
|
393
394
|
if (t && t.key !== state.drawerKey) openDrawer(t.key);
|
|
394
395
|
}, 150);
|
|
395
396
|
}
|
|
396
|
-
const kbBoardSort = (a, b) => b.priority - a.priority || a.position - b.position || a.id - b.id;
|
|
397
397
|
export async function kbMoveCard(dr, dc) {
|
|
398
398
|
const t = state.tasks.find((x) => x.id === state.kbCursor);
|
|
399
399
|
if (!t) return;
|
|
400
400
|
if (dr) {
|
|
401
|
-
|
|
401
|
+
if (colSort(t.status) !== DEFAULT_SORT) return;
|
|
402
|
+
const sorted = state.tasks.filter((x) => x.status === t.status).sort(columnComparator(DEFAULT_SORT));
|
|
402
403
|
const ri = sorted.findIndex((x) => x.id === t.id);
|
|
403
404
|
const ti = Math.max(0, Math.min(sorted.length - 1, ri + dr));
|
|
404
405
|
if (ti === ri) return;
|
package/public/js/init.js
CHANGED
|
@@ -58,7 +58,7 @@ $('search').addEventListener('input', (e) => {
|
|
|
58
58
|
window.addEventListener('resize', scheduleDrawLinks);
|
|
59
59
|
$('board').addEventListener('scroll', scheduleDrawLinks, true);
|
|
60
60
|
|
|
61
|
-
const POPUP_SELECTOR = '.modal-overlay, #card-menu, #drawer-menu-pop, #return-pop, .lbl-picker, #status-picker, .lightbox, .kbsel.open';
|
|
61
|
+
const POPUP_SELECTOR = '.modal-overlay, #card-menu, #col-sort-menu, #drawer-menu-pop, #return-pop, .lbl-picker, #status-picker, .lightbox, .kbsel.open';
|
|
62
62
|
|
|
63
63
|
function escClosePopup() {
|
|
64
64
|
if (document.querySelector('.kbsel.open')) { closeOpenSelect(); return true; }
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
|
|
2
|
+
export const SORT_MODES = ['manual', 'num-desc', 'num-asc'];
|
|
3
|
+
export const DEFAULT_SORT = 'manual';
|
|
4
|
+
|
|
5
|
+
export function sortMode(v) { return SORT_MODES.includes(v) ? v : DEFAULT_SORT; }
|
|
6
|
+
|
|
7
|
+
export function taskNumber(t) {
|
|
8
|
+
const n = Number(String(t.key || '').split('-').pop());
|
|
9
|
+
return Number.isFinite(n) ? n : 0;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const pinnedFirst = (a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0);
|
|
13
|
+
|
|
14
|
+
export function columnComparator(mode, { mixed = false, closed = false } = {}) {
|
|
15
|
+
const m = sortMode(mode);
|
|
16
|
+
const pin = closed ? () => 0 : pinnedFirst;
|
|
17
|
+
if (m === DEFAULT_SORT) {
|
|
18
|
+
return closed
|
|
19
|
+
? (a, b) => b.priority - a.priority || b.id - a.id
|
|
20
|
+
: (a, b) => pin(a, b) || b.priority - a.priority || a.position - b.position || a.id - b.id;
|
|
21
|
+
}
|
|
22
|
+
const num = mixed ? (t) => t.id : taskNumber;
|
|
23
|
+
const dir = m === 'num-asc' ? 1 : -1;
|
|
24
|
+
return (a, b) => pin(a, b) || dir * (num(a) - num(b)) || dir * (a.id - b.id);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function orderColumn(tasks, mode, { mixed = false, closed = false, limit = Infinity } = {}) {
|
|
28
|
+
let list = [...tasks];
|
|
29
|
+
if (closed) list = list.sort((a, b) => b.id - a.id).slice(0, limit);
|
|
30
|
+
return list.sort(columnComparator(mode, { mixed, closed }));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function slotBefore(tasks, task, mode, opts = {}) {
|
|
34
|
+
const cmp = columnComparator(mode, opts);
|
|
35
|
+
const next = orderColumn(tasks.filter((x) => x.id !== task.id), mode, opts).find((x) => cmp(task, x) < 0);
|
|
36
|
+
return next ? next.id : null;
|
|
37
|
+
}
|
package/public/style.css
CHANGED
|
@@ -229,7 +229,14 @@ html.booted #horizon, html.booted #calendar, html.booted #chaos { animation: boo
|
|
|
229
229
|
}
|
|
230
230
|
.col-head .dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
|
|
231
231
|
.col-head .n { color: var(--muted); font-weight: 400; }
|
|
232
|
-
.col-
|
|
232
|
+
.col-head .n + .btn-icon { margin-left: auto; }
|
|
233
|
+
.col-sort { font-size: 12px; opacity: .65; }
|
|
234
|
+
.col-sort:hover { opacity: 1; }
|
|
235
|
+
.col-sort.on { opacity: 1; color: var(--accent); }
|
|
236
|
+
.col-sort-menu { transform-origin: top left; min-width: 214px; }
|
|
237
|
+
.col-sort-menu .dm-item.on { color: var(--accent); }
|
|
238
|
+
.col-sort-menu .dm-chev { color: var(--accent); }
|
|
239
|
+
.dm-hint { width: 0; min-width: 100%; box-sizing: border-box; padding: 3px 10px 6px; font-size: 11.5px; line-height: 1.35; color: var(--muted); }
|
|
233
240
|
.col-copy { font-size: 12px; opacity: .65; }
|
|
234
241
|
.col-copy:hover { opacity: 1; }
|
|
235
242
|
.col-copy.copied { opacity: 1; color: var(--done); font-size: 14px; }
|
package/skills/kanban/SKILL.md
CHANGED
|
@@ -148,7 +148,8 @@ for each. Use `kb take X` only when asked to "work through project X" without sp
|
|
|
148
148
|
<n>`).** Before review every item is either ticked or named in the note as not done — the human
|
|
149
149
|
accepts the task by those boxes, and an unticked done item reads as undone.
|
|
150
150
|
6. Stuck, or the decision is the human's → `kb block <key> "reason"` and move on.
|
|
151
|
-
7. At the end, a one-line summary: how many in review, how many
|
|
151
|
+
7. At the end, a one-line summary: how many in review, how many went back to To do after a
|
|
152
|
+
discussion (`ask`), how many blocked.
|
|
152
153
|
|
|
153
154
|
## Grooming the backlog
|
|
154
155
|
|
|
@@ -178,6 +179,15 @@ One line of summary at the end.
|
|
|
178
179
|
study the task in `prep`, then ask your clarifying questions in chat — how you understood the
|
|
179
180
|
task, what you intend to change and where — EVEN IF everything looks obvious, and WAIT for an
|
|
180
181
|
answer. Do not move to `doing` or write code until the human replies.
|
|
182
|
+
**How an `ask` task ends.** Agreed and done → the normal loop up to `review`. The turn passes to
|
|
183
|
+
the human with the work NOT done — you hand it back with questions, you proposed options and the
|
|
184
|
+
choice is theirs, they postponed it → `kb note <key> "…"` with the outcome of the discussion (what
|
|
185
|
+
you asked, what the human answered, what was agreed, what is still open) and **`kb mv <key>
|
|
186
|
+
todo`**; when they answer in chat, `kb mv <key> prep` and carry on. Not `review`: that column holds
|
|
187
|
+
finished work, and a bare proposal there reads as delivered. Not left in `prep` either: only Claude
|
|
188
|
+
moves that column, so the human cannot pull the card out, and your turn with the questions may be
|
|
189
|
+
the last one in the session. The note is mandatory — the chat does not survive between sessions,
|
|
190
|
+
and the next one would start the discussion from scratch.
|
|
181
191
|
- **You found an unrelated problem along the way** → do not fix it silently. Create a task
|
|
182
192
|
(`kb add <proj> "..." -d "..."`) and ask in chat: do it now, defer it, or is it for the human.
|
|
183
193
|
- **Related tasks solved together** → take both at once (both in doing), fix them in one pass and
|
|
@@ -185,7 +195,9 @@ One line of summary at the end.
|
|
|
185
195
|
(status)`, `↓ subtask …`, `↔ related …` — look at them before you start: a linked task in To do
|
|
186
196
|
is usually meant to go together with this one, and a parent explains why the task exists.
|
|
187
197
|
- **A discussion or question task** (no code): study it in `prep`, put the full answer in
|
|
188
|
-
`kb note <key> "..."`, then `kb mv <key> review`.
|
|
198
|
+
`kb note <key> "..."`, then `kb mv <key> review`. **With the `ask` label it goes back to `todo`
|
|
199
|
+
instead** (see above): there the discussion is a step before the work, not the work itself. You
|
|
200
|
+
write no code and do not move it to done.
|
|
189
201
|
- A "check whether the service is alive" task → `kb svc <proj>` (one line per service, no ssh).
|
|
190
202
|
- Work time is measured for you: doing/deploy runs the timer, review/done stops it.
|
|
191
203
|
- Verifying the result: text and config changes — curl/grep (cheap). Compiled projects — **a build**
|