Skip to main content

max / goingson

The event form: create and edit from the described calendar `8fdb814c`. `afdf6352` described the calendar and refused the write side, so event editing has been absent from the UI since `events.js` went. Both words it was waiting for landed in quasi this week and both are used here. GET /events/new the create form GET /events/{id}/edit the same form, filled from the event POST /events create POST /events/{id} save The form asks what `getEventFormFields` asked, in its order, with two differences worth naming: - The zone box is out on the anchored kind alone, said by the box rather than by 20 lines of `initTzKindConfig` wiring it up after the modal is built. It sits inside the form it submits with, which is what a question carrying its own condition buys over a section beside the form. - The reminders are one question answered zero to eight times, which is what `Event.reminder_offsets_seconds` has always been. A bad answer names its own slot, and `sanitize_reminder_offsets`'s cap is said on the screen instead of truncating a ninth answer on the way in. It asks one thing the JS form does not: the project. The JS carries a project only as a hidden field set from elsewhere, and a described form asking nothing would clear the project of every event edited from the calendar. THE WRITE GOES THROUGH THE COMMAND. `create_event_now` and `update_event_now` are the command bodies split out of their Tauri wrappers, the way `schedule_task_now` already was. All-day snapping, the four timezone columns and re-deriving the UTC pair from the civil truth are decided there, and a described form authoring its own copy would be a second answer to what a wall clock means. The wall clock the reader typed is passed through rather than derived back from the instant, which is what `_eventTz` is explicit about and the whole reason the civil columns exist. Two refusals stand and neither is this screen's to invent: the recurring scope question, and bulk selection. The rich recurrence rule the form cannot ask for is threaded through an edit rather than dropped. Also here, both found by this work rather than filed separately: the shell has linked `quasi-reveal.js` and `quasi-repeat.js` since quasi 0.59 and this app served neither, which is a 404 per document and a form whose conditional section never hides; and fourteen test helpers still matched `Outcome` without `Locate`, so `cargo test` had not built since quasi 0.57. Twenty tests.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-25 19:49 UTC
Commit: 475f61832b3e091515d171e44423c171b2a77001
Parent: b69e1c7
25 files changed, +1278 insertions, -76 deletions
M Cargo.lock +8 -8
@@ -4706,7 +4706,7 @@
4706 4706
4707 4707 [[package]]
4708 4708 name = "quasi-http"
4709 - version = "0.60.0"
4709 + version = "0.61.1"
4710 4710 dependencies = [
4711 4711 "form_urlencoded",
4712 4712 "http",
@@ -4715,7 +4715,7 @@
4715 4715
4716 4716 [[package]]
4717 4717 name = "quasi-notifs"
4718 - version = "0.60.0"
4718 + version = "0.61.1"
4719 4719 dependencies = [
4720 4720 "quasi-router",
4721 4721 "synckit-config",
@@ -4723,14 +4723,14 @@
4723 4723
4724 4724 [[package]]
4725 4725 name = "quasi-router"
4726 - version = "0.60.0"
4726 + version = "0.61.1"
4727 4727 dependencies = [
4728 4728 "makeover-layout",
4729 4729 ]
4730 4730
4731 4731 [[package]]
4732 4732 name = "quasi-tauri"
4733 - version = "0.60.0"
4733 + version = "0.61.1"
4734 4734 dependencies = [
4735 4735 "http",
4736 4736 "quasi-http",
@@ -4756,7 +4756,7 @@
4756 4756
4757 4757 [[package]]
4758 4758 name = "quasi-webview"
4759 - version = "0.60.0"
4759 + version = "0.61.1"
4760 4760 dependencies = [
4761 4761 "docengine",
4762 4762 "makeover-layout",
@@ -8499,15 +8499,15 @@
8499 8499
8500 8500 [[patch.unused]]
8501 8501 name = "quasi-axum"
8502 - version = "0.60.0"
8502 + version = "0.61.1"
8503 8503
8504 8504 [[patch.unused]]
8505 8505 name = "quasi-basics"
8506 - version = "0.60.0"
8506 + version = "0.61.1"
8507 8507
8508 8508 [[patch.unused]]
8509 8509 name = "quasi-immediate"
8510 - version = "0.60.0"
8510 + version = "0.61.1"
8511 8511
8512 8512 [[patch.unused]]
8513 8513 name = "quasi-store"
@@ -36,7 +36,7 @@
36 36 makeover-geometry = "0.7"
37 37 # Only to write its selection and clock scripts into the frontend. Those two are
38 38 # the whole of `frontend/js/` since the swap.
39 - quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.60" }
39 + quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.61" }
40 40
41 41 [dependencies]
42 42 goingson-core = { workspace = true }
@@ -54,10 +54,10 @@
54 54 # dependency's source even with the feature off -- so a public clone could not
55 55 # build at all against a URL it could not reach. quasi went public that day and
56 56 # the URL moved here with it.
57 - quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.60" }
58 - quasi-http = { git = "https://makenot.work/git/max/quasi.git", version = "0.60" }
59 - quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.60" }
60 - quasi-tauri = { git = "https://makenot.work/git/max/quasi.git", version = "0.60" }
57 + quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.61" }
58 + quasi-http = { git = "https://makenot.work/git/max/quasi.git", version = "0.61" }
59 + quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.61" }
60 + quasi-tauri = { git = "https://makenot.work/git/max/quasi.git", version = "0.61" }
61 61
62 62 # The notification declaration, and it is NOT behind the `quasi` feature: the
63 63 # watcher is desktop app behaviour rather than a described screen, and the
@@ -74,7 +74,7 @@
74 74 # before it has ported its screens cannot take it. `notifications::Notifier` is
75 75 # the eight lines meanwhile, and it goes when the flip makes quasi-router
76 76 # unconditional here.
77 - quasi-notifs = { git = "https://makenot.work/git/max/quasi.git", version = "0.60", features = ["synckit", "describe"] }
77 + quasi-notifs = { git = "https://makenot.work/git/max/quasi.git", version = "0.61", features = ["synckit", "describe"] }
78 78
79 79 # Tauri
80 80 tauri = { workspace = true, features = ["image-png", "devtools"] }
@@ -612,6 +612,8 @@
612 612 ("quasi-clock.js", quasi_webview::CLOCK_JS),
613 613 ("quasi-download.js", quasi_webview::DOWNLOAD_JS),
614 614 ("quasi-fill.js", quasi_webview::FILL_JS),
615 + ("quasi-reveal.js", quasi_webview::REVEAL_JS),
616 + ("quasi-repeat.js", quasi_webview::REPEAT_JS),
615 617 ] {
616 618 fs::write(frontend.join("js").join(name), source)
617 619 .unwrap_or_else(|error| panic!("write js/{name}: {error}"));
@@ -449,6 +449,20 @@
449 449 state: State<'_, Arc<AppState>>,
450 450 input: EventInput,
451 451 ) -> Result<EventResponse, ApiError> {
452 + Ok(EventResponse::from(create_event_now(&state, input)?))
453 + }
454 +
455 + /// The create write, without the command wrapper around it.
456 + ///
457 + /// Split out so `quasi::events` can create an event without a second copy of
458 + /// the part that is easy to get wrong: the all-day snapping, the four timezone
459 + /// columns, and re-deriving the UTC pair from the civil truth. A described form
460 + /// reimplementing those would be two answers to what a wall clock means.
461 + ///
462 + /// Synchronous, which is what lets a route handler call it: nothing in here
463 + /// awaits, and the `async` above is the Tauri boundary rather than anything
464 + /// this does.
465 + pub(crate) fn create_event_now(state: &AppState, input: EventInput) -> Result<Event, ApiError> {
452 466 if input.title.trim().is_empty() {
453 467 return Err(ApiError::validation("title", "Title is required"));
454 468 }
@@ -502,8 +516,10 @@
502 516 new_event.materialize_in(system_tz());
503 517 new_event.validate()?;
504 518
505 - let event = state.events.create(DESKTOP_USER_ID, new_event)?;
506 - Ok(EventResponse::from(event))
519 + state
520 + .events
521 + .create(DESKTOP_USER_ID, new_event)
522 + .map_err(Into::into)
507 523 }
508 524
509 525 /// Updates an existing calendar event.
@@ -522,6 +538,20 @@
522 538 id: EventId,
523 539 input: EventInput,
524 540 ) -> Result<EventResponse, ApiError> {
541 + Ok(EventResponse::from(update_event_now(&state, id, input)?))
542 + }
543 +
544 + /// The update write, without the command wrapper around it.
545 + ///
546 + /// [`create_event_now`]'s counterpart and split out for the same reason, with
547 + /// one more fact worth keeping in one place: what the existing row carries and
548 + /// the form does not ask for, `linked_task_id` and a block type nobody sent, is
549 + /// read off the stored event here rather than by whoever is calling.
550 + pub(crate) fn update_event_now(
551 + state: &AppState,
552 + id: EventId,
553 + input: EventInput,
554 + ) -> Result<Event, ApiError> {
525 555 // Get existing event to preserve linked_task_id
526 556 let existing = state
527 557 .events
@@ -564,12 +594,10 @@
564 594 update_event.materialize_in(system_tz());
565 595 update_event.validate()?;
566 596
567 - let event = state
597 + state
568 598 .events
569 599 .update(id, DESKTOP_USER_ID, update_event)?
570 - .or_not_found("event", id)?;
571 -
572 - Ok(EventResponse::from(event))
600 + .or_not_found("event", id)
573 601 }
574 602
575 603 /// Deletes a calendar event.
@@ -27,7 +27,7 @@
27 27 mod email_account;
28 28 mod email_sync;
29 29 pub mod error;
30 - mod event;
30 + pub(crate) mod event;
31 31 pub(crate) mod export;
32 32 mod form;
33 33 pub(crate) mod group;
@@ -59,6 +59,8 @@
59 59 "/static/host.js" => (JS, include_bytes!("../../frontend/js/host.js")),
60 60 "/static/quasi-download.js" => (JS, include_bytes!("../../frontend/js/quasi-download.js")),
61 61 "/static/quasi-fill.js" => (JS, include_bytes!("../../frontend/js/quasi-fill.js")),
62 + "/static/quasi-reveal.js" => (JS, include_bytes!("../../frontend/js/quasi-reveal.js")),
63 + "/static/quasi-repeat.js" => (JS, include_bytes!("../../frontend/js/quasi-repeat.js")),
62 64
63 65 // `/static/../fonts/X` off typography.css, which the URL parser has
64 66 // already resolved by the time it reaches here.
@@ -103,6 +105,8 @@
103 105 "/static/quasi-clock.js",
104 106 "/static/quasi-download.js",
105 107 "/static/quasi-fill.js",
108 + "/static/quasi-reveal.js",
109 + "/static/quasi-repeat.js",
106 110 "/fonts/QuasiMono.woff2",
107 111 "/fonts/QuasiBody.woff2",
108 112 "/fonts/Reglo-Bold.woff2",
@@ -42,44 +42,54 @@
42 42 //! repository read as equivalent would show snoozed events on every visit. See
43 43 //! [`sections`].
44 44 //!
45 - //! # What this port could not say, and did not fake
45 + //! # The form, and the two words it waited for
46 46 //!
47 - //! The read side is complete. The write side is one route, delete, and the rest
48 - //! is left out rather than described badly. Four things refused, and none of
49 - //! them is new — each is a vocabulary gap already filed from another screen:
47 + //! `8fdb814c`. The read side shipped first and refused the write side, because
48 + //! two things about `events.js`'s form had no description. Both are vocabulary
49 + //! now and both are used here:
50 50 //!
51 - //! 1. **The event form's timezone block.** `initTzKindConfig` shows and hides
52 - //! the zone picker depending on which of the three `TzKind` values is
53 - //! selected. `FieldKind` has no conditionally-present field, which is the
54 - //! same gap `quasi::tasks::edit_fields` records against the recurrence rule.
55 - //! A form that always showed the picker would be describing a different
56 - //! screen.
57 - //! 2. **Reminders.** `collectReminderOffsets` reads a variable number of offsets
58 - //! off the form. A field holds one value or one choice, so a repeating group
59 - //! has nothing to be.
60 - //! 3. **The recurring scope question.** `confirmRecurringScope` asks "this
61 - //! occurrence or the whole series?" before a delete or an edit lands. That is
62 - //! a write that pauses for an answer, and the delete route here refuses a
63 - //! template rather than guessing which the user meant.
64 - //! 4. **Bulk selection.** `toggleEventSelection` and `bulkDeleteEvents` act on a
65 - //! set gathered across rows. `Row::selectable` draws the tick, and nothing
66 - //! carries the set to a write.
51 + //! 1. **The timezone block.** `initTzKindConfig` shows the "Anchored to" box on
52 + //! one of the three `TzKind` values and hides it on the other two.
53 + //! [`quasi_router::Field::revealed_by`] is that, and the box sits in the
54 + //! form it submits with rather than in a section beside it.
55 + //! 2. **Reminders.** `collectReminderOffsets` reads a variable number of
56 + //! offsets off the form, and `Event::reminder_offsets_seconds` has always
57 + //! been a `Vec<i64>`. [`quasi_router::Repeat`] is that: one question, N
58 + //! slots, one submit, and `sanitize_reminder_offsets`'s cap of eight said on
59 + //! the screen rather than applied silently on the way in.
67 60 //!
68 - //! Recording them here rather than filing four new tasks: they are one gap seen
69 - //! four times, they are already filed against the screens that found them
70 - //! first, and this port is evidence of how often they recur rather than a new
71 - //! finding.
61 + //! Two of the four refusals stand, and neither is this screen's to invent:
62 + //!
63 + //! - **The recurring scope question.** `confirmRecurringScope` asks "this
64 + //! occurrence or the whole series?" before a delete or an edit lands. That is
65 + //! a write that pauses for an answer, and the delete route here refuses a
66 + //! template rather than guessing which the user meant. The form threads a
67 + //! stored rule through untouched for the same reason.
68 + //! - **Bulk selection.** `toggleEventSelection` and `bulkDeleteEvents` act on a
69 + //! set gathered across rows. `Row::selectable` draws the tick, and nothing
70 + //! carries the set to a write.
71 + //!
72 + //! # The write goes through the command, not around it
73 + //!
74 + //! [`crate::commands::event::create_event_now`] and its update counterpart are
75 + //! what the routes here call. All-day snapping, the four timezone columns and
76 + //! re-deriving the UTC pair from the civil truth are decided there, and a
77 + //! described form authoring its own copy of them would be a second answer to
78 + //! what a wall clock means. It is the same instruction the read side records
79 + //! about `list_events`: look at what the command does before assuming the
80 + //! layer underneath it is the same thing.
72 81
73 82 // Handlers take their request by value because `quasi_router::Handler` is a
74 83 // plain `fn(&S, Request)` pointer, so the signature is the router's and not a
75 84 // choice made here.
76 85 #![allow(clippy::needless_pass_by_value)]
77 86
78 - use chrono::Utc;
79 - use goingson_core::{Event, EventId};
80 - use quasi_router::screen::{Row, Tag};
81 - use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
87 + use chrono::{Local, NaiveDateTime, Utc};
88 + use goingson_core::{BlockType, DbValue, Event, EventId, Recurrence, TzKind, parse_natural_date};
89 + use quasi_router::screen::{Choice, Field, Repeat, Row, Tag};
90 + use quasi_router::{Action, Node, RegionKind, Response, Reveal, RouteError, Router, Screen, Slot};
82 91
92 + use crate::commands::event::{EventInput, create_event_now, update_event_now};
83 93 use crate::state::{AppState, DESKTOP_USER_ID};
84 94
85 95 #[cfg(test)]
@@ -218,10 +228,14 @@
218 228
219 229 /// The whole screen.
220 230 fn index(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
221 - let snoozed = snoozed_on(&request);
231 + Ok(screen(state, snoozed_on(&request))?.into())
232 + }
222 233
234 + /// The screen under one filter, which a read and a write both answer with.
235 + fn screen(state: &AppState, snoozed: bool) -> Result<Screen, RouteError> {
223 236 let band = Slot::new("events-band", RegionKind::Band)
224 237 .with(Node::page("Events"))
238 + .with(Node::act("New event", Action::get("/events/new")))
225 239 .with(Node::Token(
226 240 Tag::chip("Snoozed", list_action(!snoozed)).latched(snoozed),
227 241 ));
@@ -235,8 +249,7 @@
235 249 .at_place(super::shell::EVENTS)
236 250 .with(band)
237 251 .with(pane)
238 - .with(Slot::new("events-detail", RegionKind::Pane).with(Node::empty("Nothing selected")))
239 - .into())
252 + .with(Slot::new("events-detail", RegionKind::Pane).with(Node::empty("Nothing selected"))))
240 253 }
241 254
242 255 /// The list alone, which is what the filter and a delete swap.
@@ -313,6 +326,11 @@
313 326 // Delete is offered on an occurrence and withheld on a template, which is
314 327 // the scope question the JS asks with a dialog. Refusing is not the answer
315 328 // it should end at; it is the honest state until a write can pause for one.
329 + nodes.push(Node::act(
330 + "Edit".to_owned(),
331 + Action::get(format!("/events/{}/edit", event.id)),
332 + ));
333 +
316 334 if is_template(&event) {
317 335 nodes.push(Node::empty(
318 336 "This is a recurring rule. Deleting it needs the scope question, which nothing describes yet.",
@@ -356,14 +374,735 @@
356 374 list(state, request)
357 375 }
358 376
377 + // The form. `8fdb814c`.
378 +
379 + /// How an event's time relates to a zone, labelled as `events.js` labels it:
380 + /// for what the choice does rather than for the stored word, because "relative
381 + /// vs local vs absolute" means nothing standing in front of a calendar.
382 + const TZ_KINDS: [(&str, &str); 3] = [
383 + ("relative", "Relative to me (follows me when I travel)"),
384 + ("local", "Anchored to a place"),
385 + ("absolute", "Fixed point in time"),
386 + ];
387 +
388 + /// The block types the shipped select offers, empty first for an ordinary
389 + /// event. The empty option is what clears one, which is why it is a real
390 + /// member here rather than an absent value.
391 + const BLOCK_TYPES: [(&str, &str); 5] = [
392 + ("", "Regular Event"),
393 + ("free_time", "Free Time"),
394 + ("personal", "Personal"),
395 + ("vacation", "Vacation"),
396 + ("focus", "Focus"),
397 + ];
398 +
399 + /// The four patterns the recurrence select offers.
400 + const RECURRENCES: [&str; 4] = ["None", "Daily", "Weekly", "Monthly"];
401 +
402 + /// The most reminders one event may carry.
403 + ///
404 + /// `sanitize_reminder_offsets`'s cap, said on the screen. The write path
405 + /// truncates silently, so a form that did not say it would take a ninth
406 + /// reminder and throw it away without telling anyone.
407 + const MOST_REMINDERS: usize = 8;
408 +
409 + /// The shape a datetime box offers and accepts.
410 + ///
411 + /// `getEventFormFields` builds the same string out of a `Date`, and
412 + /// [`parse_natural_date`] reads it back, so the value the form offers is a
413 + /// value the form accepts. A prefill the parser would reject is a field that
414 + /// cannot be left alone.
415 + const TYPED_TIME: &str = "%Y-%m-%dT%H:%M";
416 +
417 + /// Why a submission was refused, in the three shapes a form can say it.
418 + #[derive(Debug, Default)]
419 + struct Refusal {
420 + /// What is wrong with one question, by field name.
421 + fields: Vec<(&'static str, String)>,
422 + /// What is wrong with one reminder, by slot.
423 + ///
424 + /// A repeating question needs this and [`fields`](Self::fields) cannot
425 + /// carry it: one string on one field can say the set is wrong and never
426 + /// which answer is.
427 + slots: Vec<(usize, String)>,
428 + /// A refusal from the write itself that names no question of this form.
429 + ///
430 + /// Mapped where it can be and shown whole where it cannot, rather than
431 + /// hung on whichever field is nearest: a message pinned to the wrong box
432 + /// is worse than a message above the form.
433 + notice: Option<String>,
434 + }
435 +
436 + impl Refusal {
437 + /// Whether anything is wrong.
438 + fn any(&self) -> bool {
439 + !self.fields.is_empty() || !self.slots.is_empty() || self.notice.is_some()
440 + }
441 +
442 + /// What is wrong with one question, if anything is.
443 + fn about(&self, name: &str) -> Option<String> {
444 + self.fields
445 + .iter()
446 + .find(|(field, _)| *field == name)
447 + .map(|(_, message)| message.clone())
448 + }
449 + }
450 +
451 + /// The value a datetime box should show for a start or an end.
452 + ///
453 + /// A civil-kind event's stored wall clock is used verbatim. An anchored
454 + /// event's instant means 10:00 *there*, and rendering it through this
455 + /// machine's zone would show the reader's offset instead of the time the user
456 + /// set. The instant, converted to local, is the fallback and is the whole
457 + /// answer for a fixed event. `prefillTime` makes the same three choices.
458 + fn typed_time(
459 + kind: TzKind,
460 + civil: Option<NaiveDateTime>,
461 + instant: Option<chrono::DateTime<Utc>>,
462 + ) -> String {
463 + if kind.is_civil()
464 + && let Some(civil) = civil
465 + {
466 + return civil.format(TYPED_TIME).to_string();
467 + }
468 + instant.map_or_else(String::new, |instant| {
469 + instant.with_timezone(&Local).format(TYPED_TIME).to_string()
470 + })
471 + }
472 +
473 + /// The reminders standing in the form, as strings under one question.
474 + ///
475 + /// From the submission when there is one, so a refusal hands back the slots
476 + /// the reader had rather than the ones the record holds; from the event
477 + /// otherwise. Seconds either way, which is the column's own unit.
478 + fn reminder_slots(event: Option<&Event>, submitted: Option<&quasi_router::Params>) -> Vec<String> {
479 + match submitted {
480 + Some(params) => params
481 + .repeated("reminder")
482 + .into_iter()
483 + .map(ToOwned::to_owned)
484 + .collect(),
485 + None => event.map_or_else(Vec::new, |event| {
486 + event
487 + .reminder_offsets_seconds
488 + .iter()
489 + .map(i64::to_string)
490 + .collect()
491 + }),
492 + }
493 + }
494 +
495 + /// The questions the form asks, in `getEventFormFields`'s order.
496 + ///
497 + /// # What it asks that the JS form does not
498 + ///
499 + /// **The project.** `getEventFormFields` carries a project only as a hidden
500 + /// field, set by `openNewForProject` and absent from the form reached from the
501 + /// calendar. A described form with no project question would be worse than
502 + /// that rather than equal to it: the update write takes the project from the
503 + /// submission, so a form that never asked would clear the project of every
504 + /// event edited from here. A select that offers "No Project" says the same
505 + /// thing without losing anything.
506 + ///
507 + /// # What it does not ask, and why
508 + ///
509 + /// **The recurrence rule.** The select offers the four patterns and stops
510 + /// there, which is exactly where `quasi::tasks::edit_fields` stops and for the
511 + /// same reason: `buildRecurrenceConfigHtml` grows a second form underneath it
512 + /// whose shape changes with the pattern, and a multi-select is still missing
513 + /// from `FieldKind`. The consequence is handled rather than ignored:
514 + /// [`answers`] threads the stored [`Event::recurrence_rule`] through
515 + /// untouched, so editing the title of an event that repeats on Tuesdays leaves
516 + /// it repeating on Tuesdays.
517 + fn form_fields(
518 + state: &AppState,
519 + event: Option<&Event>,
520 + refusal: &Refusal,
521 + submitted: Option<&quasi_router::Params>,
522 + ) -> Result<Vec<Field>, RouteError> {
523 + let apply = |field: Field, name: &str| match refusal.about(name) {
524 + Some(message) => field.error(message),
525 + None => field,
526 + };
527 + let projects = state
528 + .projects
529 + .list_all(DESKTOP_USER_ID)
530 + .map_err(|error| RouteError::internal(error.to_string()))?;
531 + let contacts = state
532 + .contacts
533 + .list_all(DESKTOP_USER_ID)
534 + .map_err(|error| RouteError::internal(error.to_string()))?;
535 +
536 + let kind = event.map_or(TzKind::Relative, |event| event.tz_kind);
537 + let all_day = event.is_some_and(|event| event.is_all_day_in(&Local));
538 +
539 + let mut all_day_field = Field::new(
540 + makeover_layout::FieldKind::Checkbox,
541 + "is_all_day",
542 + "All day",
543 + )
544 + .hint("Removes the time component. The event spans the whole day.");
545 + if all_day {
546 + all_day_field = all_day_field.value(Node::SELECTED);
547 + }
548 +
549 + let mut title = Field::new(makeover_layout::FieldKind::Text, "title", "Title")
550 + .required()
551 + .value(event.map_or("", |event| event.title.as_str()));
552 + title.placeholder = Some("Event title".to_owned());
553 +
554 + let mut description = Field::new(
555 + makeover_layout::FieldKind::Textarea,
556 + "description",
557 + "Description",
558 + )
559 + .value(event.map_or("", |event| event.description.as_str()));
560 + description.placeholder = Some("Event details...".to_owned());
561 +
562 + // Opens on now for a new event, which is what `getEventFormFields` fills
563 + // the box with: a calendar form with an empty start is a chore, and the
564 + // common case is something soon.
565 + let start_value = event.map_or_else(
566 + || Local::now().format(TYPED_TIME).to_string(),
567 + |event| typed_time(event.tz_kind, event.start_local, Some(event.start_time)),
568 + );
569 + let mut start = Field::new(
570 + makeover_layout::FieldKind::Text,
571 + "start_time",
572 + "Start Date & Time",
573 + )
574 + .required()
575 + .value(start_value);
576 + start.placeholder = Some("tomorrow 3pm, friday 10:00, 2026-12-25...".to_owned());
577 +
578 + let mut end = Field::new(
579 + makeover_layout::FieldKind::Text,
580 + "end_time",
581 + "End Time (optional)",
582 + )
583 + .value(event.map_or_else(String::new, |event| {
584 + typed_time(event.tz_kind, event.end_local, event.end_time)
585 + }));
586 + end.placeholder = Some("tomorrow 5pm, friday 12:00...".to_owned());
587 +
588 + let mut location = Field::new(makeover_layout::FieldKind::Text, "location", "Location")
589 + .value(event.map_or("", Event::location_or_empty));
590 + location.placeholder = Some("Zoom / Office / Coffee Shop".to_owned());
591 +
592 + // The zone box, out on one of the three kinds. `initTzKindConfig` is 20
593 + // lines of DOM wiring that this one member replaces, and every renderer
594 + // answers it without asking a route.
595 + let mut zone = Field::new(makeover_layout::FieldKind::Text, "timezone", "Anchored to")
596 + .hint("IANA zone name, e.g. America/Denver.")
597 + .revealed_by(Reveal::holding("tz_kind", TzKind::Local.db_value()))
598 + .value(
599 + event
600 + .and_then(|event| event.timezone.clone())
601 + .unwrap_or_default(),
602 + );
603 + zone.placeholder = Some(goingson_core::tz::system_tz().name().to_owned());
604 +
605 + let mut project_options = vec![Choice::new("", "No Project")];
606 + project_options.extend(
607 + projects
608 + .iter()
609 + .map(|project| Choice::new(project.id.to_string(), &project.name)),
610 + );
611 +
612 + let mut contact_options = vec![Choice::new("", "No Contact")];
613 + contact_options.extend(
614 + contacts
615 + .iter()
616 + .map(|contact| Choice::new(contact.id.to_string(), &contact.display_name)),
617 + );
618 +
619 + // The reminders, as one question answered zero or more times. The ceiling
620 + // is `sanitize_reminder_offsets`'s and is stated rather than applied
621 + // silently; a message about the set rides on the question, and a message
622 + // about one answer rides on its own slot.
623 + let mut standing = Repeat::answered(reminder_slots(event, submitted))
624 + .most(MOST_REMINDERS)
625 + .adding("Add reminder")
626 + .removing("Remove");
627 + for (at, message) in &refusal.slots {
628 + standing = standing.wrong(*at, message.clone());
629 + }
630 + let mut reminders = Field::new(makeover_layout::FieldKind::Number, "reminder", "Reminder")
631 + .hint("Seconds before the event starts: 300 is five minutes, 3600 an hour.")
632 + .unit("s")
633 + .repeating(standing);
634 + reminders.min = Some("0".to_owned());
635 +
636 + let fields = vec![
637 + all_day_field,
638 + apply(title, "title"),
639 + apply(description, "description"),
640 + apply(start, "start_time"),
641 + apply(end, "end_time"),
642 + apply(location, "location"),
643 + apply(
644 + Field::select(
645 + "recurrence",
646 + "Recurrence",
647 + RECURRENCES
648 + .iter()
649 + .map(|pattern| Choice::new(*pattern, *pattern))
650 + .collect(),
651 + )
652 + .hint("Recurring events appear automatically on matching days")
653 + .value(event.map_or(Recurrence::None.db_value(), |event| {
654 + event.recurrence.db_value()
655 + })),
656 + "recurrence",
657 + ),
658 + apply(
659 + Field::select(
660 + "tz_kind",
661 + "Time zone",
662 + TZ_KINDS
663 + .iter()
664 + .map(|(value, label)| Choice::new(*value, *label))
665 + .collect(),
666 + )
667 + .hint("Relative follows you when you travel. Anchored stays put. Fixed is a moment in time.")
668 + .value(kind.db_value()),
669 + "tz_kind",
670 + ),
671 + apply(zone, "timezone"),
672 + apply(
673 + Field::select(
674 + "block_type",
675 + "Type",
676 + BLOCK_TYPES
677 + .iter()
678 + .map(|(value, label)| Choice::new(*value, *label))
679 + .collect(),
680 + )
681 + .value(
682 + event
683 + .and_then(|event| event.block_type.as_ref())
684 + .map_or("", DbValue::db_value),
685 + ),
686 + "block_type",
687 + ),
688 + apply(
689 + Field::select("contact_id", "Contact", contact_options).value(
690 + event
691 + .and_then(|event| event.contact_id)
692 + .map(|id| id.to_string())
693 + .unwrap_or_default(),
694 + ),
695 + "contact_id",
696 + ),
697 + apply(
698 + Field::select("project_id", "Project", project_options).value(
699 + event
700 + .and_then(|event| event.project_id)
701 + .map(|id| id.to_string())
702 + .unwrap_or_default(),
703 + ),
704 + "project_id",
705 + ),
706 + apply(reminders, "reminder"),
707 + ];
708 +
709 + // Everything the reader typed, back in the boxes it was typed into. The
710 + // reminders are already the submitted slots, and `refilled` leaves a
711 + // repeating question alone: what it re-offers is one value under one name,
712 + // and a question answered N times has neither.
713 + Ok(match submitted {
714 + Some(params) => fields
715 + .into_iter()
716 + .map(|field| field.refilled(params))
717 + .collect(),
718 + None => fields,
719 + })
720 + }
721 +
722 + /// The form, in the pane the detail pane uses.
723 + ///
724 + /// Both create and edit are this: the same questions, a different address and
725 + /// a different submit label. An event being edited is the only thing that
726 + /// differs, and it is what fills the boxes.
727 + fn form_pane(
728 + state: &AppState,
729 + event: Option<&Event>,
730 + refusal: &Refusal,
731 + submitted: Option<&quasi_router::Params>,
732 + ) -> Result<Node, RouteError> {
733 + let (heading, action, submit) = match event {
734 + Some(event) => (
735 + format!("Edit {}", event.title),
736 + Action::post(format!("/events/{}", event.id)),
737 + "Save event",
738 + ),
739 + None => (
740 + "New event".to_owned(),
741 + Action::post("/events"),
742 + "Create event",
743 + ),
744 + };
745 +
746 + let mut region = Slot::new("events-detail", RegionKind::Pane).with(Node::section(heading));
747 + if let Some(notice) = &refusal.notice {
748 + region = region.with(Node::Text {
749 + text: notice.clone(),
750 + tone: makeover_layout::Tone::Danger,
751 + });
752 + }
753 + Ok(Node::Region(region.with(Node::Form {
754 + action,
755 + submit: submit.to_owned(),
756 + fields: form_fields(state, event, refusal, submitted)?,
Lines truncated
@@ -295,6 +295,33 @@
295 295 }
296 296 }
297 297
298 + /// An id a select offers as an option, where the empty option means none.
299 + ///
300 + /// Here rather than in one screen because three forms ask it: a task's project,
301 + /// contact and milestone, and an event's project and contact.
302 + ///
303 + /// Two layers of `Option` and both mean something: the outer is whether the
304 + /// value parsed, the inner is whether one was chosen. Flattening them here
305 + /// would make a typo indistinguishable from "No Project", which is the one
306 + /// pair of outcomes this function exists to keep apart.
307 + #[allow(clippy::option_option)]
308 + pub(crate) fn parse_optional_id<T: From<uuid::Uuid>>(
309 + params: &quasi_router::Params,
310 + name: &'static str,
311 + errors: &mut Vec<(&'static str, String)>,
312 + ) -> Option<Option<T>> {
313 + match params.get(name).unwrap_or_default().trim() {
314 + "" => Some(None),
315 + raw => match uuid::Uuid::parse_str(raw) {
316 + Ok(id) => Some(Some(T::from(id))),
317 + Err(_) => {
318 + errors.push((name, "Not one of the options offered.".to_owned()));
319 + None
320 + }
321 + },
322 + }
323 + }
324 +
298 325 /// Every described screen's routes.
299 326 #[must_use]
300 327 pub fn router() -> Router<AppState> {