Skip to main content

max / goingson

44.6 KB · 1270 lines History Blame Raw
1 //! The calendar, described rather than built.
2 //!
3 //! <!-- wiki: quasi-overview -->
4 //!
5 //! # Three lists rather than one
6 //!
7 //! The three sections answer different questions:
8 //!
9 //! - **Recurring** shows *templates*: the rule itself, not its occurrences, so
10 //! a weekly meeting does not bury the rule under fifty instances.
11 //! `is_template` is computed in Rust already (`recurrence != None &&
12 //! !is_recurring_instance`), so this reads it rather than deriving it again.
13 //! - **Upcoming** is everything ahead that is not a template.
14 //! - **Past** is everything behind, newest first: the interesting end of the
15 //! past is the recent end.
16 //!
17 //! # The shape
18 //!
19 //! - `GET /events` — the document, all three sections.
20 //! - `GET /events/list` — the sections alone, which is what the snoozed filter
21 //! swaps.
22 //! - `GET /events/{id}` — the detail pane.
23 //! - `POST /events/{id}/delete` — delete one, and answer with the list.
24 //!
25 //! The snoozed filter is `?snoozed=1`, per decision 2: a view a user is looking
26 //! at should be a view they can link to.
27 //!
28 //! Watch the layer it reads from. The `list_events` command excludes snoozed
29 //! rows; the repository's `list_all` underneath it does not, filtering only
30 //! events converted into contexts. Reading the repository as equivalent would
31 //! show snoozed events on every visit. See [`sections`].
32 //!
33 //! # The form
34 //!
35 //! Two members carry it:
36 //!
37 //! 1. **The timezone block.** [`quasi_router::Field::revealed_by`] shows the
38 //! "Anchored to" box on one of the three `TzKind` values and hides it on the
39 //! other two, and the box sits in the form it submits with rather than in a
40 //! section beside it.
41 //! 2. **Reminders.** [`quasi_router::Repeat`] is one question, N slots, one
42 //! submit, matching `Event::reminder_offsets_seconds`.
43 //! `sanitize_reminder_offsets`'s cap of eight is said on the screen rather
44 //! than applied silently on the way in.
45 //!
46 //! Two things this screen refuses, and neither is its to invent:
47 //!
48 //! - **The recurring scope question.** "This occurrence or the whole series?"
49 //! is a write that pauses for an answer, so the delete route refuses a
50 //! template rather than guessing which the user meant. The form threads a
51 //! stored rule through untouched for the same reason.
52 //! - **Bulk selection.** `Row::selectable` draws the tick, and nothing carries
53 //! the set to a write.
54 //!
55 //! # The write goes through the command, not around it
56 //!
57 //! [`crate::commands::event::create_event_now`] and its update counterpart are
58 //! what the routes here call. All-day snapping, the four timezone columns and
59 //! re-deriving the UTC pair from the civil truth are decided there, and a
60 //! described form authoring its own copy of them would be a second answer to
61 //! what a wall clock means. Look at what the command does before assuming the
62 //! layer underneath it is the same thing.
63
64 // Handlers take their request by value because `quasi_router::Handler` is a
65 // plain `fn(&S, Request)` pointer, so the signature is the router's and not a
66 // choice made here.
67 #![allow(clippy::needless_pass_by_value)]
68
69 use chrono::{Local, NaiveDateTime, Utc};
70 use goingson_core::{BlockType, DbValue, Event, EventId, Recurrence, TzKind, parse_natural_date};
71 use makeover_layout::Tone;
72 use quasi_declare::declare;
73 use quasi_router::screen::{Choice, Repeat, Tag};
74 use quasi_router::{Action, Node, Response, Reveal, RouteError, Router};
75
76 use crate::commands::event::{EventInput, create_event_now, update_event_now};
77 use crate::state::{AppState, DESKTOP_USER_ID};
78
79 #[cfg(test)]
80 mod tests;
81
82 /// What a row leads with.
83 ///
84 /// A template's date cell is its pattern, not a start date. `events.js` swaps
85 /// the same cell for the same reason: the arbitrary date a weekly rule happens
86 /// to start on tells the reader nothing about the rule.
87 fn lead(event: &Event, recurring: bool) -> String {
88 if recurring {
89 event.effective_recurrence_rule().map_or_else(
90 || event.recurrence.as_str().to_owned(),
91 |rule| rule.display(),
92 )
93 } else {
94 event.date_formatted()
95 }
96 }
97
98 declare! {
99 /// One event, as a row in one of the three sections.
100 ///
101 /// The shipped table has five columns (date, time, title, location,
102 /// actions) and this is the same five facts in a row's slots. The kebab menu
103 /// is not one of them: `contextMenus.showEvent` offers open, edit, snooze
104 /// and delete, and three of those four have nowhere to go yet (see the
105 /// module header), so the row carries the one that does rather than a menu
106 /// that is mostly disabled.
107 ///
108 /// A time block is marked as one. The JS draws no marker and the row reads
109 /// as an ordinary event; the fact is in the data and worth one token, since
110 /// deleting a block and deleting a meeting are different acts.
111 shape row_for(event: &Event, recurring: bool) -> Row;
112
113 row &event.title {
114 secondary lead(event, recurring);
115 meta event.time_formatted();
116
117 token Tag::badge(event.location_or_empty()) when event.has_location();
118 token Tag::badge(event.project_name_or_empty()) when event.has_project();
119 token Tag::badge("Snoozed").tone(Tone::Warning) when event.is_snoozed();
120 token Tag::badge("Time block").tone(Tone::Info) when event.is_linked_to_task();
121
122 activate to get "/events/{event.id}";
123 }
124 }
125
126 /// The address of the list under a given filter.
127 fn list_action(snoozed: bool) -> Action {
128 let action = Action::get("/events/list");
129 if snoozed {
130 action.carrying("snoozed", "1")
131 } else {
132 action
133 }
134 }
135
136 /// Whether the snoozed filter is on, read off whichever half of the request
137 /// carries it.
138 ///
139 /// A filter arrives carried on a read and, after a write, on the request that
140 /// answered it. Reading both is what keeps a delete made from the filtered view
141 /// answering with the filtered view.
142 fn snoozed_on(request: &quasi_router::Request) -> bool {
143 let set = |params: &quasi_router::Params| params.get("snoozed").is_some_and(|v| v == "1");
144 set(&request.carried) || set(&request.payload)
145 }
146
147 /// The calendar, split the three ways the screen draws it.
148 struct Listing {
149 /// The rules themselves, not their occurrences.
150 templates: Vec<Event>,
151 upcoming: Vec<Event>,
152 /// Newest first. The recent end of the past is the end anyone looks at.
153 past: Vec<Event>,
154 /// Nothing on the calendar at all, which is a different statement from
155 /// three empty sections.
156 bare: bool,
157 snoozed: bool,
158 }
159
160 /// Read the calendar.
161 ///
162 /// The snooze filter is applied here rather than read off a narrower query, and
163 /// finding out which was the port's one real correction. `events.js` calls the
164 /// `list_events` COMMAND, which excludes snoozed rows and offers
165 /// `list_snoozed_events` to merge them back in. The repository's `list_all` is
166 /// not that query: it filters only events converted into contexts, so a
167 /// described screen reading it directly would have shown snoozed events always,
168 /// which is not what the shipped screen does.
169 ///
170 /// Filtering here rather than adding a repository read keeps the two versions of
171 /// "what is on the calendar" in one place, and there is no second list to
172 /// de-duplicate against.
173 fn read(state: &AppState, snoozed: bool) -> Result<Listing, RouteError> {
174 let mut events = state
175 .events
176 .list_all(DESKTOP_USER_ID)
177 .map_err(|error| RouteError::internal(error.to_string()))?;
178
179 if !snoozed {
180 events.retain(|event| !event.is_snoozed());
181 }
182 let bare = events.is_empty();
183
184 let now = Utc::now();
185 let (templates, rest): (Vec<Event>, Vec<Event>) = events.into_iter().partition(is_template);
186 let (mut past, upcoming): (Vec<Event>, Vec<Event>) =
187 rest.into_iter().partition(|event| event.start_time < now);
188 past.reverse();
189
190 Ok(Listing {
191 templates,
192 upcoming,
193 past,
194 bare,
195 snoozed,
196 })
197 }
198
199 /// How the Recurring heading counts itself.
200 fn recurring_heading(listing: &Listing) -> String {
201 format!("Recurring ({})", listing.templates.len())
202 }
203
204 /// How the Past heading counts itself.
205 fn past_heading(listing: &Listing) -> String {
206 format!("Past ({})", listing.past.len())
207 }
208
209 declare! {
210 /// The three sections, in the order the screen draws them.
211 ///
212 /// Recurring first, which is `events.js`'s own order and not alphabetical
213 /// accident: the rules are what a reader scans for, and they are the
214 /// shortest list.
215 shape sections(listing: &Listing) -> Slot;
216
217 region "events-list" as Pane {
218 empty "No events scheduled." when listing.bare;
219
220 section recurring_heading(listing) unless listing.templates.is_empty();
221 list {
222 for event in listing.templates.iter() {
223 include row_for(event, true);
224 }
225 } unless listing.templates.is_empty();
226
227 section "Upcoming" unless listing.bare;
228 empty "Nothing ahead." when listing.upcoming.is_empty() and not listing.bare;
229 list {
230 for event in listing.upcoming.iter() {
231 include row_for(event, false);
232 }
233 } unless listing.upcoming.is_empty();
234
235 section past_heading(listing) unless listing.past.is_empty();
236 list {
237 for event in listing.past.iter() {
238 include row_for(event, false);
239 }
240 } unless listing.past.is_empty();
241 }
242 }
243
244 declare! {
245 /// The screen under one filter, which a read and a write both answer with.
246 shape screen(listing: &Listing) -> Screen;
247
248 screen list_detail "Events" false {
249 at_place super::shell::EVENTS;
250
251 region "events-band" as Band {
252 page "Events";
253 act "New event" to get "/events/new";
254 chip "Snoozed" to doing list_action(not_snoozed(listing)) {
255 latched listing.snoozed;
256 }
257 }
258
259 include sections(listing);
260
261 region "events-detail" as Pane {
262 empty "Nothing selected";
263 }
264 }
265 }
266
267 /// The filter a press on the Snoozed chip leaves the list under.
268 fn not_snoozed(listing: &Listing) -> bool {
269 !listing.snoozed
270 }
271
272 /// The whole screen, as an answer.
273 fn index(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
274 Ok(screen(&read(state, snoozed_on(&request))?).into())
275 }
276
277 /// The list alone, which is what the filter and a delete swap.
278 fn list(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
279 let listing = read(state, snoozed_on(&request))?;
280 Ok(Response::fragment(
281 "events-list",
282 Node::Region(sections(&listing)),
283 ))
284 }
285
286 /// Load one event, or answer 404.
287 fn load(state: &AppState, id: EventId) -> Result<Event, RouteError> {
288 state
289 .events
290 .get_by_id(id, DESKTOP_USER_ID)
291 .map_err(|error| RouteError::internal(error.to_string()))?
292 .ok_or_else(|| RouteError::not_found("no such event"))
293 }
294
295 /// The event a route was addressed at.
296 fn event_id(request: &quasi_router::Request) -> Result<EventId, RouteError> {
297 let raw = request
298 .captures
299 .get("id")
300 .ok_or_else(|| RouteError::not_found("no event id"))?;
301 Ok(EventId::from(
302 uuid::Uuid::parse_str(raw).map_err(|_| RouteError::not_found("not an event id"))?,
303 ))
304 }
305
306 declare! {
307 /// One event's detail pane.
308 ///
309 /// What the modal shows, minus the controls it carries. `events.js:open`
310 /// draws title, when, where, project, contact, description, and the
311 /// reminder list; the reminders are absent here for the reason in the
312 /// module header.
313 ///
314 /// Delete is offered on an occurrence and withheld on a template, which is
315 /// the scope question the JS asks with a dialog. Refusing is not the answer
316 /// it should end at; it is the honest state until a write can pause for one.
317 shape detail_pane(event: &Event) -> Slot;
318
319 region "events-detail" as Pane {
320 section &event.title;
321 text "{event.date_formatted()} at {event.time_formatted()}";
322
323 text event.location_or_empty().to_owned() when event.has_location();
324 text "Project: {event.project_name_or_empty()}" when event.has_project();
325
326 for contact in event.contact_name.iter() {
327 text "With: {contact}";
328 }
329
330 text event.description.clone() when event.has_description();
331 text "Repeats: {lead(event, true)}" when event.has_recurrence();
332
333 act "Edit" to get "/events/{event.id}/edit";
334
335 empty "This is a recurring rule. Deleting it needs the scope question, \
336 which nothing describes yet."
337 when is_template(event);
338
339 act "Delete" to post "/events/{event.id}/delete" unless is_template(event);
340 }
341 }
342
343 /// One event's detail pane, as an answer.
344 fn detail(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
345 let event = load(state, event_id(&request)?)?;
346 Ok(Response::fragment(
347 "events-detail",
348 Node::Region(detail_pane(&event)),
349 ))
350 }
351
352 /// Delete one event, and answer with the list it came out of.
353 ///
354 /// A template is refused rather than deleted: `confirmRecurringScope` exists
355 /// because deleting a rule and deleting one occurrence of it are different
356 /// acts, and a route that picked one would be choosing for the user.
357 fn remove(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
358 let id = event_id(&request)?;
359 let event = load(state, id)?;
360 if is_template(&event) {
361 return Err(RouteError::not_found(
362 "a recurring rule needs the scope question",
363 ));
364 }
365
366 let deleted = state
367 .events
368 .delete(id, DESKTOP_USER_ID)
369 .map_err(|error| RouteError::internal(error.to_string()))?;
370 if !deleted {
371 return Err(RouteError::not_found("no such event"));
372 }
373
374 list(state, request)
375 }
376
377 // The form. `8fdb814c`.
378
379 /// One entry in a fixed select: what it submits, and what it reads.
380 ///
381 /// A struct rather than a pair, because a description names what it draws and
382 /// `.1` is not a name.
383 struct Offered {
384 value: &'static str,
385 label: &'static str,
386 }
387
388 /// How an event's time relates to a zone, labelled as `events.js` labels it:
389 /// for what the choice does rather than for the stored word, because "relative
390 /// vs local vs absolute" means nothing standing in front of a calendar.
391 const TZ_KINDS: [Offered; 3] = [
392 Offered {
393 value: "relative",
394 label: "Relative to me (follows me when I travel)",
395 },
396 Offered {
397 value: "local",
398 label: "Anchored to a place",
399 },
400 Offered {
401 value: "absolute",
402 label: "Fixed point in time",
403 },
404 ];
405
406 /// The block types the shipped select offers, empty first for an ordinary
407 /// event. The empty option is what clears one, which is why it is a real
408 /// member here rather than an absent value.
409 const BLOCK_TYPES: [Offered; 5] = [
410 Offered {
411 value: "",
412 label: "Regular Event",
413 },
414 Offered {
415 value: "free_time",
416 label: "Free Time",
417 },
418 Offered {
419 value: "personal",
420 label: "Personal",
421 },
422 Offered {
423 value: "vacation",
424 label: "Vacation",
425 },
426 Offered {
427 value: "focus",
428 label: "Focus",
429 },
430 ];
431
432 /// The four patterns the recurrence select offers.
433 const RECURRENCES: [&str; 4] = ["None", "Daily", "Weekly", "Monthly"];
434
435 /// The most reminders one event may carry.
436 ///
437 /// `sanitize_reminder_offsets`'s cap, said on the screen. The write path
438 /// truncates silently, so a form that did not say it would take a ninth
439 /// reminder and throw it away without telling anyone.
440 const MOST_REMINDERS: usize = 8;
441
442 /// The shape a datetime box offers and accepts.
443 ///
444 /// `getEventFormFields` builds the same string out of a `Date`, and
445 /// [`parse_natural_date`] reads it back, so the value the form offers is a
446 /// value the form accepts. A prefill the parser would reject is a field that
447 /// cannot be left alone.
448 const TYPED_TIME: &str = "%Y-%m-%dT%H:%M";
449
450 /// Why a submission was refused, in the three shapes a form can say it.
451 #[derive(Debug, Default)]
452 struct Refusal {
453 /// What is wrong with one question, by field name.
454 fields: Vec<(&'static str, String)>,
455 /// What is wrong with one reminder, by slot.
456 ///
457 /// A repeating question needs this and [`fields`](Self::fields) cannot
458 /// carry it: one string on one field can say the set is wrong and never
459 /// which answer is.
460 slots: Vec<(usize, String)>,
461 /// A refusal from the write itself that names no question of this form.
462 ///
463 /// Mapped where it can be and shown whole where it cannot, rather than
464 /// hung on whichever field is nearest: a message pinned to the wrong box
465 /// is worse than a message above the form.
466 notice: Option<String>,
467 }
468
469 impl Refusal {
470 /// Whether anything is wrong.
471 fn any(&self) -> bool {
472 !self.fields.is_empty() || !self.slots.is_empty() || self.notice.is_some()
473 }
474
475 /// What is wrong with one question, if anything is.
476 fn about(&self, name: &str) -> Option<String> {
477 self.fields
478 .iter()
479 .find(|(field, _)| *field == name)
480 .map(|(_, message)| message.clone())
481 }
482 }
483
484 /// The value a datetime box should show for a start or an end.
485 ///
486 /// A civil-kind event's stored wall clock is used verbatim. An anchored
487 /// event's instant means 10:00 *there*, and rendering it through this
488 /// machine's zone would show the reader's offset instead of the time the user
489 /// set. The instant, converted to local, is the fallback and is the whole
490 /// answer for a fixed event. `prefillTime` makes the same three choices.
491 fn typed_time(
492 kind: TzKind,
493 civil: Option<NaiveDateTime>,
494 instant: Option<chrono::DateTime<Utc>>,
495 ) -> String {
496 if kind.is_civil()
497 && let Some(civil) = civil
498 {
499 return civil.format(TYPED_TIME).to_string();
500 }
501 instant.map_or_else(String::new, |instant| {
502 instant.with_timezone(&Local).format(TYPED_TIME).to_string()
503 })
504 }
505
506 /// The reminders standing in the form, as strings under one question.
507 ///
508 /// From the submission when there is one, so a refusal hands back the slots
509 /// the reader had rather than the ones the record holds; from the event
510 /// otherwise. Seconds either way, which is the column's own unit.
511 fn reminder_slots(event: Option<&Event>, submitted: Option<&quasi_router::Params>) -> Vec<String> {
512 match submitted {
513 Some(params) => params
514 .repeated("reminder")
515 .into_iter()
516 .map(ToOwned::to_owned)
517 .collect(),
518 None => event.map_or_else(Vec::new, |event| {
519 event
520 .reminder_offsets_seconds
521 .iter()
522 .map(i64::to_string)
523 .collect()
524 }),
525 }
526 }
527
528 /// What the form is filling in, and what it is answering.
529 struct Asking<'a> {
530 event: Option<&'a Event>,
531 refusal: &'a Refusal,
532 submitted: Option<&'a quasi_router::Params>,
533 /// The projects and contacts on offer, each with its own "no" row.
534 ///
535 /// `getEventFormFields` carries a project only as a hidden field, set by
536 /// `openNewForProject` and absent from the form reached from the calendar. A
537 /// described form with no project question would be worse than that rather
538 /// than equal to it: the update write takes the project from the submission,
539 /// so a form that never asked would clear the project of every event edited
540 /// from here. A select that offers "No Project" says the same thing without
541 /// losing anything.
542 projects: Vec<Choice>,
543 contacts: Vec<Choice>,
544 /// The reminders standing in the form, with whatever was wrong with them.
545 standing: Repeat,
546 }
547
548 /// Read what the form needs beyond the event itself.
549 fn asking<'a>(
550 state: &AppState,
551 event: Option<&'a Event>,
552 refusal: &'a Refusal,
553 submitted: Option<&'a quasi_router::Params>,
554 ) -> Result<Asking<'a>, RouteError> {
555 let projects = state
556 .projects
557 .list_all(DESKTOP_USER_ID)
558 .map_err(|error| RouteError::internal(error.to_string()))?;
559 let contacts = state
560 .contacts
561 .list_all(DESKTOP_USER_ID)
562 .map_err(|error| RouteError::internal(error.to_string()))?;
563
564 // The reminders, as one question answered zero or more times. The ceiling
565 // is `sanitize_reminder_offsets`'s and is stated rather than applied
566 // silently; a message about the set rides on the question, and a message
567 // about one answer rides on its own slot.
568 let mut standing = Repeat::answered(reminder_slots(event, submitted))
569 .most(MOST_REMINDERS)
570 .adding("Add reminder")
571 .removing("Remove");
572 for (at, message) in &refusal.slots {
573 standing = standing.wrong(*at, message.clone());
574 }
575
576 Ok(Asking {
577 event,
578 refusal,
579 submitted,
580 projects: std::iter::once(Choice::new("", "No Project"))
581 .chain(
582 projects
583 .iter()
584 .map(|project| Choice::new(project.id.to_string(), &project.name)),
585 )
586 .collect(),
587 contacts: std::iter::once(Choice::new("", "No Contact"))
588 .chain(
589 contacts
590 .iter()
591 .map(|contact| Choice::new(contact.id.to_string(), &contact.display_name)),
592 )
593 .collect(),
594 standing,
595 })
596 }
597
598 /// Nothing was typed, which is what a form that is not answering a refusal
599 /// refills from.
600 static NOTHING_TYPED: quasi_router::Params = quasi_router::Params::new();
601
602 /// What was typed, or nothing.
603 ///
604 /// Everything the reader typed goes back in the box it was typed into, and
605 /// [`Field::refilled`] leaves a name it finds nothing under alone, so the
606 /// setting needs no guard. The reminders are already the submitted slots, and
607 /// `refilled` leaves a repeating question alone: what it re-offers is one value
608 /// under one name, and a question answered N times has neither.
609 fn typed_back<'a>(asking: &Asking<'a>) -> &'a quasi_router::Params {
610 asking.submitted.unwrap_or(&NOTHING_TYPED)
611 }
612
613 /// Whether a named question was refused.
614 fn has_error(asking: &Asking, name: &str) -> bool {
615 asking.refusal.about(name).is_some()
616 }
617
618 /// Why it was refused, or nothing.
619 fn error_for(asking: &Asking, name: &str) -> String {
620 asking.refusal.about(name).unwrap_or_default()
621 }
622
623 /// Whether the event fills the whole day.
624 fn all_day(asking: &Asking) -> bool {
625 asking
626 .event
627 .is_some_and(|event| event.is_all_day_in(&Local))
628 }
629
630 /// The event's title.
631 fn titled<'a>(asking: &Asking<'a>) -> &'a str {
632 asking.event.map_or("", |event| event.title.as_str())
633 }
634
635 /// What it says about itself.
636 fn described<'a>(asking: &Asking<'a>) -> &'a str {
637 asking.event.map_or("", |event| event.description.as_str())
638 }
639
640 /// The box the start opens on.
641 ///
642 /// Now for a new event, which is what `getEventFormFields` fills the box with: a
643 /// calendar form with an empty start is a chore, and the common case is
644 /// something soon.
645 fn start_value(asking: &Asking) -> String {
646 asking.event.map_or_else(
647 || Local::now().format(TYPED_TIME).to_string(),
648 |event| typed_time(event.tz_kind, event.start_local, Some(event.start_time)),
649 )
650 }
651
652 /// The box the end opens on.
653 fn end_value(asking: &Asking) -> String {
654 asking.event.map_or_else(String::new, |event| {
655 typed_time(event.tz_kind, event.end_local, event.end_time)
656 })
657 }
658
659 /// Where it is.
660 fn location_value<'a>(asking: &Asking<'a>) -> &'a str {
661 asking.event.map_or("", Event::location_or_empty)
662 }
663
664 /// The zone it is anchored to.
665 fn zone_value(asking: &Asking) -> String {
666 asking
667 .event
668 .and_then(|event| event.timezone.clone())
669 .unwrap_or_default()
670 }
671
672 /// This machine's own zone, which is what the empty box shows.
673 fn system_zone() -> String {
674 goingson_core::tz::system_tz().name().to_owned()
675 }
676
677 /// The pattern it repeats on.
678 fn recurrence_value<'a>(asking: &Asking<'a>) -> &'a str {
679 asking.event.map_or(Recurrence::None.db_value(), |event| {
680 event.recurrence.db_value()
681 })
682 }
683
684 /// Which of the three kinds of time it keeps.
685 fn kind_value<'a>(asking: &Asking<'a>) -> &'a str {
686 asking
687 .event
688 .map_or(TzKind::Relative, |event| event.tz_kind)
689 .db_value()
690 }
691
692 /// What kind of block it is, if it is one.
693 fn block_value<'a>(asking: &Asking<'a>) -> &'a str {
694 asking
695 .event
696 .and_then(|event| event.block_type.as_ref())
697 .map_or("", DbValue::db_value)
698 }
699
700 /// The contact it is with.
701 fn contact_value(asking: &Asking) -> String {
702 asking
703 .event
704 .and_then(|event| event.contact_id)
705 .map(|id| id.to_string())
706 .unwrap_or_default()
707 }
708
709 /// The project it belongs to.
710 fn project_value(asking: &Asking) -> String {
711 asking
712 .event
713 .and_then(|event| event.project_id)
714 .map(|id| id.to_string())
715 .unwrap_or_default()
716 }
717
718 /// Where the form writes.
719 fn form_path(asking: &Asking) -> String {
720 asking.event.map_or_else(
721 || "/events".to_owned(),
722 |event| format!("/events/{}", event.id),
723 )
724 }
725
726 /// What the form is called.
727 fn form_heading(asking: &Asking) -> String {
728 asking.event.map_or_else(
729 || "New event".to_owned(),
730 |event| format!("Edit {}", event.title),
731 )
732 }
733
734 /// What the form's button reads.
735 fn form_submit(asking: &Asking) -> &'static str {
736 if asking.event.is_some() {
737 "Save event"
738 } else {
739 "Create event"
740 }
741 }
742
743 /// Whether the whole submission was refused rather than one question.
744 fn refused_outright(asking: &Asking) -> bool {
745 asking.refusal.notice.is_some()
746 }
747
748 /// Why it was.
749 fn refusal_notice(asking: &Asking) -> String {
750 asking.refusal.notice.clone().unwrap_or_default()
751 }
752
753 declare! {
754 /// The form, in the pane the detail pane uses.
755 ///
756 /// Both create and edit are this: the same questions, a different address
757 /// and a different submit label. An event being edited is the only thing
758 /// that differs, and it is what fills the boxes. The questions are in
759 /// `getEventFormFields`'s order.
760 ///
761 /// # What it does not ask, and why
762 ///
763 /// **The recurrence rule.** The select offers the four patterns and stops
764 /// there, which is exactly where `quasi::tasks::edit_fields` stops and for
765 /// the same reason: `buildRecurrenceConfigHtml` grows a second form
766 /// underneath it whose shape changes with the pattern, and a multi-select is
767 /// still missing from `FieldKind`. The consequence is handled rather than
768 /// ignored: [`answers`] threads the stored [`Event::recurrence_rule`]
769 /// through untouched, so editing the title of an event that repeats on
770 /// Tuesdays leaves it repeating on Tuesdays.
771 shape form_pane(asking: &Asking) -> Slot;
772
773 region "events-detail" as Pane {
774 section form_heading(asking);
775
776 toned refusal_notice(asking) Tone::Danger when refused_outright(asking);
777
778 form post form_path(asking) {
779 submit form_submit(asking);
780
781 field Checkbox "is_all_day" "All day" {
782 hint "Removes the time component. The event spans the whole day.";
783 value Node::SELECTED when all_day(asking);
784 refilled typed_back(asking);
785 }
786
787 field Text "title" "Title" {
788 required;
789 placeholder "Event title";
790 value titled(asking);
791 error error_for(asking, "title") when has_error(asking, "title");
792 refilled typed_back(asking);
793 }
794
795 field Textarea "description" "Description" {
796 placeholder "Event details...";
797 value described(asking);
798 error error_for(asking, "description")
799 when has_error(asking, "description");
800 refilled typed_back(asking);
801 }
802
803 field Text "start_time" "Start Date & Time" {
804 required;
805 placeholder "tomorrow 3pm, friday 10:00, 2026-12-25...";
806 value start_value(asking);
807 error error_for(asking, "start_time") when has_error(asking, "start_time");
808 refilled typed_back(asking);
809 }
810
811 field Text "end_time" "End Time (optional)" {
812 placeholder "tomorrow 5pm, friday 12:00...";
813 value end_value(asking);
814 error error_for(asking, "end_time") when has_error(asking, "end_time");
815 refilled typed_back(asking);
816 }
817
818 field Text "location" "Location" {
819 placeholder "Zoom / Office / Coffee Shop";
820 value location_value(asking);
821 error error_for(asking, "location") when has_error(asking, "location");
822 refilled typed_back(asking);
823 }
824
825 field Select "recurrence" "Recurrence" {
826 for pattern in RECURRENCES {
827 option Choice::new(pattern, pattern);
828 }
829 hint "Recurring events appear automatically on matching days";
830 value recurrence_value(asking);
831 error error_for(asking, "recurrence") when has_error(asking, "recurrence");
832 refilled typed_back(asking);
833 }
834
835 field Select "tz_kind" "Time zone" {
836 for offered in TZ_KINDS {
837 option Choice::new(offered.value, offered.label);
838 }
839 hint "Relative follows you when you travel. Anchored stays put. \
840 Fixed is a moment in time.";
841 value kind_value(asking);
842 error error_for(asking, "tz_kind") when has_error(asking, "tz_kind");
843 refilled typed_back(asking);
844 }
845
846 // The zone box, out on one of the three kinds. `initTzKindConfig` is
847 // 20 lines of DOM wiring that this one setting replaces, and every
848 // renderer answers it without asking a route.
849 field Text "timezone" "Anchored to" {
850 hint "IANA zone name, e.g. America/Denver.";
851 revealed_by Reveal::holding("tz_kind", TzKind::Local.db_value());
852 placeholder system_zone();
853 value zone_value(asking);
854 error error_for(asking, "timezone") when has_error(asking, "timezone");
855 refilled typed_back(asking);
856 }
857
858 field Select "block_type" "Type" {
859 for offered in BLOCK_TYPES {
860 option Choice::new(offered.value, offered.label);
861 }
862 value block_value(asking);
863 error error_for(asking, "block_type") when has_error(asking, "block_type");
864 refilled typed_back(asking);
865 }
866
867 field Select "contact_id" "Contact" {
868 options asking.contacts.clone();
869 value contact_value(asking);
870 error error_for(asking, "contact_id") when has_error(asking, "contact_id");
871 refilled typed_back(asking);
872 }
873
874 field Select "project_id" "Project" {
875 options asking.projects.clone();
876 value project_value(asking);
877 error error_for(asking, "project_id") when has_error(asking, "project_id");
878 refilled typed_back(asking);
879 }
880
881 field Number "reminder" "Reminder" {
882 hint "Seconds before the event starts: 300 is five minutes, 3600 an hour.";
883 unit "s";
884 at_least "0";
885 repeating asking.standing.clone();
886 error error_for(asking, "reminder") when has_error(asking, "reminder");
887 refilled typed_back(asking);
888 }
889 }
890 }
891 }
892
893 /// The create form.
894 fn new(state: &AppState, _request: quasi_router::Request) -> Result<Response, RouteError> {
895 Ok(Response::fragment(
896 "events-detail",
897 Node::Region(form_pane(&asking(state, None, &Refusal::default(), None)?)),
898 ))
899 }
900
901 /// The edit form, filled from the event.
902 ///
903 /// Offered on an occurrence and on a rule alike. Editing a rule edits the rule,
904 /// which is what `openEdit` does after `confirmRecurringScope` is answered with
905 /// "the whole series"; what is missing is the other answer, and the header says
906 /// so where the choice would be.
907 fn edit(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
908 let event = load(state, event_id(&request)?)?;
909 Ok(Response::fragment(
910 "events-detail",
911 Node::Region(form_pane(&asking(
912 state,
913 Some(&event),
914 &Refusal::default(),
915 None,
916 )?)),
917 ))
918 }
919
920 /// One reminder, read back off its slot.
921 ///
922 /// Seconds, non-negative, whole. A message here belongs to this slot rather
923 /// than to the question: a reader who mistyped the third of five reminders is
924 /// told which one, which is the half a single error on the field cannot say.
925 fn reminder_at(raw: &str) -> Result<i64, String> {
926 match raw.trim() {
927 "" => Err("A reminder needs a number of seconds.".to_owned()),
928 value => match value.parse::<i64>() {
929 Ok(seconds) if seconds >= 0 => Ok(seconds),
930 Ok(_) => Err("A reminder cannot be before the event by a negative amount.".to_owned()),
931 Err(_) => Err("Whole seconds, e.g. 900 for fifteen minutes.".to_owned()),
932 },
933 }
934 }
935
936 /// A wall clock the reader typed, or nothing when the box was empty.
937 ///
938 /// [`parse_natural_date`] is the same function `events.js` reaches through the
939 /// `parse_natural_date` command, so the two agree about what "friday 3pm"
940 /// means and the box that accepts it here accepts it there.
941 fn typed(raw: &str) -> Option<NaiveDateTime> {
942 parse_natural_date(raw, Local::now().naive_local())
943 }
944
945 /// The instant a typed wall clock names, read in the reader's own zone.
946 fn instant(civil: NaiveDateTime) -> Option<chrono::DateTime<Utc>> {
947 civil
948 .and_local_timezone(Local)
949 .single()
950 .map(|when| when.with_timezone(&Utc))
951 }
952
953 /// What the submission says, or what is wrong with it.
954 ///
955 /// Every complaint at once. Answering with the first one found is how a form is
956 /// fixed one round trip per mistake.
957 ///
958 /// The existing event is read for the one thing the form cannot ask for, the
959 /// rich recurrence rule, which is threaded through rather than dropped. See
960 /// [`form_fields`].
961 fn answers(
962 request: &quasi_router::Request,
963 existing: Option<&Event>,
964 ) -> Result<EventInput, Refusal> {
965 let mut refusal = Refusal::default();
966 let field = |name: &str| {
967 request
968 .payload
969 .get(name)
970 .unwrap_or_default()
971 .trim()
972 .to_owned()
973 };
974
975 let title = field("title");
976 if title.is_empty() {
977 refusal
978 .fields
979 .push(("title", "An event needs a title.".to_owned()));
980 } else if title.chars().count() > 200 {
981 refusal
982 .fields
983 .push(("title", "Maximum 200 characters".to_owned()));
984 }
985
986 let description = field("description");
987 if description.chars().count() > 2000 {
988 refusal
989 .fields
990 .push(("description", "Maximum 2000 characters".to_owned()));
991 }
992
993 let location = field("location");
994 if location.chars().count() > 200 {
995 refusal
996 .fields
997 .push(("location", "Maximum 200 characters".to_owned()));
998 }
999
1000 let raw_start = field("start_time");
1001 let start = if raw_start.is_empty() {
1002 refusal
1003 .fields
1004 .push(("start_time", "An event needs a start.".to_owned()));
1005 None
1006 } else {
1007 let parsed = typed(&raw_start);
1008 if parsed.is_none() {
1009 refusal.fields.push((
1010 "start_time",
1011 "Date not recognized. Try \"tomorrow 3pm\" or \"2026-12-25 10:00\".".to_owned(),
1012 ));
1013 }
1014 parsed
1015 };
1016
1017 let raw_end = field("end_time");
1018 let end = if raw_end.is_empty() {
1019 None
1020 } else {
1021 let parsed = typed(&raw_end);
1022 match parsed {
1023 None => refusal.fields.push((
1024 "end_time",
1025 "Date not recognized. Try \"tomorrow 5pm\" or \"2026-12-25 12:00\".".to_owned(),
1026 )),
1027 Some(end) => {
1028 if start.is_some_and(|start| end <= start) {
1029 refusal
1030 .fields
1031 .push(("end_time", "End time must be after start time".to_owned()));
1032 }
1033 }
1034 }
1035 parsed
1036 };
1037
1038 let recurrence =
1039 super::parse_choice::<Recurrence>(&request.payload, "recurrence", &mut refusal.fields);
1040 let kind = super::parse_choice::<TzKind>(&request.payload, "tz_kind", &mut refusal.fields);
1041
1042 // A select offers a fixed set, and the empty option is one of them: it is
1043 // what clears a block type. Anything else has to be one of the four.
1044 let raw_block = field("block_type");
1045 let block_type = if raw_block.is_empty() || BlockType::from_str_opt(&raw_block).is_some() {
1046 Some(raw_block.clone())
1047 } else {
1048 refusal
1049 .fields
1050 .push(("block_type", "Not one of the options offered.".to_owned()));
1051 None
1052 };
1053
1054 // The zone is asked for on one kind and meaningless on the other two, so it
1055 // is required on that kind alone. Checked against the zone database rather
1056 // than for shape: a typo that reads like a zone name would otherwise fall
1057 // back to the reader's own zone on every read, silently.
1058 let raw_zone = field("timezone");
1059 let mut timezone = None;
1060 if kind == Some(TzKind::Local) {
1061 if raw_zone.is_empty() {
1062 refusal
1063 .fields
1064 .push(("timezone", "An anchored event needs a zone.".to_owned()));
1065 } else if raw_zone.parse::<chrono_tz::Tz>().is_err() {
1066 refusal
1067 .fields
1068 .push(("timezone", "Unknown time zone.".to_owned()));
1069 } else {
1070 timezone = Some(raw_zone.clone());
1071 }
1072 }
1073
1074 let project_id = super::parse_optional_id(&request.payload, "project_id", &mut refusal.fields);
1075 let contact_id = super::parse_optional_id(&request.payload, "contact_id", &mut refusal.fields);
1076
1077 let raw_reminders = request.payload.repeated("reminder");
1078 if raw_reminders.len() > MOST_REMINDERS {
1079 refusal
1080 .fields
1081 .push(("reminder", format!("At most {MOST_REMINDERS} reminders.")));
1082 }
1083 let mut reminders = Vec::new();
1084 for (at, raw) in raw_reminders.iter().enumerate() {
1085 match reminder_at(raw) {
1086 Ok(seconds) => reminders.push(seconds),
1087 Err(message) => refusal.slots.push((at, message)),
1088 }
1089 }
1090
1091 let (Some(recurrence), Some(kind), Some(block_type)) = (recurrence, kind, block_type) else {
1092 return Err(refusal);
1093 };
1094 let (Some(project_id), Some(contact_id)) = (project_id, contact_id) else {
1095 return Err(refusal);
1096 };
1097 let (Some(start), Some(start_utc)) = (start, start.and_then(instant)) else {
1098 if !refusal.any() {
1099 refusal.fields.push((
1100 "start_time",
1101 "That time does not exist in this zone.".to_owned(),
1102 ));
1103 }
1104 return Err(refusal);
1105 };
1106 if refusal.any() {
1107 return Err(refusal);
1108 }
1109
1110 Ok(EventInput {
1111 project_id,
1112 title,
1113 description: Some(description),
1114 start_time: start_utc,
1115 end_time: end.and_then(instant),
1116 location: (!location.is_empty()).then_some(location),
1117 recurrence: Some(recurrence.db_value().to_owned()),
1118 contact_id,
1119 block_type: Some(block_type),
1120 // Threaded rather than rebuilt: the form cannot ask for it. See
1121 // [`form_fields`].
1122 recurrence_rule: existing.and_then(|event| event.recurrence_rule.clone()),
1123 reminder_offsets_seconds: reminders,
1124 // A tick is there by presence, which is how every renderer submits one.
1125 is_all_day: request
1126 .payload
1127 .get("is_all_day")
1128 .is_some_and(|value| !value.is_empty()),
1129 tz_kind: Some(kind.db_value().to_owned()),
1130 timezone,
1131 // The wall clock the reader typed, passed through rather than derived
1132 // back from the instant above. `_eventTz` says why and it is the whole
1133 // reason the civil columns exist: for an anchored event the instant was
1134 // read in *this* machine's zone, so deriving the civil time from it
1135 // would be wrong by the offset between here and the zone it is anchored
1136 // to. What the user meant is what they typed.
1137 start_local: Some(start),
1138 end_local: end,
1139 })
1140 }
1141
1142 /// A refusal from the write itself, put on the question it is about.
1143 ///
1144 /// The command validates title and end-versus-start too, and reaching it means
1145 /// this form let something through. Mapped where the name matches a question
1146 /// and shown whole where it does not, rather than hung on whichever box is
1147 /// nearest.
1148 fn refused(error: &crate::commands::ApiError) -> Refusal {
1149 let named = error
1150 .details
1151 .as_ref()
1152 .and_then(|details| details.field.as_deref());
1153 let question = match named {
1154 Some("title") => Some("title"),
1155 Some("endTime" | "end_time") => Some("end_time"),
1156 Some("startTime" | "start_time") => Some("start_time"),
1157 _ => None,
1158 };
1159 match question {
1160 Some(name) => Refusal {
1161 fields: vec![(name, error.message.clone())],
1162 ..Refusal::default()
1163 },
1164 None => Refusal {
1165 notice: Some(error.message.clone()),
1166 ..Refusal::default()
1167 },
1168 }
1169 }
1170
1171 /// Create an event, or answer with the form saying why not.
1172 fn create(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
1173 let input = match answers(&request, None) {
1174 Ok(input) => input,
1175 Err(refusal) => {
1176 return Ok(Response::fragment(
1177 "events-detail",
1178 Node::Region(form_pane(&asking(
1179 state,
1180 None,
1181 &refusal,
1182 Some(&request.payload),
1183 )?)),
1184 ));
1185 }
1186 };
1187
1188 match create_event_now(state, input) {
1189 Ok(_) => Ok(wrote(state, &request)?.toast(Tone::Success, "Event created")),
1190 Err(error) => Ok(Response::fragment(
1191 "events-detail",
1192 Node::Region(form_pane(&asking(
1193 state,
1194 None,
1195 &refused(&error),
1196 Some(&request.payload),
1197 )?)),
1198 )),
1199 }
1200 }
1201
1202 /// Save an edited event, or answer with the form saying why not.
1203 fn update(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
1204 let id = event_id(&request)?;
1205 let event = load(state, id)?;
1206
1207 let input = match answers(&request, Some(&event)) {
1208 Ok(input) => input,
1209 Err(refusal) => {
1210 return Ok(Response::fragment(
1211 "events-detail",
1212 Node::Region(form_pane(&asking(
1213 state,
1214 Some(&event),
1215 &refusal,
1216 Some(&request.payload),
1217 )?)),
1218 ));
1219 }
1220 };
1221
1222 match update_event_now(state, id, input) {
1223 Ok(_) => Ok(wrote(state, &request)?.toast(Tone::Success, "Event saved")),
1224 Err(error) => Ok(Response::fragment(
1225 "events-detail",
1226 Node::Region(form_pane(&asking(
1227 state,
1228 Some(&event),
1229 &refused(&error),
1230 Some(&request.payload),
1231 )?)),
1232 )),
1233 }
1234 }
1235
1236 /// Answer a write with the screen it happened on, re-read.
1237 ///
1238 /// The list and the detail pane both change on a create or an edit, and a
1239 /// fragment names one region. Rather than swap one and leave the other stale,
1240 /// the answer is the whole screen under the filter the write carried. Re-read
1241 /// from the database rather than patched in memory: the write is the
1242 /// database's to confirm.
1243 fn wrote(state: &AppState, request: &quasi_router::Request) -> Result<Response, RouteError> {
1244 Ok(screen(&read(state, snoozed_on(request))?).into())
1245 }
1246
1247 /// The events screen's routes.
1248 #[must_use]
1249 pub fn routes(router: Router<AppState>) -> Router<AppState> {
1250 router
1251 // Above `/events/{id}`, so a literal segment is not read as an id.
1252 .get("/events/list", list)
1253 .get("/events/new", new)
1254 .get("/events", index)
1255 .get("/events/{id}/edit", edit)
1256 .get("/events/{id}", detail)
1257 .post("/events", create)
1258 .post("/events/{id}", update)
1259 .post("/events/{id}/delete", remove)
1260 }
1261
1262 /// Whether an event is a recurring *rule* rather than one of its occurrences.
1263 ///
1264 /// `EventResponse` computes this for the frontend and the model does not carry
1265 /// it, so it is derived here from the same two facts: a recurrence is set, and
1266 /// this row is not one of the expanded instances.
1267 fn is_template(event: &Event) -> bool {
1268 event.has_recurrence() && !event.is_recurring_instance
1269 }
1270