max / goingson
| 1 | //! The screens, described rather than built. This is the frontend. |
| 2 | //! |
| 3 | //! <!-- wiki: quasi-overview --> |
| 4 | //! |
| 5 | //! The window opens on `quasi://localhost/tasks` and there is no other |
| 6 | //! document. Escaping is typed in Rust at the renderer, so no call site can |
| 7 | //! forget it. |
| 8 | //! |
| 9 | //! Record next to the code that ran into it anything a real screen needs that |
| 10 | //! the description layer cannot say; a finding that lives only in a commit |
| 11 | //! message is a finding nobody acts on. Each screen module carries its own. |
| 12 | //! |
| 13 | //! # The shape |
| 14 | //! |
| 15 | //! One module per screen, each contributing its own routes. There is no |
| 16 | //! `Router::merge`, so composition is a chain of functions that each take the |
| 17 | //! router and give it back, rather than a table assembled somewhere central |
| 18 | //! that has to be kept in step with the modules. |
| 19 | //! |
| 20 | //! [`shell`] is the one module that is not a screen: it holds the app's |
| 21 | //! navigation and asks [`time_tracking`] for the running-timer band. [`assets`] |
| 22 | //! is the other, and serves the stylesheets, scripts and fonts the document |
| 23 | //! asks for. |
| 24 | //! |
| 25 | //! # Not described |
| 26 | //! |
| 27 | //! No build ships until every screen is described. |
| 28 | //! |
| 29 | //! | Missing | Comes back when | |
| 30 | //! |---|---| |
| 31 | //! | Settings > Sharing | its reads are remote, so there is no local state to draw a section from. quasicoherent `82273265` | |
| 32 | //! | Create Backup | [`data`] finding 2: a described write cannot be long-running | |
| 33 | //! | The blocking graph | `524261ac` ruled it bespoke; it draws an SVG with computed coordinates | |
| 34 | //! |
| 35 | //! The blocking graph is not coming back as a description. If it returns it is |
| 36 | //! as something a host draws. |
| 37 | //! |
| 38 | //! `check_vocabulary_use` is an open hole: it asked which generated classes no |
| 39 | //! markup emits, and the markup is `quasi-webview`'s emitter, in another crate. |
| 40 | //! goingson `43a682b0` restores it, `daac5cc7` is the stylesheet. |
| 41 | //! |
| 42 | //! # Android is the one platform this does not serve |
| 43 | //! |
| 44 | //! Its webview cannot read a request body, so a POST would arrive with its form |
| 45 | //! dropped. `build_mobile_app` registers the scheme everywhere except android |
| 46 | //! for that reason, and android has no frontend at all. goingson `23181009` |
| 47 | //! and the mobile set are where that lives. |
| 48 | //! |
| 49 | use Arc; |
| 50 | |
| 51 | use ; |
| 52 | use Tone; |
| 53 | use Tag; |
| 54 | use ; |
| 55 | |
| 56 | use crate; |
| 57 | |
| 58 | |
| 59 | |
| 60 | |
| 61 | |
| 62 | |
| 63 | |
| 64 | |
| 65 | |
| 66 | |
| 67 | |
| 68 | |
| 69 | |
| 70 | |
| 71 | |
| 72 | |
| 73 | |
| 74 | |
| 75 | |
| 76 | |
| 77 | |
| 78 | |
| 79 | /// Where a task sits in the dependency graph, for the surfaces that draw it. |
| 80 | /// |
| 81 | /// Every task surface says whether the task is available. It lives here once |
| 82 | /// rather than in each surface, so the same task cannot read as blocked in one |
| 83 | /// view and as ordinary work in the next. |
| 84 | /// |
| 85 | /// # The detail behind each label |
| 86 | /// |
| 87 | /// Each badge carries a longer form through [`Tag::hinted`]: the block depth |
| 88 | /// behind "Blocked", the freed count's wording behind "Unblocks N", the repair |
| 89 | /// instruction behind "Cycle". |
| 90 | /// |
| 91 | /// A renderer may drop a hint (quasi-tui does, having nowhere to put one), so |
| 92 | /// nothing here may live only in a hint. Each of the three is a |
| 93 | /// longer form of a label that is already on screen, which is what makes that |
| 94 | /// safe: the badge alone is the fact, and the hint is the same fact said at |
| 95 | /// length. |
| 96 | |
| 97 | pub |
| 98 | /// Something unfinished is in this task's way. |
| 99 | blocked: bool, |
| 100 | /// How many sequential steps stand between this task and being startable. |
| 101 | /// |
| 102 | /// The longest chain, not the shortest, because a task waits for every |
| 103 | /// blocker it has. Read for the "Blocked" hint and nothing else, which is |
| 104 | /// why it is not itself a badge: a number on a card competes with the |
| 105 | /// label, and the label is what a reader scans for. |
| 106 | depth: u32, |
| 107 | /// It sits on a cycle, so it can never open. |
| 108 | in_cycle: bool, |
| 109 | /// How many tasks finishing this one would free. |
| 110 | unblocks: u32, |
| 111 | |
| 112 | |
| 113 | |
| 114 | /// Read it off a task. |
| 115 | pub |
| 116 | Self |
| 117 | blocked: task.is_blocked, |
| 118 | depth: task.graph.block_depth, |
| 119 | in_cycle: task.graph.in_cycle, |
| 120 | unblocks: task.graph.unblocks_count, |
| 121 | |
| 122 | |
| 123 | |
| 124 | /// Read it off the response shape, for the screens served one. |
| 125 | /// |
| 126 | /// `TaskResponse` flattens [`goingson_core::GraphPosition`] into three |
| 127 | /// fields rather than holding it, so this is the same three facts arriving |
| 128 | /// by the other route. |
| 129 | pub |
| 130 | Self |
| 131 | blocked: task.is_blocked, |
| 132 | depth: task.block_depth, |
| 133 | in_cycle: task.in_cycle, |
| 134 | unblocks: task.unblocks_count, |
| 135 | |
| 136 | |
| 137 | |
| 138 | /// The marker a task surface carries, if any. |
| 139 | /// |
| 140 | /// One token at most. The two are mutually exclusive by construction: a |
| 141 | /// blocked task's freed count is real but not actionable, so it is omitted |
| 142 | /// rather than competing with the blocked badge, and a ready task with |
| 143 | /// nothing downstream is the ordinary case and carries nothing at all. |
| 144 | pub |
| 145 | if self.in_cycle |
| 146 | return Some |
| 147 | badge |
| 148 | .tone |
| 149 | .hinted, |
| 150 | ; |
| 151 | |
| 152 | if self.blocked |
| 153 | return Some |
| 154 | badge |
| 155 | .tone |
| 156 | .hinted, |
| 157 | ; |
| 158 | |
| 159 | self.frees_marker |
| 160 | |
| 161 | |
| 162 | /// The "frees other work" half alone. |
| 163 | /// |
| 164 | /// The day plan's pool takes only this one. Its gate already refuses to |
| 165 | /// offer a blocked task until every blocker it has is in the day, so a |
| 166 | /// bare "Blocked" there would contradict the plan's own answer; the gate |
| 167 | /// names the blocker instead. What the gate cannot say is which task is |
| 168 | /// worth scheduling first, which is what this says. |
| 169 | pub |
| 170 | .then |
| 171 | badge |
| 172 | .tone |
| 173 | .hinted |
| 174 | "Finishing this frees {}." |
| 175 | match self.unblocks |
| 176 | 1 => "1 other task".to_owned, |
| 177 | many => format!, |
| 178 | |
| 179 | ) |
| 180 | |
| 181 | |
| 182 | |
| 183 | |
| 184 | /// How far a blocked task is from being startable, in words. |
| 185 | /// |
| 186 | /// The depth is the longest chain ahead of the task, so "1 step away" means one |
| 187 | /// completion and nothing else stands in the way. A depth of zero cannot reach |
| 188 | /// here -- `is_blocked` is `block_depth > 0` -- and is said rather than |
| 189 | /// asserted, because the two facts are cached columns that a merge could in |
| 190 | /// principle disagree about, and a badge is not the place to panic. |
| 191 | |
| 192 | match depth |
| 193 | 0 => "Waiting on something unfinished.".to_owned, |
| 194 | 1 => "1 step away: one task has to finish first.".to_owned, |
| 195 | many => format!, |
| 196 | |
| 197 | |
| 198 | |
| 199 | /// Move a task to a named status, whoever asked. |
| 200 | /// |
| 201 | /// Two surfaces ask: the board, where it is a drop, and the task list, where it |
| 202 | /// is a row's own control. It lives here for the reason [`Availability`] does. |
| 203 | /// "Set the status column" is three different writes — starting stamps a start |
| 204 | /// time, completing runs the recurrence rule and stops the timer, and going |
| 205 | /// back to Pending resends every field because [`UpdateTask`] replaces rather |
| 206 | /// than patches — and a second copy of that knowledge is a second answer to |
| 207 | /// what completing a task means. |
| 208 | /// |
| 209 | /// Answers the sentence to say afterwards. The caller decides what to re-read, |
| 210 | /// because only the caller knows which region it is answering. |
| 211 | /// |
| 212 | /// `to` is never [`TaskStatus::Deleted`]: deleting is its own route on both |
| 213 | /// surfaces, and a status control that could delete would put it one keystroke |
| 214 | /// from Completed. |
| 215 | pub |
| 216 | state: &AppState, |
| 217 | task: &Task, |
| 218 | to: &TaskStatus, |
| 219 | |
| 220 | match to |
| 221 | Started => |
| 222 | state |
| 223 | .tasks |
| 224 | .start |
| 225 | .map_err?; |
| 226 | Ok |
| 227 | |
| 228 | Completed => |
| 229 | // [`crate::commands::complete`] and not the repository's `complete`, |
| 230 | // which is a status transition and three quarters of what |
| 231 | // completing a task means here. It was the repository's until |
| 232 | // 2026-08-15, and the missing quarter never showed: a weekly task |
| 233 | // completed from the board had its recurrence chain end there, a |
| 234 | // running timer kept accruing, and a milestone whose last task it |
| 235 | // was stayed open. The row left the list either way, which is why |
| 236 | // nothing noticed. |
| 237 | cratecomplete |
| 238 | .map_err?; |
| 239 | Ok |
| 240 | |
| 241 | Pending => |
| 242 | // Built from the task just read, so the only thing that changes is |
| 243 | // the status; anything left out would be cleared, and moving a task |
| 244 | // back to Pending is not a reason to lose its tags. |
| 245 | state |
| 246 | .tasks |
| 247 | .update |
| 248 | task.id, |
| 249 | DESKTOP_USER_ID, |
| 250 | UpdateTask |
| 251 | project_id: task.project_id, |
| 252 | milestone_id: task.milestone_id, |
| 253 | contact_id: task.contact_id, |
| 254 | title: task.title.clone, |
| 255 | description: task.description.clone, |
| 256 | status: Pending, |
| 257 | priority: task.priority.clone, |
| 258 | due: task.due, |
| 259 | tags: task.tags.clone, |
| 260 | recurrence: task.recurrence.clone, |
| 261 | recurrence_rule: task.recurrence_rule.clone, |
| 262 | urgency: task.urgency, |
| 263 | scheduled_start: task.scheduled_start, |
| 264 | scheduled_duration: task.scheduled_duration, |
| 265 | estimated_minutes: task.estimated_minutes, |
| 266 | , |
| 267 | |
| 268 | .map_err? |
| 269 | .ok_or_else?; |
| 270 | Ok |
| 271 | |
| 272 | Deleted => Err, |
| 273 | |
| 274 | |
| 275 | |
| 276 | /// One choice, parsed strictly, adding its own complaint if it will not. |
| 277 | /// |
| 278 | /// A select offers a fixed set, so an unparseable value did not come from the |
| 279 | /// form. Refused rather than defaulted: every one of these enums has a |
| 280 | /// `from_str_or_default` that would file a typo as Medium or Pending and say |
| 281 | /// nothing, which is the right behaviour for a database read and the wrong one |
| 282 | /// for a submission. |
| 283 | /// |
| 284 | /// Here rather than duplicated per form, because the alternative is two answers |
| 285 | /// to what a rejected option says. |
| 286 | pub |
| 287 | params: &Params, |
| 288 | name: &'static str, |
| 289 | errors: &mut , |
| 290 | |
| 291 | match params.get.unwrap_or_default.parse |
| 292 | Ok => Some, |
| 293 | Err => |
| 294 | errors.push; |
| 295 | None |
| 296 | |
| 297 | |
| 298 | |
| 299 | |
| 300 | /// An id a select offers as an option, where the empty option means none. |
| 301 | /// |
| 302 | /// Here rather than in one screen because three forms ask it: a task's project, |
| 303 | /// contact and milestone, and an event's project and contact. |
| 304 | /// |
| 305 | /// Two layers of `Option` and both mean something: the outer is whether the |
| 306 | /// value parsed, the inner is whether one was chosen. Flattening them here |
| 307 | /// would make a typo indistinguishable from "No Project", which is the one |
| 308 | /// pair of outcomes this function exists to keep apart. |
| 309 | |
| 310 | pub |
| 311 | params: &Params, |
| 312 | name: &'static str, |
| 313 | errors: &mut , |
| 314 | |
| 315 | match params.get.unwrap_or_default.trim |
| 316 | "" => Some, |
| 317 | raw => match parse_str |
| 318 | Ok => Some, |
| 319 | Err => |
| 320 | errors.push; |
| 321 | None |
| 322 | |
| 323 | , |
| 324 | |
| 325 | |
| 326 | |
| 327 | /// Every described screen's routes. |
| 328 | |
| 329 | |
| 330 | let router = new; |
| 331 | let router = routes; |
| 332 | let router = routes; |
| 333 | let router = routes; |
| 334 | let router = routes; |
| 335 | let router = routes; |
| 336 | let router = routes; |
| 337 | let router = routes; |
| 338 | let router = routes; |
| 339 | let router = routes; |
| 340 | let router = routes; |
| 341 | let router = routes; |
| 342 | let router = routes; |
| 343 | let router = routes; |
| 344 | let router = routes; |
| 345 | let router = routes; |
| 346 | let router = routes; |
| 347 | routes |
| 348 | |
| 349 | |
| 350 | /// The document every described screen is served inside. |
| 351 | /// |
| 352 | /// Separate from [`protocol`] so it can be looked at without a Tauri handle. |
| 353 | /// What the document loads is a fact worth a test. |
| 354 | |
| 355 | |
| 356 | under |
| 357 | // The same order and the same layers index.html declared, because it is |
| 358 | // the same cascade: the value sheets before the sheet that reads their |
| 359 | // custom properties. |
| 360 | .layered |
| 361 | .styled |
| 362 | .styled |
| 363 | .styled |
| 364 | .styled |
| 365 | .styled |
| 366 | // Last, so the chosen theme's intent tokens override the stock ones |
| 367 | // §3 of styles.css declares. See [`theming`]. |
| 368 | .styled |
| 369 | // Not vendored, so asking for it would be one 404 per document. |
| 370 | .without_hyperscript |
| 371 | // The host half of `Action::by_host`, which is how a file gets picked. |
| 372 | // `with_head` because a `Shell` has no `script`, and this is the app's |
| 373 | // own rather than one the renderer ships. See `assets` and |
| 374 | // `frontend/js/host.js`. |
| 375 | .with_head |
| 376 | |
| 377 | |
| 378 | /// The document the compose window is served inside. |
| 379 | /// |
| 380 | /// [`document_shell`] without [`shell::chrome`], which is the whole difference |
| 381 | /// and is the point: a compose window has no mailbox nav and no running-timer |
| 382 | /// band. Eudora's did not either. What surrounds the screen here is the |
| 383 | /// [`Frame`](quasi_router::Frame) the mount supplies, which is what that member |
| 384 | /// is for. |
| 385 | /// |
| 386 | /// Everything else is shared rather than copied. The stylesheets, their order |
| 387 | /// and `host.js` are the same document furniture, and a second list of them |
| 388 | /// would drift the first time one was added. |
| 389 | |
| 390 | |
| 391 | document_shell |
| 392 | |
| 393 | |
| 394 | /// The custom protocol serving the screens inside the app, and the handle its |
| 395 | /// state arrives through. |
| 396 | /// |
| 397 | /// `quasi://localhost/projects`. The assets come from the same scheme, which is |
| 398 | /// the one thing that differs from the same description served over HTTP. |
| 399 | /// |
| 400 | /// # Why the state comes later |
| 401 | /// |
| 402 | /// [`AppState::new`] takes an `AppHandle` to resolve the data directory, and |
| 403 | /// the handle does not exist until tauri's builder runs — which is after every |
| 404 | /// scheme is registered. So this hands back a `Late<AppState>` for `setup` to |
| 405 | /// fill in, and a request in that gap is answered 503 rather than held. See |
| 406 | /// [`quasi_tauri::Late`]. |
| 407 | |
| 408 | |
| 409 | , |
| 410 | , |
| 411 | ) |
| 412 | let = pending |
| 413 | "quasi", |
| 414 | router, |
| 415 | new |
| 416 | under.with_shell |
| 417 | // The app's own furniture: where you can go, and the |
| 418 | // running-timer band. Applied here rather than in |
| 419 | // [`document_shell`] because the compose window is served the |
| 420 | // same document without it. See [`shell`]. |
| 421 | document_shell.with_chrome, |
| 422 | , |
| 423 | , |
| 424 | ; |
| 425 | // A stylesheet is not a description. This runs before the router and wins, |
| 426 | // so the two address spaces are kept apart: see [`assets`]. |
| 427 | |
| 428 | |
| 429 | |
| 430 | /// The compose window's scheme: the same screens, in a mount of its own. |
| 431 | /// |
| 432 | /// `compose://localhost/compose/{id}`. goingson `3fb2526a`, and the second half |
| 433 | /// of what [`Frame`](quasi_router::Frame) was added for. |
| 434 | /// |
| 435 | /// # Why a second scheme rather than a second window on the first |
| 436 | /// |
| 437 | /// A [`quasi_tauri::Protocol`] is a scheme, a router, a state and **one** |
| 438 | /// renderer, and a renderer is where the frame lives. Two mounts wanting two |
| 439 | /// frames is therefore two protocols. They share [`router`] — the same |
| 440 | /// description, which is the entire point: compose does not know which window |
| 441 | /// it is in, and a screen that did would be two code paths. |
| 442 | /// |
| 443 | /// # What differs, and it is two things |
| 444 | /// |
| 445 | /// [`compose_shell`] rather than the app's, so there is no mailbox nav and no |
| 446 | /// running-timer band. And a frame that reports: the compose window has a |
| 447 | /// status line. [`Frame::holds`](quasi_router::Frame::holds) decides where a |
| 448 | /// banner goes: it rests in the line here and floats in the main window, from |
| 449 | /// one description. |
| 450 | /// |
| 451 | /// The frame offers **no verbs**, and that is deliberate rather than |
| 452 | /// unfinished. Queue, Queue later, Discard and Take it back are on the screen, |
| 453 | /// so both mounts get them from one place; a frame carrying them too would draw |
| 454 | /// each verb twice in this window. `Frame::verbs` is for what a mount adds, and |
| 455 | /// this mount adds a place to speak rather than something to press. |
| 456 | /// |
| 457 | /// # Every screen, not only compose |
| 458 | /// |
| 459 | /// The router is the whole app's, so this scheme will serve any address. That |
| 460 | /// is a consequence of sharing one description and it is harmless: nothing |
| 461 | /// links into this scheme except [`crate::commands::window::open_compose_window`], |
| 462 | /// which builds the address itself. Narrowing it to one route would mean a |
| 463 | /// second router to keep in step with the first. |
| 464 | |
| 465 | |
| 466 | , |
| 467 | , |
| 468 | ) |
| 469 | let = pending |
| 470 | "compose", |
| 471 | router, |
| 472 | new |
| 473 | under |
| 474 | .with_shell |
| 475 | .with_frame, |
| 476 | , |
| 477 | ; |
| 478 | |
| 479 | |
| 480 |