//! The task list, described rather than built. //! //! //! //! Three modules serve tasks and they are easy to confuse: //! [`tasks`](super::tasks) is the single-task drawer at `GET /tasks/{id}`, //! [`board`](super::board) is the kanban, and this is the list. //! //! # The shape //! //! - `GET /tasks` — the whole screen. //! - `GET /tasks/list` — the table alone, which is what a filter, a sort, or //! "show more" swaps. //! - `POST /tasks/list/{id}/status` — start it, complete it, or send it back to //! Pending, carrying `status`. //! - `POST /tasks/list/{id}/delete` — delete it. //! //! The writes are under `/tasks/list/` rather than beside the drawer's own //! `POST /tasks/{id}/complete`: a write is answered by the region it happened //! in, the drawer answers with the drawer, and one route cannot answer both. //! The three status writes themselves are [`super::move_to`], shared with the //! board, because "set the status column" is three different writes and two //! copies of that is two answers to what completing a task means. //! //! # The table //! //! Seven columns, four of them sortable, described as //! [`COLUMNS`]. `build.rs` holds the same seven as `makeover_layout::Column` //! for the narrowing CSS it emits; the runtime one carries an address and the //! build-time one cannot. Nothing checks that the two agree, so add a column in //! both by hand. //! //! A [`Table`] carries a [`Rest`](quasi_router::screen::Rest), which is a //! `layout::Paging`: the description carries where the reader is rather than //! merely that there is more. At `PAGE * PAGES` there is no address that would //! show anything new, so the ceiling is a sentence rather than a control, and //! narrowing is the way through a list that long. //! //! Virtual scrolling is a renderer technique over rows the app already holds, //! and a description has no word for it. A `Rest` is a fact about rows that //! were never fetched, which is a different thing. //! //! # Selection //! //! A table row joins a selection through `Screen::selecting`, //! [`Row::ticking`] and [`Act::over`]; the picker half of a bulk bar is //! [`Act::asks`]. See [`bulk`] for what the bar holds and [`View::ticked`] for //! why select-all is an address. //! //! The **count** of ticked rows is not sayable: the ticks are the host's until //! something submits them, so the description cannot know the number and a //! renderer knows exactly. Recorded on [`bulk`]. Shift-range selection is the //! host's for the same reason. //! //! Saved views are a screen this one does not have. A view *is* an address //! here; naming and listing views is a store and a screen of its own. //! //! # What the bar offers //! //! Five controls in two kinds: Complete and Delete act on the set, while //! snooze, project and priority apply a value to it. There is no word for an //! undo window, so Delete confirms instead, the same trade the row's own Delete //! makes. //! //! # What the row offers, and what it does not //! //! Start, Complete, Delete, and the title opens the drawer. Edit, Manage //! Subtasks, Add Note, Attachments, Snooze, Schedule, Track Time and Focus all //! live on the drawer the title opens: each is a modal over the list, and a //! modal form over a screen is a second arrangement this screen would have to //! describe before it could offer it. //! //! Deletion is immediate, so it is confirmed, which is the standard the drawer //! and the projects screen hold. //! //! # The default order //! //! `due` ascending, not `list_tasks_filtered`'s urgency descending. Urgency is //! a column in [`TaskSortColumn`] with no heading to press, so it is a sort //! this list cannot reach. // 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::time::{Duration, SystemTime}; use goingson_core::{ MilestoneId, Priority, ProjectId, SortDirection, Task, TaskFilterQuery, TaskId, TaskSortColumn, TaskStatus, }; use makeover_layout::{Sort, Tone}; use quasi_declare::declare; use quasi_router::screen::{Choice, Consult, Figure, Meter, Rest, Tag}; use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Slot}; use crate::state::{AppState, DESKTOP_USER_ID}; #[cfg(test)] mod tests; /// How many rows a page is. /// /// One scroll's worth of rows. const PAGE: i64 = 200; /// The ceiling on `shown`. /// /// Ten pages, which stops a hand-typed address asking for a million rows. const PAGES: i64 = 10; /// What the screen calls its set of ticks. /// /// One name, in one place: [`Screen::selecting`] declares it, every row joins /// it, and every control over it names it. See [`Act::over`] for why a renderer /// does not match the two against each other — a fragment carries no screen — /// which is exactly why this is a constant rather than a string typed five /// times. const SELECTION: &str = "chosen"; /// What "no project" travels as on the bulk picker. /// /// A blank means "the control has not been used", so clearing a project needs a /// word of its own. const NONE: &str = "none"; /// The statuses the filter offers, in the order the control reads. /// /// `None` is "every status". `Deleted` is not offered: a deleted task is not on the list, and /// `list_filtered` does not return one. const STATUSES: [Option; 4] = [ Some(TaskStatus::Pending), Some(TaskStatus::Started), Some(TaskStatus::Completed), None, ]; /// The priorities the filter offers. const PRIORITIES: [Priority; 3] = [Priority::High, Priority::Medium, Priority::Low]; /// The word a priority travels and reads as. /// /// **Not `Priority::as_str`, which is the single letter `H`/`M`/`L`.** That /// method is a display abbreviation for the priority *column*, where the cell is /// one character wide; `TaskStatus::as_str` next to it is the stored word. Two /// methods, one name, two different kinds of answer — and the filter chips were /// built with the wrong one, so every priority chip on this screen offered /// `?priority=H`, which [`View::of`] answers with a 404. Caught by the test that /// presses one. const fn priority_word(priority: &Priority) -> &'static str { match priority { Priority::High => "High", Priority::Medium => "Medium", Priority::Low => "Low", } } /// The tone a priority wears. [`super::tasks`]'s `priority_tone`, which is the /// same mapping and is not re-derived here on purpose. const fn priority_tone(priority: &Priority) -> Tone { match priority { Priority::High => Tone::Danger, Priority::Medium => Tone::Warning, Priority::Low => Tone::Neutral, } } /// The word a sort column travels as. /// /// `TaskSortColumn::from_str_or_default` reads these and defaults silently, /// which is right for a command taking whatever a caller sent and wrong for an /// address; [`View::of`] parses strictly against the same words. const fn sort_word(column: TaskSortColumn) -> &'static str { match column { TaskSortColumn::Description => "description", TaskSortColumn::Project => "project", TaskSortColumn::Priority => "priority", TaskSortColumn::Due => "due", TaskSortColumn::Urgency => "urgency", } } /// A param that is present and not blank. Blank is absent, which is what the /// "all projects" option means. fn text(params: &quasi_router::Params, name: &str) -> Option { params .get(name) .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_owned) } /// A uuid-shaped param, or a 404. /// /// An id that does not parse is a wiring mistake, and answering it with the /// unfiltered list hides one. fn id_param>( params: &quasi_router::Params, name: &str, ) -> Result, RouteError> { match text(params, name) { None => Ok(None), Some(raw) => uuid::Uuid::parse_str(&raw) .map(|id| Some(T::from(id))) .map_err(|_| RouteError::not_found("not an id")), } } /// Which rows, in what order, and how many of them. /// /// Query params rather than module state, per decision 2: a view is an address, /// and the address is the only copy. #[derive(Clone, PartialEq)] struct View { /// The status being looked at. `None` is every status. status: Option, /// The project being looked at, if it is one project. project: Option, /// The milestone within that project, if it is one milestone. milestone: Option, /// The priority being looked at, if it is one priority. priority: Option, /// Whether snoozed tasks are included. snoozed: bool, /// Whether the list is cut to what is waiting on somebody else. waiting: bool, /// Whether the rows arrive ticked. /// /// **Select-all, and it is an address rather than client state.** A /// renderer could tick every box it drew, and a webview one would need a /// script this crate does not ship; a terminal would need a key it invents. /// Answering it from the server costs one query against a local SQLite file /// and every host gets it for free, works with JS off, and survives the /// fragment swap — which the client-side version does not, since the swap /// replaces the boxes. /// /// Only the arriving state. What the user unticks afterwards is the host's, /// exactly as it is for a tick they made themselves, and nothing here tries /// to follow it: a description that tracked individual ticks would be /// carrying two hundred ids in an address. ticked: bool, /// What the table is ordered by. sort: TaskSortColumn, /// Which way. direction: SortDirection, /// How many rows are on screen. /// /// An address cannot append, so this says how many rows the list shows and /// the query asks for that many from the top. The address re-opens to what /// it described. shown: i64, } impl Default for View { /// What `GET /tasks` with no params is: pending work, most urgent first by /// due date, one page of it. fn default() -> Self { Self { status: Some(TaskStatus::Pending), project: None, milestone: None, priority: None, snoozed: false, waiting: false, ticked: false, sort: TaskSortColumn::Due, direction: SortDirection::Asc, shown: PAGE, } } } impl View { /// The view a route was addressed at. fn of(request: &quasi_router::Request) -> Result { let status = match text(&request.carried, "status") { None => Some(TaskStatus::Pending), Some(word) if word.eq_ignore_ascii_case("all") => None, Some(word) => Some(match word.as_str() { "Pending" => TaskStatus::Pending, "Started" => TaskStatus::Started, "Completed" => TaskStatus::Completed, _ => return Err(RouteError::not_found("not a task status")), }), }; let priority = match text(&request.carried, "priority") { None => None, Some(word) => Some(match word.as_str() { "High" => Priority::High, "Medium" => Priority::Medium, "Low" => Priority::Low, _ => return Err(RouteError::not_found("not a priority")), }), }; let sort = match text(&request.carried, "sort") { None => TaskSortColumn::Due, Some(word) => match word.as_str() { "description" => TaskSortColumn::Description, "project" => TaskSortColumn::Project, "priority" => TaskSortColumn::Priority, "due" => TaskSortColumn::Due, "urgency" => TaskSortColumn::Urgency, _ => return Err(RouteError::not_found("not a sortable column")), }, }; Ok(Self { status, project: id_param(&request.carried, "project")?, milestone: id_param(&request.carried, "milestone")?, priority, snoozed: matches!(request.carried.get("snoozed"), Some("1" | "true")), waiting: matches!(request.carried.get("waiting"), Some("1" | "true")), ticked: matches!(request.carried.get("ticked"), Some("all")), sort, direction: match request.carried.get("direction") { Some("desc") => SortDirection::Desc, _ => SortDirection::Asc, }, // Clamped rather than refused, for the reason the mail list gives: // this is an address, and landing on the first page is a more // useful answer than an error page. shown: request .carried .get("shown") .and_then(|raw| raw.parse::().ok()) .unwrap_or(PAGE) .clamp(PAGE, PAGE * PAGES), }) } /// The same action, still pointed at the view it was offered under. /// /// A default is never written, so two addresses for one view cannot exist. fn carry(&self, action: Action) -> Action { let mut action = action; match &self.status { Some(TaskStatus::Pending) => {} Some(status) => action = action.carrying("status", status.as_str()), None => action = action.carrying("status", "all"), } if let Some(project) = self.project { action = action.carrying("project", project.to_string()); } if let Some(milestone) = self.milestone { action = action.carrying("milestone", milestone.to_string()); } if let Some(priority) = &self.priority { action = action.carrying("priority", priority_word(priority)); } if self.snoozed { action = action.carrying("snoozed", "1"); } if self.waiting { action = action.carrying("waiting", "1"); } if self.ticked { action = action.carrying("ticked", "all"); } if self.sort != TaskSortColumn::Due { action = action.carrying("sort", sort_word(self.sort)); } if self.direction == SortDirection::Desc { action = action.carrying("direction", "desc"); } if self.shown != PAGE { action = action.carrying("shown", self.shown.to_string()); } action } /// The address of the table under this view. fn list(&self) -> Action { self.carry(Action::get("/tasks/list")) } /// The same view showing one page, with nothing ticked. /// /// A filter change is a new set of rows, so `shown` goes back to one page: /// carrying it would ask for 2000 rows of a project holding nine. /// /// It drops the ticks too. Bulk actions must not target rows the user can /// no longer see: the boxes a user made go with the rows, but `ticked=all` /// rides on the address, and carrying it through a filter change would let /// "everything" silently come to mean a different everything. fn first_page(&self) -> Self { Self { shown: PAGE, ticked: false, ..self.clone() } } /// The view ordered by this column: flipped if it is already the sort, /// ascending if it is not. /// /// `sortTasks`, which also has an "descending if urgency" branch that has /// never run because urgency has no heading to press. fn sorted_by(&self, column: TaskSortColumn) -> Self { Self { sort: column, direction: if self.sort == column { match self.direction { SortDirection::Asc => SortDirection::Desc, SortDirection::Desc => SortDirection::Asc, } } else { SortDirection::Asc }, ..self.first_page() } } /// The same view under one status. fn with_status(&self, status: Option) -> Self { Self { status, ..self.first_page() } } /// The same view with this priority on, or off if it already was. /// /// Pressing the latched one clears it, so the way back is always on screen. /// The contacts tag filter's rule. fn toggling_priority(&self, priority: &Priority) -> Self { Self { priority: (self.priority.as_ref() != Some(priority)).then(|| priority.clone()), ..self.first_page() } } /// The same view with the snoozed rows the other way round. fn toggling_snoozed(&self) -> Self { Self { snoozed: !self.snoozed, ..self.first_page() } } /// The same view cut to what is waiting, or not. fn toggling_waiting(&self) -> Self { Self { waiting: !self.waiting, ..self.first_page() } } /// The same view with no project and no milestone. /// /// Clearing the project clears the milestone with it: a milestone id that /// outlived its project filters to a project the view no longer names, and /// the control that would clear it is not on screen. fn clearing_project(&self) -> Self { Self { project: None, milestone: None, ..self.first_page() } } /// The same view with no milestone. fn clearing_milestone(&self) -> Self { Self { milestone: None, ..self.first_page() } } /// The same view with every row arriving ticked, or none of them. fn ticking(&self, ticked: bool) -> Self { Self { ticked, ..self.clone() } } /// The same view showing one more page. fn showing(&self, shown: i64) -> Self { Self { shown, ..self.clone() } } /// What this view asks the repository for. fn query(&self) -> TaskFilterQuery { TaskFilterQuery { status: self.status.clone(), project_id: self.project, milestone_id: self.milestone, priority: self.priority.clone(), show_snoozed: self.snoozed, waiting_only: self.waiting, offset: Some(0), limit: Some(self.shown), sort_column: Some(self.sort), sort_direction: Some(self.direction), } } /// Whether anything has been narrowed. What "Clear filters" is offered for, /// and it deliberately ignores the sort and the page: neither hides a row. fn filtered(&self) -> bool { let default = Self::default(); self.status != default.status || self.project.is_some() || self.milestone.is_some() || self.priority.is_some() || self.snoozed || self.waiting } } /// Minutes, the way the row says them. `formatMinutes` in `tasks-render.js`. fn minutes(total: i32) -> String { if total >= 60 { format!("{}h {}m", total / 60, total % 60) } else { format!("{total}m") } } /// What the row says about time spent on the task. /// /// The tracked-against-estimated badge, for a task whose timer is not running. /// A running one says how long it has been running instead, and that is /// [`running_for`] rather than a badge: a readout derived from the current time /// is a node carrying an instant, so the row says when the timer started and /// the renderer says how long ago that was. A badge saying "18m" would be /// describing the moment it was rendered. fn time_token(task: &Task) -> Option { if task.has_active_timer() { return None; } let tracked = task.actual_minutes; match (tracked, task.estimated_minutes) { (0, None) => None, (_, Some(estimate)) => Some( Tag::badge(format!("{} / {}", minutes(tracked), minutes(estimate))).tone( if task.is_over_estimate() { Tone::Warning } else { Tone::Neutral }, ), ), (_, None) => Some(Tag::badge(minutes(tracked)).tone(Tone::Neutral)), } } /// When a running timer started, for the readout that counts up from it. /// /// `None` when nothing is running, which is every row that is not the one being /// worked on. This is the whole of the app's half: the words, the format and the /// tick are the renderer's, which is what let `time-tracking.js`'s per-second /// subtraction stop being the app's problem. fn running_for(task: &Task) -> Option { let started = task.active_session.as_ref()?.started_at; Some(SystemTime::UNIX_EPOCH + Duration::from_secs(u64::try_from(started.timestamp()).ok()?)) } /// What a task's commits say about themselves. fn commit_label(task: &Task) -> &'static str { if task.status_token_summary() == "complete" { "Commits pushed" } else { "Commits unpushed" } } /// And what that means. fn commit_tone(task: &Task) -> Tone { if task.status_token_summary() == "complete" { Tone::Success } else { Tone::Warning } } /// Whether a waiting task has been waiting too long. fn waiting_tone(task: &Task) -> Tone { if task.is_response_overdue() { Tone::Warning } else { Tone::Neutral } } /// Whether the task can still be finished. fn can_complete(task: &Task) -> bool { matches!(task.status, TaskStatus::Pending | TaskStatus::Started) } /// Whether the task has subtasks to show progress over. fn has_subtasks(task: &Task) -> bool { task.subtask_count() > 0 } /// A count as a meter reads it. Never negative, never overflowing. fn measured(n: usize) -> u32 { u32::try_from(n).unwrap_or(u32::MAX) } /// What the progress cell says when there is nothing to measure. fn progress_dash(task: &Task) -> &'static str { if has_subtasks(task) { "" } else { "-" } } /// What the recurrence cell says. fn recurrence_word(task: &Task) -> &str { if task.has_recurrence() { task.recurrence.as_str() } else { "-" } } /// What a due date means. /// /// Overdue is a judgment the app makes and a renderer cannot, so it travels as a /// tone rather than as the `task-overdue` class the JS puts on the row. fn due_tone(task: &Task) -> Tone { if task.is_overdue() { Tone::Danger } else { Tone::Neutral } } declare! { /// One task as a row of cells, each naming the column it belongs to. /// /// The names are [`columns`]'s own words, resolved by [`Table::row`] against /// the columns declared beside them. Every row does carry all seven cells, /// so position would land them correctly today; what it would not survive is /// the two lists living in different bodies, where the only thing holding /// them in the same order is somebody reading both. /// /// # The description cell /// /// What the task is, and everything true of it that has no column of its /// own. Subtask progress is the progress column's meter and is not repeated /// here: one fact in two places can disagree only by being computed twice. /// The started state is a badge, since a class is not a fact a description /// can carry. /// /// Nothing here lives only in a `title`: a hover-only fact is one a touch or /// keyboard user never sees, so it becomes a badge that says what it means. /// /// # The moves /// /// Reopening is the board's leftward drop, offered here because a completed /// task is reachable through the status filter and a row with no move at all /// is a dead row. See the module header for the eight moves the row does not /// offer. shape row_for(listing: &Listing, task: &Task) -> Row; cells { cell at "description" task.title.clone() { activate to get "/tasks/{task.id}"; token Tag::badge("Started").tone(Tone::Info) when task.status is TaskStatus::Started; for marker in super::Availability::of(task).marker().into_iter() { token marker; } token Tag::badge(commit_label(task)).tone(commit_tone(task)) when task.has_status_tokens(); for started in running_for(task).into_iter() { since started; } for time in time_token(task).into_iter() { token time; } token Tag::badge("Notes: {task.annotation_count()}") when task.has_annotations(); for contact in task.contact_name.iter() { token Tag::badge(contact).tone(Tone::Neutral); } token Tag::badge("Snoozed").tone(Tone::Neutral) when task.is_snoozed(); token Tag::badge("Waiting").tone(waiting_tone(task)) when task.is_waiting(); } cell at "project" task.project_name_or_dash(); // The single letter the shipped column shows, which is the whole cell. // `as_str` is the right method here and only here: this is the // one-character column it was written for. cell at "priority" "" { token Tag::badge(task.priority.as_str()).tone(priority_tone(&task.priority)); } cell at "due" "" { token Tag::badge(task.due_formatted()).tone(due_tone(task)); } cell at "recurrence" recurrence_word(task); cell at "progress" progress_dash(task) { meter Meter::new(measured(task.subtasks_completed()), measured(task.subtask_count())) .tone(Tone::Success) when has_subtasks(task); } cell at "actions" "" { act "Start" to doing listing.view.carry( Action::post("/tasks/list/{task.id}/status").with("status", "Started") ) when task.status is TaskStatus::Pending; act "Complete" to doing listing.view.carry( Action::post("/tasks/list/{task.id}/status").with("status", "Completed") ) when can_complete(task); act "Reopen" to doing listing.view.carry( Action::post("/tasks/list/{task.id}/status").with("status", "Pending") ) when task.status is TaskStatus::Completed; act "Delete" to doing listing.view.carry(Action::post("/tasks/list/{task.id}/delete")) { tone Danger; confirm "Are you sure you want to delete this task? This cannot be undone."; } } // The row joins the screen's selection under its own id, which is what // the bulk bar acts on. ticking task.id.to_string() listing.view.ticked; } } /// The rows the view asks for, and how many there are in all. fn page(state: &AppState, view: &View) -> Result<(Vec, i64), RouteError> { state .tasks .list_filtered(DESKTOP_USER_ID, view.query()) .map_err(|error| RouteError::internal(error.to_string())) } /// Why the table has nothing in it. /// /// Three empty states, and the middle one is why this costs a second query: "no /// pending tasks" and "no tasks at all" are different things to say, and only /// the second one should offer a way to make the first. enum Nothing { /// There are rows. Rows, /// The filters matched nothing. Filtered, /// Nothing pending, but there are tasks. AllClear, /// No tasks have ever been made. Never, } /// Everything the list draws, read once. struct Listing { view: View, tasks: Vec, /// How many match the view in all. total: i64, /// How many are on screen. shown: i64, /// The way to more of them, when there is an address that would show any. more: Option, /// Why there are none, when there are none. nothing: Nothing, /// The projects the filter offers, and the ones the bulk picker does. projects: Vec, bulk_projects: Vec, /// The milestones of the chosen project, if one is chosen. milestones: Vec, /// The precomputed times the bulk snooze offers. /// /// The same ones the shipped modal offers, from the same function, so "Later /// Today" means one thing in the app. A described screen has no modal to put /// them in and does not need one: they are options. whens: Vec, } /// Read the list the view asks for. fn read(state: &AppState, view: View) -> Result { let (tasks, total) = page(state, &view)?; let shown = i64::try_from(tasks.len()).unwrap_or(i64::MAX); let next = (view.shown + PAGE).min(PAGE * PAGES); // The table's own, since quasi 0.15.0. This was a `Node::Act` pushed after // the table until then, because `Node::Table` carried no `Rest` and there // was nowhere else to put it; what that cost was the renderer knowing the // control belonged to the table above it. let more = (shown < total && next > view.shown).then(|| { Rest::more( usize::try_from(shown).unwrap_or(usize::MAX), view.showing(next).list(), ) .of(usize::try_from(total).unwrap_or(usize::MAX)) }); let nothing = if !tasks.is_empty() { Nothing::Rows } else if view.filtered() { Nothing::Filtered } else { let (_, ever) = page( state, &View { status: None, shown: 1, ..View::default() }, )?; if ever > 0 { Nothing::AllClear } else { Nothing::Never } }; let projects = state .projects .list_all(DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))?; let milestones = match view.project { Some(project) => state .milestones .list_by_project(project, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))?, None => Vec::new(), }; Ok(Listing { tasks, total, shown, more, nothing, projects: if projects.is_empty() { Vec::new() } else { std::iter::once(Choice::new("", "All projects")) .chain( projects .iter() .map(|project| Choice::new(project.id.to_string(), &project.name)), ) .collect() }, bulk_projects: std::iter::once(Choice::new(NONE, "No project")) .chain( projects .iter() .map(|project| Choice::new(project.id.to_string(), &project.name)), ) .collect(), milestones: if milestones.is_empty() { Vec::new() } else { std::iter::once(Choice::new("", "All milestones")) .chain( milestones .iter() .map(|milestone| Choice::new(milestone.id.to_string(), &milestone.name)), ) .collect() }, whens: crate::commands::get_snooze_options() .options .into_iter() .map(|option| Choice::new(option.time.to_rfc3339(), option.label)) .collect(), view, }) } /// Whether the table is ordered by this column, ascending. fn ascending(listing: &Listing, column: TaskSortColumn) -> bool { listing.view.sort == column && listing.view.direction == SortDirection::Asc } /// Or descending. fn descending(listing: &Listing, column: TaskSortColumn) -> bool { listing.view.sort == column && listing.view.direction == SortDirection::Desc } /// Whether there are rows the address cannot reach. /// /// At `PAGE * PAGES` there is no address that would show anything new, so the /// honest sentence is offered instead of a control that asks for the rows /// already on screen. Narrowing is the way through a list this long, and the /// filters are on the same screen. fn at_ceiling(listing: &Listing) -> bool { listing.shown < listing.total && listing.more.is_none() } /// What that sentence says. fn ceiling(listing: &Listing) -> String { format!( "Showing {} of {}. Narrow the list to see the rest.", listing.shown, listing.total ) } declare! { /// The table, and the way to more of it. /// /// Seven columns, four of them sortable. The priority a column has when room /// runs out is `build.rs`'s business, because it generates the narrowing CSS /// from its own copy of the same seven, so only the width is stated here; a /// renderer with no stylesheet reads it off the same order. Nothing checks /// that the two agree, so add a column in both by hand. /// /// The table says there is more itself, through [`Table::more`]. shape table(listing: &Listing) -> Vec; given listing.nothing { Nothing::Filtered -> empty "No tasks match the current filters." { offering "Clear filters" to doing View::default().list(); } Nothing::AllClear -> empty "All clear. No pending tasks."; Nothing::Never -> empty "No tasks yet."; otherwise -> table { column "description" { width Fill; reorder listing.view.sorted_by(TaskSortColumn::Description).list(); sorted Sort::Ascending when ascending(listing, TaskSortColumn::Description); sorted Sort::Descending when descending(listing, TaskSortColumn::Description); } column "project" { width Fixed; reorder listing.view.sorted_by(TaskSortColumn::Project).list(); sorted Sort::Ascending when ascending(listing, TaskSortColumn::Project); sorted Sort::Descending when descending(listing, TaskSortColumn::Project); } column "priority" { width Fixed; reorder listing.view.sorted_by(TaskSortColumn::Priority).list(); sorted Sort::Ascending when ascending(listing, TaskSortColumn::Priority); sorted Sort::Descending when descending(listing, TaskSortColumn::Priority); } column "due" { width Fixed; reorder listing.view.sorted_by(TaskSortColumn::Due).list(); sorted Sort::Ascending when ascending(listing, TaskSortColumn::Due); sorted Sort::Descending when descending(listing, TaskSortColumn::Due); } column "recurrence" { width Fixed; } column "progress" { width Fixed; } column "actions" { width Fixed; } for task in listing.tasks.iter() { include row_for(listing, task); } for rest in listing.more.iter() { more rest.clone(); } } } text ceiling(listing) when at_ceiling(listing); } declare! { /// The controls over the selection. /// /// Five acts. Complete and Delete act on the set and carry no value; /// priority, project and snooze apply a value, and reach it through /// [`Act::asking`] rather than by being pickers that write as they change. /// /// Said as plain acts, a project picker is one button per project. Asking /// answers that without the write, because the value rides with the press /// instead of firing on its own, so a picker over forty projects is still /// one control. Under wiki `explicit-commit-affordance` the press is also /// the commit the reader needs, and a bar whose ticks stage while its /// pickers write was half staged and half live. /// /// No blank leading option on the pickers. It existed because a bare select /// opens on its first option, and one opening on "High" read as though the /// selection already had a priority. A picker that is not on screen until /// the verb is pressed cannot say that, so the resting state has nowhere to /// be and the option that stood for it is gone. /// /// # What is not here /// /// The count. `tasks.js` writes "3 selected" into the bar and hides it when /// the selection is empty, and neither is sayable: the ticks are the host's /// until something submits them, so the description does not know how many /// there are. That is the right place for it -- a renderer knows exactly, /// and a webview one can count its own boxes -- and it is a gap in the /// renderers rather than in the vocabulary. The bar is always on screen /// here, which is the honest version of not knowing. /// /// Select-all is an address: see [`View::ticked`]. Its opposite is the same /// address without it, and only offered when there is something to clear. shape bulk(listing: &Listing) -> Vec; act "Complete" to doing listing.view.carry(Action::post("/tasks/list/complete")) { over SELECTION; } act "Delete" to doing listing.view.carry(Action::post("/tasks/list/delete")) { tone Danger; over SELECTION; confirm "Delete every selected task? This cannot be undone."; } act "Select all" to doing listing.view.ticking(true).list(); act "Clear selection" to doing listing.view.ticking(false).list() when listing.view.ticked; act "Set priority" to doing listing.view.carry(Action::post("/tasks/list/priority")) { over SELECTION; field Select "priority" "Priority" { for offered in PRIORITIES { option Choice::new(priority_word(&offered), priority_word(&offered)); } } } act "Set project" to doing listing.view.carry(Action::post("/tasks/list/project")) { over SELECTION; field Select "project" "Project" { options listing.bulk_projects.clone(); } } act "Snooze until" to doing listing.view.carry(Action::post("/tasks/list/snooze")) { over SELECTION; field Select "until" "Snooze until" { options listing.whens.clone(); } } } /// What a status filter chip reads. fn status_word(status: Option<&TaskStatus>) -> &'static str { status.map_or("All statuses", TaskStatus::as_str) } /// Whether that chip is the one in force. fn is_status(listing: &Listing, status: Option<&TaskStatus>) -> bool { listing.view.status.as_ref() == status } /// Whether that priority chip is. fn is_priority(listing: &Listing, priority: &Priority) -> bool { listing.view.priority.as_ref() == Some(priority) } /// The project the filter is on, if it is on one. fn project_value(listing: &Listing) -> Option { listing.view.project.map(|project| project.to_string()) } /// The milestone the filter is on, if it is on one. fn milestone_value(listing: &Listing) -> Option { listing .view .milestone .map(|milestone| milestone.to_string()) } declare! { /// The filter controls. /// /// The two long lists are [`Field::select`] with a /// [`consulting`](Field::consulting), for the reason the mail screen gives: /// the project set is whatever the user has and can be any length, and a /// strip of options is a shape for a handful. The short ones are chips, /// latched, which is what the problems inbox settled on. /// /// The milestone control appears only under a chosen project, which is /// `populateMilestoneFilter`'s rule: a milestone belongs to a project, so /// offering every project's milestones at once would be a control whose /// options mean nothing together. shape filters(listing: &Listing) -> Vec; for offered in STATUSES.iter() { chip status_word(offered.as_ref()) to doing listing.view.with_status(offered.clone()).list() { latched when is_status(listing, offered.as_ref()); } } field Select "project" "Project" unless listing.projects.is_empty() { options listing.projects.clone(); consulting Consult::at_once(listing.view.clearing_project().list()); for project in project_value(listing).into_iter() { value project; } } field Select "milestone" "Milestone" unless listing.milestones.is_empty() { options listing.milestones.clone(); consulting Consult::at_once(listing.view.clearing_milestone().list()); for milestone in milestone_value(listing).into_iter() { value milestone; } } for offered in PRIORITIES { chip priority_word(&offered) to doing listing.view.toggling_priority(&offered).list() { latched when is_priority(listing, &offered); } } chip "Include snoozed" to doing listing.view.toggling_snoozed().list() { latched when listing.view.snoozed; } chip "Waiting only" to doing listing.view.toggling_waiting().list() { latched when listing.view.waiting; } act "Clear filters" to doing View::default().list() when listing.view.filtered(); } /// How the band counts what the view matched. fn counted_tasks(listing: &Listing) -> String { listing.total.to_string() } /// And what it calls them. fn task_noun(listing: &Listing) -> &'static str { if listing.total == 1 { "task" } else { "tasks" } } declare! { /// The whole screen. /// /// The count is the one the shipped screen puts in a chip. "X of N" is the /// table's to say, because the table is what knows how many rows it drew. shape screen(listing: &Listing) -> Screen; screen list_detail "Tasks" false { at_place super::shell::TASKS; selecting SELECTION; region "tasks-band" as Band { page "Tasks"; stats [] { figure Figure::new(counted_tasks(listing), task_noun(listing)); } extend filters(listing); } region "tasks-bulk" as Band { extend bulk(listing); } region "tasks-list" as Pane { extend table(listing); } } } /// The whole screen, as an answer. fn index(state: &AppState, request: quasi_router::Request) -> Result { Ok(screen(&read(state, View::of(&request)?)?).into()) } /// The list, and the bar over it, as one answer. /// /// Two regions rather than one because they are two regions: the bar sits above /// the filters' output and outlives a page of it. They travel together on every /// answer because the bar changes with the view -- "Clear selection" is on it /// only when something is ticked -- and an answer moving one without the other /// would leave a bar offering to clear a selection the rows no longer have. fn answer(state: &AppState, view: &View) -> Result { let listing = read(state, view.clone())?; Ok(Response::fragment( "tasks-list", Node::Region(Slot::new("tasks-list", RegionKind::Pane).extend(table(&listing))), ) .also( "tasks-bulk", Node::Region(Slot::new("tasks-bulk", RegionKind::Band).extend(bulk(&listing))), )) } /// The table alone, which is what a filter, a sort or "show more" swaps. fn list(state: &AppState, request: quasi_router::Request) -> Result { answer(state, &View::of(&request)?) } /// The task a route was addressed at. fn task_id(request: &quasi_router::Request) -> Result { let raw = request .captures .get("id") .ok_or_else(|| RouteError::not_found("no task id"))?; Ok(TaskId::from( uuid::Uuid::parse_str(raw).map_err(|_| RouteError::not_found("not a task id"))?, )) } /// Read a task the list is acting on, or answer 404. fn load(state: &AppState, id: TaskId) -> Result { state .tasks .get_by_id(id, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))? .filter(|task| task.status != TaskStatus::Deleted) .ok_or_else(|| RouteError::not_found("no such task")) } /// Answer a write with the list it happened in, re-read. /// /// Re-read rather than patched, for the reason the problems inbox gives: the /// row usually leaves the list it was in, since the filter is `Pending` by /// default and the press is what settles it. Completing a recurring task also /// mints its successor, which the list has no way to know about without asking. fn wrote( state: &AppState, request: &quasi_router::Request, message: impl Into, ) -> Result { // The ticks are cleared by answering a view that has none. A bulk write // whose answer re-ticked every surviving row would leave "everything" // meaning something new after every press, which is `tasks.js`'s rule // (`selectedTaskIds.clear()` in each of its five bulk paths) arrived at // from the other side. let view = View { ticked: false, ..View::of(request)? }; Ok(answer(state, &view)?.toast(Tone::Success, message)) } /// Start a task, complete it, or send it back to Pending. /// /// The target is a param, never derived from what the row was drawn with, so two /// windows on the same list cannot disagree about what the next state was. It /// travels as `status` and the filter is also `status`, which is safe because /// `payload` and `carried` are different bags — the arrangement the problems /// inbox and the mail screen paid for on the same afternoon. fn set_status(state: &AppState, request: quasi_router::Request) -> Result { let task = load(state, task_id(&request)?)?; let to = match request.payload.get("status") { Some("Pending") => TaskStatus::Pending, Some("Started") => TaskStatus::Started, Some("Completed") => TaskStatus::Completed, _ => return Err(RouteError::not_found("not a status a control can set")), }; // Already there. A repeated post must not complete a task twice and mint a // second recurrence, which is the board's reasoning and matters more here: // a row can be pressed while the list it was drawn in is stale. if task.status == to { return answer(state, &View::of(&request)?); } let message = super::move_to(state, &task, &to)?; wrote(state, &request, message) } /// Delete a task, and answer the list it left. /// /// A fragment rather than the drawer's redirect: this row was already on the /// list, so there is nowhere to send anyone. fn remove(state: &AppState, request: quasi_router::Request) -> Result { let id = task_id(&request)?; let deleted = state .tasks .delete(id, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))?; if !deleted { return Err(RouteError::not_found("no such task")); } wrote(state, &request, "Task deleted.") } /// Every task the user ticked, in the order they arrived. /// /// The ticks come back under one repeated name rather than a joined string, /// which is what [`quasi_router::Params::get_all`] is for and why no delimiter /// had to be one no id can contain. /// /// An id that does not parse is dropped rather than refused. A bulk write is /// answered by the list it happened in, and failing the whole press because one /// value was malformed would lose the other thirty-nine; the count in the toast /// is what the user actually gets, so a silent drop still shows up as a smaller /// number. An empty set is not an error either: it answers the unchanged list /// with nothing said. fn chosen(request: &quasi_router::Request) -> Vec { request .payload .get_all(quasi_router::Node::TICKED) .filter_map(|raw| uuid::Uuid::parse_str(raw.trim()).ok()) .map(TaskId::from) .collect() } /// `N tasks` or `1 task`, for a toast that counts. fn counted(n: usize) -> String { if n == 1 { "1 task".to_owned() } else { format!("{n} tasks") } } /// Complete every ticked task. /// /// One at a time through [`super::move_to`], which is the same path a row's own /// Complete takes: each one may mint a recurring successor, stop a timer and /// close a milestone, and a bulk loop that skipped any of that would be a second /// meaning of the word. /// /// A task that has moved since the list was drawn is skipped rather than /// failing the press. fn complete_chosen( state: &AppState, request: quasi_router::Request, ) -> Result { let mut done = 0; for id in chosen(&request) { let Ok(task) = load(state, id) else { continue }; if task.status == TaskStatus::Completed { continue; } if super::move_to(state, &task, &TaskStatus::Completed).is_ok() { done += 1; } } wrote(state, &request, format!("{} completed.", counted(done))) } /// Delete every ticked task. fn delete_chosen(state: &AppState, request: quasi_router::Request) -> Result { let mut done = 0; for id in chosen(&request) { if state .tasks .delete(id, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))? { done += 1; } } wrote(state, &request, format!("{} deleted.", counted(done))) } /// Set the priority on every ticked task. /// /// One transaction, through the repository's own bulk write, which also /// recomputes urgency because priority is an input to it. The blank option is /// the control's resting state and writes nothing. fn priority_chosen( state: &AppState, request: quasi_router::Request, ) -> Result { let Some(word) = text(&request.payload, "priority") else { return answer(state, &View::of(&request)?); }; let priority = match word.as_str() { "High" => Priority::High, "Medium" => Priority::Medium, "Low" => Priority::Low, _ => return Err(RouteError::not_found("not a priority")), }; let ids = chosen(&request); let done = state .tasks .bulk_set_priority(DESKTOP_USER_ID, &ids, priority.clone()) .map_err(|error| RouteError::internal(error.to_string()))?; wrote( state, &request, format!("{} set to {}.", counted(done), priority_word(&priority)), ) } /// Move every ticked task to a project, or out of one. fn project_chosen( state: &AppState, request: quasi_router::Request, ) -> Result { let Some(raw) = text(&request.payload, "project") else { return answer(state, &View::of(&request)?); }; // "No project" is a word rather than a blank, because a blank is the // control saying nothing happened. let project = if raw == NONE { None } else { Some(ProjectId::from( uuid::Uuid::parse_str(&raw).map_err(|_| RouteError::not_found("not a project id"))?, )) }; let ids = chosen(&request); let done = state .tasks .bulk_set_project(DESKTOP_USER_ID, &ids, project) .map_err(|error| RouteError::internal(error.to_string()))?; wrote( state, &request, match project { Some(_) => format!("{} moved.", counted(done)), None => format!("{} taken out of their project.", counted(done)), }, ) } /// Snooze every ticked task until a time the user picked. /// /// No bulk repository write for this one, so it is a loop. The times come from /// the same function the rest of the app uses, so "Later Today" means one thing /// everywhere. fn snooze_chosen(state: &AppState, request: quasi_router::Request) -> Result { let Some(raw) = text(&request.payload, "until") else { return answer(state, &View::of(&request)?); }; let until = chrono::DateTime::parse_from_rfc3339(&raw) .map_err(|_| RouteError::not_found("not a time"))? .with_timezone(&chrono::Utc); let mut done = 0; for id in chosen(&request) { if state .tasks .snooze(id, DESKTOP_USER_ID, until) .map_err(|error| RouteError::internal(error.to_string()))? .is_some() { done += 1; } } wrote(state, &request, format!("{} snoozed.", counted(done))) } /// The task list's routes. #[must_use] pub fn routes(router: Router) -> Router { router .get("/tasks", index) .get("/tasks/list", list) .post("/tasks/list/complete", complete_chosen) .post("/tasks/list/delete", delete_chosen) .post("/tasks/list/priority", priority_chosen) .post("/tasks/list/project", project_chosen) .post("/tasks/list/snooze", snooze_chosen) .post("/tasks/list/{id}/status", set_status) .post("/tasks/list/{id}/delete", remove) }