//! The task board, described rather than built. //! //! //! //! Every card fact is ordinary vocabulary: a title is a row's primary, the //! project and the due date are meta, the blocked and unblocks markers are //! tokens, subtask progress is a `Meter`, opening a card is `activate` and the //! context menu is `menu`. A column is a heading, a count and a list. That the //! three columns are **peers** is a row whose every member asks to fill: they //! divide the room equally by `Width::Fill`'s own rule, and no member chooses //! what another shows. A board described as list-detail or sidebar-content is a //! lie about the screen. //! //! It was `RegionKind::Columns` until that variant was retired (quasicoherent //! `cf981aaa`). The row says the same thing and one thing more: `Wrap` is what //! the board does when three columns no longer fit across, which the variant //! could not state and every renderer had to invent. //! //! # The shape //! //! - `GET /board` — the three columns. //! - `POST /board/{id}/status` — move a card, carrying `to`. //! //! # Dragging //! //! A drop's *effect* is `set status to Started`: a discrete action with a //! discrete argument, which the vocabulary can say. The drag is the affordance, //! it is presentation, and the host keeps it. //! //! So each card offers its two moves as acts. A webview may wire those to a //! drop target and a terminal may bind them to keys; both are honouring the //! same description. //! //! # `to` and not `status` //! //! The target state travels as `to` because `status` is what a column *is*. // Handlers take their request by value because `quasi_router::Handler` is a // plain `fn(&S, Request)` pointer, so the signature is the router's. #![allow(clippy::needless_pass_by_value)] use goingson_core::{Priority, Task, TaskId, TaskStatus}; use makeover_layout::Tone; use quasi_declare::declare; use quasi_router::screen::{Meter, Tag}; use quasi_router::{Node, Response, RouteError, Router, Screen}; use crate::state::{AppState, DESKTOP_USER_ID}; #[cfg(test)] mod tests; /// The three columns, in the order the board reads. /// /// `tasks-kanban.js`'s `COLUMNS`, and the same order. `Deleted` is not a column /// because a deleted task is not on the board; the JS drops it by only grouping /// the three it knows. /// /// Named members rather than a tuple, for `policy`'s reason: a description /// names what it draws, and `.1` is not a name. pub(super) struct Lane { /// The status a card in it has. pub status: TaskStatus, /// The region it draws in. pub id: &'static str, /// What the heading calls it, and what a move sends. pub label: &'static str, } const COLUMNS: &[Lane] = &[ Lane { status: TaskStatus::Pending, id: "pending", label: "Pending", }, Lane { status: TaskStatus::Started, id: "started", label: "Started", }, Lane { status: TaskStatus::Completed, id: "done", label: "Completed", }, ]; /// The tone a priority wears. `tasks.rs`'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 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"))?, )) } /// Where a card is being moved to. /// /// `to`, not `status`. Unparseable is a 404 rather than a silent no-op: a /// control naming a column that does not exist is a wiring mistake, and /// answering it with an unchanged board hides it. fn target(request: &quasi_router::Request) -> Result { match request .payload .get("to") .or_else(|| request.carried.get("to")) { Some("Pending") => Ok(TaskStatus::Pending), Some("Started") => Ok(TaskStatus::Started), Some("Completed") => Ok(TaskStatus::Completed), _ => Err(RouteError::not_found("no such column")), } } /// What a priority's badge reads. fn priority_label(task: &Task) -> &'static str { match task.priority { Priority::High => "High", Priority::Medium => "Medium", Priority::Low => "Low", } } /// Whether the card carries a due date. fn has_due(task: &Task) -> bool { task.due.is_some() } /// That date, short. R9: read whether or not it is placed. fn due_label(task: &Task) -> String { task.due.map_or_else(String::new, |due| { due.with_timezone(&chrono::Local) .format("%b %-d") .to_string() }) } /// Whether it has passed. /// /// A judgment the app makes and the renderer cannot, so it travels as a tone /// rather than as a class the way `kanban-card-due.overdue` does. fn due_tone(task: &Task) -> Tone { let overdue = task .due .is_some_and(|due| due < chrono::Utc::now() && task.status != TaskStatus::Completed); if overdue { Tone::Danger } else { Tone::Neutral } } /// Whether the card names a project. fn has_project(task: &Task) -> bool { !project(task).is_empty() } /// That project, or nothing. fn project(task: &Task) -> &str { task.project_name.as_deref().unwrap_or_default() } /// How many subtasks are done, and how many there are. /// /// Suppliers because a cast is arithmetic and the form admits none. `Meter` /// says done-of-total; the JS says a percentage width, which is the same fact /// already divided. fn subtasks_done(task: &Task) -> u32 { u32::try_from(task.subtasks_completed()).unwrap_or(u32::MAX) } /// See [`subtasks_done`]. fn subtasks_all(task: &Task) -> u32 { u32::try_from(task.subtask_count()).unwrap_or(u32::MAX) } /// Whether there is any progress to draw. /// /// Absent rather than empty when a task has no subtasks, so a card does not /// carry a bar at zero. fn has_subtasks(task: &Task) -> bool { task.subtask_count() > 0 } /// Whether this lane is one the card can be moved to. /// /// Its own is left out: dropping a card where it already is is the one case /// `onDrop` bails on, and offering it would be an act that does nothing. fn elsewhere(task: &Task, lane: &Lane) -> bool { lane.status != task.status } declare! { /// One card: a task, and everything a glance at the board should say. /// /// Whether the card is available to work on is `Availability`'s, not this /// screen's: `tasks-kanban.js` drew the same marker by calling the task /// row's own renderer, and a second copy is how the board card and the task /// row drifted apart to begin with. shape card(task: &Task) -> Row; row &task.title { token Tag::badge(priority_label(task)).tone(priority_tone(&task.priority)); meta project(task) when has_project(task); for marker in super::Availability::of(task).marker().into_iter() { token marker; } token Tag::badge(due_label(task)).tone(due_tone(task)) when has_due(task); meter Meter::new(subtasks_done(task), subtasks_all(task)).tone(Tone::Success) when has_subtasks(task); // The moves this card offers, which is what a drop does. for lane in COLUMNS { act "Move to {lane.label}" to post "/board/{task.id}/status" with "to" lane.label when elsewhere(task, lane); } activate to get "/tasks/{task.id}"; } } /// The cards in one lane. /// /// A supplier because `filter` takes a closure, and it hands back borrowed /// tasks, which is not a vocabulary type and so is not counted. fn in_lane<'a>(tasks: &'a [Task], lane: &Lane) -> Vec<&'a Task> { tasks .iter() .filter(|task| task.status == lane.status) .collect() } /// How many are in it. A number with a caption is what a figure is; the JS drew /// a bare span. fn tally(tasks: &[Task], lane: &Lane) -> String { in_lane(tasks, lane).len().to_string() } /// Whether the lane is empty. fn is_empty(tasks: &[Task], lane: &Lane) -> bool { in_lane(tasks, lane).is_empty() } declare! { /// One column: its name, how many are in it, and the cards. /// /// The read happens once in the handler and every lane is drawn from the /// same list, which is also a fix: this asked the store once per column /// before, so the board read the whole task table three times per request. shape column(tasks: &[Task], lane: &Lane) -> Slot; region lane.id as Pane { section lane.label; text tally(tasks, lane); empty "No tasks" when is_empty(tasks, lane); list { for task in in_lane(tasks, lane) { include card(task); } } when not is_empty(tasks, lane); } } declare! { /// The board itself, as one region of peer columns. shape board_region(tasks: &[Task]) -> Slot; region "board" as Group { across Wrap { for lane in COLUMNS { beside Essential Fill include column(tasks, lane); } } } } /// Every task the board draws from, read once. fn everything(state: &AppState) -> Result, RouteError> { state .tasks .list_all(DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string())) } /// The whole board. fn board(state: &AppState, _request: quasi_router::Request) -> Result { // The board is a mode of the Tasks place rather than a place of its // own: `index.html` draws it behind a `data-mode="board"` toggle inside // Tasks, so the Tasks tab stays lit while it is showing. Ok(Screen::list_detail("Board", false) .at_place(super::shell::TASKS) .with(board_region(&everything(state)?)) .into()) } /// Move a card, and answer the board re-read. /// /// Re-read rather than patched, for the reason the problems triage is: the card /// leaves one column and joins another, and completing a recurring task creates /// its successor somewhere else on the board. Only the store knows what the /// board looks like afterwards. fn move_card(state: &AppState, request: quasi_router::Request) -> Result { let id = task_id(&request)?; let to = target(&request)?; let task = 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"))?; // Already there. The JS bails here too, and it matters more through a route // than through a drop: a repeated POST must not complete a task twice and // mint a second recurrence. if task.status == to { return Ok(Response::fragment( "board", Node::Region(board_region(&everything(state)?)), )); } // Three different writes, which is what the JS does and is not incidental. // The task list offers the same three from a row, so they live in // [`super::move_to`] rather than here. let message = super::move_to(state, &task, &to)?; Ok( Response::fragment("board", Node::Region(board_region(&everything(state)?))) .toast(Tone::Success, message), ) } /// This screen's routes. pub fn routes(router: Router) -> Router { router .get("/board", board) .post("/board/{id}/status", move_card) }