Skip to main content

max / goingson

Declare settings, email accounts, compose, problems, contexts and projects Twenty-five declarations across six files, goingson from declared 9 to 34. One production earned, by settings/email's advanced block: a form's field may be guarded. Every one of these screens read inside a shape and does not now. The settings pane dispatches over seven sections and two of the seven read fallibly, which is the value-producing dispatch with a fallible arm that section 10 left unruled: the dispatch is the handler's, and what the shape takes is the body it produced. projects listed the whole table twice per request, once for the grid and once for the band's two counts; it reads once. compose read the draft, the accounts and the attachments from inside its screen shape and now takes what the handler read. Four shapes are deleted rather than declared. settings::setting took a Field and handed back a Node, which is the container-in-container-out refusal a fifth time; each field says writes itself now. settings::indicator existed only because notifications had to extend a second list onto its own, and a panel says both halves in one body. problems::acts_for is four guarded act members in the row. contexts::form_pane was Node::Region(form_slot(..)) written out. SYNC_INTERVALS was a slice of tuples and is a struct. 920 lib tests pass, clippy clean over all targets.
Author: Max Johnson <me@maxj.phd> · 2026-09-04 17:19 UTC
Signed with PGP, not checked
Commit: 256f681ec8075057a9e0d8ebe402b2736f21f221
Parent: b9fed0f
7 files changed, +1467 insertions, -1257 deletions
M Cargo.lock +18 -8
@@ -2273,6 +2273,7 @@
2273 2273 "open",
2274 2274 "painhours",
2275 2275 "pter",
2276 + "quasi-declare",
2276 2277 "quasi-http",
2277 2278 "quasi-notifs",
2278 2279 "quasi-router",
@@ -4714,9 +4715,18 @@
4714 4715 source = "registry+https://github.com/rust-lang/crates.io-index"
4715 4716 checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
4716 4717
4718 + [[package]]
4719 + name = "quasi-declare"
4720 + version = "0.1.0"
4721 + dependencies = [
4722 + "proc-macro2",
4723 + "quote",
4724 + "syn 2.0.119",
4725 + ]
4726 +
4717 4727 [[package]]
4718 4728 name = "quasi-http"
4719 - version = "0.101.0"
4729 + version = "0.101.1"
4720 4730 dependencies = [
4721 4731 "form_urlencoded",
4722 4732 "http",
@@ -4725,7 +4735,7 @@
4725 4735
4726 4736 [[package]]
4727 4737 name = "quasi-notifs"
4728 - version = "0.101.0"
4738 + version = "0.101.2"
4729 4739 dependencies = [
4730 4740 "quasi-router",
4731 4741 "synckit-config",
@@ -4735,14 +4745,14 @@
4735 4745
4736 4746 [[package]]
4737 4747 name = "quasi-router"
4738 - version = "0.101.0"
4748 + version = "0.101.11"
4739 4749 dependencies = [
4740 4750 "makeover-layout",
4741 4751 ]
4742 4752
4743 4753 [[package]]
4744 4754 name = "quasi-tauri"
4745 - version = "0.101.0"
4755 + version = "0.101.1"
4746 4756 dependencies = [
4747 4757 "http",
4748 4758 "quasi-http",
@@ -4768,7 +4778,7 @@
4768 4778
4769 4779 [[package]]
4770 4780 name = "quasi-webview"
4771 - version = "0.101.0"
4781 + version = "0.101.1"
4772 4782 dependencies = [
4773 4783 "docengine",
4774 4784 "makeover-layout",
@@ -8478,15 +8488,15 @@
8478 8488
8479 8489 [[patch.unused]]
8480 8490 name = "quasi-axum"
8481 - version = "0.101.0"
8491 + version = "0.101.1"
8482 8492
8483 8493 [[patch.unused]]
8484 8494 name = "quasi-basics"
8485 - version = "0.101.0"
8495 + version = "0.101.1"
8486 8496
8487 8497 [[patch.unused]]
8488 8498 name = "quasi-immediate"
8489 - version = "0.101.0"
8499 + version = "0.101.1"
8490 8500
8491 8501 [[patch.unused]]
8492 8502 name = "quasi-store"
@@ -84,9 +84,10 @@
84 84
85 85 use chrono::{DateTime, Utc};
86 86 use goingson_core::EmailId;
87 - use quasi_router::layout::Tone;
88 - use quasi_router::screen::{Act, Choice, Field, Row, Tag};
89 - use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
87 + use quasi_declare::declare;
88 + use quasi_router::layout::{FieldKind, Tone};
89 + use quasi_router::screen::{Choice, Field, Tag};
90 + use quasi_router::{Action, Node, Response, RouteError, Router};
90 91
91 92 use crate::state::{AppState, DESKTOP_USER_ID};
92 93
@@ -194,8 +195,29 @@
194 195 .map_err(|error| RouteError::internal(error.to_string()))
195 196 }
196 197
197 - /// The screen.
198 - fn screen(state: &AppState, id: EmailId) -> Result<Screen, RouteError> {
198 + /// Everything the screen draws, read once.
199 + ///
200 + /// The reads are the handler's: the draft, the accounts and the attachments are
201 + /// three queries, and two of the three used to happen inside a shape. A
202 + /// description says what is on the screen.
203 + struct Loaded {
204 + id: EmailId,
205 + subject: String,
206 + to: String,
207 + cc: String,
208 + bcc: String,
209 + body: String,
210 + /// Which account it is from, if one has been chosen.
211 + from: Option<String>,
212 + /// The accounts there are to choose between.
213 + accounts: Vec<Choice>,
214 + /// In the Out box, so read-only until it is taken back.
215 + queued: bool,
216 + files: Vec<goingson_core::Attachment>,
217 + }
218 +
219 + /// Read it.
220 + fn read(state: &AppState, id: EmailId) -> Result<Loaded, RouteError> {
199 221 let draft = state
200 222 .emails
201 223 .get_by_id(id, DESKTOP_USER_ID)
@@ -206,163 +228,178 @@
206 228 let accounts = state
207 229 .email_accounts
208 230 .list_by_user(DESKTOP_USER_ID)
209 - .map_err(|error| RouteError::internal(error.to_string()))?;
231 + .map_err(|error| RouteError::internal(error.to_string()))?
232 + .into_iter()
233 + .map(|account| Choice::new(account.id.to_string(), account.email_address))
234 + .collect();
210 235
211 - let field_post = |name: &str| Action::post(format!("/compose/{id}/field")).with("field", name);
212 - let text = |name: &'static str, label: &str, value: String| {
213 - Node::field(
214 - Field {
215 - value: Some(value),
216 - ..Field::new(makeover_layout::FieldKind::Text, name, label)
217 - }
218 - .writes(field_post(name)),
219 - )
220 - };
221 -
222 - let mut header =
223 - Slot::new(BODY, RegionKind::Pane).with(Node::page(if draft.subject.is_empty() {
224 - "New message".to_owned()
225 - } else {
226 - draft.subject.clone()
227 - }));
228 -
229 - // The header grid, in Eudora's order. From first because it is the one
230 - // choice rather than a thing typed, and because a message with no account
231 - // cannot leave the outbox.
232 - header = header.with(Node::field(
233 - Field {
234 - options: accounts
235 - .iter()
236 - .map(|account| Choice::new(account.id.to_string(), account.email_address.clone()))
237 - .collect(),
238 - value: draft.draft_account_id.map(|id| id.to_string()),
239 - ..Field::new(makeover_layout::FieldKind::Select, FROM, "From")
240 - }
241 - .writes(field_post(FROM)),
242 - ));
243 - header = header
244 - .with(text(TO, "To", draft.to.clone()))
245 - // Always drawn. Eudora never hid them, which is why this screen needs
246 - // no word for progressive disclosure.
247 - .with(text(CC, "Cc", draft.cc_address.clone().unwrap_or_default()))
248 - .with(text(
249 - BCC,
250 - "Bcc",
251 - draft.bcc_address.clone().unwrap_or_default(),
252 - ))
253 - .with(text(SUBJECT, "Subject", draft.subject.clone()));
254 -
255 - // The verbs. Not a form's submit: the draft is already saved, so these act
256 - // on a thing that exists. See the module header.
257 - let queued = draft.is_queued();
258 -
259 - // Attached is the last header row, then the body under it, which is the
260 - // order Eudora drew and the reason the attachments bar has nowhere else to
261 - // go.
262 - header = header.extend(attached(state, id, queued)?);
263 - header = header.with(Node::field(
264 - Field {
265 - value: Some(draft.body.clone()),
266 - ..Field::new(makeover_layout::FieldKind::Textarea, MESSAGE, "Message")
267 - }
268 - .writes(field_post(MESSAGE)),
269 - ));
270 - if queued {
271 - header = header
272 - .with(Node::text(
273 - "This message is in the Out box. Take it back to edit it.",
274 - ))
275 - .with(Node::Act(Act::new(
276 - "Take it back",
277 - Action::post(format!("/compose/{id}/unqueue")),
278 - )));
279 - } else {
280 - header = header
281 - .with(Node::Act(
282 - Act::new("Queue", Action::post(format!("/compose/{id}/queue"))).tone(Tone::Success),
283 - ))
284 - .with(Node::Act(
285 - Act::new("Queue later", Action::post(format!("/compose/{id}/queue"))).asking(
286 - Field::new(
287 - makeover_layout::FieldKind::DateTime,
288 - SEND_AFTER,
289 - "Send after",
290 - ),
291 - ),
292 - ));
293 - }
294 -
295 - Ok(Screen::list_detail("Compose", false)
296 - .at_place(super::shell::EMAILS)
297 - .with(header)
298 - .with(
299 - Slot::new("compose-aside", RegionKind::Pane)
300 - // The same address this screen is already at, put up in a mount
301 - // of its own. `Action::elsewhere` is the whole of it: this
302 - // screen does not know which window it is in, and that is what
303 - // makes one description serve both. See the module header.
304 - .with(Node::Act(Act::new(
305 - "Open in a window",
306 - Action::get(format!("/compose/{id}")).elsewhere(),
307 - )))
308 - .with(Node::Act(
309 - Act::new("Discard", Action::post(format!("/compose/{id}/discard")))
310 - .tone(Tone::Danger)
311 - .confirm("Throw this message away?"),
312 - )),
313 - ))
314 - }
315 -
316 - /// The `Attached:` rows, and the way to add one.
317 - ///
318 - /// Drawn even when empty, because it is a header row rather than a bar that
319 - /// appears: the point of Eudora's shape is that the message says what it
320 - /// carries in the same place every time.
321 - ///
322 - /// Attaching is [`Action::by_host`], the same as the imports and the project
323 - /// dashboard: picking a file is not describable, and `frontend/js/host.js`
324 - /// opens the dialog and posts the path back. What lands is a row against this
325 - /// draft with the bytes in the content-addressed blob store, which is what
326 - /// makes an attachment survive until the outbox drains.
327 - fn attached(state: &AppState, id: EmailId, queued: bool) -> Result<Vec<Node>, RouteError> {
328 236 let files = state
329 237 .attachments
330 238 .list_for_email(id, DESKTOP_USER_ID)
331 239 .map_err(|error| RouteError::internal(error.to_string()))?;
332 240
333 - let mut nodes = Vec::new();
334 - if files.is_empty() {
335 - nodes.push(Node::list([Row::new("Attached").meta("Nothing")]));
241 + Ok(Loaded {
242 + id,
243 + queued: draft.is_queued(),
244 + subject: draft.subject,
245 + to: draft.to,
246 + cc: draft.cc_address.unwrap_or_default(),
247 + bcc: draft.bcc_address.unwrap_or_default(),
248 + body: draft.body,
249 + from: draft.draft_account_id.map(|id| id.to_string()),
250 + accounts,
251 + files,
252 + })
253 + }
254 +
255 + /// The route one field writes to, carrying which field it is.
256 + fn field_route(id: EmailId, name: &str) -> Action {
257 + Action::post(format!("/compose/{id}/field")).with("field", name)
258 + }
259 +
260 + /// What the page is called before it has a subject.
261 + fn heading(loaded: &Loaded) -> &str {
262 + if loaded.subject.is_empty() {
263 + "New message"
336 264 } else {
337 - nodes.push(Node::list(files.iter().map(|file| {
338 - let mut row = Row::new("Attached")
339 - .secondary(file.filename.clone())
340 - .meta(size(file.file_size));
341 - // A queued message is read-only until it is taken back, so its
342 - // files are listed and not removable: the drainer may be reading
343 - // them.
344 - if !queued {
345 - row = row.act(
346 - Act::new(
347 - "Remove",
348 - Action::post(format!("/compose/{id}/detach/{}", file.id)),
349 - )
350 - .tone(Tone::Danger),
351 - );
265 + &loaded.subject
266 + }
267 + }
268 +
269 + /// Whether an account has been chosen. R9: read whether or not it is placed.
270 + fn has_from(loaded: &Loaded) -> bool {
271 + loaded.from.is_some()
272 + }
273 +
274 + /// That account's id, or nothing.
275 + fn from_value(loaded: &Loaded) -> &str {
276 + loaded.from.as_deref().unwrap_or_default()
277 + }
278 +
279 + /// Whether the message carries nothing.
280 + fn no_files(loaded: &Loaded) -> bool {
281 + loaded.files.is_empty()
282 + }
283 +
284 + declare! {
285 + /// The `Attached:` rows, and the way to add one.
286 + ///
287 + /// Drawn even when empty, because it is a header row rather than a bar that
288 + /// appears: the point of Eudora's shape is that the message says what it
289 + /// carries in the same place every time.
290 + ///
291 + /// Attaching is [`Action::by_host`], the same as the imports and the
292 + /// project dashboard: picking a file is not describable, and
293 + /// `frontend/js/host.js` opens the dialog and posts the path back. What
294 + /// lands is a row against this draft with the bytes in the
295 + /// content-addressed blob store, which is what makes an attachment survive
296 + /// until the outbox drains.
297 + ///
298 + /// A queued message's files are listed and not removable: the drainer may
299 + /// be reading them.
300 + shape attached(loaded: &Loaded) -> Vec<Node>;
301 +
302 + list {
303 + row "Attached" when no_files(loaded) {
304 + meta "Nothing";
305 + }
306 +
307 + for file in loaded.files.iter() {
308 + row "Attached" {
309 + secondary file.filename.clone();
310 + meta size(file.file_size);
311 + act "Remove" to post "/compose/{loaded.id}/detach/{file.id}"
312 + unless loaded.queued {
313 + tone Danger;
314 + }
352 315 }
353 - row
354 - })));
316 + }
355 317 }
356 318
357 - if !queued {
358 - nodes.push(Node::Act(Act::new(
359 - "Attach a file",
360 - Action::post(format!("/compose/{id}/attach"))
361 - .by_host()
362 - .awaiting(),
363 - )));
319 + act "Attach a file" to post "/compose/{loaded.id}/attach" by_host awaiting
320 + unless loaded.queued;
321 + }
322 +
323 + declare! {
324 + /// The screen.
325 + ///
326 + /// The header grid is in Eudora's order. From first because it is the one
327 + /// choice rather than a thing typed, and because a message with no account
328 + /// cannot leave the outbox. Cc and Bcc are always drawn; Eudora never hid
329 + /// them, which is why this screen needs no word for progressive disclosure.
330 + ///
331 + /// Attached is the last header row and the body goes under it, which is the
332 + /// order Eudora drew and the reason the attachments bar has nowhere else to
333 + /// go.
334 + ///
335 + /// The verbs are not a form's submit: every field writes as it settles, so
336 + /// these act on a draft that is already saved. See the module header.
337 + shape screen(loaded: &Loaded) -> Screen;
338 +
339 + screen list_detail "Compose" false {
340 + at_place super::shell::EMAILS;
341 +
342 + region BODY as Pane {
343 + page heading(loaded);
344 +
345 + field Select FROM "From" {
346 + options loaded.accounts.clone();
347 + value from_value(loaded) when has_from(loaded);
348 + writes field_route(loaded.id, FROM);
349 + }
350 +
351 + field Text TO "To" {
352 + value loaded.to.clone();
353 + writes field_route(loaded.id, TO);
354 + }
355 +
356 + field Text CC "Cc" {
357 + value loaded.cc.clone();
358 + writes field_route(loaded.id, CC);
359 + }
360 +
361 + field Text BCC "Bcc" {
362 + value loaded.bcc.clone();
363 + writes field_route(loaded.id, BCC);
364 + }
365 +
366 + field Text SUBJECT "Subject" {
367 + value loaded.subject.clone();
368 + writes field_route(loaded.id, SUBJECT);
369 + }
370 +
371 + extend attached(loaded);
372 +
373 + field Textarea MESSAGE "Message" {
374 + value loaded.body.clone();
375 + writes field_route(loaded.id, MESSAGE);
376 + }
377 +
378 + text "This message is in the Out box. Take it back to edit it."
379 + when loaded.queued;
380 + act "Take it back" to post "/compose/{loaded.id}/unqueue" when loaded.queued;
381 +
382 + act "Queue" to post "/compose/{loaded.id}/queue" unless loaded.queued {
383 + tone Success;
384 + }
385 +
386 + act "Queue later" to post "/compose/{loaded.id}/queue" unless loaded.queued {
387 + asking Field::new(FieldKind::DateTime, SEND_AFTER, "Send after");
388 + }
389 + }
390 +
391 + // The same address this screen is already at, put up in a mount of its
392 + // own. `Action::elsewhere` is the whole of it: this screen does not know
393 + // which window it is in, and that is what makes one description serve
394 + // both. See the module header.
395 + region "compose-aside" as Pane {
396 + act "Open in a window" to get "/compose/{loaded.id}" elsewhere;
397 + act "Discard" to post "/compose/{loaded.id}/discard" {
398 + tone Danger;
399 + confirm "Throw this message away?";
400 + }
401 + }
364 402 }
365 - Ok(nodes)
366 403 }
367 404
368 405 /// A file size in words. `data::size` says the same thing and is private to it.
@@ -395,7 +432,7 @@
395 432 }
396 433
397 434 match crate::commands::attachment::attach_path(state, None, None, Some(id), &picked) {
398 - Ok(file) => Ok(Response::screen(screen(state, id)?)
435 + Ok(file) => Ok(Response::screen(screen(&read(state, id)?))
399 436 .toast(Tone::Success, format!("Attached {}.", file.filename))),
400 437 // Ours is a fault; everything else is the person's to fix by picking a
401 438 // different file, so it is said and the screen stays put. Same split
@@ -424,12 +461,12 @@
424 461 .map_err(|error| RouteError::internal(error.to_string()))?;
425 462 // The blob stays until `blob_gc` sees nothing references it, which is what
426 463 // makes removing a file a row delete rather than a disk operation.
427 - Ok(Response::screen(screen(state, id)?).toast(Tone::Success, "Taken off."))
464 + Ok(Response::screen(screen(&read(state, id)?)).toast(Tone::Success, "Taken off."))
428 465 }
429 466
430 467 /// The screen, as an answer.
431 468 fn show(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
432 - Ok(screen(state, asked_for(&request)?)?.into())
469 + Ok(screen(&read(state, asked_for(&request)?)?).into())
433 470 }
434 471
435 472 /// The id in the address.
@@ -569,7 +606,7 @@
569 606 .unqueue_draft(id, DESKTOP_USER_ID)
570 607 .map_err(|error| RouteError::internal(error.to_string()))?
571 608 .ok_or_else(|| RouteError::not_found("not in the Out box"))?;
572 - Ok(Response::screen(screen(state, id)?).toast(
609 + Ok(Response::screen(screen(&read(state, id)?)).toast(
573 610 Tone::Success,
574 611 "Taken back. It will not go until you queue it.",
575 612 ))
@@ -585,55 +622,76 @@
585 622 Ok(Response::goto(Action::get("/emails")).toast(Tone::Success, "Thrown away."))
586 623 }
587 624
588 - /// The Out box: what is waiting, when it goes, and why one is stuck.
625 + /// What a waiting message is called.
626 + fn waiting_subject(email: &goingson_core::Email) -> &str {
627 + if email.subject.is_empty() {
628 + "(no subject)"
629 + } else {
630 + &email.subject
631 + }
632 + }
633 +
634 + /// Whether the drainer has given up on it for now.
635 + fn is_stuck(email: &goingson_core::Email) -> bool {
636 + email.send_error.is_some()
637 + }
638 +
639 + /// Why it is stuck, or nothing. R9: read whether or not it is placed.
640 + fn stuck_reason(email: &goingson_core::Email) -> &str {
641 + email.send_error.as_deref().unwrap_or_default()
642 + }
643 +
644 + /// When it goes.
645 + ///
646 + /// A stuck message's timing is not the interesting fact about it, which is why
647 + /// this and [`stuck_reason`] are the two halves of one `meta`.
648 + fn when_going(email: &goingson_core::Email) -> String {
649 + match email.send_after {
650 + Some(at) => format!("after {}", at.format("%b %-d, %H:%M")),
651 + None => "next pass".to_owned(),
652 + }
653 + }
654 +
655 + declare! {
656 + /// The Out box: what is waiting, when it goes, and why one is stuck.
657 + ///
658 + /// One part per role: `meta` sets rather than appends, so the reason and
659 + /// the timing are one fact said two ways rather than two facts, and the
660 + /// guards are what pick between them.
661 + shape outbox_screen(waiting: &[goingson_core::Email]) -> Screen;
662 +
663 + screen list_detail "Out box" false {
664 + at_place super::shell::OUTBOX;
665 +
666 + region "outbox" as Pane {
667 + page "Out box";
668 +
669 + empty "Nothing waiting to go." when waiting.is_empty();
670 +
671 + list {
672 + for email in waiting.iter() {
673 + row waiting_subject(email) {
674 + secondary email.to.clone();
675 + token Tag::badge("Stuck after {email.send_attempts}").tone(Tone::Danger)
676 + when is_stuck(email);
677 + meta stuck_reason(email) when is_stuck(email);
678 + meta when_going(email) unless is_stuck(email);
679 + act "Take it back" to post "/compose/{email.id}/unqueue";
680 + activate to get "/compose/{email.id}";
681 + }
682 + }
683 + } unless waiting.is_empty();
684 + }
685 + }
686 + }
687 +
688 + /// The Out box, as an answer.
589 689 fn outbox(state: &AppState, _request: quasi_router::Request) -> Result<Response, RouteError> {
590 690 let waiting = state
591 691 .emails
592 692 .list_outbox(DESKTOP_USER_ID)
593 693 .map_err(|error| RouteError::internal(error.to_string()))?;
594 -
595 - let mut pane = Slot::new("outbox", RegionKind::Pane).with(Node::page("Out box"));
596 -
597 - if waiting.is_empty() {
598 - pane = pane.with(Node::empty("Nothing waiting to go."));
599 - } else {
600 - pane = pane.with(Node::list(waiting.iter().map(|email| {
601 - // One part per role: `Row::meta` sets rather than appends, so three
602 - // calls to it would be one fact and two discarded.
603 - let mut row = Row::new(if email.subject.is_empty() {
604 - "(no subject)"
605 - } else {
606 - &email.subject
607 - })
608 - .secondary(email.to.clone());
609 -
610 - // Why it is stuck if it is, and when it goes if it is not. A stuck
611 - // message's timing is not the interesting fact about it.
612 - row = match &email.send_error {
613 - Some(error) => row
614 - .token(
615 - Tag::badge(format!("Stuck after {}", email.send_attempts))
616 - .tone(Tone::Danger),
617 - )
618 - .meta(error.clone()),
619 - None => row.meta(match email.send_after {
620 - Some(at) => format!("after {}", at.format("%b %-d, %H:%M")),
621 - None => "next pass".to_owned(),
622 - }),
Lines truncated
@@ -61,8 +61,10 @@
61 61 use chrono::NaiveDate;
62 62 use goingson_core::id_types::ContextId;
63 63 use goingson_core::models::{Context, ContextKind};
64 - use quasi_router::screen::{Act, Choice, Field, Row, Tag};
65 - use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
64 + use quasi_declare::declare;
65 + use quasi_router::layout::Tone;
66 + use quasi_router::screen::{Choice, Tag};
67 + use quasi_router::{Node, Response, RouteError, Router, Slot};
66 68
67 69 use crate::state::{AppState, DESKTOP_USER_ID};
68 70
@@ -129,215 +131,323 @@
129 131 ))
130 132 }
131 133
132 - /// How a context reads in the list.
133 - ///
134 - /// The span is the secondary line rather than a token, because it is what the
135 - /// record *is*: a label without its dates is not a context, and a token would
136 - /// rank it beside the kind.
137 - fn row_for(context: &Context, current: bool) -> Row {
134 + /// The span, in words. The list says it as prose because it is what the record
135 + /// *is*.
136 + fn span_words(context: &Context) -> String {
138 137 let days = context.days();
139 - let mut row = Row::new(&context.label)
140 - .token(Tag::badge(kind_label(context.kind)))
141 - .secondary(format!(
142 - "{} to {} ({} day{})",
143 - context.starts_on.format("%-d %b %Y"),
144 - context.ends_on.format("%-d %b %Y"),
145 - days,
146 - if days == 1 { "" } else { "s" }
147 - ));
148 -
149 - if context.migrated_from_event_id.is_some() {
150 - row = row.token(Tag::badge("From an event"));
151 - }
152 -
153 - row.current = current;
154 - row.activate = Some(Action::get(format!("/contexts/{}", context.id)));
155 - row
138 + format!(
139 + "{} to {} ({} day{})",
140 + context.starts_on.format("%-d %b %Y"),
141 + context.ends_on.format("%-d %b %Y"),
142 + days,
143 + if days == 1 { "" } else { "s" }
144 + )
156 145 }
157 146
158 - /// The list of contexts.
159 - fn list_node(state: &AppState, current: Option<ContextId>) -> Result<Node, RouteError> {
160 - let contexts = all(state)?;
161 - if contexts.is_empty() {
162 - return Ok(Node::empty("No contexts yet.").offering(Act::new(
163 - "Record your first context",
164 - Action::get("/contexts/new"),
165 - )));
166 - }
167 - Ok(Node::list(contexts.iter().map(|context| {
168 - row_for(context, current == Some(context.id))
169 - })))
147 + /// Whether this row is the one the pane is showing.
148 + fn is_current(context: &Context, current: Option<ContextId>) -> bool {
149 + current == Some(context.id)
170 150 }
171 151
172 - /// The form's fields, for a create or an edit.
173 - ///
174 - /// `existing` fills them for an edit; `submitted` refills them after a refusal,
175 - /// and wins, because what the user just typed is nearer to what they meant than
176 - /// what is stored. [`Field::refilled`] is the same repair `1c4a66a4` closed on
177 - /// quasi-router.
178 - fn form_fields(
179 - existing: Option<&Context>,
180 - errors: &[(&str, String)],
181 - submitted: Option<&quasi_router::Params>,
182 - ) -> Vec<Field> {
183 - let error_for = |name: &str| {
184 - errors
185 - .iter()
186 - .find(|(field, _)| *field == name)
187 - .map(|(_, message)| message.clone())
188 - };
189 - let apply = |field: Field, name: &str| match error_for(name) {
190 - Some(message) => field.error(message),
191 - None => field,
192 - };
152 + declare! {
153 + /// How a context reads in the list.
154 + ///
155 + /// The span is the secondary line rather than a token, because it is what
156 + /// the record *is*: a label without its dates is not a context, and a token
157 + /// would rank it beside the kind.
158 + shape row_for(context: &Context, current: bool) -> Row;
193 159
194 - let mut label = Field::new(makeover_layout::FieldKind::Text, "label", "Label").required();
195 - label.placeholder = Some("Two weeks in Lisbon".to_owned());
196 - if let Some(context) = existing {
197 - label = label.value(context.label.clone());
198 - }
199 -
200 - let mut kind = Field::select(
201 - "kind",
202 - "Kind",
203 - KINDS
204 - .iter()
205 - .map(|kind| Choice::new(kind.as_str(), kind_label(*kind)))
206 - .collect(),
207 - );
208 - if let Some(context) = existing {
209 - kind = kind.value(context.kind.as_str());
210 - }
211 -
212 - // Two fields rather than one interval. See the module header.
213 - let mut starts = Field::new(makeover_layout::FieldKind::Date, "starts_on", "First day")
214 - .required()
215 - .hint("The first day inside it.");
216 - let mut ends = Field::new(makeover_layout::FieldKind::Date, "ends_on", "Last day")
217 - .required()
218 - .hint("Inclusive: off until the 17th means the 17th is off.");
219 - if let Some(context) = existing {
220 - starts = starts.value(context.starts_on.to_string());
221 - ends = ends.value(context.ends_on.to_string());
222 - }
223 -
224 - let fields = vec![
225 - apply(label, "label"),
226 - apply(kind, "kind"),
227 - apply(starts, "starts_on"),
228 - apply(ends, "ends_on"),
229 - ];
230 -
231 - match submitted {
232 - Some(params) => fields
233 - .into_iter()
234 - .map(|field| field.refilled(params))
235 - .collect(),
236 - None => fields,
160 + row &context.label {
161 + token Tag::badge(kind_label(context.kind));
162 + secondary span_words(context);
163 + token Tag::badge("From an event") when context.migrated_from_event_id.is_some();
164 + current current;
165 + activate to get "/contexts/{context.id}";
237 166 }
238 167 }
239 168
240 - /// The pane as a region, which is what a fragment answers with.
241 - fn form_pane(
242 - existing: Option<&Context>,
243 - errors: &[(&str, String)],
244 - submitted: Option<&quasi_router::Params>,
245 - ) -> Node {
246 - Node::Region(form_slot(existing, errors, submitted))
247 - }
169 + declare! {
170 + /// The list of contexts.
171 + shape list_node(contexts: &[Context], current: Option<ContextId>) -> Node;
248 172
249 - /// The pane: a form, and for an edit the things only an edit can offer.
250 - fn form_slot(
251 - existing: Option<&Context>,
252 - errors: &[(&str, String)],
253 - submitted: Option<&quasi_router::Params>,
254 - ) -> Slot {
255 - let mut pane = Slot::new("contexts-detail", RegionKind::Pane);
256 - pane = match existing {
257 - None => pane.with(Node::section("New context")),
258 - Some(context) => pane.with(Node::section(format!("Editing {}", context.label))),
259 - };
260 -
261 - // Provenance, and the reversal it buys, said before the controls rather than
262 - // after: deleting a migrated context is the one delete on this screen that
263 - // does something to a record the user did not author here.
264 - if existing.is_some_and(|context| context.migrated_from_event_id.is_some()) {
265 - pane = pane.with(Node::banner(
266 - makeover_layout::Tone::Info,
267 - "Converted from an event. Deleting this puts that event back on the timeline, \
268 - with its times intact.",
269 - ));
270 - }
271 -
272 - pane = pane.with(Node::Form {
273 - action: match existing {
274 - None => Action::post("/contexts"),
275 - Some(context) => Action::post(format!("/contexts/{}", context.id)),
276 - },
277 - submit: match existing {
278 - None => "Record context".to_owned(),
279 - Some(_) => "Save".to_owned(),
280 - },
281 - fields: form_fields(existing, errors, submitted),
282 - });
283 -
284 - if let Some(context) = existing {
285 - pane = pane.with(Node::Act(
286 - Act::new(
287 - "Delete",
288 - Action::post(format!("/contexts/{}/delete", context.id)),
289 - )
290 - .tone(makeover_layout::Tone::Danger)
291 - .confirm(match context.migrated_from_event_id {
292 - Some(_) => "Delete this context and put its event back?",
293 - None => "Delete this context?",
294 - }),
295 - ));
296 - }
297 -
298 - pane
299 - }
300 -
301 - /// The whole screen.
302 - fn screen(
303 - state: &AppState,
304 - current: Option<&Context>,
305 - errors: &[(&str, String)],
306 - submitted: Option<&quasi_router::Params>,
307 - ) -> Result<Screen, RouteError> {
308 - let band = Slot::new("contexts-band", RegionKind::Band)
309 - .with(Node::page("Contexts"))
310 - .with(Node::act("New context", Action::get("/contexts/new")));
311 -
312 - let pane = match current {
313 - Some(context) => form_slot(Some(context), errors, submitted),
314 - None => {
315 - Slot::new("contexts-detail", RegionKind::Pane).with(Node::empty("Nothing selected"))
173 + given contexts.is_empty() {
174 + true -> empty "No contexts yet." {
175 + offering "Record your first context" to get "/contexts/new";
316 176 }
317 - };
177 + otherwise -> list {
178 + for context in contexts.iter() {
179 + include row_for(context, is_current(context, current));
180 + }
181 + }
182 + }
183 + }
318 184
319 - Ok(Screen::list_detail("Contexts", false)
320 - // The Day place rather than a twelfth one: a context frames a day, and
321 - // the day view is where its banner is read.
322 - .at_place(super::shell::DAY)
323 - .with(band)
324 - .with(
325 - Slot::new("contexts-list", RegionKind::Pane)
326 - .with(list_node(state, current.map(|context| context.id))?),
327 - )
328 - .with(pane))
185 + /// What the pane is showing: the context being edited if there is one, what was
186 + /// refused, and what was typed.
187 + ///
188 + /// `existing` fills the fields for an edit; `submitted` refills them after a
189 + /// refusal, and wins, because what the user just typed is nearer to what they
190 + /// meant than what is stored. [`Field::refilled`] is the same repair `1c4a66a4`
191 + /// closed on quasi-router.
192 + struct Editing<'a> {
193 + existing: Option<&'a Context>,
194 + errors: &'a [(&'a str, String)],
195 + submitted: Option<&'a quasi_router::Params>,
196 + }
197 +
198 + impl<'a> Editing<'a> {
199 + /// A fresh form.
200 + const fn fresh() -> Self {
201 + Self {
202 + existing: None,
203 + errors: &[],
204 + submitted: None,
205 + }
206 + }
207 +
208 + /// The form over an existing context.
209 + const fn of(context: &'a Context) -> Self {
210 + Self {
211 + existing: Some(context),
212 + errors: &[],
213 + submitted: None,
214 + }
215 + }
216 + }
217 +
218 + /// Whether the pane is editing rather than creating.
219 + fn is_edit(editing: &Editing) -> bool {
220 + editing.existing.is_some()
221 + }
222 +
223 + /// What the pane is called.
224 + fn pane_heading(editing: &Editing) -> String {
225 + match editing.existing {
226 + None => "New context".to_owned(),
227 + Some(context) => format!("Editing {}", context.label),
228 + }
229 + }
230 +
231 + /// Whether this context was converted from an event.
232 + fn from_event(editing: &Editing) -> bool {
233 + editing
234 + .existing
235 + .is_some_and(|context| context.migrated_from_event_id.is_some())
236 + }
237 +
238 + /// Where the form writes.
239 + fn form_path(editing: &Editing) -> String {
240 + match editing.existing {
241 + None => "/contexts".to_owned(),
242 + Some(context) => format!("/contexts/{}", context.id),
243 + }
244 + }
245 +
246 + /// What the form's button reads.
247 + fn submit_label(editing: &Editing) -> &'static str {
248 + match editing.existing {
249 + None => "Record context",
250 + Some(_) => "Save",
251 + }
252 + }
253 +
254 + /// The id being edited, for the addresses that name it.
255 + ///
256 + /// R9: every hole in a guarded member is read whether or not the member is
257 + /// placed, so this answers with nothing on a create rather than refusing.
258 + fn existing_id(editing: &Editing) -> String {
259 + editing
260 + .existing
261 + .map(|context| context.id.to_string())
262 + .unwrap_or_default()
263 + }
264 +
265 + /// What Delete asks before it happens.
266 + ///
267 + /// Provenance is the reason for the two questions: deleting a migrated context
268 + /// is the one delete on this screen that does something to a record the user
269 + /// did not author here.
270 + fn delete_question(editing: &Editing) -> &'static str {
271 + if from_event(editing) {
272 + "Delete this context and put its event back?"
273 + } else {
274 + "Delete this context?"
275 + }
276 + }
277 +
278 + /// The label a stored context holds.
279 + fn stored_label(editing: &Editing) -> String {
280 + editing
281 + .existing
282 + .map(|context| context.label.clone())
283 + .unwrap_or_default()
284 + }
285 +
286 + /// The kind a stored context holds, as it is stored.
287 + fn stored_kind<'a>(editing: &Editing<'a>) -> &'a str {
288 + editing.existing.map_or("", |context| context.kind.as_str())
289 + }
290 +
291 + /// The first day a stored context holds.
292 + fn stored_start(editing: &Editing) -> String {
293 + editing
294 + .existing
295 + .map(|context| context.starts_on.to_string())
296 + .unwrap_or_default()
297 + }
298 +
299 + /// The last day a stored context holds.
300 + fn stored_end(editing: &Editing) -> String {
301 + editing
302 + .existing
303 + .map(|context| context.ends_on.to_string())
304 + .unwrap_or_default()
305 + }
306 +
307 + /// Whether a named field was refused.
308 + fn has_error(editing: &Editing, name: &str) -> bool {
309 + editing.errors.iter().any(|(field, _)| *field == name)
310 + }
311 +
312 + /// Why it was refused, or nothing.
313 + fn error_for(editing: &Editing, name: &str) -> String {
314 + editing
315 + .errors
316 + .iter()
317 + .find(|(field, _)| *field == name)
318 + .map(|(_, message)| message.clone())
319 + .unwrap_or_default()
320 + }
321 +
322 + /// Nothing was typed, which is what a form that is not answering a refusal
323 + /// refills from.
324 + static NOTHING_TYPED: quasi_router::Params = quasi_router::Params::new();
325 +
326 + /// What was typed, or nothing.
327 + ///
328 + /// Empty rather than absent, because [`Field::refilled`] leaves a name it finds
329 + /// nothing under alone: refilling from nothing is the same field back, so the
330 + /// setting needs no guard.
331 + fn typed<'a>(editing: &Editing<'a>) -> &'a quasi_router::Params {
332 + editing.submitted.unwrap_or(&NOTHING_TYPED)
333 + }
334 +
335 + declare! {
336 + /// The pane: a form, and for an edit the things only an edit can offer.
337 + ///
338 + /// The span is two `Date` fields rather than one interval. See the module
339 + /// header.
340 + ///
341 + /// `refilled` goes last on every field because it overrides what is stored
342 + /// with what was typed, which is the order the repair wants.
343 + shape form_slot(editing: &Editing) -> Slot;
344 +
345 + region "contexts-detail" as Pane {
346 + section pane_heading(editing);
347 +
348 + // Provenance, and the reversal it buys, said before the controls rather
349 + // than after.
350 + banner Tone::Info
351 + "Converted from an event. Deleting this puts that event back on the \
352 + timeline, with its times intact."
353 + when from_event(editing);
354 +
355 + form post form_path(editing) {
356 + submit submit_label(editing);
357 +
358 + field Text "label" "Label" {
359 + required;
360 + placeholder "Two weeks in Lisbon";
361 + value stored_label(editing) when is_edit(editing);
362 + error error_for(editing, "label") when has_error(editing, "label");
363 + refilled typed(editing);
364 + }
365 +
366 + field Select "kind" "Kind" {
367 + for kind in KINDS.iter().copied() {
368 + option Choice::new(kind.as_str(), kind_label(kind));
369 + }
370 + value stored_kind(editing) when is_edit(editing);
371 + error error_for(editing, "kind") when has_error(editing, "kind");
372 + refilled typed(editing);
373 + }
374 +
375 + field Date "starts_on" "First day" {
376 + required;
377 + hint "The first day inside it.";
378 + value stored_start(editing) when is_edit(editing);
379 + error error_for(editing, "starts_on") when has_error(editing, "starts_on");
380 + refilled typed(editing);
381 + }
382 +
383 + field Date "ends_on" "Last day" {
384 + required;
385 + hint "Inclusive: off until the 17th means the 17th is off.";
386 + value stored_end(editing) when is_edit(editing);
387 + error error_for(editing, "ends_on") when has_error(editing, "ends_on");
388 + refilled typed(editing);
389 + }
390 + }
391 +
392 + act "Delete" to post "/contexts/{existing_id(editing)}/delete" when is_edit(editing) {
393 + tone Danger;
394 + confirm delete_question(editing);
395 + }
396 + }
397 + }
398 +
399 + declare! {
400 + /// The pane before anything is selected.
401 + shape idle_pane() -> Slot;
402 +
403 + region "contexts-detail" as Pane {
404 + empty "Nothing selected";
405 + }
406 + }
407 +
408 + declare! {
409 + /// The whole screen.
410 + ///
411 + /// The Day place rather than a twelfth one: a context frames a day, and the
412 + /// day view is where its banner is read.
413 + shape screen(contexts: &[Context], current: Option<ContextId>, pane: Slot) -> Screen;
414 +
415 + screen list_detail "Contexts" false {
416 + at_place super::shell::DAY;
417 +
418 + region "contexts-band" as Band {
419 + page "Contexts";
420 + act "New context" to get "/contexts/new";
421 + }
422 +
423 + region "contexts-list" as Pane {
424 + include list_node(contexts, current);
425 + }
426 +
427 + include pane;
428 + }
429 + }
430 +
431 + /// The document, with nothing selected.
432 + fn document(state: &AppState, message: Option<&str>) -> Result<Response, RouteError> {
433 + let contexts = all(state)?;
Lines truncated
@@ -40,8 +40,10 @@
40 40
41 41 use chrono::{DateTime, Utc};
42 42 use goingson_core::{Problem, ProblemBand, ProblemFilter, ProblemId, ProblemStatus, ProjectId};
43 - use quasi_router::screen::{Act, Row, Tag};
44 - use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
43 + use quasi_declare::declare;
44 + use quasi_router::layout::Tone;
45 + use quasi_router::screen::Tag;
46 + use quasi_router::{Action, Response, RouteError, Router};
45 47
46 48 use crate::commands::{PromoteProblemInput, promote};
47 49 use crate::state::{AppState, DESKTOP_USER_ID};
@@ -165,153 +167,42 @@
165 167 Ok(sources)
166 168 }
167 169
168 - /// One problem as a row.
169 - ///
170 - /// # What the shipped screen says with a tooltip, and this one says out loud
171 - ///
172 - /// `rowHtml` puts the score's derivation in a `title=` on the badge and the
173 - /// source ref in a `title=` on the age. A description has no word for "text
174 - /// that appears if you hover", and should not grow one: hover is absent on a
175 - /// touch screen and on a keyboard, so a `title` is a fact the app knows and
176 - /// most of its users never see. Both move into `meta`, where a plain fact
177 - /// belongs.
178 - ///
179 - /// # A finding: the screen never shows staleness
180 - ///
181 - /// `ProblemResponse` has carried a `stale` flag since the inbox was built, and
182 - /// the comment on it explains why staleness is shown rather than deleted — a
183 - /// source being briefly unreachable must not erase triage history. Nothing in
184 - /// `problems.js` reads the field. So the backend computes a fact for the user,
185 - /// serialises it, and the screen drops it on the floor. Described, it is a
186 - /// badge like any other. The shipped screen carries the same badge as of
187 - /// 2026-08-10, so the two agree until `problems.js` retires.
188 - fn row_for(
189 - problem: &Problem,
190 - project: Option<&str>,
191 - last_pull: Option<DateTime<Utc>>,
192 - status: Option<ProblemStatus>,
193 - source: Option<&str>,
194 - ) -> Row {
195 - let mut row = Row::new(&problem.title);
196 -
197 - if !problem.body.trim().is_empty() {
198 - row = row.secondary(problem.body.clone());
199 - }
200 -
201 - // The score leads the row: it is the reason this problem is where it is in
202 - // the list, so it reads before the title in every renderer that puts tokens
203 - // first, and it is the sort key either way.
204 - row = row.token(Tag::badge(problem.painhours().to_string()).tone(band_tone(problem.band())));
205 -
206 - // The source is also the filter, which is the click contacts already
207 - // established on a row's own tags. Not latched: a row says what it carries,
208 - // and whether that is the active filter is the band's question.
209 - row = row.token(Tag::chip(
210 - &problem.source,
211 - list_action(status, Some(&problem.source)),
212 - ));
213 -
214 - if let Some(project) = project {
215 - row = row.token(Tag::badge(project).tone(makeover_layout::Tone::Info));
216 - }
217 -
218 - if problem.status.is_settled() {
219 - row = row.token(Tag::badge(problem.status.as_str()).tone(status_tone(problem.status)));
220 - }
221 -
222 - // A problem its project has shelved is frozen rather than triaged, and the
223 - // two look identical in a ranking that only shows the score.
224 - if problem.is_dormant() {
225 - row = row.token(Tag::badge("Dormant").tone(makeover_layout::Tone::Neutral));
226 - }
227 -
228 - if last_pull.is_some_and(|at| problem.is_stale(at)) {
229 - row = row.token(Tag::badge("Stale").tone(makeover_layout::Tone::Warning));
230 - }
231 -
232 - for tag in &problem.tags {
233 - row = row.token(Tag::badge(tag));
234 - }
235 -
236 - row = row.meta(format!(
237 - "pain {} x scale {}, aged {} · {}",
238 - problem.pain,
239 - problem.scale,
240 - problem.age(),
241 - problem.source_ref,
242 - ));
243 -
244 - for act in acts_for(problem, status, source) {
245 - row = row.act(act);
246 - }
247 -
248 - row
170 + /// One problem as the list draws it: the problem, and the two facts the list
171 + /// resolved once for the whole page rather than once per row.
172 + struct Listed {
173 + problem: Problem,
174 + /// Its project's name, if it belongs to one.
175 + project: Option<String>,
176 + /// Whether its source has stopped reporting it.
177 + stale: bool,
249 178 }
250 179
251 - /// The moves a problem offers, which are its triage state's.
252 - fn acts_for(problem: &Problem, status: Option<ProblemStatus>, source: Option<&str>) -> Vec<Act> {
253 - let id = problem.id;
254 - let reopen = || {
255 - Act::new(
256 - "Reopen",
257 - filtered(
258 - Action::post(format!("/problems/{id}/status")).with("status", "Open"),
259 - status,
260 - source,
261 - ),
262 - )
263 - };
264 -
265 - match problem.status {
266 - ProblemStatus::Open => vec![
267 - Act::new(
268 - "Promote",
269 - filtered(
270 - Action::post(format!("/problems/{id}/promote")),
271 - status,
272 - source,
273 - ),
274 - ),
275 - Act::new(
276 - "Dismiss",
277 - filtered(
278 - Action::post(format!("/problems/{id}/status")).with("status", "Dismissed"),
279 - status,
280 - source,
281 - ),
282 - ),
283 - ],
284 - // The backlink is the point of promoting, so the row offers it. The JS
285 - // switches view and calls into the tasks module; here it is an address,
286 - // which is the whole of what "open the task" means.
287 - ProblemStatus::Promoted => {
288 - let mut acts = Vec::with_capacity(2);
289 - if let Some(task) = problem.promoted_task_id {
290 - acts.push(Act::new("Open task", Action::get(format!("/tasks/{task}"))));
291 - }
292 - acts.push(reopen());
293 - acts
294 - }
295 - ProblemStatus::Dismissed | ProblemStatus::Resolved => vec![reopen()],
296 - }
180 + /// The ranked list, and the filters it was drawn under.
181 + ///
182 + /// The repository ranks by painhours descending, so nothing re-sorts. The score
183 + /// moves with the clock, which is why it is computed on read and why the order
184 + /// is the repository's rather than SQL's.
185 + struct Listing {
186 + rows: Vec<Listed>,
187 + status: Option<ProblemStatus>,
188 + source: Option<String>,
297 189 }
298 190
299 - /// The ranked list, filtered the way the screen's two filters filter it.
191 + /// Read the list the request asks for, and everything its rows need.
300 192 ///
301 - /// The repository ranks by painhours descending, so nothing here re-sorts. The
302 - /// score moves with the clock, which is why it is computed on read and why the
303 - /// order is the repository's rather than SQL's.
304 - fn ranked(
305 - state: &AppState,
306 - status: Option<ProblemStatus>,
307 - source: Option<&str>,
308 - ) -> Result<Node, RouteError> {
193 + /// One project lookup for the whole list rather than one per row, and one
194 + /// last-pull lookup per distinct source rather than per row. Both are the shape
195 + /// `list_problems` already uses.
196 + fn read(state: &AppState, request: &quasi_router::Request) -> Result<Listing, RouteError> {
197 + let status = status_filter(request)?;
198 + let source = text(&request.carried, "source").map(str::to_owned);
199 +
309 200 let problems = state
310 201 .problems
311 202 .list(
312 203 DESKTOP_USER_ID,
313 204 &ProblemFilter {
314 - source: source.map(str::to_owned),
205 + source: source.clone(),
315 206 status,
316 207 project_id: None,
317 208 },
@@ -319,19 +210,13 @@
319 210 .map_err(|error| RouteError::internal(error.to_string()))?;
320 211
321 212 if problems.is_empty() {
322 - return Ok(Node::empty(match (status, source) {
323 - (Some(ProblemStatus::Open), None) => {
324 - "Nothing waiting for triage. Problems arrive from wam and from audit runs; \
325 - they are candidates, and promoting one makes it a task."
326 - }
327 - (Some(_), _) | (None, Some(_)) => "No problems match that filter.",
328 - (None, None) => "No problems yet.",
329 - }));
213 + return Ok(Listing {
214 + rows: Vec::new(),
215 + status,
216 + source,
217 + });
330 218 }
331 219
332 - // One project lookup for the whole list rather than one per row, and one
333 - // last-pull lookup per distinct source rather than per row. Both are the
334 - // shape `list_problems` already uses.
335 220 let projects = state
336 221 .projects
337 222 .list_all(DESKTOP_USER_ID)
@@ -340,73 +225,271 @@
340 225 id.and_then(|id| {
341 226 projects
342 227 .iter()
343 - .find(|p| p.id == id)
344 - .map(|p| p.name.as_str())
228 + .find(|project| project.id == id)
229 + .map(|project| project.name.clone())
345 230 })
346 231 };
347 232
348 - let mut last_pulls: HashMap<&str, Option<DateTime<Utc>>> = HashMap::new();
233 + let mut last_pulls: HashMap<String, Option<DateTime<Utc>>> = HashMap::new();
349 234 for name in problems
350 235 .iter()
351 - .map(|problem| problem.source.as_str())
236 + .map(|problem| problem.source.clone())
352 237 .collect::<HashSet<_>>()
353 238 {
354 239 let at = state
355 240 .problems
356 - .last_pulled_at(DESKTOP_USER_ID, name)
241 + .last_pulled_at(DESKTOP_USER_ID, &name)
357 242 .map_err(|error| RouteError::internal(error.to_string()))?;
358 243 last_pulls.insert(name, at);
359 244 }
360 245
361 - Ok(Node::list(problems.iter().map(|problem| {
362 - let last_pull = last_pulls.get(problem.source.as_str()).copied().flatten();
363 - row_for(
246 + let rows = problems
247 + .into_iter()
248 + .map(|problem| Listed {
249 + project: name_of(problem.project_id),
250 + stale: last_pulls
251 + .get(&problem.source)
252 + .copied()
253 + .flatten()
254 + .is_some_and(|at| problem.is_stale(at)),
364 255 problem,
365 - name_of(problem.project_id),
366 - last_pull,
367 - status,
368 - source,
369 - )
370 - })))
256 + })
257 + .collect();
258 +
259 + Ok(Listing {
260 + rows,
261 + status,
262 + source,
263 + })
371 264 }
372 265
373 - /// The whole screen.
266 + /// Whether the problem carries a body worth drawing under its title.
267 + fn has_body(listed: &Listed) -> bool {
268 + !listed.problem.body.trim().is_empty()
269 + }
270 +
271 + /// The score, as the badge reads it.
272 + fn painhours(listed: &Listed) -> String {
273 + listed.problem.painhours().to_string()
274 + }
275 +
276 + /// Whether the problem belongs to a project. R9: read whether or not the badge
277 + /// is placed.
278 + fn has_project(listed: &Listed) -> bool {
279 + listed.project.is_some()
280 + }
281 +
282 + /// That project's name, or nothing.
283 + fn project_name(listed: &Listed) -> &str {
284 + listed.project.as_deref().unwrap_or_default()
285 + }
286 +
287 + /// Where the score came from, and how old the report is.
288 + ///
289 + /// `rowHtml` puts this in a `title=` on the badge and the source ref in a
290 + /// `title=` on the age. A description has no word for "text that appears if you
291 + /// hover", and should not grow one: hover is absent on a touch screen and on a
292 + /// keyboard, so a `title` is a fact the app knows and most of its users never
293 + /// see. Both are said here, where a plain fact belongs.
294 + fn score_line(listed: &Listed) -> String {
295 + format!(
296 + "pain {} x scale {}, aged {} · {}",
297 + listed.problem.pain,
298 + listed.problem.scale,
299 + listed.problem.age(),
300 + listed.problem.source_ref,
301 + )
302 + }
303 +
304 + /// Whether the problem is still waiting for a decision.
305 + fn is_open(listed: &Listed) -> bool {
306 + listed.problem.status == ProblemStatus::Open
307 + }
308 +
309 + /// The task a promotion made, if this problem is promoted.
310 + ///
311 + /// Guarded on the state rather than on the column alone: the id survives a
312 + /// reopen, and a dismissed problem should not offer a task it no longer stands
313 + /// behind.
314 + fn promoted_task(listed: &Listed) -> Option<goingson_core::TaskId> {
315 + (listed.problem.status == ProblemStatus::Promoted)
316 + .then_some(listed.problem.promoted_task_id)
317 + .flatten()
318 + }
319 +
320 + /// The route that promotes this problem, keeping the view it was pressed in.
321 + fn promote_action(listing: &Listing, listed: &Listed) -> Action {
322 + filtered(
323 + Action::post(format!("/problems/{}/promote", listed.problem.id)),
324 + listing.status,
325 + listing.source.as_deref(),
326 + )
327 + }
328 +
329 + /// The route that moves this problem to a named triage state.
330 + ///
331 + /// The target is a param, never derived from what the row was drawn with. Two
332 + /// windows on the same inbox therefore cannot disagree about what "the next
333 + /// state" was.
334 + fn triage_action(listing: &Listing, listed: &Listed, to: &str) -> Action {
335 + filtered(
336 + Action::post(format!("/problems/{}/status", listed.problem.id)),
337 + listing.status,
338 + listing.source.as_deref(),
339 + )
340 + .with("status", to)
341 + }
342 +
343 + declare! {
344 + /// One problem as a row.
345 + ///
346 + /// # A finding: the screen never shows staleness
347 + ///
348 + /// `ProblemResponse` has carried a `stale` flag since the inbox was built,
349 + /// and the comment on it explains why staleness is shown rather than
350 + /// deleted — a source being briefly unreachable must not erase triage
351 + /// history. Nothing in `problems.js` reads the field. So the backend
352 + /// computes a fact for the user, serialises it, and the screen drops it on
353 + /// the floor. Described, it is a badge like any other. The shipped screen
354 + /// carries the same badge as of 2026-08-10, so the two agree until
355 + /// `problems.js` retires.
356 + ///
357 + /// The score leads the row: it is the reason this problem is where it is in
358 + /// the list, so it reads before the title in every renderer that puts
359 + /// tokens first, and it is the sort key either way.
360 + ///
361 + /// The source is also the filter, which is the click contacts already
362 + /// established on a row's own tags. Not latched: a row says what it
363 + /// carries, and whether that is the active filter is the band's question.
364 + ///
365 + /// A problem its project has shelved is frozen rather than triaged, and the
366 + /// two look identical in a ranking that only shows the score, so Dormant is
367 + /// a badge of its own.
368 + ///
369 + /// The moves are the triage state's. The backlink is the point of
370 + /// promoting, so a promoted row offers it; the JS switches view and calls
371 + /// into the tasks module, and here it is an address, which is the whole of
372 + /// what "open the task" means.
373 + shape row_for(listing: &Listing, listed: &Listed) -> Row;
374 +
375 + row &listed.problem.title {
376 + secondary listed.problem.body.clone() when has_body(listed);
377 +
378 + token Tag::badge(painhours(listed)).tone(band_tone(listed.problem.band()));
379 + token Tag::chip(
380 + &listed.problem.source,
381 + list_action(listing.status, Some(&listed.problem.source))
382 + );
383 + token Tag::badge(project_name(listed)).tone(Tone::Info) when has_project(listed);
384 + token Tag::badge(listed.problem.status.as_str())
385 + .tone(status_tone(listed.problem.status))
386 + when listed.problem.status.is_settled();
387 + token Tag::badge("Dormant").tone(Tone::Neutral) when listed.problem.is_dormant();
388 + token Tag::badge("Stale").tone(Tone::Warning) when listed.stale;
389 +
390 + for tag in listed.problem.tags.iter() {
391 + token Tag::badge(tag);
392 + }
393 +
394 + meta score_line(listed);
395 +
396 + act "Promote" to doing promote_action(listing, listed) when is_open(listed);
397 + act "Dismiss" to doing triage_action(listing, listed, "Dismissed") when is_open(listed);
398 +
399 + for task in promoted_task(listed).into_iter() {
400 + act "Open task" to get "/tasks/{task}";
401 + }
402 +
403 + act "Reopen" to doing triage_action(listing, listed, "Open") unless is_open(listed);
404 + }
405 + }
406 +
407 + /// What to say when the filter matched nothing.
408 + fn nothing_here(listing: &Listing) -> &'static str {
409 + match (listing.status, listing.source.as_deref()) {
410 + (Some(ProblemStatus::Open), None) => {
411 + "Nothing waiting for triage. Problems arrive from wam and from audit runs; \
412 + they are candidates, and promoting one makes it a task."
413 + }
414 + (Some(_), _) | (None, Some(_)) => "No problems match that filter.",
415 + (None, None) => "No problems yet.",
416 + }
417 + }
418 +
419 + declare! {
420 + /// The ranked list, filtered the way the screen's two filters filter it.
421 + shape ranked(listing: &Listing) -> Node;
422 +
423 + given listing.rows.is_empty() {
424 + true -> empty nothing_here(listing);
425 + otherwise -> list {
426 + for listed in listing.rows.iter() {
427 + include row_for(listing, listed);
428 + }
429 + }
430 + }
431 + }
432 +
433 + /// Whether the band's status chip for `offered` is the one in force.
434 + fn status_latched(listing: &Listing, offered: Option<ProblemStatus>) -> bool {
435 + listing.status == offered
436 + }
437 +
438 + /// Whether the band's chip for this source is the one in force.
439 + fn source_latched(listing: &Listing, offered: &str) -> bool {
440 + listing.source.as_deref() == Some(offered)
441 + }
442 +
443 + /// The source a press on this chip leaves the list filtered to.
444 + ///
445 + /// A source that is filtered on stays offered even when it is the only one
446 + /// left, so the way back is always on screen: pressing a latched chip clears
447 + /// it. Same rule as the contacts tag filter.
448 + fn cleared<'a>(listing: &Listing, offered: &'a str) -> Option<&'a str> {
449 + (!source_latched(listing, offered)).then_some(offered)
450 + }
451 +
452 + declare! {
453 + /// The whole screen.
454 + shape screen(listing: &Listing, offered: &[String]) -> Screen;
455 +
456 + screen list_detail "Problems" false {
457 + at_place super::shell::PROBLEMS;
458 +
459 + region "problems-band" as Band {
460 + page "Problems";
461 +
462 + for state in STATUSES {
463 + chip status_word(state)
464 + to doing list_action(state, listing.source.as_deref()) {
465 + latched status_latched(listing, state);
466 + }
467 + }
468 +
469 + for source in offered.iter() {
470 + chip source to doing list_action(listing.status, cleared(listing, source)) {
471 + latched source_latched(listing, source);
472 + }
473 + }
474 + }
475 +
476 + region "problems-list" as Pane {
477 + include ranked(listing);
478 + }
479 + }
480 + }
481 +
482 + /// The whole screen, as an answer.
374 483 fn index(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
375 - let status = status_filter(&request)?;
376 - let source = text(&request.carried, "source");
377 -
378 - let mut band = Slot::new("problems-band", RegionKind::Band).with(Node::page("Problems"));
379 -
380 - for offered in STATUSES {
381 - let latched = offered == status;
382 - band = band.with(Node::Token(
383 - Tag::chip(status_word(offered), list_action(offered, source)).latched(latched),
384 - ));
385 - }
Lines truncated
@@ -49,8 +49,10 @@
49 49 #![allow(clippy::needless_pass_by_value)]
50 50
51 51 use goingson_core::{DbValue as _, NewProject, Project, ProjectStatus, ProjectType};
52 - use quasi_router::screen::{Act, Choice, Field, Prose, Row, Tag};
53 - use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
52 + use quasi_declare::declare;
53 + use quasi_router::layout::Tone;
54 + use quasi_router::screen::{Choice, Prose, Tag};
55 + use quasi_router::{Action, Node, Response, RouteError, Router};
54 56
55 57 use super::parse_choice;
56 58 use crate::state::{AppState, DESKTOP_USER_ID};
@@ -117,104 +119,228 @@
117 119 }
118 120 }
119 121
120 - /// One project as a row.
122 + /// The filters the screen is under.
121 123 ///
122 - /// Two trailing facts, the type badge and the status badge, carried as
123 - /// `RowPart::Tokens` so the status keeps its tone through [`status_tone`].
124 - ///
125 - /// A row part holds a string and never a node, so the description goes into
126 - /// `secondary` as [`Prose`], which says which kind of string it is.
127 - /// `Prose::rich` says markdown once and each renderer decides: quasi-webview
128 - /// draws it through docengine's `phrase` preset, inline and one line tall, and
129 - /// a terminal can emit bold from exactly the same description. Never flatten
130 - /// markdown at the call site: that throws the fact away and every site with
131 - /// markdown copies the same three lines.
132 - fn row_for(project: &Project, current: bool, shared_only: bool, show_retired: bool) -> Row {
133 - let mut row = Row::new(&project.name)
134 - .token(Tag::badge(type_label(&project.project_type)))
135 - .token(Tag::badge(status_label(&project.status)).tone(status_tone(&project.status)));
136 -
137 - // A scope is a fact about the project, and the row is where a fact about
138 - // the project goes.
139 - if project.group_id.is_some() {
140 - row = row.token(Tag::badge("Shared"));
141 - }
142 -
143 - if !project.description.is_empty() {
144 - // Markdown, said so rather than pre-flattened. See the note above.
145 - row = row.secondary(Prose::rich(&project.description));
146 - }
147 -
148 - row.current = current;
149 - // Filtered, so the pane knows which view it was opened from and the delete
150 - // it offers can answer with that view rather than the unfiltered one.
151 - row.activate = Some(filtered(
152 - Action::get(format!("/projects/{}", project.id)),
153 - shared_only,
154 - show_retired,
155 - ));
156 - row
124 + /// Every address on it carries them, or acting resets the view, and the filters
125 + /// are the only state this screen has.
126 + #[derive(Clone, Copy)]
127 + struct View {
128 + shared_only: bool,
129 + show_retired: bool,
157 130 }
158 131
159 - /// The grid, filtered the way the screen's two toggles filter it.
160 - fn grid(state: &AppState, shared_only: bool, show_retired: bool) -> Result<Node, RouteError> {
132 + impl View {
133 + /// The view a request is asking for.
134 + fn of(request: &quasi_router::Request) -> Self {
135 + Self {
136 + shared_only: flag(request, "shared"),
137 + show_retired: flag(request, "retired"),
138 + }
139 + }
140 +
141 + /// The same view with the shared filter the other way round.
142 + const fn sharing_toggled(self) -> Self {
143 + Self {
144 + shared_only: !self.shared_only,
145 + ..self
146 + }
147 + }
148 +
149 + /// The same view with the retired filter the other way round.
150 + const fn retired_toggled(self) -> Self {
151 + Self {
152 + show_retired: !self.show_retired,
153 + ..self
154 + }
155 + }
156 + }
157 +
158 + /// Why the grid has nothing to show.
159 + ///
160 + /// Three different facts that all draw as an empty state, and only the first has
161 + /// a way out of it.
162 + enum Grid {
163 + /// No projects at all.
164 + Fresh,
165 + /// Filtered to shared, and nothing is shared.
166 + NoneShared,
167 + /// Everything is completed or archived, and retired is hidden.
168 + AllRetired,
169 + /// There are rows.
170 + Showing,
171 + }
172 +
173 + /// Everything the screen draws, read once.
174 + ///
175 + /// One read for the grid and the two counts, where the grid and the band used to
176 + /// list the whole table separately.
177 + struct Loaded {
178 + /// What the grid shows, in order: live first, then retired if they are
179 + /// shown at all.
180 + shown: Vec<Project>,
181 + /// Why it shows nothing, when it shows nothing.
182 + grid: Grid,
183 + /// How many projects are shared into a group.
184 + shared: usize,
185 + /// How many have stopped being worked on.
186 + dormant: usize,
187 + view: View,
188 + }
189 +
190 + /// Read it.
191 + fn read(state: &AppState, view: View) -> Result<Loaded, RouteError> {
161 192 let all = state
162 193 .projects
163 194 .list_all(DESKTOP_USER_ID)
164 195 .map_err(|error| RouteError::internal(error.to_string()))?;
165 196
166 - if all.is_empty() {
167 - // The one empty state in the app with a way out of it, which is what
168 - // `Node::StandIn`'s optional act is for: 2 of goingson's 27 offer one
169 - // and 25 say a sentence and stop. `projects.js` draws the same button.
170 - return Ok(Node::empty("No projects yet.").offering(Act::new(
171 - "Create your first project",
172 - filtered(Action::get("/projects/new"), shared_only, show_retired),
173 - )));
174 - }
197 + let shared = all.iter().filter(|p| p.group_id.is_some()).count();
198 + let dormant = all.iter().filter(|p| retired(p)).count();
199 + let nothing_at_all = all.is_empty();
175 200
176 - let scoped: Vec<&Project> = all
177 - .iter()
178 - .filter(|project| !shared_only || project.group_id.is_some())
201 + let scoped: Vec<Project> = all
202 + .into_iter()
203 + .filter(|project| !view.shared_only || project.group_id.is_some())
179 204 .collect();
205 + let nothing_scoped = scoped.is_empty();
180 206
181 - if scoped.is_empty() {
182 - return Ok(Node::empty(
183 - "No shared projects yet. Share a project from its menu to see it here.",
184 - ));
185 - }
186 -
187 - let (live, dormant): (Vec<&Project>, Vec<&Project>) =
207 + let (live, sleeping): (Vec<Project>, Vec<Project>) =
188 208 scoped.into_iter().partition(|project| !retired(project));
189 209
190 - if live.is_empty() && !show_retired {
191 - return Ok(Node::empty("Every project is completed or archived."));
192 - }
193 -
194 - let shown = if show_retired {
195 - live.into_iter().chain(dormant).collect::<Vec<_>>()
210 + let (grid, shown) = if nothing_at_all {
211 + (Grid::Fresh, Vec::new())
212 + } else if nothing_scoped {
213 + (Grid::NoneShared, Vec::new())
214 + } else if live.is_empty() && !view.show_retired {
215 + (Grid::AllRetired, Vec::new())
216 + } else if view.show_retired {
217 + (Grid::Showing, live.into_iter().chain(sleeping).collect())
196 218 } else {
197 - live
219 + (Grid::Showing, live)
198 220 };
199 221
200 - Ok(Node::list(shown.into_iter().map(|project| {
201 - row_for(project, false, shared_only, show_retired)
202 - })))
222 + Ok(Loaded {
223 + shown,
224 + grid,
225 + shared,
226 + dormant,
227 + view,
228 + })
203 229 }
204 230
205 - /// How many projects are shared into a group, and how many are retired.
231 + /// Whether the project says anything about itself.
232 + fn has_description(project: &Project) -> bool {
233 + !project.description.is_empty()
234 + }
235 +
236 + /// The project's own description, as the markdown it is.
206 237 ///
207 - /// Both counts drive whether a control appears at all, so they are read once
208 - /// per screen rather than per control.
209 - fn counts(state: &AppState) -> Result<(usize, usize), RouteError> {
210 - let all = state
211 - .projects
212 - .list_all(DESKTOP_USER_ID)
213 - .map_err(|error| RouteError::internal(error.to_string()))?;
214 - Ok((
215 - all.iter().filter(|p| p.group_id.is_some()).count(),
216 - all.iter().filter(|p| retired(p)).count(),
217 - ))
238 + /// A row part holds a string and never a node, so this goes into `secondary` as
239 + /// [`Prose`], which says which kind of string it is. `Prose::rich` says markdown
240 + /// once and each renderer decides: quasi-webview draws it through docengine's
241 + /// `phrase` preset, inline and one line tall, and a terminal can emit bold from
242 + /// exactly the same description. Never flatten markdown at the call site: that
243 + /// throws the fact away and every site with markdown copies the same three
244 + /// lines.
245 + fn described(project: &Project) -> Prose {
246 + Prose::rich(&project.description)
247 + }
248 +
249 + declare! {
250 + /// One project as a row.
251 + ///
252 + /// Two trailing facts, the type badge and the status badge, carried as
253 + /// `RowPart::Tokens` so the status keeps its tone through [`status_tone`].
254 + /// A scope is a third fact about the project, and the row is where a fact
255 + /// about the project goes.
256 + ///
257 + /// The address is filtered, so the pane knows which view it was opened from
258 + /// and the delete it offers can answer with that view rather than the
259 + /// unfiltered one.
260 + shape row_for(loaded: &Loaded, project: &Project) -> Row;
261 +
262 + row &project.name {
263 + token Tag::badge(type_label(&project.project_type));
264 + token Tag::badge(status_label(&project.status)).tone(status_tone(&project.status));
265 + token Tag::badge("Shared") when project.group_id.is_some();
266 + secondary described(project) when has_description(project);
267 + activate to doing filtered(Action::get("/projects/{project.id}"), loaded.view);
268 + }
269 + }
270 +
271 + declare! {
272 + /// The grid, filtered the way the screen's two toggles filter it.
273 + ///
274 + /// The first empty state is the one in the app with a way out of it, which
275 + /// is what `Node::StandIn`'s optional act is for: 2 of goingson's 27 offer
276 + /// one and 25 say a sentence and stop. `projects.js` draws the same button.
277 + shape grid(loaded: &Loaded) -> Node;
278 +
279 + given loaded.grid {
280 + Grid::Fresh -> empty "No projects yet." {
281 + offering "Create your first project"
282 + to doing filtered(Action::get("/projects/new"), loaded.view);
283 + }
284 + Grid::NoneShared -> empty "No shared projects yet. Share a project from its menu \
285 + to see it here.";
286 + Grid::AllRetired -> empty "Every project is completed or archived.";
287 + otherwise -> list {
288 + for project in loaded.shown.iter() {
289 + include row_for(loaded, project);
290 + }
291 + }
292 + }
293 + }
294 +
295 + /// Whether the shared filter is on the band at all.
296 + ///
297 + /// It surfaces only when sharing is in play, which is the rule `projects.js`
298 + /// already applies to the same control.
299 + fn offers_sharing(loaded: &Loaded) -> bool {
300 + loaded.shared > 0 || loaded.view.shared_only
301 + }
302 +
303 + /// What the retired toggle reads.
304 + fn retired_label(loaded: &Loaded) -> String {
305 + if loaded.view.show_retired {
306 + "Hide completed and archived".to_owned()
307 + } else {
308 + format!("Show {} completed or archived", loaded.dormant)
309 + }
310 + }
311 +
312 + declare! {
313 + /// The whole screen under a given pair of filters.
314 + ///
315 + /// Declared rather than built inside the route because a write answers with
316 + /// it too: creating or deleting changes the grid and the detail pane at
317 + /// once, and a [`Response`] names one region. See [`wrote`].
318 + shape screen(loaded: &Loaded) -> Screen;
319 +
320 + screen list_detail "Projects" false {
321 + at_place super::shell::PROJECTS;
322 +
323 + region "projects-band" as Band {
324 + page "Projects";
325 + act "New project" to doing filtered(Action::get("/projects/new"), loaded.view);
326 +
327 + chip "Shared only" to doing list_action(loaded.view.sharing_toggled())
328 + when offers_sharing(loaded) {
329 + latched loaded.view.shared_only;
330 + }
331 +
332 + act retired_label(loaded) to doing list_action(loaded.view.retired_toggled())
333 + when loaded.dormant over 0;
334 + }
335 +
336 + region "projects-grid" as Pane {
337 + include grid(loaded);
338 + }
339 +
340 + region "projects-detail" as Pane {
341 + empty "Nothing selected";
342 + }
343 + }
218 344 }
219 345
220 346 /// Whether a param is on. Absent is off, which is what a URL without it means.
@@ -240,70 +366,29 @@
240 366 /// Every address on this screen goes through here, including the two writes.
241 367 /// A filtered view whose controls drop the filters is a view you fall out of by
242 368 /// using it, and the filters are the only state this screen has.
243 - fn filtered(action: Action, shared_only: bool, show_retired: bool) -> Action {
244 - let action = filtered_by(action, "shared", shared_only);
245 - filtered_by(action, "retired", show_retired)
369 + fn filtered(action: Action, view: View) -> Action {
370 + let action = filtered_by(action, "shared", view.shared_only);
371 + filtered_by(action, "retired", view.show_retired)
246 372 }
247 373
248 374 /// The address of the grid under a given pair of filters.
249 - fn list_action(shared_only: bool, show_retired: bool) -> Action {
250 - filtered(Action::get("/projects/list"), shared_only, show_retired)
251 - }
252 -
253 - /// The whole screen under a given pair of filters.
254 - ///
255 - /// Built here rather than inside the route because a write answers with it
256 - /// too: creating or deleting changes the grid and the detail pane at once, and
257 - /// a [`Response`] names one region. See [`created`].
258 - fn screen(state: &AppState, shared_only: bool, show_retired: bool) -> Result<Screen, RouteError> {
259 - let (shared, dormant) = counts(state)?;
260 -
261 - let mut band = Slot::new("projects-band", RegionKind::Band)
262 - .with(Node::page("Projects"))
263 - .with(Node::act(
264 - "New project",
265 - filtered(Action::get("/projects/new"), shared_only, show_retired),
266 - ));
267 -
268 - // The filter surfaces only when sharing is in play, which is the rule
269 - // `projects.js` already applies to the same control.
270 - if shared > 0 || shared_only {
271 - band = band.with(Node::Token(
272 - Tag::chip("Shared only", list_action(!shared_only, show_retired)).latched(shared_only),
273 - ));
274 - }
275 -
276 - if dormant > 0 {
277 - band = band.with(Node::Act(Act::new(
278 - if show_retired {
279 - "Hide completed and archived".to_owned()
280 - } else {
281 - format!("Show {dormant} completed or archived")
282 - },
283 - list_action(shared_only, !show_retired),
284 - )));
285 - }
286 -
287 - Ok(Screen::list_detail("Projects", false)
288 - .at_place(super::shell::PROJECTS)
289 - .with(band)
290 - .with(Slot::new("projects-grid", RegionKind::Pane).with(grid(
291 - state,
292 - shared_only,
293 - show_retired,
294 - )?))
295 - .with(Slot::new("projects-detail", RegionKind::Pane).with(Node::empty("Nothing selected"))))
375 + fn list_action(view: View) -> Action {
376 + filtered(Action::get("/projects/list"), view)
296 377 }
297 378
298 379 /// The whole screen.
299 380 fn index(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
300 - Ok(screen(state, flag(&request, "shared"), flag(&request, "retired"))?.into())
381 + let view = View::of(&request);
382 + Ok(screen(&read(state, view)?).into())
301 383 }
302 384
303 385 /// The grid alone, which is what a filter toggle replaces.
304 386 fn list(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
305 - let node = grid(state, flag(&request, "shared"), flag(&request, "retired"))?;
306 - Ok(Response::fragment("projects-grid", node))
387 + let view = View::of(&request);
388 + Ok(Response::fragment(
389 + "projects-grid",
390 + grid(&read(state, view)?),
391 + ))
307 392 }
308 393
309 394 /// The project a route was addressed at.
@@ -322,97 +407,37 @@
322 407 ))
323 408 }
324 409
325 - /// One project's detail pane.
326 - fn detail(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
327 - let id = project_id(&request)?;
328 - let shared_only = flag(&request, "shared");
329 - let show_retired = flag(&request, "retired");
330 -
410 + /// One project and what this device knows about sharing it, read once.
411 + fn showing(state: &AppState, request: &quasi_router::Request) -> Result<Showing, RouteError> {
412 + let id = project_id(request)?;
331 413 let project = state
332 414 .projects
333 415 .get_by_id(id, DESKTOP_USER_ID)
334 416 .map_err(|error| RouteError::internal(error.to_string()))?
335 417 .ok_or_else(|| RouteError::not_found("no such project"))?;
336 418
337 - let mut slot = Slot::new("projects-detail", RegionKind::Pane)
338 - .with(Node::section(&project.name))
339 - .with(Node::text(format!(
340 - "{} · {}",
341 - type_label(&project.project_type),
342 - status_label(&project.status)
343 - )));
419 + // Read only where a picker could be drawn, so a shared project pays nothing
420 + // for the directory it would not offer.
421 + let groups = if project.group_id.is_none() {
422 + known_groups(state)?
423 + } else {
424 + Vec::new()
425 + };
344 426
345 - if !project.description.is_empty() {
346 - slot = slot.with(Node::text(&project.description));
347 - }
427 + Ok(Showing {
428 + project,
429 + groups,
430 + view: View::of(request),
431 + })
432 + }
348 433
349 - // Share, offered only on a personal project and only when this device knows
350 - // of a group to offer. Both halves read the directory synckit writes each
351 - // cycle; see the module header for why that had to exist first.
352 - if project.group_id.is_none() {
353 - let groups = known_groups(state)?;
354 - if !groups.is_empty() {
355 - slot = slot.with(Node::Form {
356 - action: filtered(
357 - Action::post(format!("/projects/{}/share", project.id)),
358 - shared_only,
359 - show_retired,
360 - ),
361 - submit: "Share into a group".to_owned(),
362 - fields: vec![
363 - Field::select(
364 - "group_id",
365 - "Group",
366 - groups
367 - .iter()
368 - .map(|group| Choice::new(group.id.to_string(), &group.name))
369 - .collect(),
370 - )
371 - .required()
372 - .hint(
373 - "Everything in the project goes with it: its tasks, events, \
374 - milestones and attachments.",
375 - ),
376 - ],
377 - });
378 - }
379 - }
380 -
381 - if project.group_id.is_some() {
382 - slot = slot.with(Node::text(
383 - "Shared into a group. Its tasks, events, milestones and attachments \
384 - are shared with it.",
385 - ));
386 - slot = slot.with(Node::Act(
387 - Act::new(
388 - "Move back to personal",
389 - filtered(
390 - Action::post(format!("/projects/{}/unshare", project.id)),
391 - shared_only,
392 - show_retired,
393 - ),
394 - )
395 - .confirm(
396 - "Move this project and everything in it back to personal scope? \
397 - Other members of the group will stop seeing it.",
398 - ),
399 - ));
400 - }
401 -
402 - slot = slot.with(Node::Act(
403 - Act::new(
404 - "Delete project",
Lines truncated
@@ -49,10 +49,11 @@
49 49
50 50 use std::collections::HashMap;
51 51
52 + use quasi_declare::declare;
52 53 use quasi_notifs::pane;
53 - use quasi_router::layout::{Contrast, Heading, ThemeVariant};
54 - use quasi_router::screen::{Choice, Field, Row, ThemeChoice};
55 - use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
54 + use quasi_router::layout::{Contrast, ThemeVariant};
55 + use quasi_router::screen::{Choice, ThemeChoice};
56 + use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Slot};
56 57
57 58 use crate::notifs::NOTIFS;
58 59
@@ -182,40 +183,40 @@
182 183 }
183 184
184 185 /// The route that writes one config key.
185 - fn writes(key: &str) -> Action {
186 + ///
187 + /// Named for the route rather than for the builder it feeds, so the setting it
188 + /// is written on reads as `writes config_route(key)`.
189 + fn config_route(key: &str) -> Action {
186 190 Action::post(format!("/settings/config/{key}"))
187 191 }
188 192
189 - /// A control that stands on its own and writes as soon as it changes.
190 - ///
191 - /// [`Node::Field`] and [`Field::writes`] together, which is what finding
192 - /// `14612ed8` was closed for and what this screen is the first consumer of. The
193 - /// JS says the same thing with `data-change` on a bare `<select>` with no form
194 - /// around it; wrapping these in a [`Node::Form`] would describe a submit button
195 - /// that does not exist.
196 - fn setting(field: Field) -> Node {
197 - let key = field.name.clone();
198 - Node::field(field.writes(writes(&key)))
193 + declare! {
194 + /// A select over a fixed set of values, holding the one in force.
195 + ///
196 + /// It writes as soon as it changes and stands on its own: [`Field::writes`]
197 + /// on a field in a panel, which is what finding `14612ed8` was closed for
198 + /// and what this screen is the first consumer of. The JS says the same
199 + /// thing with `data-change` on a bare `<select>` with no form around it;
200 + /// wrapping these in a [`Node::Form`] would describe a submit button that
201 + /// does not exist.
202 + shape choice_field(
203 + config: &HashMap<String, String>,
204 + key: &'static str,
205 + label: &str,
206 + choices: Vec<Choice>,
207 + ) -> Field;
208 +
209 + field Select key label {
210 + options choices;
211 + value value_of(config, key);
212 + writes config_route(key);
213 + }
199 214 }
200 215
201 - /// A select over a fixed set of values, holding the one in force.
202 - fn choice_field(
203 - config: &HashMap<String, String>,
204 - key: &'static str,
205 - label: &str,
206 - options: Vec<Choice>,
207 - ) -> Field {
208 - Field::select(key, label, options).value(value_of(config, key))
209 - }
210 -
211 - /// The themes on offer.
216 + /// The themes on offer, in the order the resolver read them out.
212 217 ///
213 - /// A described theme picker rather than a select of grouped options:
214 - /// [`Field::theme`], whose entries carry their
215 - /// variant and their measured contrast tier as values rather than as prose. The
216 - /// grouping comes back, and the tier arrives with it — a fact this app never
217 - /// had, because deriving it means resolving every theme's colours and this
218 - /// screen only ever had the names.
218 + /// A supplier because the mapping is a closure. It hands back [`ThemeChoice`],
219 + /// which is not a vocabulary type, so it costs the population nothing.
219 220 ///
220 221 /// # Nothing here sorts, and nothing here groups
221 222 ///
@@ -226,9 +227,9 @@
226 227 /// three apps in the first place.
227 228 ///
228 229 /// The list is read through [`AppState::theme_dirs`], which is the whole reason
229 - /// this section exists at all — see the module header.
230 - fn theme_field(state: &AppState, config: &HashMap<String, String>) -> Field {
231 - let themes = makeover::theme_options(&state.theme_dirs)
230 + /// the Appearance section exists at all — see the module header.
231 + fn theme_choices(app: &AppState) -> Vec<ThemeChoice> {
232 + makeover::theme_options(&app.theme_dirs)
232 233 .into_iter()
233 234 .map(|theme| {
234 235 ThemeChoice::new(
@@ -238,14 +239,30 @@
238 239 tier_of(theme.contrast),
239 240 )
240 241 })
241 - .collect();
242 + .collect()
243 + }
242 244
243 - // `makeover::FOLLOW` rather than a literal: the sentinel this app stores is
244 - // the one the crate that resolves it reads back, and spelling it here is
245 - // how the two drift.
246 - Field::theme("theme", "Theme", themes)
247 - .following(Choice::new(makeover::FOLLOW, "Follow System"))
248 - .value(value_of(config, "theme"))
245 + declare! {
246 + /// The theme picker.
247 + ///
248 + /// A described theme picker rather than a select of grouped options:
249 + /// [`Field::theme`]'s kind, whose entries carry their variant and their
250 + /// measured contrast tier as values rather than as prose. The grouping
251 + /// comes back, and the tier arrives with it — a fact this app never had,
252 + /// because deriving it means resolving every theme's colours and this
253 + /// screen only ever had the names.
254 + ///
255 + /// `makeover::FOLLOW` rather than a literal: the sentinel this app stores
256 + /// is the one the crate that resolves it reads back, and spelling it here
257 + /// is how the two drift.
258 + shape theme_field(app: &AppState, config: &HashMap<String, String>) -> Field;
259 +
260 + field Theme "theme" "Theme" {
261 + themes theme_choices(app);
262 + following Choice::new(makeover::FOLLOW, "Follow System");
263 + value value_of(config, "theme");
264 + writes config_route("theme");
265 + }
249 266 }
250 267
251 268 /// `makeover`'s variant as the description layer's own.
@@ -274,82 +291,49 @@
274 291 }
275 292 }
276 293
277 - /// Appearance.
278 - ///
279 - /// # The second finding
280 - ///
281 - /// **Import and Export are absent because a file dialog is not an address.**
282 - /// `themes.importTheme` opens a native open-dialog and `themes.exportTheme` a
283 - /// native save-dialog, and both then call a command with the path the user
284 - /// picked. `FieldKind::File` covers picking a file to *submit*, which is the
285 - /// import half and would work here if the write route existed; the export half
286 - /// is a control that asks the host where to put something and then acts, and
287 - /// nothing in the vocabulary names that.
288 - ///
289 - /// Left out rather than dangled, to the standard the contacts port set. Filed
290 - /// on quasicoherent alongside the About section's host facts, because it is the
291 - /// same finding wearing different clothes: the description can say what to do
292 - /// and cannot reach what the host knows.
293 - ///
294 - /// # There is no hint any more, because there is nothing left to apologise for
295 - ///
296 - /// There was one, and it said a named theme took effect the next time GoingsOn
297 - /// started. That was true while the sheet held one theme and the document
298 - /// linked it once. It holds every theme now, keyed by a root attribute
299 - /// (`super::theming`), and picking one sets the attribute, so every choice on
300 - /// this control lands at once and none of them is worth explaining.
301 - ///
302 - /// Deleted rather than reworded. The remaining sentence would have said that
303 - /// Follow System follows the system, which the option's own label says.
304 - fn appearance(state: &AppState, config: &HashMap<String, String>) -> Vec<Node> {
305 - vec![
306 - Node::section("Appearance"),
307 - setting(theme_field(state, config)),
308 - ]
294 + declare! {
295 + /// Appearance.
296 + ///
297 + /// # The second finding
298 + ///
299 + /// **Import and Export are absent because a file dialog is not an
300 + /// address.** `themes.importTheme` opens a native open-dialog and
301 + /// `themes.exportTheme` a native save-dialog, and both then call a command
302 + /// with the path the user picked. `FieldKind::File` covers picking a file
303 + /// to *submit*, which is the import half and would work here if the write
304 + /// route existed; the export half is a control that asks the host where to
305 + /// put something and then acts, and nothing in the vocabulary names that.
306 + ///
307 + /// Left out rather than dangled, to the standard the contacts port set.
308 + /// Filed on quasicoherent alongside the About section's host facts, because
309 + /// it is the same finding wearing different clothes: the description can
310 + /// say what to do and cannot reach what the host knows.
311 + ///
312 + /// # There is no hint any more, because there is nothing left to apologise for
313 + ///
314 + /// There was one, and it said a named theme took effect the next time
315 + /// GoingsOn started. That was true while the sheet held one theme and the
316 + /// document linked it once. It holds every theme now, keyed by a root
317 + /// attribute (`super::theming`), and picking one sets the attribute, so
318 + /// every choice on this control lands at once and none of them is worth
319 + /// explaining.
320 + ///
321 + /// Deleted rather than reworded. The remaining sentence would have said
322 + /// that Follow System follows the system, which the option's own label
323 + /// says.
324 + shape appearance(app: &AppState, config: &HashMap<String, String>) -> Vec<Node>;
325 +
326 + section "Appearance";
327 + include theme_field(app, config);
309 328 }
310 329
311 - /// Notifications.
330 + /// The lead times the Events tab indicator offers.
312 331 ///
313 - /// Two halves, and the point of the section is that they are two.
314 - ///
315 - /// The first is generated: [`quasi_notifs::pane`] emits a control per declared
316 - /// kind straight from [`NOTIFS`], so adding a kind adds its settings and there
317 - /// is no list here to keep in step. That replaced a hand-built section, which
318 - /// is what task `07830eb5` was for.
319 - ///
320 - /// The shipped JavaScript screen renders the same half from the same registry,
321 - /// over [`crate::commands::list_notification_kinds`], because this screen is
322 - /// behind the `quasi` feature and a pane nobody can reach is not somewhere
323 - /// onboarding can point (`b6c634fb`). It writes the same generated keys, so the
324 - /// flip deletes it rather than migrating it.
325 - ///
326 - /// The second is `event_lead_minutes`, which stays hand-written because it is
327 - /// not a notification setting at all: it colours a dot on the Events tab. It
328 - /// sat alone under this heading before the generated half arrived, and the risk
329 - /// the adoption had to avoid was folding it into the event-reminder kind by
330 - /// name-similarity. Generated keys are dotted and this one is not, so they
331 - /// cannot collide in the store; what they could still do is read alike in the
332 - /// pane, which is what the hint and the sub-heading below are for.
333 - fn notifications(config: &HashMap<String, String>) -> Vec<Node> {
334 - let mut nodes = vec![Node::section("Notifications")];
335 -
336 - // Every generated control writes to one route under its own key. The
337 - // section heading the generator emits per category is why this does not add
338 - // one: "Reminders" is the category GoingsOn declared.
339 - let stored = |key: &str| config.get(key).cloned();
340 - nodes.push(Node::Region(pane::pane(
341 - &NOTIFS,
342 - &stored,
343 - &Action::post("/settings/notifications"),
344 - )));
345 -
346 - nodes.extend(indicator(config));
347 - nodes
348 - }
349 -
350 - /// The Events tab's indicator lead time. Not a notification; see [`notifications`].
351 - fn indicator(config: &HashMap<String, String>) -> Vec<Node> {
352 - let options = [5, 10, 15, 30, 60]
332 + /// The default is marked in the label because the control cannot otherwise say
333 + /// which value it would hold if nobody had chosen. The JS marks the same one
334 + /// the same way.
335 + fn lead_choices() -> Vec<Choice> {
336 + [5, 10, 15, 30, 60]
353 337 .into_iter()
354 338 .map(|minutes| {
355 339 let label = if minutes == 60 {
@@ -357,9 +341,6 @@
357 341 } else {
358 342 format!("{minutes} minutes")
359 343 };
360 - // The default is marked in the label because the control cannot
361 - // otherwise say which value it would hold if nobody had chosen. The
362 - // JS marks the same one the same way.
363 344 let label = if minutes == 15 {
364 345 format!("{label} (default)")
365 346 } else {
@@ -367,23 +348,53 @@
367 348 };
368 349 Choice::new(minutes.to_string(), label)
369 350 })
370 - .collect();
351 + .collect()
352 + }
371 353
372 - vec![
373 - Node::Heading {
374 - level: Heading::Section,
375 - text: "Events tab".to_owned(),
376 - },
377 - setting(
378 - choice_field(
379 - config,
380 - "event_lead_minutes",
381 - "Event indicator lead time",
382 - options,
383 - )
384 - .hint("How far in advance the Events tab dot turns yellow. This is the indicator, not a notification."),
385 - ),
386 - ]
354 + declare! {
355 + /// Notifications.
356 + ///
357 + /// Two halves, and the point of the section is that they are two.
358 + ///
359 + /// The first is generated: [`quasi_notifs::pane`] emits a control per
360 + /// declared kind straight from [`NOTIFS`], so adding a kind adds its
361 + /// settings and there is no list here to keep in step. That replaced a
362 + /// hand-built section, which is what task `07830eb5` was for. Every
363 + /// generated control writes to one route under its own key, and the
364 + /// section heading the generator emits per category is why nothing is
365 + /// added around it: "Reminders" is the category GoingsOn declared.
366 + ///
367 + /// The shipped JavaScript screen renders the same half from the same
368 + /// registry, over [`crate::commands::list_notification_kinds`], because
369 + /// this screen is behind the `quasi` feature and a pane nobody can reach is
370 + /// not somewhere onboarding can point (`b6c634fb`). It writes the same
371 + /// generated keys, so the flip deletes it rather than migrating it.
372 + ///
373 + /// The second is `event_lead_minutes`, which stays hand-written because it
374 + /// is not a notification setting at all: it colours a dot on the Events
375 + /// tab. It sat alone under this heading before the generated half arrived,
376 + /// and the risk the adoption had to avoid was folding it into the
377 + /// event-reminder kind by name-similarity. Generated keys are dotted and
378 + /// this one is not, so they cannot collide in the store; what they could
379 + /// still do is read alike in the pane, which is what the sub-heading and
380 + /// the hint are for.
381 + ///
382 + /// It sat in a shape of its own while this one had to `extend` a second
383 + /// list onto its own; a panel says both halves in one body, so that shape
384 + /// is gone.
385 + shape notifications(config: &HashMap<String, String>) -> Vec<Node>;
386 +
387 + section "Notifications";
388 + include pane::pane(&NOTIFS, config, &Action::post("/settings/notifications"));
389 +
390 + section "Events tab";
391 + field Select "event_lead_minutes" "Event indicator lead time" {
392 + options lead_choices();
393 + value value_of(config, "event_lead_minutes");
394 + hint "How far in advance the Events tab dot turns yellow. This is the \
395 + indicator, not a notification.";
396 + writes config_route("event_lead_minutes");
397 + }
387 398 }
388 399
389 400 /// Every hour of the day, as the clock writes it.
@@ -412,34 +423,23 @@
412 423 ]
413 424 }
414 425
415 - /// Planning and review.
416 - ///
417 - /// **Two controls answering one question are two controls.** Work hours is a
418 - /// start and an end, and the description has one label per field, so it says
419 - /// "Work day starts" and "Work day ends" rather than pairing them into a row. A
420 - /// group of fields inside a form is furniture, which the vocabulary declines to
421 - /// state.
422 - fn planning(config: &HashMap<String, String>) -> Vec<Node> {
423 - vec![
424 - Node::section("Planning & Review"),
425 - setting(
426 - choice_field(config, "work_start_hour", "Work day starts", hour_choices())
427 - .hint("Controls when plan and review nudge dots appear."),
428 - ),
429 - setting(choice_field(
430 - config,
431 - "work_end_hour",
432 - "Work day ends",
433 - hour_choices(),
434 - )),
435 - setting(choice_field(config, "plan_nudges", "Plan nudges", on_off())),
436 - setting(choice_field(
437 - config,
438 - "review_nudges",
439 - "Review nudges",
440 - on_off(),
441 - )),
442 - ]
426 + declare! {
427 + /// Planning and review.
428 + ///
429 + /// **Two controls answering one question are two controls.** Work hours is
430 + /// a start and an end, and the description has one label per field, so it
431 + /// says "Work day starts" and "Work day ends" rather than pairing them into
432 + /// a row. A group of fields inside a form is furniture, which the
433 + /// vocabulary declines to state.
434 + shape planning(config: &HashMap<String, String>) -> Vec<Node>;
435 +
436 + section "Planning & Review";
437 +
438 + include choice_field(config, "work_start_hour", "Work day starts", hour_choices())
439 + .hint("Controls when plan and review nudge dots appear.");
440 + include choice_field(config, "work_end_hour", "Work day ends", hour_choices());
441 + include choice_field(config, "plan_nudges", "Plan nudges", on_off());
442 + include choice_field(config, "review_nudges", "Review nudges", on_off());
443 443 }
444 444
445 445 /// The section under this slug, or 404.
@@ -469,48 +469,87 @@
469 469 }
470 470 }
471 471
472 - /// The whole screen, showing one section.
473 - pub(super) fn screen(state: &AppState, slug: &str) -> Result<Screen, RouteError> {
472 + /// Where a sidebar row leads.
473 + ///
474 + /// A section of this screen is `/settings/{slug}`; a row that navigates away
475 + /// carries its own address. See [`Section::at`].
476 + fn section_path(section: &Section) -> String {
477 + section
478 + .at
479 + .map_or_else(|| format!("/settings/{}", section.slug), ToOwned::to_owned)
480 + }
481 +
482 + /// Whether this row is the one the pane is showing.
483 + ///
484 + /// `current` and not `selected`: this is the app's own pointer at what the pane
485 + /// is showing rather than a tick the reader made. A row that leaves is never
486 + /// current; see [`Section::at`].
487 + fn is_showing(item: &Section, section: &Section) -> bool {
488 + item.at.is_none() && item.slug == section.slug
489 + }
490 +
491 + /// The section the screen is showing, and the body it draws.
492 + struct Showing {
493 + section: &'static Section,
494 + body: Vec<Node>,
495 + }
496 +
497 + /// The section under this slug, and the body it draws.
498 + ///
499 + /// The read is the handler's, which is what converting this screen meant: four
500 + /// of the seven sections read the database or the sync client, two of them
501 + /// fallibly, and a description says what is on the screen rather than fetching
502 + /// it.
503 + fn read(state: &AppState, slug: &str) -> Result<Showing, RouteError> {
474 504 let section = section_of(slug)?;
475 505 let config = all_config(state).map_err(|error| RouteError::internal(error.to_string()))?;
476 -
477 - // `Region::Sidebar` for what it is named for: the sidebar is the section
478 - // nav.
479 - let nav = Slot::new("settings-nav", RegionKind::Sidebar).with(Node::list(SECTIONS.iter().map(
480 - |item| {
481 - let at = item
482 - .at
483 - .map_or_else(|| format!("/settings/{}", item.slug), ToOwned::to_owned);
484 - let mut row = Row::new(item.title).activate(Action::get(at));
485 - // `current` and not `selected`: this is the app's own pointer at
486 - // what the pane is showing. Set on the field because the two have
487 - // no paired constructor the way `toggling` pairs the other two.
488 - // A row that leaves is never current; see `Section::at`.
489 - row.current = item.at.is_none() && item.slug == section.slug;
490 - row
491 - },
492 - )));
493 -
494 - let mut pane = Slot::new(SECTION_REGION, RegionKind::Pane);
495 - pane = pane.extend(match section.slug {
506 + let body = match section.slug {
496 507 "notifications" => notifications(&config),
497 508 "planning" => planning(&config),
498 - "email" => email::pane(state)?,
509 + "email" => email::pane(&email::accounts(state)?),
499 510 "about" => about::pane(state),
500 511 "sync" => sync::pane(state),
501 512 "sharing" => sharing::pane(state)?,
502 513 _ => appearance(state, &config),
503 - });
514 + };
515 + Ok(Showing { section, body })
516 + }
504 517
505 - Ok(Screen::sidebar_content("Settings")
506 - .at_place(super::shell::SETTINGS)
507 - .with(nav)
508 - .with(pane))
518 + declare! {
519 + /// The whole screen, showing one section.
520 + ///
521 + /// `Sidebar` for what it is named for: the sidebar is the section nav.
522 + pub(super) shape screen(section: &Section, body: Vec<Node>) -> Screen;
523 +
524 + screen sidebar_content "Settings" {
525 + at_place super::shell::SETTINGS;
526 +
527 + region "settings-nav" as Sidebar {
528 + list {
529 + for item in SECTIONS.iter() {
530 + row item.title {
531 + current is_showing(item, section);
532 + activate to get section_path(item);
533 + }
534 + }
535 + }
536 + }
537 +
538 + region SECTION_REGION as Pane {
539 + extend body;
540 + }
541 + }
542 + }
543 +
544 + /// One section, drawn and answered with.
545 + pub(super) fn showing(state: &AppState, slug: &str) -> Result<Response, RouteError> {
546 + let showing = read(state, slug)?;
547 + Ok(screen(showing.section, showing.body).into())
509 548 }
510 549
511 550 /// Appearance, which is where the screen opens.
512 551 fn index(state: &AppState, _request: quasi_router::Request) -> Result<Response, RouteError> {
513 - Ok(screen(state, "appearance")?.into())
552 + showing(state, "appearance")
514 553 }
515 554
516 555 /// Turn the launch-time update check on or off.
@@ -647,7 +686,7 @@
647 686 .captures
648 687 .get("section")
Lines truncated
@@ -46,21 +46,47 @@
46 46 #![allow(clippy::needless_pass_by_value)]
47 47
48 48 use goingson_core::{EmailAccount, EmailAccountId, EmailAuthType, NewEmailAccount};
49 - use quasi_router::screen::{Act, Choice, Field, Row};
50 - use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
49 + use quasi_declare::declare;
50 + use quasi_router::screen::Choice;
51 + use quasi_router::{Response, RouteError, Router};
51 52
52 53 use crate::state::{AppState, DESKTOP_USER_ID};
53 54
54 55 #[cfg(test)]
55 56 mod tests;
56 57
58 + /// One auto-sync interval on offer.
59 + ///
60 + /// A struct rather than a pair, because a description names what it draws and
61 + /// `.1` is not a name.
62 + struct Interval {
63 + /// The minutes, as the field submits them. Empty means manual only.
64 + value: &'static str,
65 + label: &'static str,
66 + }
67 +
57 68 /// The intervals the JS offers, as its own `SYNC_INTERVAL_OPTIONS`.
58 - const SYNC_INTERVALS: [(&str, &str); 5] = [
59 - ("", "Manual only"),
60 - ("5", "Every 5 minutes"),
61 - ("15", "Every 15 minutes"),
62 - ("30", "Every 30 minutes"),
63 - ("60", "Hourly"),
69 + const SYNC_INTERVALS: [Interval; 5] = [
70 + Interval {
71 + value: "",
72 + label: "Manual only",
73 + },
74 + Interval {
75 + value: "5",
76 + label: "Every 5 minutes",
77 + },
78 + Interval {
79 + value: "15",
80 + label: "Every 15 minutes",
81 + },
82 + Interval {
83 + value: "30",
84 + label: "Every 30 minutes",
85 + },
86 + Interval {
87 + value: "60",
88 + label: "Hourly",
89 + },
64 90 ];
65 91
66 92 /// How an account authenticates, for the row that says so.
@@ -74,319 +100,348 @@
74 100 }
75 101 }
76 102
77 - /// One account.
78 - ///
79 - /// Edit is offered on a password account and withheld on an OAuth one, because
80 - /// the form below is the IMAP/SMTP form and an OAuth account has no servers,
81 - /// username or password to edit. Delete is offered on both: removing an account
82 - /// is the same act either way.
83 - fn row_for(account: &EmailAccount) -> Row {
84 - let mut row = Row::new(&account.account_name)
85 - .secondary(&account.email_address)
86 - .meta(auth_label(&account.auth_type));
103 + declare! {
104 + /// One account.
105 + ///
106 + /// Edit is offered on a password account and withheld on an OAuth one,
107 + /// because the form below is the IMAP/SMTP form and an OAuth account has no
108 + /// servers, username or password to edit. Delete is offered on both:
109 + /// removing an account is the same act either way.
110 + shape row_for(account: &EmailAccount) -> Row;
87 111
88 - if account.auth_type == EmailAuthType::Password {
89 - row = row.act(Act::new(
90 - "Edit",
91 - Action::get(format!("/settings/email/{}/edit", account.id)),
92 - ));
112 + row &account.account_name {
113 + secondary &account.email_address;
114 + meta auth_label(&account.auth_type);
115 +
116 + act "Edit" to get "/settings/email/{account.id}/edit"
117 + when account.auth_type is EmailAuthType::Password;
118 +
119 + act "Delete" to post "/settings/email/{account.id}/delete" {
120 + tone Danger;
121 + }
93 122 }
94 -
95 - row.act(
96 - Act::new(
97 - "Delete",
98 - Action::post(format!("/settings/email/{}/delete", account.id)),
99 - )
100 - .tone(makeover_layout::Tone::Danger),
101 - )
102 123 }
103 124
104 - /// The section's body: the accounts, and the way to add one.
105 - pub(super) fn pane(state: &AppState) -> Result<Vec<Node>, RouteError> {
106 - let accounts = state
125 + /// Every account this user has.
126 + ///
127 + /// The read is the parent screen's, which is what converting this section
128 + /// meant: a description says what is on the screen rather than fetching it.
129 + pub(super) fn accounts(state: &AppState) -> Result<Vec<EmailAccount>, RouteError> {
130 + state
107 131 .email_accounts
108 132 .list_by_user(DESKTOP_USER_ID)
109 - .map_err(|error| RouteError::internal(error.to_string()))?;
110 -
111 - let mut out = vec![Node::section("Email accounts")];
112 - if accounts.is_empty() {
113 - out.push(Node::empty("No accounts yet."));
114 - } else {
115 - out.push(Node::list(accounts.iter().map(row_for)));
116 - }
117 - out.push(Node::act(
118 - "Add an account".to_owned(),
119 - Action::get("/settings/email/new"),
120 - ));
121 - Ok(out)
133 + .map_err(|error| RouteError::internal(error.to_string()))
122 134 }
123 135
124 - /// The account form, for adding and for editing.
136 + declare! {
137 + /// The section's body: the accounts, and the way to add one.
138 + pub(super) shape pane(accounts: &[EmailAccount]) -> Vec<Node>;
139 +
140 + section "Email accounts";
141 +
142 + empty "No accounts yet." when accounts.is_empty();
143 +
144 + list {
145 + for account in accounts.iter() {
146 + include row_for(account);
147 + }
148 + } unless accounts.is_empty();
149 +
150 + act "Add an account" to get "/settings/email/new";
151 + }
152 +
153 + /// What the form is filling in, and what it is answering.
125 154 ///
126 - /// `existing` is the account being edited, or `None` for a new one. The two
127 - /// differ in exactly one place — the password field, which is required when
128 - /// there is no stored secret and optional when leaving it empty means keep the
129 - /// current one. `buildAccountFormHtml` differs them the same way and says so in
130 - /// the label.
131 - fn fields(
132 - existing: Option<&EmailAccount>,
155 + /// `existing` is the account being edited, or `None` for a new one. `advanced`
156 + /// is the disclosure's state, which is an address rather than a toggle so that a
157 + /// form reopened after a refusal is open to the same depth it was.
158 + struct Editing<'a> {
159 + existing: Option<&'a EmailAccount>,
133 160 advanced: bool,
134 - errors: &[(&str, String)],
135 - submitted: Option<&quasi_router::Params>,
136 - ) -> Vec<Field> {
137 - let error_for = |name: &str| {
138 - errors
139 - .iter()
140 - .find(|(field, _)| *field == name)
141 - .map(|(_, message)| message.clone())
142 - };
143 - let apply = |field: Field, name: &str| match error_for(name) {
144 - Some(message) => field.error(message),
145 - None => field,
146 - };
147 - // A refused submission beats the stored value, which beats the default.
148 - let value_of = |name: &str, stored: String| {
149 - submitted
150 - .and_then(|params| params.get(name).map(std::borrow::ToOwned::to_owned))
151 - .unwrap_or(stored)
152 - };
153 -
154 - let mut name = Field::new(
155 - makeover_layout::FieldKind::Text,
156 - "account_name",
157 - "Account Name",
158 - )
159 - .required()
160 - .value(value_of(
161 - "account_name",
162 - existing.map(|a| a.account_name.clone()).unwrap_or_default(),
163 - ));
164 - name.placeholder = Some("Personal, Work, etc.".to_owned());
165 -
166 - let mut address = Field::new(
167 - makeover_layout::FieldKind::Email,
168 - "email_address",
169 - "Email Address",
170 - )
171 - .required()
172 - .value(value_of(
173 - "email_address",
174 - existing
175 - .map(|a| a.email_address.clone())
176 - .unwrap_or_default(),
177 - ));
178 - address.placeholder = Some("you@example.com".to_owned());
179 -
180 - let mut username = Field::new(makeover_layout::FieldKind::Text, "username", "Username")
181 - .required()
182 - .value(value_of(
183 - "username",
184 - existing.map(|a| a.username.clone()).unwrap_or_default(),
185 - ));
186 - username.placeholder = Some("Usually your email address".to_owned());
187 -
188 - // Never `Text`. `FieldKind::Secret` is the kind whose contract is that the
189 - // value is not echoed or round-tripped, which is exactly what a password
190 - // typed into a form wants, and it is never given a `value` here: the stored
191 - // secret lives in the OS keychain and the form has no business carrying it
192 - // back out even when it could.
193 - let mut password = Field::new(makeover_layout::FieldKind::Secret, "password", {
194 - if existing.is_some() {
195 - "Password (leave empty to keep current)"
196 - } else {
197 - "Password"
198 - }
199 - });
200 - if existing.is_none() {
201 - password = password.required();
202 - }
203 - password.placeholder = Some(
204 - if existing.is_some() {
205 - "Enter new password or leave empty"
206 - } else {
207 - "your password"
208 - }
209 - .to_owned(),
210 - );
211 -
212 - let mut archive = Field::new(
213 - makeover_layout::FieldKind::Text,
214 - "archive_folder_name",
215 - "Archive Folder Name",
216 - )
217 - .hint("Gmail: [Gmail]/All Mail, Fastmail: Archive.")
218 - .value(value_of(
219 - "archive_folder_name",
220 - existing
221 - .and_then(|a| a.archive_folder_name.clone())
222 - .unwrap_or_else(|| "Archive".to_owned()),
223 - ));
224 - archive.placeholder = Some("Archive".to_owned());
225 -
226 - let mut signature = Field::new(
227 - makeover_layout::FieldKind::Textarea,
228 - "email_signature",
229 - "Email Signature",
230 - )
231 - .hint("Appended to outbound emails. Plain text only.")
232 - .value(value_of(
233 - "email_signature",
234 - existing
235 - .and_then(|a| a.email_signature.clone())
236 - .unwrap_or_default(),
237 - ));
238 - signature.placeholder = Some("-- \nYour Name".to_owned());
239 -
240 - let mut out = vec![
241 - apply(name, "account_name"),
242 - apply(address, "email_address"),
243 - apply(username, "username"),
244 - apply(password, "password"),
245 - apply(archive, "archive_folder_name"),
246 - apply(signature, "email_signature"),
247 - ];
248 -
249 - if !advanced {
250 - return out;
251 - }
252 -
253 - let mut imap = Field::new(
254 - makeover_layout::FieldKind::Text,
255 - "imap_server",
256 - "IMAP Server",
257 - )
258 - .required()
259 - .value(value_of(
260 - "imap_server",
261 - existing.map(|a| a.imap_server.clone()).unwrap_or_default(),
262 - ));
263 - imap.placeholder = Some("imap.example.com".to_owned());
264 -
265 - let mut smtp = Field::new(
266 - makeover_layout::FieldKind::Text,
267 - "smtp_server",
268 - "SMTP Server",
269 - )
270 - .required()
271 - .value(value_of(
272 - "smtp_server",
273 - existing.map(|a| a.smtp_server.clone()).unwrap_or_default(),
274 - ));
275 - smtp.placeholder = Some("smtp.example.com".to_owned());
276 -
277 - out.extend([
278 - apply(imap, "imap_server"),
279 - apply(
280 - Field::new(makeover_layout::FieldKind::Number, "imap_port", "IMAP Port")
281 - .required()
282 - .value(value_of(
283 - "imap_port",
284 - existing.map_or_else(|| "993".to_owned(), |a| a.imap_port.to_string()),
285 - )),
286 - "imap_port",
287 - ),
288 - apply(smtp, "smtp_server"),
289 - apply(
290 - Field::new(makeover_layout::FieldKind::Number, "smtp_port", "SMTP Port")
291 - .required()
292 - .value(value_of(
293 - "smtp_port",
294 - existing.map_or_else(|| "587".to_owned(), |a| a.smtp_port.to_string()),
295 - )),
296 - "smtp_port",
297 - ),
298 - apply(
299 - Field::new(
300 - makeover_layout::FieldKind::Checkbox,
301 - "notify_new_emails",
302 - "Notify on new emails",
303 - )
304 - .hint("A system notification when new mail arrives during auto-sync. Off by default.")
305 - .value(match existing {
306 - Some(account) if account.notify_new_emails => "1",
307 - _ => "",
308 - }),
309 - "notify_new_emails",
310 - ),
311 - apply(
312 - Field::select(
313 - "sync_interval_minutes",
314 - "Auto-sync Interval",
315 - SYNC_INTERVALS
316 - .iter()
317 - .map(|(value, label)| Choice::new(*value, *label))
318 - .collect(),
319 - )
320 - .hint("Check for new email at this interval.")
321 - .value(value_of(
322 - "sync_interval_minutes",
323 - existing
324 - .and_then(|a| a.sync_interval_minutes)
325 - .map_or_else(|| "15".to_owned(), |minutes| minutes.to_string()),
326 - )),
327 - "sync_interval_minutes",
328 - ),
329 - ]);
330 -
331 - out
161 + errors: &'a [(&'a str, String)],
162 + submitted: Option<&'a quasi_router::Params>,
332 163 }
333 164
334 - /// The form as a screen of its own, on the shape every other form here takes.
335 - fn form(
336 - existing: Option<&EmailAccount>,
337 - advanced: bool,
338 - errors: &[(&str, String)],
339 - submitted: Option<&quasi_router::Params>,
340 - ) -> Screen {
341 - let (title, action) = match existing {
342 - Some(account) => (
343 - format!("Edit {}", account.account_name),
344 - Action::post(format!("/settings/email/{}", account.id)),
345 - ),
346 - None => (
347 - "Add an email account".to_owned(),
348 - Action::post("/settings/email"),
349 - ),
350 - };
351 -
352 - // The disclosure is an address, so a form reopened after a refusal is open
353 - // to the same depth it was.
354 - let toggle = {
355 - let here = match existing {
356 - Some(account) => format!("/settings/email/{}/edit", account.id),
357 - None => "/settings/email/new".to_owned(),
358 - };
359 - let action = Action::get(here);
360 - if advanced {
361 - Node::act("Hide advanced settings".to_owned(), action)
362 - } else {
363 - Node::act(
364 - "Advanced settings".to_owned(),
365 - action.carrying("advanced", "1"),
366 - )
165 + impl<'a> Editing<'a> {
166 + /// A fresh form.
167 + ///
168 + /// Advanced opens by default on a new account, because the server fields
169 + /// are required and a form whose required fields are hidden cannot be
170 + /// submitted. The JS reaches the same state by a different route: it opens
171 + /// the block once autodetect has filled those fields in.
172 + const fn fresh() -> Self {
173 + Self {
174 + existing: None,
175 + advanced: true,
176 + errors: &[],
177 + submitted: None,
367 178 }
179 + }
180 +
181 + /// The form over an existing account.
182 + const fn of(account: &'a EmailAccount, advanced: bool) -> Self {
183 + Self {
184 + existing: Some(account),
185 + advanced,
186 + errors: &[],
187 + submitted: None,
188 + }
189 + }
190 + }
191 +
192 + /// Whether the form is editing rather than adding.
193 + fn is_edit(editing: &Editing) -> bool {
194 + editing.existing.is_some()
195 + }
196 +
197 + /// What each question falls back to when nothing has been submitted.
198 + ///
199 + /// One table by name rather than a default spelled at each field, which is the
200 + /// same argument the parent screen's `default_for` makes: `archive_folder_name`
201 + /// and the two ports had their fallbacks written where the field was built, and
202 + /// the field is not where a default belongs.
203 + fn stored(editing: &Editing, name: &str) -> String {
204 + let Some(account) = editing.existing else {
205 + return match name {
206 + "archive_folder_name" => "Archive".to_owned(),
207 + "imap_port" => "993".to_owned(),
208 + "smtp_port" => "587".to_owned(),
209 + "sync_interval_minutes" => "15".to_owned(),
210 + _ => String::new(),
211 + };
368 212 };
369 -
370 - let band = Slot::new("email-band", RegionKind::Band)
371 - .with(Node::page(title))
372 - .with(Node::act("Cancel", Action::get("/settings/email")));
373 -
374 - let pane = Slot::new("email-form", RegionKind::Pane)
375 - .with(Node::Form {
376 - action,
377 - submit: if existing.is_some() {
378 - "Save account".to_owned()
213 + match name {
214 + "account_name" => account.account_name.clone(),
215 + "email_address" => account.email_address.clone(),
216 + "username" => account.username.clone(),
217 + "archive_folder_name" => account
218 + .archive_folder_name
219 + .clone()
220 + .unwrap_or_else(|| "Archive".to_owned()),
221 + "email_signature" => account.email_signature.clone().unwrap_or_default(),
222 + "imap_server" => account.imap_server.clone(),
223 + "smtp_server" => account.smtp_server.clone(),
224 + "imap_port" => account.imap_port.to_string(),
225 + "smtp_port" => account.smtp_port.to_string(),
226 + "notify_new_emails" => {
227 + if account.notify_new_emails {
228 + "1".to_owned()
379 229 } else {
380 - "Add account".to_owned()
381 - },
382 - fields: fields(existing, advanced, errors, submitted),
383 - })
384 - .with(toggle);
230 + String::new()
231 + }
232 + }
233 + "sync_interval_minutes" => account
234 + .sync_interval_minutes
235 + .map_or_else(|| "15".to_owned(), |minutes| minutes.to_string()),
236 + _ => String::new(),
237 + }
238 + }
385 239
386 - Screen::list_detail("Email account", false)
387 - .at_place(crate::quasi::shell::SETTINGS)
388 - .with(band)
389 - .with(pane)
240 + /// What one question holds.
241 + ///
242 + /// A refused submission beats the stored value, which beats the default.
243 + fn value_of(editing: &Editing, name: &str) -> String {
244 + editing
245 + .submitted
246 + .and_then(|params| params.get(name).map(std::borrow::ToOwned::to_owned))
247 + .unwrap_or_else(|| stored(editing, name))
Lines truncated