Skip to main content

max / goingson

19.6 KB · 480 lines History Blame Raw
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 std::sync::Arc;
50
51 use goingson_core::{Task, TaskStatus, UpdateTask};
52 use makeover_layout::Tone;
53 use quasi_router::screen::Tag;
54 use quasi_router::{RouteError, Router};
55
56 use crate::state::{AppState, DESKTOP_USER_ID};
57
58 pub mod assets;
59 pub mod board;
60 pub mod compose;
61 pub mod contacts;
62 pub mod contexts;
63 pub mod data;
64 pub mod day_planning;
65 pub mod emails;
66 pub mod events;
67 pub mod monthly_review;
68 pub mod problems;
69 pub mod projects;
70 pub mod search;
71 pub mod settings;
72 pub mod shell;
73 pub mod task_list;
74 pub mod tasks;
75 pub mod theming;
76 pub mod time_tracking;
77 pub mod weekly_review;
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 #[derive(Clone, Copy)]
97 pub(crate) struct Availability {
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 impl Availability {
114 /// Read it off a task.
115 pub(crate) fn of(task: &Task) -> Self {
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(crate) fn reported(task: &crate::commands::TaskResponse) -> Self {
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(crate) fn marker(self) -> Option<Tag> {
145 if self.in_cycle {
146 return Some(
147 Tag::badge("Cycle")
148 .tone(Tone::Danger)
149 .hinted("On a dependency cycle, so it can never open. Remove an edge."),
150 );
151 }
152 if self.blocked {
153 return Some(
154 Tag::badge("Blocked")
155 .tone(Tone::Warning)
156 .hinted(steps_away(self.depth)),
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(crate) fn frees_marker(self) -> Option<Tag> {
170 (!self.blocked && !self.in_cycle && self.unblocks > 0).then(|| {
171 Tag::badge(format!("Unblocks {}", self.unblocks))
172 .tone(Tone::Info)
173 .hinted(format!(
174 "Finishing this frees {}.",
175 match self.unblocks {
176 1 => "1 other task".to_owned(),
177 many => format!("{many} other tasks"),
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 fn steps_away(depth: u32) -> String {
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!("{many} steps away, counting the longest chain of blockers."),
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(crate) fn move_to(
216 state: &AppState,
217 task: &Task,
218 to: &TaskStatus,
219 ) -> Result<&'static str, RouteError> {
220 match to {
221 TaskStatus::Started => {
222 state
223 .tasks
224 .start(task.id, DESKTOP_USER_ID)
225 .map_err(|error| RouteError::internal(error.to_string()))?;
226 Ok("Task started.")
227 }
228 TaskStatus::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 crate::commands::complete(state, task.id)
238 .map_err(|error| RouteError::internal(error.to_string()))?;
239 Ok("Task completed.")
240 }
241 TaskStatus::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: TaskStatus::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(|error| RouteError::internal(error.to_string()))?
269 .ok_or_else(|| RouteError::not_found("no such task"))?;
270 Ok("Task moved to Pending.")
271 }
272 TaskStatus::Deleted => Err(RouteError::not_found("not a status a control can set")),
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(crate) fn parse_choice<T: std::str::FromStr>(
287 params: &quasi_router::Params,
288 name: &'static str,
289 errors: &mut Vec<(&'static str, String)>,
290 ) -> Option<T> {
291 match params.get(name).unwrap_or_default().parse() {
292 Ok(value) => Some(value),
293 Err(_) => {
294 errors.push((name, "Not one of the options offered.".to_owned()));
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 #[allow(clippy::option_option)]
310 pub(crate) fn parse_optional_id<T: From<uuid::Uuid>>(
311 params: &quasi_router::Params,
312 name: &'static str,
313 errors: &mut Vec<(&'static str, String)>,
314 ) -> Option<Option<T>> {
315 match params.get(name).unwrap_or_default().trim() {
316 "" => Some(None),
317 raw => match uuid::Uuid::parse_str(raw) {
318 Ok(id) => Some(Some(T::from(id))),
319 Err(_) => {
320 errors.push((name, "Not one of the options offered.".to_owned()));
321 None
322 }
323 },
324 }
325 }
326
327 /// Every described screen's routes.
328 #[must_use]
329 pub fn router() -> Router<AppState> {
330 let router = Router::<AppState>::new();
331 let router = projects::routes(router);
332 let router = contacts::routes(router);
333 let router = tasks::routes(router);
334 let router = settings::routes(router);
335 let router = weekly_review::routes(router);
336 let router = monthly_review::routes(router);
337 let router = problems::routes(router);
338 let router = search::routes(router);
339 let router = day_planning::routes(router);
340 let router = contexts::routes(router);
341 let router = board::routes(router);
342 let router = task_list::routes(router);
343 let router = data::routes(router);
344 let router = time_tracking::routes(router);
345 let router = emails::routes(router);
346 let router = compose::routes(router);
347 events::routes(router)
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 #[must_use]
355 pub fn document_shell() -> quasi_webview::Shell {
356 quasi_webview::Shell::under("quasi://localhost/static")
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(["base", "components", "responsive"])
361 .styled("/static/typography.css")
362 .styled("/static/geometry.css")
363 .styled("/static/timing.css")
364 .styled("/static/layout.css")
365 .styled("/static/styles.css")
366 // Last, so the chosen theme's intent tokens override the stock ones
367 // §3 of styles.css declares. See [`theming`].
368 .styled(theming::ADDRESS)
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("<script src=\"/static/host.js\" defer></script>")
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 #[must_use]
390 pub fn compose_shell() -> quasi_webview::Shell {
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 #[must_use]
408 pub fn protocol() -> (
409 quasi_tauri::Protocol<AppState, quasi_webview::Webview>,
410 quasi_tauri::Late<AppState>,
411 ) {
412 let (protocol, late) = quasi_tauri::Protocol::pending(
413 "quasi",
414 router(),
415 Arc::new(
416 quasi_webview::Webview::under("quasi://localhost/static").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(shell::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 (protocol.passthrough(assets::get), late)
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 #[must_use]
465 pub fn compose_protocol() -> (
466 quasi_tauri::Protocol<AppState, quasi_webview::Webview>,
467 quasi_tauri::Late<AppState>,
468 ) {
469 let (protocol, late) = quasi_tauri::Protocol::pending(
470 "compose",
471 router(),
472 Arc::new(
473 quasi_webview::Webview::under("compose://localhost/static")
474 .with_shell(compose_shell())
475 .with_frame(quasi_router::Frame::new().reporting()),
476 ),
477 );
478 (protocol.passthrough(assets::get), late)
479 }
480