@unbrained/pm-web 2026.9.18 → 2026.9.26

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 (126) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +1 -4
  3. package/dist/app.d.ts +10 -0
  4. package/dist/app.js +19 -9
  5. package/dist/app.js.map +1 -1
  6. package/dist/index.js +1 -1
  7. package/dist/routes/admin.js +6 -6
  8. package/dist/routes/admin.js.map +1 -1
  9. package/dist/routes/auth.js +2 -2
  10. package/dist/routes/auth.js.map +1 -1
  11. package/dist/routes/extensions.js +24 -37
  12. package/dist/routes/extensions.js.map +1 -1
  13. package/dist/routes/github.js +169 -107
  14. package/dist/routes/github.js.map +1 -1
  15. package/dist/routes/groups.js +9 -14
  16. package/dist/routes/groups.js.map +1 -1
  17. package/dist/routes/pm.js +532 -1015
  18. package/dist/routes/pm.js.map +1 -1
  19. package/dist/routes/projects.js +10 -6
  20. package/dist/routes/projects.js.map +1 -1
  21. package/dist/routes/route-helpers.d.ts +74 -0
  22. package/dist/routes/route-helpers.js +85 -0
  23. package/dist/routes/route-helpers.js.map +1 -0
  24. package/dist/routes/sharing.js +30 -18
  25. package/dist/routes/sharing.js.map +1 -1
  26. package/dist/server.js +1 -20
  27. package/dist/server.js.map +1 -1
  28. package/dist/services/mutation-event-watcher.js +3 -28
  29. package/dist/services/mutation-event-watcher.js.map +1 -1
  30. package/dist/services/pm-runner.js +3 -1
  31. package/dist/services/pm-runner.js.map +1 -1
  32. package/dist/services/project-watcher.js +14 -38
  33. package/dist/services/project-watcher.js.map +1 -1
  34. package/dist/services/watcher-utils.d.ts +42 -0
  35. package/dist/services/watcher-utils.js +60 -0
  36. package/dist/services/watcher-utils.js.map +1 -0
  37. package/manifest.json +2 -2
  38. package/package.json +17 -6
  39. package/public/src/app.js +22 -52
  40. package/public/src/app.js.map +1 -1
  41. package/public/src/app.ts +18 -75
  42. package/public/src/browser-window.js +29 -0
  43. package/public/src/browser-window.js.map +1 -0
  44. package/public/src/browser-window.ts +56 -0
  45. package/public/src/components/modals.js +1 -1
  46. package/public/src/components/modals.js.map +1 -1
  47. package/public/src/components/modals.ts +1 -1
  48. package/public/src/i18n.js +14 -6
  49. package/public/src/i18n.js.map +1 -1
  50. package/public/src/i18n.ts +16 -6
  51. package/public/src/sw.ts +1 -0
  52. package/public/src/utils.js +58 -0
  53. package/public/src/utils.js.map +1 -1
  54. package/public/src/utils.ts +53 -0
  55. package/public/src/views/admin.js +2 -5
  56. package/public/src/views/admin.js.map +1 -1
  57. package/public/src/views/admin.ts +2 -2
  58. package/public/src/views/auth.js +5 -1
  59. package/public/src/views/auth.js.map +1 -1
  60. package/public/src/views/auth.ts +6 -1
  61. package/public/src/views/calendar.js +1 -2
  62. package/public/src/views/calendar.js.map +1 -1
  63. package/public/src/views/calendar.ts +1 -2
  64. package/public/src/views/config.js +14 -21
  65. package/public/src/views/config.js.map +1 -1
  66. package/public/src/views/config.ts +14 -19
  67. package/public/src/views/context.js +11 -12
  68. package/public/src/views/context.js.map +1 -1
  69. package/public/src/views/context.ts +13 -12
  70. package/public/src/views/create.js +3 -11
  71. package/public/src/views/create.js.map +1 -1
  72. package/public/src/views/create.ts +3 -3
  73. package/public/src/views/export.js +2 -3
  74. package/public/src/views/export.js.map +1 -1
  75. package/public/src/views/export.ts +2 -3
  76. package/public/src/views/github.js +33 -47
  77. package/public/src/views/github.js.map +1 -1
  78. package/public/src/views/github.ts +35 -28
  79. package/public/src/views/graph-canvas.js +92 -99
  80. package/public/src/views/graph-canvas.js.map +1 -1
  81. package/public/src/views/graph-canvas.ts +94 -93
  82. package/public/src/views/graph.js +88 -73
  83. package/public/src/views/graph.js.map +1 -1
  84. package/public/src/views/graph.ts +97 -63
  85. package/public/src/views/groups.js +2 -5
  86. package/public/src/views/groups.js.map +1 -1
  87. package/public/src/views/groups.ts +2 -2
  88. package/public/src/views/guide.js +11 -10
  89. package/public/src/views/guide.js.map +1 -1
  90. package/public/src/views/guide.ts +12 -10
  91. package/public/src/views/items.js +100 -130
  92. package/public/src/views/items.js.map +1 -1
  93. package/public/src/views/items.ts +99 -117
  94. package/public/src/views/normalize.js +2 -9
  95. package/public/src/views/normalize.js.map +1 -1
  96. package/public/src/views/normalize.ts +2 -9
  97. package/public/src/views/packages.js +7 -4
  98. package/public/src/views/packages.js.map +1 -1
  99. package/public/src/views/packages.ts +8 -4
  100. package/public/src/views/plan-execution.js.map +1 -1
  101. package/public/src/views/plan-execution.ts +5 -15
  102. package/public/src/views/plan.js +2 -1
  103. package/public/src/views/plan.js.map +1 -1
  104. package/public/src/views/plan.ts +6 -17
  105. package/public/src/views/projects.js +5 -6
  106. package/public/src/views/projects.js.map +1 -1
  107. package/public/src/views/projects.ts +5 -6
  108. package/public/src/views/search.js +5 -1
  109. package/public/src/views/search.js.map +1 -1
  110. package/public/src/views/search.ts +7 -1
  111. package/public/src/views/settings.js +30 -55
  112. package/public/src/views/settings.js.map +1 -1
  113. package/public/src/views/settings.ts +23 -17
  114. package/public/src/views/shared.js +2 -5
  115. package/public/src/views/shared.js.map +1 -1
  116. package/public/src/views/shared.ts +2 -5
  117. package/public/src/views/sharing.js +2 -5
  118. package/public/src/views/sharing.js.map +1 -1
  119. package/public/src/views/sharing.ts +2 -2
  120. package/public/src/views/templates.js +9 -16
  121. package/public/src/views/templates.js.map +1 -1
  122. package/public/src/views/templates.ts +9 -14
  123. package/public/src/views/validate.js +2 -9
  124. package/public/src/views/validate.js.map +1 -1
  125. package/public/src/views/validate.ts +2 -9
  126. package/public/sw.js +1 -0
