Skip to main content

max / goingson

8.9 KB · 230 lines History Blame Raw
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 /** Current filter state, kept in the module rather than the URL: the inbox
23 * is a working view, not a place you deep-link into. */
24 let filters = { status: 'Open', source: null };
25
26 /** Last loaded rows, so an action can act without a refetch. */
27 let rows = [];
28
29 /** Map a painhours band onto the shared badge palette. */
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 // The score leads the row: it is the reason this problem is where it is
111 // in the list, so it should be readable before the title.
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 // Dismissed or Resolved: the only move left is putting it back.
144 return `<button class="btn btn-sm btn-secondary" data-act="problems.reopen" data-a1="${escAttr(p.id)}">Reopen</button>`;
145 }
146
147 /** Populate the source filter from what is actually present. */
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 // Keep the current selection even when this page has none of that
153 // source, so filtering to an empty result doesn't silently reset.
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 // Actions
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 /** Jump to the task a problem was promoted into. */
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