/** * @fileoverview Problems inbox — the triage surface between external sources * and the task list. * * GoingsOn is the list of solutions. A problem is a *candidate* pulled from * somewhere else (wam tickets, /audit and /fuzz findings) that becomes work only * when promoted. Nothing here creates a problem: they arrive from adapters and * from go-mcp's `report_problems`, because a hand-typed problem would have no * source to reconcile against on the next pull. * * Rows are ranked by painhours, a 0-100 score computed from pain, scale, and * age. It is not stored, so an untriaged problem climbs on its own between one * visit and the next — that is the anti-starvation mechanism, and it is why the * list is worth revisiting rather than draining once. */ (function () { 'use strict'; const esc = GoingsOn.utils.escapeHtml; const escAttr = GoingsOn.utils.escapeAttrValue; /** Current filter state, kept in the module rather than the URL: the inbox * is a working view, not a place you deep-link into. */ let filters = { status: 'Open', source: null }; /** Last loaded rows, so an action can act without a refetch. */ let rows = []; /** Map a painhours band onto the shared badge palette. */ function bandColor(band) { switch (band) { case 'critical': return 'red'; case 'high': return 'yellow'; case 'medium': return 'blue'; default: return 'muted'; } } function statusColor(status) { switch (status) { case 'Promoted': return 'green'; case 'Dismissed': return 'muted'; case 'Resolved': return 'cyan'; default: return 'purple'; } } /** * Load the inbox and render it. */ async function load() { const grid = document.getElementById('problems-list'); if (!grid) return; try { rows = await GoingsOn.api.problems.list({ status: filters.status, source: filters.source, }); } catch (err) { console.error('[problems] load failed:', err); grid.innerHTML = `

Could not load problems.

`; return; } render(grid); renderSourceFilter(); await refreshCount(); } /** * Refresh the count badge on the Problems pill. * * Always the Open count regardless of the current filter: the badge answers * "is there anything waiting for me", which does not change because you are * currently looking at dismissed items. */ async function refreshCount() { const badge = document.getElementById('problems-count'); if (!badge) return; try { const n = await GoingsOn.api.problems.countOpen(); badge.textContent = n > 0 ? String(n) : ''; badge.classList.toggle('hidden', n === 0); } catch (err) { console.error('[problems] count failed:', err); } } function render(grid) { if (!rows.length) { const what = filters.status === 'Open' ? 'Nothing waiting for triage.' : `No ${filters.status.toLowerCase()} problems.`; grid.innerHTML = `
` + `

${esc(what)}

` + `

Problems arrive from wam and from audit runs. They are candidates: promote one to make it a task.

` + `
`; return; } grid.innerHTML = rows.map(rowHtml).join(''); } function rowHtml(p) { const settled = p.status !== 'Open'; const tags = (p.tags || []).map(t => `${esc(t)}`).join(''); // The score leads the row: it is the reason this problem is where it is // in the list, so it should be readable before the title. return `
${p.painhours}
${esc(p.title)}
${p.body ? `
${esc(p.body)}
` : ''}
${esc(p.source)} ${p.projectName ? `${esc(p.projectName)}` : ''} ${settled ? `${esc(p.status)}` : ''} ${esc(p.age)} ${tags}
${actionsHtml(p)}
`; } function actionsHtml(p) { if (p.status === 'Promoted') { return `` + ``; } if (p.status === 'Open') { return `` + ``; } // Dismissed or Resolved: the only move left is putting it back. return ``; } /** Populate the source filter from what is actually present. */ function renderSourceFilter() { const sel = document.getElementById('problems-source-filter'); if (!sel) return; const sources = [...new Set(rows.map(r => r.source))].sort(); // Keep the current selection even when this page has none of that // source, so filtering to an empty result doesn't silently reset. if (filters.source && !sources.includes(filters.source)) sources.push(filters.source); sel.innerHTML = `` + sources.map(s => ``).join(''); } // Actions async function setStatusFilter(status) { filters.status = status || 'Open'; await load(); } async function setSourceFilter(source) { filters.source = source || null; await load(); } /** * Promote a problem into a task. * * Deliberately one click with no modal: the description defaults to the * problem's own text and the priority to its painhours band, both of which * are better defaults than anything retyped at triage time. Edit the task * afterwards if it needs shaping. */ async function promote(id) { try { const res = await GoingsOn.api.problems.promote(id); GoingsOn.ui.showToast( res.created ? 'Promoted to a task.' : 'Already promoted; opening the existing task.', 'success', ); await load(); } catch (err) { console.error('[problems] promote failed:', err); GoingsOn.ui.showToast('Could not promote this problem.', 'error'); } } async function dismiss(id) { await setStatus(id, 'Dismissed', 'Dismissed. It stays down through the next pull.'); } async function reopen(id) { await setStatus(id, 'Open', 'Back in triage.'); } async function setStatus(id, status, message) { try { await GoingsOn.api.problems.setStatus(id, status); GoingsOn.ui.showToast(message, 'success'); await load(); } catch (err) { console.error('[problems] status change failed:', err); GoingsOn.ui.showToast('Could not update this problem.', 'error'); } } /** Jump to the task a problem was promoted into. */ function openTask(taskId) { if (!taskId) return; GoingsOn.navigation.switchView('tasks'); if (GoingsOn.tasks?.openSubtasks) GoingsOn.tasks.openSubtasks(taskId); } GoingsOn.problems = { load, refreshCount, setStatusFilter, setSourceFilter, promote, dismiss, reopen, openTask, }; })();