//! The task overview, described rather than built. //! //! //! //! The completion heatmap is a month grid of counts, and a description //! expressive enough to produce one is a widget library wearing a description's //! name. It gets a [`RegionKind::Ceded`] and stops there: nothing is owed, so //! a renderer with no fill for it draws nothing and is right to. //! //! # The shape //! //! - `GET /tasks/{id}` — the whole overview. //! - `GET /tasks/{id}/edit` — the edit form, which is a screen of its own. //! - `POST /tasks/{id}` — save it. //! - `POST /tasks/{id}/complete` — mark it done. //! - `POST /tasks/{id}/delete` — delete it. //! - `POST /tasks/{id}/subtasks` — add one. //! - `POST /tasks/{id}/subtasks/{sub}/toggle` — tick or untick one. //! - `POST /tasks/{id}/notes` — add a note. //! - `POST /tasks/{id}/blockers` — draw an edge, carrying `blocker`. //! - `POST /tasks/{id}/dependencies/{other}/remove` — cut one, carrying `role`. //! //! Every described control reaches one of those. //! //! Edit is an address rather than an arrangement: the form is [`edit_screen`], //! and the overview links to it. The two things it cannot ask for are recorded //! on [`edit_fields`]. // 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 chrono::{DateTime, Local, Utc}; use goingson_core::{ Annotation, DbValue as _, LinkedTaskRef, Priority, Recurrence, Subtask, Task, TaskId, TaskStatus, TimeSession, UpdateTask, }; use quasi_declare::declare; use quasi_router::screen::{Choice, Figure, Tag}; use quasi_router::{Action, RegionKind, Response, RouteError, Router}; use super::parse_optional_id; use crate::commands::{StreakInfo, compute_streak}; use crate::state::{AppState, DESKTOP_USER_ID}; #[cfg(test)] mod tests; /// The tone a status badge wears. /// /// Green, blue and muted across the three live statuses. `Deleted` never /// reaches a rendered screen and takes the same neutral as pending rather than /// a tone of its own. const fn status_tone(status: &TaskStatus) -> makeover_layout::Tone { match status { TaskStatus::Completed => makeover_layout::Tone::Success, TaskStatus::Started => makeover_layout::Tone::Info, TaskStatus::Pending | TaskStatus::Deleted => makeover_layout::Tone::Neutral, } } /// The tone a priority badge wears. /// /// Red, yellow and muted. Low is neutral rather than a cool colour for the /// reason `Tone`'s own docs give: a tone on everything is a tone on nothing. const fn priority_tone(priority: &Priority) -> makeover_layout::Tone { match priority { Priority::High => makeover_layout::Tone::Danger, Priority::Medium => makeover_layout::Tone::Warning, Priority::Low => makeover_layout::Tone::Neutral, } } /// A short local date, the way every list on this screen writes one. /// /// One function rather than a format string per call site, because three call /// sites that format a date three ways is how a screen ends up looking /// assembled. fn short_date(at: DateTime) -> String { at.with_timezone(&Local).format("%b %-d").to_string() } /// 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 the task, or answer 404. /// /// A deleted task is a 404 here even though `get_by_id` still returns it. /// `TaskCrud::delete` is a soft delete — it sets the status and `list_all` /// filters the row out — so a screen addressing one by id would otherwise /// render a deleted task as an ordinary one, complete with controls offering to /// complete it. The JS never met this because it closes the drawer on delete /// and does not re-fetch. An address that outlives the thing it addresses is /// exactly what a router has to answer for. fn load(state: &AppState, id: TaskId) -> Result { 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"))?; Ok(task) } /// The streak stats, for a task that has a recurrence chain. /// /// `None` for a one-off, which is what makes the whole completion-history /// section absent rather than empty. Reuses `compute_streak` from the command /// layer rather than restating it: the streak table is already Rust, and two /// copies of a rule that decides what a number means is worse than an import /// across module lines. fn streak_for(state: &AppState, task: &Task) -> Result, RouteError> { if !task.has_recurrence() && task.recurrence_parent_id.is_none() { return Ok(None); } let root = task.recurrence_parent_id.unwrap_or(task.id); let chain = state .tasks .list_recurrence_chain(root, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))?; Ok(Some(compute_streak(&chain))) } declare! { /// The four streak figures, as one strip. /// /// A large value over a small caption, laid out as a strip: `Figure` plus /// `Node::Stats`. The set is the node rather than each figure, because four /// tiles in a strip and four down a column are different things and a /// renderer handed one at a time cannot tell which it is looking at. /// /// None of these four answers a click. `Node::Stats` carries an optional /// action per figure for the one goingson site that does -- sync's "Not /// Applied: 3" -- and this section does not use it. shape habit_figures(streak: &StreakInfo) -> Node; stats [] { figure Figure::new("{streak.current_streak}d", "Current Streak"); figure Figure::new("{streak.best_streak}d", "Best Streak"); figure Figure::new("{completion_rate(streak)}%", "Completion Rate"); figure Figure::new( "{streak.total_completed}/{streak.total_instances}", "Total Completed" ); } } /// The thirty-day rate, rounded the way the strip shows it. fn completion_rate(streak: &StreakInfo) -> i64 { streak.completion_rate_30d.round() as i64 } declare! { /// The completion-history section, for a recurring task. shape habit_section(streak: &StreakInfo, task: TaskId) -> Vec; section "Completion History"; include habit_figures(streak); // The heatmap. A month grid of completion counts is exactly what // `RegionKind::Ceded` was named for: the description says a thing called // `task-heatmap` goes here and says nothing else, and // `task-overview.js:renderHeatmap` fills it. Not a workaround and not a gap // -- a description able to produce a calendar grid is a widget library with // a description's name on it. // // The id carries the task so the filling code knows which chain to render // without asking the screen. region "task-heatmap-{task}" as RegionKind::ceded("task-heatmap") {} } declare! { /// The badges across the top: status, priority, and whatever else is true. /// /// Tokens rather than text, so a status keeps its tone. The three /// conditional ones come in the order focus, overdue, snoozed. shape badges(task: &Task) -> Vec; badge task.status.as_str() { tone status_tone(&task.status); } badge task.priority.as_str() { tone priority_tone(&task.priority); } badge "Focus" when task.is_focus { tone Info; } badge "Overdue" when task.is_overdue() { tone Danger; } badge "Snoozed" when task.is_snoozed() { tone Warning; } } declare! { /// The metadata section. /// /// A task's description is markdown, and it goes in as `rich`, which /// carries the markdown **source** rather than markup. Every renderer /// renders that source its own way, and nothing in a description is ever /// markup, so `Node::Text`'s escaping guarantee is untouched. /// /// quasi-webview renders it through docengine's strict preset, so raw HTML /// inside a description reads as text. A shared renderer taking what a user /// typed should be the safer of the two. /// /// # The labelled facts under the badges /// /// `Project: X`, `Due: Y` and the rest, each a row whose primary is the /// label and whose meta is the value. The JS bolds the label inside a /// sentence; a row is the nearest thing the vocabulary has to a definition /// list, and unlike the stats above the label really is the primary here. shape metadata(task: &Task) -> Vec; extend badges(task); rich &task.description unless task.description.is_empty(); list { for project in task.project_name.iter() { row "Project" { meta project; } } row "Due" when task.due.is_some() { meta task.due_formatted(); } row "Recurrence" when task.has_recurrence() { meta task.recurrence.as_str(); } for contact in task.contact_name.iter() { row "Contact" { meta contact; } } // Tokens, because a tag is a badge in the JS and `RowPart::Tokens` // exists now to keep it one. row "Tags" unless task.tags.is_empty() { for tag in task.tags.iter() { token Tag::badge(tag); } } } unless no_details(task); } /// Whether the task has any labelled fact worth a row. fn no_details(task: &Task) -> bool { task.project_name.is_none() && task.due.is_none() && !task.has_recurrence() && task.contact_name.is_none() && task.tags.is_empty() } declare! { /// One subtask. /// /// # The tick is the write /// /// **A tick that means something has no route.** `Row::selected` said /// whether a row is ticked and whether it can be, and nothing said what /// ticking it *calls*. That was right for the case it was added for -- /// goingson's bulk-selection checkboxes are client state feeding a later /// bulk action -- and a subtask is the other case: the tick is the write. /// /// `Row::toggle` lives on quasi-router rather than makeover-layout: the /// vocabulary has no notion of an action, so "what this calls" is not a /// thing it can say. A standalone control uses `Field::writes` instead. /// /// A linked subtask keeps the button: its state follows the task it links /// to, so it is not tickable at all, and a disabled act is what says that. /// Describing it as a tick that refuses to move would be the same defect /// the other way round. shape subtask_row(task: TaskId, subtask: &Subtask) -> Row; row &subtask.text { toggling subtask.is_completed Action::post("/tasks/{task}/subtasks/{subtask.id}/toggle") unless is_linked(subtask); selectable subtask.is_completed when is_linked(subtask); token Tag::badge("Linked") when is_linked(subtask); act undo_or_done(subtask) to post "/tasks/{task}/subtasks/{subtask.id}/toggle" when is_linked(subtask) { disabled; } } } /// Whether the subtask's state follows a task it links to. fn is_linked(subtask: &Subtask) -> bool { subtask.linked_task_id.is_some() } /// What the linked subtask's refused button reads. fn undo_or_done(subtask: &Subtask) -> &'static str { if subtask.is_completed { "Undo" } else { "Done" } } declare! { /// The subtasks section. /// /// # A heading cannot carry a count, and a proportion is a `Meter` /// /// The count rides in the heading text; the proportion is a `proportion` /// member. This section is the first consumer -- the bar comes back, with /// the count still in the heading because that half is a nice-to-have and /// stays one. `d0b58239`. /// /// The count is deliberately not moved into the meter's label. The /// heading's count names the section and reads without the bar; the meter's /// label names what is being counted. Saying "3/7" twice would be the same /// fact in two places, which is what the concatenation was. shape subtasks_section(task: &Task) -> Vec; section "Subtasks {task.subtasks_completed()}/{task.subtask_count()}"; // Success, matching `tasks-render.js` and the four other subtask rollups. // Completion is the one proportion here that cannot mean anything bad. proportion counted(task.subtasks_completed()) counted(task.subtask_count()) unless task.subtasks.is_empty() { tone Success; label "subtasks"; } list { for subtask in task.subtasks.iter() { include subtask_row(task.id, subtask); } } unless task.subtasks.is_empty(); form post "/tasks/{task.id}/subtasks" { submit "Add"; field Text "text" "Subtask" { required; placeholder "Add subtask..."; } } } /// A count as a meter reads one. fn counted(n: usize) -> u32 { u32::try_from(n).unwrap_or(u32::MAX) } declare! { /// One tracked session. shape session_row(session: &TimeSession) -> Row; row when_started(session) { // A session with no end is one running right now, which is a fact about // the task and not a missing value. meta ran_for(session); } } /// When a session started, as the row reads it. fn when_started(session: &TimeSession) -> String { format!( "{} {}", short_date(session.started_at), session.started_at.with_timezone(&Local).format("%-I:%M %p") ) } /// How long it ran, or that it still is. fn ran_for(session: &TimeSession) -> String { session .duration_minutes .map_or_else(|| "active".to_owned(), |minutes| format!("{minutes}m")) } declare! { /// The time-tracking section. /// /// The site that decided `Meter`'s shape. This bar is toned, red past the /// estimate and green under it, and it is the one place in either app where /// the numerator can exceed the denominator, which is why the member /// carries the pair and not the percentage `Task::time_progress` computes: /// that function clamps to 100 and the over-run survives only in the /// separate `is_over_estimate` flag beside it. /// /// So the heading keeps the readable summary and the meter carries the /// numbers unclamped. ", over" stays in the heading text: the meter says it /// to a renderer through `data-over`, and the heading says it to someone /// reading. /// /// Track Time is the shipped drawer's modal button and is the one control /// that starts a timer anywhere in the described app. Stopping is not /// offered beside it: a running timer is the chrome panel's, and a second /// Stop here would be a second answer to what stopping means. See /// [`super::time_tracking`]. shape time_section(task: &Task, sessions: &[TimeSession]) -> Vec; section "Time Tracking {time_label(task)}"; // The tone `Meter` refuses to derive: the same fullness is success on the // subtask bar above and danger here. proportion counted_i32(task.actual_minutes) counted_i32(estimate(task)) when estimate(task) over 0 { tone over_estimate(task); label "minutes"; } list { for session in sessions.iter() { include session_row(session); } } unless sessions.is_empty(); act "Track time" to post "/timer/start" with "task" task.id.to_string() unless task.has_active_timer(); } /// The estimate, or zero where there is none. fn estimate(task: &Task) -> i32 { task.estimated_minutes.unwrap_or(0) } /// The readable summary in the heading. fn time_label(task: &Task) -> String { let tracked = format!("{}m tracked", task.actual_minutes); if estimate(task) > 0 { let over = if task.is_over_estimate() { ", over" } else { "" }; format!("{tracked} / {}m est{over}", estimate(task)) } else { tracked } } /// Which way the bar reads. fn over_estimate(task: &Task) -> makeover_layout::Tone { if task.is_over_estimate() { makeover_layout::Tone::Danger } else { makeover_layout::Tone::Success } } /// A count of minutes as a meter reads one. fn counted_i32(minutes: i32) -> u32 { u32::try_from(minutes).unwrap_or(u32::MAX) } declare! { /// One note. shape annotation_row(annotation: &Annotation) -> Row; row &annotation.note { meta noted_at(annotation); } } /// When a note was written, as the row reads it. fn noted_at(annotation: &Annotation) -> String { format!( "{} {}", short_date(annotation.timestamp), annotation .timestamp .with_timezone(&Local) .format("%-I:%M %p") ) } declare! { /// The notes section. shape notes_section(task: &Task) -> Vec; section "Notes {task.annotations.len()}"; list { for annotation in task.annotations.iter() { include annotation_row(annotation); } } unless task.annotations.is_empty(); form post "/tasks/{task.id}/notes" { submit "Add"; field Text "note" "Note" { required; placeholder "Add note..."; } } } /// Which end of an edge a row is looking at. /// /// Removing is always expressed as `(blocked, blocker)` regardless of which /// list the user is reading, so the row has to say which side it is on. It /// travels as a payload value rather than as two ids in the address, because /// the address names the task whose screen answers and that is the viewed task /// either way. #[derive(Clone, Copy, PartialEq, Eq)] enum Role { /// The other task blocks this one. Blocker, /// The other task waits on this one. Dependent, } impl Role { const fn as_str(self) -> &'static str { match self { Self::Blocker => "blocker", Self::Dependent => "dependent", } } fn from_payload(raw: &str) -> Result { match raw { "blocker" => Ok(Self::Blocker), "dependent" => Ok(Self::Dependent), _ => Err(RouteError::not_found("no such side of an edge")), } } } declare! { /// One end of an edge. /// /// Satisfied edges are drawn, greyed rather than hidden: a completed /// blocker is the record of what this task waited for, and dropping it /// would make a finished chain look like it never existed. The greying is a /// tone, because "satisfied" is a fact about the edge and `.is-satisfied` /// is one host's way of drawing it. shape dependency_row(viewed: TaskId, entry: &LinkedTaskRef, role: Role) -> Row; row &entry.title { token Tag::badge(entry.status.as_str()).tone(edge_tone(entry)); for project in entry.project_name.iter() { meta project; } act "Remove" to post "/tasks/{viewed}/dependencies/{entry.id}/remove" with "role" role.as_str(); activate to get "/tasks/{entry.id}"; } } /// How an edge's status badge reads: neutral once it is satisfied, because a /// finished blocker is history rather than a state to act on. fn edge_tone(entry: &LinkedTaskRef) -> makeover_layout::Tone { if entry.is_satisfied() { makeover_layout::Tone::Neutral } else { status_tone(&entry.status) } } /// Where the task sits in the graph, said once and plainly. /// /// Unlike the row markers on the board and the dashboard, this section says /// "Ready" out loud: it is the screen the reader came to for the answer, so /// having no badge would read as the section failing to say rather than as the /// ordinary case. enum Standing { /// On a cycle, so it can never become available. Cycle, /// Waiting on something, some number of steps away. Blocked, /// Nothing in the way. Ready, } impl Standing { /// Which of the three this task is in. fn of(task: &Task) -> Self { if task.graph.in_cycle { Self::Cycle } else if task.is_blocked() { Self::Blocked } else { Self::Ready } } } /// What the blocked badge reads, which counts the steps. fn blocked_word(task: &Task) -> String { if task.graph.block_depth == 1 { "Blocked, 1 step away".to_owned() } else { format!("Blocked, {} steps away", task.graph.block_depth) } } /// What this task unblocks, counted. fn unblocks_word(task: &Task) -> String { let n = task.graph.unblocks_count; format!("unblocks {n} task{}", if n == 1 { "" } else { "s" }) } /// Whether both lists are empty, which is the one thing the section says /// instead of showing. fn no_edges(dependencies: &Dependencies) -> bool { dependencies.blockers.is_empty() && dependencies.dependents.is_empty() } /// The edges around a task, and what may be added. struct Dependencies { /// What blocks it. blockers: Vec, /// What waits on it. dependents: Vec, /// The tasks that could block it, already filtered. candidates: Vec, } declare! { /// The dependencies section: what blocks this task and what waits on it. /// /// Two lists of rows, a marker, a count, and a select of candidate tasks. /// /// # The picker is a field, not a modal /// /// A modal is an arrangement and the router answers one screen at a time /// (see [`screen`]), so what is described is the *choice*: a select of the /// tasks that could block this one, submitted as an ordinary form. A /// webview may still draw it as a modal. /// /// The candidate filter is: not this task, not already a blocker, not /// deleted. The repository is the authority and refuses a cycle-closing /// edge naming the chain, which [`add_blocker`] passes through verbatim. shape dependencies_section(task: &Task, dependencies: &Dependencies) -> Vec; section "Dependencies"; given Standing::of(task) { Standing::Cycle -> badge "In a cycle" { tone Danger; } Standing::Blocked -> badge blocked_word(task) { tone Warning; } otherwise -> badge "Ready" { tone Success; } } text unblocks_word(task) when task.graph.unblocks_count over 0; toned "This task sits on a dependency cycle, so it can never become available. Remove \ one of the edges below to break it." makeover_layout::Tone::Danger when task.graph.in_cycle; subsection "Blocked by" unless dependencies.blockers.is_empty(); list { for entry in dependencies.blockers.iter() { include dependency_row(task.id, entry, Role::Blocker); } } unless dependencies.blockers.is_empty(); subsection "Blocks" unless dependencies.dependents.is_empty(); list { for entry in dependencies.dependents.iter() { include dependency_row(task.id, entry, Role::Dependent); } } unless dependencies.dependents.is_empty(); text "Nothing blocks this task and nothing waits on it." when no_edges(dependencies); // A completed task is not offered a new blocker, the JS's own condition, // and neither is one with nothing left to depend on: an empty select is a // control that cannot be used, which is the toast the JS shows instead. form post "/tasks/{task.id}/blockers" when task.status is_not TaskStatus::Completed and not dependencies.candidates.is_empty() { submit "Add blocker"; field Select "blocker" "Must be completed first" { options dependencies.candidates.clone(); hint "This task stays unavailable until that one is done."; } } } /// The tasks that could block this one. /// /// `pickBlocker`'s filter, moved to where the data is. The JS fetches every /// task and filters in the browser; here the same rule runs before anything is /// described, so a screen never offers a choice the repository would refuse. fn blocker_candidates( state: &AppState, task: &Task, blockers: &[LinkedTaskRef], ) -> Result, RouteError> { let already: std::collections::HashSet = blockers.iter().map(|b| b.id).collect(); Ok(state .tasks .list_all(DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))? .into_iter() .filter(|other| other.id != task.id && !already.contains(&other.id)) .map(|other| { let label = match &other.project_name { Some(project) => format!("{} ({project})", other.title), None => other.title.clone(), }; Choice::new(other.id.to_string(), label) }) .collect()) } /// Everything the task drawer draws, read once. struct Drawer { /// The task itself. task: Task, /// Its tracked sessions, newest as the store lists them. sessions: Vec, /// Its streak stats, for a task with a recurrence chain. `None` for a /// one-off, which is what makes the whole completion-history section absent /// rather than empty. streak: Option, /// The edges around it, and what may be added. dependencies: Dependencies, } /// The drawer's read. fn drawer(state: &AppState, id: TaskId) -> Result { let task = load(state, id)?; let blockers = state .tasks .list_blockers(DESKTOP_USER_ID, id) .map_err(|error| RouteError::internal(error.to_string()))?; Ok(Drawer { sessions: state .tasks .list_time_sessions(id, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))?, streak: streak_for(state, &task)?, dependencies: Dependencies { candidates: blocker_candidates(state, &task, &blockers)?, dependents: state .tasks .list_dependents(DESKTOP_USER_ID, id) .map_err(|error| RouteError::internal(error.to_string()))?, blockers, }, task, }) } /// Whether the subtasks section is drawn at all. /// /// The JS hides it on a completed task with none, on the grounds that there is /// nothing to add one for any more. fn offers_subtasks(task: &Task) -> bool { !task.subtasks.is_empty() || task.status != TaskStatus::Completed } declare! { /// The whole screen. /// /// Built here rather than inside the route because every write answers with /// it, for the reason the projects screen gives: a write lands in more than /// one section and a `Response` names one region. /// /// # Edit is an address, not an overlay /// /// A screen that offers a control which opens a form over itself has to /// describe two arrangements at once, which the router cannot answer. Edit /// addresses [`edit_screen`] instead. /// /// The drawer is reached from a row on the task list, so the place it marks /// is the list it came from. `navigation.js` says the same thing with /// `TAB_GROUPS["task-overview"] = "work"`. shape screen(drawer: &Drawer) -> Screen; screen list_detail "Task" false { at_place super::shell::TASKS; region "task-band" as Band { page &drawer.task.title; act "Edit" to get "/tasks/{drawer.task.id}/edit"; act "Complete" to post "/tasks/{drawer.task.id}/complete" when drawer.task.status is_not TaskStatus::Completed; // The confirmation is the description's now, as of `524a63fe`. It // was the JS's -- `confirmDelete` at 17 call sites -- so the // described screen deleted without asking where the shipped one // asks, which is the described screen being worse than what it // replaces. act "Delete" to post "/tasks/{drawer.task.id}/delete" { tone Danger; confirm "Are you sure you want to delete this task? This cannot be undone."; } } region "task-overview" as Pane { for streak in drawer.streak.iter() { extend habit_section(streak, drawer.task.id); } extend metadata(&drawer.task); extend subtasks_section(&drawer.task) when offers_subtasks(&drawer.task); extend dependencies_section(&drawer.task, &drawer.dependencies); extend time_section(&drawer.task, &drawer.sessions); extend notes_section(&drawer.task); } } } /// The whole overview. fn overview(state: &AppState, request: quasi_router::Request) -> Result { Ok(screen(&drawer(state, task_id(&request)?)?).into()) } /// The statuses the edit form offers, which are `task-forms.js:STATUS_OPTIONS`. /// /// [`TaskStatus::Deleted`] is not among them, for the reason /// [`super::move_to`] gives: deleting is its own control, and a status select /// that could delete would put it one option away from Completed. const EDIT_STATUSES: [&str; 3] = ["Pending", "Started", "Completed"]; /// `task-forms.js:PRIORITIES`, in its order, which is lowest first. const EDIT_PRIORITIES: [&str; 3] = ["Low", "Medium", "High"]; /// `task-forms.js:RECURRENCE_OPTIONS`. const EDIT_RECURRENCES: [&str; 4] = ["None", "Daily", "Weekly", "Monthly"]; /// The due date as the form shows it, which is local wall clock. /// /// `getTaskFormFields` builds the same `YYYY-MM-DDTHH:MM` out of a `Date`, and /// [`goingson_core::parse_natural_date`] reads that format back, so the value /// the form offers is a value the form accepts. A prefill the parser would /// reject is a field that cannot be left alone. fn due_value(task: &Task) -> String { task.due .map(|due| { due.with_timezone(&Local) .format("%Y-%m-%dT%H:%M") .to_string() }) .unwrap_or_default() } /// Everything the edit form needs, read once. struct Editing { /// The task being edited. task: Task, /// The projects it may be filed under, with "none" at the head. projects: Vec, /// The contacts it may name. contacts: Vec, /// The milestones of its project, empty when it has none. milestones: Vec, /// What the last submission got wrong, by field name. errors: Vec<(String, String)>, /// What that submission sent, so a refused form comes back filled. submitted: quasi_router::Params, } /// The edit form's read. fn editing( state: &AppState, task: Task, errors: &[(&str, String)], submitted: Option<&quasi_router::Params>, ) -> Result { let offered = |none: &str, values: Vec<(String, String)>| -> Vec { let mut options = vec![Choice::new("", none)]; options.extend(values.into_iter().map(|(id, label)| Choice::new(id, label))); options }; let projects = state .projects .list_all(DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))?; let contacts = state .contacts .list_all(DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))?; let milestones = match task.project_id { Some(project_id) => state .milestones .list_by_project(project_id, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))?, None => Vec::new(), }; Ok(Editing { projects: offered( "No Project", projects .into_iter() .map(|project| (project.id.to_string(), project.name)) .collect(), ), contacts: offered( "No Contact", contacts .into_iter() .map(|contact| (contact.id.to_string(), contact.display_name)) .collect(), ), milestones: offered( "No Milestone", milestones .into_iter() .map(|milestone| (milestone.id.to_string(), milestone.name)) .collect(), ), errors: errors .iter() .map(|(field, message)| ((*field).to_owned(), message.clone())) .collect(), // An unanswered form refills from an empty `Params`, which is a no-op, // so the shape needs no guard around it. submitted: submitted.cloned().unwrap_or_default(), task, }) } impl Editing { /// Whether a named field was refused. fn refused(&self, name: &str) -> bool { self.errors.iter().any(|(field, _)| field == name) } /// What it was refused for. Total, because a hole is evaluated whether or /// not the setting it feeds is placed. fn refusal(&self, name: &str) -> String { self.errors .iter() .find(|(field, _)| field == name) .map_or_else(String::new, |(_, message)| message.clone()) } /// An optional id as the select spells it, which is the empty string for /// none. fn id_or_none(id: Option) -> String { id.map(|id| id.to_string()).unwrap_or_default() } /// The estimate as the box shows it, blank where there is none. fn estimate_box(&self) -> String { Self::id_or_none(self.task.estimated_minutes) } } declare! { /// The edit form, at its own address. /// /// The form is a screen of its own, reached by address, and the control on /// the overview is a link to it rather than a second arrangement drawn on /// top. Cancel is the overview's own address, and the overview is rebuilt /// from the database rather than restored from memory. shape edit_screen(editing: &Editing) -> Screen; screen list_detail "Edit task" false { at_place super::shell::TASKS; region "task-band" as Band { page "Edit {editing.task.title}"; act "Cancel" to get "/tasks/{editing.task.id}"; } region "task-overview" as Pane { form post "/tasks/{editing.task.id}" { submit "Save task"; field Text "title" "Title" { required; value &editing.task.title; placeholder "What needs to be done?"; error editing.refusal("title") when editing.refused("title"); refilled &editing.submitted; } field Textarea "description" "Details" { value &editing.task.description; placeholder "Anything the title does not cover (optional)"; error editing.refusal("description") when editing.refused("description"); refilled &editing.submitted; } field Select "project_id" "Project" { options editing.projects.clone(); value Editing::id_or_none(editing.task.project_id); error editing.refusal("project_id") when editing.refused("project_id"); refilled &editing.submitted; } field Select "status" "Status" { for status in EDIT_STATUSES { option Choice::new(status, status); } value editing.task.status.as_str(); error editing.refusal("status") when editing.refused("status"); refilled &editing.submitted; } field Select "priority" "Priority" { for priority in EDIT_PRIORITIES { option Choice::new(priority, priority); } value editing.task.priority.db_value(); error editing.refusal("priority") when editing.refused("priority"); refilled &editing.submitted; } field Text "due" "Due Date (optional)" { value due_value(&editing.task); placeholder "tomorrow, friday 3pm, 2026-12-25..."; error editing.refusal("due") when editing.refused("due"); refilled &editing.submitted; } field Text "tags" "Tags (comma-separated)" { value editing.task.tags.join(", "); placeholder "work, urgent, meeting"; error editing.refusal("tags") when editing.refused("tags"); refilled &editing.submitted; } field Select "recurrence" "Recurrence" { for pattern in EDIT_RECURRENCES { option Choice::new(pattern, pattern); } value editing.task.recurrence.db_value(); hint "Completing a recurring task auto-creates the next occurrence"; error editing.refusal("recurrence") when editing.refused("recurrence"); refilled &editing.submitted; } field Number "estimated_minutes" "Estimated Time (minutes)" { value editing.estimate_box(); placeholder "e.g. 30, 60, 120"; hint "Used for day plan scheduling and time tracking progress"; error editing.refusal("estimated_minutes") when editing.refused("estimated_minutes"); refilled &editing.submitted; } field Select "contact_id" "Contact" { options editing.contacts.clone(); value Editing::id_or_none(editing.task.contact_id); error editing.refusal("contact_id") when editing.refused("contact_id"); refilled &editing.submitted; } field Select "milestone_id" "Milestone" { options editing.milestones.clone(); value Editing::id_or_none(editing.task.milestone_id); hint "Group tasks into project phases; milestones are managed per project"; error editing.refusal("milestone_id") when editing.refused("milestone_id"); refilled &editing.submitted; } } } } } /// The edit form. fn edit(state: &AppState, request: quasi_router::Request) -> Result { let task = load(state, task_id(&request)?)?; Ok(edit_screen(&editing(state, task, &[], None)?).into()) } /// What the JS form refuses, refused here. /// /// The title length is `getTaskFormFields`'s own `validate` closure. The empty /// title is `update_task`'s, which checks it server-side and is the only one of /// the two that a described form could not skip. fn validate_edit(title: &str) -> Vec<(&'static str, String)> { let mut errors = Vec::new(); if title.is_empty() { errors.push(("title", "A task needs a title.".to_owned())); } else if title.chars().count() > 80 { errors.push(("title", "Maximum 80 characters".to_owned())); } errors } /// Save the edited task, or answer with the form saying why not. /// /// The write is [`UpdateTask`] through the repository rather than the /// `update_task` command, which is a Tauri wrapper around exactly this. What /// the command holds that is worth keeping — the urgency recalculation, the /// title/description split, and reading `scheduled_start` and /// `scheduled_duration` off the stored row so a time-blocked task does not lose /// its block on an unrelated edit — is in core and is called here for the same /// reason. fn update(state: &AppState, request: quasi_router::Request) -> Result { let id = task_id(&request)?; let task = load(state, id)?; let field = |name: &str| { request .payload .get(name) .unwrap_or_default() .trim() .to_owned() }; let title = field("title"); let description = field("description"); let mut errors = validate_edit(&title); let status = super::parse_choice::(&request.payload, "status", &mut errors) .filter(|status| EDIT_STATUSES.contains(&status.as_str())); if status.is_none() && !errors.iter().any(|(name, _)| *name == "status") { errors.push(("status", "Not a status a control can set.".to_owned())); } let priority = super::parse_choice::(&request.payload, "priority", &mut errors); let recurrence = super::parse_choice::(&request.payload, "recurrence", &mut errors); // Blank is "no due date", which is how the field is cleared. Anything else // has to parse, and an unparseable date is refused rather than dropped: // dropping it is a task that silently loses its deadline on an edit that // was about something else. `parse_natural_date` is the same function the // JS reaches through the `parse_natural_date` command, so the two agree on // what "friday 3pm" means. let raw_due = field("due"); let due = if raw_due.is_empty() { None } else { match goingson_core::parse_natural_date(&raw_due, Local::now().naive_local()) .and_then(|when| when.and_local_timezone(Local).single()) { Some(when) => Some(when.with_timezone(&Utc)), None => { errors.push(( "due", "Date not recognized. Try \"tomorrow\", \"friday 3pm\", or \"2026-12-25\"." .to_owned(), )); None } } }; let estimated_minutes = match field("estimated_minutes").as_str() { "" => None, raw => match raw.parse::() { Ok(minutes) if minutes >= 0 => Some(minutes), _ => { errors.push(("estimated_minutes", "A number of minutes.".to_owned())); None } }, }; let project_id = parse_optional_id(&request.payload, "project_id", &mut errors); let contact_id = parse_optional_id(&request.payload, "contact_id", &mut errors); let milestone_id = parse_optional_id(&request.payload, "milestone_id", &mut errors); // A milestone belongs to a project, and the form offered the milestones of // the project the task was in. Moving both at once is refused rather than // stored, because the pairing the form could offer and the pairing the // submission carries are not the same thing. See [`edit_fields`]. if milestone_id.flatten().is_some() && project_id.flatten() != task.project_id { errors.push(( "milestone_id", "Move the task first, then file it under a milestone of its new project.".to_owned(), )); } let (Some(status), Some(priority), Some(recurrence)) = (status, priority, recurrence) else { return Ok(edit_screen(&editing(state, task, &errors, Some(&request.payload))?).into()); }; let (Some(project_id), Some(contact_id), Some(milestone_id)) = (project_id, contact_id, milestone_id) else { return Ok(edit_screen(&editing(state, task, &errors, Some(&request.payload))?).into()); }; if !errors.is_empty() { return Ok(edit_screen(&editing(state, task, &errors, Some(&request.payload))?).into()); } let tags: Vec = field("tags") .split(',') .map(|tag| tag.trim().to_owned()) .filter(|tag| !tag.is_empty()) .collect(); let context = state .tasks .get_update_context(id, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))? .ok_or_else(|| RouteError::not_found("no such task"))?; state .tasks .update( id, DESKTOP_USER_ID, UpdateTask { project_id, milestone_id, contact_id, urgency: goingson_core::calculate_urgency( &priority, &status, due.as_ref(), &context.created_at, &tags, ), title, description, status, priority, due, tags, recurrence, // Threaded rather than rebuilt: the form cannot ask for it. // See [`edit_fields`]. recurrence_rule: task.recurrence_rule.clone(), scheduled_start: context.scheduled_start, scheduled_duration: context.scheduled_duration, estimated_minutes, }, ) .map_err(|error| RouteError::internal(error.to_string()))? .ok_or_else(|| RouteError::not_found("no such task"))?; Ok(wrote(state, id)?.toast(makeover_layout::Tone::Success, "Task saved")) } /// Answer a write with the screen it happened on, re-read. /// /// Re-read rather than patched in memory: the write is the database's to /// confirm, and a screen rebuilt from what the handler hoped happened is how a /// screen disagrees with its own storage. fn wrote(state: &AppState, id: TaskId) -> Result { Ok(screen(&drawer(state, id)?).into()) } /// Mark the task complete. /// /// `complete` handles the recurring case itself, minting the next instance, so /// this does not branch on recurrence. Answering with the same address then /// shows the completed instance rather than the new one, which matches what the /// JS does: it re-opens the task it was showing. fn complete(state: &AppState, request: quasi_router::Request) -> Result { let id = task_id(&request)?; // Through [`super::move_to`], so this drawer, the board and the task list // are one answer to what completing a task means. See the note there for // what the repository's `complete` leaves out. let task = state .tasks .get_by_id(id, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))? .ok_or_else(|| RouteError::not_found("no such task"))?; super::move_to(state, &task, &TaskStatus::Completed)?; wrote(state, id) } /// Delete the task. /// /// # Deleting answers with a different address /// /// Deleting the thing a screen is about is the one case where the right answer /// is a *different* address. [`Response`] carries an /// [`Outcome`](quasi_router::Outcome) that can be a redirect, plus a notice /// beside it: a redirect alone cannot say what happened, because the list it /// lands on looks the same whether a task was deleted or the user navigated. 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")); } Ok(Response::goto(Action::get("/tasks")).toast(makeover_layout::Tone::Success, "Task deleted")) } /// Add a subtask. fn add_subtask(state: &AppState, request: quasi_router::Request) -> Result { let id = task_id(&request)?; let text = request.payload.get("text").unwrap_or_default().trim(); // An empty add is the JS's early return, not an error: the user pressed the // button with nothing typed and the screen should simply not change. if !text.is_empty() { state .tasks .add_subtask(id, DESKTOP_USER_ID, text) .map_err(|error| RouteError::internal(error.to_string()))? .ok_or_else(|| RouteError::not_found("no such task"))?; } wrote(state, id) } /// Tick or untick a subtask. fn toggle_subtask( state: &AppState, request: quasi_router::Request, ) -> Result { let id = task_id(&request)?; let raw = request .captures .get("sub") .ok_or_else(|| RouteError::not_found("no subtask id"))?; let sub = goingson_core::SubtaskId::from( uuid::Uuid::parse_str(raw).map_err(|_| RouteError::not_found("not a subtask id"))?, ); state .tasks .toggle_subtask(sub, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))? .ok_or_else(|| RouteError::not_found("no such subtask"))?; wrote(state, id) } /// Add a note. fn add_note(state: &AppState, request: quasi_router::Request) -> Result { let id = task_id(&request)?; let note = request.payload.get("note").unwrap_or_default().trim(); if !note.is_empty() { state .tasks .add_annotation(id, DESKTOP_USER_ID, note) .map_err(|error| RouteError::internal(error.to_string()))?; } wrote(state, id) } /// Draw an edge: the picked task must finish before this one starts. /// /// A cycle-closing edge is refused by the repository with a message naming the /// chain already in the way, and that message is what the user sees. It is the /// only useful thing to say here, so it is passed through rather than replaced /// with a generic failure — the same call `addBlocker` makes, and a toast for /// the same reason: the screen is fine, one submission was not. fn add_blocker(state: &AppState, request: quasi_router::Request) -> Result { let id = task_id(&request)?; let raw = request.payload.get("blocker").unwrap_or_default(); // An empty submit is the JS's early return: the user pressed the button // without picking, and the screen should simply not change. if raw.trim().is_empty() { return wrote(state, id); } let blocker = TaskId::from( uuid::Uuid::parse_str(raw.trim()).map_err(|_| RouteError::not_found("not a task id"))?, ); state .tasks .add_dependency(DESKTOP_USER_ID, id, blocker) .map_err(|error| RouteError::conflict(error.to_string()).as_toast())?; wrote(state, id) } /// Cut an edge, from either side of it. fn remove_dependency( state: &AppState, request: quasi_router::Request, ) -> Result { let id = task_id(&request)?; let raw = request .captures .get("other") .ok_or_else(|| RouteError::not_found("no task id"))?; let other = TaskId::from( uuid::Uuid::parse_str(raw).map_err(|_| RouteError::not_found("not a task id"))?, ); let (blocked, blocker) = match Role::from_payload(request.payload.get("role").unwrap_or_default()) { Ok(Role::Blocker) => (id, other), Ok(Role::Dependent) => (other, id), Err(error) => return Err(error), }; let removed = state .tasks .remove_dependency(DESKTOP_USER_ID, blocked, blocker) .map_err(|error| RouteError::internal(error.to_string()))?; if !removed { return Err(RouteError::not_found("no such dependency")); } // The viewed task's screen, not the edge's other end: the address names // where the user is standing, which is what makes one route serve both // lists. wrote(state, id) } /// The task overview's routes. #[must_use] pub fn routes(router: Router) -> Router { router .get("/tasks/{id}/edit", edit) .get("/tasks/{id}", overview) .post("/tasks/{id}", update) .post("/tasks/{id}/blockers", add_blocker) .post("/tasks/{id}/dependencies/{other}/remove", remove_dependency) .post("/tasks/{id}/complete", complete) .post("/tasks/{id}/delete", remove) .post("/tasks/{id}/subtasks", add_subtask) .post("/tasks/{id}/subtasks/{sub}/toggle", toggle_subtask) .post("/tasks/{id}/notes", add_note) }