Skip to main content

max / goingson

The problems inbox is described Eighth screen, and the last one that needed nothing decided first. Ranked list, rows that act on themselves, filters in the address rather than in module scope. promote is lifted out of the tauri command and shared, because the defaults are the interesting part of promoting and two screens disagreeing about them would be two meanings of the word. Three things the port turned up. The score's derivation and the source ref are tooltips in the shipped screen, so they are invisible on touch and to a keyboard; here they are meta text. ProblemResponse.stale has been computed and serialised since the inbox was built and problems.js never reads it, so a source that stops reporting a problem says nothing; here it is a badge. And the sixth finding bit again: the target status had to be named `to` because `status` is already this screen's view filter.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-10 19:08 UTC
Signed with PGP, not checked
Commit: b8ea9b7ec0a624a6315a8071eee34ee5ca5e3e6f
Parent: 11424fa
4 files changed, +854 insertions, -10 deletions
@@ -183,18 +183,17 @@
183 183 /// leave a problem marked Promoted pointing at nothing, which nothing recovers
184 184 /// from; this way a failure leaves a stray task and an Open problem, and
185 185 /// retrying is safe.
186 - #[tauri::command]
187 - #[instrument(skip_all)]
188 - pub async fn promote_problem(
189 - state: State<'_, Arc<AppState>>,
186 + ///
187 + /// The command below is a wrapper. The work is here so the described screen
188 + /// (`quasi::problems`) promotes the same way rather than reimplementing the
189 + /// defaults, which are the interesting part: the description falls back to the
190 + /// problem's own text and the priority to its painhours band, and two screens
191 + /// disagreeing about either would be two different meanings of "promote".
192 + pub fn promote(
193 + state: &AppState,
190 194 id: ProblemId,
191 - input: Option<PromoteProblemInput>,
195 + input: &PromoteProblemInput,
192 196 ) -> Result<PromoteProblemResponse, ApiError> {
193 - let input = input.unwrap_or(PromoteProblemInput {
194 - description: None,
195 - priority: None,
196 - });
197 -
198 197 let problem = state
199 198 .problems
200 199 .get_by_id(id, DESKTOP_USER_ID)?
@@ -260,6 +259,24 @@
260 259 })
261 260 }
262 261
262 + /// Promotes a problem into a task, linking the two. See [`promote`].
263 + #[tauri::command]
264 + #[instrument(skip_all)]
265 + pub async fn promote_problem(
266 + state: State<'_, Arc<AppState>>,
267 + id: ProblemId,
268 + input: Option<PromoteProblemInput>,
269 + ) -> Result<PromoteProblemResponse, ApiError> {
270 + promote(
271 + &state,
272 + id,
273 + &input.unwrap_or(PromoteProblemInput {
274 + description: None,
275 + priority: None,
276 + }),
277 + )
278 + }
279 +
263 280 /// Sets a problem's triage state.
264 281 ///
265 282 /// `Dismissed` is the "seen it, not acting" verdict and is the right way to
@@ -37,6 +37,7 @@
37 37 pub mod contacts;
38 38 pub mod emails;
39 39 pub mod monthly_review;
40 + pub mod problems;
40 41 pub mod projects;
41 42 pub mod settings;
42 43 pub mod tasks;
@@ -52,6 +53,7 @@
52 53 let router = settings::routes(router);
53 54 let router = weekly_review::routes(router);
54 55 let router = monthly_review::routes(router);
56 + let router = problems::routes(router);
55 57 emails::routes(router)
56 58 }
57 59
@@ -1,0 +1,514 @@
1 + //! The problems inbox, described rather than built.
2 + //!
3 + //! <!-- wiki: quasi-overview -->
4 + //!
5 + //! The eighth screen ported, and the last one on the list that needed nothing
6 + //! decided first: `problems.js` is 229 lines and tasks-shaped, a ranked list
7 + //! whose rows act on themselves. The shipped screen is still that file; see
8 + //! [the module above](super).
9 + //!
10 + //! GoingsOn is the list of solutions. A problem is a candidate pulled from
11 + //! somewhere else that becomes work only when promoted, which is why nothing
12 + //! here creates one and why [`promote`](crate::commands::problem::promote) is
13 + //! shared with the command rather than reimplemented: two screens disagreeing
14 + //! about what a promotion defaults to would be two meanings of the word.
15 + //!
16 + //! # The shape
17 + //!
18 + //! - `GET /problems` — the document.
19 + //! - `GET /problems/list` — the ranked list alone, which is what the filters
20 + //! swap.
21 + //! - `POST /problems/{id}/promote` — make a task from it.
22 + //! - `POST /problems/{id}/status` — dismiss it, or send it back to triage.
23 + //!
24 + //! The filters are query params rather than module state, per decision 2 and
25 + //! for the same reason as contacts: `problems.js` holds `{status, source}` in
26 + //! module scope, so the view a user is looking at has no address. Its comment
27 + //! defends that ("the inbox is a working view, not a place you deep-link
28 + //! into"), and the port disagrees on the evidence of the file itself — the
29 + //! source filter needs a workaround, [`sources`], precisely because the list it
30 + //! is drawn from is whatever the last fetch happened to hold.
31 + //!
32 + //! Each status change names its target rather than cycling, which is the same
33 + //! decision the monthly review's goals landed on and for the same reason: a
34 + //! control that derives its target from what it was drawn with races anything
35 + //! that already moved the row.
36 + //!
37 + //! # The sixth finding again, and this time it bit before it was read
38 + //!
39 + //! The target travels as `to`, not as `status`, because `status` is already
40 + //! this screen's view filter. Written the obvious way the route works and the
41 + //! screen moves under the user: dismissing from the Open inbox answers with the
42 + //! Dismissed list, because the write's own parameter is what the next
43 + //! `status_filter` reads back. This port found it by a failing test rather than
44 + //! by reading, which is the argument for the finding the mail port filed
45 + //! against quasicoherent: the convention is held by hand, it is invisible until
46 + //! something collides, and `Params` knows enough to make it a compile-time
47 + //! question. Second screen to hit it, second one to work around it by naming.
48 +
49 + // Handlers take their params by value because `quasi_router::Handler` is a
50 + // plain `fn(&S, Params)` pointer, so the signature is the router's and not a
51 + // choice made here. Same allow, for the same reason, as quasi-axum's tests.
52 + #![allow(clippy::needless_pass_by_value)]
53 +
54 + use std::collections::{HashMap, HashSet};
55 +
56 + use chrono::{DateTime, Utc};
57 + use goingson_core::{Problem, ProblemBand, ProblemFilter, ProblemId, ProblemStatus, ProjectId};
58 + use quasi_router::screen::{Act, Row, Tag};
59 + use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
60 +
61 + use crate::commands::{PromoteProblemInput, promote};
62 + use crate::state::{AppState, DESKTOP_USER_ID};
63 +
64 + #[cfg(test)]
65 + mod tests;
66 +
67 + /// The status words the filter offers, in triage order. `None` is "everything",
68 + /// which the JS spells as a fifth option on the same control.
69 + const STATUSES: [Option<ProblemStatus>; 5] = [
70 + Some(ProblemStatus::Open),
71 + Some(ProblemStatus::Promoted),
72 + Some(ProblemStatus::Dismissed),
73 + Some(ProblemStatus::Resolved),
74 + None,
75 + ];
76 +
77 + /// How urgent the score says it is.
78 + ///
79 + /// `problems.js` maps the band onto its own palette words (red, yellow, blue,
80 + /// muted). Here it maps onto what the band *means*, which is the tone, and the
81 + /// palette is the renderer's business.
82 + const fn band_tone(band: ProblemBand) -> makeover_layout::Tone {
83 + match band {
84 + ProblemBand::Critical => makeover_layout::Tone::Danger,
85 + ProblemBand::High => makeover_layout::Tone::Warning,
86 + ProblemBand::Medium => makeover_layout::Tone::Info,
87 + ProblemBand::Low => makeover_layout::Tone::Neutral,
88 + }
89 + }
90 +
91 + /// What the triage state says about itself.
92 + const fn status_tone(status: ProblemStatus) -> makeover_layout::Tone {
93 + match status {
94 + ProblemStatus::Promoted => makeover_layout::Tone::Success,
95 + ProblemStatus::Resolved => makeover_layout::Tone::Info,
96 + ProblemStatus::Open | ProblemStatus::Dismissed => makeover_layout::Tone::Neutral,
97 + }
98 + }
99 +
100 + /// A param that is present and not blank. Blank is absent, which is what the
101 + /// "all sources" option means.
102 + fn text<'a>(params: &'a quasi_router::Params, name: &str) -> Option<&'a str> {
103 + params.get(name).map(str::trim).filter(|v| !v.is_empty())
104 + }
105 +
106 + /// The status the screen is filtered to.
107 + ///
108 + /// Absent means `Open`, because the inbox question is what is untriaged;
109 + /// `all` means unfiltered. Same three cases as `list_problems`, and an
110 + /// unrecognised word is a 404 rather than a silent fall back to `Open`, which
111 + /// would answer with a different list than the one asked for.
112 + fn status_filter(params: &quasi_router::Params) -> Result<Option<ProblemStatus>, RouteError> {
113 + match text(params, "status") {
114 + None => Ok(Some(ProblemStatus::Open)),
115 + Some(word) if word.eq_ignore_ascii_case("all") => Ok(None),
116 + Some(word) => word
117 + .parse()
118 + .map(Some)
119 + .map_err(|_| RouteError::not_found("not a triage state")),
120 + }
121 + }
122 +
123 + /// The word a status filter travels as.
124 + fn status_word(status: Option<ProblemStatus>) -> &'static str {
125 + status.as_ref().map_or("all", ProblemStatus::as_str)
126 + }
127 +
128 + /// Carry the current filters on an action, so every control keeps the view it
129 + /// was pressed in. The same job `in_month` does on the monthly review.
130 + fn filtered(mut action: Action, status: Option<ProblemStatus>, source: Option<&str>) -> Action {
131 + action = action.with("status", status_word(status));
132 + if let Some(source) = source {
133 + action = action.with("source", source);
134 + }
135 + action
136 + }
137 +
138 + /// The address of the list under a given filter pair.
139 + fn list_action(status: Option<ProblemStatus>, source: Option<&str>) -> Action {
140 + filtered(Action::get("/problems/list"), status, source)
141 + }
142 +
143 + /// Read one problem, or answer 404.
144 + fn load(state: &AppState, id: ProblemId) -> Result<Problem, RouteError> {
145 + state
146 + .problems
147 + .get_by_id(id, DESKTOP_USER_ID)
148 + .map_err(|error| RouteError::internal(error.to_string()))?
149 + .ok_or_else(|| RouteError::not_found("no such problem"))
150 + }
151 +
152 + /// Parse the path id, or answer 404.
153 + fn problem_id(params: &quasi_router::Params) -> Result<ProblemId, RouteError> {
154 + let raw = params
155 + .get("id")
156 + .ok_or_else(|| RouteError::not_found("no id"))?;
157 + uuid::Uuid::parse_str(raw)
158 + .map(ProblemId::from)
159 + .map_err(|_| RouteError::not_found("not an id"))
160 + }
161 +
162 + /// Every source that has ever reported a problem, in a stable order.
163 + ///
164 + /// Drawn from the whole table rather than from the rows on screen, which is the
165 + /// one place this screen deliberately does more work than `problems.js`. That
166 + /// file builds the option list out of the rows it last fetched, so filtering to
167 + /// a source with nothing Open would empty the control that got you there; it
168 + /// carries a workaround pushing the current selection back in. Reading the
169 + /// sources from the sources removes the need for one.
170 + fn sources(state: &AppState) -> Result<Vec<String>, RouteError> {
171 + let all = state
172 + .problems
173 + .list(DESKTOP_USER_ID, &ProblemFilter::default())
174 + .map_err(|error| RouteError::internal(error.to_string()))?;
175 +
176 + let mut sources: Vec<String> = all.into_iter().map(|problem| problem.source).collect();
177 + sources.sort_unstable();
178 + sources.dedup();
179 + Ok(sources)
180 + }
181 +
182 + /// One problem as a row.
183 + ///
184 + /// # What the shipped screen says with a tooltip, and this one says out loud
185 + ///
186 + /// `rowHtml` puts the score's derivation in a `title=` on the badge and the
187 + /// source ref in a `title=` on the age. A description has no word for "text
188 + /// that appears if you hover", and should not grow one: hover is absent on a
189 + /// touch screen and on a keyboard, so a `title` is a fact the app knows and
190 + /// most of its users never see. Both move into `meta`, where a plain fact
191 + /// belongs.
192 + ///
193 + /// # A finding: the screen never shows staleness
194 + ///
195 + /// `ProblemResponse` has carried a `stale` flag since the inbox was built, and
196 + /// the comment on it explains why staleness is shown rather than deleted — a
197 + /// source being briefly unreachable must not erase triage history. Nothing in
198 + /// `problems.js` reads the field. So the backend computes a fact for the user,
199 + /// serialises it, and the screen drops it on the floor. Described, it is a
200 + /// badge like any other. Filed rather than only fixed here, because the shipped
201 + /// screen has the same gap until it retires.
202 + fn row_for(
203 + problem: &Problem,
204 + project: Option<&str>,
205 + last_pull: Option<DateTime<Utc>>,
206 + status: Option<ProblemStatus>,
207 + source: Option<&str>,
208 + ) -> Row {
209 + let mut row = Row::new(&problem.title);
210 +
211 + if !problem.body.trim().is_empty() {
212 + row = row.secondary(problem.body.clone());
213 + }
214 +
215 + // The score leads the row: it is the reason this problem is where it is in
216 + // the list, so it reads before the title in every renderer that puts tokens
217 + // first, and it is the sort key either way.
218 + row = row.token(Tag::badge(problem.painhours().to_string()).tone(band_tone(problem.band())));
219 +
220 + // The source is also the filter, which is the click contacts already
221 + // established on a row's own tags. Not latched: a row says what it carries,
222 + // and whether that is the active filter is the band's question.
223 + row = row.token(Tag::chip(
224 + &problem.source,
225 + list_action(status, Some(&problem.source)),
226 + ));
227 +
228 + if let Some(project) = project {
229 + row = row.token(Tag::badge(project).tone(makeover_layout::Tone::Info));
230 + }
231 +
232 + if problem.status.is_settled() {
233 + row = row.token(Tag::badge(problem.status.as_str()).tone(status_tone(problem.status)));
234 + }
235 +
236 + // A problem its project has shelved is frozen rather than triaged, and the
237 + // two look identical in a ranking that only shows the score.
238 + if problem.is_dormant() {
239 + row = row.token(Tag::badge("Dormant").tone(makeover_layout::Tone::Neutral));
240 + }
241 +
242 + if last_pull.is_some_and(|at| problem.is_stale(at)) {
243 + row = row.token(Tag::badge("Stale").tone(makeover_layout::Tone::Warning));
244 + }
245 +
246 + for tag in &problem.tags {
247 + row = row.token(Tag::badge(tag));
248 + }
249 +
250 + row = row.meta(format!(
251 + "pain {} x scale {}, aged {} · {}",
252 + problem.pain,
253 + problem.scale,
254 + problem.age(),
255 + problem.source_ref,
256 + ));
257 +
258 + for act in acts_for(problem, status, source) {
259 + row = row.act(act);
260 + }
261 +
262 + row
263 + }
264 +
265 + /// The moves a problem offers, which are its triage state's.
266 + fn acts_for(problem: &Problem, status: Option<ProblemStatus>, source: Option<&str>) -> Vec<Act> {
267 + let id = problem.id;
268 + let reopen = || {
269 + Act::new(
270 + "Reopen",
271 + filtered(
272 + Action::post(format!("/problems/{id}/status")).with("status", "Open"),
273 + status,
274 + source,
275 + ),
276 + )
277 + };
278 +
279 + match problem.status {
280 + ProblemStatus::Open => vec![
281 + Act::new(
282 + "Promote",
283 + filtered(
284 + Action::post(format!("/problems/{id}/promote")),
285 + status,
286 + source,
287 + ),
288 + ),
289 + Act::new(
290 + "Dismiss",
291 + filtered(
292 + Action::post(format!("/problems/{id}/status")).with("to", "Dismissed"),
293 + status,
294 + source,
295 + ),
296 + ),
297 + ],
298 + // The backlink is the point of promoting, so the row offers it. The JS
299 + // switches view and calls into the tasks module; here it is an address,
300 + // which is the whole of what "open the task" means.
301 + ProblemStatus::Promoted => {
302 + let mut acts = Vec::with_capacity(2);
303 + if let Some(task) = problem.promoted_task_id {
304 + acts.push(Act::new("Open task", Action::get(format!("/tasks/{task}"))));
305 + }
306 + acts.push(reopen());
307 + acts
308 + }
309 + ProblemStatus::Dismissed | ProblemStatus::Resolved => vec![reopen()],
310 + }
311 + }
312 +
313 + /// The ranked list, filtered the way the screen's two filters filter it.
314 + ///
315 + /// The repository ranks by painhours descending, so nothing here re-sorts. The
316 + /// score moves with the clock, which is why it is computed on read and why the
317 + /// order is the repository's rather than SQL's.
318 + fn ranked(
319 + state: &AppState,
320 + status: Option<ProblemStatus>,
321 + source: Option<&str>,
322 + ) -> Result<Node, RouteError> {
323 + let problems = state
324 + .problems
325 + .list(
326 + DESKTOP_USER_ID,
327 + &ProblemFilter {
328 + source: source.map(str::to_owned),
329 + status,
330 + project_id: None,
331 + },
332 + )
333 + .map_err(|error| RouteError::internal(error.to_string()))?;
334 +
335 + if problems.is_empty() {
336 + return Ok(Node::empty(match (status, source) {
337 + (Some(ProblemStatus::Open), None) => {
338 + "Nothing waiting for triage. Problems arrive from wam and from audit runs; \
339 + they are candidates, and promoting one makes it a task."
340 + }
341 + (Some(_), _) | (None, Some(_)) => "No problems match that filter.",
342 + (None, None) => "No problems yet.",
343 + }));
344 + }
345 +
346 + // One project lookup for the whole list rather than one per row, and one
347 + // last-pull lookup per distinct source rather than per row. Both are the
348 + // shape `list_problems` already uses.
349 + let projects = state
350 + .projects
351 + .list_all(DESKTOP_USER_ID)
352 + .map_err(|error| RouteError::internal(error.to_string()))?;
353 + let name_of = |id: Option<ProjectId>| {
354 + id.and_then(|id| {
355 + projects
356 + .iter()
357 + .find(|p| p.id == id)
358 + .map(|p| p.name.as_str())
359 + })
360 + };
361 +
362 + let mut last_pulls: HashMap<&str, Option<DateTime<Utc>>> = HashMap::new();
363 + for name in problems
364 + .iter()
365 + .map(|problem| problem.source.as_str())
366 + .collect::<HashSet<_>>()
367 + {
368 + let at = state
369 + .problems
370 + .last_pulled_at(DESKTOP_USER_ID, name)
371 + .map_err(|error| RouteError::internal(error.to_string()))?;
372 + last_pulls.insert(name, at);
373 + }
374 +
375 + Ok(Node::list(problems.iter().map(|problem| {
376 + let last_pull = last_pulls.get(problem.source.as_str()).copied().flatten();
377 + row_for(
378 + problem,
379 + name_of(problem.project_id),
380 + last_pull,
381 + status,
382 + source,
383 + )
384 + })))
385 + }
386 +
387 + /// The whole screen.
388 + fn index(state: &AppState, params: quasi_router::Params) -> Result<Response, RouteError> {
389 + let status = status_filter(&params)?;
390 + let source = text(&params, "source");
391 +
392 + let mut band = Slot::new("problems-band", RegionKind::Band).with(Node::page("Problems"));
393 +
394 + for offered in STATUSES {
395 + let latched = offered == status;
396 + band = band.with(Node::Token(
397 + Tag::chip(status_word(offered), list_action(offered, source)).latched(latched),
398 + ));
399 + }
400 +
401 + // A source that is filtered on stays offered even when it is the only one
402 + // left, so the way back is always on screen: pressing a latched chip clears
403 + // it. Same rule as the contacts tag filter.
404 + for offered in sources(state)? {
405 + let latched = source == Some(offered.as_str());
406 + let action = list_action(status, (!latched).then_some(offered.as_str()));
407 + band = band.with(Node::Token(Tag::chip(&offered, action).latched(latched)));
408 + }
409 +
410 + Ok(Screen::list_detail("Problems", false)
411 + .with(band)
412 + .with(Slot::new("problems-list", RegionKind::Pane).with(ranked(state, status, source)?))
413 + .into())
414 + }
415 +
416 + /// The list alone, which is what a filter chip replaces.
417 + fn list(state: &AppState, params: quasi_router::Params) -> Result<Response, RouteError> {
418 + let status = status_filter(&params)?;
419 + let source = text(&params, "source");
420 + Ok(Response::fragment(
421 + "problems-list",
422 + ranked(state, status, source)?,
423 + ))
424 + }
425 +
426 + /// Answer a triage decision with the list it happened in, re-read.
427 + ///
428 + /// Re-read rather than patched in memory, for the reason the contacts removals
429 + /// are: the row may well leave the list it was in, since the filter it was
430 + /// pressed under is usually `Open` and the press is what settles it.
431 + fn triaged(
432 + state: &AppState,
433 + params: &quasi_router::Params,
434 + message: &str,
435 + ) -> Result<Response, RouteError> {
436 + let status = status_filter(params)?;
437 + let source = text(params, "source");
438 + Ok(
439 + Response::fragment("problems-list", ranked(state, status, source)?)
440 + .toast(makeover_layout::Tone::Success, message),
441 + )
442 + }
443 +
444 + /// Make a task from a problem.
445 + ///
446 + /// One press with nothing to fill in, which is `problems.js`'s decision and a
447 + /// good one: the description defaults to the problem's own text and the
448 + /// priority to its painhours band, and both are better than anything retyped at
449 + /// triage time. Shape the task afterwards if it needs it.
450 + fn promote_one(state: &AppState, params: quasi_router::Params) -> Result<Response, RouteError> {
451 + let id = problem_id(&params)?;
452 + let outcome = promote(
453 + state,
454 + id,
455 + &PromoteProblemInput {
456 + description: None,
457 + priority: None,
458 + },
459 + )
460 + .map_err(|error| RouteError::internal(error.to_string()))?;
461 +
462 + triaged(
463 + state,
464 + &params,
465 + if outcome.created {
466 + "Promoted to a task."
467 + } else {
468 + "Already promoted; the task it made is on the row."
469 + },
470 + )
471 + }
472 +
473 + /// Move a problem to a named triage state.
474 + ///
475 + /// The target is a param, never derived from what the row was drawn with. Two
476 + /// windows on the same inbox therefore cannot disagree about what "the next
477 + /// state" was.
478 + fn set_status(state: &AppState, params: quasi_router::Params) -> Result<Response, RouteError> {
479 + let id = problem_id(&params)?;
480 + let target: ProblemStatus = params
481 + .get("to")
482 + .ok_or_else(|| RouteError::not_found("no status"))?
483 + .parse()
484 + .map_err(|_| RouteError::not_found("not a triage state"))?;
485 +
486 + // The row has to exist before the message can claim anything happened to it.
487 + load(state, id)?;
488 + state
489 + .problems
490 + .set_status(id, DESKTOP_USER_ID, target)
491 + .map_err(|error| RouteError::internal(error.to_string()))?
492 + .ok_or_else(|| RouteError::not_found("no such problem"))?;
493 +
494 + triaged(
495 + state,
496 + &params,
497 + match target {
498 + ProblemStatus::Open => "Back in triage.",
499 + ProblemStatus::Dismissed => "Dismissed. It stays down through the next pull.",
500 + ProblemStatus::Promoted => "Marked promoted.",
Lines truncated
@@ -1,0 +1,325 @@
1 + //! The problems inbox, driven through the router against a real database.
2 + //!
3 + //! Same property as its siblings: no Tauri runtime and no window, because a
4 + //! route is a function from state and params to a description.
5 +
6 + use std::sync::Arc;
7 +
8 + use chrono::{Duration, Utc};
9 + use goingson_core::{NewProblem, Problem, ProblemStatus};
10 + use quasi_http::Render as _;
11 + use quasi_router::Outcome;
12 + use quasi_router::{Method, Params, Response};
13 +
14 + use super::super::router;
15 + use crate::state::{AppState, DESKTOP_USER_ID};
16 +
17 + /// State with the desktop user in place, which is who the handlers read as.
18 + async fn state() -> Arc<AppState> {
19 + let (state, _) = crate::test_utils::setup_test_state().await;
20 + let now = Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
21 + state
22 + .db
23 + .conn()
24 + .unwrap()
25 + .execute(
26 + "INSERT OR IGNORE INTO users (id, email, password_hash, display_name, created_at) \
27 + VALUES (?, ?, ?, ?, ?)",
28 + rusqlite::params![
29 + DESKTOP_USER_ID.to_string(),
30 + "desktop@localhost",
31 + "x",
32 + "Desktop User",
33 + &now,
34 + ],
35 + )
36 + .unwrap();
37 + state
38 + }
39 +
40 + /// A problem as an adapter would report it. `pain` and `scale` are the score's
41 + /// two stored factors; age is the third and comes from `created_at`.
42 + fn report(state: &AppState, source: &str, title: &str, pain: u8, weeks_old: i64) -> Problem {
43 + let created = Utc::now() - Duration::weeks(weeks_old);
44 + state
45 + .problems
46 + .ingest(
47 + DESKTOP_USER_ID,
48 + NewProblem {
49 + source: source.to_owned(),
50 + source_ref: format!("{source}-{title}"),
51 + title: title.to_owned(),
52 + body: String::new(),
53 + pain,
54 + scale: 3,
55 + project_id: None,
56 + tags: Vec::new(),
57 + created_at: created,
58 + updated_at: created,
59 + resolved_upstream: false,
60 + },
61 + )
62 + .unwrap()
63 + }
64 +
65 + fn get(state: &AppState, path: &str, params: Params) -> Response {
66 + router()
67 + .handle(state, Method::Get, path, params)
68 + .expect("the route answers")
69 + }
70 +
71 + fn post(state: &AppState, path: &str, params: Params) -> Response {
72 + router()
73 + .handle(state, Method::Post, path, params)
74 + .expect("the route answers")
75 + }
76 +
77 + fn screen_html(response: Response) -> String {
78 + let Outcome::Screen(screen) = response.outcome else {
79 + panic!("the route answers with a screen");
80 + };
81 + quasi_webview::Webview::new().screen(&screen)
82 + }
83 +
84 + fn fragment_html(response: Response) -> String {
85 + let Outcome::Fragment { node, .. } = response.outcome else {
86 + panic!("the route answers with a fragment");
87 + };
88 + quasi_webview::Webview::new().fragment(&node)
89 + }
90 +
91 + fn reread(state: &AppState, problem: &Problem) -> Problem {
92 + state
93 + .problems
94 + .get_by_id(problem.id, DESKTOP_USER_ID)
95 + .unwrap()
96 + .expect("the problem is still there")
97 + }
98 +
99 + #[tokio::test]
100 + async fn an_empty_inbox_says_what_would_fill_it() {
101 + let state = state().await;
102 + let html = screen_html(get(&state, "/problems", Params::new()));
103 + assert!(html.contains("Nothing waiting for triage"), "got: {html}");
104 + assert!(
105 + html.contains("promoting one makes it a task"),
106 + "got: {html}"
107 + );
108 + }
109 +
110 + #[tokio::test]
111 + async fn the_inbox_shows_the_untriaged_and_ranks_them_by_painhours() {
112 + // The repository's order, not this screen's: the score moves with the clock,
113 + // so it is computed on read and sorted after the fetch.
114 + let state = state().await;
115 + report(&state, "wam", "mild and new", 1, 1);
116 + report(&state, "audit", "bad and old", 5, 40);
117 +
118 + let html = screen_html(get(&state, "/problems", Params::new()));
119 + let worse = html.find("bad and old").expect("the worse one is shown");
120 + let milder = html.find("mild and new").expect("the milder one is shown");
121 + assert!(worse < milder, "most urgent first: {html}");
122 + }
123 +
124 + #[tokio::test]
125 + async fn a_row_says_out_loud_what_the_shipped_screen_hides_in_a_tooltip() {
126 + // `rowHtml` puts the derivation in `title=`, which is absent on touch and on
127 + // a keyboard. Here it is meta text.
128 + let state = state().await;
129 + report(&state, "wam", "it breaks", 4, 2);
130 +
131 + let html = screen_html(get(&state, "/problems", Params::new()));
132 + assert!(html.contains("pain 4 x scale 3"), "got: {html}");
133 + assert!(html.contains("wam-it breaks"), "the source ref: {html}");
134 + }
135 +
136 + #[tokio::test]
137 + async fn the_status_filter_is_in_the_address_rather_than_in_module_state() {
138 + let state = state().await;
139 + let open = report(&state, "wam", "still open", 3, 1);
140 + let other = report(&state, "wam", "dealt with", 3, 1);
141 + state
142 + .problems
143 + .set_status(other.id, DESKTOP_USER_ID, ProblemStatus::Dismissed)
144 + .unwrap();
145 +
146 + let inbox = screen_html(get(&state, "/problems", Params::new()));
147 + assert!(inbox.contains("still open"), "got: {inbox}");
148 + assert!(
149 + !inbox.contains("dealt with"),
150 + "Open is the default: {inbox}"
151 + );
152 +
153 + let dismissed = fragment_html(get(
154 + &state,
155 + "/problems/list",
156 + Params::new().with("status", "Dismissed"),
157 + ));
158 + assert!(dismissed.contains("dealt with"), "got: {dismissed}");
159 + assert!(!dismissed.contains("still open"), "got: {dismissed}");
160 +
161 + let everything = fragment_html(get(
162 + &state,
163 + "/problems/list",
164 + Params::new().with("status", "all"),
165 + ));
166 + assert!(everything.contains("still open"), "got: {everything}");
167 + assert!(everything.contains("dealt with"), "got: {everything}");
168 + let _ = open;
169 + }
170 +
171 + #[tokio::test]
172 + async fn an_unknown_status_word_is_refused_rather_than_read_as_open() {
173 + // Falling back would answer with a different list than the one asked for,
174 + // which is `list_problems`'s rule and the reason it has one.
175 + let state = state().await;
176 + let refused = router().handle(
177 + &state,
178 + Method::Get,
179 + "/problems/list",
180 + Params::new().with("status", "bogus"),
181 + );
182 + assert!(refused.is_err(), "an invented state is not a filter");
183 + }
184 +
185 + #[tokio::test]
186 + async fn the_source_filter_offers_a_source_that_the_current_view_has_none_of() {
187 + // The workaround `problems.js` needs, not needed: the option list comes from
188 + // the whole table rather than from the rows on screen.
189 + let state = state().await;
190 + let audited = report(&state, "audit", "found by reading", 3, 1);
191 + state
192 + .problems
193 + .set_status(audited.id, DESKTOP_USER_ID, ProblemStatus::Dismissed)
194 + .unwrap();
195 + report(&state, "wam", "reported by a user", 3, 1);
196 +
197 + // The Open list holds nothing from `audit`, and the chip is still there.
198 + let html = screen_html(get(&state, "/problems", Params::new()));
199 + assert!(html.contains("audit"), "got: {html}");
200 + }
201 +
202 + #[tokio::test]
203 + async fn promoting_makes_a_task_and_leaves_the_backlink_on_the_row() {
204 + let state = state().await;
205 + let problem = report(&state, "audit", "the thing is wrong", 4, 3);
206 +
207 + let html = fragment_html(post(
208 + &state,
209 + &format!("/problems/{}/promote", problem.id),
210 + Params::new().with("status", "all"),
211 + ));
212 +
213 + let promoted = reread(&state, &problem);
214 + assert_eq!(promoted.status, ProblemStatus::Promoted);
215 + let task_id = promoted.promoted_task_id.expect("the backlink is written");
216 +
217 + let task = state
218 + .tasks
219 + .get_by_id(task_id, DESKTOP_USER_ID)
220 + .unwrap()
221 + .expect("the task exists");
222 + assert!(
223 + task.title.contains("the thing is wrong"),
224 + "the description defaults to the problem's own text: {task:?}"
225 + );
226 +
227 + // The row now offers the way to the task it made, as an address rather than
228 + // as a view switch.
229 + assert!(html.contains(&format!("/tasks/{task_id}")), "got: {html}");
230 + }
231 +
232 + #[tokio::test]
233 + async fn promoting_twice_reports_the_task_it_already_made() {
234 + // The shared `promote` is idempotent, and the screen must not turn that into
235 + // two tasks by pressing twice.
236 + let state = state().await;
237 + let problem = report(&state, "audit", "double pressed", 4, 3);
238 + let path = format!("/problems/{}/promote", problem.id);
239 +
240 + post(&state, &path, Params::new().with("status", "all"));
241 + let first = reread(&state, &problem).promoted_task_id.unwrap();
242 + post(&state, &path, Params::new().with("status", "all"));
243 + let second = reread(&state, &problem).promoted_task_id.unwrap();
244 +
245 + assert_eq!(first, second, "one problem, one task");
246 + }
247 +
248 + #[tokio::test]
249 + async fn the_target_state_is_named_so_two_windows_cannot_race() {
250 + // The same decision the monthly review's goals landed on. Two presses from
251 + // two stale windows name the same target, so the second is a no-op rather
252 + // than a step around a cycle nobody can see.
253 + let state = state().await;
254 + let problem = report(&state, "wam", "seen it", 2, 1);
255 + let path = format!("/problems/{}/status", problem.id);
256 +
257 + for _ in 0..2 {
258 + post(&state, &path, Params::new().with("to", "Dismissed"));
259 + }
260 + assert_eq!(reread(&state, &problem).status, ProblemStatus::Dismissed);
261 +
262 + post(&state, &path, Params::new().with("to", "Open"));
263 + let reopened = reread(&state, &problem);
264 + assert_eq!(reopened.status, ProblemStatus::Open);
265 + assert!(
266 + reopened.promoted_task_id.is_none(),
267 + "reopening clears the backlink"
268 + );
269 + }
270 +
271 + #[tokio::test]
272 + async fn a_triage_decision_answers_with_the_list_it_happened_in() {
273 + // Dismissing from the Open view removes the row, which is the point: the
274 + // answer is the list re-read, not the row patched in place.
275 + let state = state().await;
276 + let problem = report(&state, "wam", "goes away", 2, 1);
277 + report(&state, "wam", "stays put", 2, 1);
278 +
279 + let html = fragment_html(post(
280 + &state,
281 + &format!("/problems/{}/status", problem.id),
282 + Params::new().with("to", "Dismissed"),
283 + ));
284 + assert!(!html.contains("goes away"), "got: {html}");
285 + assert!(html.contains("stays put"), "got: {html}");
286 + }
287 +
288 + #[tokio::test]
289 + async fn a_stale_problem_says_so_where_the_shipped_screen_drops_the_fact() {
290 + // The finding on `row_for`: `ProblemResponse.stale` is computed, serialised,
291 + // and never read by `problems.js`. A source that stops reporting a problem
292 + // leaves it in place, marked, because a brief outage must not erase triage
293 + // history.
294 + let state = state().await;
295 + let old = report(&state, "wam", "no longer reported", 3, 4);
296 + report(&state, "wam", "still reported", 3, 4);
297 +
298 + // A later pull that saw only the second one moves the source's last-pull
299 + // instant past the first one's `last_seen_at`.
300 + state
301 + .db
302 + .conn()
303 + .unwrap()
304 + .execute(
305 + "UPDATE problems SET last_seen_at = datetime('now', '-1 day') WHERE id = ?",
306 + rusqlite::params![old.id.to_string()],
307 + )
308 + .unwrap();
309 +
310 + let html = screen_html(get(&state, "/problems", Params::new()));
311 + assert!(html.contains("Stale"), "got: {html}");
312 + }
313 +
314 + #[tokio::test]
315 + async fn a_problem_that_does_not_exist_is_a_404_rather_than_a_crash() {
316 + let state = state().await;
317 + let missing = uuid::Uuid::new_v4();
318 + let answer = router().handle(
319 + &state,
320 + Method::Post,
321 + &format!("/problems/{missing}/status"),
322 + Params::new().with("to", "Open"),
323 + );
324 + assert!(answer.is_err());
325 + }