local-kanban 1.14.1 → 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 CHANGED
@@ -8,6 +8,24 @@ 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
+
11
29
  ## [1.14.1]
12
30
 
13
31
  ### Changed
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[rel]) die('kb link <key> <key> [child|parent|related] (child: the second task becomes a sub-task of the first)');
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "local-kanban",
3
- "version": "1.14.1",
3
+ "version": "1.15.0",
4
4
  "license": "MIT",
5
5
  "author": "Dannylamao",
6
6
  "repository": {
@@ -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
- 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
- }
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 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;
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)}`, { status, position });
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)}`, { status, position }).then(refresh));
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
- await api('PATCH', `/api/tasks/${seg(movable[i].id)}`, { status, position: prev + step * (i + 1) });
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': 'Звук на «Проверяю»',
@@ -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
- const sorted = state.tasks.filter((x) => x.status === t.status).sort(kbBoardSort);
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-add { margin-left: auto; }
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; }