//! The project dashboard, described rather than built. //! //! //! //! What a project has linked to it, in four columns, plus its milestones. //! //! # The shape //! //! - `GET /projects/{id}/dashboard` — the whole thing. //! - `GET /projects/{id}/milestones/new` — the add form. //! - `POST /projects/{id}/milestones` — create one. //! - `GET /projects/{id}/milestones/{milestone}/edit` — the edit form. //! - `POST /projects/{id}/milestones/{milestone}` — save one. //! - `POST /projects/{id}/milestones/{milestone}/move` — reorder, `by=-1|1`. //! - `POST /projects/{id}/milestones/{milestone}/delete` — delete one. //! - `POST /projects/{id}/attachments` — attach the picked file. //! - `GET /projects/{id}/attachments/{attachment}/open` — hand one to the host. //! //! Showing completed milestones is `?completed=1`, per decision 2, so the //! expanded dashboard is reachable by address. //! //! The file picker and the handoff to the OS are both described; see //! [`attachments_column`]. #![allow(clippy::needless_pass_by_value)] use chrono::Local; use goingson_core::{ Attachment, Email, Event, Milestone, MilestoneStatus, Project, ProjectId, Task, TaskStatus, }; use makeover_layout::Tone; use quasi_declare::declare; use quasi_router::screen::{Choice, Meter, Tag}; use quasi_router::{Action, Node, Response, RouteError, Router}; use super::{filtered_by, project_id, status_tone, type_label}; use crate::state::{AppState, DESKTOP_USER_ID}; #[cfg(test)] mod tests; /// A count as a meter reads it. Never negative, never overflowing. fn counted(n: usize) -> u32 { u32::try_from(n).unwrap_or(u32::MAX) } /// Whether the task has subtasks to show progress over. fn has_subtasks(task: &Task) -> bool { task.subtask_count() > 0 } declare! { /// One linked task. /// /// The subtask bar is `RowPart::Proportion` rather than a `Node::Meter`: a /// row holds no nodes, so it carries the description of a bar instead. /// /// Whether the task is available is [`crate::quasi::Availability`]'s, which /// is shared rather than redrawn. shape task_row(task: &Task) -> Row; row &task.title { token Tag::badge(task.priority.as_str()); for marker in crate::quasi::Availability::of(task).marker().into_iter() { token marker; } meter Meter::new(counted(task.subtasks_completed()), counted(task.subtask_count())) .label("subtasks") when has_subtasks(task); token Tag::badge(task.due_formatted()) when task.due.is_some(); activate to get "/tasks/{task.id}"; } } /// Whether every linked task is finished. /// /// Three states rather than two, which is the JS's own distinction and worth /// keeping: nothing linked yet is a different thing from everything being done. fn all_done(tasks: &[Task]) -> bool { !tasks.is_empty() && tasks .iter() .all(|task| task.status == TaskStatus::Completed) } declare! { /// The tasks column. shape tasks_column(tasks: &[Task]) -> Node; given tasks_state(tasks) { Showing::Nothing -> empty "No tasks linked yet."; Showing::Done -> empty "All tasks complete."; otherwise -> list { for task in tasks.iter() { include task_row(task); } } } } /// What a column has to show. enum Showing { /// Nothing linked yet. Nothing, /// Linked, and all of it finished. Done, /// Rows. Rows, } /// Which of the three the tasks column is in. fn tasks_state(tasks: &[Task]) -> Showing { if tasks.is_empty() { Showing::Nothing } else if all_done(tasks) { Showing::Done } else { Showing::Rows } } /// When an event starts, as the column reads it. fn event_at(event: &Event) -> String { event .start_time .with_timezone(&Local) .format("%b %-d, %-I:%M %p") .to_string() } declare! { /// The events column. shape events_column(events: &[Event]) -> Node; given events.is_empty() { true -> empty "No events linked yet."; otherwise -> list { for event in events.iter() { row &event.title { meta event_at(event); } } } } } declare! { /// The emails column. shape emails_column(emails: &[Email]) -> Node; given emails.is_empty() { true -> empty "No emails linked yet."; otherwise -> list { for email in emails.iter() { row &email.subject { secondary &email.from; meta email.received_formatted(); token Tag::badge("Unread").tone(Tone::Info) unless email.is_read; } } } } } /// A file size, in the largest unit that keeps it above 1. fn file_size(bytes: i64) -> String { const UNITS: [&str; 4] = ["B", "KB", "MB", "GB"]; let mut size = bytes as f64; let mut unit = 0; while size >= 1024.0 && unit < UNITS.len() - 1 { size /= 1024.0; unit += 1; } if unit == 0 { format!("{bytes} {}", UNITS[0]) } else { format!("{size:.1} {}", UNITS[unit]) } } /// The attachments column's contents, read once. struct Attached { project: ProjectId, files: Vec, /// What a refused attach carries back, as a notice beside the control /// rather than on it, since there is no field to hang it on. error: Option, } /// Read the attachments. fn attached( state: &AppState, project: ProjectId, error: Option<&str>, ) -> Result { Ok(Attached { project, files: state .attachments .list_for_project(project, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))?, error: error.map(ToOwned::to_owned), }) } /// Whether the attach was refused. fn refused(attached: &Attached) -> bool { attached.error.is_some() } /// Why it was. fn refusal(attached: &Attached) -> String { attached.error.clone().unwrap_or_default() } declare! { /// The attachments column, both its controls included. /// /// # The finding this port turned up, and how it closed /// /// **An action that opens a native dialog is not a route, and `Destination` /// had nowhere to put it.** Attaching opens the OS file picker; opening /// hands the blob to the OS. They are two answers rather than one member: /// /// - *Opening* is a one-way handoff and needed no new API. The route spools /// the blob out under its own filename and answers /// `Response::goto(Action::external("file://…"))`, which the webview host /// sends as `HX-Redirect` and every other host reads as "leave". /// - *Picking* returns a value into a write, which is a form concern, so it /// is `FieldKind::File` in makeover-layout 0.11.0 -- a native picker in /// Tauri, an `` on a server, a path prompt in a /// terminal. /// /// # Where the bytes go /// /// Picking a file says nothing about where the bytes go, so this column /// needs `POST /projects/{id}/attachments`. The handler is [`attach`], and /// the work is `commands::attachment::attach_path`, lifted out of the Tauri /// command so there is one copy of the hashing, the dedup and the size limit /// rather than two. /// /// # The transport half is the host's, and the field could not carry it /// /// **Not a `FieldKind::File`.** That renders `` into the /// same webview a browser would use, htmx submits it urlencoded, and a /// browser reports a masked filename; multipart is refused outright by /// `quasi_http::is_form`. So the field would deliver neither bytes nor a /// path. /// /// It is an [`Act`] carrying [`Action::by_host`]: the host makes this call /// and the renderer does not. `frontend/js/host.js` opens the native dialog /// and posts the path here, and a path under `file` is what the route /// reads. `a81384d4` is the ruling. shape attachments_column(attached: &Attached) -> Slot; region "dashboard-attachments" as Pane { section "Attachments"; empty "No attachments yet." when attached.files.is_empty(); list { for file in attached.files.iter() { row &file.filename { meta file_size(file.file_size); act "Open" to get "/projects/{attached.project}/attachments/{file.id}/open"; } } } unless attached.files.is_empty(); act "Attach a file" to post "/projects/{attached.project}/attachments" by_host awaiting; banner Tone::Danger refusal(attached) when refused(attached); text "" unless refused(attached); } } /// How far along one milestone is. /// /// `list_milestones` computes the same three numbers in the command layer. Not /// shared, because sharing it would mean lifting `MilestoneResponse` out of the /// command module into something both can see, and the arithmetic is one line. /// The comment is the guard: if the rule for what counts as done ever stops /// being "status is Completed", both move together. fn milestone_progress(tasks: &[Task], milestone: &Milestone) -> (usize, usize) { let mine: Vec<&Task> = tasks .iter() .filter(|task| task.milestone_id == Some(milestone.id)) .collect(); let done = mine .iter() .filter(|task| task.status == TaskStatus::Completed) .count(); (done, mine.len()) } /// One open milestone, with where it sits in the order. struct Standing { milestone: Milestone, /// How far along it is. /// /// A bar rather than "3/7" in the meta slot, as of `da5666ae`. The ratio is /// still readable: `meter_html` writes both numbers into the accessible /// name, which is what the concatenated text was for. progress: Meter, /// Whether it is already at the top of the list. first: bool, /// Whether it is already at the bottom. last: bool, /// The date it is aimed at, if it has one. target: Option, } /// The milestones section, read once. struct Milestones { project: ProjectId, open: Vec, done: Vec, /// Whether the finished ones are on screen. show_completed: bool, /// Whether the project has no milestones at all, which is a different thing /// from having none open. bare: bool, } /// Work out the milestones section. fn milestones_of( project: ProjectId, all: &[Milestone], tasks: &[Task], show_completed: bool, ) -> Milestones { let (open, done): (Vec, Vec) = all .iter() .cloned() .partition(|milestone| milestone.status != MilestoneStatus::Completed); let of = open.len(); Milestones { project, open: open .into_iter() .enumerate() .map(|(at, milestone)| { let (done, total) = milestone_progress(tasks, &milestone); Standing { progress: Meter::new(counted(done), counted(total)).label("tasks"), first: at == 0, last: at + 1 == of, target: milestone .target_date .map(|date| date.format("%Y-%m-%d").to_string()), milestone, } }) .collect(), done, show_completed, bare: all.is_empty(), } } /// What the completed-milestones disclosure reads. fn completed_label(milestones: &Milestones) -> String { if milestones.show_completed { "Hide completed".to_owned() } else { format!("Show {} completed", milestones.done.len()) } } /// What pressing that disclosure leaves it set to. fn completed_next(milestones: &Milestones) -> bool { !milestones.show_completed } declare! { /// One open milestone, with the controls that act on it. /// /// Reordering is two acts rather than a drag. The act that would go off the /// end is `Act::disabled` rather than hidden: a control that vanishes at the /// edge of a list is a control the user has to discover twice. shape milestone_row(milestones: &Milestones, standing: &Standing) -> Row; row &standing.milestone.name { meter standing.progress.clone(); for target in standing.target.iter() { token Tag::badge(target); } act "Edit" to get "/projects/{milestones.project}/milestones/{standing.milestone.id}/edit"; act "Move up" to post "/projects/{milestones.project}/milestones/{standing.milestone.id}/move" with "by" "-1" { disabled when standing.first; } act "Move down" to post "/projects/{milestones.project}/milestones/{standing.milestone.id}/move" with "by" "1" { disabled when standing.last; } act "Delete" to post "/projects/{milestones.project}/milestones/{standing.milestone.id}/delete" { tone Danger; } } } declare! { /// The milestones section. /// /// Add and edit are addresses rather than modals, on the shape the task /// overview established: an edit form is a screen of its own rather than a /// control on the screen it edits. That is why they were absent when this /// screen first landed, and it is the same reason they are here now -- the /// forms exist as `/projects/{id}/milestones/new` and /// `/projects/{id}/milestones/{milestone}/edit`, so the controls are links. /// /// The completed disclosure is an address, so an expanded dashboard survives /// a reload and can be linked to. `showCompletedMilestones` in the JS is /// module state that a re-render throws away. shape milestones(milestones: &Milestones) -> Vec; section "Milestones"; empty "No milestones yet" when milestones.bare; act "New milestone" to get "/projects/{milestones.project}/milestones/new"; list { for standing in milestones.open.iter() { include milestone_row(milestones, standing); } } unless milestones.bare; act completed_label(milestones) to doing filtered_by( Action::get("/projects/{milestones.project}/dashboard"), "completed", completed_next(milestones) ) when shows_completed(milestones); list { for milestone in milestones.done.iter() { row &milestone.name { token Tag::badge("Complete").tone(Tone::Success); } } } when milestones.show_completed and shows_completed(milestones); } /// Whether there are completed milestones to disclose. fn shows_completed(milestones: &Milestones) -> bool { !milestones.bare && !milestones.done.is_empty() } /// Everything the dashboard draws, read once. struct Dashboard { project: Project, tasks: Vec, events: Vec, emails: Vec, attached: Attached, milestones: Milestones, } /// Read the dashboard. /// /// `attach_error` is what a refused attach carries back into the column it was /// refused in. Every other caller passes `None`. fn read( state: &AppState, id: ProjectId, show_completed: bool, attach_error: Option<&str>, ) -> Result { let project: Project = state .projects .get_by_id(id, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))? .ok_or_else(|| RouteError::not_found("no such project"))?; let internal = |error: goingson_core::CoreError| RouteError::internal(error.to_string()); let tasks = state .tasks .list_by_project(DESKTOP_USER_ID, id) .map_err(internal)?; let all_milestones = state .milestones .list_by_project(id, DESKTOP_USER_ID) .map_err(internal)?; Ok(Dashboard { milestones: milestones_of(id, &all_milestones, &tasks, show_completed), events: state .events .list_by_project(DESKTOP_USER_ID, id) .map_err(internal)?, emails: state .emails .list_by_project(DESKTOP_USER_ID, id) .map_err(internal)?, attached: attached(state, id, attach_error)?, project, tasks, }) } declare! { /// One column, as a region of its own. /// /// # The arrangement finding /// /// **Four peer columns are neither of the two arrangements.** /// `Arrangement` is `ListDetail` or `SidebarContent`, taken from what the /// two webview apps do, and this screen does a third thing: four equal panes /// side by side under a band, none of which chooses what another shows. /// /// The regions themselves are fine -- a `Pane` each, and `Slot` takes as /// many as it is given -- so the screen renders. What is wrong is that it /// has to claim `ListDetail` while being nothing of the sort, and a renderer /// that laid out list-detail faithfully would put the emails pane where the /// detail goes. /// /// Not filed as its own task. `Arrangement`'s docs say the two members came /// from measuring the two apps and that discovering the layer missing after /// the renderers exist is a redesign; this is the first counter-example and /// one counter-example is not a member. It is noted on the /// `Region::Handover` reasoning instead: a dashboard is a candidate for the /// app owning its own arrangement, the way it owns the heatmap. shape column(id: &str, title: &str, body: Node) -> Slot; region id as Pane { section title; include body; } } declare! { /// The whole dashboard. shape screen(dashboard: &Dashboard) -> Screen; screen list_detail "Project" false { at_place crate::quasi::shell::PROJECTS; region "dashboard-band" as Band { page &dashboard.project.name; badge type_label(&dashboard.project.project_type); badge dashboard.project.status.as_str() { tone status_tone(&dashboard.project.status); } act "Back to projects" to get "/projects/{dashboard.project.id}"; } region "dashboard-milestones" as Pane { extend milestones(&dashboard.milestones); } include column("dashboard-tasks", "Tasks", tasks_column(&dashboard.tasks)); include column("dashboard-events", "Events", events_column(&dashboard.events)); include column("dashboard-emails", "Emails", emails_column(&dashboard.emails)); include attachments_column(&dashboard.attached); } } /// Whether a param is on. fn flag(request: &quasi_router::Request, name: &str) -> bool { matches!(request.carried.get(name), Some("1" | "true")) } /// The dashboard. fn dashboard(state: &AppState, request: quasi_router::Request) -> Result { let id = project_id(&request)?; Ok(screen(&read(state, id, flag(&request, "completed"), None)?).into()) } /// Answer a write with the dashboard it happened on, re-read. fn wrote(state: &AppState, id: ProjectId, show_completed: bool) -> Result { Ok(screen(&read(state, id, show_completed, None)?).into()) } /// The attachments column alone, re-read. /// /// What attaching answers with, refused or not. A milestone write reflows the /// document because it lands in two places at once; attaching lands in one, so /// it takes decision 7's narrow swap and leaves the rest of the screen — and a /// half-expanded milestones section — where it was. fn attachments_pane( state: &AppState, id: ProjectId, error: Option<&str>, ) -> Result { Ok(Response::fragment( "dashboard-attachments", Node::Region(attachments_column(&attached(state, id, error)?)), )) } /// Attach the picked file to this project. /// /// The path arrives under `file`, which is the name the `FieldKind::File` field /// submits under. See [`attachments_column`] for why it is a path and what that /// leaves open. fn attach(state: &AppState, request: quasi_router::Request) -> Result { let id = project_id(&request)?; let picked = request .payload .get("file") .unwrap_or_default() .trim() .to_owned(); if picked.is_empty() { return attachments_pane(state, id, Some("Choose a file to attach.")); } match crate::commands::attachment::attach_path(state, None, Some(id), None, &picked) { Ok(attachment) => Ok(attachments_pane(state, id, None)? .toast(Tone::Success, format!("Attached {}.", attachment.filename))), // A failure is ours and is not something a form can say anything useful // about. Everything else is the user's to fix by picking another file, // so it goes back on the field — including the project having gone, // which is a stale screen rather than a 404 worth navigating to. Err(crate::commands::attachment::AttachFailure::Failed(message)) => { Err(RouteError::internal(message)) } Err(failure) => attachments_pane(state, id, Some(&failure.message())), } } /// The attachment a route was addressed at. fn attachment_id( request: &quasi_router::Request, ) -> Result { let raw = request .captures .get("attachment") .ok_or_else(|| RouteError::not_found("no attachment id"))?; Ok(goingson_core::AttachmentId::from( uuid::Uuid::parse_str(raw).map_err(|_| RouteError::not_found("not an attachment id"))?, )) } /// Hand one attachment to whatever the host opens files with. /// /// A `GET`, because nothing about the project changes: the spool copy is how a /// content-addressed blob is read under the name it was attached with, not a /// write the user made. fn open(state: &AppState, request: quasi_router::Request) -> Result { let id = attachment_id(&request)?; let attachment = state .attachments .get_by_id(id, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))? .ok_or_else(|| RouteError::not_found("no such attachment"))?; let spooled = crate::commands::attachment::spool( &state.data_dir, &attachment.blob_hash, &attachment.filename, ) .map_err(|failure| match failure { crate::commands::attachment::AttachFailure::Failed(message) => { RouteError::internal(message) } // Not there yet rather than not there at all: an unsynced blob is a // thing the user can wait for, and the message says which it is. other => RouteError::not_found(other.message()), })?; Ok(Response::goto(Action::external(file_url(&spooled)))) } /// A `file://` address for a path on this machine. /// /// Percent-encoded by hand rather than by a crate: one call site, and the whole /// rule is that everything outside the unreserved set goes out as `%XX` with /// the separator kept. A filename with a space or a `#` in it is the common /// case this exists for, and both would otherwise truncate the address. fn file_url(path: &std::path::Path) -> String { use std::fmt::Write as _; let mut url = String::from("file://"); for byte in path.to_string_lossy().bytes() { match byte { b'/' | b'-' | b'.' | b'_' | b'~' => url.push(byte as char), _ if byte.is_ascii_alphanumeric() => url.push(byte as char), // Infallible into a `String`, and the one thing a `?` here could // report is that formatting failed, which it cannot. _ => { let _ = write!(url, "%{byte:02X}"); } } } url } /// The milestone a route was addressed at. fn milestone_id(request: &quasi_router::Request) -> Result { let raw = request .captures .get("milestone") .ok_or_else(|| RouteError::not_found("no milestone id"))?; Ok(goingson_core::MilestoneId::from( uuid::Uuid::parse_str(raw).map_err(|_| RouteError::not_found("not a milestone id"))?, )) } /// The two states a milestone can be put into by hand. /// /// Spelled as the display strings rather than the db values, because /// `MilestoneStatus` parses the display form (`#[strum(serialize = "Open")]`) /// and `as_str` produces it, so the field's value and its options agree without /// a mapping in between. const MILESTONE_STATUSES: &[&str] = &["Open", "Completed"]; /// What the milestone form is filling in, and what it is answering. struct Asking<'a> { project: ProjectId, existing: Option<&'a Milestone>, errors: &'a [(&'a str, String)], submitted: Option<&'a quasi_router::Params>, } /// Whether the form is editing rather than adding. fn is_edit(asking: &Asking) -> bool { asking.existing.is_some() } /// What one question holds. /// /// A refused submission wins over the stored value, and the stored value over /// nothing. Reading the submission first is what stops a validation error from /// handing back the row as it was and losing the edit. fn value_of(asking: &Asking, name: &str) -> String { asking .submitted .and_then(|params| params.get(name).map(std::borrow::ToOwned::to_owned)) .unwrap_or_else(|| stored(asking, name)) } /// What the milestone itself says. fn stored(asking: &Asking, name: &str) -> String { let Some(milestone) = asking.existing else { return String::new(); }; match name { "name" => milestone.name.clone(), "description" => milestone.description.clone(), "target_date" => milestone .target_date .map(|date| date.format("%Y-%m-%d").to_string()) .unwrap_or_default(), "status" => milestone.status.as_str().to_owned(), _ => String::new(), } } /// Whether a named question was refused. fn has_error(asking: &Asking, name: &str) -> bool { asking.errors.iter().any(|(field, _)| *field == name) } /// Why it was refused, or nothing. fn error_for(asking: &Asking, name: &str) -> String { asking .errors .iter() .find(|(field, _)| *field == name) .map(|(_, message)| message.clone()) .unwrap_or_default() } /// What the form is called. fn form_title(asking: &Asking) -> String { asking.existing.map_or_else( || "New milestone".to_owned(), |milestone| format!("Edit {}", milestone.name), ) } /// Where it writes. fn form_path(asking: &Asking) -> String { match asking.existing { Some(milestone) => format!("/projects/{}/milestones/{}", asking.project, milestone.id), None => format!("/projects/{}/milestones", asking.project), } } /// What its button reads. fn form_submit(asking: &Asking) -> &'static str { if is_edit(asking) { "Save milestone" } else { "Create milestone" } } declare! { /// The add or edit form as a screen of its own. /// /// Both are addresses rather than overlays, on the shape task edit /// established (goingson@2384df1) and for the reason recorded there: a modal /// is a second arrangement drawn over the first, and a screen that offers a /// control which opens a form over itself has to describe two arrangements /// at once. Cancel is the dashboard's own address, and the dashboard is /// rebuilt from the database rather than restored from memory. /// /// Status is asked only on an edit: a milestone being created is Open, and /// a question with one useful answer is not a question. shape milestone_form(asking: &Asking) -> Screen; screen list_detail "Milestone" false { at_place crate::quasi::shell::PROJECTS; region "milestone-band" as Band { page form_title(asking); act "Cancel" to get "/projects/{asking.project}/dashboard"; } region "milestone-form" as Pane { form post form_path(asking) { submit form_submit(asking); field Text "name" "Name" { required; placeholder "What does reaching it mean?"; value value_of(asking, "name"); error error_for(asking, "name") when has_error(asking, "name"); } field Textarea "description" "Description" { placeholder "Anything the name does not cover (optional)"; value value_of(asking, "description"); error error_for(asking, "description") when has_error(asking, "description"); } field Text "target_date" "Target Date (optional)" { placeholder "next friday, 2026-03-01..."; value value_of(asking, "target_date"); error error_for(asking, "target_date") when has_error(asking, "target_date"); } field Select "status" "Status" when is_edit(asking) { for offered in MILESTONE_STATUSES.iter().copied() { option Choice::new(offered, offered); } value value_of(asking, "status"); error error_for(asking, "status") when has_error(asking, "status"); } } } } } /// Load one milestone, refusing one that belongs to another project. /// /// `get_by_id` scopes by user and not by project, so the project in the address /// is checked here. Without it `/projects/{a}/milestones/{b-of-another}/edit` /// would render a form that saves to a milestone the address does not name. fn load_milestone( state: &AppState, project: ProjectId, id: goingson_core::MilestoneId, ) -> Result { let milestone = state .milestones .get_by_id(id, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))? .ok_or_else(|| RouteError::not_found("no such milestone"))?; if milestone.project_id != project { return Err(RouteError::not_found("no such milestone")); } Ok(milestone) } /// What a submitted milestone must satisfy. /// /// A described `.required()` is a claim to the renderer rather than a check: /// the form can be submitted past it, so the refusal has to exist here too. fn validate_milestone(name: &str) -> Vec<(&'static str, String)> { let mut errors = Vec::new(); if name.is_empty() { errors.push(("name", "A milestone needs a name.".to_owned())); } errors } /// The target date, parsed from what the field holds. /// /// A described form has no transform step, so "next friday" is parsed here, /// against the same core function the task form uses. A date is a day rather /// than an instant, so the time half of the parse is dropped. fn milestone_target( raw: &str, errors: &mut Vec<(&'static str, String)>, ) -> Option { if raw.is_empty() { return None; } match goingson_core::parse_natural_date(raw, Local::now().naive_local()) { Some(when) => Some(when.date()), None => { errors.push(( "target_date", "Date not recognized. Try \"next friday\" or \"2026-03-01\".".to_owned(), )); None } } } /// The add form. fn new_milestone(state: &AppState, request: quasi_router::Request) -> Result { let project = project_id(&request)?; // Loaded rather than trusted: the form posts to this project, so an address // naming one that does not exist should say so now rather than on submit. state .projects .get_by_id(project, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))? .ok_or_else(|| RouteError::not_found("no such project"))?; Ok(milestone_form(&Asking { project, existing: None, errors: &[], submitted: None, }) .into()) } /// Create it, or answer with the form saying why not. /// /// `position` is the count of what is already there, which is what /// `list_by_project` orders by: a new milestone lands last, and `move` is how /// it gets anywhere else. fn create_milestone( state: &AppState, request: quasi_router::Request, ) -> Result { let project = project_id(&request)?; let field = |name: &str| { request .payload .get(name) .unwrap_or_default() .trim() .to_owned() }; let name = field("name"); let mut errors = validate_milestone(&name); let target_date = milestone_target(&field("target_date"), &mut errors); if !errors.is_empty() { return Ok(milestone_form(&Asking { project, existing: None, errors: &errors, submitted: Some(&request.payload), }) .into()); } let existing = state .milestones .list_by_project(project, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))?; state .milestones .create( DESKTOP_USER_ID, goingson_core::NewMilestone { project_id: project, name, description: field("description"), position: i32::try_from(existing.len()).unwrap_or(i32::MAX), target_date, }, ) .map_err(|error| RouteError::internal(error.to_string()))?; wrote(state, project, flag(&request, "completed")) } /// The edit form. fn edit_milestone( state: &AppState, request: quasi_router::Request, ) -> Result { let project = project_id(&request)?; let milestone = load_milestone(state, project, milestone_id(&request)?)?; Ok(milestone_form(&Asking { project, existing: Some(&milestone), errors: &[], submitted: None, }) .into()) } /// Save the edited milestone, or answer with the form saying why not. fn update_milestone( state: &AppState, request: quasi_router::Request, ) -> Result { let project = project_id(&request)?; let milestone = load_milestone(state, project, milestone_id(&request)?)?; let field = |name: &str| { request .payload .get(name) .unwrap_or_default() .trim() .to_owned() }; let name = field("name"); let mut errors = validate_milestone(&name); let target_date = milestone_target(&field("target_date"), &mut errors); let status = super::super::parse_choice::(&request.payload, "status", &mut errors); let (Some(status), true) = (status, errors.is_empty()) else { return Ok(milestone_form(&Asking { project, existing: Some(&milestone), errors: &errors, submitted: Some(&request.payload), }) .into()); }; state .milestones .update( milestone.id, DESKTOP_USER_ID, &name, &field("description"), target_date, &status, ) .map_err(|error| RouteError::internal(error.to_string()))? .ok_or_else(|| RouteError::not_found("no such milestone"))?; wrote(state, project, flag(&request, "completed")) } /// Move a milestone one place up or down among the open ones. /// /// `reorder` takes the whole order rather than a swap, so the handler reads the /// current order, moves one, and writes it back. The order it writes is every /// milestone, completed ones included: they carry positions too, and sending /// only the open ones would silently renumber the rest. fn move_milestone( state: &AppState, request: quasi_router::Request, ) -> Result { let project = project_id(&request)?; let target = milestone_id(&request)?; let by: i32 = request .payload .get("by") .and_then(|raw| raw.parse().ok()) .filter(|by| *by == -1 || *by == 1) .ok_or_else(|| RouteError::not_found("move by -1 or 1"))?; let mut order: Vec = state .milestones .list_by_project(project, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))? .iter() .map(|milestone| milestone.id) .collect(); let at = order .iter() .position(|id| *id == target) .ok_or_else(|| RouteError::not_found("no such milestone"))?; let to = at as i32 + by; // Off either end is a no-op rather than an error: the control that sent it // is disabled, so arriving here means a stale screen, and the right answer // to a stale screen is a fresh one. if to >= 0 && (to as usize) < order.len() { order.swap(at, to as usize); state .milestones .reorder(project, DESKTOP_USER_ID, &order) .map_err(|error| RouteError::internal(error.to_string()))?; } wrote(state, project, flag(&request, "completed")) } /// Delete a milestone. fn delete_milestone( state: &AppState, request: quasi_router::Request, ) -> Result { let project = project_id(&request)?; let deleted = state .milestones .delete(milestone_id(&request)?, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))?; if !deleted { return Err(RouteError::not_found("no such milestone")); } wrote(state, project, flag(&request, "completed")) } /// The dashboard's routes. #[must_use] pub(super) fn routes(router: Router) -> Router { router .get("/projects/{id}/dashboard", dashboard) // Above `/milestones/{milestone}/...`, because `new` and `edit` are // literal segments where the others capture: a router that matched the // capture first would read `new` as a milestone id. .get("/projects/{id}/milestones/new", new_milestone) .post("/projects/{id}/milestones", create_milestone) .get("/projects/{id}/milestones/{milestone}/edit", edit_milestone) .post("/projects/{id}/milestones/{milestone}", update_milestone) .post("/projects/{id}/milestones/{milestone}/move", move_milestone) .post( "/projects/{id}/milestones/{milestone}/delete", delete_milestone, ) .post("/projects/{id}/attachments", attach) .get("/projects/{id}/attachments/{attachment}/open", open) }