vibeops-tracker 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/app.js ADDED
@@ -0,0 +1,733 @@
1
+ /* Tracker board UI. All user-generated strings are rendered via textContent — never innerHTML. */
2
+ (function () {
3
+ 'use strict';
4
+
5
+ const STATUSES = [
6
+ { key: 'backlog', label: 'Backlog' },
7
+ { key: 'in-progress', label: 'In Progress' },
8
+ { key: 'in-review', label: 'In Review' },
9
+ { key: 'done', label: 'Done' },
10
+ ];
11
+ const TYPES = ['bug', 'improvement', 'feature', 'other'];
12
+ const POLL_MS = 5000;
13
+
14
+ const state = {
15
+ projects: [],
16
+ projectKey: localStorage.getItem('it-project') || null,
17
+ issues: [],
18
+ closed: [],
19
+ view: 'board', // board | archive
20
+ query: '',
21
+ draggingId: null,
22
+ drawerId: null,
23
+ };
24
+
25
+ const $board = document.getElementById('board');
26
+ const $select = document.getElementById('project-select');
27
+ const $count = document.getElementById('issue-count');
28
+ const $search = document.getElementById('search');
29
+ const $drawer = document.getElementById('drawer');
30
+ const $backdrop = document.getElementById('drawer-backdrop');
31
+ const $modalRoot = document.getElementById('modal-root');
32
+ const $viewBoard = document.getElementById('view-board');
33
+ const $viewArchive = document.getElementById('view-archive');
34
+
35
+ function el(tag, cls, text) {
36
+ const node = document.createElement(tag);
37
+ if (cls) node.className = cls;
38
+ if (text != null) node.textContent = text;
39
+ return node;
40
+ }
41
+
42
+ async function api(path, opts) {
43
+ const res = await fetch(path, opts);
44
+ if (!res.ok) {
45
+ let msg = `${res.status}`;
46
+ try { msg = (await res.json()).error || msg; } catch {}
47
+ throw new Error(msg);
48
+ }
49
+ return res.headers.get('content-type')?.includes('json') ? res.json() : res.text();
50
+ }
51
+
52
+ function relAge(iso) {
53
+ if (!iso) return '';
54
+ const ms = Date.now() - new Date(iso).getTime();
55
+ const m = Math.floor(ms / 60000);
56
+ if (m < 1) return 'now';
57
+ if (m < 60) return `${m}m`;
58
+ const h = Math.floor(m / 60);
59
+ if (h < 24) return `${h}h`;
60
+ return `${Math.floor(h / 24)}d`;
61
+ }
62
+
63
+ function matchesQuery(issue, q) {
64
+ if (!q) return true;
65
+ return [issue.id, issue.title, (issue.tags || []).join(' '), issue.seeing, issue.expecting, issue.resolution || '']
66
+ .join('\n')
67
+ .toLowerCase()
68
+ .includes(q);
69
+ }
70
+
71
+ // ---- project switcher ---------------------------------------------------
72
+
73
+ async function loadProjects() {
74
+ state.projects = await api('/api/projects');
75
+ $select.replaceChildren();
76
+ for (const p of state.projects) {
77
+ const opt = el('option', null, p.name);
78
+ opt.value = p.key;
79
+ $select.appendChild(opt);
80
+ }
81
+ if (!state.projects.length) {
82
+ state.projectKey = null;
83
+ } else if (!state.projects.some((p) => p.key === state.projectKey)) {
84
+ state.projectKey = state.projects[0].key;
85
+ }
86
+ if (state.projectKey) $select.value = state.projectKey;
87
+ }
88
+
89
+ $select.addEventListener('change', () => {
90
+ state.projectKey = $select.value;
91
+ localStorage.setItem('it-project', state.projectKey);
92
+ refresh();
93
+ });
94
+
95
+ $search.addEventListener('input', () => {
96
+ state.query = $search.value.trim().toLowerCase();
97
+ render();
98
+ });
99
+
100
+ $viewBoard.addEventListener('click', () => setView('board'));
101
+ $viewArchive.addEventListener('click', () => setView('archive'));
102
+
103
+ function setView(view) {
104
+ state.view = view;
105
+ $viewBoard.classList.toggle('on', view === 'board');
106
+ $viewArchive.classList.toggle('on', view === 'archive');
107
+ refresh();
108
+ }
109
+
110
+ // ---- data + render ------------------------------------------------------
111
+
112
+ async function refresh() {
113
+ if (!state.projectKey) {
114
+ renderEmptyHint();
115
+ return;
116
+ }
117
+ if (state.view === 'board') {
118
+ state.issues = await api(`/api/projects/${encodeURIComponent(state.projectKey)}/issues`);
119
+ } else {
120
+ state.closed = await api(`/api/projects/${encodeURIComponent(state.projectKey)}/closed`);
121
+ }
122
+ render();
123
+ }
124
+
125
+ function render() {
126
+ if (!state.projectKey) {
127
+ renderEmptyHint();
128
+ return;
129
+ }
130
+ if (state.view === 'board') renderBoard();
131
+ else renderArchive();
132
+ }
133
+
134
+ function renderEmptyHint() {
135
+ $count.textContent = '';
136
+ $board.replaceChildren();
137
+ const hint = el('div', 'hint');
138
+ hint.appendChild(el('p', null, 'No projects yet. Install the widget in any of your apps and capture your first issue:'));
139
+ const code = el('code', null, `<script src="${location.origin}/widget.js" data-project="my-project" defer><\/script>`);
140
+ hint.appendChild(code);
141
+ const more = el('p', null, 'Or create one right here with “+ New issue” after registering a project, or see Help for the full guide.');
142
+ hint.appendChild(more);
143
+ $board.appendChild(hint);
144
+ }
145
+
146
+ function renderBoard() {
147
+ if (state.draggingId) return; // never re-render mid-drag
148
+ const visible = state.issues.filter((i) => matchesQuery(i, state.query));
149
+ $count.textContent = state.query
150
+ ? `${visible.length} of ${state.issues.length} issue${state.issues.length === 1 ? '' : 's'}`
151
+ : `${state.issues.length} issue${state.issues.length === 1 ? '' : 's'}`;
152
+ $board.className = '';
153
+ $board.replaceChildren();
154
+ for (const status of STATUSES) {
155
+ const issues = visible.filter((i) => i.status === status.key);
156
+ const col = el('div', 'col');
157
+ const head = el('div', 'col-head');
158
+ head.appendChild(el('span', null, status.label));
159
+ const right = el('span', 'col-head-right');
160
+ if (status.key === 'done' && issues.length && !state.query) {
161
+ const sweep = el('button', 'sweep-btn', 'Sweep');
162
+ sweep.title = 'Move all Done issues to the archive';
163
+ sweep.addEventListener('click', async () => {
164
+ if (!confirm(`Sweep ${issues.length} done issue${issues.length === 1 ? '' : 's'} to the archive?`)) return;
165
+ await api(`/api/projects/${encodeURIComponent(state.projectKey)}/sweep`, { method: 'POST' });
166
+ refresh();
167
+ });
168
+ right.appendChild(sweep);
169
+ }
170
+ right.appendChild(el('span', 'col-count', String(issues.length)));
171
+ head.appendChild(right);
172
+ col.appendChild(head);
173
+
174
+ const list = el('div', 'col-cards');
175
+ list.dataset.status = status.key;
176
+ list.addEventListener('dragover', onDragOver);
177
+ list.addEventListener('dragleave', onDragLeave);
178
+ list.addEventListener('drop', onDrop);
179
+ if (!issues.length) list.appendChild(el('div', 'empty', '—'));
180
+ for (const issue of issues) list.appendChild(renderCard(issue));
181
+ col.appendChild(list);
182
+ $board.appendChild(col);
183
+ }
184
+ }
185
+
186
+ function renderCard(issue) {
187
+ const card = el('div', 'card');
188
+ card.draggable = true;
189
+ card.dataset.id = issue.id;
190
+ card.dataset.ordinal = issue.ordinal;
191
+
192
+ const top = el('div', 'card-top');
193
+ top.appendChild(el('span', 'card-id', issue.id));
194
+ top.appendChild(el('span', 'card-age', relAge(issue.created)));
195
+ card.appendChild(top);
196
+
197
+ card.appendChild(el('div', 'card-title', issue.title || (issue.seeing || '').slice(0, 80) || '(untitled)'));
198
+
199
+ const meta = el('div', 'card-meta');
200
+ meta.appendChild(el('span', `pill pill-type-${issue.type}`, issue.type));
201
+ meta.appendChild(el('span', `pill pill-sev pill-sev-${issue.severity}`, `S${issue.severity}`));
202
+ for (const tag of issue.tags || []) meta.appendChild(el('span', 'tag', tag));
203
+ card.appendChild(meta);
204
+
205
+ card.addEventListener('click', () => openDrawer(issue.id));
206
+ card.addEventListener('dragstart', (e) => {
207
+ state.draggingId = issue.id;
208
+ card.classList.add('dragging');
209
+ e.dataTransfer.setData('text/plain', issue.id);
210
+ e.dataTransfer.effectAllowed = 'move';
211
+ });
212
+ card.addEventListener('dragend', () => {
213
+ state.draggingId = null;
214
+ card.classList.remove('dragging');
215
+ document.querySelectorAll('.drag-over, .drop-before').forEach((n) => n.classList.remove('drag-over', 'drop-before'));
216
+ refresh();
217
+ });
218
+ return card;
219
+ }
220
+
221
+ function renderArchive() {
222
+ const visible = state.closed.filter((i) => matchesQuery(i, state.query));
223
+ $count.textContent = `${visible.length} archived issue${visible.length === 1 ? '' : 's'}`;
224
+ $board.className = 'archive';
225
+ $board.replaceChildren();
226
+ if (!visible.length) {
227
+ $board.appendChild(el('div', 'hint', state.query ? 'No archived issues match the search.' : 'Nothing swept yet. Use “Sweep” on the Done column to archive finished issues.'));
228
+ return;
229
+ }
230
+ const list = el('div', 'archive-list');
231
+ for (const issue of visible) {
232
+ const row = el('div', 'archive-row');
233
+ row.appendChild(el('span', 'card-id', issue.id));
234
+ row.appendChild(el('span', 'archive-title', issue.title || (issue.seeing || '').slice(0, 80) || '(untitled)'));
235
+ const meta = el('span', 'card-meta');
236
+ meta.appendChild(el('span', `pill pill-type-${issue.type}`, issue.type));
237
+ meta.appendChild(el('span', `pill pill-sev pill-sev-${issue.severity}`, `S${issue.severity}`));
238
+ meta.appendChild(el('span', 'card-age', `closed ${issue.closed ? new Date(issue.closed).toLocaleDateString() : ''}`));
239
+ row.appendChild(meta);
240
+ row.addEventListener('click', () => openDrawer(issue.id));
241
+ list.appendChild(row);
242
+ }
243
+ $board.appendChild(list);
244
+ }
245
+
246
+ // ---- drag & drop --------------------------------------------------------
247
+
248
+ function cardAfterPointer(list, y) {
249
+ const cards = [...list.querySelectorAll('.card:not(.dragging)')];
250
+ return cards.find((c) => y < c.getBoundingClientRect().top + c.getBoundingClientRect().height / 2) || null;
251
+ }
252
+
253
+ function onDragOver(e) {
254
+ e.preventDefault();
255
+ e.dataTransfer.dropEffect = 'move';
256
+ const list = e.currentTarget;
257
+ list.classList.add('drag-over');
258
+ list.querySelectorAll('.drop-before').forEach((n) => n.classList.remove('drop-before'));
259
+ const after = cardAfterPointer(list, e.clientY);
260
+ if (after) after.classList.add('drop-before');
261
+ }
262
+
263
+ function onDragLeave(e) {
264
+ if (e.currentTarget.contains(e.relatedTarget)) return;
265
+ e.currentTarget.classList.remove('drag-over');
266
+ e.currentTarget.querySelectorAll('.drop-before').forEach((n) => n.classList.remove('drop-before'));
267
+ }
268
+
269
+ async function onDrop(e) {
270
+ e.preventDefault();
271
+ const list = e.currentTarget;
272
+ list.classList.remove('drag-over');
273
+ const id = e.dataTransfer.getData('text/plain');
274
+ const status = list.dataset.status;
275
+ const columnIssues = state.issues
276
+ .filter((i) => i.status === status && i.id !== id)
277
+ .sort((a, b) => a.ordinal - b.ordinal);
278
+
279
+ const before = cardAfterPointer(list, e.clientY);
280
+ let ordinal;
281
+ if (!columnIssues.length) {
282
+ ordinal = 1000;
283
+ } else if (!before) {
284
+ ordinal = columnIssues[columnIssues.length - 1].ordinal + 1000;
285
+ } else {
286
+ const idx = columnIssues.findIndex((i) => i.id === before.dataset.id);
287
+ const prev = idx > 0 ? columnIssues[idx - 1].ordinal : 0;
288
+ ordinal = (prev + columnIssues[idx].ordinal) / 2;
289
+ }
290
+
291
+ state.draggingId = null;
292
+ try {
293
+ await api(`/api/issues/${encodeURIComponent(id)}`, {
294
+ method: 'PATCH',
295
+ headers: { 'Content-Type': 'application/json' },
296
+ body: JSON.stringify({ status, ordinal }),
297
+ });
298
+ } catch (err) {
299
+ console.warn('reorder failed', err);
300
+ }
301
+ refresh();
302
+ }
303
+
304
+ // ---- drawer -------------------------------------------------------------
305
+
306
+ function closeDrawer() {
307
+ state.drawerId = null;
308
+ $drawer.hidden = true;
309
+ $backdrop.hidden = true;
310
+ if (location.hash) history.replaceState(null, '', location.pathname);
311
+ }
312
+
313
+ $backdrop.addEventListener('click', closeDrawer);
314
+ document.addEventListener('keydown', (e) => {
315
+ if (e.key === 'Escape' && state.drawerId) closeDrawer();
316
+ });
317
+
318
+ async function openDrawer(id) {
319
+ let issue;
320
+ try {
321
+ issue = await api(`/api/issues/${encodeURIComponent(id)}`);
322
+ } catch {
323
+ return;
324
+ }
325
+ const readOnly = !!issue.closed;
326
+ state.drawerId = id;
327
+ history.replaceState(null, '', `#${encodeURIComponent(id)}`);
328
+ $drawer.replaceChildren();
329
+ $drawer.hidden = false;
330
+ $backdrop.hidden = false;
331
+
332
+ const head = el('div', 'drawer-head');
333
+ const titleWrap = el('div');
334
+ titleWrap.appendChild(el('div', 'drawer-id', issue.id));
335
+ titleWrap.appendChild(el('h2', null, issue.title || '(untitled)'));
336
+ head.appendChild(titleWrap);
337
+ const close = el('button', 'drawer-close', '×');
338
+ close.addEventListener('click', closeDrawer);
339
+ head.appendChild(close);
340
+ $drawer.appendChild(head);
341
+
342
+ // Click-to-change type/severity. Closed issues reject mutations (assertOpen),
343
+ // so they stay static pills; open issues get a colored dropdown in their place.
344
+ function patchPill(field, current, colorClass, options) {
345
+ const sel = el('select', `pill pill-select ${colorClass}`);
346
+ sel.title = field === 'type' ? 'Type — click to change' : 'Severity — click to change';
347
+ sel.setAttribute('aria-label', sel.title);
348
+ for (const o of options) {
349
+ const opt = el('option', null, o.label);
350
+ opt.value = String(o.value);
351
+ if (String(o.value) === String(current)) opt.selected = true;
352
+ sel.appendChild(opt);
353
+ }
354
+ const cls = (v) => (field === 'severity' ? `pill-sev-${v}` : `pill-type-${v}`);
355
+ sel.addEventListener('change', async () => {
356
+ const prev = issue[field]; // last committed value (the select persists across edits)
357
+ const value = field === 'severity' ? Number(sel.value) : sel.value;
358
+ if (value === prev) return;
359
+ try {
360
+ await api(`/api/issues/${encodeURIComponent(issue.id)}`, {
361
+ method: 'PATCH',
362
+ headers: { 'Content-Type': 'application/json' },
363
+ body: JSON.stringify({ [field]: value }),
364
+ });
365
+ // Recolor the pill in place — no full re-render, so keyboard focus
366
+ // and the drawer's scroll position are preserved.
367
+ sel.classList.replace(cls(prev), cls(value));
368
+ issue[field] = value;
369
+ refresh(); // sync the board card's color/severity
370
+ } catch (err) {
371
+ console.warn(`update ${field} failed`, err);
372
+ sel.value = String(prev); // revert to the last committed value
373
+ openDrawer(issue.id); // resync — flips the drawer to read-only if the issue was closed mid-edit
374
+ }
375
+ });
376
+ return sel;
377
+ }
378
+
379
+ const meta = el('div', 'drawer-meta');
380
+ if (readOnly) {
381
+ meta.appendChild(el('span', `pill pill-type-${issue.type}`, issue.type));
382
+ meta.appendChild(el('span', `pill pill-sev pill-sev-${issue.severity}`, `Severity ${issue.severity}`));
383
+ } else {
384
+ meta.appendChild(patchPill('type', issue.type, `pill-type-${issue.type}`, TYPES.map((t) => ({ value: t, label: t }))));
385
+ meta.appendChild(patchPill('severity', issue.severity, `pill-sev pill-sev-${issue.severity}`, [1, 2, 3, 4, 5].map((s) => ({ value: s, label: `Severity ${s}` }))));
386
+ }
387
+ for (const tag of issue.tags || []) meta.appendChild(el('span', 'tag', tag));
388
+ meta.appendChild(el('span', 'card-age', `created ${relAge(issue.created)} ago`));
389
+ if (issue.relatedTo) meta.appendChild(el('span', 'tag', `related: ${issue.relatedTo}`));
390
+ if (readOnly) meta.appendChild(el('span', 'pill pill-closed', `archived ${new Date(issue.closed).toLocaleDateString()}`));
391
+ $drawer.appendChild(meta);
392
+
393
+ const actions = el('div', 'drawer-actions');
394
+ if (!readOnly) {
395
+ const statusSelect = el('select');
396
+ statusSelect.id = 'status-select';
397
+ for (const s of STATUSES) {
398
+ const opt = el('option', null, s.label);
399
+ opt.value = s.key;
400
+ statusSelect.appendChild(opt);
401
+ }
402
+ statusSelect.value = issue.status;
403
+ statusSelect.addEventListener('change', async () => {
404
+ try {
405
+ await api(`/api/issues/${encodeURIComponent(issue.id)}`, {
406
+ method: 'PATCH',
407
+ headers: { 'Content-Type': 'application/json' },
408
+ body: JSON.stringify({ status: statusSelect.value }),
409
+ });
410
+ } catch (err) {
411
+ console.warn('update status failed', err); // never leave an unhandled rejection
412
+ }
413
+ refresh();
414
+ openDrawer(issue.id); // re-render so the drawer reflects server state (read-only if closed)
415
+ });
416
+ actions.appendChild(statusSelect);
417
+ }
418
+
419
+ const copyBtn = el('button', 'btn btn-primary', 'Copy Prompt');
420
+ copyBtn.addEventListener('click', async () => {
421
+ try {
422
+ const text = await api(`/api/issues/${encodeURIComponent(issue.id)}/prompt`);
423
+ await navigator.clipboard.writeText(text);
424
+ copyBtn.textContent = 'Copied!';
425
+ } catch {
426
+ copyBtn.textContent = 'Copy failed';
427
+ }
428
+ setTimeout(() => (copyBtn.textContent = 'Copy Prompt'), 1800);
429
+ });
430
+ actions.appendChild(copyBtn);
431
+ $drawer.appendChild(actions);
432
+
433
+ const seeing = el('div', 'section');
434
+ seeing.appendChild(el('h3', null, 'Seeing'));
435
+ seeing.appendChild(el('div', 'prose', issue.seeing || '—'));
436
+ $drawer.appendChild(seeing);
437
+
438
+ const expecting = el('div', 'section');
439
+ expecting.appendChild(el('h3', null, 'Expecting'));
440
+ expecting.appendChild(el('div', 'prose', issue.expecting || '—'));
441
+ $drawer.appendChild(expecting);
442
+
443
+ if (issue.resolution) {
444
+ const resolution = el('div', 'section section-resolution');
445
+ resolution.appendChild(el('h3', null, 'Resolution'));
446
+ resolution.appendChild(el('div', 'prose', issue.resolution));
447
+ if (issue.modifiedFiles?.length) {
448
+ const files = el('div', 'modified-files');
449
+ for (const f of issue.modifiedFiles) files.appendChild(el('span', 'tag', f));
450
+ resolution.appendChild(files);
451
+ }
452
+ $drawer.appendChild(resolution);
453
+ }
454
+
455
+ if (issue.context) {
456
+ const ctx = el('div', 'section');
457
+ ctx.appendChild(el('h3', null, 'Context'));
458
+ const list = el('ul', 'ctx-list');
459
+ const c = issue.context;
460
+ if (c.url) list.appendChild(el('li', null, `URL: ${c.url}`));
461
+ if (c.viewport?.w) list.appendChild(el('li', null, `Viewport: ${c.viewport.w}×${c.viewport.h}`));
462
+ if (c.capturedAt) list.appendChild(el('li', null, `Captured: ${c.capturedAt}`));
463
+ if (c.selectedText) list.appendChild(el('li', null, `Selected text: "${c.selectedText}"`));
464
+ if (c.recentErrors?.length) list.appendChild(el('li', null, `JS errors: ${c.recentErrors.length} (latest: ${c.recentErrors.at(-1).message})`));
465
+ if (c.recentFetchFailures?.length) list.appendChild(el('li', null, `Failed requests: ${c.recentFetchFailures.length}`));
466
+ if (c.clickBreadcrumbs?.length) list.appendChild(el('li', null, `Click breadcrumbs: ${c.clickBreadcrumbs.length}`));
467
+ const git = c.git || c.app?.git;
468
+ if (git?.branch) list.appendChild(el('li', null, `Branch at capture: ${git.branch}${git.commit ? ` @ ${git.commit}` : ''}`));
469
+ ctx.appendChild(list);
470
+
471
+ const details = el('details', 'ctx-json');
472
+ details.appendChild(el('summary', null, 'Full context JSON'));
473
+ details.appendChild(el('pre', null, JSON.stringify(issue.context, null, 2)));
474
+ ctx.appendChild(details);
475
+ $drawer.appendChild(ctx);
476
+ }
477
+
478
+ const comments = el('div', 'section');
479
+ comments.appendChild(el('h3', null, `Comments (${issue.comments.length})`));
480
+ for (const c of issue.comments) {
481
+ const item = el('div', 'comment');
482
+ item.appendChild(el('div', 'comment-head', `${c.author} — ${c.at ? new Date(c.at).toLocaleString() : ''}`));
483
+ item.appendChild(el('div', 'comment-body', c.text));
484
+ comments.appendChild(item);
485
+ }
486
+
487
+ if (!readOnly) {
488
+ const form = el('div', 'comment-form');
489
+ const ta = el('textarea');
490
+ ta.placeholder = 'Add a comment…';
491
+ const row = el('div', 'row');
492
+ const author = el('input');
493
+ author.value = localStorage.getItem('it-author') || 'igor';
494
+ const send = el('button', 'btn', 'Comment');
495
+ send.addEventListener('click', async () => {
496
+ if (!ta.value.trim()) return;
497
+ localStorage.setItem('it-author', author.value || 'igor');
498
+ await api(`/api/issues/${encodeURIComponent(issue.id)}/comments`, {
499
+ method: 'POST',
500
+ headers: { 'Content-Type': 'application/json' },
501
+ body: JSON.stringify({ author: author.value || 'igor', text: ta.value }),
502
+ });
503
+ openDrawer(issue.id);
504
+ });
505
+ row.appendChild(author);
506
+ row.appendChild(send);
507
+ form.appendChild(ta);
508
+ form.appendChild(row);
509
+ comments.appendChild(form);
510
+ }
511
+ $drawer.appendChild(comments);
512
+
513
+ // ---- danger zone: permanent delete (two-step confirm) -----------------
514
+ const danger = el('div', 'section danger-zone');
515
+ danger.appendChild(el('h3', null, 'Danger zone'));
516
+
517
+ const delBtn = el('button', 'btn btn-danger del-issue', 'Delete issue');
518
+ danger.appendChild(delBtn);
519
+
520
+ const confirm = el('div', 'danger-confirm');
521
+ confirm.style.display = 'none';
522
+ confirm.appendChild(el('div', 'danger-warn', `Permanently delete ${issue.id}? This can’t be undone — the issue file is removed for good.`));
523
+ const confirmBtns = el('div', 'danger-btns');
524
+ const cancelDel = el('button', 'btn', 'Cancel');
525
+ const reallyDel = el('button', 'btn btn-danger del-confirm', 'Delete permanently');
526
+ confirmBtns.appendChild(cancelDel);
527
+ confirmBtns.appendChild(reallyDel);
528
+ confirm.appendChild(confirmBtns);
529
+ const delErr = el('div', 'form-error', '');
530
+ delErr.setAttribute('role', 'alert'); // announce failures to assistive tech
531
+ confirm.appendChild(delErr);
532
+ danger.appendChild(confirm);
533
+
534
+ delBtn.addEventListener('click', () => {
535
+ delBtn.style.display = 'none';
536
+ confirm.style.display = 'flex';
537
+ cancelDel.focus(); // land on the safe action, not the destructive one
538
+ });
539
+ cancelDel.addEventListener('click', () => {
540
+ confirm.style.display = 'none';
541
+ delBtn.style.display = 'inline-block';
542
+ delErr.textContent = '';
543
+ });
544
+ reallyDel.addEventListener('click', async () => {
545
+ reallyDel.disabled = true;
546
+ cancelDel.disabled = true;
547
+ try {
548
+ await api(`/api/issues/${encodeURIComponent(issue.id)}`, { method: 'DELETE' });
549
+ closeDrawer();
550
+ refresh();
551
+ } catch (err) {
552
+ reallyDel.disabled = false;
553
+ cancelDel.disabled = false;
554
+ delErr.textContent = `Could not delete: ${err.message}`;
555
+ }
556
+ });
557
+ $drawer.appendChild(danger);
558
+ }
559
+
560
+ // ---- new issue modal ----------------------------------------------------
561
+
562
+ document.getElementById('new-issue').addEventListener('click', () => {
563
+ if (!state.projectKey) {
564
+ alert('Register a project first (capture from a widget once, or POST /api/projects).');
565
+ return;
566
+ }
567
+ openNewIssueModal();
568
+ });
569
+
570
+ function openNewIssueModal() {
571
+ if (document.querySelector('.modal-backdrop')) return;
572
+ let selectedType = 'improvement';
573
+ let selectedSev = 3;
574
+
575
+ const backdrop = el('div', 'modal-backdrop');
576
+ const modal = el('div', 'modal');
577
+ modal.setAttribute('role', 'dialog');
578
+ modal.setAttribute('aria-label', 'New issue');
579
+
580
+ const head = el('div', 'modal-head');
581
+ head.appendChild(el('b', null, `New issue in ${state.projectKey}`));
582
+ const x = el('button', 'drawer-close', '×');
583
+ head.appendChild(x);
584
+ modal.appendChild(head);
585
+
586
+ const body = el('div', 'modal-body');
587
+
588
+ const typeRow = el('div', 'form-row');
589
+ typeRow.appendChild(el('span', 'form-label', 'Type'));
590
+ const pills = el('div', 'form-pills');
591
+ for (const t of TYPES) {
592
+ const pill = el('button', `form-pill${t === selectedType ? ' on' : ''}`, t);
593
+ pill.addEventListener('click', () => {
594
+ selectedType = t;
595
+ pills.querySelectorAll('.form-pill').forEach((p) => p.classList.toggle('on', p.textContent === t));
596
+ });
597
+ pills.appendChild(pill);
598
+ }
599
+ typeRow.appendChild(pills);
600
+ body.appendChild(typeRow);
601
+
602
+ const titleRow = el('div', 'form-row');
603
+ titleRow.appendChild(el('span', 'form-label', 'Title (optional)'));
604
+ const title = el('input', 'form-input');
605
+ title.placeholder = 'Short summary';
606
+ titleRow.appendChild(title);
607
+ body.appendChild(titleRow);
608
+
609
+ const seeingRow = el('div', 'form-row');
610
+ seeingRow.appendChild(el('span', 'form-label', 'What is wrong / current state *'));
611
+ const seeing = el('textarea', 'form-input');
612
+ seeing.rows = 4;
613
+ seeingRow.appendChild(seeing);
614
+ body.appendChild(seeingRow);
615
+
616
+ const expRow = el('div', 'form-row');
617
+ expRow.appendChild(el('span', 'form-label', 'What should happen / requirements *'));
618
+ const expecting = el('textarea', 'form-input');
619
+ expecting.rows = 3;
620
+ expRow.appendChild(expecting);
621
+ body.appendChild(expRow);
622
+
623
+ const sevRow = el('div', 'form-row');
624
+ sevRow.appendChild(el('span', 'form-label', 'Severity'));
625
+ const sevs = el('div', 'form-pills');
626
+ for (let s = 1; s <= 5; s++) {
627
+ const dot = el('button', `sev-dot${s <= selectedSev ? ' on' : ''}`, String(s));
628
+ dot.addEventListener('click', () => {
629
+ selectedSev = s;
630
+ [...sevs.children].forEach((d, i) => d.classList.toggle('on', i < s));
631
+ });
632
+ sevs.appendChild(dot);
633
+ }
634
+ sevRow.appendChild(sevs);
635
+ body.appendChild(sevRow);
636
+
637
+ const tagsRow = el('div', 'form-row');
638
+ tagsRow.appendChild(el('span', 'form-label', 'Tags (comma-separated)'));
639
+ const tags = el('input', 'form-input');
640
+ tags.placeholder = 'roadmap, ux';
641
+ tagsRow.appendChild(tags);
642
+ body.appendChild(tagsRow);
643
+
644
+ const error = el('div', 'form-error', '');
645
+ body.appendChild(error);
646
+ modal.appendChild(body);
647
+
648
+ const actions = el('div', 'modal-actions');
649
+ const cancel = el('button', 'btn', 'Cancel');
650
+ const submit = el('button', 'btn btn-primary', 'Create issue');
651
+ actions.appendChild(cancel);
652
+ actions.appendChild(submit);
653
+ modal.appendChild(actions);
654
+
655
+ function close() {
656
+ backdrop.remove();
657
+ modal.remove();
658
+ document.removeEventListener('keydown', onKey);
659
+ }
660
+ function onKey(e) {
661
+ if (e.key === 'Escape') close();
662
+ }
663
+ x.addEventListener('click', close);
664
+ cancel.addEventListener('click', close);
665
+ backdrop.addEventListener('click', close);
666
+ document.addEventListener('keydown', onKey);
667
+
668
+ submit.addEventListener('click', async () => {
669
+ if (!seeing.value.trim() || !expecting.value.trim()) {
670
+ error.textContent = 'Both required fields must be filled in.';
671
+ return;
672
+ }
673
+ submit.disabled = true;
674
+ try {
675
+ const issue = await api('/api/issues', {
676
+ method: 'POST',
677
+ headers: { 'Content-Type': 'application/json' },
678
+ body: JSON.stringify({
679
+ project: state.projectKey,
680
+ title: title.value.trim(),
681
+ type: selectedType,
682
+ severity: selectedSev,
683
+ tags: tags.value.split(',').map((t) => t.trim()).filter(Boolean),
684
+ seeing: seeing.value,
685
+ expecting: expecting.value,
686
+ context: { source: 'tracker-ui', capturedAt: new Date().toISOString() },
687
+ }),
688
+ });
689
+ close();
690
+ setView('board');
691
+ await refresh();
692
+ openDrawer(issue.id);
693
+ } catch (err) {
694
+ submit.disabled = false;
695
+ error.textContent = `Could not create issue: ${err.message}`;
696
+ }
697
+ });
698
+
699
+ $modalRoot.appendChild(backdrop);
700
+ $modalRoot.appendChild(modal);
701
+ seeing.focus();
702
+ }
703
+
704
+ // ---- boot ---------------------------------------------------------------
705
+
706
+ async function boot() {
707
+ await loadProjects();
708
+
709
+ // deep link: #<issue-id> selects the project and opens the drawer
710
+ const hash = decodeURIComponent(location.hash.slice(1));
711
+ if (hash) {
712
+ const m = /^(.+)-\d+$/.exec(hash);
713
+ if (m && state.projects.some((p) => p.key === m[1])) {
714
+ state.projectKey = m[1];
715
+ $select.value = m[1];
716
+ }
717
+ }
718
+ await refresh();
719
+ if (hash) openDrawer(hash);
720
+
721
+ setInterval(async () => {
722
+ try {
723
+ await loadProjects();
724
+ if (state.projectKey) $select.value = state.projectKey;
725
+ await refresh();
726
+ } catch (err) {
727
+ console.warn('poll failed', err);
728
+ }
729
+ }, POLL_MS);
730
+ }
731
+
732
+ boot();
733
+ })();