Skip to main content

max / goingson

Add the Problems inbox to the Work tab The triage surface: candidates from external sources ranked by painhours, with a one-click promote that creates the task and links it back. Sits beside Tasks and Projects because it is the input side of the task list, not a peer of it. Five Tauri commands, but deliberately no create_problem. Problems arrive from adapters and from go-mcp's report_problems; one typed in the UI would have no source to reconcile against, so a re-pull could neither update nor settle it. Promote takes no modal. The description defaults to the problem's own text and the priority to its painhours band, both better defaults than anything retyped at triage time, and the task is editable afterwards. Dismiss is offered ahead of delete because a deleted problem returns on the next pull while a dismissed one stays down. The score leads each row: it is the reason the row sits where it does, and it moves between visits, so the list rewards revisiting rather than draining once. Settled rows stay visible but recede. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-25 02:08 UTC
Commit: 52a737542505d137077bb58816947193cb2c3fe5
Parent: d80be8d
12 files changed, +908 insertions, -0 deletions
M Cargo.lock +1
@@ -2279,6 +2279,7 @@ dependencies = [
2279 2279 "notify",
2280 2280 "notify-debouncer-mini",
2281 2281 "open",
2282 + "painhours",
2282 2283 "pter",
2283 2284 "rand 0.10.2",
2284 2285 "reqwest",
@@ -18,6 +18,7 @@ makeover.workspace = true
18 18
19 19 [dependencies]
20 20 goingson-core = { workspace = true }
21 + painhours = { workspace = true }
21 22 goingson-db-sqlite = { workspace = true }
22 23 synckit-client = { path = "../../../synckit/synckit-client" }
23 24 synckit-config = { path = "../../../synckit/synckit-config" }
@@ -9876,3 +9876,120 @@ body.compose-window .compose-loading {
9876 9876 color: var(--content-secondary);
9877 9877 opacity: 0.7;
9878 9878 }
9879 +
9880 + /* Problems Inbox
9881 + Candidates pulled from external sources, ranked by painhours. The score leads
9882 + each row because it is the reason the row sits where it does. */
9883 +
9884 + .pill-count {
9885 + display: inline-block;
9886 + min-width: 1.25rem;
9887 + padding: 0 0.3rem;
9888 + margin-left: 0.3rem;
9889 + border-radius: var(--radius-xl);
9890 + background: var(--category-one);
9891 + color: var(--content-on-action);
9892 + font-size: var(--font-size-xxs);
9893 + font-weight: 700;
9894 + line-height: 1.4;
9895 + text-align: center;
9896 + }
9897 +
9898 + /* On the active (inverted) pill the red badge loses contrast, so flip it to the
9899 + pill's own background. */
9900 + .ui-mode-desktop .pill.active .pill-count {
9901 + background: var(--surface-raised);
9902 + color: var(--content);
9903 + }
9904 +
9905 + .view-hint {
9906 + margin: 0 0 var(--space-4);
9907 + font-size: var(--font-size-sm);
9908 + color: var(--content-secondary);
9909 + max-width: 70ch;
9910 + }
9911 +
9912 + .problems-list {
9913 + display: flex;
9914 + flex-direction: column;
9915 + gap: var(--space-2);
9916 + }
9917 +
9918 + .problem-row {
9919 + display: grid;
9920 + /* Fixed score gutter, elastic middle, actions sized to their buttons: the
9921 + score column must stay aligned down the list to be scannable as a ranking. */
9922 + grid-template-columns: 3.5rem minmax(0, 1fr) auto;
9923 + align-items: start;
9924 + gap: var(--space-3);
9925 + padding: var(--space-3) var(--space-4);
9926 + border: var(--border-width-sm) solid var(--border);
9927 + border-radius: var(--radius-md);
9928 + background: var(--surface-raised);
9929 + box-shadow: var(--shadow-brutal-xs);
9930 + }
9931 +
9932 + /* Settled problems stay visible but recede: they are history, not queue. */
9933 + .problem-row--settled {
9934 + opacity: 0.65;
9935 + box-shadow: none;
9936 + }
9937 +
9938 + .problem-score {
9939 + display: flex;
9940 + justify-content: center;
9941 + padding-top: 0.1rem;
9942 + }
9943 +
9944 + .problem-title {
9945 + font-weight: 600;
9946 + line-height: 1.35;
9947 + }
9948 +
9949 + .problem-body {
9950 + margin-top: var(--space-1);
9951 + font-size: var(--font-size-sm);
9952 + color: var(--content-secondary);
9953 + /* Findings bodies run long; two lines is enough to recognize one, and the
9954 + full text is in the task once promoted. */
9955 + display: -webkit-box;
9956 + -webkit-line-clamp: 2;
9957 + -webkit-box-orient: vertical;
9958 + overflow: hidden;
9959 + }
9960 +
9961 + .problem-meta {
9962 + display: flex;
9963 + flex-wrap: wrap;
9964 + align-items: center;
9965 + gap: var(--space-1);
9966 + margin-top: var(--space-2);
9967 + }
9968 +
9969 + .problem-age {
9970 + font-size: var(--font-size-xs);
9971 + color: var(--content-muted);
9972 + }
9973 +
9974 + .problem-actions {
9975 + display: flex;
9976 + flex-wrap: wrap;
9977 + gap: var(--space-1);
9978 + justify-content: flex-end;
9979 + }
9980 +
9981 + .empty-state-text--muted {
9982 + color: var(--content-muted);
9983 + font-size: var(--font-size-sm);
9984 + max-width: 55ch;
9985 + }
9986 +
9987 + /* Mobile: the actions wrap under the text rather than fighting it for width. */
9988 + .ui-mode-mobile .problem-row {
9989 + grid-template-columns: 3rem minmax(0, 1fr);
9990 + }
9991 +
9992 + .ui-mode-mobile .problem-actions {
9993 + grid-column: 1 / -1;
9994 + justify-content: flex-start;
9995 + }
@@ -66,6 +66,7 @@
66 66 <div class="pill-nav" role="tablist" aria-label="Work views">
67 67 <button class="pill active" data-subview="tasks">Tasks</button>
68 68 <button class="pill" data-subview="projects">Projects</button>
69 + <button class="pill" data-subview="problems" title="Candidates from wam and audit runs, ranked by painhours">Problems <span id="problems-count" class="pill-count hidden"></span></button>
69 70 </div>
70 71
71 72 <!-- Tasks Sub-View -->
@@ -188,6 +189,32 @@
188 189 </div>
189 190 </div>
190 191
192 + <!-- Problems Sub-View -->
193 + <div id="problems-view" class="subview hidden" role="tabpanel" aria-labelledby="problems-tab">
194 + <div class="page-header">
195 + <h2 class="page-title">Problems</h2>
196 + <div class="page-header-actions">
197 + <select id="problems-source-filter" class="form-select form-select--compact" aria-label="Filter by source" data-change="problems.setSourceFilter">
198 + <option value="">All sources</option>
199 + </select>
200 + <select id="problems-status-filter" class="form-select form-select--compact" aria-label="Filter by status" data-change="problems.setStatusFilter">
201 + <option value="Open" selected>Open</option>
202 + <option value="Promoted">Promoted</option>
203 + <option value="Dismissed">Dismissed</option>
204 + <option value="Resolved">Resolved</option>
205 + <option value="all">All</option>
206 + </select>
207 + </div>
208 + </div>
209 + <p class="view-hint">Candidates, not work. Ranked by painhours, so anything left untriaged climbs on its own. Promote the ones worth doing.</p>
210 + <div id="problems-list" class="problems-list">
211 + <div class="skeleton-shimmer" aria-label="Loading problems">
212 + <div class="skeleton-row"><div class="skeleton-lines"><div class="skeleton-line long"></div><div class="skeleton-line short"></div></div></div>
213 + <div class="skeleton-row"><div class="skeleton-lines"><div class="skeleton-line medium"></div><div class="skeleton-line long"></div></div></div>
214 + </div>
215 + </div>
216 + </div>
217 +
191 218 <!-- Project Dashboard Sub-View -->
192 219 <div id="project-dashboard-view" class="subview hidden">
193 220 <div class="page-header">
@@ -653,6 +680,7 @@
653 680 <script src="js/monthly-review-render.js"></script>
654 681 <!-- Domains -->
655 682 <script src="js/projects.js"></script>
683 + <script src="js/problems.js"></script>
656 684 <script src="js/tasks-filter.js"></script>
657 685 <script src="js/task-forms.js"></script>
658 686 <script src="js/task-board.js"></script>
@@ -88,6 +88,16 @@ const api = {
88 88 removeMember: (groupId, userId) => invoke('group_remove_member', { groupId, userId }),
89 89 },
90 90
91 + // Problems: the triage inbox. Read + triage only — problems arrive from
92 + // adapters and go-mcp's report_problems, never created here.
93 + problems: {
94 + list: (filter) => invoke('list_problems', { filter }), // Ranked by painhours; defaults to Open
95 + countOpen: () => invoke('count_open_problems'), // Pill badge
96 + promote: (id, input) => invoke('promote_problem', { id, input }), // Creates the task + backlink
97 + setStatus: (id, status) => invoke('set_problem_status', { id, status }),
98 + setProject: (id, projectId) => invoke('set_problem_project', { id, projectId }),
99 + },
100 +
91 101 // Tasks: CRUD + lifecycle (start/complete) + snooze/waiting status
92 102 tasks: {
93 103 list: () => invoke('list_tasks'),
@@ -11,6 +11,7 @@
11 11 const TAB_GROUPS = {
12 12 'tasks': 'work',
13 13 'projects': 'work',
14 + 'problems': 'work',
14 15 'project-dashboard': 'work',
15 16 'task-overview': 'work',
16 17 'day-plan': 'time',
@@ -230,6 +231,7 @@ async function updateWindowTitle(view) {
230 231 async function loadViewData(view) {
231 232 switch (view) {
232 233 case 'projects': await GoingsOn.projects.load(); break;
234 + case 'problems': await GoingsOn.problems.load(); break;
233 235 case 'tasks': await GoingsOn.tasks.load(); break;
234 236 case 'events': await GoingsOn.events.load(); break;
235 237 case 'emails': await GoingsOn.emails.load(); break;
@@ -0,0 +1,229 @@
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 + })();
@@ -35,6 +35,7 @@ mod milestone;
35 35 mod monthly_review;
36 36 mod oauth;
37 37 mod preferences;
38 + mod problem;
38 39 mod project;
39 40 mod saved_views;
40 41 mod search;
@@ -133,6 +134,7 @@ pub use monthly_review::*;
133 134 pub use oauth::*;
134 135 pub use preferences::load as load_preferences;
135 136 pub use preferences::*;
137 + pub use problem::*;
136 138 pub use project::*;
137 139 pub use saved_views::*;
138 140 pub use search::*;
@@ -0,0 +1,318 @@
1 + //! Problems inbox commands.
2 + //!
3 + //! A problem is a candidate for work pulled from an external source (wam
4 + //! tickets, `/audit` and `/fuzz` findings). GoingsOn is the list of solutions;
5 + //! these commands are the triage surface that turns a candidate into one.
6 + //!
7 + //! There is deliberately no `create_problem` here. Problems arrive from
8 + //! adapters and from go-mcp's `report_problems`, never by hand in the UI: a
9 + //! problem the user typed would have no source to reconcile against, so a
10 + //! re-pull could not update or settle it.
11 +
12 + use serde::{Deserialize, Serialize};
13 + use std::sync::Arc;
14 + use tauri::State;
15 + use tracing::instrument;
16 +
17 + use goingson_core::{
18 + DbValue, NewTask, Priority, Problem, ProblemFilter, ProblemId, ProblemStatus, ProjectId, TaskId,
19 + };
20 +
21 + use super::{ApiError, OptionNotFound};
22 + use crate::state::{AppState, DESKTOP_USER_ID};
23 +
24 + // Types
25 +
26 + #[derive(Debug, Default, Deserialize)]
27 + #[serde(rename_all = "camelCase")]
28 + pub struct ProblemFilterInput {
29 + pub source: Option<String>,
30 + /// `Open` | `Promoted` | `Dismissed` | `Resolved`, or `all` for every state.
31 + /// Absent means Open, since the inbox question is what is untriaged.
32 + pub status: Option<String>,
33 + pub project_id: Option<ProjectId>,
34 + }
35 +
36 + #[derive(Debug, Serialize)]
37 + #[serde(rename_all = "camelCase")]
38 + pub struct ProblemResponse {
39 + pub id: ProblemId,
40 + pub source: String,
41 + pub source_ref: String,
42 + pub title: String,
43 + pub body: String,
44 + pub pain: u8,
45 + pub scale: u8,
46 + /// The 0-100 urgency score. Computed on read, never stored: an untriaged
47 + /// problem climbs on its own, so this moves between one fetch and the next.
48 + pub painhours: u32,
49 + /// Color band derived from the score: low | medium | high | critical.
50 + pub band: String,
51 + /// Human-readable age, e.g. `3d`.
52 + pub age: String,
53 + pub status: String,
54 + pub project_id: Option<ProjectId>,
55 + pub project_name: Option<String>,
56 + pub tags: Vec<String>,
57 + pub promoted_task_id: Option<TaskId>,
58 + }
59 +
60 + #[derive(Debug, Deserialize)]
61 + #[serde(rename_all = "camelCase")]
62 + pub struct PromoteProblemInput {
63 + /// Task description. Defaults to the problem's title, with its body
64 + /// appended when there is one.
65 + pub description: Option<String>,
66 + /// `High` | `Medium` | `Low`. Defaults from the painhours band.
67 + pub priority: Option<String>,
68 + }
69 +
70 + #[derive(Debug, Serialize)]
71 + #[serde(rename_all = "camelCase")]
72 + pub struct PromoteProblemResponse {
73 + pub task_id: TaskId,
74 + pub problem: ProblemResponse,
75 + /// False when the problem was already promoted, in which case `task_id` is
76 + /// the task it already has.
77 + pub created: bool,
78 + }
79 +
80 + /// Project the domain type onto the wire shape, resolving the project name the
81 + /// frontend cannot compute for itself.
82 + ///
83 + /// `Problem::is_stale` is deliberately not surfaced yet: with no pull adapter,
84 + /// every problem's `last_seen_at` equals its source's newest, so the flag would
85 + /// always be false. It belongs here once the wam adapter lands.
86 + fn to_response(p: Problem, project_name: Option<String>) -> ProblemResponse {
87 + ProblemResponse {
88 + painhours: p.painhours(),
89 + band: p.band().to_string(),
90 + age: p.age(),
91 + status: p.status.db_value().to_string(),
92 + id: p.id,
93 + source: p.source,
94 + source_ref: p.source_ref,
95 + title: p.title,
96 + body: p.body,
97 + pain: p.pain,
98 + scale: p.scale,
99 + project_id: p.project_id,
100 + project_name,
101 + tags: p.tags,
102 + promoted_task_id: p.promoted_task_id,
103 + }
104 + }
105 +
106 + /// Parse the wire status word. `all` (or absent-as-`all`) is the caller's job to
107 + /// map to `None`; anything else must be a real variant, since silently falling
108 + /// back to `Open` would show a different list than the one asked for.
109 + fn parse_status(raw: &str) -> Result<ProblemStatus, ApiError> {
110 + raw.parse::<ProblemStatus>().map_err(|_| {
111 + ApiError::validation("status", "Expected Open, Promoted, Dismissed, or Resolved")
112 + })
113 + }
114 +
115 + // Commands
116 +
117 + /// Lists problems, most urgent first.
118 + #[tauri::command]
119 + #[instrument(skip_all)]
120 + pub async fn list_problems(
121 + state: State<'_, Arc<AppState>>,
122 + filter: Option<ProblemFilterInput>,
123 + ) -> Result<Vec<ProblemResponse>, ApiError> {
124 + let input = filter.unwrap_or_default();
125 +
126 + let status = match input.status.as_deref().map(str::trim) {
127 + Some(s) if s.eq_ignore_ascii_case("all") => None,
128 + Some("") => Some(ProblemStatus::Open),
129 + Some(s) => Some(parse_status(s)?),
130 + None => Some(ProblemStatus::Open),
131 + };
132 +
133 + let problems = state
134 + .problems
135 + .list(
136 + DESKTOP_USER_ID,
137 + &ProblemFilter {
138 + source: input.source,
139 + status,
140 + project_id: input.project_id,
141 + },
142 + )
143 + .await?;
144 +
145 + // One project lookup for the whole page rather than per row.
146 + let projects = state.projects.list_all(DESKTOP_USER_ID).await?;
147 + let name_of = |id: Option<ProjectId>| {
148 + id.and_then(|id| projects.iter().find(|p| p.id == id).map(|p| p.name.clone()))
149 + };
150 +
151 + Ok(problems
152 + .into_iter()
153 + .map(|p| {
154 + let name = name_of(p.project_id);
155 + to_response(p, name)
156 + })
157 + .collect())
158 + }
159 +
160 + /// Promotes a problem into a task, linking the two.
161 + ///
162 + /// The task is created before the backlink is written. The reverse order could
163 + /// leave a problem marked Promoted pointing at nothing, which nothing recovers
164 + /// from; this way a failure leaves a stray task and an Open problem, and
165 + /// retrying is safe.
166 + #[tauri::command]
167 + #[instrument(skip_all)]
168 + pub async fn promote_problem(
169 + state: State<'_, Arc<AppState>>,
170 + id: ProblemId,
171 + input: Option<PromoteProblemInput>,
172 + ) -> Result<PromoteProblemResponse, ApiError> {
173 + let input = input.unwrap_or(PromoteProblemInput {
174 + description: None,
175 + priority: None,
176 + });
177 +
178 + let problem = state
179 + .problems
180 + .get_by_id(id, DESKTOP_USER_ID)
181 + .await?
182 + .or_not_found("Problem", id)?;
183 +
184 + let projects = state.projects.list_all(DESKTOP_USER_ID).await?;
185 + let project_name = |pid: Option<ProjectId>| {
186 + pid.and_then(|pid| {
187 + projects
188 + .iter()
189 + .find(|p| p.id == pid)
190 + .map(|p| p.name.clone())
191 + })
192 + };
193 +
194 + // Idempotent: a retried promote reports the task it already made.
195 + if let Some(existing) = problem.promoted_task_id {
196 + let name = project_name(problem.project_id);
197 + return Ok(PromoteProblemResponse {
198 + task_id: existing,
199 + problem: to_response(problem, name),
200 + created: false,
201 + });
202 + }
203 +
204 + let description = match input.description.as_deref().map(str::trim) {
205 + Some(d) if !d.is_empty() => d.to_string(),
206 + _ if problem.body.trim().is_empty() => problem.title.clone(),
207 + _ => format!("{}\n\n{}", problem.title, problem.body),
208 + };
209 +
210 + // The band is the problem's own read on urgency, so it is the sensible
211 + // default. Critical and High both land on High: tasks have no fourth level.
212 + let priority = match input.priority.as_deref() {
213 + Some(p) if !p.trim().is_empty() => Priority::from_str_or_default(p),
214 + _ => match problem.band() {
215 + painhours::Priority::Critical | painhours::Priority::High => Priority::High,
216 + painhours::Priority::Medium => Priority::Medium,
217 + painhours::Priority::Low => Priority::Low,
218 + },
219 + };
220 +
221 + let mut tags = problem.tags.clone();
222 + tags.push(format!("problem:{}:{}", problem.source, problem.source_ref));
223 +
224 + let mut builder = NewTask::builder(description).priority(priority).tags(tags);
225 + if let Some(pid) = problem.project_id {
226 + builder = builder.project_id(pid);
227 + }
228 +
229 + let task = state.tasks.create(DESKTOP_USER_ID, builder.build()).await?;
230 +
231 + let promoted = state
232 + .problems
233 + .promote(id, DESKTOP_USER_ID, task.id)
234 + .await?
235 + .or_not_found("Problem", id)?;
236 +
237 + let name = project_name(promoted.project_id);
238 + Ok(PromoteProblemResponse {
239 + task_id: task.id,
240 + problem: to_response(promoted, name),
241 + created: true,
242 + })
243 + }
244 +
245 + /// Sets a problem's triage state.
246 + ///
247 + /// `Dismissed` is the "seen it, not acting" verdict and is the right way to
248 + /// clear something from the inbox: a deleted problem comes straight back on the
249 + /// next pull, while a dismissed one stays down. `Open` sends a settled problem
250 + /// back to triage, clearing any task backlink.
251 + #[tauri::command]
252 + #[instrument(skip_all)]
253 + pub async fn set_problem_status(
254 + state: State<'_, Arc<AppState>>,
255 + id: ProblemId,
256 + status: String,
257 + ) -> Result<ProblemResponse, ApiError> {
258 + let status = parse_status(&status)?;
259 +
260 + let updated = state
261 + .problems
262 + .set_status(id, DESKTOP_USER_ID, status)
263 + .await?
264 + .or_not_found("Problem", id)?;
265 +
266 + let projects = state.projects.list_all(DESKTOP_USER_ID).await?;
267 + let name = updated.project_id.and_then(|pid| {
268 + projects
269 + .iter()
270 + .find(|p| p.id == pid)
271 + .map(|p| p.name.clone())
272 + });
273 +
274 + Ok(to_response(updated, name))
275 + }
276 +
277 + /// Attributes a problem to a project, or clears the attribution when
278 + /// `project_id` is absent.
279 + #[tauri::command]
280 + #[instrument(skip_all)]
281 + pub async fn set_problem_project(
282 + state: State<'_, Arc<AppState>>,
283 + id: ProblemId,
284 + project_id: Option<ProjectId>,
285 + ) -> Result<ProblemResponse, ApiError> {
286 + let updated = state
287 + .problems
288 + .set_project(id, DESKTOP_USER_ID, project_id)
289 + .await?
290 + .or_not_found("Problem", id)?;
291 +
292 + let projects = state.projects.list_all(DESKTOP_USER_ID).await?;
293 + let name = updated.project_id.and_then(|pid| {
294 + projects
295 + .iter()
296 + .find(|p| p.id == pid)
297 + .map(|p| p.name.clone())
298 + });
299 +
300 + Ok(to_response(updated, name))
301 + }
302 +
303 + /// Counts the problems awaiting triage, for the badge on the Problems pill.
304 + #[tauri::command]
305 + #[instrument(skip_all)]
306 + pub async fn count_open_problems(state: State<'_, Arc<AppState>>) -> Result<usize, ApiError> {
307 + let problems = state
308 + .problems
309 + .list(
310 + DESKTOP_USER_ID,
311 + &ProblemFilter {
312 + status: Some(ProblemStatus::Open),
313 + ..Default::default()
314 + },
315 + )
316 + .await?;
317 + Ok(problems.len())
318 + }
@@ -12,6 +12,7 @@ mod email_tests;
12 12 mod event_tests;
13 13 mod export_tests;
14 14 mod milestone_tests;
15 + mod problem_tests;
15 16 mod project_tests;
16 17 mod saved_views_tests;
17 18 mod search_tests;
@@ -0,0 +1,193 @@
1 + //! Integration tests for the Problems inbox commands.
2 + //!
3 + //! The command layer's own job is thin — filtering, project-name resolution,
4 + //! and the promote sequence — so that is what these cover. The upsert and
5 + //! ageing semantics are pinned in `goingson-db-sqlite`'s `problem_repo_tests`.
6 +
7 + use chrono::{Duration, Utc};
8 + use goingson_core::{NewProblem, ProblemStatus, TaskStatus};
9 +
10 + use crate::test_utils::{create_test_project, setup_test_state};
11 +
12 + fn new_problem(source_ref: &str, pain: u8, scale: u8) -> NewProblem {
13 + let now = Utc::now();
14 + NewProblem {
15 + source: "audit".to_string(),
16 + source_ref: source_ref.to_string(),
17 + title: format!("finding {source_ref}"),
18 + body: "db-sqlite/task_repo.rs:120".to_string(),
19 + pain,
20 + scale,
21 + project_id: None,
22 + tags: vec!["testing".to_string()],
23 + created_at: now,
24 + updated_at: now,
25 + resolved_upstream: false,
26 + }
27 + }
28 +
29 + #[tokio::test]
30 + async fn list_defaults_to_open_and_ranks_by_painhours() {
31 + let (state, user_id) = setup_test_state().await;
32 +
33 + state
34 + .problems
35 + .ingest(user_id, new_problem("narrow", 1, 1))
36 + .await
37 + .unwrap();
38 + state
39 + .problems
40 + .ingest(user_id, new_problem("widespread", 1, 5))
41 + .await
42 + .unwrap();
43 + let dismissed = state
44 + .problems
45 + .ingest(user_id, new_problem("ignored", 3, 3))
46 + .await
47 + .unwrap();
48 + state
49 + .problems
50 + .set_status(dismissed.id, user_id, ProblemStatus::Dismissed)
51 + .await
52 + .unwrap();
53 +
54 + let open = state
55 + .problems
56 + .list(
57 + user_id,
58 + &goingson_core::ProblemFilter {
59 + status: Some(ProblemStatus::Open),
60 + ..Default::default()
61 + },
62 + )
63 + .await
64 + .unwrap();
65 +
66 + assert_eq!(open.len(), 2, "the dismissed problem is out of the inbox");
67 + assert_eq!(open[0].source_ref, "widespread", "scale drives the ranking");
68 + assert!(open[0].painhours() > open[1].painhours());
69 + }
70 +
71 + #[tokio::test]
72 + async fn promoting_creates_a_linked_task_carrying_project_and_tags() {
73 + let (state, user_id) = setup_test_state().await;
74 + let project_id = create_test_project(&state, user_id).await;
75 +
76 + let mut input = new_problem("blob-memory", 5, 4);
77 + input.project_id = Some(project_id);
78 + // Backdate it so the freeze has something to freeze.
79 + input.created_at = Utc::now() - Duration::days(60);
80 + let problem = state.problems.ingest(user_id, input).await.unwrap();
81 + let open_score = problem.painhours();
82 +
83 + let task = {
84 + let mut tags = problem.tags.clone();
85 + tags.push(format!("problem:{}:{}", problem.source, problem.source_ref));
86 + let builder = goingson_core::NewTask::builder(problem.title.clone())
87 + .priority(goingson_core::Priority::High)
88 + .tags(tags)
89 + .project_id(project_id);
90 + state.tasks.create(user_id, builder.build()).await.unwrap()
91 + };
92 +
93 + let promoted = state
94 + .problems
95 + .promote(problem.id, user_id, task.id)
96 + .await
97 + .unwrap()
98 + .expect("promote returned None");
99 +
100 + assert_eq!(promoted.status, ProblemStatus::Promoted);
101 + assert_eq!(promoted.promoted_task_id, Some(task.id));
102 + assert_eq!(promoted.project_id, Some(project_id));
103 +
104 + // Settling anchors age at promotion: the score holds rather than dropping,
105 + // and stops climbing from here.
106 + assert_eq!(promoted.painhours(), open_score);
107 + assert!(promoted.settled_at.is_some());
108 +
109 + let task = state
110 + .tasks
111 + .get_by_id(task.id, user_id)
112 + .await
113 + .unwrap()
114 + .expect("task missing");
115 + assert_eq!(task.status, TaskStatus::Pending);
116 + assert!(
117 + task.tags.contains(&"problem:audit:blob-memory".to_string()),
118 + "the task must carry its provenance back to the problem"
119 + );
120 + assert!(task.tags.contains(&"testing".to_string()));
121 + }
122 +
123 + #[tokio::test]
124 + async fn dismissing_survives_a_re_report() {
125 + let (state, user_id) = setup_test_state().await;
126 +
127 + let problem = state
128 + .problems
129 + .ingest(user_id, new_problem("style-nit", 2, 2))
130 + .await
131 + .unwrap();
132 + state
133 + .problems
134 + .set_status(problem.id, user_id, ProblemStatus::Dismissed)
135 + .await
136 + .unwrap();
137 +
138 + // The next audit run reports it again, and now calls it fixed.
139 + let mut again = new_problem("style-nit", 2, 2);
140 + again.resolved_upstream = true;
141 + state.problems.ingest(user_id, again).await.unwrap();
142 +
143 + let after = state
144 + .problems
145 + .find_by_source_ref(user_id, "audit", "style-nit")
146 + .await
147 + .unwrap()
148 + .expect("problem missing");
149 + assert_eq!(
150 + after.status,
151 + ProblemStatus::Dismissed,
152 + "a human ruling outranks what the source says"
153 + );
154 + }
155 +
156 + #[tokio::test]
157 + async fn project_attribution_resolves_to_a_name() {
158 + let (state, user_id) = setup_test_state().await;
159 + let project_id = create_test_project(&state, user_id).await;
160 +
161 + let problem = state
162 + .problems
163 + .ingest(user_id, new_problem("unattributed", 3, 3))
164 + .await
165 + .unwrap();
166 + assert!(problem.project_id.is_none());
167 +
168 + let attributed = state
169 + .problems
170 + .set_project(problem.id, user_id, Some(project_id))
171 + .await
172 + .unwrap()
173 + .expect("problem missing");
174 + assert_eq!(attributed.project_id, Some(project_id));
175 +
176 + // The command layer resolves the name from this id; confirm the project it
177 + // points at is really there, which is what that lookup depends on.
178 + let project = state
179 + .projects
180 + .get_by_id(project_id, user_id)
181 + .await
182 + .unwrap()
183 + .expect("project missing");
184 + assert_eq!(attributed.project_id, Some(project.id));
185 +
186 + let cleared = state
187 + .problems
188 + .set_project(problem.id, user_id, None)
189 + .await
190 + .unwrap()
191 + .expect("problem missing");
192 + assert!(cleared.project_id.is_none());
193 + }
@@ -105,6 +105,12 @@ macro_rules! all_commands {
105 105 $crate::commands::update_milestone,
106 106 $crate::commands::delete_milestone,
107 107 $crate::commands::reorder_milestones,
108 + // Problems inbox (triage; problems arrive from adapters, never created here)
109 + $crate::commands::list_problems,
110 + $crate::commands::promote_problem,
111 + $crate::commands::set_problem_status,
112 + $crate::commands::set_problem_project,
113 + $crate::commands::count_open_problems,
108 114 // Events
109 115 $crate::commands::list_events,
110 116 $crate::commands::get_event,