local-kanban 2.0.0-beta.2 → 2.0.0-beta.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.
- package/CHANGELOG.md +19 -0
- package/bin/kb +19 -6
- package/package.json +1 -1
- package/public/js/automations.js +2 -12
- package/public/js/board.js +14 -20
- package/public/js/core.js +19 -5
- package/public/js/drawer.js +72 -16
- package/public/js/glyphs.js +1 -1
- package/public/js/init.js +12 -8
- package/public/js/now.js +3 -5
- package/public/js/onboarding-rules.js +3 -0
- package/public/js/onboarding.js +2 -2
- package/public/js/ready-rules.js +25 -0
- package/public/js/review-rules.js +15 -1
- package/public/js/settings.js +7 -4
- package/public/js/share.js +4 -0
- package/public/js/shipped-rules.js +18 -0
- package/public/js/sidebar.js +4 -2
- package/public/js/sse.js +3 -1
- package/public/js/unsaved.js +50 -0
- package/public/style.css +4 -3
- package/skills/kanban/SKILL.md +19 -6
- package/src/auto-resume.js +35 -15
- package/src/automations.js +17 -10
- package/src/claude-live.js +4 -0
- package/src/db.js +1 -0
- package/src/ready-gate.js +11 -2
- package/src/review-check.js +1 -1
- package/src/routes/dashboard.js +3 -1
- package/src/routes/projects.js +9 -2
- package/src/routes/system.js +4 -3
- package/src/routes/tasks.js +17 -6
- package/src/routes/terminal.js +3 -18
- package/src/schema.sql +7 -1
- package/src/share.js +26 -17
- package/src/shipped.js +94 -52
- package/src/term-screens.js +56 -0
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,25 @@ version it goes out as and opens an empty one again. Released sections are histo
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [2.0.0-beta.3]
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- The mark «on main» counts a squash and a copy under another subject as shipped, no longer reads the head of the project, a foreign pull request or a mere mention as a task's commit, does not call a branch that took its work back shipped, finds a key after a number in the subject, costs a look a few git processes instead of four a commit, and leaves no objects in the project's repository.
|
|
16
|
+
- Ready to ship: Prep and a label set on the way are no road past the column; an emptied column goes at once; the Dashboard's «Waiting for you» lists what waits for your word, with its number in the sidebar; Done with Claude and the first-run tour know the column; a guest of the Wi-Fi board cannot change a project's own word.
|
|
17
|
+
- A label or a move the board refuses no longer holds the rest of the card: the label comes off with the reason said, and what you typed is saved in any order, after a lost connection and with the card closed too.
|
|
18
|
+
- A guest of the Wi-Fi board is told that only the owner can do this, instead of «the board did not accept it».
|
|
19
|
+
- **Wi-Fi: a guest could get the key out of a backup.** Backups hold the whole database, the key of the door with it, and a guest with a pass could take one and download it. Backups now stay with the owner, the list too, and Settings → Backups tells a guest so; About no longer gives a guest the paths on disk.
|
|
20
|
+
- **Wi-Fi: the key no longer stays behind in the page of the board.** A link opens the board by a redirect, so a script there finds nothing of the key in `document.referrer`, and Back does not lead to its address. A right key on a long address opens the board instead of an empty page, and a guest with words typed when the key changes is told to copy them and reload.
|
|
21
|
+
- The screens the terminal keeps to send only what changed take less memory and go when nobody looks.
|
|
22
|
+
- A comment is no longer written twice when you press send again while it waits, or «Send back» again with the same reason. One written with its answer lost no longer stands under itself as «not sent», and one deleted before the resend stays deleted.
|
|
23
|
+
- Automations: a board that has just restarted no longer types into a question or a limit screen it has not read yet, and going on with interrupted work leaves alone what is typed in the field, a task that left work and a session a day old; a card says the board went on only when the words got there.
|
|
24
|
+
- Automations: «Did not start» says in the board's language that a Claude already works on the tasks, or that tmux did not answer.
|
|
25
|
+
- `kb show A B`: a card whose comments did not load says it is cut short, and a refusal leaves no `---` behind.
|
|
26
|
+
- `kb note` no longer reports a failure for a note it saved, and an answer of the board that breaks off in the middle exits with code 4, not as a failure of `kb` itself.
|
|
27
|
+
- A release check names the file and line of what it found, and a broken private file of patterns is said in words.
|
|
28
|
+
- A release runs the test of the mark «on main» after the rest of the suite, not beside it: on a Mac the two together failed tmux tests at random and stopped the release.
|
|
29
|
+
|
|
11
30
|
## [2.0.0-beta.2]
|
|
12
31
|
|
|
13
32
|
### Added
|
package/bin/kb
CHANGED
|
@@ -21,7 +21,7 @@ const ST_ALIAS = {
|
|
|
21
21
|
// Exit codes are a contract for automation (scripts gate their moves on them):
|
|
22
22
|
// 0 — success, 1 — the board is not responding, 2 — the request was rejected (4xx or an
|
|
23
23
|
// unknown command), 3 — kb itself broke, 4 — the board answered, but with a failure of its
|
|
24
|
-
// own (5xx) or with something that is not JSON,
|
|
24
|
+
// own (5xx) or with something that is not JSON, not of the shape the board answers with, or cut off.
|
|
25
25
|
// Previously an exception exited with 1 as well, so "the board is down" was indistinguishable from
|
|
26
26
|
// "kb is broken".
|
|
27
27
|
// Code 4 exists because 2 was answering two different questions at once. A live board that
|
|
@@ -99,7 +99,11 @@ async function api(method, path, body, expect = null) {
|
|
|
99
99
|
return data;
|
|
100
100
|
};
|
|
101
101
|
if (res.status === 204) return shaped(null);
|
|
102
|
-
|
|
102
|
+
// Headers came and the body did not finish: the board went down in the middle of its answer. That throws here,
|
|
103
|
+
// past the check above, and read as kb itself breaking.
|
|
104
|
+
let text;
|
|
105
|
+
try { text = await res.text(); }
|
|
106
|
+
catch { die(`kb: the board answered ${res.status} and broke off in the middle — whether it did what was asked is not known`, EXIT.BOARD_ERROR); }
|
|
103
107
|
let data = null;
|
|
104
108
|
let parsed = false;
|
|
105
109
|
try { data = JSON.parse(text); parsed = true; } catch { /* not JSON — see below */ }
|
|
@@ -473,16 +477,22 @@ const commands = {
|
|
|
473
477
|
|
|
474
478
|
// A batch is read in one go, so every key is printed, cards apart by a line of ---. A wrong key stops the
|
|
475
479
|
// command before the first request; a task the board does not know stops it there, and says at which key.
|
|
480
|
+
// The line goes out once the next card is read, so a refused one leaves none hanging, and a card whose comments
|
|
481
|
+
// did not come is called cut short: its head is printed, and it does not look like a whole one.
|
|
476
482
|
async show() {
|
|
477
483
|
if (!args.length) die('kb show <key> [<key> …]');
|
|
478
484
|
const ids = args.map(key);
|
|
479
485
|
let at = 0;
|
|
486
|
+
let cut = null;
|
|
480
487
|
process.on('exit', (code) => {
|
|
481
|
-
if (code
|
|
488
|
+
if (!code) return;
|
|
489
|
+
const part = cut ? `${cut} is cut short — its comments did not load` : '';
|
|
490
|
+
if (ids.length > 1) process.stderr.write(`kb: stopped at ${args[at]}: ${at} of ${ids.length} printed${part ? `, ${part}` : ''}\n`);
|
|
491
|
+
else if (part) process.stderr.write(`kb: ${part}\n`);
|
|
482
492
|
});
|
|
483
493
|
for (; at < ids.length; at++) {
|
|
484
|
-
if (at) console.log('---');
|
|
485
494
|
const t = await api('GET', `/api/tasks/${ids[at]}`, null, TASK);
|
|
495
|
+
if (at) console.log('---');
|
|
486
496
|
console.log(`${t.key} ${t.project} ${t.status}${t.pinned ? ' ' + PIN : ''}${t.blocked ? ` blocked: ${t.blocked_reason || ''}` : ''}${PRI[t.priority] ? ' ' + PRI[t.priority] : ''}${t.labels.length ? ' [' + t.labels.join(',') + ']' : ''}`);
|
|
487
497
|
console.log(t.title);
|
|
488
498
|
if (t.description) console.log(t.description);
|
|
@@ -493,7 +503,9 @@ const commands = {
|
|
|
493
503
|
printSessions(t);
|
|
494
504
|
printUsage(t);
|
|
495
505
|
printAttachments(t);
|
|
506
|
+
cut = t.key;
|
|
496
507
|
printComments(await api('GET', `/api/tasks/${t.id}/comments`, null, LIST), 5);
|
|
508
|
+
cut = null;
|
|
497
509
|
}
|
|
498
510
|
},
|
|
499
511
|
|
|
@@ -591,9 +603,10 @@ const commands = {
|
|
|
591
603
|
async note() {
|
|
592
604
|
const [id, body] = args;
|
|
593
605
|
if (!id || !body) die('kb note <key> "text"');
|
|
606
|
+
// Nothing is read after the write: a read that failed reported a failure of a note already saved, and the retry
|
|
607
|
+
// wrote a second one. So the key is printed as it was typed.
|
|
594
608
|
await api('POST', `/api/tasks/${key(id)}/comments`, { body, author: 'claude' }, COMMENT);
|
|
595
|
-
|
|
596
|
-
console.log(`${t.key} 📝`);
|
|
609
|
+
console.log(`${id} 📝`);
|
|
597
610
|
},
|
|
598
611
|
|
|
599
612
|
// kb link A B [child|parent|related] — `child`: B becomes a sub-task of A, `parent`: B becomes
|
package/package.json
CHANGED
package/public/js/automations.js
CHANGED
|
@@ -8,6 +8,7 @@ import { checkWords } from './review.js';
|
|
|
8
8
|
import { getSetting, openSettingsModal } from './settings.js';
|
|
9
9
|
import { renderSidebar, selectProject, styledAlert } from './sidebar.js';
|
|
10
10
|
import { refusalLine } from './unsaved.js';
|
|
11
|
+
import { failWord } from './review-rules.js';
|
|
11
12
|
|
|
12
13
|
const CHECK_KIND = 'finished-check';
|
|
13
14
|
const SCHEDULED = new Set(['schedule-check', 'schedule-groom']);
|
|
@@ -80,18 +81,7 @@ export function phaseLine(r) {
|
|
|
80
81
|
}
|
|
81
82
|
const phaseText = (r) => { const d = document.createElement('div'); d.innerHTML = phaseLine(r); return d.textContent; };
|
|
82
83
|
const hm = (ms) => { const d = new Date(ms); return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; };
|
|
83
|
-
const
|
|
84
|
-
'no-folder': 'the project has no folder on this computer',
|
|
85
|
-
'no-tmux': 'tmux is not installed here',
|
|
86
|
-
'no-claude': 'Claude Code is not installed here',
|
|
87
|
-
'too-long': 'the prompt is too long for the board\'s terminal',
|
|
88
|
-
'check-open': 'the project already has an open check',
|
|
89
|
-
manual: 'a task done by hand cannot be given to Claude',
|
|
90
|
-
};
|
|
91
|
-
const failWhy = (r) => {
|
|
92
|
-
const reason = String(r.error || '').match(/^([a-z-]+): /)?.[1];
|
|
93
|
-
return reason && FAIL_WORDS[reason] ? tr(FAIL_WORDS[reason]) : esc(r.error || '');
|
|
94
|
-
};
|
|
84
|
+
const failWhy = (r) => { const word = failWord(r.error); return word ? tr(word) : esc(r.error || ''); };
|
|
95
85
|
function endedLine(r) {
|
|
96
86
|
const key = r.check_key ? `<button type="button" class="auto-key" data-key="${esc(r.check_key)}">${esc(r.check_key)}</button>` : '';
|
|
97
87
|
if (r.state === 'fired') return `${tr('Check started')} ${key} · ${hm(r.ended_at)}`;
|
package/public/js/board.js
CHANGED
|
@@ -15,7 +15,8 @@ import { autoSlot } from './automations.js';
|
|
|
15
15
|
import { paintAttention } from './attention.js';
|
|
16
16
|
import { refusalLine } from './unsaved.js';
|
|
17
17
|
import { localStore } from './store.js';
|
|
18
|
-
import { SHIP_TITLES, SHIP_WORDS, notOnMain, showsShip } from './shipped-rules.js';
|
|
18
|
+
import { SHIP_TITLES, SHIP_WORDS, notOnMain, shipAsker, showsShip } from './shipped-rules.js';
|
|
19
|
+
import { boardColumns } from './ready-rules.js';
|
|
19
20
|
|
|
20
21
|
function matchesSearch(t) {
|
|
21
22
|
return inSearch(t, { search: state.search, applied: state.searchApplied, hits: state.searchHits });
|
|
@@ -99,22 +100,17 @@ const SHIP_KEY = 'kb.ui.reviewNotMain';
|
|
|
99
100
|
const SHIP_EVERY_MS = 30000;
|
|
100
101
|
const shipFilter = () => localStore.getItem(SHIP_KEY) === '1';
|
|
101
102
|
function setShipFilter(on) { try { localStore.setItem(SHIP_KEY, on ? '1' : '0'); } catch { /* the storage is off: the filter stays as it was */ } }
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
renderBoard();
|
|
114
|
-
})
|
|
115
|
-
.catch(() => { /* the card says nothing: it is a hint, not a state of the task */ })
|
|
116
|
-
.finally(() => { shipAsked.flying = false; });
|
|
117
|
-
}
|
|
103
|
+
const askShipped = shipAsker({
|
|
104
|
+
every: SHIP_EVERY_MS,
|
|
105
|
+
board: () => state.slug,
|
|
106
|
+
wanted: () => state.tasks.some((t) => showsShip(t.status)),
|
|
107
|
+
ask: (slug) => api('GET', `/api/shipped${slug === ALL ? '' : `?project=${encodeURIComponent(slug)}`}`, undefined, { quiet: true }),
|
|
108
|
+
apply: (r) => {
|
|
109
|
+
if (JSON.stringify(r.tasks) === JSON.stringify(state.shipped)) return;
|
|
110
|
+
state.shipped = r.tasks;
|
|
111
|
+
renderBoard();
|
|
112
|
+
},
|
|
113
|
+
});
|
|
118
114
|
const gateHere = () => (state.slug === ALL ? state.projects.some((p) => p.ship_gate) : Boolean(state.projects.find((p) => p.slug === state.slug)?.ship_gate));
|
|
119
115
|
function columnHead(key, tasks, mode, labels) {
|
|
120
116
|
const head = document.createElement('div');
|
|
@@ -216,9 +212,7 @@ export function renderBoard() {
|
|
|
216
212
|
const sameBoard = prevRenderSlug === state.slug;
|
|
217
213
|
const animate = prevCardIds.size > 0 && sameBoard;
|
|
218
214
|
const labels = Object.fromEntries(ALL_STATUSES);
|
|
219
|
-
const columns = ALL_STATUSES.map(([k]) => k)
|
|
220
|
-
.filter((k) => (k !== 'cancelled' || state.tasks.some((t) => t.status === 'cancelled'))
|
|
221
|
-
&& (k !== 'ready' || readyInUse(state.slug) || state.tasks.some((t) => t.status === 'ready')));
|
|
215
|
+
const columns = boardColumns(ALL_STATUSES.map(([k]) => k), state.tasks, readyInUse(state.slug));
|
|
222
216
|
const plan = columns.map((key) => {
|
|
223
217
|
const mode = colSort(key);
|
|
224
218
|
const hidden = key === 'review' && shipFilter() ? (t) => !notOnMain(t, state.shipped) : () => false;
|
package/public/js/core.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
|
|
2
2
|
import { createRetryQueue, resendDelay } from './unsaved.js';
|
|
3
3
|
import { localStore, sessionStore } from './store.js';
|
|
4
|
+
import { readyShown, statusesShown } from './ready-rules.js';
|
|
4
5
|
|
|
5
6
|
const ICONS = {
|
|
6
7
|
grid: 'M2.5 2.5h4.5v4.5H2.5zM9 2.5h4.5v4.5H9zM2.5 9h4.5v4.5H2.5zM9 9h4.5v4.5H9z',
|
|
@@ -142,6 +143,7 @@ const I18N_RU = {
|
|
|
142
143
|
'Port %s is taken by another program — the board did not open to the network. Free it or set another one in KB_SHARE_PORT.': 'Порт %s занят другой программой — доска в сеть не открылась. Освободи его или задай другой в KB_SHARE_PORT.',
|
|
143
144
|
'Could not open the board to the network — the details are in Settings → Errors.': 'Не удалось открыть доску в сеть — подробности в «Настройки → Ошибки».',
|
|
144
145
|
'You are a guest here': 'Ты здесь гость',
|
|
146
|
+
'backups stay with the owner of the board: a backup holds its key': 'бэкапы остаются у владельца доски: в бэкапе лежит её ключ',
|
|
145
147
|
'this board runs on another computer; its owner opens and closes it to the network': 'доска работает на другом компьютере; открывает и закрывает её для сети владелец',
|
|
146
148
|
'The board stays on this computer: it has to be running, and switching off closes it to the network at once. After a restart of the board it is open again, with the same link.': 'Доска остаётся на этом компьютере: она должна работать, а выключатель сразу закрывает её для сети. После перезапуска доски вход снова открыт — по той же ссылке.',
|
|
147
149
|
'On a Mac the system may ask whether node may accept incoming connections — allow it, or nobody gets in.': 'На маке система может спросить, разрешить ли node входящие подключения, — разреши, иначе никто не войдёт.',
|
|
@@ -349,6 +351,8 @@ const I18N_RU = {
|
|
|
349
351
|
'The switch is off: the column goes once it is empty': 'Переключатель выключен: колонка уйдёт, когда опустеет',
|
|
350
352
|
'Statuses': 'Статусы',
|
|
351
353
|
'Wait for the word before shipping': 'Ждать команды перед выкаткой',
|
|
354
|
+
'These projects say otherwise and do not wait:': 'Эти проекты решили иначе и не ждут:',
|
|
355
|
+
'tasks waiting for your word to ship': 'задачи ждут твоей команды на выкатку',
|
|
352
356
|
'a finished task stops in Ready to ship until you say to ship it; Ready to ship means it is not on main yet, Review that people have it': 'готовая задача останавливается в «Ждёт выкатки», пока ты не скажешь выкатывать; «Ждёт выкатки» — в main ещё нет, «Проверяю» — уже у людей',
|
|
353
357
|
'A project can say otherwise in its settings.': 'Проект может решить иначе в своих настройках.',
|
|
354
358
|
'Waits in the column:': 'В колонке ждут:',
|
|
@@ -1468,6 +1472,16 @@ const I18N_RU = {
|
|
|
1468
1472
|
'Not saved: the board answered with an error — the details are in Settings → Errors.': 'Не сохранено: доска ответила ошибкой — подробности в «Настройках → Ошибки».',
|
|
1469
1473
|
'Not saved: the task has to go through work first — the board showed it in an old column.': 'Не сохранено: задачу сначала нужно провести через работу — доска показывала её в старой колонке.',
|
|
1470
1474
|
'Not saved: the board did not accept it.': 'Не сохранено: доска это не приняла.',
|
|
1475
|
+
'Not saved: noclaude and check are not put on a task in work — this project ships on your word.': 'Не сохранено: noclaude и check не ставятся на задачу в работе — этот проект выкатывается по твоей команде.',
|
|
1476
|
+
'noclaude and check are not put on a task in work — this project ships on your word': 'noclaude и check не ставятся на задачу в работе — этот проект выкатывается по твоей команде',
|
|
1477
|
+
'Not saved: the task goes to Ready to ship first — this project ships on your word.': 'Не сохранено: задача сначала идёт в «Ждёт выкатки» — этот проект выкатывается по твоей команде.',
|
|
1478
|
+
'the task goes to Ready to ship first — this project ships on your word': 'задача сначала идёт в «Ждёт выкатки» — этот проект выкатывается по твоей команде',
|
|
1479
|
+
'Not saved: only the owner of the board can do this.': 'Не сохранено: это может только владелец доски.',
|
|
1480
|
+
'only the owner of the board can do this': 'это может только владелец доски',
|
|
1481
|
+
'a guest starts Claude only inside the board': 'гость запускает Claude только внутри доски',
|
|
1482
|
+
'this board has no such label': 'на этой доске такой метки нет',
|
|
1483
|
+
'Not saved: this device no longer has the key of the board, it may have been changed. Copy what you typed, then reload the page and enter the key.': 'Не сохранено: у этого устройства больше нет ключа доски, возможно, его сменили. Скопируй набранное, обнови страницу и введи ключ.',
|
|
1484
|
+
'this device no longer has the key of the board, it may have been changed — copy what you typed, then reload the page and enter the key': 'у этого устройства больше нет ключа доски, возможно, его сменили — скопируй набранное, обнови страницу и введи ключ',
|
|
1471
1485
|
'The task is not created': 'Задача не создана',
|
|
1472
1486
|
'The task is not created yet: there is no connection to the board. It is created by itself once the connection is back.': 'Задача ещё не создана: нет связи с доской. Она создастся сама, когда связь вернётся.',
|
|
1473
1487
|
'The task is not created yet: the board answered with an error — the details are in Settings → Errors. Your next change tries again, or click “not saved” at the top.': 'Задача ещё не создана: доска ответила ошибкой — подробности в «Настройках → Ошибки». Следующая правка попробует снова — или нажми «не сохранено» вверху.',
|
|
@@ -1625,6 +1639,7 @@ const I18N_RU = {
|
|
|
1625
1639
|
'Its terminal, its steps and its questions are right in the task.': 'Его терминал, шаги и вопросы — прямо в задаче.',
|
|
1626
1640
|
'Done is your word': '«Готово» — твоё слово',
|
|
1627
1641
|
'Claude stops at Review: you accept the work or send it back.': 'Claude останавливается на «Проверяю»: ты принимаешь работу или возвращаешь её.',
|
|
1642
|
+
'Claude stops at Ready to ship until you say to ship it, then at Review: you accept the work or send it back.': 'Claude останавливается на «Ждёт выкатки» до твоей команды, потом на «Проверяю»: ты принимаешь работу или возвращаешь её.',
|
|
1628
1643
|
'Open issue': 'Открыта',
|
|
1629
1644
|
'adds this': 'добавит это',
|
|
1630
1645
|
'hides this': 'спрячет это',
|
|
@@ -1725,6 +1740,8 @@ const I18N_RU = {
|
|
|
1725
1740
|
'Automations are switched off in Settings': 'Автоматизации выключены в Настройках',
|
|
1726
1741
|
'the project has no folder on this computer': 'у проекта нет папки на этом компьютере',
|
|
1727
1742
|
'tmux is not installed here': 'здесь не установлен tmux',
|
|
1743
|
+
'a Claude of the board already works on these tasks': 'над этими задачами уже работает Claude доски',
|
|
1744
|
+
'tmux did not answer in time': 'tmux не ответил вовремя',
|
|
1728
1745
|
'Claude Code is not installed here': 'здесь не установлен Claude Code',
|
|
1729
1746
|
"the prompt is too long for the board's terminal": 'задание слишком длинное для терминала доски',
|
|
1730
1747
|
'the project already has an open check': 'в проекте уже открыта проверка',
|
|
@@ -1851,11 +1868,8 @@ export const ALL_STATUSES = [
|
|
|
1851
1868
|
['done', tr('Done')],
|
|
1852
1869
|
['cancelled', tr('Cancelled')],
|
|
1853
1870
|
];
|
|
1854
|
-
export
|
|
1855
|
-
|
|
1856
|
-
return slug === ALL ? state.projects.some(on) : on(state.projects.find((p) => p.slug === slug) || {});
|
|
1857
|
-
}
|
|
1858
|
-
export const statusesFor = (slug, current) => ALL_STATUSES.filter(([v]) => v !== 'ready' || v === current || readyInUse(slug));
|
|
1871
|
+
export const readyInUse = (slug) => readyShown(state.projects, slug, ALL);
|
|
1872
|
+
export const statusesFor = (slug, current) => statusesShown(ALL_STATUSES, current, readyInUse(slug));
|
|
1859
1873
|
export const PRI_ICON = { 1: ['↓', 'p1', tr('low')], 2: ['=', 'p2', tr('medium')], 3: ['↑', 'p3', tr('high')] };
|
|
1860
1874
|
export const PRI_LEVELS = [[3, tr('↑ High')], [2, tr('= Medium')], [1, tr('↓ Low')], [0, tr('No priority')]];
|
|
1861
1875
|
export let LABEL_COLORS = {};
|
package/public/js/drawer.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
|
|
2
2
|
import { wholeAnswer } from './term-delta.js';
|
|
3
3
|
import { WORKING, buildSelect, canMoveTo, clearSelection, closeOpenSelect, colSort, labelChip, moveBlocked, revealCard, scheduleDrawLinks, selVal, selectTasks, selectedIds, setScrollToNewCardId, taskIsNoclaude } from './board.js';
|
|
4
|
-
import { $, ALL, statusesFor, LABEL_COLORS, LABEL_SELECTABLE, PRI_LEVELS, TITLE_LABEL_WORDS, api, apiBlob, dropUnsaved, esc, ghSyncOn, ic, noteUnsaved, seg, state, tr } from './core.js';
|
|
4
|
+
import { $, ALL, statusesFor, LABEL_COLORS, LABEL_SELECTABLE, PRI_LEVELS, TITLE_LABEL_WORDS, api, apiBlob, dropUnsaved, esc, ghSyncOn, ic, noteUnsaved, retryUnsaved, seg, state, tr } from './core.js';
|
|
5
5
|
import { columnComparator, DEFAULT_SORT } from './order.js';
|
|
6
6
|
import { mountChat } from './chat.js';
|
|
7
7
|
import { getSetting, openSettingsModal, plural, setClaudeView } from './settings.js';
|
|
8
8
|
import { onPhone } from './phone-rules.js';
|
|
9
9
|
import { NARROW, closeTopLayer, currentProject, layersOpen, overlayLayer, popLayer, pushLayer, selectProject, styledAlert, styledConfirm, watchLayers } from './sidebar.js';
|
|
10
10
|
import { SHORT_MONTHS, askUsageNow, autoGrow, claudeInfoHTML, copyText, onClaudeInfo, launchInClaude, openSessionInClaude, refresh, relTime, renderTimeline, resumeOnBoard, taskPromptText } from './sse.js';
|
|
11
|
-
import { canRetry, changedFields, createTurns, createUnsentComments, createUnsentEdits, imageTooLarge, pullExternal, refusalLine, refusalText, resendDelay } from './unsaved.js';
|
|
11
|
+
import { canRetry, changedFields, createCommentKeys, createTurns, createUnsentComments, createUnsentEdits, imageTooLarge, pullExternal, refusalLine, refusalText, refusedFields, resendDelay } from './unsaved.js';
|
|
12
12
|
import { renderAsks } from './asks.js';
|
|
13
13
|
import { stillOpen } from './review-rules.js';
|
|
14
14
|
import { wireTermCopy } from './term-copy.js';
|
|
@@ -246,6 +246,7 @@ function drawComments(key, cs) {
|
|
|
246
246
|
box.innerHTML = (cs || []).map(commentHTML).join('');
|
|
247
247
|
commentsShown = { of: cs ? key : null, whole: Boolean(cs), ids: new Set((cs || []).map((c) => c.id)), max: Math.max(0, ...(cs || []).map((c) => c.id)) };
|
|
248
248
|
wireComments(box);
|
|
249
|
+
for (const c of cs || []) commentOnServer(key, c);
|
|
249
250
|
paintUnsent(key);
|
|
250
251
|
}
|
|
251
252
|
function commentHTML(c) {
|
|
@@ -275,6 +276,7 @@ function commentsFailed(key, e) {
|
|
|
275
276
|
/** A comment of task `key` is known to be on the server — its event came, or the answer to the person's own.
|
|
276
277
|
`c` — the comment, when it is known (a deleted one is not); `n` — how many the task holds now, when known. */
|
|
277
278
|
export function commentArrived(key, c, n) {
|
|
279
|
+
commentOnServer(key, c);
|
|
278
280
|
if (key !== state.drawerKey) return;
|
|
279
281
|
const s = commentsShown;
|
|
280
282
|
if (c && s.of === key && s.whole && !commentsRead) {
|
|
@@ -323,7 +325,15 @@ setInterval(refreshCommentTimes, 30000);
|
|
|
323
325
|
document.addEventListener('visibilitychange', refreshCommentTimes);
|
|
324
326
|
|
|
325
327
|
const unsentComments = createUnsentComments();
|
|
328
|
+
export const commentKeys = createCommentKeys();
|
|
329
|
+
export const unsentToken = (key, body) => unsentComments.tokenOf(key, body);
|
|
330
|
+
/** The server said the key is of a deleted comment: an entry under it would swallow new words of the same text. */
|
|
331
|
+
export function dropUnsentByToken(key, token) {
|
|
332
|
+
const c = unsentComments.byToken(key, token);
|
|
333
|
+
if (c) dropUnsentComment(key, c.id);
|
|
334
|
+
}
|
|
326
335
|
export function queueComment(key, body, token) {
|
|
336
|
+
if (commentKeys.written(key, body)) return; // its event came first: only the answer was lost, nothing waits
|
|
327
337
|
const c = unsentComments.add(key, body, token);
|
|
328
338
|
noteUnsaved(c.again ? null : () => resendComment(key, c.id), `comment:${key}:${c.id}`);
|
|
329
339
|
paintUnsent(key);
|
|
@@ -341,7 +351,13 @@ async function resendComment(key, id) {
|
|
|
341
351
|
}
|
|
342
352
|
unsentComments.done(key, id);
|
|
343
353
|
paintUnsent(key);
|
|
344
|
-
commentArrived(key, sent);
|
|
354
|
+
if (!sent?.deleted) commentArrived(key, sent); // deleted since it was written: delivered, and nothing to add to the list
|
|
355
|
+
}
|
|
356
|
+
function commentOnServer(key, c) {
|
|
357
|
+
if (!c?.client_key) return;
|
|
358
|
+
commentKeys.heard(c.client_key);
|
|
359
|
+
const waiting = unsentComments.byToken(key, c.client_key);
|
|
360
|
+
if (waiting) dropUnsentComment(key, waiting.id);
|
|
345
361
|
}
|
|
346
362
|
function paintUnsent(key) {
|
|
347
363
|
if (key !== state.drawerKey) return;
|
|
@@ -2229,7 +2245,9 @@ async function doReturn(reason) {
|
|
|
2229
2245
|
const key = state.drawerKey;
|
|
2230
2246
|
try {
|
|
2231
2247
|
if (reason) {
|
|
2232
|
-
|
|
2248
|
+
const body = `${tr('Send back')}: ${reason}`;
|
|
2249
|
+
await api('POST', `/api/tasks/${seg(key)}/comments`, { body, author: 'me', client_key: commentKeys.of(key, body) });
|
|
2250
|
+
commentKeys.done(key, body);
|
|
2233
2251
|
const input = $('return-pop')?.querySelector('.return-pop-input');
|
|
2234
2252
|
if (input) input.value = '';
|
|
2235
2253
|
}
|
|
@@ -2436,7 +2454,10 @@ export async function doAutosave() {
|
|
|
2436
2454
|
return await doAutosaveOnce();
|
|
2437
2455
|
} catch (e) {
|
|
2438
2456
|
if (e?.offline) return undefined;
|
|
2439
|
-
if (e?.saidInCard)
|
|
2457
|
+
if (e?.saidInCard) {
|
|
2458
|
+
if (e.restQueued) await retryUnsaved(); // the rest of a refused edit does not wait for the next try
|
|
2459
|
+
return undefined;
|
|
2460
|
+
}
|
|
2440
2461
|
if (e?.taskKey && refusalText(e).gone) {
|
|
2441
2462
|
if (e.taskKey === state.drawerKey) sayRefused(e, e.taskKey);
|
|
2442
2463
|
else refresh();
|
|
@@ -2468,6 +2489,16 @@ async function resendTaskEdit(key) {
|
|
|
2468
2489
|
try {
|
|
2469
2490
|
saved = await editInTurn(key, () => api('PATCH', `/api/tasks/${seg(key)}`, sent));
|
|
2470
2491
|
} catch (e) {
|
|
2492
|
+
const back = refusalText(e).gone ? [] : refusedFields(e, sent);
|
|
2493
|
+
if (back.length) { // the rest of the edit is not refused with the field: words typed offline still get there, open card or not
|
|
2494
|
+
unsentEdits.supersede(key, back);
|
|
2495
|
+
if (here && state.drawerKey === key) { takeBackRefused(back, e); sayFieldRefused(key, e); }
|
|
2496
|
+
else styledAlert(`${key}: ${refusalLine('The change is not saved', e, tr)}`);
|
|
2497
|
+
const left = { ...dirty };
|
|
2498
|
+
for (const f of back) delete left[f];
|
|
2499
|
+
if (Object.keys(left).length) unsentEdits.fail(key, left);
|
|
2500
|
+
return resendTaskEdit(key);
|
|
2501
|
+
}
|
|
2471
2502
|
if (!e?.offline && !(e?.status >= 500)) { unsentEdits.drop(key); queuedBase.delete(key); }
|
|
2472
2503
|
else unsentEdits.fail(key, dirty);
|
|
2473
2504
|
throw e;
|
|
@@ -2484,19 +2515,44 @@ async function resendTaskEdit(key) {
|
|
|
2484
2515
|
unsentEdits.settle(key, fields);
|
|
2485
2516
|
unsentEdits.settle(key, dirty);
|
|
2486
2517
|
if (here && state.drawerKey === key && drawerSnapshot) Object.assign(drawerSnapshot, dirty);
|
|
2487
|
-
if (here && state.drawerKey === key) showSaveNote(null);
|
|
2518
|
+
if (here && state.drawerKey === key && refusedNote !== key) showSaveNote(null);
|
|
2488
2519
|
}
|
|
2489
2520
|
const unsentCreates = new Map();
|
|
2490
2521
|
function editRefusedText(e) {
|
|
2491
2522
|
if (e?.status >= 500) return tr('The change is not saved yet: the board answered with an error — the details are in Settings → Errors. Your next change tries again, or click “not saved” at the top.');
|
|
2492
2523
|
return refusalLine('The change is not saved', e, tr);
|
|
2493
2524
|
}
|
|
2525
|
+
/** The card lets go of what the server refused. A PATCH is all or nothing: left in the form, a refused field went out with
|
|
2526
|
+
every later edit and the board refused each whole, so words typed after it never got there, without a word when the card
|
|
2527
|
+
closed. Labels go back to what the server holds, and what depends on them with them. */
|
|
2528
|
+
function takeBackRefused(fields, e) {
|
|
2529
|
+
if (!fields.length) return;
|
|
2530
|
+
if (fields.includes('labels') && drawerSnapshot) {
|
|
2531
|
+
// the answer says what the server holds: a label set elsewhere while this one went must not be erased by the next save
|
|
2532
|
+
if (Array.isArray(e?.body?.labels)) drawerSnapshot.labels = [...e.body.labels];
|
|
2533
|
+
drawerLabels = [...drawerSnapshot.labels];
|
|
2534
|
+
closeLabelPicker();
|
|
2535
|
+
renderDrawerLabels();
|
|
2536
|
+
setDrawerLock(DrawerSync.cardLocked(drawerStatus, drawerLabels));
|
|
2537
|
+
launchFollowsLabels();
|
|
2538
|
+
}
|
|
2539
|
+
// the picker goes back to the status the card knows; a reading of the board brings the real one (`refresh` of the refusal)
|
|
2540
|
+
buildStatusSelect(drawerStatus, false, drawerLabels.includes('noclaude'));
|
|
2541
|
+
if (fields.includes('status') && drawerSnapshot) drawerSnapshot.status = drawerStatus;
|
|
2542
|
+
}
|
|
2494
2543
|
function createRefusedText(e) {
|
|
2495
2544
|
if (e?.offline) return tr('The task is not created yet: there is no connection to the board. It is created by itself once the connection is back.');
|
|
2496
2545
|
if (e?.status >= 500) return tr('The task is not created yet: the board answered with an error — the details are in Settings → Errors. Your next change tries again, or click “not saved” at the top.');
|
|
2497
2546
|
return refusalLine('The task is not created', e, tr);
|
|
2498
2547
|
}
|
|
2548
|
+
// the note stands for a field refused while the rest of its edit is on the way: saving that rest proves nothing about the field
|
|
2549
|
+
let refusedNote = null;
|
|
2550
|
+
function sayFieldRefused(key, e) {
|
|
2551
|
+
showSaveNote(editRefusedText(e));
|
|
2552
|
+
refusedNote = key;
|
|
2553
|
+
}
|
|
2499
2554
|
function showSaveNote(text) {
|
|
2555
|
+
refusedNote = null;
|
|
2500
2556
|
const el = $('d-save-note');
|
|
2501
2557
|
el.textContent = text || '';
|
|
2502
2558
|
el.classList.toggle('hidden', !text);
|
|
@@ -2541,21 +2597,21 @@ async function doAutosaveOnce() {
|
|
|
2541
2597
|
const withBase = () => ('description_base' in body && queuedBase.has(key) ? { ...body, description_base: queuedBase.get(key) } : body);
|
|
2542
2598
|
try { saved = await editInTurn(key, () => api('PATCH', `/api/tasks/${seg(key)}`, withBase())); }
|
|
2543
2599
|
catch (e) {
|
|
2544
|
-
|
|
2600
|
+
const r = refusalText(e);
|
|
2601
|
+
const back = r.gone ? [] : refusedFields(e, patch); // the field the refusal is about; the rest of the edit is not refused with it
|
|
2602
|
+
const rest = { ...patch };
|
|
2603
|
+
for (const f of back) delete rest[f];
|
|
2604
|
+
const restQueued = back.length > 0 && Object.keys(rest).length > 0;
|
|
2605
|
+
if ((canRetry(e) || restQueued) && 'description' in body && !queuedBase.has(key)) queuedBase.set(key, body.description_base);
|
|
2545
2606
|
if (canRetry(e)) queueTaskEdit(key, patch);
|
|
2546
2607
|
// says it); the card moved on to another task while the request went: a refusal is said with the task's key
|
|
2547
|
-
const r = refusalText(e);
|
|
2548
2608
|
if (!e?.offline && !r.gone) {
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
if (
|
|
2552
|
-
buildStatusSelect(drawerStatus, false, drawerLabels.includes('noclaude'));
|
|
2553
|
-
if (drawerSnapshot) drawerSnapshot.status = drawerStatus;
|
|
2554
|
-
}
|
|
2555
|
-
if (key === state.drawerKey) showSaveNote(editRefusedText(e));
|
|
2609
|
+
if (key === state.drawerKey) takeBackRefused(back, e);
|
|
2610
|
+
if (restQueued) queueTaskEdit(key, rest); // as data: the card may close before it is sent
|
|
2611
|
+
if (key === state.drawerKey) { if (back.length) sayFieldRefused(key, e); else showSaveNote(editRefusedText(e)); }
|
|
2556
2612
|
else if (!canRetry(e)) styledAlert(`${key}: ${refusalLine('The change is not saved', e, tr)}`);
|
|
2557
2613
|
if (r.refresh) refresh(); // the card showed an old column: the board and the card are read again
|
|
2558
|
-
Object.assign(e, { saidInCard: true });
|
|
2614
|
+
Object.assign(e, { saidInCard: true, restQueued });
|
|
2559
2615
|
}
|
|
2560
2616
|
throw Object.assign(e, { taskKey: key });
|
|
2561
2617
|
} finally {
|
package/public/js/glyphs.js
CHANGED
|
@@ -355,7 +355,7 @@ export function initGlyphs({ enabled } = {}) {
|
|
|
355
355
|
pulse(e.clientX, e.clientY);
|
|
356
356
|
}, { capture: true, passive: true });
|
|
357
357
|
document.addEventListener('visibilitychange', syncGlyphs);
|
|
358
|
-
const active = () => { activeAt = performance.now(); if (!frame) syncGlyphs(); };
|
|
358
|
+
const active = () => { activeAt = performance.now(); if (!frame && running()) syncGlyphs(); };
|
|
359
359
|
for (const ev of ['pointermove', 'pointerdown', 'keydown', 'wheel', 'touchstart']) document.addEventListener(ev, active, { capture: true, passive: true });
|
|
360
360
|
matchMedia('(prefers-reduced-motion: reduce)').addEventListener?.('change', syncGlyphs);
|
|
361
361
|
syncGlyphs();
|
package/public/js/init.js
CHANGED
|
@@ -3,7 +3,7 @@ import { commentToken } from './unsaved.js';
|
|
|
3
3
|
import { clearSelection, closeOpenSelect, initMarquee, multiAction, renderBoard, scheduleDrawLinks, selectedIds } from './board.js';
|
|
4
4
|
import { chaosPending } from './chaos.js';
|
|
5
5
|
import { $, ALL, AUTO, HEALTH, CALENDAR, CHAOS, DASH, DONE, HORIZON, NOW, REVIEW, SETTINGS, api, hasUnsaved, ic, liftCurtain, loadLabelPalette, seg, setDoorClosed, state, tr } from './core.js';
|
|
6
|
-
import { addChecklistItem, applyCommentDraft, applyKeepDrawer, applyTitleLabelShortcut, closeDrawer, commentDrafts, deleteTask, doAutosave, initTerminal, kbMoveCard, kbMoveCursor, launchOpenTask, onDrawerPaste, openDrawer, uploadCommentImage, wirePicturePick, wireTaskPicture, openDrawerMenu, openDrawerNew, openOpenTaskSession, openReturnPop, showOwnComment, pendingAttachments, pullDrawerFresh, queueComment, commentArrived, sayRefused, scheduleAutosave, setKbCursor, wireCardBack, descTyped, descConflictPending } from './drawer.js';
|
|
6
|
+
import { addChecklistItem, applyCommentDraft, applyKeepDrawer, applyTitleLabelShortcut, closeDrawer, commentDrafts, deleteTask, doAutosave, dropUnsentByToken, initTerminal, kbMoveCard, kbMoveCursor, launchOpenTask, onDrawerPaste, openDrawer, uploadCommentImage, wirePicturePick, wireTaskPicture, openDrawerMenu, openDrawerNew, openOpenTaskSession, openReturnPop, showOwnComment, pendingAttachments, pullDrawerFresh, queueComment, commentArrived, commentKeys, unsentToken, sayRefused, scheduleAutosave, setKbCursor, wireCardBack, descTyped, descConflictPending } from './drawer.js';
|
|
7
7
|
import { closeProjectSettings, fileEditorHasChanges, openProjectSettings, projectSettingsDirty } from './project.js';
|
|
8
8
|
import { applyCompact, applyTheme, customBinding, getSetting, listenForInstall, matchKey, skillDirty, syncLangToServer } from './settings.js';
|
|
9
9
|
import { closeTopLayer, loadTasks, switchProject } from './sidebar.js';
|
|
@@ -61,23 +61,27 @@ $('project-menu').onclick = () => openProjectSettings();
|
|
|
61
61
|
// but not for longer than COMMENT_HOLD_MS — a request that never answers must not lock the field of the task for good
|
|
62
62
|
const commentSending = new Map();
|
|
63
63
|
const COMMENT_HOLD_MS = 15000;
|
|
64
|
-
const commentTokens = new Map();
|
|
65
64
|
$('c-send').onclick = async () => {
|
|
66
65
|
const body = $('c-input').value.trim();
|
|
67
66
|
const key = state.drawerKey;
|
|
68
67
|
if (!body || !key || Date.now() - (commentSending.get(key) ?? -Infinity) < COMMENT_HOLD_MS) return;
|
|
69
68
|
const at = Date.now();
|
|
70
69
|
commentSending.set(key, at);
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
commentTokens.set(sending, token);
|
|
70
|
+
let token = commentKeys.of(key, body, unsentToken(key, body));
|
|
71
|
+
const post = () => api('POST', `/api/tasks/${seg(key)}/comments`, { body, author: 'me', client_key: token });
|
|
74
72
|
let sent = null;
|
|
75
|
-
try {
|
|
76
|
-
|
|
73
|
+
try {
|
|
74
|
+
sent = await post();
|
|
75
|
+
if (sent?.deleted) { // the person deleted it and pressed now: these are new words, and no entry waits under the old key
|
|
76
|
+
dropUnsentByToken(key, token);
|
|
77
|
+
token = commentToken();
|
|
78
|
+
sent = await post();
|
|
79
|
+
}
|
|
80
|
+
} catch (e) {
|
|
77
81
|
if (!e?.offline) { sayRefused(e, key); return; }
|
|
78
82
|
queueComment(key, body, token);
|
|
79
83
|
} finally { if (commentSending.get(key) === at) commentSending.delete(key); }
|
|
80
|
-
|
|
84
|
+
commentKeys.done(key, body);
|
|
81
85
|
// typed on while it went: what is left after the sent text is a comment of its own — it stays, the sent text goes
|
|
82
86
|
const rest = (text) => { const t = text.trimStart(); return t.startsWith(body) ? t.slice(body.length).trimStart() : text; };
|
|
83
87
|
const draft = rest(commentDrafts.get(key) || '');
|
package/public/js/now.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
|
|
2
|
+
import { handedIn } from './ready-rules.js';
|
|
2
3
|
import { $, NOW, api, esc, ic, state, tr } from './core.js';
|
|
3
4
|
import { openDrawer } from './drawer.js';
|
|
4
5
|
import { selectProject } from './sidebar.js';
|
|
@@ -68,11 +69,8 @@ function runBadge(run) {
|
|
|
68
69
|
|
|
69
70
|
// the queue are gone — the owner asked what they were for: the badge of the run already says what its Claude does, and
|
|
70
71
|
// the line under the title says the stage and "working" or "next in line".
|
|
71
|
-
function wasHanded(t) {
|
|
72
|
-
return t.status === 'review' || t.status === 'done';
|
|
73
|
-
}
|
|
74
72
|
function mark(t) {
|
|
75
|
-
return
|
|
73
|
+
return handedIn(t.status) ? `<span class="now-check" title="${esc(STATUS_LABELS[t.status])}">✓</span>` : '';
|
|
76
74
|
}
|
|
77
75
|
|
|
78
76
|
function taskLine(t, run, column) {
|
|
@@ -154,7 +152,7 @@ function runHead(run, column) {
|
|
|
154
152
|
|
|
155
153
|
function runRows(run, column) {
|
|
156
154
|
// the column of ✓ only in a run that handed something in — elsewhere the keys start at the edge
|
|
157
|
-
const marks = run.tasks.some(
|
|
155
|
+
const marks = run.tasks.some((t) => handedIn(t.status));
|
|
158
156
|
const rows = run.tasks.map((t) => `<div class="now-row" data-key="${esc(t.key)}" tabindex="0" role="button">`
|
|
159
157
|
+ (marks ? `<span class="now-mk">${mark(t)}</span>` : '')
|
|
160
158
|
+ `<span class="now-main"><span class="now-t"><span class="now-key">${esc(t.key)}</span><span class="now-title">${titleOf(t)}</span></span>`
|
|
@@ -33,6 +33,9 @@ export const FEATURES = [
|
|
|
33
33
|
{ id: 'accept', icon: 'check', title: 'Done is your word', note: 'Claude stops at Review: you accept the work or send it back.' },
|
|
34
34
|
];
|
|
35
35
|
|
|
36
|
+
const ACCEPT_GATED = 'Claude stops at Ready to ship until you say to ship it, then at Review: you accept the work or send it back.';
|
|
37
|
+
export const featuresFor = (gated) => FEATURES.map((f) => (gated && f.id === 'accept' ? { ...f, note: ACCEPT_GATED } : f));
|
|
38
|
+
|
|
36
39
|
/** The ways ▶ can start Claude, in the order offered (the values of the `launch` setting), and why one cannot here:
|
|
37
40
|
`avail` — the answer of GET /api/launch; before it comes nothing is said to be missing. */
|
|
38
41
|
export function launchChoices(avail = {}) {
|
package/public/js/onboarding.js
CHANGED
|
@@ -2,7 +2,7 @@ import { $, LANG, api, esc, ic, liftCurtain, smoothReload, state, tr } from './c
|
|
|
2
2
|
import { setupView } from './claude-setup-rules.js';
|
|
3
3
|
import { launchPick } from './launch-rules.js';
|
|
4
4
|
import { renderLimits } from './limits.js';
|
|
5
|
-
import { CONTACT_NAME, CONTACT_URL, FEATURES, STEP_HEADS, VIEW_CHOICES, accessLines, launchChoices, onboardingSteps, scanLines } from './onboarding-rules.js';
|
|
5
|
+
import { CONTACT_NAME, CONTACT_URL, FEATURES, STEP_HEADS, VIEW_CHOICES, accessLines, featuresFor, launchChoices, onboardingSteps, scanLines } from './onboarding-rules.js';
|
|
6
6
|
import { sceneAccept, sceneAccess, sceneDemo, sceneNow, sceneScan, sceneSync, sceneTake, sceneView, sceneWatch, sceneWay } from './onboarding-scenes.js';
|
|
7
7
|
import { appInstallView, appStepsHTML, applyCompact, getSetting, notifyPerm, notifyTestState, pickedSetting, promptInstall, sendTestNotification, setClaudeView, setSetting, syncLangToServer } from './settings.js';
|
|
8
8
|
import { manualSetupHTML, selectProject, setupPromptBlockHTML, wireSetupPrompt } from './sidebar.js';
|
|
@@ -123,7 +123,7 @@ export function openOnboarding({ updated = state.projects.length > 0 } = {}) {
|
|
|
123
123
|
<button type="button" class="${LANG === 'ru' ? 'on' : ''}" data-lang="ru">${LANG_NAMES.ru}</button>
|
|
124
124
|
<button type="button" class="${LANG === 'en' ? 'on' : ''}" data-lang="en">${LANG_NAMES.en}</button>
|
|
125
125
|
</div></div>`
|
|
126
|
-
+ `<div class="onb-list" id="onb-feats">${
|
|
126
|
+
+ `<div class="onb-list" id="onb-feats">${featuresFor(state.projects.some((p) => p.ship_gate)).map((f, i) => `<button type="button" class="onb-item" data-i="${i}">`
|
|
127
127
|
+ `<span class="onb-ic">${ic(f.icon, 15)}</span><span class="onb-item-t"><b>${esc(tr(f.title))}</b><small>${esc(typeset(tr(f.note)))}</small></span>`
|
|
128
128
|
+ `<span class="onb-chev">${ic('chevron', 12)}</span></button>`).join('')}</div>`
|
|
129
129
|
+ `<div class="muted onb-contact">${tr('Questions about anything — write to me on Telegram:')} <a href="${CONTACT_URL}" target="_blank" rel="noopener noreferrer">${CONTACT_NAME}</a></div>`;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
|
|
2
|
+
/** Whether the status is in use on a board: the switch is on for it, or tasks still stand in the column. Off and empty, the
|
|
3
|
+
status is in no menu and no column. `all` — the slug of the board of all projects. */
|
|
4
|
+
export function readyShown(projects, slug, all) {
|
|
5
|
+
const on = (p) => Boolean(p.ship_gate || p.c_ready);
|
|
6
|
+
return slug === all ? projects.some(on) : on(projects.find((p) => p.slug === slug) || {});
|
|
7
|
+
}
|
|
8
|
+
/** The statuses a menu offers: `current` stays, whatever the switch says. */
|
|
9
|
+
export const statusesShown = (statuses, current, shown) => statuses.filter(([v]) => v !== 'ready' || v === current || shown);
|
|
10
|
+
/** The columns of a board: Cancelled and Ready to ship only when there is a reason for them. */
|
|
11
|
+
export const boardColumns = (keys, tasks, shown) => keys.filter((k) => (k !== 'cancelled' || tasks.some((t) => t.status === 'cancelled'))
|
|
12
|
+
&& (k !== 'ready' || shown || tasks.some((t) => t.status === 'ready')));
|
|
13
|
+
/** The number of «All projects» in the sidebar. */
|
|
14
|
+
export const activeTotal = (projects) => projects.reduce((s, p) => s + (p.c_todo || 0) + (p.c_prep || 0) + (p.c_doing || 0) + (p.c_ready || 0) + (p.c_deploy || 0) + (p.c_review || 0), 0);
|
|
15
|
+
/** The number of the Dashboard in the sidebar: its «Waiting for you» lists them, «Claude review» does not (it counts what it shows). */
|
|
16
|
+
export const waitingForWord = (projects) => projects.reduce((s, p) => s + (p.c_ready || 0), 0);
|
|
17
|
+
/** A task its Claude has handed in: the ✓ of «Claude now». */
|
|
18
|
+
export const handedIn = (status) => status === 'ready' || status === 'review' || status === 'done';
|
|
19
|
+
/** The line under the switch in Settings → Statuses, from GET /api/flow. */
|
|
20
|
+
export function flowNote(v, tr) {
|
|
21
|
+
const left = v.ready_n ? `${tr('Waits in the column:')} ${v.ready_n}` : '';
|
|
22
|
+
const gone = !v.ready_gate && v.left_n ? tr('The switch is off, and the tasks stay: the column goes once it is empty.') : '';
|
|
23
|
+
const own = v.ready_gate && v.own_off?.length ? `${tr('These projects say otherwise and do not wait:')} ${v.own_off.join(', ')}` : '';
|
|
24
|
+
return [gone, left, own].filter(Boolean).join(' · ');
|
|
25
|
+
}
|