| 1 |
|
| 2 |
* @fileoverview Problems inbox — the triage surface between external sources |
| 3 |
* and the task list. |
| 4 |
* |
| 5 |
* GoingsOn is the list of solutions. A problem is a *candidate* pulled from |
| 6 |
* somewhere else (wam tickets, /audit and /fuzz findings) that becomes work only |
| 7 |
* when promoted. Nothing here creates a problem: they arrive from adapters and |
| 8 |
* from go-mcp's `report_problems`, because a hand-typed problem would have no |
| 9 |
* source to reconcile against on the next pull. |
| 10 |
* |
| 11 |
* Rows are ranked by painhours, a 0-100 score computed from pain, scale, and |
| 12 |
* age. It is not stored, so an untriaged problem climbs on its own between one |
| 13 |
* visit and the next — that is the anti-starvation mechanism, and it is why the |
| 14 |
* list is worth revisiting rather than draining once. |
| 15 |
|
| 16 |
|
| 17 |
(function () { |
| 18 |
'use strict'; |
| 19 |
const esc = GoingsOn.utils.escapeHtml; |
| 20 |
const escAttr = GoingsOn.utils.escapeAttrValue; |
| 21 |
|
| 22 |
|
| 23 |
* is a working view, not a place you deep-link into. |
| 24 |
let filters = { status: 'Open', source: null }; |
| 25 |
|
| 26 |
|
| 27 |
let rows = []; |
| 28 |
|
| 29 |
|
| 30 |
function bandColor(band) { |
| 31 |
switch (band) { |
| 32 |
case 'critical': return 'red'; |
| 33 |
case 'high': return 'yellow'; |
| 34 |
case 'medium': return 'blue'; |
| 35 |
default: return 'muted'; |
| 36 |
} |
| 37 |
} |
| 38 |
|
| 39 |
function statusColor(status) { |
| 40 |
switch (status) { |
| 41 |
case 'Promoted': return 'green'; |
| 42 |
case 'Dismissed': return 'muted'; |
| 43 |
case 'Resolved': return 'cyan'; |
| 44 |
default: return 'purple'; |
| 45 |
} |
| 46 |
} |
| 47 |
|
| 48 |
|
| 49 |
* Load the inbox and render it. |
| 50 |
|
| 51 |
async function load() { |
| 52 |
const grid = document.getElementById('problems-list'); |
| 53 |
if (!grid) return; |
| 54 |
|
| 55 |
try { |
| 56 |
rows = await GoingsOn.api.problems.list({ |
| 57 |
status: filters.status, |
| 58 |
source: filters.source, |
| 59 |
}); |
| 60 |
} catch (err) { |
| 61 |
console.error('[problems] load failed:', err); |
| 62 |
grid.innerHTML = `<div class="empty-state"><p class="empty-state-text">Could not load problems.</p></div>`; |
| 63 |
return; |
| 64 |
} |
| 65 |
|
| 66 |
render(grid); |
| 67 |
renderSourceFilter(); |
| 68 |
await refreshCount(); |
| 69 |
} |
| 70 |
|
| 71 |
|
| 72 |
* Refresh the count badge on the Problems pill. |
| 73 |
* |
| 74 |
* Always the Open count regardless of the current filter: the badge answers |
| 75 |
* "is there anything waiting for me", which does not change because you are |
| 76 |
* currently looking at dismissed items. |
| 77 |
|
| 78 |
async function refreshCount() { |
| 79 |
const badge = document.getElementById('problems-count'); |
| 80 |
if (!badge) return; |
| 81 |
try { |
| 82 |
const n = await GoingsOn.api.problems.countOpen(); |
| 83 |
badge.textContent = n > 0 ? String(n) : ''; |
| 84 |
badge.classList.toggle('hidden', n === 0); |
| 85 |
} catch (err) { |
| 86 |
console.error('[problems] count failed:', err); |
| 87 |
} |
| 88 |
} |
| 89 |
|
| 90 |
function render(grid) { |
| 91 |
if (!rows.length) { |
| 92 |
const what = filters.status === 'Open' |
| 93 |
? 'Nothing waiting for triage.' |
| 94 |
: `No ${filters.status.toLowerCase()} problems.`; |
| 95 |
grid.innerHTML = `<div class="empty-state">` |
| 96 |
+ `<p class="empty-state-text">${esc(what)}</p>` |
| 97 |
+ `<p class="empty-state-text empty-state-text--muted">Problems arrive from wam and from audit runs. They are candidates: promote one to make it a task.</p>` |
| 98 |
+ `</div>`; |
| 99 |
return; |
| 100 |
} |
| 101 |
|
| 102 |
grid.innerHTML = rows.map(rowHtml).join(''); |
| 103 |
} |
| 104 |
|
| 105 |
function rowHtml(p) { |
| 106 |
const settled = p.status !== 'Open'; |
| 107 |
const tags = (p.tags || []).map(t => |
| 108 |
`<span class="tag badge--xs" data-color="muted">${esc(t)}</span>`).join(''); |
| 109 |
|
| 110 |
|
| 111 |
|
| 112 |
return ` |
| 113 |
<div class="problem-row${settled ? ' problem-row--settled' : ''}" data-problem-id="${escAttr(p.id)}"> |
| 114 |
<div class="problem-score"> |
| 115 |
<span class="badge badge--filled" data-color="${bandColor(p.band)}" title="painhours: pain ${p.pain} x scale ${p.scale}, aged ${esc(p.age)}">${p.painhours}</span> |
| 116 |
</div> |
| 117 |
<div class="problem-main"> |
| 118 |
<div class="problem-title">${esc(p.title)}</div> |
| 119 |
${p.body ? `<div class="problem-body">${esc(p.body)}</div>` : ''} |
| 120 |
<div class="problem-meta"> |
| 121 |
<span class="badge badge--xs" data-color="cyan">${esc(p.source)}</span> |
| 122 |
${p.projectName ? `<span class="badge badge--xs" data-color="blue">${esc(p.projectName)}</span>` : ''} |
| 123 |
${settled ? `<span class="badge badge--xs" data-color="${statusColor(p.status)}">${esc(p.status)}</span>` : ''} |
| 124 |
<span class="problem-age" title="${escAttr(p.sourceRef)}">${esc(p.age)}</span> |
| 125 |
${tags} |
| 126 |
</div> |
| 127 |
</div> |
| 128 |
<div class="problem-actions"> |
| 129 |
${actionsHtml(p)} |
| 130 |
</div> |
| 131 |
</div>`; |
| 132 |
} |
| 133 |
|
| 134 |
function actionsHtml(p) { |
| 135 |
if (p.status === 'Promoted') { |
| 136 |
return `<button class="btn btn-sm btn-secondary" data-act="problems.openTask" data-a1="${escAttr(p.promotedTaskId)}">Open task</button>` |
| 137 |
+ `<button class="btn btn-sm btn-secondary" data-act="problems.reopen" data-a1="${escAttr(p.id)}" title="Send back to triage and unlink the task">Reopen</button>`; |
| 138 |
} |
| 139 |
if (p.status === 'Open') { |
| 140 |
return `<button class="btn btn-sm btn-primary" data-act="problems.promote" data-a1="${escAttr(p.id)}" title="Create a task from this problem">Promote</button>` |
| 141 |
+ `<button class="btn btn-sm btn-secondary" data-act="problems.dismiss" data-a1="${escAttr(p.id)}" title="Seen it, not acting. Survives the next pull.">Dismiss</button>`; |
| 142 |
} |
| 143 |
|
| 144 |
return `<button class="btn btn-sm btn-secondary" data-act="problems.reopen" data-a1="${escAttr(p.id)}">Reopen</button>`; |
| 145 |
} |
| 146 |
|
| 147 |
|
| 148 |
function renderSourceFilter() { |
| 149 |
const sel = document.getElementById('problems-source-filter'); |
| 150 |
if (!sel) return; |
| 151 |
const sources = [...new Set(rows.map(r => r.source))].sort(); |
| 152 |
|
| 153 |
|
| 154 |
if (filters.source && !sources.includes(filters.source)) sources.push(filters.source); |
| 155 |
sel.innerHTML = `<option value="">All sources</option>` |
| 156 |
+ sources.map(s => `<option value="${escAttr(s)}"${s === filters.source ? ' selected' : ''}>${esc(s)}</option>`).join(''); |
| 157 |
} |
| 158 |
|
| 159 |
|
| 160 |
|
| 161 |
async function setStatusFilter(status) { |
| 162 |
filters.status = status || 'Open'; |
| 163 |
await load(); |
| 164 |
} |
| 165 |
|
| 166 |
async function setSourceFilter(source) { |
| 167 |
filters.source = source || null; |
| 168 |
await load(); |
| 169 |
} |
| 170 |
|
| 171 |
|
| 172 |
* Promote a problem into a task. |
| 173 |
* |
| 174 |
* Deliberately one click with no modal: the description defaults to the |
| 175 |
* problem's own text and the priority to its painhours band, both of which |
| 176 |
* are better defaults than anything retyped at triage time. Edit the task |
| 177 |
* afterwards if it needs shaping. |
| 178 |
|
| 179 |
async function promote(id) { |
| 180 |
try { |
| 181 |
const res = await GoingsOn.api.problems.promote(id); |
| 182 |
GoingsOn.ui.showToast( |
| 183 |
res.created ? 'Promoted to a task.' : 'Already promoted; opening the existing task.', |
| 184 |
'success', |
| 185 |
); |
| 186 |
await load(); |
| 187 |
} catch (err) { |
| 188 |
console.error('[problems] promote failed:', err); |
| 189 |
GoingsOn.ui.showToast('Could not promote this problem.', 'error'); |
| 190 |
} |
| 191 |
} |
| 192 |
|
| 193 |
async function dismiss(id) { |
| 194 |
await setStatus(id, 'Dismissed', 'Dismissed. It stays down through the next pull.'); |
| 195 |
} |
| 196 |
|
| 197 |
async function reopen(id) { |
| 198 |
await setStatus(id, 'Open', 'Back in triage.'); |
| 199 |
} |
| 200 |
|
| 201 |
async function setStatus(id, status, message) { |
| 202 |
try { |
| 203 |
await GoingsOn.api.problems.setStatus(id, status); |
| 204 |
GoingsOn.ui.showToast(message, 'success'); |
| 205 |
await load(); |
| 206 |
} catch (err) { |
| 207 |
console.error('[problems] status change failed:', err); |
| 208 |
GoingsOn.ui.showToast('Could not update this problem.', 'error'); |
| 209 |
} |
| 210 |
} |
| 211 |
|
| 212 |
|
| 213 |
function openTask(taskId) { |
| 214 |
if (!taskId) return; |
| 215 |
GoingsOn.navigation.switchView('tasks'); |
| 216 |
if (GoingsOn.tasks?.openSubtasks) GoingsOn.tasks.openSubtasks(taskId); |
| 217 |
} |
| 218 |
|
| 219 |
GoingsOn.problems = { |
| 220 |
load, |
| 221 |
refreshCount, |
| 222 |
setStatusFilter, |
| 223 |
setSourceFilter, |
| 224 |
promote, |
| 225 |
dismiss, |
| 226 |
reopen, |
| 227 |
openTask, |
| 228 |
}; |
| 229 |
})(); |
| 230 |
|