//! The problems inbox, described rather than built. //! //! //! //! GoingsOn is the list of solutions. A problem is a candidate pulled from //! somewhere else that becomes work only when promoted, which is why nothing //! here creates one and why [`promote`](crate::commands::problem::promote) is //! shared with the command rather than reimplemented: two screens disagreeing //! about what a promotion defaults to would be two meanings of the word. //! //! # The shape //! //! - `GET /problems` — the document. //! - `GET /problems/list` — the ranked list alone, which is what the filters //! swap. //! - `POST /problems/{id}/promote` — make a task from it. //! - `POST /problems/{id}/status` — dismiss it, or send it back to triage. //! //! The filters are query params rather than module state, per decision 2, so //! the view a user is looking at has an address. The source filter needs //! [`sources`] for its options precisely because a filter drawn from whatever //! the last fetch held is a filter that cannot be linked to. //! //! Each status change names its target rather than cycling: a control that //! derives its target from what it was drawn with races anything that already //! moved the row. //! //! # Two bags, so a filter and a write cannot collide //! //! This screen filters on `status` and writes a `status`. A write's values //! arrive in `payload` and the view arrives in `carried`, so both are called //! `status` because that is what each is, and neither can reach the other. // Handlers take their request by value because `quasi_router::Handler` is a // plain `fn(&S, Request)` pointer, so the signature is the router's and not a // choice made here. Same allow, for the same reason, as quasi-axum's tests. #![allow(clippy::needless_pass_by_value)] use std::collections::{HashMap, HashSet}; use chrono::{DateTime, Utc}; use goingson_core::{Problem, ProblemBand, ProblemFilter, ProblemId, ProblemStatus, ProjectId}; use quasi_declare::declare; use quasi_router::layout::Tone; use quasi_router::screen::Tag; use quasi_router::{Action, Response, RouteError, Router}; use crate::commands::{PromoteProblemInput, promote}; use crate::state::{AppState, DESKTOP_USER_ID}; #[cfg(test)] mod tests; /// The status words the filter offers, in triage order. `None` is "everything", /// which the JS spells as a fifth option on the same control. const STATUSES: [Option; 5] = [ Some(ProblemStatus::Open), Some(ProblemStatus::Promoted), Some(ProblemStatus::Dismissed), Some(ProblemStatus::Resolved), None, ]; /// How urgent the score says it is. /// /// `problems.js` maps the band onto its own palette words (red, yellow, blue, /// muted). Here it maps onto what the band *means*, which is the tone, and the /// palette is the renderer's business. const fn band_tone(band: ProblemBand) -> makeover_layout::Tone { match band { ProblemBand::Critical => makeover_layout::Tone::Danger, ProblemBand::High => makeover_layout::Tone::Warning, ProblemBand::Medium => makeover_layout::Tone::Info, ProblemBand::Low => makeover_layout::Tone::Neutral, } } /// What the triage state says about itself. const fn status_tone(status: ProblemStatus) -> makeover_layout::Tone { match status { ProblemStatus::Promoted => makeover_layout::Tone::Success, ProblemStatus::Resolved => makeover_layout::Tone::Info, ProblemStatus::Open | ProblemStatus::Dismissed => makeover_layout::Tone::Neutral, } } /// A param that is present and not blank. Blank is absent, which is what the /// "all sources" option means. fn text<'a>(params: &'a quasi_router::Params, name: &str) -> Option<&'a str> { params.get(name).map(str::trim).filter(|v| !v.is_empty()) } /// The status the screen is filtered to. /// /// Absent means `Open`, because the inbox question is what is untriaged; /// `all` means unfiltered. Same three cases as `list_problems`, and an /// unrecognised word is a 404 rather than a silent fall back to `Open`, which /// would answer with a different list than the one asked for. fn status_filter(request: &quasi_router::Request) -> Result, RouteError> { match text(&request.carried, "status") { None => Ok(Some(ProblemStatus::Open)), Some(word) if word.eq_ignore_ascii_case("all") => Ok(None), Some(word) => word .parse() .map(Some) .map_err(|_| RouteError::not_found("not a triage state")), } } /// The word a status filter travels as. fn status_word(status: Option) -> &'static str { status.as_ref().map_or("all", ProblemStatus::as_str) } /// Carry the current filters on an action, so every control keeps the view it /// was pressed in. The same job `in_month` does on the monthly review. fn filtered(mut action: Action, status: Option, source: Option<&str>) -> Action { action = action.carrying("status", status_word(status)); if let Some(source) = source { action = action.carrying("source", source); } action } /// The address of the list under a given filter pair. fn list_action(status: Option, source: Option<&str>) -> Action { filtered(Action::get("/problems/list"), status, source) } /// Read one problem, or answer 404. fn load(state: &AppState, id: ProblemId) -> Result { state .problems .get_by_id(id, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))? .ok_or_else(|| RouteError::not_found("no such problem")) } /// Parse the path id, or answer 404. fn problem_id(request: &quasi_router::Request) -> Result { let raw = request .captures .get("id") .ok_or_else(|| RouteError::not_found("no id"))?; uuid::Uuid::parse_str(raw) .map(ProblemId::from) .map_err(|_| RouteError::not_found("not an id")) } /// Every source that has ever reported a problem, in a stable order. /// /// Drawn from the whole table rather than from the rows on screen, which is the /// one place this screen deliberately does more work than `problems.js`. That /// file builds the option list out of the rows it last fetched, so filtering to /// a source with nothing Open would empty the control that got you there; it /// carries a workaround pushing the current selection back in. Reading the /// sources from the sources removes the need for one. fn sources(state: &AppState) -> Result, RouteError> { let all = state .problems .list(DESKTOP_USER_ID, &ProblemFilter::default()) .map_err(|error| RouteError::internal(error.to_string()))?; let mut sources: Vec = all.into_iter().map(|problem| problem.source).collect(); sources.sort_unstable(); sources.dedup(); Ok(sources) } /// One problem as the list draws it: the problem, and the two facts the list /// resolved once for the whole page rather than once per row. struct Listed { problem: Problem, /// Its project's name, if it belongs to one. project: Option, /// Whether its source has stopped reporting it. stale: bool, } /// The ranked list, and the filters it was drawn under. /// /// The repository ranks by painhours descending, so nothing re-sorts. The score /// moves with the clock, which is why it is computed on read and why the order /// is the repository's rather than SQL's. struct Listing { rows: Vec, status: Option, source: Option, } /// Read the list the request asks for, and everything its rows need. /// /// One project lookup for the whole list rather than one per row, and one /// last-pull lookup per distinct source rather than per row. Both are the shape /// `list_problems` already uses. fn read(state: &AppState, request: &quasi_router::Request) -> Result { let status = status_filter(request)?; let source = text(&request.carried, "source").map(str::to_owned); let problems = state .problems .list( DESKTOP_USER_ID, &ProblemFilter { source: source.clone(), status, project_id: None, }, ) .map_err(|error| RouteError::internal(error.to_string()))?; if problems.is_empty() { return Ok(Listing { rows: Vec::new(), status, source, }); } let projects = state .projects .list_all(DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))?; let name_of = |id: Option| { id.and_then(|id| { projects .iter() .find(|project| project.id == id) .map(|project| project.name.clone()) }) }; let mut last_pulls: HashMap>> = HashMap::new(); for name in problems .iter() .map(|problem| problem.source.clone()) .collect::>() { let at = state .problems .last_pulled_at(DESKTOP_USER_ID, &name) .map_err(|error| RouteError::internal(error.to_string()))?; last_pulls.insert(name, at); } let rows = problems .into_iter() .map(|problem| Listed { project: name_of(problem.project_id), stale: last_pulls .get(&problem.source) .copied() .flatten() .is_some_and(|at| problem.is_stale(at)), problem, }) .collect(); Ok(Listing { rows, status, source, }) } /// Whether the problem carries a body worth drawing under its title. fn has_body(listed: &Listed) -> bool { !listed.problem.body.trim().is_empty() } /// The score, as the badge reads it. fn painhours(listed: &Listed) -> String { listed.problem.painhours().to_string() } /// The project the problem belongs to, if it belongs to one. fn project_name(listed: &Listed) -> Option<&str> { listed.project.as_deref() } /// Where the score came from, and how old the report is. /// /// `rowHtml` puts this in a `title=` on the badge and the source ref in a /// `title=` on the age. A description has no word for "text that appears if you /// hover", and should not grow one: hover is absent on a touch screen and on a /// keyboard, so a `title` is a fact the app knows and most of its users never /// see. Both are said here, where a plain fact belongs. fn score_line(listed: &Listed) -> String { format!( "pain {} x scale {}, aged {} · {}", listed.problem.pain, listed.problem.scale, listed.problem.age(), listed.problem.source_ref, ) } /// Whether the problem is still waiting for a decision. fn is_open(listed: &Listed) -> bool { listed.problem.status == ProblemStatus::Open } /// The task a promotion made, if this problem is promoted. /// /// Guarded on the state rather than on the column alone: the id survives a /// reopen, and a dismissed problem should not offer a task it no longer stands /// behind. fn promoted_task(listed: &Listed) -> Option { (listed.problem.status == ProblemStatus::Promoted) .then_some(listed.problem.promoted_task_id) .flatten() } /// The route that promotes this problem, keeping the view it was pressed in. fn promote_action(listing: &Listing, listed: &Listed) -> Action { filtered( Action::post(format!("/problems/{}/promote", listed.problem.id)), listing.status, listing.source.as_deref(), ) } /// The route that moves this problem to a named triage state. /// /// The target is a param, never derived from what the row was drawn with. Two /// windows on the same inbox therefore cannot disagree about what "the next /// state" was. fn triage_action(listing: &Listing, listed: &Listed, to: &str) -> Action { filtered( Action::post(format!("/problems/{}/status", listed.problem.id)), listing.status, listing.source.as_deref(), ) .with("status", to) } declare! { /// One problem as a row. /// /// # A finding: the screen never shows staleness /// /// `ProblemResponse` has carried a `stale` flag since the inbox was built, /// and the comment on it explains why staleness is shown rather than /// deleted — a source being briefly unreachable must not erase triage /// history. Nothing in `problems.js` reads the field. So the backend /// computes a fact for the user, serialises it, and the screen drops it on /// the floor. Described, it is a badge like any other. The shipped screen /// carries the same badge as of 2026-08-10, so the two agree until /// `problems.js` retires. /// /// The score leads the row: it is the reason this problem is where it is in /// the list, so it reads before the title in every renderer that puts /// tokens first, and it is the sort key either way. /// /// The source is also the filter, which is the click contacts already /// established on a row's own tags. Not latched: a row says what it /// carries, and whether that is the active filter is the band's question. /// /// A problem its project has shelved is frozen rather than triaged, and the /// two look identical in a ranking that only shows the score, so Dormant is /// a badge of its own. /// /// The moves are the triage state's. The backlink is the point of /// promoting, so a promoted row offers it; the JS switches view and calls /// into the tasks module, and here it is an address, which is the whole of /// what "open the task" means. shape row_for(listing: &Listing, listed: &Listed) -> Row; row &listed.problem.title { secondary listed.problem.body.clone() when has_body(listed); token Tag::badge(painhours(listed)).tone(band_tone(listed.problem.band())); token Tag::chip( &listed.problem.source, list_action(listing.status, Some(&listed.problem.source)) ); for project in project_name(listed).into_iter() { token Tag::badge(project).tone(Tone::Info); } token Tag::badge(listed.problem.status.as_str()) .tone(status_tone(listed.problem.status)) when listed.problem.status.is_settled(); token Tag::badge("Dormant").tone(Tone::Neutral) when listed.problem.is_dormant(); token Tag::badge("Stale").tone(Tone::Warning) when listed.stale; for tag in listed.problem.tags.iter() { token Tag::badge(tag); } meta score_line(listed); act "Promote" to doing promote_action(listing, listed) when is_open(listed); act "Dismiss" to doing triage_action(listing, listed, "Dismissed") when is_open(listed); for task in promoted_task(listed).into_iter() { act "Open task" to get "/tasks/{task}"; } act "Reopen" to doing triage_action(listing, listed, "Open") unless is_open(listed); } } /// What to say when the filter matched nothing. fn nothing_here(listing: &Listing) -> &'static str { match (listing.status, listing.source.as_deref()) { (Some(ProblemStatus::Open), None) => { "Nothing waiting for triage. Problems arrive from wam and from audit runs; \ they are candidates, and promoting one makes it a task." } (Some(_), _) | (None, Some(_)) => "No problems match that filter.", (None, None) => "No problems yet.", } } declare! { /// The ranked list, filtered the way the screen's two filters filter it. shape ranked(listing: &Listing) -> Node; given listing.rows.is_empty() { true -> empty nothing_here(listing); otherwise -> list { for listed in listing.rows.iter() { include row_for(listing, listed); } } } } /// Whether the band's status chip for `offered` is the one in force. fn status_latched(listing: &Listing, offered: Option) -> bool { listing.status == offered } /// Whether the band's chip for this source is the one in force. fn source_latched(listing: &Listing, offered: &str) -> bool { listing.source.as_deref() == Some(offered) } /// The source a press on this chip leaves the list filtered to. /// /// A source that is filtered on stays offered even when it is the only one /// left, so the way back is always on screen: pressing a latched chip clears /// it. Same rule as the contacts tag filter. fn cleared<'a>(listing: &Listing, offered: &'a str) -> Option<&'a str> { (!source_latched(listing, offered)).then_some(offered) } declare! { /// The whole screen. shape screen(listing: &Listing, offered: &[String]) -> Screen; screen list_detail "Problems" false { at_place super::shell::PROBLEMS; region "problems-band" as Band { page "Problems"; for state in STATUSES { chip status_word(state) to doing list_action(state, listing.source.as_deref()) { latched when status_latched(listing, state); } } for source in offered.iter() { chip source to doing list_action(listing.status, cleared(listing, source)) { latched when source_latched(listing, source); } } } region "problems-list" as Pane { include ranked(listing); } } } /// The whole screen, as an answer. fn index(state: &AppState, request: quasi_router::Request) -> Result { let listing = read(state, &request)?; Ok(screen(&listing, &sources(state)?).into()) } /// The list alone, which is what a filter chip replaces. fn list(state: &AppState, request: quasi_router::Request) -> Result { Ok(Response::fragment( "problems-list", ranked(&read(state, &request)?), )) } /// Answer a triage decision with the list it happened in, re-read. /// /// Re-read rather than patched in memory, for the reason the contacts removals /// are: the row may well leave the list it was in, since the filter it was /// pressed under is usually `Open` and the press is what settles it. fn triaged( state: &AppState, request: &quasi_router::Request, message: &str, ) -> Result { Ok( Response::fragment("problems-list", ranked(&read(state, request)?)) .toast(Tone::Success, message), ) } /// Make a task from a problem. /// /// One press with nothing to fill in, which is `problems.js`'s decision and a /// good one: the description defaults to the problem's own text and the /// priority to its painhours band, and both are better than anything retyped at /// triage time. Shape the task afterwards if it needs it. fn promote_one(state: &AppState, request: quasi_router::Request) -> Result { let id = problem_id(&request)?; let outcome = promote( state, id, &PromoteProblemInput { description: None, priority: None, }, ) .map_err(|error| RouteError::internal(error.to_string()))?; triaged( state, &request, if outcome.created { "Promoted to a task." } else { "Already promoted; the task it made is on the row." }, ) } /// Move a problem to a named triage state. /// /// The target is a param, never derived from what the row was drawn with. Two /// windows on the same inbox therefore cannot disagree about what "the next /// state" was. fn set_status(state: &AppState, request: quasi_router::Request) -> Result { let id = problem_id(&request)?; let target: ProblemStatus = request .payload .get("status") .ok_or_else(|| RouteError::not_found("no status"))? .parse() .map_err(|_| RouteError::not_found("not a triage state"))?; // The row has to exist before the message can claim anything happened to it. load(state, id)?; state .problems .set_status(id, DESKTOP_USER_ID, target) .map_err(|error| RouteError::internal(error.to_string()))? .ok_or_else(|| RouteError::not_found("no such problem"))?; triaged( state, &request, match target { ProblemStatus::Open => "Back in triage.", ProblemStatus::Dismissed => "Dismissed. It stays down through the next pull.", ProblemStatus::Promoted => "Marked promoted.", ProblemStatus::Resolved => "Marked resolved.", }, ) } /// The problems screen's routes. #[must_use] pub fn routes(router: Router) -> Router { router .get("/problems", index) .get("/problems/list", list) .post("/problems/{id}/promote", promote_one) .post("/problems/{id}/status", set_status) }