@@ -3,7 +3,7 @@
3
3
  // ═══════════════════════════════════════════════════════════════
4
4
  import { state } from '../state.js';
5
5
  import { api } from '../api.js';
6
- import { escHtml, statusBadge, priorityDot, typeIcon } from '../utils.js';
6
+ import { escHtml, statusBadge, priorityDot, typeIcon, relTime, fmtDate, setFormValue } from '../utils.js';
7
7
  import { showModal, hideModal, createModal, confirmDialog } from '../components/modals.js';
8
8
  import { toast } from '../components/toast.js';
9
9
  import { getTypes, getStatuses, PRIORITY_LABELS } from '../constants.js';
@@ -11,11 +11,71 @@ import { showView } from './router.js';
11
11
  import { loadItemsBadge } from './projects.js';
12
12
  import { renderLocalGraph, destroyLocalGraph } from './graph.js';
13
13
  import { EMPTY_FILTERS, filtersToQueryString, filtersFromSearchParams, hasActiveFilters } from '../filters.js';
14
+ /** Store the fetched item list outside the async loader. */
15
+ function rememberItems(items) {
16
+ state.items = items;
17
+ }
14
18
  // Stash the bulk-update / bulk-close payloads on the apply buttons without
15
19
  // mutating the DOM nodes. A module-scoped WeakMap per modal keeps the payload
