//! The screens, described rather than built. This is the frontend. //! //! //! //! The window opens on `quasi://localhost/tasks` and there is no other //! document. Escaping is typed in Rust at the renderer, so no call site can //! forget it. //! //! Record next to the code that ran into it anything a real screen needs that //! the description layer cannot say; a finding that lives only in a commit //! message is a finding nobody acts on. Each screen module carries its own. //! //! # The shape //! //! One module per screen, each contributing its own routes. There is no //! `Router::merge`, so composition is a chain of functions that each take the //! router and give it back, rather than a table assembled somewhere central //! that has to be kept in step with the modules. //! //! [`shell`] is the one module that is not a screen: it holds the app's //! navigation and asks [`time_tracking`] for the running-timer band. [`assets`] //! is the other, and serves the stylesheets, scripts and fonts the document //! asks for. //! //! # Not described //! //! No build ships until every screen is described. //! //! | Missing | Comes back when | //! |---|---| //! | Settings > Sharing | its reads are remote, so there is no local state to draw a section from. quasicoherent `82273265` | //! | Create Backup | [`data`] finding 2: a described write cannot be long-running | //! | The blocking graph | `524261ac` ruled it bespoke; it draws an SVG with computed coordinates | //! //! The blocking graph is not coming back as a description. If it returns it is //! as something a host draws. //! //! `check_vocabulary_use` is an open hole: it asked which generated classes no //! markup emits, and the markup is `quasi-webview`'s emitter, in another crate. //! goingson `43a682b0` restores it, `daac5cc7` is the stylesheet. //! //! # Android is the one platform this does not serve //! //! Its webview cannot read a request body, so a POST would arrive with its form //! dropped. `build_mobile_app` registers the scheme everywhere except android //! for that reason, and android has no frontend at all. goingson `23181009` //! and the mobile set are where that lives. //! use std::sync::Arc; use goingson_core::{Task, TaskStatus, UpdateTask}; use makeover_layout::Tone; use quasi_router::screen::Tag; use quasi_router::{RouteError, Router}; use crate::state::{AppState, DESKTOP_USER_ID}; pub mod assets; pub mod board; pub mod compose; pub mod contacts; pub mod contexts; pub mod data; pub mod day_planning; pub mod emails; pub mod events; pub mod monthly_review; pub mod problems; pub mod projects; pub mod search; pub mod settings; pub mod shell; pub mod task_list; pub mod tasks; pub mod theming; pub mod time_tracking; pub mod weekly_review; /// Where a task sits in the dependency graph, for the surfaces that draw it. /// /// Every task surface says whether the task is available. It lives here once /// rather than in each surface, so the same task cannot read as blocked in one /// view and as ordinary work in the next. /// /// # The detail behind each label /// /// Each badge carries a longer form through [`Tag::hinted`]: the block depth /// behind "Blocked", the freed count's wording behind "Unblocks N", the repair /// instruction behind "Cycle". /// /// A renderer may drop a hint (quasi-tui does, having nowhere to put one), so /// nothing here may live only in a hint. Each of the three is a /// longer form of a label that is already on screen, which is what makes that /// safe: the badge alone is the fact, and the hint is the same fact said at /// length. #[derive(Clone, Copy)] pub(crate) struct Availability { /// Something unfinished is in this task's way. blocked: bool, /// How many sequential steps stand between this task and being startable. /// /// The longest chain, not the shortest, because a task waits for every /// blocker it has. Read for the "Blocked" hint and nothing else, which is /// why it is not itself a badge: a number on a card competes with the /// label, and the label is what a reader scans for. depth: u32, /// It sits on a cycle, so it can never open. in_cycle: bool, /// How many tasks finishing this one would free. unblocks: u32, } impl Availability { /// Read it off a task. pub(crate) fn of(task: &Task) -> Self { Self { blocked: task.is_blocked(), depth: task.graph.block_depth, in_cycle: task.graph.in_cycle, unblocks: task.graph.unblocks_count, } } /// Read it off the response shape, for the screens served one. /// /// `TaskResponse` flattens [`goingson_core::GraphPosition`] into three /// fields rather than holding it, so this is the same three facts arriving /// by the other route. pub(crate) fn reported(task: &crate::commands::TaskResponse) -> Self { Self { blocked: task.is_blocked, depth: task.block_depth, in_cycle: task.in_cycle, unblocks: task.unblocks_count, } } /// The marker a task surface carries, if any. /// /// One token at most. The two are mutually exclusive by construction: a /// blocked task's freed count is real but not actionable, so it is omitted /// rather than competing with the blocked badge, and a ready task with /// nothing downstream is the ordinary case and carries nothing at all. pub(crate) fn marker(self) -> Option { if self.in_cycle { return Some( Tag::badge("Cycle") .tone(Tone::Danger) .hinted("On a dependency cycle, so it can never open. Remove an edge."), ); } if self.blocked { return Some( Tag::badge("Blocked") .tone(Tone::Warning) .hinted(steps_away(self.depth)), ); } self.frees_marker() } /// The "frees other work" half alone. /// /// The day plan's pool takes only this one. Its gate already refuses to /// offer a blocked task until every blocker it has is in the day, so a /// bare "Blocked" there would contradict the plan's own answer; the gate /// names the blocker instead. What the gate cannot say is which task is /// worth scheduling first, which is what this says. pub(crate) fn frees_marker(self) -> Option { (!self.blocked && !self.in_cycle && self.unblocks > 0).then(|| { Tag::badge(format!("Unblocks {}", self.unblocks)) .tone(Tone::Info) .hinted(format!( "Finishing this frees {}.", match self.unblocks { 1 => "1 other task".to_owned(), many => format!("{many} other tasks"), } )) }) } } /// How far a blocked task is from being startable, in words. /// /// The depth is the longest chain ahead of the task, so "1 step away" means one /// completion and nothing else stands in the way. A depth of zero cannot reach /// here -- `is_blocked` is `block_depth > 0` -- and is said rather than /// asserted, because the two facts are cached columns that a merge could in /// principle disagree about, and a badge is not the place to panic. fn steps_away(depth: u32) -> String { match depth { 0 => "Waiting on something unfinished.".to_owned(), 1 => "1 step away: one task has to finish first.".to_owned(), many => format!("{many} steps away, counting the longest chain of blockers."), } } /// Move a task to a named status, whoever asked. /// /// Two surfaces ask: the board, where it is a drop, and the task list, where it /// is a row's own control. It lives here for the reason [`Availability`] does. /// "Set the status column" is three different writes — starting stamps a start /// time, completing runs the recurrence rule and stops the timer, and going /// back to Pending resends every field because [`UpdateTask`] replaces rather /// than patches — and a second copy of that knowledge is a second answer to /// what completing a task means. /// /// Answers the sentence to say afterwards. The caller decides what to re-read, /// because only the caller knows which region it is answering. /// /// `to` is never [`TaskStatus::Deleted`]: deleting is its own route on both /// surfaces, and a status control that could delete would put it one keystroke /// from Completed. pub(crate) fn move_to( state: &AppState, task: &Task, to: &TaskStatus, ) -> Result<&'static str, RouteError> { match to { TaskStatus::Started => { state .tasks .start(task.id, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))?; Ok("Task started.") } TaskStatus::Completed => { // [`crate::commands::complete`] and not the repository's `complete`, // which is a status transition and three quarters of what // completing a task means here. It was the repository's until // 2026-08-15, and the missing quarter never showed: a weekly task // completed from the board had its recurrence chain end there, a // running timer kept accruing, and a milestone whose last task it // was stayed open. The row left the list either way, which is why // nothing noticed. crate::commands::complete(state, task.id) .map_err(|error| RouteError::internal(error.to_string()))?; Ok("Task completed.") } TaskStatus::Pending => { // Built from the task just read, so the only thing that changes is // the status; anything left out would be cleared, and moving a task // back to Pending is not a reason to lose its tags. state .tasks .update( task.id, DESKTOP_USER_ID, UpdateTask { project_id: task.project_id, milestone_id: task.milestone_id, contact_id: task.contact_id, title: task.title.clone(), description: task.description.clone(), status: TaskStatus::Pending, priority: task.priority.clone(), due: task.due, tags: task.tags.clone(), recurrence: task.recurrence.clone(), recurrence_rule: task.recurrence_rule.clone(), urgency: task.urgency, scheduled_start: task.scheduled_start, scheduled_duration: task.scheduled_duration, estimated_minutes: task.estimated_minutes, }, ) .map_err(|error| RouteError::internal(error.to_string()))? .ok_or_else(|| RouteError::not_found("no such task"))?; Ok("Task moved to Pending.") } TaskStatus::Deleted => Err(RouteError::not_found("not a status a control can set")), } } /// One choice, parsed strictly, adding its own complaint if it will not. /// /// A select offers a fixed set, so an unparseable value did not come from the /// form. Refused rather than defaulted: every one of these enums has a /// `from_str_or_default` that would file a typo as Medium or Pending and say /// nothing, which is the right behaviour for a database read and the wrong one /// for a submission. /// /// Here rather than duplicated per form, because the alternative is two answers /// to what a rejected option says. pub(crate) fn parse_choice( params: &quasi_router::Params, name: &'static str, errors: &mut Vec<(&'static str, String)>, ) -> Option { match params.get(name).unwrap_or_default().parse() { Ok(value) => Some(value), Err(_) => { errors.push((name, "Not one of the options offered.".to_owned())); None } } } /// An id a select offers as an option, where the empty option means none. /// /// Here rather than in one screen because three forms ask it: a task's project, /// contact and milestone, and an event's project and contact. /// /// Two layers of `Option` and both mean something: the outer is whether the /// value parsed, the inner is whether one was chosen. Flattening them here /// would make a typo indistinguishable from "No Project", which is the one /// pair of outcomes this function exists to keep apart. #[allow(clippy::option_option)] pub(crate) fn parse_optional_id>( params: &quasi_router::Params, name: &'static str, errors: &mut Vec<(&'static str, String)>, ) -> Option> { match params.get(name).unwrap_or_default().trim() { "" => Some(None), raw => match uuid::Uuid::parse_str(raw) { Ok(id) => Some(Some(T::from(id))), Err(_) => { errors.push((name, "Not one of the options offered.".to_owned())); None } }, } } /// Every described screen's routes. #[must_use] pub fn router() -> Router { let router = Router::::new(); let router = projects::routes(router); let router = contacts::routes(router); let router = tasks::routes(router); let router = settings::routes(router); let router = weekly_review::routes(router); let router = monthly_review::routes(router); let router = problems::routes(router); let router = search::routes(router); let router = day_planning::routes(router); let router = contexts::routes(router); let router = board::routes(router); let router = task_list::routes(router); let router = data::routes(router); let router = time_tracking::routes(router); let router = emails::routes(router); let router = compose::routes(router); events::routes(router) } /// The document every described screen is served inside. /// /// Separate from [`protocol`] so it can be looked at without a Tauri handle. /// What the document loads is a fact worth a test. #[must_use] pub fn document_shell() -> quasi_webview::Shell { quasi_webview::Shell::under("quasi://localhost/static") // The same order and the same layers index.html declared, because it is // the same cascade: the value sheets before the sheet that reads their // custom properties. .layered(["base", "components", "responsive"]) .styled("/static/typography.css") .styled("/static/geometry.css") .styled("/static/timing.css") .styled("/static/layout.css") .styled("/static/styles.css") // Last, so the chosen theme's intent tokens override the stock ones // §3 of styles.css declares. See [`theming`]. .styled(theming::ADDRESS) // Not vendored, so asking for it would be one 404 per document. .without_hyperscript() // The host half of `Action::by_host`, which is how a file gets picked. // `with_head` because a `Shell` has no `script`, and this is the app's // own rather than one the renderer ships. See `assets` and // `frontend/js/host.js`. .with_head("") } /// The document the compose window is served inside. /// /// [`document_shell`] without [`shell::chrome`], which is the whole difference /// and is the point: a compose window has no mailbox nav and no running-timer /// band. Eudora's did not either. What surrounds the screen here is the /// [`Frame`](quasi_router::Frame) the mount supplies, which is what that member /// is for. /// /// Everything else is shared rather than copied. The stylesheets, their order /// and `host.js` are the same document furniture, and a second list of them /// would drift the first time one was added. #[must_use] pub fn compose_shell() -> quasi_webview::Shell { document_shell() } /// The custom protocol serving the screens inside the app, and the handle its /// state arrives through. /// /// `quasi://localhost/projects`. The assets come from the same scheme, which is /// the one thing that differs from the same description served over HTTP. /// /// # Why the state comes later /// /// [`AppState::new`] takes an `AppHandle` to resolve the data directory, and /// the handle does not exist until tauri's builder runs — which is after every /// scheme is registered. So this hands back a `Late` for `setup` to /// fill in, and a request in that gap is answered 503 rather than held. See /// [`quasi_tauri::Late`]. #[must_use] pub fn protocol() -> ( quasi_tauri::Protocol, quasi_tauri::Late, ) { let (protocol, late) = quasi_tauri::Protocol::pending( "quasi", router(), Arc::new( quasi_webview::Webview::under("quasi://localhost/static").with_shell( // The app's own furniture: where you can go, and the // running-timer band. Applied here rather than in // [`document_shell`] because the compose window is served the // same document without it. See [`shell`]. document_shell().with_chrome(shell::chrome()), ), ), ); // A stylesheet is not a description. This runs before the router and wins, // so the two address spaces are kept apart: see [`assets`]. (protocol.passthrough(assets::get), late) } /// The compose window's scheme: the same screens, in a mount of its own. /// /// `compose://localhost/compose/{id}`. goingson `3fb2526a`, and the second half /// of what [`Frame`](quasi_router::Frame) was added for. /// /// # Why a second scheme rather than a second window on the first /// /// A [`quasi_tauri::Protocol`] is a scheme, a router, a state and **one** /// renderer, and a renderer is where the frame lives. Two mounts wanting two /// frames is therefore two protocols. They share [`router`] — the same /// description, which is the entire point: compose does not know which window /// it is in, and a screen that did would be two code paths. /// /// # What differs, and it is two things /// /// [`compose_shell`] rather than the app's, so there is no mailbox nav and no /// running-timer band. And a frame that reports: the compose window has a /// status line. [`Frame::holds`](quasi_router::Frame::holds) decides where a /// banner goes: it rests in the line here and floats in the main window, from /// one description. /// /// The frame offers **no verbs**, and that is deliberate rather than /// unfinished. Queue, Queue later, Discard and Take it back are on the screen, /// so both mounts get them from one place; a frame carrying them too would draw /// each verb twice in this window. `Frame::verbs` is for what a mount adds, and /// this mount adds a place to speak rather than something to press. /// /// # Every screen, not only compose /// /// The router is the whole app's, so this scheme will serve any address. That /// is a consequence of sharing one description and it is harmless: nothing /// links into this scheme except [`crate::commands::window::open_compose_window`], /// which builds the address itself. Narrowing it to one route would mean a /// second router to keep in step with the first. #[must_use] pub fn compose_protocol() -> ( quasi_tauri::Protocol, quasi_tauri::Late, ) { let (protocol, late) = quasi_tauri::Protocol::pending( "compose", router(), Arc::new( quasi_webview::Webview::under("compose://localhost/static") .with_shell(compose_shell()) .with_frame(quasi_router::Frame::new().reporting()), ), ); (protocol.passthrough(assets::get), late) }