local-kanban 1.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +381 -0
  2. package/LICENSE +21 -0
  3. package/README.md +144 -0
  4. package/README.ru.md +144 -0
  5. package/bin/kb +306 -0
  6. package/bin/local-kanban +217 -0
  7. package/ecosystem.example.cjs +25 -0
  8. package/package.json +52 -0
  9. package/public/app.js +18 -0
  10. package/public/boot-fallback.js +3 -0
  11. package/public/drawer-sync.js +14 -0
  12. package/public/favicon-180.png +0 -0
  13. package/public/favicon-32.png +0 -0
  14. package/public/favicon-64.png +0 -0
  15. package/public/favicon.ico +0 -0
  16. package/public/favicon.svg +6 -0
  17. package/public/icon-192.png +0 -0
  18. package/public/icon-512.png +0 -0
  19. package/public/icon-maskable-192.png +0 -0
  20. package/public/icon-maskable-512.png +0 -0
  21. package/public/index.html +138 -0
  22. package/public/js/board.js +698 -0
  23. package/public/js/chaos.js +247 -0
  24. package/public/js/core.js +821 -0
  25. package/public/js/dash.js +114 -0
  26. package/public/js/drawer.js +919 -0
  27. package/public/js/horizon.js +140 -0
  28. package/public/js/init.js +208 -0
  29. package/public/js/project.js +338 -0
  30. package/public/js/settings.js +905 -0
  31. package/public/js/sidebar.js +640 -0
  32. package/public/js/sse.js +386 -0
  33. package/public/manifest.webmanifest +32 -0
  34. package/public/offline.html +39 -0
  35. package/public/style.css +1247 -0
  36. package/public/sw.js +31 -0
  37. package/scripts/raycast/open-kanban.sh +18 -0
  38. package/scripts/update.js +43 -0
  39. package/skills/deploy/SKILL.md +70 -0
  40. package/skills/kanban/SKILL.md +217 -0
  41. package/src/backup.js +111 -0
  42. package/src/bus.js +10 -0
  43. package/src/config.js +40 -0
  44. package/src/db.js +321 -0
  45. package/src/error-handler.js +11 -0
  46. package/src/file-snapshot.js +22 -0
  47. package/src/origin-guard.js +32 -0
  48. package/src/pm2-services.js +31 -0
  49. package/src/query-params.js +17 -0
  50. package/src/repo-base.js +23 -0
  51. package/src/restore.js +153 -0
  52. package/src/routes/dashboard.js +132 -0
  53. package/src/routes/events.js +30 -0
  54. package/src/routes/horizons.js +66 -0
  55. package/src/routes/projects.js +532 -0
  56. package/src/routes/skills.js +116 -0
  57. package/src/routes/system.js +285 -0
  58. package/src/routes/tasks.js +477 -0
  59. package/src/schema.sql +135 -0
  60. package/src/server.js +100 -0
  61. package/src/skills.js +119 -0
  62. package/src/sync/github.js +265 -0
  63. package/src/sync/worker.js +136 -0
  64. package/src/task-shape.js +29 -0