16
20
  // alive across the Preview → Apply call pair and is GC-friendly.
17
21
  const bulkUpdatePayloads = new WeakMap();
18
22
  const bulkClosePayloads = new WeakMap();
23
+ // ─── Shared item-action helpers ───────────────────────────────
24
+ /** Toasts a standardized error message extracted from a caught error value. */
25
+ function toastError(err) {
26
+ toast(err instanceof Error ? err.message : String(err), 'error');
27
+ }
28
+ /** Performs a POST item action (start, pause, release) and refreshes the
29
+ * view, optionally reopening the detail modal and refreshing the badge. */
30
+ async function postItemAction(endpoint, itemId, successMsg, withBadge) {
31
+ try {
32
+ await api('POST', `/projects/${state.currentProject.id}/pm/${endpoint}/${itemId}`, {});
33
+ toast(successMsg, 'success');
34
+ openItemDetail(itemId);
35
+ if (state.currentView === 'items')
36
+ fetchAndRenderItems();
37
+ if (withBadge)
38
+ loadItemsBadge();
39
+ }
40
+ catch (err) {
41
+ toastError(err);
42
+ }
43
+ }
44
+ /** Refreshes the items view after a successful item action, optionally
45
+ * reopening the detail modal or hiding it first, and refreshing the badge. */
46
+ function refreshAfterItemAction(opts) {
47
+ toast(opts.toastMsg, 'success');
48
+ if (opts.hideDetail)
49
+ hideModal('item-detail-modal');
50
+ if (opts.reopenDetail)
51
+ openItemDetail(opts.reopenDetail);
52
+ if (state.currentView === 'items')
53
+ fetchAndRenderItems();
54
+ if (opts.withBadge)
55
+ loadItemsBadge();
56
+ }
57
+ /** Renders the "no items match" preview placeholder and disables the apply
58
+ * button. */
59
+ function renderNoMatchPreview(previewEl, applyBtn) {
60
+ previewEl.innerHTML = `<div style="color:var(--text-muted);font-size:13px;padding:10px 0">No items match the filter criteria.</div>`;
61
+ if (applyBtn)
62
+ applyBtn.disabled = true;
63
+ }
64
+ /** Toasts the result of a bulk operation, noting failures when present. */
65
+ function toastBulkResult(action, count, failed) {
66
+ if (failed > 0) {
67
+ toast(`${action} ${count} item${count !== 1 ? 's' : ''} (${failed} failed)`, 'info');
68
+ }
69
+ else {
70
+ toast(`${action} ${count} item${count !== 1 ? 's' : ''}`, 'success');
71
+ }
72
+ }
73
+ /** Renders a preview list of matched items with their changes, capped at 8
74
+ * entries with an overflow note. */
75
+ function renderMatchedPreview(matched, displayCount) {
76
+ return `${matched.slice(0, 8).map((it) => `<div style="color:var(--text-secondary);font-size:12px">· ${escHtml(it.id)}${it.changes?.length ? ` — ${escHtml(it.changes.map((c) => `${c.field}: ${String(c.before)} → ${String(c.after)}`).join(', '))}` : ''}</div>`).join('')}
77
+ ${displayCount > 8 ? `<div style="color:var(--text-muted);font-size:12px;margin-top:4px">… and ${displayCount - 8} more</div>` : ''}`;
78
+ }
19
79
  /**
20
80
  * Mirrors the current item filters into the page URL query string when the
21
81
  * items view is active, using history.replaceState so the filtered view is
@@ -261,17 +321,14 @@ export async function previewBulkUpdate() {
261
321
  updateParts.push(`assignee → ${uAssignee}`);
262
322
  const updateDesc = updateParts.map(p => `<strong>${escHtml(p)}</strong>`).join(', ');
263
323
  if (count === 0 && matched.length === 0) {
264
- previewEl.innerHTML = `<div style="color:var(--text-muted);font-size:13px;padding:10px 0">No items match the filter criteria.</div>`;
265
- if (applyBtn)
266
- applyBtn.disabled = true;
324
+ renderNoMatchPreview(previewEl, applyBtn);
267
325
  }
268
326
  else {
269
327
  const displayCount = count || matched.length;
270
328
  previewEl.innerHTML = `
271
329
  <div style="background:rgba(99,102,241,0.08);border:1px solid rgba(99,102,241,0.25);border-radius:var(--radius);padding:10px 14px;font-size:13px">
272
330
  <div style="margin-bottom:8px">Will update <strong>${displayCount}</strong> item${displayCount !== 1 ? 's' : ''}: ${updateDesc}</div>
273
- ${matched.slice(0, 8).map((it) => `<div style="color:var(--text-secondary);font-size:12px">· ${escHtml(it.id)}${it.changes?.length ? ` — ${escHtml(it.changes.map((c) => `${c.field}: ${String(c.before)} → ${String(c.after)}`).join(', '))}` : ''}</div>`).join('')}
274
- ${displayCount > 8 ? `<div style="color:var(--text-muted);font-size:12px;margin-top:4px">… and ${displayCount - 8} more</div>` : ''}
331
+ ${renderMatchedPreview(matched, displayCount)}
275
332
  </div>`;
276
333
  if (applyBtn)
277
334
  applyBtn.disabled = false;
@@ -312,19 +369,14 @@ export async function applyBulkUpdate() {
312
369
  const data = await api('POST', `/projects/${pid}/pm/update-many`, payload);
313
370
  const updated = data.updated_count ?? 'some';
314
371
  const failed = data.failed_count ?? 0;
315
- if (failed > 0) {
316
- toast(`Updated ${updated} item${updated !== 1 ? 's' : ''} (${failed} failed)`, 'info');
317
- }
318
- else {
319
- toast(`Updated ${updated} item${updated !== 1 ? 's' : ''}`, 'success');
320
- }
372
+ toastBulkResult('Updated', updated, failed);
321
373
  hideModal('bulk-update-modal');
322
374
  if (state.currentView === 'items')
323
375
  fetchAndRenderItems();
324
376
  loadItemsBadge();
325
377
  }
326
378
  catch (err) {
327
- toast(err instanceof Error ? err.message : String(err), 'error');
379
+ toastError(err);
328
380
  if (applyBtn) {
329
381
  applyBtn.disabled = false;
330
382
  applyBtn.textContent = 'Apply Update';
@@ -447,17 +499,14 @@ export async function previewBulkClose() {
447
499
  const applyBtn = document.getElementById('bc-apply-btn');
448
500
  if (previewEl) {
449
501
  if (count === 0 && matched.length === 0) {
450
- previewEl.innerHTML = `<div style="color:var(--text-muted);font-size:13px;padding:10px 0">No items match the filter criteria.</div>`;
451
- if (applyBtn)
452
- applyBtn.disabled = true;
502
+ renderNoMatchPreview(previewEl, applyBtn);
453
503
  }
454
504
  else {
455
505
  const displayCount = count || matched.length;
456
506
  previewEl.innerHTML = `
457
507
  <div style="background:rgba(248,113,113,0.08);border:1px solid rgba(248,113,113,0.25);border-radius:var(--radius);padding:10px 14px;font-size:13px">
458
508
  <div style="margin-bottom:8px">Will <strong>${targetStatus}</strong> <strong>${displayCount}</strong> item${displayCount !== 1 ? 's' : ''}. Reason: <em>${escHtml(reason)}</em></div>
459
- ${matched.slice(0, 8).map((it) => `<div style="color:var(--text-secondary);font-size:12px">· ${escHtml(it.id)}${it.changes?.length ? ` — ${escHtml(it.changes.map((c) => `${c.field}: ${String(c.before)} → ${String(c.after)}`).join(', '))}` : ''}</div>`).join('')}
460
- ${displayCount > 8 ? `<div style="color:var(--text-muted);font-size:12px;margin-top:4px">… and ${displayCount - 8} more</div>` : ''}
509
+ ${renderMatchedPreview(matched, displayCount)}
461
510
  </div>`;
462
511
  if (applyBtn) {
463
512
  applyBtn.disabled = false;
@@ -510,19 +559,14 @@ export async function applyBulkClose() {
510
559
  const data = await api('POST', `/projects/${pid}/pm/close-many`, payload);
511
560
  const closed = data.closed_count ?? 'some';
512
561
  const failed = data.failed_count ?? 0;
513
- if (failed > 0) {
514
- toast(`Closed ${closed} item${closed !== 1 ? 's' : ''} (${failed} failed)`, 'info');
515
- }
516
- else {
517
- toast(`Closed ${closed} item${closed !== 1 ? 's' : ''}`, 'success');
518
- }
562
+ toastBulkResult('Closed', closed, failed);
519
563
  hideModal('bulk-close-modal');
520
564
  if (state.currentView === 'items')
521
565
  fetchAndRenderItems();
522
566
  loadItemsBadge();
523
567
  }
524
568
  catch (err) {
525
- toast(err instanceof Error ? err.message : String(err), 'error');
569
+ toastError(err);
526
570
  if (applyBtn) {
527
571
  applyBtn.disabled = false;
528
572
  applyBtn.textContent = 'Close Items';
@@ -572,7 +616,7 @@ export async function renderItemsView() {
572
616
  </select>
573
617
  <select class="filter-select" id="filter-priority" onchange="window.__app.applyItemFilters()">
574
618
  <option value="">All Priorities</option>
575
- ${[0, 1, 2, 3, 4].map(p => `<option value="${p}"${state.itemFilters.priority == String(p) ? ' selected' : ''}>P${p}: ${PRIORITY_LABELS[p]}</option>`).join('')}
619
+ ${[0, 1, 2, 3, 4].map(p => `<option value="${p}"${state.itemFilters.priority === String(p) ? ' selected' : ''}>P${p}: ${PRIORITY_LABELS[p]}</option>`).join('')}
576
620
  </select>
577
621
  <input class="filter-select" id="filter-sprint" type="text" placeholder="Sprint…" value="${escHtml(state.itemFilters.sprint)}" oninput="window.__app.applyItemFilters()" style="width:100px">
578
622
  <input class="filter-select" id="filter-release" type="text" placeholder="Release…" value="${escHtml(state.itemFilters.release)}" oninput="window.__app.applyItemFilters()" style="width:100px">
@@ -618,7 +662,7 @@ export async function fetchAndRenderItems() {
618
662
  if (tag) {
619
663
  items = items.filter((i) => (i.tags || []).some((t) => t.toLowerCase() === tag));
620
664
  }
621
- state.items = items;
665
+ rememberItems(items);
622
666
  const sub = document.getElementById('items-subtitle');
623
667
  if (sub)
624
668
  sub.textContent = `${state.items.length} item${state.items.length !== 1 ? 's' : ''}`;
@@ -889,7 +933,7 @@ export async function openItemDetail(itemId) {
889
933
  <div class="form-group">
890
934
  <label class="form-label">Priority</label>
891
935
  <select class="form-select" id="edit-priority">
892
- ${[0, 1, 2, 3, 4].map(p => `<option value="${p}"${item.priority == p ? ' selected' : ''}>P${p}: ${PRIORITY_LABELS[p]}</option>`).join('')}
936
+ ${[0, 1, 2, 3, 4].map(p => `<option value="${p}"${item.priority === p ? ' selected' : ''}>P${p}: ${PRIORITY_LABELS[p]}</option>`).join('')}
893
937
  </select>
894
938
  </div>
895
939
  </div>
@@ -1070,32 +1114,6 @@ export async function openItemDetail(itemId) {
1070
1114
  bodyEl.innerHTML = `<div class="empty-state"><div class="empty-state-text">Failed to load: ${escHtml(err instanceof Error ? err.message : String(err))}</div></div>`;
1071
1115
  }
1072
1116
  }
1073
- /**
1074
- * Formats a timestamp as a human-readable relative age string (just now,
1075
- * Nm/Nh/Nd ago) for recent times, or a localized date for anything older than
1076
- * a week. Returns an empty string when the timestamp is missing.
1077
- */
1078
- function relTime(ts) {
1079
- if (!ts)
1080
- return '';
1081
- const d = new Date(ts);
1082
- const diff = Date.now() - d.getTime();
1083
- const s = Math.floor(diff / 1000);
1084
- if (s < 60)
1085
- return 'just now';
1086
- if (s < 3600)
1087
- return `${Math.floor(s / 60)}m ago`;
1088
- if (s < 86400)
1089
- return `${Math.floor(s / 3600)}h ago`;
1090
- if (s < 604800)
1091
- return `${Math.floor(s / 86400)}d ago`;
1092
- return d.toLocaleDateString();
1093
- }
1094
- function fmtDate(ts) {
1095
- if (!ts)
1096
- return '';
1097
- return new Date(ts).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
1098
- }
1099
1117
  /**
1100
1118
  * Switches the item detail modal to the selected tab: toggles the active class
1101
1119
  * on the tabs, shows the target tab panel while hiding the others, tears down
@@ -1150,7 +1168,7 @@ export async function addComment(itemId) {
1150
1168
  openItemDetail(itemId);
1151
1169
  }
1152
1170
  catch (err) {
1153
- toast(err instanceof Error ? err.message : String(err), 'error');
1171
+ toastError(err);
1154
1172
  }
1155
1173
  }
1156
1174
  /**
@@ -1172,7 +1190,7 @@ export async function addNote(itemId) {
1172
1190
  el.value = '';
1173
1191
  }
1174
1192
  catch (err) {
1175
- toast(err instanceof Error ? err.message : String(err), 'error');
1193
+ toastError(err);
1176
1194
  }
1177
1195
  }
1178
1196
  /**
@@ -1195,7 +1213,7 @@ export async function appendItem(itemId) {
1195
1213
  el.value = '';
1196
1214
  }
1197
1215
  catch (err) {
1198
- toast(err instanceof Error ? err.message : String(err), 'error');
1216
+ toastError(err);
1199
1217
  }
1200
1218
  }
1201
1219
  /**
@@ -1253,14 +1271,10 @@ export async function updateItem(itemId) {
1253
1271
  if (blockedReason)
1254
1272
  payload.blockedReason = blockedReason;
1255
1273
  await api('PATCH', `/projects/${state.currentProject.id}/pm/update/${itemId}`, payload);
1256
- toast('Item updated', 'success');
1257
- openItemDetail(itemId);
1258
- if (state.currentView === 'items')
1259
- fetchAndRenderItems();
1260
- loadItemsBadge();
1274
+ refreshAfterItemAction({ toastMsg: 'Item updated', reopenDetail: itemId, withBadge: true });
1261
1275
  }
1262
1276
  catch (err) {
1263
- toast(err instanceof Error ? err.message : String(err), 'error');
1277
+ toastError(err);
1264
1278
  }
1265
1279
  }
1266
1280
  /**
@@ -1284,14 +1298,10 @@ export async function closeItem(itemId, targetStatus) {
1284
1298
  else {
1285
1299
  await api('POST', `/projects/${state.currentProject.id}/pm/close/${itemId}`, { reason });
1286
1300
  }
1287
- toast(`Item ${targetStatus}`, 'success');
1288
- hideModal('item-detail-modal');
1289
- if (state.currentView === 'items')
1290
- fetchAndRenderItems();
1291
- loadItemsBadge();
1301
+ refreshAfterItemAction({ toastMsg: `Item ${targetStatus}`, hideDetail: true, withBadge: true });
1292
1302
  }
1293
1303
  catch (err) {
1294
- toast(err instanceof Error ? err.message : String(err), 'error');
1304
+ toastError(err);
1295
1305
  }
1296
1306
  }
1297
1307
  /**
@@ -1302,14 +1312,10 @@ export function confirmDeleteItem(itemId) {
1302
1312
  confirmDialog('Delete Item?', 'This action cannot be undone. The item and all its data will be permanently removed.', async () => {
1303
1313
  try {
1304
1314
  await api('DELETE', `/projects/${state.currentProject.id}/pm/delete/${itemId}`);
1305
- toast('Item deleted', 'success');
1306
- hideModal('item-detail-modal');
1307
- if (state.currentView === 'items')
1308
- fetchAndRenderItems();
1309
- loadItemsBadge();
1315
+ refreshAfterItemAction({ toastMsg: 'Item deleted', hideDetail: true, withBadge: true });
1310
1316
  }
1311
1317
  catch (err) {
1312
- toast(err instanceof Error ? err.message : String(err), 'error');
1318
+ toastError(err);
1313
1319
  }
1314
1320
  }, true);
1315
1321
  }
@@ -1334,7 +1340,7 @@ export async function claimItem(itemId) {
1334
1340
  fetchAndRenderItems();
1335
1341
  }
1336
1342
  catch (err) {
1337
- toast(err instanceof Error ? err.message : String(err), 'error');
1343
+ toastError(err);
1338
1344
  if (row)
1339
1345
  row.style.opacity = '';
1340
1346
  }
@@ -1344,50 +1350,21 @@ export async function claimItem(itemId) {
1344
1350
  * reopens the detail and refreshes the items list.
1345
1351
  */
