local-kanban 2.0.0-beta.1 → 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 +75 -0
- package/README.md +1 -1
- package/README.ru.md +1 -1
- package/bin/kb +45 -22
- package/package.json +1 -1
- package/public/js/automations.js +141 -44
- package/public/js/away-rules.js +5 -0
- package/public/js/away.js +55 -0
- package/public/js/board.js +44 -12
- package/public/js/chat.js +62 -18
- package/public/js/core.js +118 -5
- package/public/js/drawer.js +142 -38
- package/public/js/glyphs.js +29 -8
- package/public/js/init.js +20 -6
- package/public/js/link-lines.js +48 -0
- package/public/js/live-link.js +1 -1
- package/public/js/now-terms.js +10 -2
- 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/project.js +12 -2
- package/public/js/ready-rules.js +25 -0
- package/public/js/review-rules.js +20 -3
- package/public/js/settings.js +28 -6
- package/public/js/share.js +22 -3
- package/public/js/shipped-rules.js +29 -0
- package/public/js/sidebar.js +17 -2
- package/public/js/sse.js +6 -4
- package/public/js/term-delta.js +52 -0
- package/public/js/term-input-rules.js +2 -1
- package/public/js/unsaved.js +57 -2
- package/public/style.css +50 -7
- package/skills/kanban/SKILL.md +24 -7
- package/src/auto-health.js +36 -0
- package/src/auto-resume.js +203 -0
- package/src/automations.js +301 -27
- package/src/chat-feed.js +73 -25
- package/src/claude-asks.js +2 -2
- package/src/claude-live.js +4 -0
- package/src/claude-now.js +6 -6
- package/src/claude-sessions.js +10 -6
- package/src/db.js +45 -1
- package/src/own-wait.js +14 -6
- package/src/phantom.js +8 -4
- package/src/ready-gate.js +20 -0
- package/src/review-check.js +1 -1
- package/src/routes/automations.js +19 -1
- package/src/routes/chat.js +17 -2
- package/src/routes/dashboard.js +7 -5
- package/src/routes/projects.js +28 -6
- package/src/routes/share.js +6 -1
- package/src/routes/system.js +34 -7
- package/src/routes/tasks.js +33 -11
- package/src/routes/terminal.js +5 -6
- package/src/schema.sql +21 -6
- package/src/share.js +138 -16
- package/src/shipped.js +159 -0
- package/src/sync/github.js +1 -0
- package/src/term-screens.js +56 -0
- package/src/work-feed.js +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,81 @@ 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
|
+
|
|
30
|
+
## [2.0.0-beta.2]
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
|
|
34
|
+
- A status «Ready to ship» between Doing and Deploying, off by default: with the switch in Settings → Statuses a finished task waits for your word, and the board itself refuses Deploying past it, and Doing straight to Review. A project can say otherwise in its settings.
|
|
35
|
+
- A mark on the cards of Ready to ship and Review: on main, only in a branch or no commits found, and a filter of Review for what is not on main.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- The chat shows helpers and workflows at work: those started without the background flag, those brought back after a stop, and those that woke after finishing.
|
|
40
|
+
- The terminal sends what changed on the screen instead of the whole screen every second: far less traffic on a phone.
|
|
41
|
+
- The symbol background holds no video memory while it is off, and resizing the window no longer piles up canvases.
|
|
42
|
+
- Scrolling a column with many link lines no longer rewrites all of them on every frame.
|
|
43
|
+
- A comment whose answer got lost on the way is no longer written twice when the page sends it again.
|
|
44
|
+
- Automations: a restart of the board no longer loses the words that tell an interrupted Claude to go on, and a limit line left on the screen past its moment does not hold them.
|
|
45
|
+
- Automations: after a refused start, «Next batch in» counts down to the next try instead of standing at 0:00 for five minutes.
|
|
46
|
+
- `kb show A B` prints every card, a line of `---` between them; it used to print the first one and drop the rest with code 0.
|
|
47
|
+
- **Automations.** The fix of a returned task no longer waits hours for the terminals at their prompts; the grooming's Claude is
|
|
48
|
+
not closed while it asks you; one held task does not stop a batch; a blocked or `ask` task is not resumed.
|
|
49
|
+
- **Words kept.** A last word in a checklist item is not overwritten by a late resend, a failed save of the description no longer
|
|
50
|
+
claims someone else changed it, and words typed into a question that went away come back by themselves.
|
|
51
|
+
- **Phones and failures.** A phone on its side keeps the back gesture on the page, a phone says when the projects did not load,
|
|
52
|
+
a stream that never opened is announced, and a device stopped at the key page is listed in Turned away.
|
|
53
|
+
- **Sessions.** Only a move of the status takes a task over: a note or a field edited from another session does not.
|
|
54
|
+
|
|
55
|
+
### Changed
|
|
56
|
+
|
|
57
|
+
- **Automations watch the size of CLAUDE.md.** A switch opens a task in a project's backlog when its CLAUDE.md is past the
|
|
58
|
+
norm of Health; you start it yourself, and a task you cancelled does not come back.
|
|
59
|
+
- **A summary when you come back.** With automations on, the first look after eight hours of silence shows what was handed
|
|
60
|
+
in, accepted, returned or got stuck, each line opening its card.
|
|
61
|
+
- **Automations take the queue and a schedule.** Once a marked project is finished its next batch of To do starts, at most 5
|
|
62
|
+
tasks and not over 80% of the limit; a check of Review or the grooming of the backlog can run at a set time. A run the
|
|
63
|
+
sleeping computer missed is skipped.
|
|
64
|
+
- **A repeating automation can fix what its check returns.** A returned task goes back to work in a new session and the
|
|
65
|
+
check comes after it, at most 2 rounds; then the task waits for you.
|
|
66
|
+
- **The board can go on with interrupted work by itself.** A switch in Automations: once the limit has reset or the Mac is
|
|
67
|
+
up, a session cut off mid-work goes on, one a minute, at most 3 times in a row, and never one that waits for you.
|
|
68
|
+
- **An automation can repeat, and one form covers several projects.** A repeating rule stays after its check and waits for
|
|
69
|
+
the next batch, at most 10 checks a day; tick any projects, or all of them, and each gets its rule. At most two checks
|
|
70
|
+
run on the board at once, the others wait for a place.
|
|
71
|
+
- **The board over Wi-Fi opens by a key again.** The QR code and the link carry it, and a browser without them is asked for
|
|
72
|
+
it, so a home-screen app or a private tab is never locked out: the 16 characters can be typed by hand, and the device
|
|
73
|
+
remembers. Settings → Wi-Fi shows the key and changes it. Everyone connected by the plain address needs the new link.
|
|
74
|
+
- **Markdown files open in a window.** A `.md` file Claude wrote or edited opens from the list of changed files in the
|
|
75
|
+
chat, rendered and readable at the width of a page, on a phone too.
|
|
76
|
+
- **A card opens with its chat closed.** It used to open on the chat and scroll to its end, taking the title and the
|
|
77
|
+
description out of sight; now the chat opens from its tab, from ▶ or from «Open in the card».
|
|
78
|
+
|
|
79
|
+
### Fixed
|
|
80
|
+
|
|
81
|
+
- **Words typed in a chat survive Claude moving to its next task.** In a column of Terminals the field emptied and the
|
|
82
|
+
chat blinked «Loading…» whenever the session took the next task of its batch; the words now stay and the chat stands still.
|
|
83
|
+
- **Claude's last words stay in sight when its turn ends on a step.** A turn that ended by sending helpers or a command to
|
|
84
|
+
the background folded all its text behind the «Worked for» bar.
|
|
85
|
+
|
|
11
86
|
## [2.0.0-beta.1]
|
|
12
87
|
|
|
13
88
|
**2.0 beta.** The board stopped being a list of tasks next to Claude and became the place you work with it: Claude starts
|
package/README.md
CHANGED
|
@@ -134,7 +134,7 @@ Claude on it — the report stays on the card of the check.
|
|
|
134
134
|
|
|
135
135
|
The server listens on **127.0.0.1 only** and has **no authentication**. The board is strictly local: your machine is the trust boundary. **Never expose the port to the network** (no reverse proxies, no `0.0.0.0`, no port forwarding). Third-party integrations should run on the same machine and talk to `127.0.0.1` (see [docs/API.md](docs/API.md)).
|
|
136
136
|
|
|
137
|
-
The one exception is your own switch: **Settings → Wi-Fi** opens a separate door on the local network (port + 10, `KB_SHARE_PORT`) for people you show its QR code to. Only the link
|
|
137
|
+
The one exception is your own switch: **Settings → Wi-Fi** opens a separate door on the local network (port + 10, `KB_SHARE_PORT`) for people you show its QR code to. Only its key lets anyone in: in the link of the QR code, or typed by hand when a browser has no link. With it a guest can do on the board everything you can — including talking to Claude on your computer. The connection is plain HTTP, so the key keeps people out but does not encrypt the traffic: switch the door off on a network of strangers. It is off by default; switching it off closes the door, a new key drops every guest.
|
|
138
138
|
|
|
139
139
|
**The board holds no credentials.** A project can name an SSH host to deploy to, but neither a key
|
|
140
140
|
nor a password is ever entered here: the board simply runs `ssh <host>`, and who you are, on which
|
package/README.ru.md
CHANGED
|
@@ -133,7 +133,7 @@ local-kanban start # доска на http://localhost:3100
|
|
|
133
133
|
|
|
134
134
|
Сервер слушает **только 127.0.0.1**, авторизации **нет**. Доска строго локальная: граница доверия — твоя машина. **Никогда не выставляй порт наружу** (никаких reverse proxy, `0.0.0.0`, проброса портов). Сторонние интеграции запускаются на той же машине и ходят на `127.0.0.1` (см. [docs/API.md](docs/API.md)).
|
|
135
135
|
|
|
136
|
-
Исключение одно — твой выключатель: **«Настройки → Wi-Fi»** открывает отдельный вход в локальной сети (порт + 10, `KB_SHARE_PORT`) для тех, кому ты покажешь QR-код. Пускает только
|
|
136
|
+
Исключение одно — твой выключатель: **«Настройки → Wi-Fi»** открывает отдельный вход в локальной сети (порт + 10, `KB_SHARE_PORT`) для тех, кому ты покажешь QR-код. Пускает только ключ: он в ссылке QR-кода, а в браузере без ссылки его можно ввести руками. С ним гость может на доске всё то же, что и ты, — в том числе говорить с Claude на твоём компьютере. Соединение идёт простым HTTP: ключ не пускает посторонних, но не шифрует трафик, поэтому в сети с чужими людьми вход выключай. По умолчанию он закрыт; выключатель закрывает вход, а новый ключ выкидывает всех гостей.
|
|
137
137
|
|
|
138
138
|
**Доска не хранит доступов.** У проекта можно указать ssh-хост, куда он выкатывается, но ни ключ, ни
|
|
139
139
|
пароль здесь не вводятся: доска просто запускает `ssh <хост>`, а кто ты, на каком порту и с каким
|
package/bin/kb
CHANGED
|
@@ -14,14 +14,14 @@ const PRI_BY_NAME = { none: 0, low: 1, med: 2, medium: 2, high: 3, p0: 0, p1: 1,
|
|
|
14
14
|
const ST_ALIAS = {
|
|
15
15
|
b: 'backlog', backlog: 'backlog', t: 'todo', todo: 'todo',
|
|
16
16
|
pp: 'prep', prep: 'prep', d: 'doing', doing: 'doing',
|
|
17
|
-
dp: 'deploy', deploy: 'deploy', r: 'review', review: 'review', dn: 'done', done: 'done',
|
|
17
|
+
rd: 'ready', ready: 'ready', dp: 'deploy', deploy: 'deploy', r: 'review', review: 'review', dn: 'done', done: 'done',
|
|
18
18
|
c: 'cancelled', cancelled: 'cancelled', cancel: 'cancelled',
|
|
19
19
|
};
|
|
20
20
|
|
|
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 */ }
|
|
@@ -409,7 +413,7 @@ const commands = {
|
|
|
409
413
|
const ps = await api('GET', '/api/projects', null, LIST);
|
|
410
414
|
if (!ps.length) return console.log('no projects');
|
|
411
415
|
for (const p of ps) {
|
|
412
|
-
console.log(`${p.slug} (${p.prefix}) b${p.c_backlog || 0} t${p.c_todo || 0} pp${p.c_prep || 0} d${p.c_doing || 0} dp${p.c_deploy || 0} r${p.c_review || 0} ✓${p.c_done || 0}${p.c_blocked ? ` !${p.c_blocked}` : ''}`);
|
|
416
|
+
console.log(`${p.slug} (${p.prefix}) b${p.c_backlog || 0} t${p.c_todo || 0} pp${p.c_prep || 0} d${p.c_doing || 0}${p.c_ready ? ` rd${p.c_ready}` : ''} dp${p.c_deploy || 0} r${p.c_review || 0} ✓${p.c_done || 0}${p.c_blocked ? ` !${p.c_blocked}` : ''}`);
|
|
413
417
|
}
|
|
414
418
|
},
|
|
415
419
|
|
|
@@ -471,21 +475,38 @@ const commands = {
|
|
|
471
475
|
console.log(`${t.key} review 📝`);
|
|
472
476
|
},
|
|
473
477
|
|
|
478
|
+
// A batch is read in one go, so every key is printed, cards apart by a line of ---. A wrong key stops the
|
|
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.
|
|
474
482
|
async show() {
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
483
|
+
if (!args.length) die('kb show <key> [<key> …]');
|
|
484
|
+
const ids = args.map(key);
|
|
485
|
+
let at = 0;
|
|
486
|
+
let cut = null;
|
|
487
|
+
process.on('exit', (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`);
|
|
492
|
+
});
|
|
493
|
+
for (; at < ids.length; at++) {
|
|
494
|
+
const t = await api('GET', `/api/tasks/${ids[at]}`, null, TASK);
|
|
495
|
+
if (at) console.log('---');
|
|
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(',') + ']' : ''}`);
|
|
497
|
+
console.log(t.title);
|
|
498
|
+
if (t.description) console.log(t.description);
|
|
499
|
+
printChecklist(t);
|
|
500
|
+
printLinks(t);
|
|
501
|
+
printIssue(t);
|
|
502
|
+
printMoves(t);
|
|
503
|
+
printSessions(t);
|
|
504
|
+
printUsage(t);
|
|
505
|
+
printAttachments(t);
|
|
506
|
+
cut = t.key;
|
|
507
|
+
printComments(await api('GET', `/api/tasks/${t.id}/comments`, null, LIST), 5);
|
|
508
|
+
cut = null;
|
|
509
|
+
}
|
|
489
510
|
},
|
|
490
511
|
|
|
491
512
|
async add() {
|
|
@@ -506,7 +527,7 @@ const commands = {
|
|
|
506
527
|
}
|
|
507
528
|
if (si >= 0) {
|
|
508
529
|
body.status = ST_ALIAS[args[si + 1]];
|
|
509
|
-
if (!body.status) die('kb: status backlog|todo|prep|doing|deploy|review|done');
|
|
530
|
+
if (!body.status) die('kb: status backlog|todo|prep|doing|ready|deploy|review|done');
|
|
510
531
|
}
|
|
511
532
|
const t = await api('POST', '/api/tasks', body, TASK);
|
|
512
533
|
console.log(`${t.key} ${t.status} ${t.project}`);
|
|
@@ -515,7 +536,7 @@ const commands = {
|
|
|
515
536
|
async mv() {
|
|
516
537
|
const [id, st] = args;
|
|
517
538
|
const status = ST_ALIAS[st];
|
|
518
|
-
if (!id || !status) die('kb mv <key> backlog|todo|prep|doing|deploy|review');
|
|
539
|
+
if (!id || !status) die('kb mv <key> backlog|todo|prep|doing|ready|deploy|review');
|
|
519
540
|
const k = key(id);
|
|
520
541
|
// started by the board runs only after asking (src/phantom.js), while its `kb mv` goes without asking. Before
|
|
521
542
|
// this, `kb mv <key> done` sent the very request of `kb done` and skipped the question.
|
|
@@ -582,9 +603,10 @@ const commands = {
|
|
|
582
603
|
async note() {
|
|
583
604
|
const [id, body] = args;
|
|
584
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.
|
|
585
608
|
await api('POST', `/api/tasks/${key(id)}/comments`, { body, author: 'claude' }, COMMENT);
|
|
586
|
-
|
|
587
|
-
console.log(`${t.key} 📝`);
|
|
609
|
+
console.log(`${id} 📝`);
|
|
588
610
|
},
|
|
589
611
|
|
|
590
612
|
// kb link A B [child|parent|related] — `child`: B becomes a sub-task of A, `parent`: B becomes
|
|
@@ -650,6 +672,7 @@ const commands = {
|
|
|
650
672
|
if (p.domain) out.push(`domain: ${p.domain}`);
|
|
651
673
|
// CLAUDE.md", "deployed by skill X", DNS facts) — info printed everything but it
|
|
652
674
|
if (p.description) out.push(`description: ${p.description}`);
|
|
675
|
+
if (p.ship_gate) out.push('ships on the word: after the commit kb mv <key> ready and wait; deploy only once the person says so');
|
|
653
676
|
// page; without this line Claude went looking by itself and sometimes missed. A missing
|
|
654
677
|
// CLAUDE.md is said out loud — it is a signal (the task starts without architecture notes).
|
|
655
678
|
if (p.path) {
|
package/package.json
CHANGED
package/public/js/automations.js
CHANGED
|
@@ -8,14 +8,16 @@ 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
|
|
|
13
|
+
const CHECK_KIND = 'finished-check';
|
|
14
|
+
const SCHEDULED = new Set(['schedule-check', 'schedule-groom']);
|
|
12
15
|
let auto = { on: false, rules: [] };
|
|
13
16
|
let skew = 0; // the server's clock minus ours: a guest's phone may run off the Mac's
|
|
14
17
|
/** The last view of the server: {on, rules}. */
|
|
15
18
|
export const automations = () => auto;
|
|
16
|
-
/** The armed rules (one per project at most). */
|
|
17
19
|
export const armedRules = () => (auto.on ? auto.rules.filter((r) => r.state === 'armed') : []);
|
|
18
|
-
const armedOf = (slug) => armedRules().find((r) => r.project === slug) || null;
|
|
20
|
+
const armedOf = (slug) => armedRules().find((r) => r.project === slug && r.kind === CHECK_KIND) || null; // what ▶ of Review waits for
|
|
19
21
|
|
|
20
22
|
/** Read the view from the server; quiet — the board works the same without it. */
|
|
21
23
|
export async function loadAutomations() {
|
|
@@ -26,7 +28,7 @@ export function applyAutomations(v) {
|
|
|
26
28
|
if (!v || !Array.isArray(v.rules)) return;
|
|
27
29
|
const sig = (x) => JSON.stringify([x.on, x.rules.filter((r) => r.state === 'armed').length]);
|
|
28
30
|
const sidebar = sig(v) !== sig(auto);
|
|
29
|
-
auto = { on: Boolean(v.on), rules: v.rules };
|
|
31
|
+
auto = { on: Boolean(v.on), rules: v.rules, extras: v.extras || {} };
|
|
30
32
|
if (typeof v.now === 'number') skew = v.now - Date.now();
|
|
31
33
|
if (sidebar) renderSidebar();
|
|
32
34
|
if (state.slug === AUTO && $('automations')) paint();
|
|
@@ -52,19 +54,26 @@ function tick() {
|
|
|
52
54
|
const keysOf = (r) => (r.keys || []).join(', ');
|
|
53
55
|
/** What an armed rule waits for, in the board's words. */
|
|
54
56
|
export function phaseLine(r) {
|
|
55
|
-
if (r.phase === 'ready' && r.due_at) return `${tr('Check in')} <span class="auto-due" data-due="${r.due_at}">${mmss(left(r.due_at))}</span>`;
|
|
57
|
+
if (r.phase === 'ready' && r.due_at) return `${tr(r.kind === 'todo-queue' ? 'Next batch in' : 'Check in')} <span class="auto-due" data-due="${r.due_at}">${mmss(left(r.due_at))}</span>`;
|
|
56
58
|
const say = {
|
|
57
59
|
idle: tr('Waits for Claude to take up tasks of the project'),
|
|
58
60
|
working: `${tr('Claude is at work:')} ${esc(keysOf(r))}`,
|
|
59
61
|
ready: tr('Claude has finished — the pause begins'),
|
|
62
|
+
planned: `${tr('Next run:')} ${esc(r.plan?.at || '')}`,
|
|
60
63
|
}[r.phase];
|
|
61
64
|
if (say) return say;
|
|
62
65
|
const held = {
|
|
66
|
+
'at-work': `${tr('Claude is at work:')} ${esc(keysOf(r))}`,
|
|
63
67
|
question: `${tr('Waits for you: a question of Claude in')} ${esc(keysOf(r))}`,
|
|
64
68
|
ask: `${tr('Waits for you: Claude asks in')} ${esc(keysOf(r))}`,
|
|
65
69
|
'sent-back': `${tr('Waits for you: back in To do —')} ${esc(keysOf(r))}`,
|
|
66
70
|
limit: tr('Waits for the subscription limit to reset'),
|
|
67
71
|
'check-open': `${tr('The project already has an open check')} ${esc(keysOf(r))}`,
|
|
72
|
+
'day-cap': `${tr('The checks of the day are used up:')} ${r.fires_max}`,
|
|
73
|
+
busy: tr('Waits for a place: enough checks run on the board now'),
|
|
74
|
+
'limit-high': tr('Waits: over 80% of the subscription limit is used'),
|
|
75
|
+
'queue-empty': tr('Nothing to take in To do'),
|
|
76
|
+
unfinished: `${tr('Waits: the work in the project was cut off —')} ${esc(keysOf(r))}`,
|
|
68
77
|
nothing: tr('Nothing in Review to check'),
|
|
69
78
|
off: tr('Automations are switched off in Settings'),
|
|
70
79
|
}[r.reason];
|
|
@@ -72,43 +81,103 @@ export function phaseLine(r) {
|
|
|
72
81
|
}
|
|
73
82
|
const phaseText = (r) => { const d = document.createElement('div'); d.innerHTML = phaseLine(r); return d.textContent; };
|
|
74
83
|
const hm = (ms) => { const d = new Date(ms); return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; };
|
|
75
|
-
const
|
|
76
|
-
'no-folder': 'the project has no folder on this computer',
|
|
77
|
-
'no-tmux': 'tmux is not installed here',
|
|
78
|
-
'no-claude': 'Claude Code is not installed here',
|
|
79
|
-
'too-long': 'the prompt is too long for the board\'s terminal',
|
|
80
|
-
'check-open': 'the project already has an open check',
|
|
81
|
-
manual: 'a task done by hand cannot be given to Claude',
|
|
82
|
-
};
|
|
84
|
+
const failWhy = (r) => { const word = failWord(r.error); return word ? tr(word) : esc(r.error || ''); };
|
|
83
85
|
function endedLine(r) {
|
|
84
86
|
const key = r.check_key ? `<button type="button" class="auto-key" data-key="${esc(r.check_key)}">${esc(r.check_key)}</button>` : '';
|
|
85
87
|
if (r.state === 'fired') return `${tr('Check started')} ${key} · ${hm(r.ended_at)}`;
|
|
86
88
|
if (r.state === 'covered') return `${tr('The check was started by hand:')} ${key} · ${hm(r.ended_at)}`;
|
|
87
89
|
if (r.state === 'cancelled') return `${tr('Taken off')} · ${hm(r.ended_at)}${/^archived:/.test(r.error || '') ? `: ${tr('the project was archived')}` : ''}`;
|
|
88
|
-
|
|
89
|
-
const why = reason && FAIL_WORDS[reason] ? tr(FAIL_WORDS[reason]) : esc(r.error || '');
|
|
90
|
-
return `${tr('Did not start')} · ${hm(r.ended_at)}: ${why}${key ? ` · ${tr('the check waits in To do:')} ${key}` : ''}`;
|
|
90
|
+
return `${tr('Did not start')} · ${hm(r.ended_at)}: ${failWhy(r)}${key ? ` · ${tr('the check waits in To do:')} ${key}` : ''}`;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
/* ── the section ── */
|
|
94
94
|
const projectName = (slug) => { const p = state.projects.find((x) => x.slug === slug); return p ? (p.name || p.slug) : slug; };
|
|
95
|
-
const
|
|
95
|
+
const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
96
|
+
const whenWords = (plan) => `${plan?.day === null || plan?.day === undefined ? tr('Every day') : tr(DAYS[plan.day])}, ${esc(plan?.at || '')}`;
|
|
97
|
+
const ruleWhat = (r) => (r.kind === 'todo-queue' ? `${tr('When')} <b>${esc(r.project_name || projectName(r.project))}</b> ${tr('finishes, wait')} ${Math.round(r.delay_s / 60)} ${tr('min and start the next batch of To do')}`
|
|
98
|
+
: r.kind === 'schedule-check' ? `<b>${esc(r.project_name || projectName(r.project))}</b>: ${tr('a check of Review')} · ${whenWords(r.plan)}`
|
|
99
|
+
: r.kind === 'schedule-groom' ? `<b>${esc(r.project_name || projectName(r.project))}</b>: ${tr('the grooming of the backlog')} · ${whenWords(r.plan)}` : checkWhat(r));
|
|
100
|
+
const checkWhat = (r) => `${tr('When')} <b>${esc(r.project_name || projectName(r.project))}</b> ${tr('finishes, wait')} ${Math.round(r.delay_s / 60)} ${tr(r.repeat ? 'min and check Review, every time' : 'min and check Review')}${r.fix ? `, ${tr('fixing what comes back')}` : ''}`;
|
|
96
101
|
|
|
102
|
+
const KIND_NAMES = [
|
|
103
|
+
[CHECK_KIND, 'A check once Claude has finished'], ['todo-queue', 'The next batch of To do'],
|
|
104
|
+
['schedule-check', 'A check of Review on a schedule'], ['schedule-groom', 'The grooming of the backlog on a schedule'],
|
|
105
|
+
];
|
|
106
|
+
const KIND_HINTS = {
|
|
107
|
+
[CHECK_KIND]: 'Starts when every task Claude took in the project has reached Review and nothing waits for you. Like ▶ on the Review column — Claude inside the board, with the permissions and effort of Settings → Claude Code.',
|
|
108
|
+
'todo-queue': 'At most 5 tasks by priority, without ask and noclaude; it does not start while over 80% of the 5-hour or the weekly limit is used, nor over a question of Claude.',
|
|
109
|
+
'schedule-check': 'If the computer sleeps at that time the run is skipped until the next one. It does not start over work going on in the project.',
|
|
110
|
+
'schedule-groom': 'Claude prepares the backlog and moves no tasks. If the computer sleeps at that time the run is skipped until the next one.',
|
|
111
|
+
};
|
|
112
|
+
let formKind = CHECK_KIND;
|
|
113
|
+
let formDay = 'every';
|
|
97
114
|
function newRuleHTML() {
|
|
98
115
|
return '<div class="panel auto-new">'
|
|
99
116
|
+ `<div class="auto-h">${ic('bolt', 14)} ${tr('New automation')}</div>`
|
|
100
|
-
+
|
|
101
|
-
+ `<
|
|
117
|
+
+ `<div class="auto-sentence"><div class="kbsel auto-kind" id="auto-kind"></div></div>`
|
|
118
|
+
+ `<div class="auto-sentence" data-for="wait"><span>${tr('When the project finishes its work,')}</span>`
|
|
102
119
|
+ `<span>${tr('wait')}</span><input type="number" id="auto-min" class="auto-min" min="0" max="120" step="1" value="3" inputmode="numeric" aria-label="${esc(tr('minutes'))}"><span>${tr('min')}</span>`
|
|
103
|
-
+ `<span
|
|
104
|
-
+ `<div class="
|
|
120
|
+
+ `<span id="auto-then"></span></div>`
|
|
121
|
+
+ `<div class="auto-sentence" data-for="plan"><span>${tr('At')}</span><input type="time" id="auto-at" class="auto-min auto-at" value="03:00" aria-label="${esc(tr('Time'))}"><div class="kbsel auto-day" id="auto-day"></div></div>`
|
|
122
|
+
+ `<div class="auto-picks-h"><span>${tr('Projects')}</span><button type="button" class="auto-all" id="auto-all"></button></div>`
|
|
123
|
+
+ '<div class="auto-picks" id="auto-picks"></div>'
|
|
124
|
+
+ `<label class="set-row auto-repeat" data-for="check"><span class="set-lab">${tr('Repeat')}<small>${tr('after a check the rule waits for the next batch, until you take it off')}</small></span><input type="checkbox" class="set-toggle" id="auto-repeat" checked></label>`
|
|
125
|
+
+ `<label class="set-row auto-repeat" data-for="check" id="auto-fix-row"><span class="set-lab">${tr('Fix what the check returns')}<small>${tr('the board starts a returned task in a new session and the check comes after it, at most 2 rounds; then the task waits for you')}</small></span><input type="checkbox" class="set-toggle" id="auto-fix"></label>`
|
|
126
|
+
+ '<div class="muted auto-hint" id="auto-hint"></div>'
|
|
105
127
|
+ `<div class="auto-actions"><button type="button" class="btn-primary" id="auto-add">${tr('Add')}</button></div>`
|
|
106
128
|
+ '</div>';
|
|
107
129
|
}
|
|
130
|
+
/** The form for the kind of rule chosen. */
|
|
131
|
+
function paintKind() {
|
|
132
|
+
const shows = { wait: !SCHEDULED.has(formKind), plan: SCHEDULED.has(formKind), check: formKind === CHECK_KIND };
|
|
133
|
+
document.querySelectorAll('.auto-new [data-for]').forEach((el) => { el.classList.toggle('hidden', !shows[el.dataset.for]); });
|
|
134
|
+
$('auto-then').textContent = tr(formKind === CHECK_KIND ? 'and start a check of the tasks in Review' : 'and start the next batch of To do');
|
|
135
|
+
$('auto-hint').textContent = tr(KIND_HINTS[formKind]);
|
|
136
|
+
if (formKind === CHECK_KIND) $('auto-fix-row').classList.toggle('hidden', !$('auto-repeat').checked);
|
|
137
|
+
}
|
|
138
|
+
function wireForm() {
|
|
139
|
+
buildSelect($('auto-kind'), { value: formKind, options: KIND_NAMES.map(([value, label]) => ({ value, label: tr(label) })),
|
|
140
|
+
onChange: (v) => { formKind = v; freeSig = ''; paintKind(); paint(); } });
|
|
141
|
+
buildSelect($('auto-day'), { value: formDay, options: [{ value: 'every', label: tr('Every day') }, ...[1, 2, 3, 4, 5, 6, 0].map((d) => ({ value: String(d), label: tr(DAYS[d]) }))],
|
|
142
|
+
onChange: (v) => { formDay = v; } });
|
|
143
|
+
paintKind();
|
|
144
|
+
}
|
|
145
|
+
const EXTRAS = [
|
|
146
|
+
['resume', 'Go on with interrupted work', 'once the limit has reset or the Mac is up: all sessions, at most 3 times in a row, one a minute; never one that waits for you'],
|
|
147
|
+
['health', 'Watch the size of CLAUDE.md', 'a file past the norm of Health gets a task in the backlog of its project, once a day; you start it yourself'],
|
|
148
|
+
];
|
|
149
|
+
function extrasHTML() {
|
|
150
|
+
return `<div class="panel auto-extras"><div class="auto-h">${ic('bolt', 14)} ${tr('By itself')}</div>`
|
|
151
|
+
+ EXTRAS.map(([k, name, hint]) => `<label class="set-row auto-repeat"><span class="set-lab">${tr(name)}<small>${tr(hint)}</small></span><input type="checkbox" class="set-toggle" data-extra="${k}"></label>`).join('')
|
|
152
|
+
+ '</div>';
|
|
153
|
+
}
|
|
154
|
+
function paintExtras() {
|
|
155
|
+
document.querySelectorAll('[data-extra]').forEach((b) => { if (!b.disabled) b.checked = Boolean(auto.extras?.[b.dataset.extra]); });
|
|
156
|
+
}
|
|
157
|
+
function wireExtras() {
|
|
158
|
+
document.querySelectorAll('[data-extra]').forEach((b) => {
|
|
159
|
+
b.onchange = async () => {
|
|
160
|
+
b.disabled = true;
|
|
161
|
+
const launch = { permission: getSetting('permission'), effort: startEffort(false, getSetting('effort'), getSetting('effortCheck')) || undefined };
|
|
162
|
+
try { const v = await api('PUT', '/api/automations/extras', { [b.dataset.extra]: b.checked, launch }, { quiet: true }); b.disabled = false; applyAutomations(v); } catch (e) {
|
|
163
|
+
b.disabled = false;
|
|
164
|
+
b.checked = !b.checked;
|
|
165
|
+
styledAlert(refusalLine('Could not change the automation', e, tr));
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function repeatLine(r) {
|
|
172
|
+
if (!r.repeat || !(r.fires_n || r.error)) return '';
|
|
173
|
+
const key = r.check_key ? ` · <button type="button" class="auto-key" data-key="${esc(r.check_key)}">${esc(r.check_key)}</button>` : '';
|
|
174
|
+
if (r.error) return `<div class="auto-phase">${tr('Did not start')}: ${failWhy(r)}${r.check_key && r.check_status === 'todo' ? ` · ${tr('the check waits in To do:')}${key.slice(2)}` : ''}</div>`;
|
|
175
|
+
return `<div class="auto-phase">${tr('Checks in the last day:')} ${r.fires_n} / ${r.fires_max}${key}</div>`;
|
|
176
|
+
}
|
|
108
177
|
function armedHTML(r) {
|
|
109
178
|
return `<div class="panel auto-rule" data-id="${r.id}">`
|
|
110
179
|
+ `<div class="auto-rule-main"><div class="auto-what">${ruleWhat(r)}</div>`
|
|
111
|
-
+ `<div class="auto-phase${r.phase === 'ready' ? ' auto-ready' : ''}">${phaseLine(r)}</div
|
|
180
|
+
+ `<div class="auto-phase${r.phase === 'ready' ? ' auto-ready' : ''}">${phaseLine(r)}</div>${repeatLine(r)}</div>`
|
|
112
181
|
+ `<button type="button" class="btn-ghost auto-drop" data-id="${r.id}">${tr('Take off')}</button></div>`;
|
|
113
182
|
}
|
|
114
183
|
function endedHTML(r) {
|
|
@@ -117,7 +186,7 @@ function endedHTML(r) {
|
|
|
117
186
|
+ `<button type="button" class="btn-icon auto-forget" data-id="${r.id}" title="${esc(tr('Remove from the list'))}" aria-label="${esc(tr('Remove from the list'))}">✕</button></div>`;
|
|
118
187
|
}
|
|
119
188
|
|
|
120
|
-
|
|
189
|
+
const picked = new Set();
|
|
121
190
|
let freeSig = '';
|
|
122
191
|
/* The form is drawn once per opening and kept: the section repaints on every event of the board (it is a special view,
|
|
123
192
|
and the 5 s look of the server sends its own), and a form drawn anew took the focus out of the minutes field and
|
|
@@ -134,23 +203,32 @@ function paint() {
|
|
|
134
203
|
}
|
|
135
204
|
if (box.dataset.mode !== 'on' || !$('auto-list')) {
|
|
136
205
|
box.dataset.mode = 'on';
|
|
137
|
-
box.innerHTML = `<div class="now-wrap">${newRuleHTML()}<div id="auto-list" class="auto-list"></div></div>`;
|
|
206
|
+
box.innerHTML = `<div class="now-wrap">${newRuleHTML()}${extrasHTML()}<div id="auto-list" class="auto-list"></div></div>`;
|
|
207
|
+
wireExtras();
|
|
138
208
|
freeSig = '';
|
|
139
209
|
$('auto-add').onclick = () => addRule($('auto-add'));
|
|
210
|
+
$('auto-repeat').onchange = paintKind;
|
|
211
|
+
wireForm();
|
|
212
|
+
$('auto-all').onclick = () => {
|
|
213
|
+
const boxes = [...document.querySelectorAll('#auto-picks input')];
|
|
214
|
+
const all = boxes.every((b) => b.checked);
|
|
215
|
+
for (const b of boxes) { b.checked = !all; if (all) picked.delete(b.value); else picked.add(b.value); }
|
|
216
|
+
paintPicks();
|
|
217
|
+
};
|
|
140
218
|
}
|
|
219
|
+
paintExtras();
|
|
141
220
|
const armed = auto.rules.filter((r) => r.state === 'armed');
|
|
142
221
|
const past = auto.rules.filter((r) => r.state !== 'armed');
|
|
143
|
-
const free = state.projects.filter((p) => !p.archived && !armed.some((r) => r.project === p.slug));
|
|
222
|
+
const free = state.projects.filter((p) => !p.archived && !armed.some((r) => r.project === p.slug && r.kind === formKind));
|
|
144
223
|
const sig = JSON.stringify(free.map((p) => [p.slug, p.name]));
|
|
145
|
-
if (sig !== freeSig
|
|
224
|
+
if (sig !== freeSig) {
|
|
146
225
|
freeSig = sig;
|
|
147
|
-
if (!free.some((p) => p.slug ===
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
$('auto-add').disabled = !free.length;
|
|
226
|
+
for (const slug of picked) if (!free.some((p) => p.slug === slug)) picked.delete(slug);
|
|
227
|
+
if (!picked.size) { const first = free.find((p) => p.slug === state.lastProject) || free[0]; if (first) picked.add(first.slug); }
|
|
228
|
+
$('auto-picks').innerHTML = free.map((p) => `<label class="auto-pick"><input type="checkbox" value="${esc(p.slug)}"${picked.has(p.slug) ? ' checked' : ''}><span>${esc(p.name || p.slug)}</span></label>`).join('')
|
|
229
|
+
|| `<span class="muted">${tr('no project is free')}</span>`;
|
|
230
|
+
$('auto-picks').querySelectorAll('input').forEach((b) => { b.onchange = () => { if (b.checked) picked.add(b.value); else picked.delete(b.value); paintPicks(); }; });
|
|
231
|
+
paintPicks();
|
|
154
232
|
}
|
|
155
233
|
const list = $('auto-list');
|
|
156
234
|
keepFocus(list, () => {
|
|
@@ -161,22 +239,41 @@ function paint() {
|
|
|
161
239
|
list.querySelectorAll('.auto-key').forEach((b) => { b.onclick = () => openDrawer(b.dataset.key); });
|
|
162
240
|
}
|
|
163
241
|
|
|
242
|
+
function paintPicks() {
|
|
243
|
+
const boxes = [...document.querySelectorAll('#auto-picks input')];
|
|
244
|
+
$('auto-add').disabled = !picked.size;
|
|
245
|
+
$('auto-all').textContent = tr(boxes.length && boxes.every((b) => b.checked) ? 'Clear' : 'All projects');
|
|
246
|
+
$('auto-all').hidden = boxes.length < 2;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const BACK_WORDS = 'Each task that does not pass goes back to work by itself: write what is wrong with kb note <KEY>, then kb mv <KEY> todo.';
|
|
164
250
|
async function addRule(btn) {
|
|
165
251
|
const min = Number($('auto-min').value);
|
|
166
|
-
if (!picked) return;
|
|
167
|
-
if (!Number.isInteger(min) || min < 0 || min > 120) { styledAlert(tr('The pause is whole minutes, from 0 to 120.')); return; }
|
|
252
|
+
if (!picked.size) return;
|
|
253
|
+
if (!SCHEDULED.has(formKind) && (!Number.isInteger(min) || min < 0 || min > 120)) { styledAlert(tr('The pause is whole minutes, from 0 to 120.')); return; }
|
|
168
254
|
btn.disabled = true;
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
255
|
+
const check = formKind === CHECK_KIND;
|
|
256
|
+
const repeat = check && $('auto-repeat').checked;
|
|
257
|
+
const fix = repeat && $('auto-fix').checked;
|
|
258
|
+
const plan = { at: $('auto-at').value, day: formDay === 'every' ? null : Number(formDay) };
|
|
259
|
+
const launch = { permission: getSetting('permission'), effort: startEffort(formKind === 'schedule-check' || check, getSetting('effort'), getSetting('effortCheck')) || undefined };
|
|
260
|
+
const wordsFor = (project) => {
|
|
261
|
+
if (formKind === 'todo-queue') { const w = checkWords(); return { run: w.run, doTask: w.doTask }; }
|
|
262
|
+
if (formKind === 'schedule-groom') return { run: checkWords().run, groom: `${tr('Groom the backlog of')} ${project} ${tr('(grooming mode):')}` };
|
|
263
|
+
return { ...checkWords(), ...(fix ? { back: tr(BACK_WORDS) } : {}) };
|
|
264
|
+
};
|
|
265
|
+
const refused = [];
|
|
266
|
+
let last = null;
|
|
267
|
+
for (const project of [...picked]) {
|
|
268
|
+
try {
|
|
269
|
+
last = await api('POST', '/api/automations', {
|
|
270
|
+
project, kind: formKind, ...(SCHEDULED.has(formKind) ? { plan } : { delay_min: min }), ...(check ? { repeat, fix } : {}), words: wordsFor(project), launch,
|
|
271
|
+
}, { quiet: true });
|
|
272
|
+
} catch (e) { refused.push(`${projectName(project)}: ${refusalLine('Could not add the automation', e, tr)}`); }
|
|
179
273
|
}
|
|
274
|
+
btn.disabled = !picked.size;
|
|
275
|
+
if (last) applyAutomations(last);
|
|
276
|
+
if (refused.length) styledAlert(refused.join('\n'));
|
|
180
277
|
}
|
|
181
278
|
async function dropRule(id, btn) {
|
|
182
279
|
btn.disabled = true;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export const AWAY_MS = 8 * 3600_000; // the owner's word: eight hours of silence
|
|
2
|
+
/** Whether a page seen last at `last` owes a summary at `now`. A first visit has nothing to tell. */
|
|
3
|
+
export const awayDue = (last, now) => Number.isFinite(last) && last > 0 && now - last >= AWAY_MS;
|
|
4
|
+
/** Whether a summary has anything to tell. */
|
|
5
|
+
export const hasNews = (s) => ['handed', 'accepted', 'returned', 'stuck'].some((k) => s?.[k]?.n > 0);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { api, esc, tr } from './core.js';
|
|
2
|
+
import { automations, loadAutomations } from './automations.js';
|
|
3
|
+
import { openDrawer } from './drawer.js';
|
|
4
|
+
import { plural } from './settings.js';
|
|
5
|
+
import { overlayLayer } from './sidebar.js';
|
|
6
|
+
import { localStore } from './store.js';
|
|
7
|
+
import { awayDue, hasNews } from './away-rules.js';
|
|
8
|
+
|
|
9
|
+
const SEEN_KEY = 'kb.ui.lastSeen';
|
|
10
|
+
const SEEN_EVERY_MS = 60_000;
|
|
11
|
+
const seenAt = () => { try { return Number(localStore.getItem(SEEN_KEY)); } catch { return 0; } };
|
|
12
|
+
const noteSeen = () => { try { localStore.setItem(SEEN_KEY, String(Date.now())); } catch { /* not kept */ } };
|
|
13
|
+
|
|
14
|
+
const SECTIONS = [['handed', 'Handed in'], ['accepted', 'Accepted'], ['returned', 'Returned'], ['stuck', 'Stuck']];
|
|
15
|
+
function sectionHTML(k, name, s) {
|
|
16
|
+
if (!s?.n) return '';
|
|
17
|
+
const rows = s.tasks.map((t) => `<button type="button" class="away-row" data-key="${esc(t.key)}"><span class="away-key">${esc(t.key)}</span><span class="away-title">${esc(t.title)}</span><span class="away-proj">${esc(t.project)}</span></button>`).join('');
|
|
18
|
+
const more = s.n - s.tasks.length;
|
|
19
|
+
return `<div class="away-sec"><div class="away-h">${esc(tr(name))}: ${s.n}</div>${rows}${more > 0 ? `<div class="muted away-more">${tr('and more:')} ${more} ${esc(plural(more, 'task', 'tasks'))}</div>` : ''}</div>`;
|
|
20
|
+
}
|
|
21
|
+
function showSummary(s) {
|
|
22
|
+
const overlay = document.createElement('div');
|
|
23
|
+
overlay.className = 'modal-overlay';
|
|
24
|
+
overlay.innerHTML = `<div class="modal away-box" role="dialog" aria-label="${esc(tr('While you were away'))}"><div class="modal-title">${esc(tr('While you were away'))}</div>`
|
|
25
|
+
+ `<div class="away-body">${SECTIONS.map(([k, name]) => sectionHTML(k, name, s[k])).join('')}</div>`
|
|
26
|
+
+ `<div class="modal-actions"><button type="button" class="btn-primary modal-ok">${esc(tr('Got it'))}</button></div></div>`;
|
|
27
|
+
const close = overlayLayer(overlay);
|
|
28
|
+
document.body.appendChild(overlay);
|
|
29
|
+
overlay.querySelector('.modal-ok').onclick = close;
|
|
30
|
+
overlay.querySelectorAll('.away-row').forEach((b) => { b.onclick = () => { close(); openDrawer(b.dataset.key); }; });
|
|
31
|
+
overlay.querySelector('.modal-ok').focus();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let looking = false;
|
|
35
|
+
async function look() {
|
|
36
|
+
if (looking || document.hidden) return;
|
|
37
|
+
const last = seenAt();
|
|
38
|
+
if (Date.now() - last < SEEN_EVERY_MS) return;
|
|
39
|
+
looking = true;
|
|
40
|
+
noteSeen();
|
|
41
|
+
try {
|
|
42
|
+
if (!awayDue(last, Date.now())) return;
|
|
43
|
+
await loadAutomations();
|
|
44
|
+
if (!automations().on) return;
|
|
45
|
+
const s = await api('GET', `/api/automations/summary?since=${Math.round(last)}`, undefined, { quiet: true });
|
|
46
|
+
if (hasNews(s)) showSummary(s);
|
|
47
|
+
} catch { try { localStore.setItem(SEEN_KEY, String(last)); } catch { /* not kept */ } /* asked again at the next touch */ } finally { looking = false; }
|
|
48
|
+
}
|
|
49
|
+
/** On start, on a return to the tab and on the person's first touch: a tab left on the screen overnight is no one there, so
|
|
50
|
+
the moment seen is the person's, not the page's. */
|
|
51
|
+
export function startAway() {
|
|
52
|
+
look();
|
|
53
|
+
document.addEventListener('visibilitychange', look);
|
|
54
|
+
for (const e of ['pointerdown', 'keydown', 'wheel']) document.addEventListener(e, look, { passive: true, capture: true });
|
|
55
|
+
}
|