@@ -0,0 +1,905 @@
1
+
2
+ import { WORKING, buildSelect, drawLinks } from './board.js';
3
+ import { $, ALL_STATUSES, LANG, api, apiBlob, esc, ic, seg, state, tr } from './core.js';
4
+ import { applyKeepDrawer } from './drawer.js';
5
+ import { checkVersion, pendingReload } from './init.js';
6
+ import { loadProjects, popLayer, pushLayer, setupPromptBlockHTML, styledAlert, styledConfirm, styledPrompt, wireSetupPrompt } from './sidebar.js';
7
+ import { SHORT_MONTHS, SOUND_LIB, STATUS_LABELS, audioCtx, lastMissedAt, lastSseAt, missedSounds, pageLoadedAt, playNamedSound, refresh, relTime, titleOr, updateSoundBadge } from './sse.js';
8
+
9
+ const SETTINGS_DEFAULTS = { sound: true, soundReview: 'ding', soundDone: 'click', startScreen: 'dashboard', dashRange: 'week', theme: 'system', keepDrawer: false, notifyReview: false, compactCards: false, linkLines: true, keymap: {} };
10
+ export function getSetting(k) {
11
+ const v = localStorage.getItem('kb.set.' + k);
12
+ if (v === null) return SETTINGS_DEFAULTS[k];
13
+ try { return JSON.parse(v); } catch { return SETTINGS_DEFAULTS[k]; }
14
+ }
15
+ export function setSetting(k, v) { localStorage.setItem('kb.set.' + k, JSON.stringify(v)); }
16
+
17
+ export function syncLangToServer(lang = LANG) {
18
+ return api('POST', '/api/lang', { lang }).catch(() => {});
19
+ }
20
+
21
+ const KEY_ACTIONS = [
22
+ { id: 'newTask', label: tr('new task'), def: { code: 'KeyN' } },
23
+ { id: 'prevProject', label: tr('previous project (in the sidebar)'), def: { code: 'BracketLeft' } },
24
+ { id: 'nextProject', label: tr('next project (in the sidebar)'), def: { code: 'BracketRight' } },
25
+ { id: 'openCard', label: tr('open the selected card'), def: { code: 'Enter' } },
26
+ { id: 'deleteCard', label: tr('delete the selection / the card under the cursor'), def: { code: 'Delete' } },
27
+ ];
28
+ const KEY_FIXED = [
29
+ ['← ↑ ↓ →', 'move the selection across cards'],
30
+ ['Shift + ← ↑ ↓ →', 'move a card (between columns / by position)'],
31
+ ['Esc', 'close the card · clear the selection'],
32
+ ['⌘ + Enter', 'save edits in the open card'],
33
+ ['⌘ + V', 'paste a screenshot into the open task or a comment'],
34
+ ['Shift + Enter', 'line break in the comment field'],
35
+ ['right-click on a card', 'action menu (status, priority, labels, duplicate…)'],
36
+ ];
37
+ function keyBinding(id) {
38
+ const km = getSetting('keymap') || {};
39
+ return km[id] || KEY_ACTIONS.find((a) => a.id === id).def;
40
+ }
41
+ export function matchKey(e, id) {
42
+ const b = keyBinding(id);
43
+ return e.code === b.code && !!b.shift === e.shiftKey && !!b.alt === e.altKey && !!b.ctrl === e.ctrlKey && !!b.meta === e.metaKey;
44
+ }
45
+ const KEY_NAMES = { BracketLeft: '[', BracketRight: ']', Backspace: '⌫', Escape: 'Esc', Space: 'Space', Minus: '-', Equal: '=', Comma: ',', Period: '.', Slash: '/', Semicolon: ';', Quote: "'", Backquote: '`' };
46
+ function comboLabel(b) {
47
+ const name = KEY_NAMES[b.code] || b.code.replace(/^(Key|Digit)/, '');
48
+ return `${b.ctrl ? '⌃ ' : ''}${b.alt ? '⌥ ' : ''}${b.shift ? '⇧ ' : ''}${b.meta ? '⌘ ' : ''}${name}`;
49
+ }
50
+
51
+ const THEMES = [
52
+ { v: 'system', label: 'System', sw: ['#fbfbfa', '#ffffff', '#e8704e'] },
53
+ { v: 'light', label: 'Light', sw: ['#fbfbfa', '#ffffff', '#e8704e'] },
54
+ { v: 'dark', label: 'Dark', sw: ['#1c1b19', '#262522', '#e8854a'] },
55
+ { v: 'claude', label: 'Claude', sw: ['#1f1f1e', '#262626', '#d97757'] },
56
+ ];
57
+ export function applyTheme() {
58
+ let t = getSetting('theme');
59
+ if (t === 'coral') { setSetting('theme', 'light'); t = 'light'; }
60
+ let mode = t;
61
+ if (t === 'system') mode = (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
62
+ document.documentElement.dataset.theme = mode; // light | dark | claude
63
+ syncThemeColor();
64
+ }
65
+
66
+ export function applyCompact() {
67
+ $('board').classList.toggle('compact', !!getSetting('compactCards'));
68
+ }
69
+
70
+ function syncThemeColor() {
71
+ const meta = document.querySelector('meta[name="theme-color"]');
72
+ if (!meta) return;
73
+ const bg = getComputedStyle(document.documentElement).getPropertyValue('--bg').trim();
74
+ if (bg) meta.setAttribute('content', bg);
75
+ }
76
+
77
+ const SM_SECTIONS = [['general', tr('General')], ['appearance', tr('Appearance')], ['cats', tr('Sections')], ['skills', tr('Skills')], ['archive', tr('Archive')], ['sync', tr('Sync')], ['errors', tr('Errors')], ['backups', tr('Backups')], ['keys', tr('Hotkeys')], ['about', tr('About')]];
78
+ function settingsModalKey(e) { if (e.key === 'Escape') closeSettingsModal(); }
79
+ function closeSettingsModal() {
80
+ popLayer('settings-overlay');
81
+ $('settings-overlay')?.remove();
82
+ document.removeEventListener('keydown', settingsModalKey);
83
+ }
84
+ export function openSettingsModal() {
85
+ closeSettingsModal();
86
+ pushLayer('settings-overlay', closeSettingsModal);
87
+ const overlay = document.createElement('div');
88
+ overlay.className = 'modal-overlay';
89
+ overlay.id = 'settings-overlay';
90
+ overlay.innerHTML = `<div class="settings-modal">
91
+ <div class="sm-nav">
92
+ <div class="sm-nav-head">${tr('Settings')}</div>
93
+ ${SM_SECTIONS.map(([id, lab], i) => `<div class="sm-nav-item${i === 0 ? ' active' : ''}" data-sec="${id}">${lab}</div>`).join('')}
94
+ </div>
95
+ <div class="sm-main">
96
+ <button class="sm-close btn-icon" title="${tr('Close')}">✕</button>
97
+ <div class="sm-body" id="sm-body"></div>
98
+ </div>
99
+ </div>`;
100
+ document.body.appendChild(overlay);
101
+ overlay.querySelector('.sm-close').onclick = closeSettingsModal;
102
+ overlay.onclick = (e) => { if (e.target === overlay) closeSettingsModal(); };
103
+ overlay.querySelectorAll('.sm-nav-item').forEach((n) => {
104
+ n.onclick = () => { overlay.querySelectorAll('.sm-nav-item').forEach((x) => x.classList.toggle('active', x === n)); renderSection(n.dataset.sec); };
105
+ });
106
+ document.addEventListener('keydown', settingsModalKey);
107
+ renderSection('general');
108
+ }
109
+ async function renderSection(sec) {
110
+ const body = $('sm-body');
111
+ if (!body) return;
112
+ if (sec === 'general') {
113
+ const soundList = Object.entries(SOUND_LIB).map(([id, s]) => ({ value: id, label: tr(s.name) }));
114
+ body.innerHTML = `<div class="sm-h">${tr('General')}</div>`
115
+ + `<div class="set-row"><span class="set-lab">${tr('Interface language')}<small>${tr('applies after page reload')}</small></span><div class="kbsel set-sel" id="sel-lang"></div></div>`
116
+ + `<label class="set-row"><span class="set-lab">${tr('Sound notifications')}<small>${tr('sound on Review and Done')}</small></span><input type="checkbox" class="set-toggle" data-set="sound"></label>`
117
+ + `<label class="set-row"><span class="set-lab">${tr('Review notifications')}<small>${tr('system notification when a task reaches Review')}</small></span><input type="checkbox" class="set-toggle" data-set="notifyReview"></label>`
118
+ + `<div class="set-row"><span class="set-lab">${tr('Sound for Review')}</span><span class="sound-pick"><div class="kbsel set-sel" id="sel-soundReview"></div><button class="btn-ghost snd-test" data-test="soundReview" title="▶">▶</button></span></div>`
119
+ + `<div class="set-row"><span class="set-lab">${tr('Sound for Done')}</span><span class="sound-pick"><div class="kbsel set-sel" id="sel-soundDone"></div><button class="btn-ghost snd-test" data-test="soundDone" title="▶">▶</button></span></div>`
120
+ + `<label class="set-row"><span class="set-lab">${tr('Task sidebar always open')}<small>${tr('keep the right panel visible even with no task selected')}</small></span><input type="checkbox" class="set-toggle" data-set="keepDrawer"></label>`
121
+ + `<div class="set-row"><span class="set-lab">${tr('Start screen')}<small>${tr('what to open on launch')}</small></span><div class="kbsel set-sel" id="sel-startScreen"></div></div>`
122
+ + `<div class="set-row"><span class="set-lab">${tr('Default dashboard range')}</span><div class="kbsel set-sel" id="sel-dashRange"></div></div>`
123
+ + `<div class="set-row"><span class="set-lab">${tr('Sound diagnostics')}<small id="snd-diag">…</small></span></div>`;
124
+ const fmtT = (d) => d ? d.toLocaleTimeString(LANG === 'en' ? 'en-GB' : 'ru-RU', { hour: '2-digit', minute: '2-digit' }) : '—';
125
+ const renderSndDiag = () => {
126
+ const el = document.getElementById('snd-diag');
127
+ if (!el) return false;
128
+ const st = audioCtx ? ({ running: tr('running'), suspended: tr('blocked by autoplay'), closed: tr('closed') }[audioCtx.state] || audioCtx.state) : tr('not created');
129
+ const sseAge = Math.round((Date.now() - lastSseAt) / 1000);
130
+ el.textContent = `${tr('audio:')} ${st} · ${tr('sounds missed:')} ${missedSounds}${lastMissedAt ? ` (${tr('last at')} ${fmtT(lastMissedAt)})` : ''} · ${tr('server signal:')} ${sseAge}${tr('s ago')} · ${tr('page loaded at')} ${fmtT(pageLoadedAt)}`;
131
+ return true;
132
+ };
133
+ renderSndDiag();
134
+ const diagTimer = setInterval(() => { if (!renderSndDiag()) clearInterval(diagTimer); }, 1000);
135
+ body.querySelector('[data-set="sound"]').checked = !!getSetting('sound');
136
+ body.querySelector('[data-set="sound"]').onchange = (e) => { setSetting('sound', e.target.checked); updateSoundBadge(); };
137
+ const notifyEl = body.querySelector('[data-set="notifyReview"]');
138
+ notifyEl.checked = !!getSetting('notifyReview');
139
+ notifyEl.onchange = async (e) => {
140
+ if (e.target.checked && typeof Notification !== 'undefined' && Notification.permission !== 'granted') {
141
+ const perm = await Notification.requestPermission();
142
+ if (perm !== 'granted') { e.target.checked = false; setSetting('notifyReview', false); return; }
143
+ }
144
+ setSetting('notifyReview', e.target.checked);
145
+ };
146
+ body.querySelector('[data-set="keepDrawer"]').checked = !!getSetting('keepDrawer');
147
+ body.querySelector('[data-set="keepDrawer"]').onchange = (e) => { setSetting('keepDrawer', e.target.checked); applyKeepDrawer(); };
148
+ buildSelect($('sel-soundReview'), { value: getSetting('soundReview'), options: soundList, onChange: (v) => { setSetting('soundReview', v); playNamedSound(v); } });
149
+ buildSelect($('sel-soundDone'), { value: getSetting('soundDone'), options: soundList, onChange: (v) => { setSetting('soundDone', v); playNamedSound(v); } });
150
+ buildSelect($('sel-startScreen'), { value: getSetting('startScreen'), options: [{ value: 'dashboard', label: tr('Dashboard') }, { value: 'chaos', label: tr('Chaos') }, { value: 'last', label: tr('Last opened') }], onChange: (v) => setSetting('startScreen', v) });
151
+ buildSelect($('sel-dashRange'), { value: getSetting('dashRange'), options: RANGE_BTN.map(([v, lab]) => ({ value: v, label: tr(lab) })), onChange: (v) => { setSetting('dashRange', v); state.dashRange = v; } });
152
+ buildSelect($('sel-lang'), { value: LANG, options: [{ value: 'ru', label: tr('Russian') }, { value: 'en', label: 'English' }], onChange: async (v) => { setSetting('lang', v); await syncLangToServer(v); location.reload(); } });
153
+ body.querySelectorAll('.snd-test').forEach((b) => { b.onclick = () => playNamedSound(getSetting(b.dataset.test)); });
154
+ } else if (sec === 'appearance') {
155
+ const cur = getSetting('theme');
156
+ body.innerHTML = `<div class="sm-h">${tr('Appearance')}</div>`
157
+ + `<div class="set-col"><span class="set-lab">${tr('Theme')}<small>${tr('light, dark or in Claude colors')}</small></span>`
158
+ + `<div class="theme-grid">${THEMES.map((t) => `<button class="theme-card${t.v === cur ? ' active' : ''}" data-theme="${t.v}">`
159
+ + `<span class="theme-sw">${t.sw.map((c) => `<span style="background:${c}"></span>`).join('')}</span>`
160
+ + `<span class="theme-card-lab">${tr(t.label)}</span></button>`).join('')}</div></div>`
161
+ + `<label class="set-row"><span class="set-lab">${tr('Compact cards')}<small>${tr('hide the description preview — more cards fit on screen')}</small></span><input type="checkbox" class="set-toggle" data-set="compactCards"></label>`
162
+ + `<label class="set-row"><span class="set-lab">${tr('Link lines')}<small>${tr('draw lines between linked cards; when off, links show as a chain badge')}</small></span><input type="checkbox" class="set-toggle" data-set="linkLines"></label>`;
163
+ body.querySelectorAll('.theme-card').forEach((b) => {
164
+ b.onclick = () => { setSetting('theme', b.dataset.theme); applyTheme(); body.querySelectorAll('.theme-card').forEach((x) => x.classList.toggle('active', x === b)); };
165
+ });
166
+ body.querySelector('[data-set="compactCards"]').checked = !!getSetting('compactCards');
167
+ body.querySelector('[data-set="compactCards"]').onchange = (e) => { setSetting('compactCards', e.target.checked); applyCompact(); };
168
+ body.querySelector('[data-set="linkLines"]').checked = getSetting('linkLines') !== false;
169
+ body.querySelector('[data-set="linkLines"]').onchange = (e) => { setSetting('linkLines', e.target.checked); drawLinks(); };
170
+ } else if (sec === 'archive') {
171
+ body.innerHTML = `<div class="sm-h">${tr('Archive')}</div>`
172
+ + `<div class="kbh-note muted">${tr('Archived boards are hidden from the sidebar and left out of every dashboard number. Their tasks are kept.')}</div>`
173
+ + '<div id="arch-list" class="bk-list">…</div>';
174
+ const load = async () => {
175
+ let list;
176
+ try { list = await api('GET', '/api/projects/archived'); } catch { $('arch-list').textContent = '—'; return; }
177
+ $('arch-list').innerHTML = list.length
178
+ ? list.map((p) => `<div class="bk-row"><span>${esc(p.name)}</span>`
179
+ + `<span class="muted">${p.tasks_n} ${plural(p.tasks_n, 'task', 'tasks')}</span>`
180
+ + `<button class="btn-ghost arch-back" data-slug="${esc(p.slug)}">${tr('Bring back')}</button></div>`).join('')
181
+ : `<div class="muted">${tr('nothing archived')}</div>`;
182
+ $('arch-list').querySelectorAll('.arch-back').forEach((b) => {
183
+ b.onclick = async () => {
184
+ b.disabled = true;
185
+ try { await api('PATCH', `/api/projects/${seg(b.dataset.slug)}`, { archived: 0 }); } catch { b.disabled = false; return; }
186
+ await loadProjects();
187
+ load();
188
+ };
189
+ });
190
+ };
191
+ load();
192
+ } else if (sec === 'cats') {
193
+ body.innerHTML = `<div class="sm-h">${tr('Sections')}<button class="btn-ghost" id="cat-add">${tr('+ New section')}</button></div>`
194
+ + `<div class="kbh-note muted">${tr('Sections group boards in the sidebar. A new name applies to every board in the section.')}</div>`
195
+ + '<div id="cat-list" class="cat-list">…</div>';
196
+ const byCat = new Map();
197
+ for (const p of state.projects) {
198
+ const c = p.category || 'Other';
199
+ if (!byCat.has(c)) byCat.set(c, []);
200
+ byCat.get(c).push(p);
201
+ }
202
+ let cats;
203
+ try { cats = await api('GET', '/api/categories'); }
204
+ catch { $('cat-list').textContent = '—'; return; }
205
+ const opts = cats.map((c) => ({ value: c.name, label: c.locked ? tr(c.name) : c.name }));
206
+ $('cat-list').innerHTML = cats.map((c) => `<div class="cat-block" data-cat="${esc(c.name)}">
207
+ <div class="cat-row">
208
+ <input class="cat-name" type="text" value="${esc(c.locked ? tr(c.name) : c.name)}"${c.locked ? ` disabled title="${esc(tr('built-in section — the name cannot be changed'))}"` : ''}>
209
+ <span class="muted cat-count">${c.count || tr('empty')}</span>
210
+ <button class="btn-ghost cat-del"${c.locked ? ` disabled title="${esc(tr('built-in section — cannot be deleted'))}"` : ''}>${tr('Delete')}</button>
211
+ </div>
212
+ <div class="cat-projs">${(byCat.get(c.name) || []).map((p) => `<div class="cat-proj">
213
+ <span class="cat-proj-name">${esc(p.name || p.slug)}</span>
214
+ <div class="kbsel cat-sel" data-slug="${esc(p.slug)}"></div></div>`).join('')}</div>
215
+ </div>`).join('');
216
+ $('cat-list').querySelectorAll('.cat-sel').forEach((host) => {
217
+ const slug = host.dataset.slug;
218
+ const cur = state.projects.find((p) => p.slug === slug);
219
+ buildSelect(host, {
220
+ value: cur?.category || 'Other',
221
+ options: opts,
222
+ onChange: async (v) => {
223
+ try { await api('PATCH', `/api/projects/${seg(slug)}`, { category: v === 'Other' ? null : v }); }
224
+ catch { }
225
+ await refresh();
226
+ renderSection('cats');
227
+ },
228
+ });
229
+ });
230
+ const catAct = async (fn) => {
231
+ try { await fn(); } catch (e) { $('cat-list').insertAdjacentHTML('afterbegin', `<div class="cat-err">${esc(e.message || tr('did not work'))}</div>`); return; }
232
+ await refresh();
233
+ renderSection('cats');
234
+ };
235
+ $('cat-list').querySelectorAll('.cat-name').forEach((inp) => {
236
+ const from = inp.closest('.cat-block').dataset.cat;
237
+ inp.onchange = () => {
238
+ const to = inp.value.trim();
239
+ if (!to || to === from) { inp.value = from; return; }
240
+ catAct(() => api('PATCH', `/api/categories/${encodeURIComponent(from)}`, { name: to }));
241
+ };
242
+ inp.onkeydown = (e) => { if (e.key === 'Enter') inp.blur(); };
243
+ });
244
+ $('cat-list').querySelectorAll('.cat-del').forEach((btn) => {
245
+ const name = btn.closest('.cat-block').dataset.cat;
246
+ const n = (byCat.get(name) || []).length;
247
+ btn.onclick = async () => {
248
+ const msg = n ? `${tr('Delete section')} «${name}»? ${n} ${tr('board(s) will move to “Other”.')}` : `${tr('Delete section')} «${name}»?`;
249
+ if (!await styledConfirm(msg, { okLabel: tr('Delete'), danger: true })) return;
250
+ catAct(() => api('DELETE', `/api/categories/${encodeURIComponent(name)}`));
251
+ };
252
+ });
253
+ $('cat-add').onclick = () => styledPrompt(tr('New section'), {
254
+ placeholder: tr('section name'),
255
+ okLabel: tr('Add'),
256
+ onSubmit: async (name) => { await api('POST', '/api/categories', { name }); await refresh(); renderSection('cats'); },
257
+ });
258
+ } else if (sec === 'sync') {
259
+ body.innerHTML = `<div class="sm-h">${tr('GitHub sync')}</div>`
260
+ + `<div class="set-col"><span class="set-lab">${tr('Owner and issues repository')}<small id="sync-cfg-hint">${tr('empty — sync is off, the board runs locally')}</small></span>`
261
+ + `<div class="sync-cfg-row"><input type="text" id="sync-owner" placeholder="${tr('owner (GitHub user)')}"><input type="text" id="sync-repo" placeholder="${tr('owner/repo for issues')}"><button class="btn-ghost" id="sync-cfg-save">${tr('Save')}</button></div></div>`
262
+ + `<div class="set-row"><span class="set-lab" id="set-sync">…<small id="set-sync-last"></small></span><button class="btn-ghost" id="set-sync-retry">${tr('Retry failed')}</button></div>`
263
+ + `<label class="set-row"><span class="set-lab">${tr('Pause sync')}<small>${tr('keep changes local and catch up with GitHub later')}</small></span><input type="checkbox" class="set-toggle" id="sync-pause"></label>`
264
+ + `<div class="set-col hidden" id="sync-errors-wrap"><span class="set-lab">${tr('Recent errors')}</span><div id="sync-errors" class="bk-list"></div></div>`;
265
+ const OP_LABEL = { create_issue: tr('create issue'), set_status: tr('status'), set_priority: tr('priority'), set_labels: tr('labels'), set_blocked: tr('blocked flag'), add_comment: tr('comment'), update_issue: tr('edit issue'), close_issue: tr('close issue'), reopen_issue: tr('reopen issue'), delete_issue: tr('delete issue') };
266
+ try {
267
+ const s = await api('GET', '/api/sync');
268
+ $('sync-owner').value = s.owner || '';
269
+ $('sync-repo').value = s.repo || '';
270
+ if (s.source === 'env') {
271
+ $('sync-cfg-hint').textContent = tr('set via env KB_GH_OWNER/KB_GH_REPO — edit your launch config');
272
+ $('sync-owner').disabled = $('sync-repo').disabled = $('sync-cfg-save').disabled = true;
273
+ }
274
+ $('set-sync').childNodes[0].textContent = s.configured === false ? tr('sync is off — the board runs locally')
275
+ : s.paused ? tr('sync paused') + (s.pending ? ` · ${s.pending} ${tr('piling up')}` : '')
276
+ : s.gh === false ? tr('GitHub is not set up (gh auth needed)')
277
+ : s.failed.length ? `${s.failed.length} ${tr('failed in the queue')}`
278
+ : s.pending ? `${s.pending} ${tr('queued')}` : tr('everything synced ✓');
279
+ $('set-sync-last').textContent = s.last_ok ? `${tr('last successful sync:')} ${fmtDbTime(s.last_ok)}` : tr('no successful syncs yet');
280
+ $('set-sync-retry').disabled = !s.failed.length;
281
+ $('sync-pause').checked = !!s.paused;
282
+ if (s.errors?.length) {
283
+ $('sync-errors-wrap').classList.remove('hidden');
284
+ $('sync-errors').innerHTML = s.errors.map((r) =>
285
+ `<div class="bk-row"><span>${esc(OP_LABEL[r.op] || r.op)}${r.key ? ` · ${esc(r.key)}` : ''}</span><span class="muted" title="${esc(r.last_error || '')}">${esc((r.last_error || '').slice(0, 80))}${(r.last_error || '').length > 80 ? '…' : ''}</span></div>`).join('');
286
+ }
287
+ } catch { $('set-sync').childNodes[0].textContent = '—'; $('set-sync-retry').disabled = true; }
288
+ $('sync-pause').onchange = async (e) => { try { await api('POST', '/api/sync/pause', { paused: e.target.checked }); } catch { } renderSection('sync'); };
289
+ $('set-sync-retry').onclick = async () => { try { await api('POST', '/api/sync/retry'); } catch { } renderSection('sync'); };
290
+ $('sync-cfg-save').onclick = async () => {
291
+ try { await api('POST', '/api/sync/config', { owner: $('sync-owner').value, repo: $('sync-repo').value }); }
292
+ catch (e) { $('sync-cfg-hint').textContent = e.message || tr('not saved'); return; }
293
+ renderSection('sync');
294
+ };
295
+ } else if (sec === 'errors') {
296
+ body.innerHTML = `<div class="sm-h">${tr('Errors')}<span class="muted err-count" id="err-count"></span><button class="btn-ghost" id="err-clear">${tr('Clear the log')}</button></div>`
297
+ + `<div class="kbh-note muted">${tr('Everything lands here: a server failure, a failed sync op, an error in the browser. Empty means the board runs clean.')}</div>`
298
+ + '<div id="err-list" class="bk-list">…</div>';
299
+ const SRC = { server: tr('server'), sync: tr('sync'), client: tr('browser') };
300
+ const load = async () => {
301
+ try {
302
+ const list = await api('GET', '/api/errors');
303
+ const open = list.filter((r) => !r.resolved_at).length;
304
+ const fixed = list.length - open;
305
+ $('err-count').textContent = list.length
306
+ ? (open ? `${open} ${tr('unresolved')}${fixed ? ` · ${fixed} ${tr('self-healed')}` : ''}` : `${tr('all self-healed')}`)
307
+ : '';
308
+ $('err-list').innerHTML = list.length
309
+ ? list.map((r) => `<div class="bk-row err-row${r.resolved_at ? ' err-fixed' : ''}" title="${esc(r.detail || '')}">`
310
+ + `<span class="err-when muted">${esc(fmtDbTime(r.at))}</span>`
311
+ + `<span class="err-src">${esc(SRC[r.source] || r.source)}${r.scope ? ` · ${esc(r.scope)}` : ''}</span>`
312
+ + `<span class="err-msg">${esc(r.message)}`
313
+ + `${r.repeats ? ` <span class="muted">×${r.repeats + 1}</span>` : ''}`
314
+ + `${r.resolved_at ? `<span class="err-fixed-tag">${tr('recovered on retry')}</span>` : ''}`
315
+ + `</span></div>`).join('')
316
+ : `<div class="muted">${tr('no errors ✓')}</div>`;
317
+ } catch { $('err-list').textContent = '—'; }
318
+ };
319
+ $('err-clear').onclick = async () => {
320
+ if (!await styledConfirm(tr('Clear the error log?'), { okLabel: tr('Clear'), danger: true })) return;
321
+ try { await api('DELETE', '/api/errors'); } catch { }
322
+ load();
323
+ };
324
+ load();
325
+ } else if (sec === 'backups') {
326
+ body.innerHTML = `<div class="sm-h">${tr('Database backups')}`
327
+ + `<button class="btn-ghost" id="bk-up">${tr('Upload')}</button>`
328
+ + `<button class="btn-ghost" id="bk-now">${tr('Back up now')}</button></div>`
329
+ + `<div class="kbh-note muted">${tr('The uploaded file opens as a separate board on a neighbouring port — this board and its tasks stay untouched.')}</div>`
330
+ + `<input type="file" id="bk-file" accept=".db,application/octet-stream" class="hidden">`
331
+ + `<div id="bk-restore" class="bk-restore hidden"></div>`
332
+ + `<div id="bk-list" class="bk-list">…</div>`;
333
+ const load = async () => {
334
+ try {
335
+ const info = await api('GET', '/api/backups');
336
+ const list = info.items;
337
+ const stamp = (iso) => esc(String(iso).slice(0, 16).replace('T', ' '));
338
+ const head = (info.last_error
339
+ ? `<div class="kbh-note warn">${tr('The last automatic snapshot failed')} (${stamp(info.last_error_at)}): ${esc(info.last_error)}</div>`
340
+ : '')
341
+ + (info.last_ok ? `<div class="kbh-note muted">${tr('Last successful snapshot')}: ${stamp(info.last_ok)}</div>` : '')
342
+ + `<div class="kbh-note muted">${info.attachments
343
+ ? tr('Images and attachments are mirrored next to the snapshots, not inside the downloaded file.')
344
+ : tr('The snapshot covers the database only.')}</div>`;
345
+ $('bk-list').innerHTML = head + (list.length
346
+ ? list.map((b) => `<div class="bk-row"><span>${esc(b.name)}</span><span class="muted">${(b.size / 1048576).toFixed(1)} ${tr('MB')} · ${esc(b.mtime.slice(0, 16).replace('T', ' '))}</span><a class="bk-dl" href="/api/backups/${encodeURIComponent(b.name)}" download="${esc(b.name)}" title="${tr('Download backup')}">${ic('download', 13)}</a></div>`).join('')
347
+ : `<div class="muted">${tr('no backups yet')}</div>`);
348
+ $('bk-list').querySelectorAll('.bk-dl').forEach((a) => {
349
+ a.onclick = async (e) => {
350
+ e.preventDefault();
351
+ const alive = await fetch(a.href, { method: 'HEAD' }).then((r) => r.ok).catch(() => false);
352
+ if (!alive) { await styledAlert(tr('This snapshot has already been rotated out — refreshing the list.')); load(); return; }
353
+ const tmp = document.createElement('a');
354
+ tmp.href = a.href; tmp.download = a.getAttribute('download');
355
+ document.body.appendChild(tmp); tmp.click(); tmp.remove();
356
+ };
357
+ });
358
+ } catch { $('bk-list').textContent = '—'; }
359
+ };
360
+ $('bk-now').onclick = async () => { $('bk-now').disabled = true; try { await api('POST', '/api/backups'); } catch { } $('bk-now').disabled = false; load(); };
361
+
362
+ const box = () => $('bk-restore');
363
+ const showRestore = (html) => { const b = box(); if (!b) return; b.classList.remove('hidden'); b.innerHTML = html; };
364
+ const renderRunning = (r) => {
365
+ const mins = Math.round((r.expires_in || 0) / 60000);
366
+ showRestore(`<div class="bkr-head">${ic('download', 13)} ${tr('Check board is up')}</div>`
367
+ + `<div class="bkr-sub muted">${tr('tasks')}: <b>${r.stats.tasks}</b> · ${tr('projects')}: <b>${r.stats.projects}</b>`
368
+ + ` · ${tr('closes in')} ${mins} ${tr('min')}</div>`
369
+ + `<div class="bkr-act"><a class="btn-primary" href="${esc(r.url)}" target="_blank" rel="noopener noreferrer">${tr('Open')} ${esc(r.url)}</a>`
370
+ + `<button class="btn-ghost" id="bkr-stop">${tr('Close the check board')}</button></div>`);
371
+ $('bkr-stop').onclick = async () => { try { await api('DELETE', '/api/backups/restore'); } catch { } box().classList.add('hidden'); };
372
+ };
373
+ api('GET', '/api/backups/restore', null, { quiet: true }).then((r) => { if (r && r.port) renderRunning(r); }).catch(() => {});
374
+
375
+ $('bk-up').onclick = () => $('bk-file').click();
376
+ $('bk-file').onchange = async (e) => {
377
+ const file = e.target.files && e.target.files[0];
378
+ e.target.value = '';
379
+ if (!file) return;
380
+ const goOn = await styledConfirm(
381
+ `${tr('File')}: ${file.name} (${(file.size / 1048576).toFixed(1)} ${tr('MB')}).\n`
382
+ + tr('It will be checked and opened as a SEPARATE board on a neighbouring port. This board and its tasks will not change.'),
383
+ { okLabel: tr('Check the backup') });
384
+ if (!goOn) return;
385
+ $('bk-up').disabled = true;
386
+ showRestore(`<div class="bkr-head">${tr('Checking the file and starting the board…')}</div>`
387
+ + `<div class="bkr-sub muted">${tr('this takes a few seconds')}</div>`);
388
+ try {
389
+ const r = await apiBlob('/api/backups/restore', new Blob([file], { type: 'application/octet-stream' }), { quiet: true });
390
+ renderRunning(r);
391
+ } catch (err) {
392
+ showRestore(`<div class="bkr-head bkr-bad">${ic('warn', 13)} ${tr('Did not work out')}</div>`
393
+ + `<div class="bkr-sub muted">${esc(err.message || String(err))}</div>`);
394
+ }
395
+ $('bk-up').disabled = false;
396
+ };
397
+ load();
398
+ } else if (sec === 'skills') {
399
+ await renderSkillsSection(body);
400
+ } else if (sec === 'keys') {
401
+ body.innerHTML = `<div class="sm-h">${tr('Hotkeys')}<button class="btn-ghost" id="keys-reset">${tr('Reset to defaults')}</button></div>`
402
+ + '<div class="kb-help">'
403
+ + KEY_ACTIONS.map((a) => `<div class="kbh-row kbh-remap" data-act="${a.id}" title="${esc(tr('click to assign your own shortcut'))}"><kbd>${esc(comboLabel(keyBinding(a.id)))}</kbd><span>${esc(tr(a.label))}</span></div>`).join('')
404
+ + KEY_FIXED.map(([k, d]) => `<div class="kbh-row"><kbd>${esc(tr(k))}</kbd><span>${esc(tr(d))}</span></div>`).join('')
405
+ + '</div>'
406
+ + `<div class="kbh-note muted">${tr('click the rows above to assign your own shortcut (Esc cancels); the ones below are fixed')}</div>`;
407
+ $('keys-reset').onclick = () => { setSetting('keymap', {}); renderSection('keys'); };
408
+ body.querySelectorAll('.kbh-remap').forEach((row) => { row.onclick = () => listenRemap(row); });
409
+ } else {
410
+ body.innerHTML = `<div class="sm-h">${tr('About')}</div>`
411
+ + `<div class="set-row"><span class="set-lab">${tr('Code version')}<small id="about-ver">…</small></span><span class="about-fresh" id="about-fresh"></span></div>`
412
+ + `<div class="set-row"><span class="set-lab"><span id="about-branch-lab">${tr('Board updates')}</span><small id="about-upd-sub">…</small></span><span class="about-fresh" id="about-upd"></span></div>`
413
+ + `<div class="set-row hidden" id="about-dev-row"><span class="set-lab">${tr('dev branch')}<small id="about-dev-sub">…</small></span><span class="about-fresh" id="about-dev"></span></div>`
414
+ + `<div class="set-row"><span class="set-lab">${tr('Check for updates')}<small>${tr('re-read branch state from GitHub')}</small></span><button class="btn-ghost" id="about-recheck">${tr('Check')}</button></div>`
415
+ + `<div class="set-row"><span class="set-lab">${tr('Reload the page')}<small>${tr('re-read the board code right now')}</small></span><button class="btn-ghost" id="about-reload">${tr('Reload now')}</button></div>`
416
+ + `<div class="set-row"><span class="set-lab">${tr('Using the board at work')}<small>${tr('free for everyone, nothing switched off — if it earns you money, $12 a year per person keeps it maintained')}</small></span>`
417
+ + `<a class="btn-ghost" href="https://github.com/lamaohub/local-kanban/blob/main/docs/COMMERCIAL.md" target="_blank" rel="noopener noreferrer">${tr('Read')}</a></div>`
418
+ + `<div class="set-col"><span class="set-lab">${tr('Diagnostics')}</span><div class="bk-list" id="about-diag">…</div></div>`
419
+ + `<div class="set-col"><span class="set-lab">${tr('Setup via Claude')}<small>${tr('copy and paste into Claude chat — it will run the setup and ask you questions')}</small></span>`
420
+ + setupPromptBlockHTML() + '</div>';
421
+ wireSetupPrompt(body);
422
+ $('about-reload').onclick = () => location.reload();
423
+ checkVersion().then(() => {
424
+ const el = $('about-fresh');
425
+ if (el) { el.textContent = pendingReload ? tr('⟳ a new version is available') : tr('the board is fresh ✓'); el.classList.toggle('stale-ver', pendingReload); }
426
+ });
427
+ let about = null;
428
+ const updateHint = () => (about?.packaged
429
+ ? tr('update with: npm install -g local-kanban@latest')
430
+ : tr('update with: npm run update'));
431
+ const renderUpd = (u) => {
432
+ const el = $('about-upd'); const sub = $('about-upd-sub'); const lab = $('about-branch-lab');
433
+ if (!el || !sub) return;
434
+ if (lab && u.branch) lab.textContent = `${tr('Branch')} ${u.branch}`;
435
+ const tag = u.tag ? ` · ${tr('tag')} ${u.tag}` : '';
436
+ el.className = 'about-fresh';
437
+ if (u.update_available === null) { el.textContent = ''; sub.textContent = tr('cannot check (no network, or the repo is private/not a git checkout)'); }
438
+ else if (u.update_available) { el.textContent = `⟳ ${tr('update available')} ${u.local} → ${u.remote}`; el.classList.add('stale-ver'); sub.textContent = `${tr('commit')} ${u.local}${tag} · ${updateHint()}`; }
439
+ else if (u.dev?.ahead) { el.textContent = `${tr('in dev')} ${u.dev.ahead} ${commitsWord(u.dev.ahead)} ${tr('awaiting release')}`; el.classList.add('stale-ver'); sub.textContent = `${tr('commit')} ${u.local}${tag} · ${tr('matches GitHub')}`; }
440
+ else { el.textContent = tr('up to date ✓'); sub.textContent = `${tr('commit')} ${u.local}${tag} · ${tr('matches GitHub')}`; }
441
+ const row = $('about-dev-row'); const dEl = $('about-dev'); const dSub = $('about-dev-sub');
442
+ if (!row || !u.dev) return;
443
+ row.classList.remove('hidden');
444
+ dEl.className = 'about-fresh';
445
+ if (u.dev.ahead) { dEl.textContent = `${u.dev.ahead} ${commitsWord(u.dev.ahead)} ${tr('awaiting release')}`; dEl.classList.add('stale-ver'); }
446
+ else dEl.textContent = tr('matches main ✓');
447
+ dSub.textContent = u.dev.sha
448
+ ? `${u.dev.sha}${u.dev.date ? ` ${tr('at')} ${fmtDbTime(u.dev.date.slice(0, 19).replace('T', ' '))}` : ''}${u.dev.message ? ` · ${u.dev.message}` : ''}`
449
+ : tr('dev has no commits beyond main');
450
+ };
451
+ api('GET', '/api/about').catch(() => null).then((a) => { about = a; })
452
+ .then(() => api('GET', '/api/update-check')).then(renderUpd)
453
+ .catch(() => { const s = $('about-upd-sub'); if (s) s.textContent = '—'; });
454
+ $('about-recheck').onclick = async () => {
455
+ const btn = $('about-recheck');
456
+ btn.disabled = true; $('about-upd-sub').textContent = tr('checking…');
457
+ try { renderUpd(await api('GET', '/api/update-check?refresh=1')); }
458
+ catch { $('about-upd-sub').textContent = '—'; }
459
+ btn.disabled = false;
460
+ };
461
+ try {
462
+ const a = await api('GET', '/api/about');
463
+ about = a;
464
+ $('about-ver').textContent = a.commit ? `${tr('commit')} ${a.commit}` : (a.app_mtime ? `app.js ${tr('from')} ${fmtDbTime(a.app_mtime.slice(0, 19).replace('T', ' '))}` : '—');
465
+ const up = a.uptime;
466
+ const upStr = up >= 86400 ? `${Math.floor(up / 86400)}${tr('d')} ${Math.floor((up % 86400) / 3600)}${tr('h')}` : up >= 3600 ? `${Math.floor(up / 3600)}${tr('h')} ${Math.floor((up % 3600) / 60)}${tr('m')}` : `${Math.floor(up / 60)}${tr('m')}`;
467
+ $('about-diag').innerHTML = [
468
+ [tr('Database'), `${(a.db_size / 1048576).toFixed(1)} ${tr('MB')}`],
469
+ [tr('Database path'), a.db_path],
470
+ [tr('Server uptime'), upStr],
471
+ [tr('Tasks · projects'), `${a.tasks} · ${a.projects}`],
472
+ ['Node', a.node],
473
+ ].map(([k, v]) => `<div class="bk-row"><span>${esc(k)}</span><span class="muted">${esc(String(v))}</span></div>`).join('');
474
+ } catch { $('about-diag').textContent = '—'; }
475
+ }
476
+ }
477
+
478
+ let skillSel = null;
479
+ let skillsInfo = null;
480
+ let pendingSkill = null;
481
+ function skillDirty() {
482
+ const ta = $('sk-text');
483
+ return Boolean(skillSel && ta && ta.value !== skillSel.saved);
484
+ }
485
+ function updateSkillState(note) {
486
+ const dirty = skillDirty();
487
+ const save = $('sk-save');
488
+ if (save) save.disabled = !dirty;
489
+ const el = $('sk-note');
490
+ if (!el) return;
491
+ if (note !== undefined) { el.textContent = note; return; }
492
+ el.textContent = dirty ? tr('changed — not written to the file yet') : '';
493
+ }
494
+ async function selectSkillRow(name) {
495
+ const list = $('sk-list');
496
+ const row = list?.querySelector(`.sk-row[data-name="${CSS.escape(name)}"]`);
497
+ if (!row) return;
498
+ list.querySelectorAll('.sk-row').forEach((x) => x.classList.toggle('active', x === row));
499
+ $('sk-edit').classList.remove('hidden');
500
+ await loadSkillText(name);
501
+ }
502
+
503
+ let skillLoadSeq = 0;
504
+ function setSkillButtons(enabled) {
505
+ for (const id of ['sk-fetch', 'sk-save', 'sk-del']) { const b = $(id); if (b) b.disabled = !enabled; }
506
+ }
507
+ async function loadSkillText(name) {
508
+ const ta = $('sk-text');
509
+ if (!ta) return;
510
+ const seq = ++skillLoadSeq;
511
+ skillSel = null;
512
+ setSkillButtons(false);
513
+ ta.value = '';
514
+ let r;
515
+ try { r = await api('GET', `/api/skills/${seg(name)}`); }
516
+ catch (e) { if (seq === skillLoadSeq) updateSkillState(e.message || tr('could not read the file')); return; }
517
+ if (seq !== skillLoadSeq) return;
518
+ if (!$('sk-text')) return;
519
+ setSkillButtons(true);
520
+ skillSel = { name, real_path: r.real_path, symlink: r.symlink, exists: r.exists, saved: r.text || '' };
521
+ ta.value = skillSel.saved;
522
+ $('sk-path').textContent = r.real_path;
523
+ const shared = (skillsInfo?.packaged_skills || []).includes(name);
524
+ $('sk-fetch').disabled = !shared;
525
+ $('sk-del').disabled = false;
526
+ $('sk-fetch').title = shared ? '' : tr('the board has no shared version of this skill — it is your own');
527
+ $('sk-del').classList.toggle('hidden', !r.exists);
528
+ $('sk-link-warn').classList.toggle('hidden', !r.symlink);
529
+ $('sk-link-warn').textContent = r.symlink
530
+ ? `${tr('this is a symlink — saving rewrites the file it points to:')} ${r.real_path}`
531
+ : '';
532
+ updateSkillState(r.exists ? '' : tr('the skill is not installed — the file will be created on save'));
533
+ }
534
+ async function renderSkillsSection(body) {
535
+ body.innerHTML = `<div class="sm-h">${tr('Skills')}<button class="btn-ghost" id="sk-new">${tr('+ New skill')}</button></div>`
536
+ + `<div class="kbh-note muted">${tr('Skills are the instructions Claude reads. They live outside the board, in ~/.claude/skills, and the board only shows and updates them.')}</div>`
537
+ + '<div id="sk-list" class="bk-list">…</div>'
538
+ + `<div class="set-col sk-edit hidden" id="sk-edit">
539
+ <span class="set-lab">${tr('Skill file')}<small class="sk-path" id="sk-path"></small></span>
540
+ <div class="kbh-note warn hidden" id="sk-link-warn"></div>
541
+ <textarea id="sk-text" class="sk-text" spellcheck="false"></textarea>
542
+ <div class="sk-actions">
543
+ <button class="btn-ghost" id="sk-fetch">${tr('Load the shared version')}</button>
544
+ <button class="btn-ghost" id="sk-revert">${tr('Revert')}</button>
545
+ <button class="btn-primary" id="sk-save" disabled>${tr('Save to the file')}</button>
546
+ <button class="btn-ghost sk-del hidden" id="sk-del">${tr('Delete')}</button>
547
+ <span class="muted sk-note" id="sk-note"></span>
548
+ </div>
549
+ </div>`;
550
+ let info;
551
+ try { info = await api('GET', '/api/skills'); }
552
+ catch { $('sk-list').textContent = '—'; return; }
553
+ if (!$('sk-list')) return;
554
+ skillsInfo = info;
555
+ const items = [...info.items];
556
+ if (!items.some((s) => s.name === info.board_skill)) items.unshift({ ...info.board, used_by: [], packaged: true });
557
+ items.sort((a, b) => (a.name === info.board_skill ? -1 : b.name === info.board_skill ? 1 : a.name.localeCompare(b.name)));
558
+ const usedByLabel = (list) => (list.length > 2
559
+ ? `${tr('used by')} ${list.length} ${plural(list.length, 'project', 'projects')}`
560
+ : `${tr('used by')} ${list.map((p) => p.name || p.slug).join(', ')}`);
561
+ const tagsFor = (s) => {
562
+ const tags = [];
563
+ if (s.name === info.board_skill) tags.push(tr('board skill'));
564
+ if (s.name === info.generic_deploy_skill) tags.push(tr('shared deploy skill'));
565
+ if (s.used_by?.length) tags.push(usedByLabel(s.used_by));
566
+ if (!s.exists) tags.push(tr('not installed'));
567
+ return tags;
568
+ };
569
+ const row = (s) => `<div class="bk-row sk-row" data-name="${esc(s.name)}" title="${esc((s.used_by || []).map((p) => p.name || p.slug).join(', '))}">
570
+ <span class="sk-name"><span class="sk-title">${esc(s.name)}</span>${tagsFor(s).map((t) => `<span class="ps-doc-tag">${esc(t)}</span>`).join('')}</span>
571
+ <span class="muted sk-meta" title="${esc(s.real_path)}">${s.exists ? `${Math.max(1, Math.round((s.size || 0) / 1024))} ${tr('KB')} · ${esc(String(s.mtime || '').slice(0, 10))}` : '—'}</span>
572
+ </div>`;
573
+ const own = new Set(info.own_skills || []);
574
+ const isDeploy = (s) => s.name === info.generic_deploy_skill || s.used_by?.length || own.has(s.name);
575
+ const groups = [
576
+ [tr('The board'), items.filter((s) => s.name === info.board_skill)],
577
+ [tr('Deploy skills'), items.filter((s) => s.name !== info.board_skill && isDeploy(s))],
578
+ ];
579
+ $('sk-list').innerHTML = groups.filter(([, list]) => list.length)
580
+ .map(([title, list]) => `<div class="sk-group-h">${esc(title)}</div>${list.map(row).join('')}`).join('');
581
+ $('sk-list').querySelectorAll('.sk-row').forEach((row) => {
582
+ row.onclick = () => selectSkillRow(row.dataset.name);
583
+ });
584
+ $('sk-new').onclick = () => styledPrompt(tr('New skill'), {
585
+ placeholder: tr('name: latin letters, digits, hyphen'),
586
+ okLabel: tr('Create'),
587
+ onSubmit: async (raw) => {
588
+ const name = raw.trim();
589
+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(name)) throw new Error(tr('name: latin letters, digits, hyphen, underscore only'));
590
+ const cur = await api('GET', `/api/skills/${seg(name)}`);
591
+ if (cur.exists) {
592
+ await api('POST', `/api/skills/${seg(name)}/adopt`);
593
+ pendingSkill = name;
594
+ await renderSection('skills');
595
+ updateSkillState(tr('this skill was already on disk — added it to the list'));
596
+ return;
597
+ }
598
+ let text = `---\nname: ${name}\ndescription: \n---\n\n`;
599
+ if ((info.packaged_skills || []).includes(name)) {
600
+ try { text = (await api('GET', `/api/skills/${seg(name)}/upstream?lang=${LANG}`, null, { quiet: true })).text; }
601
+ catch { }
602
+ }
603
+ await api('PUT', `/api/skills/${seg(name)}`, { text, confirm_path: cur.real_path });
604
+ pendingSkill = name;
605
+ await renderSection('skills');
606
+ updateSkillState(tr('created ✓'));
607
+ },
608
+ });
609
+
610
+ $('sk-del').onclick = async () => {
611
+ if (!skillSel) return;
612
+ const item = (skillsInfo?.items || []).find((s) => s.name === skillSel.name);
613
+ const used = (item?.used_by || []).map((p) => p.name || p.slug);
614
+ const msg = `${tr('Delete the skill')} «${skillSel.name}»?\n${skillSel.real_path}`
615
+ + (skillSel.symlink ? `\n${tr('this is a link — only the link is removed, the file it points to stays')}` : '')
616
+ + (used.length ? `\n${tr('used by')}: ${used.join(', ')}` : '');
617
+ if (!await styledConfirm(msg, { okLabel: tr('Delete'), danger: true })) return;
618
+ try { await api('DELETE', `/api/skills/${seg(skillSel.name)}`, { confirm_path: skillSel.real_path }); }
619
+ catch (e) { updateSkillState(e.message || tr('not deleted')); return; }
620
+ skillSel = null;
621
+ renderSection('skills');
622
+ };
623
+
624
+ $('sk-text').oninput = () => updateSkillState();
625
+ $('sk-revert').onclick = () => { if (skillSel) loadSkillText(skillSel.name); };
626
+ $('sk-fetch').onclick = async () => {
627
+ if (!skillSel) return;
628
+ const btn = $('sk-fetch');
629
+ btn.disabled = true;
630
+ updateSkillState(tr('fetching…'));
631
+ try {
632
+ const r = await api('GET', `/api/skills/${seg(skillSel.name)}/upstream?lang=${LANG}`);
633
+ $('sk-text').value = r.text;
634
+ updateSkillState(`${r.source === 'github' ? tr('loaded from GitHub') : tr('loaded from the installed package')}`
635
+ + ` · ${r.lang.toUpperCase()} · ${tr('nothing is written to disk until you save')}`);
636
+ } catch (e) { updateSkillState(e.message || tr('could not load')); }
637
+ btn.disabled = false;
638
+ };
639
+ $('sk-save').onclick = async () => {
640
+ if (!skillSel || !skillDirty()) return;
641
+ const ok = await styledConfirm(`${tr('Overwrite this file?')}\n${skillSel.real_path}`, { okLabel: tr('Overwrite'), danger: true });
642
+ if (!ok) return;
643
+ $('sk-save').disabled = true;
644
+ try {
645
+ const r = await api('PUT', `/api/skills/${seg(skillSel.name)}`, { text: $('sk-text').value, confirm_path: skillSel.real_path });
646
+ skillSel.saved = $('sk-text').value;
647
+ updateSkillState(r.backup_name ? `${tr('saved ✓ · the previous version is kept as')} ${r.backup_name}` : tr('saved ✓'));
648
+ } catch (e) {
649
+ updateSkillState(e.message || tr('not saved'));
650
+ if (e.status === 409) loadSkillText(skillSel.name);
651
+ }
652
+ };
653
+
654
+ const want = pendingSkill && items.some((s) => s.name === pendingSkill) ? pendingSkill : items[0]?.name;
655
+ pendingSkill = null;
656
+ if (want) await selectSkillRow(want);
657
+ }
658
+
659
+ function listenRemap(row) {
660
+ const kbd = row.querySelector('kbd');
661
+ const prev = kbd.textContent;
662
+ row.classList.add('listening');
663
+ kbd.textContent = tr('press a key…');
664
+ const done = (label) => {
665
+ document.removeEventListener('keydown', onKey, true);
666
+ row.classList.remove('listening');
667
+ kbd.textContent = label;
668
+ };
669
+ const flash = (msg) => { kbd.textContent = msg; setTimeout(() => { if (row.classList.contains('listening')) kbd.textContent = tr('press a key…'); }, 900); };
670
+ function onKey(e) {
671
+ e.preventDefault();
672
+ e.stopPropagation();
673
+ if (['Shift', 'Meta', 'Alt', 'Control'].includes(e.key)) return;
674
+ if (e.code === 'Escape') { done(prev); return; }
675
+ if (e.code.startsWith('Arrow')) { flash(tr('the arrow keys are taken')); return; }
676
+ const b = { code: e.code, shift: e.shiftKey, alt: e.altKey, ctrl: e.ctrlKey, meta: e.metaKey };
677
+ const clash = KEY_ACTIONS.find((a) => a.id !== row.dataset.act && comboLabel(keyBinding(a.id)) === comboLabel(b));
678
+ if (clash) { flash(`${tr('taken')}: ${comboLabel(keyBinding(clash.id))}`); return; }
679
+ const km = { ...(getSetting('keymap') || {}) };
680
+ km[row.dataset.act] = b;
681
+ setSetting('keymap', km);
682
+ done(comboLabel(b));
683
+ }
684
+ document.addEventListener('keydown', onKey, true);
685
+ }
686
+
687
+ export const DOW = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
688
+ export const RANGE_LABEL = { week: tr('this week'), month: tr('this month'), half: tr('in 6 months'), year: tr('this year') };
689
+ export const RANGE_BTN = [['week', 'Week'], ['month', 'Month'], ['half', 'Half a year'], ['year', 'Year']];
690
+ export function ymdLocal(d) {
691
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
692
+ }
693
+ function fmtDbTime(s) {
694
+ const d = new Date(s.replace(' ', 'T') + 'Z');
695
+ const p = (n) => String(n).padStart(2, '0');
696
+ return `${p(d.getDate())}.${p(d.getMonth() + 1)} ${p(d.getHours())}:${p(d.getMinutes())}`;
697
+ }
698
+
699
+ // commit counter: one call, both languages
700
+ function commitsWord(n) {
701
+ return plural(n, 'commit', 'commits');
702
+ }
703
+
704
+ // Plurals. English is the source language and needs two forms, so that is the call signature:
705
+ // plural(n, 'task', 'tasks'). Russian needs three and picks them by a rule of its own, so the
706
+ // forms live here keyed by the English singular. A word with no entry falls back to the English
707
+ // pair — a missing translation shows an English word, it does not break the sentence.
708
+ const PLURAL_RU = {
709
+ task: ['задача', 'задачи', 'задач'],
710
+ 'task to delete': ['задачу', 'задачи', 'задач'],
711
+ commit: ['коммит', 'коммита', 'коммитов'],
712
+ project: ['проект', 'проекта', 'проектов'],
713
+ };
714
+ export function plural(n, one, many) {
715
+ const forms = LANG === 'ru' && PLURAL_RU[one];
716
+ if (!forms) return n === 1 ? one : many;
717
+ const m10 = n % 10; const m100 = n % 100;
718
+ if (m10 === 1 && m100 !== 11) return forms[0];
719
+ if (m10 >= 2 && m10 <= 4 && (m100 < 10 || m100 >= 20)) return forms[1];
720
+ return forms[2];
721
+ }
722
+ const tipTasks = (n) => `${n} ${plural(n, 'task', 'tasks')}`;
723
+ function heatBg(n, max) {
724
+ if (!n) return 'background:var(--border-soft)';
725
+ const lvl = Math.min(4, Math.ceil((n / max) * 4));
726
+ return `background:rgba(232, 112, 78,${[0, 0.28, 0.5, 0.74, 1][lvl]})`;
727
+ }
728
+
729
+ export function calendarHTML(days, since) {
730
+ const map = Object.fromEntries(days.map((x) => [x.d, x.n]));
731
+ const max = Math.max(1, ...days.map((x) => x.n));
732
+ const today = new Date(); today.setHours(0, 0, 0, 0);
733
+ const start = since ? new Date(`${since}T00:00:00`) : new Date(today);
734
+ start.setDate(start.getDate() - ((start.getDay() + 6) % 7));
735
+ const cells = [];
736
+ for (let d = new Date(start); d <= today; d.setDate(d.getDate() + 1)) {
737
+ const key = ymdLocal(d);
738
+ if (since && key < since) { cells.push('<div class="hcell hcell-off"></div>'); continue; }
739
+ const n = map[key] || 0;
740
+ cells.push(`<div class="hcell" data-tip="${tipTasks(n)}" data-tip-sub="${dayLabel(key)}" style="${heatBg(n, max)}"></div>`);
741
+ }
742
+ return `<div class="hscroll"><div class="cal">${cells.join('')}</div></div>`;
743
+ }
744
+
745
+ export function timeHeatHTML(rows) {
746
+ const grid = Array.from({ length: 7 }, () => Array(24).fill(0));
747
+ let max = 1;
748
+ for (const r of rows) { const row = (r.w + 6) % 7; grid[row][r.h] = r.n; if (r.n > max) max = r.n; }
749
+ let html = '<div class="thm">';
750
+ for (let row = 0; row < 7; row++) {
751
+ html += `<div class="thm-day">${tr(DOW[row])}</div>`;
752
+ for (let h = 0; h < 24; h++) {
753
+ html += `<div class="hcell" data-tip="${tipTasks(grid[row][h])}" data-tip-sub="${tr(DOW[row])} ${h}:00" style="${heatBg(grid[row][h], max)}"></div>`;
754
+ }
755
+ }
756
+ return `${html}</div><div class="thm-axis"><span>0</span><span>6</span><span>12</span><span>18</span><span>23</span></div>`;
757
+ }
758
+
759
+ export function doneBarsHTML(days, since) {
760
+ const map = Object.fromEntries(days.map((x) => [x.d, x.n]));
761
+ const max = Math.max(1, ...days.map((x) => x.n));
762
+ const today = new Date(); today.setHours(0, 0, 0, 0);
763
+ const start = since ? new Date(`${since}T00:00:00`) : new Date(today);
764
+ const bars = [];
765
+ for (let d = new Date(start); d <= today; d.setDate(d.getDate() + 1)) {
766
+ const key = ymdLocal(d); const n = map[key] || 0;
767
+ bars.push(`<div class="bar-col" data-tip="${n} ${plural(n, 'task', 'tasks')}" data-tip-sub="${dayLabel(key)}"><div class="bar" style="height:${Math.round((n / max) * 100)}%"></div><div class="bar-x">${d.getDate()}</div></div>`);
768
+ }
769
+ return `<div class="hscroll"><div class="bars">${bars.join('')}</div></div>`;
770
+ }
771
+
772
+ export function topProjHTML(list) {
773
+ if (!list.length) return `<div class="muted">${tr('nothing finished yet')}</div>`;
774
+ const max = Math.max(...list.map((x) => x.n));
775
+ return list.map((p) => `<div class="tp-row"><span class="tp-name" title="${esc(p.name)}">${esc(p.name)}</span>`
776
+ + `<span class="tp-bar-wrap"><span class="tp-bar" style="width:${Math.round((p.n / max) * 100)}%"></span></span>`
777
+ + `<span class="tp-n">${p.n}</span></div>`).join('');
778
+ }
779
+
780
+ function wRow(t, dot, meta, wrapMeta) {
781
+ const metaHTML = meta
782
+ ? `<span class="${wrapMeta ? 'wr-reason' : 'wr-meta'}">${esc(meta)}</span>`
783
+ : '';
784
+ return `<div class="wait-row${wrapMeta && meta ? ' wait-row-wrap' : ''}" data-key="${t.key}">`
785
+ + `<span class="dot s-${dot} wr-dot" data-pick="${t.key}" data-cur="${dot}" title="${esc(tr('change status'))}"></span>`
786
+ + `<span class="wr-key">${t.key}</span><span class="wr-title">${titleOr(t)}</span>`
787
+ + `<span class="wr-proj">${esc(t.project)}</span>${metaHTML}</div>`;
788
+ }
789
+
790
+ function closeStatusPicker() {
791
+ popLayer('status-picker');
792
+ $('status-picker')?.remove();
793
+ document.removeEventListener('click', spOutside);
794
+ }
795
+ function spOutside(e) {
796
+ const m = $('status-picker');
797
+ if (m && !m.contains(e.target) && !e.target.classList.contains('wr-dot')) closeStatusPicker();
798
+ }
799
+ export function openStatusPicker(anchor, key, current) {
800
+ closeStatusPicker();
801
+ pushLayer('status-picker', closeStatusPicker);
802
+ const menu = document.createElement('div');
803
+ menu.className = 'status-picker';
804
+ menu.id = 'status-picker';
805
+ menu.innerHTML = ALL_STATUSES.map(([v, lab]) => {
806
+ const disabled = WORKING.has(v) && v !== current;
807
+ return `<div class="sp-opt${v === current ? ' active' : ''}${disabled ? ' disabled' : ''}" data-v="${v}">`
808
+ + `<span class="dot s-${v}"></span>${lab}</div>`;
809
+ }).join('');
810
+ document.body.appendChild(menu);
811
+ const r = anchor.getBoundingClientRect();
812
+ let left = r.left; let top = r.bottom + 5;
813
+ if (top + menu.offsetHeight > window.innerHeight - 8) top = r.top - menu.offsetHeight - 5;
814
+ if (left + menu.offsetWidth > window.innerWidth - 8) left = window.innerWidth - menu.offsetWidth - 8;
815
+ menu.style.left = `${Math.max(8, left)}px`;
816
+ menu.style.top = `${Math.max(8, top)}px`;
817
+ menu.querySelectorAll('.sp-opt:not(.disabled)').forEach((o) => {
818
+ o.onclick = async (e) => {
819
+ e.stopPropagation();
820
+ closeStatusPicker();
821
+ if (o.dataset.v === current) return;
822
+ try { await api('PATCH', `/api/tasks/${seg(key)}`, { status: o.dataset.v }, { quiet: true }); }
823
+ catch { }
824
+ await refresh();
825
+ };
826
+ });
827
+ setTimeout(() => document.addEventListener('click', spOutside), 0);
828
+ }
829
+ export function waitingHTML(w) {
830
+ const secs = [];
831
+ if (w.review.length) secs.push(`<div class="wait-sec-h">${tr('Check')} (${w.review.length})</div>${w.review.map((t) => wRow(t, 'review', '')).join('')}`);
832
+ if (w.blocked.length) secs.push(`<div class="wait-sec-h">${tr('Blocked')} (${w.blocked.length})</div>${w.blocked.map((t) => wRow(t, 'cancelled', t.reason || '', true)).join('')}`);
833
+ if (w.stale.length) secs.push(`<div class="wait-sec-h">${tr('Stalled')} (${w.stale.length})</div>${w.stale.map((t) => wRow(t, 'doing', `${t.hours}${tr('h without movement')}`)).join('')}`);
834
+ if (!secs.length) return `<div class="wait-empty">✓ ${tr('All clear — nothing needs your decision')}</div>`;
835
+ return secs.join('');
836
+ }
837
+
838
+ function dayLabel(key) {
839
+ const today = new Date(); today.setHours(0, 0, 0, 0);
840
+ const yest = new Date(today); yest.setDate(yest.getDate() - 1);
841
+ if (key === ymdLocal(today)) return tr('Today');
842
+ if (key === ymdLocal(yest)) return tr('Yesterday');
843
+ const [, m, d] = key.split('-');
844
+ return `${Number(d)} ${SHORT_MONTHS[Number(m) - 1]}`;
845
+ }
846
+ export function feedHTML(items) {
847
+ if (!items.length) return `<div class="muted">${tr('no activity yet')}</div>`;
848
+ const byDay = new Map();
849
+ for (const e of items) {
850
+ const key = ymdLocal(new Date(`${e.created_at}Z`));
851
+ if (!byDay.has(key)) byDay.set(key, []);
852
+ byDay.get(key).push(e);
853
+ }
854
+ let html = '';
855
+ for (const [day, evs] of byDay) {
856
+ html += `<div class="feed-day">${dayLabel(day)} <span class="feed-day-n">· ${evs.length} ${tr('events')}</span></div>`;
857
+ for (const e of evs) {
858
+ html += `<div class="feed-row" data-key="${e.key}"><span class="dot s-${e.status}"></span>`
859
+ + `<span class="fr-key fr-proj" title="${esc(e.project || '')}">${esc(e.project || e.key)}</span><span class="fr-title">${titleOr(e)}</span>`
860
+ + `<span class="fr-arrow">→ ${STATUS_LABELS[e.status] || e.status}</span>`
861
+ + `<span class="fr-time">${relTime(e.created_at)}</span></div>`;
862
+ }
863
+ }
864
+ return html;
865
+ }
866
+
867
+ export function streaks(days) {
868
+ const set = new Set(days.filter((x) => x.n > 0).map((x) => x.d));
869
+ if (!set.size) return [0, 0];
870
+ const sorted = [...set].sort();
871
+ let max = 1; let run = 1;
872
+ for (let i = 1; i < sorted.length; i++) {
873
+ const diff = Math.round((new Date(`${sorted[i]}T00:00:00`) - new Date(`${sorted[i - 1]}T00:00:00`)) / 86400000);
874
+ run = diff === 1 ? run + 1 : 1;
875
+ if (run > max) max = run;
876
+ }
877
+ const today = new Date(); today.setHours(0, 0, 0, 0);
878
+ const d = new Date(today);
879
+ if (!set.has(ymdLocal(d))) d.setDate(d.getDate() - 1);
880
+ let cur = 0;
881
+ while (set.has(ymdLocal(d))) { cur++; d.setDate(d.getDate() - 1); }
882
+ return [cur, max];
883
+ }
884
+
885
+ export function statCard(label, value, sub) {
886
+ return `<div class="dcard"><div class="dc-label">${label}</div><div class="dc-value">${value}</div>`
887
+ + `${sub ? `<div class="dc-sub">${sub}</div>` : ''}</div>`;
888
+ }
889
+
890
+ export function isoWeek(d) {
891
+ const dt = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
892
+ const day = (dt.getUTCDay() + 6) % 7;
893
+ dt.setUTCDate(dt.getUTCDate() - day + 3);
894
+ const firstThu = new Date(Date.UTC(dt.getUTCFullYear(), 0, 4));
895
+ return 1 + Math.round(((dt - firstThu) / 86400000 - 3 + ((firstThu.getUTCDay() + 6) % 7)) / 7);
896
+ }
897
+ export function dashPanel(title, body, extra) {
898
+ return `<div class="panel"><div class="panel-h">${title}${extra || ''}</div>${body}</div>`;
899
+ }
900
+ export const HEAT_LEGEND = `<span class="heat-legend">${tr('less')}`
901
+ + '<span class="hcell hcell-off"></span>'
902
+ + '<span class="hcell" style="background:rgba(232, 112, 78,.28)"></span>'
903
+ + '<span class="hcell" style="background:rgba(232, 112, 78,.5)"></span>'
904
+ + '<span class="hcell" style="background:rgba(232, 112, 78,.74)"></span>'
905
+ + `<span class="hcell" style="background:rgba(232, 112, 78,1)"></span>${tr('more')}</span>`;