1346
1352
  export async function releaseItem(itemId) {
1347
- try {
1348
- await api('POST', `/projects/${state.currentProject.id}/pm/release/${itemId}`, {});
1349
- toast('Item released', 'success');
1350
- openItemDetail(itemId);
1351
- if (state.currentView === 'items')
1352
- fetchAndRenderItems();
1353
- }
1354
- catch (err) {
1355
- toast(err instanceof Error ? err.message : String(err), 'error');
1356
- }
1353
+ await postItemAction('release', itemId, 'Item released', false);
1357
1354
  }
1358
1355
  /**
1359
1356
  * Starts the item (moving it into in_progress) via the start-task endpoint,
1360
1357
  * then reopens the detail and refreshes the items list and project badge.
1361
1358
  */
1362
1359
  export async function startItem(itemId) {
1363
- try {
1364
- await api('POST', `/projects/${state.currentProject.id}/pm/start-task/${itemId}`, {});
1365
- toast('Item started', 'success');
1366
- openItemDetail(itemId);
1367
- if (state.currentView === 'items')
1368
- fetchAndRenderItems();
1369
- loadItemsBadge();
1370
- }
1371
- catch (err) {
1372
- toast(err instanceof Error ? err.message : String(err), 'error');
1373
- }
1360
+ await postItemAction('start-task', itemId, 'Item started', true);
1374
1361
  }
