Skip to main content

max / goingson

20.7 KB · 580 lines History Blame Raw
1 //! The problems inbox, described rather than built.
2 //!
3 //! <!-- wiki: quasi-overview -->
4 //!
5 //! GoingsOn is the list of solutions. A problem is a candidate pulled from
6 //! somewhere else that becomes work only when promoted, which is why nothing
7 //! here creates one and why [`promote`](crate::commands::problem::promote) is
8 //! shared with the command rather than reimplemented: two screens disagreeing
9 //! about what a promotion defaults to would be two meanings of the word.
10 //!
11 //! # The shape
12 //!
13 //! - `GET /problems` — the document.
14 //! - `GET /problems/list` — the ranked list alone, which is what the filters
15 //! swap.
16 //! - `POST /problems/{id}/promote` — make a task from it.
17 //! - `POST /problems/{id}/status` — dismiss it, or send it back to triage.
18 //!
19 //! The filters are query params rather than module state, per decision 2, so
20 //! the view a user is looking at has an address. The source filter needs
21 //! [`sources`] for its options precisely because a filter drawn from whatever
22 //! the last fetch held is a filter that cannot be linked to.
23 //!
24 //! Each status change names its target rather than cycling: a control that
25 //! derives its target from what it was drawn with races anything that already
26 //! moved the row.
27 //!
28 //! # Two bags, so a filter and a write cannot collide
29 //!
30 //! This screen filters on `status` and writes a `status`. A write's values
31 //! arrive in `payload` and the view arrives in `carried`, so both are called
32 //! `status` because that is what each is, and neither can reach the other.
33
34 // Handlers take their request by value because `quasi_router::Handler` is a
35 // plain `fn(&S, Request)` pointer, so the signature is the router's and not a
36 // choice made here. Same allow, for the same reason, as quasi-axum's tests.
37 #![allow(clippy::needless_pass_by_value)]
38
39 use std::collections::{HashMap, HashSet};
40
41 use chrono::{DateTime, Utc};
42 use goingson_core::{Problem, ProblemBand, ProblemFilter, ProblemId, ProblemStatus, ProjectId};
43 use quasi_declare::declare;
44 use quasi_router::layout::Tone;
45 use quasi_router::screen::Tag;
46 use quasi_router::{Action, Response, RouteError, Router};
47
48 use crate::commands::{PromoteProblemInput, promote};
49 use crate::state::{AppState, DESKTOP_USER_ID};
50
51 #[cfg(test)]
52 mod tests;
53
54 /// The status words the filter offers, in triage order. `None` is "everything",
55 /// which the JS spells as a fifth option on the same control.
56 const STATUSES: [Option<ProblemStatus>; 5] = [
57 Some(ProblemStatus::Open),
58 Some(ProblemStatus::Promoted),
59 Some(ProblemStatus::Dismissed),
60 Some(ProblemStatus::Resolved),
61 None,
62 ];
63
64 /// How urgent the score says it is.
65 ///
66 /// `problems.js` maps the band onto its own palette words (red, yellow, blue,
67 /// muted). Here it maps onto what the band *means*, which is the tone, and the
68 /// palette is the renderer's business.
69 const fn band_tone(band: ProblemBand) -> makeover_layout::Tone {
70 match band {
71 ProblemBand::Critical => makeover_layout::Tone::Danger,
72 ProblemBand::High => makeover_layout::Tone::Warning,
73 ProblemBand::Medium => makeover_layout::Tone::Info,
74 ProblemBand::Low => makeover_layout::Tone::Neutral,
75 }
76 }
77
78 /// What the triage state says about itself.
79 const fn status_tone(status: ProblemStatus) -> makeover_layout::Tone {
80 match status {
81 ProblemStatus::Promoted => makeover_layout::Tone::Success,
82 ProblemStatus::Resolved => makeover_layout::Tone::Info,
83 ProblemStatus::Open | ProblemStatus::Dismissed => makeover_layout::Tone::Neutral,
84 }
85 }
86
87 /// A param that is present and not blank. Blank is absent, which is what the
88 /// "all sources" option means.
89 fn text<'a>(params: &'a quasi_router::Params, name: &str) -> Option<&'a str> {
90 params.get(name).map(str::trim).filter(|v| !v.is_empty())
91 }
92
93 /// The status the screen is filtered to.
94 ///
95 /// Absent means `Open`, because the inbox question is what is untriaged;
96 /// `all` means unfiltered. Same three cases as `list_problems`, and an
97 /// unrecognised word is a 404 rather than a silent fall back to `Open`, which
98 /// would answer with a different list than the one asked for.
99 fn status_filter(request: &quasi_router::Request) -> Result<Option<ProblemStatus>, RouteError> {
100 match text(&request.carried, "status") {
101 None => Ok(Some(ProblemStatus::Open)),
102 Some(word) if word.eq_ignore_ascii_case("all") => Ok(None),
103 Some(word) => word
104 .parse()
105 .map(Some)
106 .map_err(|_| RouteError::not_found("not a triage state")),
107 }
108 }
109
110 /// The word a status filter travels as.
111 fn status_word(status: Option<ProblemStatus>) -> &'static str {
112 status.as_ref().map_or("all", ProblemStatus::as_str)
113 }
114
115 /// Carry the current filters on an action, so every control keeps the view it
116 /// was pressed in. The same job `in_month` does on the monthly review.
117 fn filtered(mut action: Action, status: Option<ProblemStatus>, source: Option<&str>) -> Action {
118 action = action.carrying("status", status_word(status));
119 if let Some(source) = source {
120 action = action.carrying("source", source);
121 }
122 action
123 }
124
125 /// The address of the list under a given filter pair.
126 fn list_action(status: Option<ProblemStatus>, source: Option<&str>) -> Action {
127 filtered(Action::get("/problems/list"), status, source)
128 }
129
130 /// Read one problem, or answer 404.
131 fn load(state: &AppState, id: ProblemId) -> Result<Problem, RouteError> {
132 state
133 .problems
134 .get_by_id(id, DESKTOP_USER_ID)
135 .map_err(|error| RouteError::internal(error.to_string()))?
136 .ok_or_else(|| RouteError::not_found("no such problem"))
137 }
138
139 /// Parse the path id, or answer 404.
140 fn problem_id(request: &quasi_router::Request) -> Result<ProblemId, RouteError> {
141 let raw = request
142 .captures
143 .get("id")
144 .ok_or_else(|| RouteError::not_found("no id"))?;
145 uuid::Uuid::parse_str(raw)
146 .map(ProblemId::from)
147 .map_err(|_| RouteError::not_found("not an id"))
148 }
149
150 /// Every source that has ever reported a problem, in a stable order.
151 ///
152 /// Drawn from the whole table rather than from the rows on screen, which is the
153 /// one place this screen deliberately does more work than `problems.js`. That
154 /// file builds the option list out of the rows it last fetched, so filtering to
155 /// a source with nothing Open would empty the control that got you there; it
156 /// carries a workaround pushing the current selection back in. Reading the
157 /// sources from the sources removes the need for one.
158 fn sources(state: &AppState) -> Result<Vec<String>, RouteError> {
159 let all = state
160 .problems
161 .list(DESKTOP_USER_ID, &ProblemFilter::default())
162 .map_err(|error| RouteError::internal(error.to_string()))?;
163
164 let mut sources: Vec<String> = all.into_iter().map(|problem| problem.source).collect();
165 sources.sort_unstable();
166 sources.dedup();
167 Ok(sources)
168 }
169
170 /// One problem as the list draws it: the problem, and the two facts the list
171 /// resolved once for the whole page rather than once per row.
172 struct Listed {
173 problem: Problem,
174 /// Its project's name, if it belongs to one.
175 project: Option<String>,
176 /// Whether its source has stopped reporting it.
177 stale: bool,
178 }
179
180 /// The ranked list, and the filters it was drawn under.
181 ///
182 /// The repository ranks by painhours descending, so nothing re-sorts. The score
183 /// moves with the clock, which is why it is computed on read and why the order
184 /// is the repository's rather than SQL's.
185 struct Listing {
186 rows: Vec<Listed>,
187 status: Option<ProblemStatus>,
188 source: Option<String>,
189 }
190
191 /// Read the list the request asks for, and everything its rows need.
192 ///
193 /// One project lookup for the whole list rather than one per row, and one
194 /// last-pull lookup per distinct source rather than per row. Both are the shape
195 /// `list_problems` already uses.
196 fn read(state: &AppState, request: &quasi_router::Request) -> Result<Listing, RouteError> {
197 let status = status_filter(request)?;
198 let source = text(&request.carried, "source").map(str::to_owned);
199
200 let problems = state
201 .problems
202 .list(
203 DESKTOP_USER_ID,
204 &ProblemFilter {
205 source: source.clone(),
206 status,
207 project_id: None,
208 },
209 )
210 .map_err(|error| RouteError::internal(error.to_string()))?;
211
212 if problems.is_empty() {
213 return Ok(Listing {
214 rows: Vec::new(),
215 status,
216 source,
217 });
218 }
219
220 let projects = state
221 .projects
222 .list_all(DESKTOP_USER_ID)
223 .map_err(|error| RouteError::internal(error.to_string()))?;
224 let name_of = |id: Option<ProjectId>| {
225 id.and_then(|id| {
226 projects
227 .iter()
228 .find(|project| project.id == id)
229 .map(|project| project.name.clone())
230 })
231 };
232
233 let mut last_pulls: HashMap<String, Option<DateTime<Utc>>> = HashMap::new();
234 for name in problems
235 .iter()
236 .map(|problem| problem.source.clone())
237 .collect::<HashSet<_>>()
238 {
239 let at = state
240 .problems
241 .last_pulled_at(DESKTOP_USER_ID, &name)
242 .map_err(|error| RouteError::internal(error.to_string()))?;
243 last_pulls.insert(name, at);
244 }
245
246 let rows = problems
247 .into_iter()
248 .map(|problem| Listed {
249 project: name_of(problem.project_id),
250 stale: last_pulls
251 .get(&problem.source)
252 .copied()
253 .flatten()
254 .is_some_and(|at| problem.is_stale(at)),
255 problem,
256 })
257 .collect();
258
259 Ok(Listing {
260 rows,
261 status,
262 source,
263 })
264 }
265
266 /// Whether the problem carries a body worth drawing under its title.
267 fn has_body(listed: &Listed) -> bool {
268 !listed.problem.body.trim().is_empty()
269 }
270
271 /// The score, as the badge reads it.
272 fn painhours(listed: &Listed) -> String {
273 listed.problem.painhours().to_string()
274 }
275
276 /// The project the problem belongs to, if it belongs to one.
277 fn project_name(listed: &Listed) -> Option<&str> {
278 listed.project.as_deref()
279 }
280
281 /// Where the score came from, and how old the report is.
282 ///
283 /// `rowHtml` puts this in a `title=` on the badge and the source ref in a
284 /// `title=` on the age. A description has no word for "text that appears if you
285 /// hover", and should not grow one: hover is absent on a touch screen and on a
286 /// keyboard, so a `title` is a fact the app knows and most of its users never
287 /// see. Both are said here, where a plain fact belongs.
288 fn score_line(listed: &Listed) -> String {
289 format!(
290 "pain {} x scale {}, aged {} · {}",
291 listed.problem.pain,
292 listed.problem.scale,
293 listed.problem.age(),
294 listed.problem.source_ref,
295 )
296 }
297
298 /// Whether the problem is still waiting for a decision.
299 fn is_open(listed: &Listed) -> bool {
300 listed.problem.status == ProblemStatus::Open
301 }
302
303 /// The task a promotion made, if this problem is promoted.
304 ///
305 /// Guarded on the state rather than on the column alone: the id survives a
306 /// reopen, and a dismissed problem should not offer a task it no longer stands
307 /// behind.
308 fn promoted_task(listed: &Listed) -> Option<goingson_core::TaskId> {
309 (listed.problem.status == ProblemStatus::Promoted)
310 .then_some(listed.problem.promoted_task_id)
311 .flatten()
312 }
313
314 /// The route that promotes this problem, keeping the view it was pressed in.
315 fn promote_action(listing: &Listing, listed: &Listed) -> Action {
316 filtered(
317 Action::post(format!("/problems/{}/promote", listed.problem.id)),
318 listing.status,
319 listing.source.as_deref(),
320 )
321 }
322
323 /// The route that moves this problem to a named triage state.
324 ///
325 /// The target is a param, never derived from what the row was drawn with. Two
326 /// windows on the same inbox therefore cannot disagree about what "the next
327 /// state" was.
328 fn triage_action(listing: &Listing, listed: &Listed, to: &str) -> Action {
329 filtered(
330 Action::post(format!("/problems/{}/status", listed.problem.id)),
331 listing.status,
332 listing.source.as_deref(),
333 )
334 .with("status", to)
335 }
336
337 declare! {
338 /// One problem as a row.
339 ///
340 /// # A finding: the screen never shows staleness
341 ///
342 /// `ProblemResponse` has carried a `stale` flag since the inbox was built,
343 /// and the comment on it explains why staleness is shown rather than
344 /// deleted — a source being briefly unreachable must not erase triage
345 /// history. Nothing in `problems.js` reads the field. So the backend
346 /// computes a fact for the user, serialises it, and the screen drops it on
347 /// the floor. Described, it is a badge like any other. The shipped screen
348 /// carries the same badge as of 2026-08-10, so the two agree until
349 /// `problems.js` retires.
350 ///
351 /// The score leads the row: it is the reason this problem is where it is in
352 /// the list, so it reads before the title in every renderer that puts
353 /// tokens first, and it is the sort key either way.
354 ///
355 /// The source is also the filter, which is the click contacts already
356 /// established on a row's own tags. Not latched: a row says what it
357 /// carries, and whether that is the active filter is the band's question.
358 ///
359 /// A problem its project has shelved is frozen rather than triaged, and the
360 /// two look identical in a ranking that only shows the score, so Dormant is
361 /// a badge of its own.
362 ///
363 /// The moves are the triage state's. The backlink is the point of
364 /// promoting, so a promoted row offers it; the JS switches view and calls
365 /// into the tasks module, and here it is an address, which is the whole of
366 /// what "open the task" means.
367 shape row_for(listing: &Listing, listed: &Listed) -> Row;
368
369 row &listed.problem.title {
370 secondary listed.problem.body.clone() when has_body(listed);
371
372 token Tag::badge(painhours(listed)).tone(band_tone(listed.problem.band()));
373 token Tag::chip(
374 &listed.problem.source,
375 list_action(listing.status, Some(&listed.problem.source))
376 );
377 for project in project_name(listed).into_iter() {
378 token Tag::badge(project).tone(Tone::Info);
379 }
380 token Tag::badge(listed.problem.status.as_str())
381 .tone(status_tone(listed.problem.status))
382 when listed.problem.status.is_settled();
383 token Tag::badge("Dormant").tone(Tone::Neutral) when listed.problem.is_dormant();
384 token Tag::badge("Stale").tone(Tone::Warning) when listed.stale;
385
386 for tag in listed.problem.tags.iter() {
387 token Tag::badge(tag);
388 }
389
390 meta score_line(listed);
391
392 act "Promote" to doing promote_action(listing, listed) when is_open(listed);
393 act "Dismiss" to doing triage_action(listing, listed, "Dismissed") when is_open(listed);
394
395 for task in promoted_task(listed).into_iter() {
396 act "Open task" to get "/tasks/{task}";
397 }
398
399 act "Reopen" to doing triage_action(listing, listed, "Open") unless is_open(listed);
400 }
401 }
402
403 /// What to say when the filter matched nothing.
404 fn nothing_here(listing: &Listing) -> &'static str {
405 match (listing.status, listing.source.as_deref()) {
406 (Some(ProblemStatus::Open), None) => {
407 "Nothing waiting for triage. Problems arrive from wam and from audit runs; \
408 they are candidates, and promoting one makes it a task."
409 }
410 (Some(_), _) | (None, Some(_)) => "No problems match that filter.",
411 (None, None) => "No problems yet.",
412 }
413 }
414
415 declare! {
416 /// The ranked list, filtered the way the screen's two filters filter it.
417 shape ranked(listing: &Listing) -> Node;
418
419 given listing.rows.is_empty() {
420 true -> empty nothing_here(listing);
421 otherwise -> list {
422 for listed in listing.rows.iter() {
423 include row_for(listing, listed);
424 }
425 }
426 }
427 }
428
429 /// Whether the band's status chip for `offered` is the one in force.
430 fn status_latched(listing: &Listing, offered: Option<ProblemStatus>) -> bool {
431 listing.status == offered
432 }
433
434 /// Whether the band's chip for this source is the one in force.
435 fn source_latched(listing: &Listing, offered: &str) -> bool {
436 listing.source.as_deref() == Some(offered)
437 }
438
439 /// The source a press on this chip leaves the list filtered to.
440 ///
441 /// A source that is filtered on stays offered even when it is the only one
442 /// left, so the way back is always on screen: pressing a latched chip clears
443 /// it. Same rule as the contacts tag filter.
444 fn cleared<'a>(listing: &Listing, offered: &'a str) -> Option<&'a str> {
445 (!source_latched(listing, offered)).then_some(offered)
446 }
447
448 declare! {
449 /// The whole screen.
450 shape screen(listing: &Listing, offered: &[String]) -> Screen;
451
452 screen list_detail "Problems" false {
453 at_place super::shell::PROBLEMS;
454
455 region "problems-band" as Band {
456 page "Problems";
457
458 for state in STATUSES {
459 chip status_word(state)
460 to doing list_action(state, listing.source.as_deref()) {
461 latched status_latched(listing, state);
462 }
463 }
464
465 for source in offered.iter() {
466 chip source to doing list_action(listing.status, cleared(listing, source)) {
467 latched source_latched(listing, source);
468 }
469 }
470 }
471
472 region "problems-list" as Pane {
473 include ranked(listing);
474 }
475 }
476 }
477
478 /// The whole screen, as an answer.
479 fn index(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
480 let listing = read(state, &request)?;
481 Ok(screen(&listing, &sources(state)?).into())
482 }
483
484 /// The list alone, which is what a filter chip replaces.
485 fn list(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
486 Ok(Response::fragment(
487 "problems-list",
488 ranked(&read(state, &request)?),
489 ))
490 }
491
492 /// Answer a triage decision with the list it happened in, re-read.
493 ///
494 /// Re-read rather than patched in memory, for the reason the contacts removals
495 /// are: the row may well leave the list it was in, since the filter it was
496 /// pressed under is usually `Open` and the press is what settles it.
497 fn triaged(
498 state: &AppState,
499 request: &quasi_router::Request,
500 message: &str,
501 ) -> Result<Response, RouteError> {
502 Ok(
503 Response::fragment("problems-list", ranked(&read(state, request)?))
504 .toast(Tone::Success, message),
505 )
506 }
507
508 /// Make a task from a problem.
509 ///
510 /// One press with nothing to fill in, which is `problems.js`'s decision and a
511 /// good one: the description defaults to the problem's own text and the
512 /// priority to its painhours band, and both are better than anything retyped at
513 /// triage time. Shape the task afterwards if it needs it.
514 fn promote_one(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
515 let id = problem_id(&request)?;
516 let outcome = promote(
517 state,
518 id,
519 &PromoteProblemInput {
520 description: None,
521 priority: None,
522 },
523 )
524 .map_err(|error| RouteError::internal(error.to_string()))?;
525
526 triaged(
527 state,
528 &request,
529 if outcome.created {
530 "Promoted to a task."
531 } else {
532 "Already promoted; the task it made is on the row."
533 },
534 )
535 }
536
537 /// Move a problem to a named triage state.
538 ///
539 /// The target is a param, never derived from what the row was drawn with. Two
540 /// windows on the same inbox therefore cannot disagree about what "the next
541 /// state" was.
542 fn set_status(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
543 let id = problem_id(&request)?;
544 let target: ProblemStatus = request
545 .payload
546 .get("status")
547 .ok_or_else(|| RouteError::not_found("no status"))?
548 .parse()
549 .map_err(|_| RouteError::not_found("not a triage state"))?;
550
551 // The row has to exist before the message can claim anything happened to it.
552 load(state, id)?;
553 state
554 .problems
555 .set_status(id, DESKTOP_USER_ID, target)
556 .map_err(|error| RouteError::internal(error.to_string()))?
557 .ok_or_else(|| RouteError::not_found("no such problem"))?;
558
559 triaged(
560 state,
561 &request,
562 match target {
563 ProblemStatus::Open => "Back in triage.",
564 ProblemStatus::Dismissed => "Dismissed. It stays down through the next pull.",
565 ProblemStatus::Promoted => "Marked promoted.",
566 ProblemStatus::Resolved => "Marked resolved.",
567 },
568 )
569 }
570
571 /// The problems screen's routes.
572 #[must_use]
573 pub fn routes(router: Router<AppState>) -> Router<AppState> {
574 router
575 .get("/problems", index)
576 .get("/problems/list", list)
577 .post("/problems/{id}/promote", promote_one)
578 .post("/problems/{id}/status", set_status)
579 }
580