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,140 @@
1
+
2
+ import { $, LANG, api, esc, seg, tr } from './core.js';
3
+ import { DOW, isoWeek, ymdLocal } from './settings.js';
4
+ import { styledPrompt } from './sidebar.js';
5
+ import { SHORT_MONTHS } from './sse.js';
6
+
7
+ const HZ_TRASH = '<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true"><path d="M2.5 4h11M6.5 4V2.8a.8.8 0 0 1 .8-.8h1.4a.8.8 0 0 1 .8.8V4M5 4l.5 9a1 1 0 0 0 1 .95h3a1 1 0 0 0 1-.95L12 4" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>';
8
+ const HORIZON_SCALES = [['days', tr('Days')], ['weeks', tr('Weeks')], ['months', tr('Months')], ['years', tr('Years')]];
9
+ const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
10
+ const MONTHS = LANG === 'ru'
11
+ ? ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь']
12
+ : ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
13
+ let horizonScale = 'months';
14
+ let calMonth = null; // {y, m}
15
+ const pad2 = (n) => String(n).padStart(2, '0');
16
+ const weekKey = (d) => `${d.getFullYear()}-W${pad2(isoWeek(d))}`;
17
+ const hzSaveTimers = {};
18
+ const hzAdd = (scale, period, text) => api('POST', '/api/horizons', { scale, period, text });
19
+ const hzPatch = (id, body) => api('PATCH', `/api/horizons/${seg(id)}`, body);
20
+ const deleteHorizonGoal = (id) => api('DELETE', `/api/horizons/${seg(id)}`);
21
+ function hzScheduleSave(id, text) { clearTimeout(hzSaveTimers[id]); hzSaveTimers[id] = setTimeout(() => hzPatch(id, { text }).catch(() => {}), 450); }
22
+
23
+ function goalRowHTML(g) {
24
+ return `<div class="hz-goal${g.done ? ' done' : ''}" data-id="${g.id}">`
25
+ + `<button class="hz-check" data-act="toggle">${g.done ? '✓' : ''}</button>`
26
+ + `<input class="hz-text" data-act="text" value="${esc(g.text)}" placeholder="${tr('goal')}"${g.done ? ' readonly' : ''}>`
27
+ + `<button class="hz-del" data-act="del" title="${tr('delete')}">${HZ_TRASH}</button></div>`;
28
+ }
29
+ function wireGoals(container, afterChange) {
30
+ container.querySelectorAll('.hz-goal').forEach((row) => {
31
+ const id = row.dataset.id;
32
+ row.querySelector('[data-act="toggle"]').onclick = async () => { await hzPatch(id, { done: !row.classList.contains('done') }).catch(() => {}); afterChange(); };
33
+ const inp = row.querySelector('[data-act="text"]');
34
+ if (inp && !inp.readOnly) inp.oninput = () => hzScheduleSave(id, inp.value);
35
+ row.querySelector('[data-act="del"]').onclick = async () => { await deleteHorizonGoal(id).catch(() => {}); afterChange(); };
36
+ });
37
+ container.querySelectorAll('.hz-add-input').forEach((inp) => {
38
+ inp.onkeydown = async (e) => {
39
+ if (e.key === 'Enter' && inp.value.trim()) { await hzAdd(inp.dataset.scale, inp.dataset.period, inp.value.trim()).catch(() => {}); afterChange(inp.dataset.period); }
40
+ };
41
+ });
42
+ }
43
+
44
+ function horizonColumns(scale) {
45
+ const now = new Date(); now.setHours(0, 0, 0, 0);
46
+ const cols = [];
47
+ if (scale === 'years') {
48
+ const y = now.getFullYear();
49
+ for (let i = -1; i <= 3; i++) cols.push({ period: String(y + i), label: String(y + i), cur: i === 0 });
50
+ } else if (scale === 'months') {
51
+ for (let i = -1; i <= 4; i++) { const d = new Date(now.getFullYear(), now.getMonth() + i, 1); cols.push({ period: `${d.getFullYear()}-${pad2(d.getMonth() + 1)}`, label: MONTHS[d.getMonth()], sub: String(d.getFullYear()), cur: i === 0 }); }
52
+ } else if (scale === 'weeks') {
53
+ const mon = new Date(now); mon.setDate(mon.getDate() - ((mon.getDay() + 6) % 7));
54
+ for (let i = -1; i <= 4; i++) { const d = new Date(mon); d.setDate(d.getDate() + i * 7); cols.push({ period: weekKey(d), label: `W${isoWeek(d)}`, sub: `${d.getDate()} ${SHORT_MONTHS[d.getMonth()]}`, cur: i === 0 }); }
55
+ } else {
56
+ for (let i = 0; i < 7; i++) { const d = new Date(now); d.setDate(d.getDate() + i); cols.push({ period: ymdLocal(d), label: tr(WEEKDAYS[d.getDay()]), sub: `${d.getDate()} ${SHORT_MONTHS[d.getMonth()]}`, cur: i === 0 }); }
57
+ }
58
+ return cols;
59
+ }
60
+
61
+ export async function renderHorizon(focusPeriod) {
62
+ const el = $('horizon');
63
+ let goals = [];
64
+ try { goals = await api('GET', `/api/horizons?scale=${horizonScale}`); } catch { }
65
+ const byP = {};
66
+ for (const g of goals) (byP[g.period] ||= []).push(g);
67
+ const cols = horizonColumns(horizonScale);
68
+ el.innerHTML = `<div class="hz-wrap">
69
+ <div class="hz-scales">${HORIZON_SCALES.map(([v, l]) => `<button class="hz-scale${v === horizonScale ? ' active' : ''}" data-scale="${v}">${l}</button>`).join('')}</div>
70
+ <div class="hz-cols">${cols.map((c) => `<div class="hz-col${c.cur ? ' cur' : ''}">
71
+ <div class="hz-col-h">${esc(c.label)}${c.sub ? `<small>${esc(c.sub)}</small>` : ''}</div>
72
+ <div class="hz-goals">${(byP[c.period] || []).map(goalRowHTML).join('')}
73
+ <input class="hz-add-input" placeholder="${tr('Add…')}" data-scale="${horizonScale}" data-period="${c.period}"></div>
74
+ </div>`).join('')}</div>
75
+ </div>`;
76
+ el.querySelectorAll('.hz-scale').forEach((b) => { b.onclick = () => { horizonScale = b.dataset.scale; renderHorizon(); }; });
77
+ wireGoals(el, (fp) => renderHorizon(fp));
78
+ if (focusPeriod) el.querySelector(`.hz-add-input[data-period="${focusPeriod}"]`)?.focus();
79
+ }
80
+
81
+ export async function renderCalendar(focusPeriod) {
82
+ if (!calMonth) { const n = new Date(); calMonth = { y: n.getFullYear(), m: n.getMonth() }; }
83
+ const el = $('calendar');
84
+ let life = [], years = [], months = [], days = [];
85
+ try { [life, years, months, days] = await Promise.all(['life', 'years', 'months', 'days'].map((s) => api('GET', `/api/horizons?scale=${s}`))); } catch { }
86
+ const yKey = String(calMonth.y); const mKey = `${calMonth.y}-${pad2(calMonth.m + 1)}`;
87
+ const byDay = {}; for (const g of days) (byDay[g.period] ||= []).push(g);
88
+ const card = (title, scale, period, list) => `<div class="cal-hz">
89
+ <div class="cal-hz-h">${esc(title)}</div>
90
+ <div class="hz-goals">${list.map(goalRowHTML).join('')}
91
+ <input class="hz-add-input" placeholder="${tr('Add…')}" data-scale="${scale}" data-period="${period}"></div>
92
+ </div>`;
93
+ const first = new Date(calMonth.y, calMonth.m, 1);
94
+ const start = new Date(first); start.setDate(start.getDate() - ((start.getDay() + 6) % 7));
95
+ const today = ymdLocal(new Date());
96
+ let grid = '';
97
+ for (let w = 0; w < 6; w++) {
98
+ let row = '';
99
+ const wd = new Date(start); wd.setDate(wd.getDate() + w * 7);
100
+ for (let i = 0; i < 7; i++) {
101
+ const d = new Date(start); d.setDate(d.getDate() + w * 7 + i);
102
+ const key = ymdLocal(d); const gl = byDay[key] || [];
103
+ row += `<div class="cal-day${d.getMonth() === calMonth.m ? '' : ' off'}${key === today ? ' today' : ''}" data-day="${key}">
104
+ <div class="cal-day-n">${d.getDate()}</div>
105
+ <div class="cal-day-goals">${gl.map((g) => `<div class="cal-chip${g.done ? ' done' : ''}" data-id="${g.id}" title="${esc(g.text)}">${esc(g.text || '…')}</div>`).join('')}</div></div>`;
106
+ }
107
+ grid += `<div class="cal-row"><div class="cal-wk">W${isoWeek(wd)}</div>${row}</div>`;
108
+ }
109
+ el.innerHTML = `<div class="cal-wrap">
110
+ <div class="cal-horizons">
111
+ ${card(tr('Life'), 'life', 'life', life)}
112
+ ${card(yKey, 'years', yKey, years.filter((g) => g.period === yKey))}
113
+ ${card(MONTHS[calMonth.m], 'months', mKey, months.filter((g) => g.period === mKey))}
114
+ </div>
115
+ <div class="cal-head">
116
+ <button class="cal-nav" data-nav="-1">‹</button>
117
+ <span class="cal-title">${MONTHS[calMonth.m]} ${calMonth.y}</span>
118
+ <button class="cal-nav" data-nav="1">›</button>
119
+ <button class="cal-today" data-nav="0">${tr('Today')}</button>
120
+ </div>
121
+ <div class="cal-grid">
122
+ <div class="cal-row cal-wdh"><div class="cal-wk"></div>${DOW.map((w) => `<div class="cal-wd">${tr(w)}</div>`).join('')}</div>
123
+ ${grid}
124
+ </div>
125
+ </div>`;
126
+ wireGoals(el, () => renderCalendar());
127
+ el.querySelectorAll('.cal-nav').forEach((b) => { b.onclick = () => {
128
+ const n = Number(b.dataset.nav);
129
+ if (n === 0) { const t = new Date(); calMonth = { y: t.getFullYear(), m: t.getMonth() }; }
130
+ else { let m = calMonth.m + n, y = calMonth.y; if (m < 0) { m = 11; y--; } if (m > 11) { m = 0; y++; } calMonth = { y, m }; }
131
+ renderCalendar();
132
+ }; });
133
+ el.querySelectorAll('.cal-chip').forEach((ch) => { ch.onclick = async (e) => { e.stopPropagation(); const id = ch.dataset.id; await hzPatch(id, { done: !ch.classList.contains('done') }).catch(() => {}); renderCalendar(); }; });
134
+ el.querySelectorAll('.cal-day').forEach((cell) => { cell.onclick = () => {
135
+ styledPrompt(tr('Goal for the day'), { placeholder: tr('what to do'), okLabel: tr('Add'), onSubmit: async (text) => {
136
+ if (text && text.trim()) { await hzAdd('days', cell.dataset.day, text.trim()).catch(() => {}); renderCalendar(); }
137
+ } });
138
+ }; });
139
+ if (focusPeriod) el.querySelector(`.hz-add-input[data-period="${focusPeriod}"]`)?.focus();
140
+ }
@@ -0,0 +1,208 @@
1
+
2
+ import { clearSelection, closeOpenSelect, initMarquee, multiAction, renderBoard, scheduleDrawLinks, selectedIds } from './board.js';
3
+ import { chaosPending } from './chaos.js';
4
+ import { $, ALL, CHAOS, DASH, SETTINGS, api, loadLabelPalette, seg, state } from './core.js';
5
+ import { addChecklistItem, applyCommentDraft, applyKeepDrawer, applyTitleLabelShortcut, closeDrawer, commentDrafts, deleteTask, doAutosave, kbMoveCard, kbMoveCursor, onDrawerPaste, openDrawer, openDrawerMenu, openDrawerNew, openReturnPop, pendingAttachments, renderComments, scheduleAutosave, setKbCursor } from './drawer.js';
6
+ import { closeProjectSettings, openProjectSettings } from './project.js';
7
+ import { applyCompact, applyTheme, getSetting, matchKey, syncLangToServer } from './settings.js';
8
+ import { closeTopLayer, loadTasks, maybeOnboarding, switchProject } from './sidebar.js';
9
+ import { autoGrow, connectSSE, ensureAudio, ensureSSE, refresh, refreshSync } from './sse.js';
10
+
11
+ /* ── init ── */
12
+ $('new-task').onclick = () => openDrawerNew('backlog');
13
+ $('drawer-close').onclick = closeDrawer;
14
+ $('drawer-menu').onclick = openDrawerMenu;
15
+ $('d-return-icon').onclick = openReturnPop;
16
+ $('d-accept-btn').onclick = async () => {
17
+ if (!state.drawerKey) return;
18
+ await api('PATCH', `/api/tasks/${seg(state.drawerKey)}`, { status: 'done' });
19
+ closeDrawer();
20
+ await refresh();
21
+ };
22
+ $('d-desc').addEventListener('input', scheduleAutosave);
23
+ $('d-checklist-add').onclick = addChecklistItem;
24
+ $('d-title').addEventListener('input', (e) => { autoGrow(e.target); applyTitleLabelShortcut(); scheduleAutosave(); });
25
+ $('d-title').addEventListener('keydown', (e) => {
26
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); e.target.blur(); doAutosave(); }
27
+ });
28
+ $('project-menu').onclick = () => openProjectSettings();
29
+ $('c-send').onclick = async () => {
30
+ const body = $('c-input').value.trim();
31
+ if (!body || !state.drawerKey) return;
32
+ await api('POST', `/api/tasks/${seg(state.drawerKey)}/comments`, { body, author: 'me' });
33
+ commentDrafts.delete(state.drawerKey);
34
+ $('c-input').value = '';
35
+ autoGrow($('c-input'));
36
+ renderComments(state.drawerKey);
37
+ };
38
+ $('c-input').addEventListener('keydown', (e) => {
39
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); $('c-send').click(); }
40
+ });
41
+ $('c-input').addEventListener('input', (e) => {
42
+ autoGrow(e.target);
43
+ if (state.drawerKey) {
44
+ if (e.target.value) commentDrafts.set(state.drawerKey, e.target.value);
45
+ else commentDrafts.delete(state.drawerKey);
46
+ }
47
+ });
48
+ $('drawer').addEventListener('paste', onDrawerPaste);
49
+
50
+ let searchTimer = null;
51
+ $('search').addEventListener('input', (e) => {
52
+ state.search = e.target.value.trim();
53
+ renderBoard();
54
+ clearTimeout(searchTimer);
55
+ searchTimer = setTimeout(loadTasks, 120);
56
+ });
57
+
58
+ window.addEventListener('resize', scheduleDrawLinks);
59
+ $('board').addEventListener('scroll', scheduleDrawLinks, true);
60
+
61
+ const POPUP_SELECTOR = '.modal-overlay, #card-menu, #drawer-menu-pop, #return-pop, .lbl-picker, #status-picker, .lightbox, .kbsel.open';
62
+
63
+ function escClosePopup() {
64
+ if (document.querySelector('.kbsel.open')) { closeOpenSelect(); return true; }
65
+ if (closeTopLayer()) return true;
66
+ if (selectedIds.size) { clearSelection(); return true; }
67
+ return false;
68
+ }
69
+
70
+ document.addEventListener('keydown', (e) => {
71
+ if (e.key === 'Escape') {
72
+ if (escClosePopup()) { e.stopImmediatePropagation(); return; }
73
+ if (state.projSettings) {
74
+ const inp = document.activeElement;
75
+ if (inp && /^(INPUT|TEXTAREA)$/.test(inp.tagName) && $('projset').contains(inp)) inp.blur();
76
+ else closeProjectSettings();
77
+ e.stopImmediatePropagation();
78
+ return;
79
+ }
80
+ const ae = document.activeElement;
81
+ if (ae && /^(INPUT|TEXTAREA)$/.test(ae.tagName) && $('drawer').contains(ae)) {
82
+ ae.blur(); e.stopImmediatePropagation(); return;
83
+ }
84
+ if (!$('drawer').classList.contains('hidden') && !$('drawer').classList.contains('is-empty')) closeDrawer();
85
+ else if (state.kbCursor != null) setKbCursor(null);
86
+ return;
87
+ }
88
+ if (e.key === 'Enter' && e.metaKey && !$('drawer').classList.contains('hidden')) { doAutosave(); return; }
89
+
90
+ const inField = /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName) || e.target.isContentEditable;
91
+ const drawerClosed = $('drawer').classList.contains('hidden') || $('drawer').classList.contains('is-empty');
92
+
93
+ if (drawerClosed && !inField && (matchKey(e, 'prevProject') || matchKey(e, 'nextProject'))) {
94
+ e.preventDefault();
95
+ switchProject(matchKey(e, 'nextProject') ? 1 : -1);
96
+ return;
97
+ }
98
+
99
+ const delDefault = !(getSetting('keymap') || {}).deleteCard;
100
+ if ((matchKey(e, 'deleteCard') || (delDefault && e.key === 'Backspace' && !e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey)) && !inField) {
101
+ if (selectedIds.size) { e.preventDefault(); multiAction('del'); return; }
102
+ if (!drawerClosed && state.drawerKey) { e.preventDefault(); deleteTask(state.drawerKey); return; }
103
+ if (drawerClosed && !$('board').classList.contains('hidden') && state.kbCursor != null) {
104
+ const t = state.tasks.find((x) => x.id === state.kbCursor);
105
+ if (t) { e.preventDefault(); deleteTask(t.key); }
106
+ return;
107
+ }
108
+ }
109
+
110
+ const boardVisible = !$('board').classList.contains('hidden') && !inField;
111
+ if (!boardVisible) return;
112
+
113
+ if (drawerClosed && matchKey(e, 'newTask')) {
114
+ e.preventDefault();
115
+ openDrawerNew('backlog');
116
+ return;
117
+ }
118
+ if (document.querySelector(POPUP_SELECTOR)) return;
119
+
120
+ const ARROW = { ArrowUp: [-1, 0], ArrowDown: [1, 0], ArrowLeft: [0, -1], ArrowRight: [0, 1] };
121
+ if (ARROW[e.key] && !e.metaKey && !e.ctrlKey && !e.altKey) {
122
+ e.preventDefault();
123
+ const [dr, dc] = ARROW[e.key];
124
+ if (e.shiftKey) kbMoveCard(dr, dc); else kbMoveCursor(dr, dc);
125
+ return;
126
+ }
127
+ if (matchKey(e, 'openCard') && state.kbCursor != null) {
128
+ const t = state.tasks.find((x) => x.id === state.kbCursor);
129
+ if (t) { e.preventDefault(); openDrawer(t.key); }
130
+ }
131
+ });
132
+
133
+ applyTheme();
134
+ applyCompact();
135
+ applyCommentDraft(null);
136
+ state.dashRange = getSetting('dashRange');
137
+ const sessionSlug = sessionStorage.getItem('kb.session.slug');
138
+ if (sessionSlug) {
139
+ state.slug = sessionSlug;
140
+ } else if (getSetting('startScreen') === 'chaos') {
141
+ state.slug = CHAOS;
142
+ } else if (getSetting('startScreen') === 'last') {
143
+ const last = localStorage.getItem('kb.ui.project');
144
+ if (last && last !== SETTINGS) state.slug = last;
145
+ }
146
+
147
+ const launchParams = new URLSearchParams(location.search);
148
+ const launchAction = launchParams.get('action');
149
+ if (launchParams.get('view') === 'dash') state.slug = DASH;
150
+ if (launchAction === 'new') {
151
+ const last = localStorage.getItem('kb.ui.project');
152
+ state.slug = (last && last !== SETTINGS && last !== DASH) ? last : ALL;
153
+ }
154
+ if (location.search) history.replaceState(null, '', location.pathname);
155
+
156
+ let appVer = null;
157
+ export let pendingReload = false;
158
+ function safeToReload() {
159
+ const d = $('drawer');
160
+ const drawerBusy = !d.classList.contains('hidden') && !d.classList.contains('is-empty');
161
+ return !drawerBusy && !selectedIds.size && !document.querySelector(POPUP_SELECTOR) && !hasUnsavedInput();
162
+ }
163
+ function hasUnsavedInput() {
164
+ const chaos = $('chaos-input');
165
+ if (chaos && chaos.value.trim()) return true;
166
+ if (chaosPending.length || pendingAttachments.length) return true;
167
+ if (commentDrafts.size) return true;
168
+ const ae = document.activeElement;
169
+ if (ae && /^(INPUT|TEXTAREA)$/.test(ae.tagName) && ae.value.trim()) return true;
170
+ return false;
171
+ }
172
+ export async function checkVersion() {
173
+ try {
174
+ const res = await fetch('/api/ui-version', { cache: 'no-store' });
175
+ const v = String((await res.json()).mtime || '');
176
+ if (appVer === null) { appVer = v; return; }
177
+ if (v && v !== appVer) pendingReload = true;
178
+ } catch { }
179
+ if (pendingReload && safeToReload()) location.reload();
180
+ }
181
+
182
+ if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {});
183
+
184
+ function bootDone() {
185
+ const html = document.documentElement;
186
+ if (!html.classList.contains('booting')) return;
187
+ html.classList.remove('booting');
188
+ html.classList.add('booted');
189
+ setTimeout(() => html.classList.remove('booted'), 400);
190
+ }
191
+ setTimeout(bootDone, 8000);
192
+
193
+ loadLabelPalette().catch(() => {}).then(() => refresh()).catch(() => {}).then(() => {
194
+ bootDone();
195
+ refreshSync(); applyKeepDrawer();
196
+ if (launchAction === 'new') openDrawerNew('backlog');
197
+ maybeOnboarding();
198
+ });
199
+ syncLangToServer();
200
+ initMarquee();
201
+ connectSSE();
202
+ ensureAudio();
203
+ checkVersion();
204
+ setInterval(refreshSync, 30000);
205
+ setInterval(checkVersion, 30000);
206
+ window.addEventListener('focus', checkVersion);
207
+ setInterval(renderBoard, 60000);
208
+ setInterval(ensureSSE, 20000);