1375
1362
  /**
1376
1363
  * Pauses the in-progress item via the pause-task endpoint, then reopens the
1377
1364
  * detail and refreshes the items list and project badge.
1378
1365
  */
1379
1366
  export async function pauseItem(itemId) {
1380
- try {
1381
- await api('POST', `/projects/${state.currentProject.id}/pm/pause-task/${itemId}`, {});
1382
- toast('Item paused', 'success');
1383
- openItemDetail(itemId);
1384
- if (state.currentView === 'items')
1385
- fetchAndRenderItems();
1386
- loadItemsBadge();
1387
- }
1388
- catch (err) {
1389
- toast(err instanceof Error ? err.message : String(err), 'error');
1390
- }
1367
+ await postItemAction('pause-task', itemId, 'Item paused', true);
1391
1368
  }
1392
1369
  // ═══════════════════════════════════════════════════════════════
1393
1370
  // DEPS / LEARNINGS / TESTS / FILES
@@ -1417,7 +1394,7 @@ export async function addDep(itemId) {
1417
1394
  openItemDetail(itemId);
1418
1395
  }
1419
1396
  catch (err) {
1420
- toast(err instanceof Error ? err.message : String(err), 'error');
1397
+ toastError(err);
1421
1398
  }
1422
1399
  }
1423
1400
  /**
@@ -1438,7 +1415,7 @@ export async function removeDep(itemId, targetId, relation) {
1438
1415
  openItemDetail(itemId);
1439
1416
  }
1440
1417
  catch (err) {
1441
- toast(err instanceof Error ? err.message : String(err), 'error');
1418
+ toastError(err);
1442
1419
  }
1443
1420
  }, true);
1444
1421
  }
@@ -1460,7 +1437,7 @@ export async function addLearning(itemId) {
1460
1437
  openItemDetail(itemId);
1461
1438
  }
1462
1439
  catch (err) {
1463
- toast(err instanceof Error ? err.message : String(err), 'error');
1440
+ toastError(err);
1464
1441
  }
1465
1442
  }
1466
1443
  /**
@@ -1483,7 +1460,7 @@ export async function addTest(itemId) {
1483
1460
  openItemDetail(itemId);
1484
1461
  }
1485
1462
  catch (err) {
1486
- toast(err instanceof Error ? err.message : String(err), 'error');
1463
+ toastError(err);
1487
1464
  }
1488
1465
  }
1489
1466
  /**
@@ -1503,7 +1480,7 @@ export async function addFileLink(itemId) {
1503
1480
  openItemDetail(itemId);
1504
1481
  }
1505
1482
  catch (err) {
1506
- toast(err instanceof Error ? err.message : String(err), 'error');
1483
+ toastError(err);
1507
1484
  }
1508
1485
  }
1509
1486
  // ═══════════════════════════════════════════════════════════════
@@ -1521,29 +1498,22 @@ export function useItemAsTemplate(item) {
1521
1498
  showView('create');
1522
1499
  // Give the create view time to render, then fill fields
1523
1500
  setTimeout(() => {
1524
- const setVal = (id, val) => {
1525
- if (!val)
1526
- return;
1527
- const el = document.getElementById(id);
1528
- if (el)
1529
- el.value = val;
1530
- };
1531
1501
  // Pre-fill create form from item fields (title gets "Copy of …" prefix)
1532
1502
  const origTitle = String(item['title'] || '');
1533
- setVal('ci-title', origTitle ? `Copy of ${origTitle}` : '');
1534
- setVal('ci-type', String(item['type'] || ''));
1535
- setVal('ci-priority', String(item['priority'] || ''));
1503
+ setFormValue('ci-title', origTitle ? `Copy of ${origTitle}` : '');
1504
+ setFormValue('ci-type', String(item['type'] || ''));
1505
+ setFormValue('ci-priority', String(item['priority'] || ''));
1536
1506
  const tags = Array.isArray(item['tags']) ? item['tags'].join(', ') : String(item['tags'] || '');
1537
- setVal('ci-tags', tags);
1538
- setVal('ci-desc', String(item['description'] || ''));
1539
- setVal('ci-sprint', String(item['sprint'] || ''));
1540
- setVal('ci-release', String(item['release'] || ''));
1541
- setVal('ci-assignee', String(item['assignee'] || ''));
1507
+ setFormValue('ci-tags', tags);
1508
+ setFormValue('ci-desc', String(item['description'] || ''));
1509
+ setFormValue('ci-sprint', String(item['sprint'] || ''));
1510
+ setFormValue('ci-release', String(item['release'] || ''));
1511
+ setFormValue('ci-assignee', String(item['assignee'] || ''));
1542
1512
  if (item['acceptance_criteria'] || item['acceptanceCriteria']) {
1543
- setVal('ci-acceptance-criteria', String(item['acceptance_criteria'] || item['acceptanceCriteria'] || ''));
1513
+ setFormValue('ci-acceptance-criteria', String(item['acceptance_criteria'] || item['acceptanceCriteria'] || ''));
1544
1514
  }
1545
1515
  if (item['body']) {
1546
- setVal('ci-body', String(item['body']));
1516
+ setFormValue('ci-body', String(item['body']));
1547
1517
  }
1548
1518
  document.getElementById('ci-title')?.focus();
1549
1519
  // Select all title text so user can immediately replace or refine