//! The calendar, described rather than built. //! //! //! //! # Three lists rather than one //! //! The three sections answer different questions: //! //! - **Recurring** shows *templates*: the rule itself, not its occurrences, so //! a weekly meeting does not bury the rule under fifty instances. //! `is_template` is computed in Rust already (`recurrence != None && //! !is_recurring_instance`), so this reads it rather than deriving it again. //! - **Upcoming** is everything ahead that is not a template. //! - **Past** is everything behind, newest first: the interesting end of the //! past is the recent end. //! //! # The shape //! //! - `GET /events` — the document, all three sections. //! - `GET /events/list` — the sections alone, which is what the snoozed filter //! swaps. //! - `GET /events/{id}` — the detail pane. //! - `POST /events/{id}/delete` — delete one, and answer with the list. //! //! The snoozed filter is `?snoozed=1`, per decision 2: a view a user is looking //! at should be a view they can link to. //! //! Watch the layer it reads from. The `list_events` command excludes snoozed //! rows; the repository's `list_all` underneath it does not, filtering only //! events converted into contexts. Reading the repository as equivalent would //! show snoozed events on every visit. See [`sections`]. //! //! # The form //! //! Two members carry it: //! //! 1. **The timezone block.** [`quasi_router::Field::revealed_by`] shows the //! "Anchored to" box on one of the three `TzKind` values and hides it on the //! other two, and the box sits in the form it submits with rather than in a //! section beside it. //! 2. **Reminders.** [`quasi_router::Repeat`] is one question, N slots, one //! submit, matching `Event::reminder_offsets_seconds`. //! `sanitize_reminder_offsets`'s cap of eight is said on the screen rather //! than applied silently on the way in. //! //! Two things this screen refuses, and neither is its to invent: //! //! - **The recurring scope question.** "This occurrence or the whole series?" //! is a write that pauses for an answer, so the delete route refuses a //! template rather than guessing which the user meant. The form threads a //! stored rule through untouched for the same reason. //! - **Bulk selection.** `Row::selectable` draws the tick, and nothing carries //! the set to a write. //! //! # The write goes through the command, not around it //! //! [`crate::commands::event::create_event_now`] and its update counterpart are //! what the routes here call. 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 of them would be a second answer to //! what a wall clock means. Look at what the command does before assuming the //! layer underneath it is the same thing. // Handlers take their request by value because `quasi_router::Handler` is a // plain `fn(&S, Request)` pointer, so the signature is the router's and not a // choice made here. #![allow(clippy::needless_pass_by_value)] use chrono::{Local, NaiveDateTime, Utc}; use goingson_core::{BlockType, DbValue, Event, EventId, Recurrence, TzKind, parse_natural_date}; use makeover_layout::Tone; use quasi_declare::declare; use quasi_router::screen::{Choice, Repeat, Tag}; use quasi_router::{Action, Node, Response, Reveal, RouteError, Router}; use crate::commands::event::{EventInput, create_event_now, update_event_now}; use crate::state::{AppState, DESKTOP_USER_ID}; #[cfg(test)] mod tests; /// What a row leads with. /// /// A template's date cell is its pattern, not a start date. `events.js` swaps /// the same cell for the same reason: the arbitrary date a weekly rule happens /// to start on tells the reader nothing about the rule. fn lead(event: &Event, recurring: bool) -> String { if recurring { event.effective_recurrence_rule().map_or_else( || event.recurrence.as_str().to_owned(), |rule| rule.display(), ) } else { event.date_formatted() } } declare! { /// One event, as a row in one of the three sections. /// /// The shipped table has five columns (date, time, title, location, /// actions) and this is the same five facts in a row's slots. The kebab menu /// is not one of them: `contextMenus.showEvent` offers open, edit, snooze /// and delete, and three of those four have nowhere to go yet (see the /// module header), so the row carries the one that does rather than a menu /// that is mostly disabled. /// /// A time block is marked as one. The JS draws no marker and the row reads /// as an ordinary event; the fact is in the data and worth one token, since /// deleting a block and deleting a meeting are different acts. shape row_for(event: &Event, recurring: bool) -> Row; row &event.title { secondary lead(event, recurring); meta event.time_formatted(); token Tag::badge(event.location_or_empty()) when event.has_location(); token Tag::badge(event.project_name_or_empty()) when event.has_project(); token Tag::badge("Snoozed").tone(Tone::Warning) when event.is_snoozed(); token Tag::badge("Time block").tone(Tone::Info) when event.is_linked_to_task(); activate to get "/events/{event.id}"; } } /// The address of the list under a given filter. fn list_action(snoozed: bool) -> Action { let action = Action::get("/events/list"); if snoozed { action.carrying("snoozed", "1") } else { action } } /// Whether the snoozed filter is on, read off whichever half of the request /// carries it. /// /// A filter arrives carried on a read and, after a write, on the request that /// answered it. Reading both is what keeps a delete made from the filtered view /// answering with the filtered view. fn snoozed_on(request: &quasi_router::Request) -> bool { let set = |params: &quasi_router::Params| params.get("snoozed").is_some_and(|v| v == "1"); set(&request.carried) || set(&request.payload) } /// The calendar, split the three ways the screen draws it. struct Listing { /// The rules themselves, not their occurrences. templates: Vec, upcoming: Vec, /// Newest first. The recent end of the past is the end anyone looks at. past: Vec, /// Nothing on the calendar at all, which is a different statement from /// three empty sections. bare: bool, snoozed: bool, } /// Read the calendar. /// /// The snooze filter is applied here rather than read off a narrower query, and /// finding out which was the port's one real correction. `events.js` calls the /// `list_events` COMMAND, which excludes snoozed rows and offers /// `list_snoozed_events` to merge them back in. The repository's `list_all` is /// not that query: it filters only events converted into contexts, so a /// described screen reading it directly would have shown snoozed events always, /// which is not what the shipped screen does. /// /// Filtering here rather than adding a repository read keeps the two versions of /// "what is on the calendar" in one place, and there is no second list to /// de-duplicate against. fn read(state: &AppState, snoozed: bool) -> Result { let mut events = state .events .list_all(DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))?; if !snoozed { events.retain(|event| !event.is_snoozed()); } let bare = events.is_empty(); let now = Utc::now(); let (templates, rest): (Vec, Vec) = events.into_iter().partition(is_template); let (mut past, upcoming): (Vec, Vec) = rest.into_iter().partition(|event| event.start_time < now); past.reverse(); Ok(Listing { templates, upcoming, past, bare, snoozed, }) } /// How the Recurring heading counts itself. fn recurring_heading(listing: &Listing) -> String { format!("Recurring ({})", listing.templates.len()) } /// How the Past heading counts itself. fn past_heading(listing: &Listing) -> String { format!("Past ({})", listing.past.len()) } declare! { /// The three sections, in the order the screen draws them. /// /// Recurring first, which is `events.js`'s own order and not alphabetical /// accident: the rules are what a reader scans for, and they are the /// shortest list. shape sections(listing: &Listing) -> Slot; region "events-list" as Pane { empty "No events scheduled." when listing.bare; section recurring_heading(listing) unless listing.templates.is_empty(); list { for event in listing.templates.iter() { include row_for(event, true); } } unless listing.templates.is_empty(); section "Upcoming" unless listing.bare; empty "Nothing ahead." when listing.upcoming.is_empty() and not listing.bare; list { for event in listing.upcoming.iter() { include row_for(event, false); } } unless listing.upcoming.is_empty(); section past_heading(listing) unless listing.past.is_empty(); list { for event in listing.past.iter() { include row_for(event, false); } } unless listing.past.is_empty(); } } declare! { /// The screen under one filter, which a read and a write both answer with. shape screen(listing: &Listing) -> Screen; screen list_detail "Events" false { at_place super::shell::EVENTS; region "events-band" as Band { page "Events"; act "New event" to get "/events/new"; chip "Snoozed" to doing list_action(not_snoozed(listing)) { latched when listing.snoozed; } } include sections(listing); region "events-detail" as Pane { empty "Nothing selected"; } } } /// The filter a press on the Snoozed chip leaves the list under. fn not_snoozed(listing: &Listing) -> bool { !listing.snoozed } /// The whole screen, as an answer. fn index(state: &AppState, request: quasi_router::Request) -> Result { Ok(screen(&read(state, snoozed_on(&request))?).into()) } /// The list alone, which is what the filter and a delete swap. fn list(state: &AppState, request: quasi_router::Request) -> Result { let listing = read(state, snoozed_on(&request))?; Ok(Response::fragment( "events-list", Node::Region(sections(&listing)), )) } /// Load one event, or answer 404. fn load(state: &AppState, id: EventId) -> Result { state .events .get_by_id(id, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))? .ok_or_else(|| RouteError::not_found("no such event")) } /// The event a route was addressed at. fn event_id(request: &quasi_router::Request) -> Result { let raw = request .captures .get("id") .ok_or_else(|| RouteError::not_found("no event id"))?; Ok(EventId::from( uuid::Uuid::parse_str(raw).map_err(|_| RouteError::not_found("not an event id"))?, )) } declare! { /// One event's detail pane. /// /// What the modal shows, minus the controls it carries. `events.js:open` /// draws title, when, where, project, contact, description, and the /// reminder list; the reminders are absent here for the reason in the /// module header. /// /// Delete is offered on an occurrence and withheld on a template, which is /// the scope question the JS asks with a dialog. Refusing is not the answer /// it should end at; it is the honest state until a write can pause for one. shape detail_pane(event: &Event) -> Slot; region "events-detail" as Pane { section &event.title; text "{event.date_formatted()} at {event.time_formatted()}"; text event.location_or_empty().to_owned() when event.has_location(); text "Project: {event.project_name_or_empty()}" when event.has_project(); for contact in event.contact_name.iter() { text "With: {contact}"; } text event.description.clone() when event.has_description(); text "Repeats: {lead(event, true)}" when event.has_recurrence(); act "Edit" to get "/events/{event.id}/edit"; empty "This is a recurring rule. Deleting it needs the scope question, \ which nothing describes yet." when is_template(event); act "Delete" to post "/events/{event.id}/delete" unless is_template(event); } } /// One event's detail pane, as an answer. fn detail(state: &AppState, request: quasi_router::Request) -> Result { let event = load(state, event_id(&request)?)?; Ok(Response::fragment( "events-detail", Node::Region(detail_pane(&event)), )) } /// Delete one event, and answer with the list it came out of. /// /// A template is refused rather than deleted: `confirmRecurringScope` exists /// because deleting a rule and deleting one occurrence of it are different /// acts, and a route that picked one would be choosing for the user. fn remove(state: &AppState, request: quasi_router::Request) -> Result { let id = event_id(&request)?; let event = load(state, id)?; if is_template(&event) { return Err(RouteError::not_found( "a recurring rule needs the scope question", )); } let deleted = state .events .delete(id, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))?; if !deleted { return Err(RouteError::not_found("no such event")); } list(state, request) } // The form. `8fdb814c`. /// One entry in a fixed select: what it submits, and what it reads. /// /// A struct rather than a pair, because a description names what it draws and /// `.1` is not a name. struct Offered { value: &'static str, label: &'static str, } /// How an event's time relates to a zone, labelled as `events.js` labels it: /// for what the choice does rather than for the stored word, because "relative /// vs local vs absolute" means nothing standing in front of a calendar. const TZ_KINDS: [Offered; 3] = [ Offered { value: "relative", label: "Relative to me (follows me when I travel)", }, Offered { value: "local", label: "Anchored to a place", }, Offered { value: "absolute", label: "Fixed point in time", }, ]; /// The block types the shipped select offers, empty first for an ordinary /// event. The empty option is what clears one, which is why it is a real /// member here rather than an absent value. const BLOCK_TYPES: [Offered; 5] = [ Offered { value: "", label: "Regular Event", }, Offered { value: "free_time", label: "Free Time", }, Offered { value: "personal", label: "Personal", }, Offered { value: "vacation", label: "Vacation", }, Offered { value: "focus", label: "Focus", }, ]; /// The four patterns the recurrence select offers. const RECURRENCES: [&str; 4] = ["None", "Daily", "Weekly", "Monthly"]; /// The most reminders one event may carry. /// /// `sanitize_reminder_offsets`'s cap, said on the screen. The write path /// truncates silently, so a form that did not say it would take a ninth /// reminder and throw it away without telling anyone. const MOST_REMINDERS: usize = 8; /// The shape a datetime box offers and accepts. /// /// `getEventFormFields` builds the same string out of a `Date`, and /// [`parse_natural_date`] reads it back, so the value the form offers is a /// value the form accepts. A prefill the parser would reject is a field that /// cannot be left alone. const TYPED_TIME: &str = "%Y-%m-%dT%H:%M"; /// Why a submission was refused, in the three shapes a form can say it. #[derive(Debug, Default)] struct Refusal { /// What is wrong with one question, by field name. fields: Vec<(&'static str, String)>, /// What is wrong with one reminder, by slot. /// /// A repeating question needs this and [`fields`](Self::fields) cannot /// carry it: one string on one field can say the set is wrong and never /// which answer is. slots: Vec<(usize, String)>, /// A refusal from the write itself that names no question of this form. /// /// Mapped where it can be and shown whole where it cannot, rather than /// hung on whichever field is nearest: a message pinned to the wrong box /// is worse than a message above the form. notice: Option, } impl Refusal { /// Whether anything is wrong. fn any(&self) -> bool { !self.fields.is_empty() || !self.slots.is_empty() || self.notice.is_some() } /// What is wrong with one question, if anything is. fn about(&self, name: &str) -> Option { self.fields .iter() .find(|(field, _)| *field == name) .map(|(_, message)| message.clone()) } } /// The value a datetime box should show for a start or an end. /// /// A civil-kind event's stored wall clock is used verbatim. An anchored /// event's instant means 10:00 *there*, and rendering it through this /// machine's zone would show the reader's offset instead of the time the user /// set. The instant, converted to local, is the fallback and is the whole /// answer for a fixed event. `prefillTime` makes the same three choices. fn typed_time( kind: TzKind, civil: Option, instant: Option>, ) -> String { if kind.is_civil() && let Some(civil) = civil { return civil.format(TYPED_TIME).to_string(); } instant.map_or_else(String::new, |instant| { instant.with_timezone(&Local).format(TYPED_TIME).to_string() }) } /// The reminders standing in the form, as strings under one question. /// /// From the submission when there is one, so a refusal hands back the slots /// the reader had rather than the ones the record holds; from the event /// otherwise. Seconds either way, which is the column's own unit. fn reminder_slots(event: Option<&Event>, submitted: Option<&quasi_router::Params>) -> Vec { match submitted { Some(params) => params .repeated("reminder") .into_iter() .map(ToOwned::to_owned) .collect(), None => event.map_or_else(Vec::new, |event| { event .reminder_offsets_seconds .iter() .map(i64::to_string) .collect() }), } } /// What the form is filling in, and what it is answering. struct Asking<'a> { event: Option<&'a Event>, refusal: &'a Refusal, submitted: Option<&'a quasi_router::Params>, /// The projects and contacts on offer, each with its own "no" row. /// /// `getEventFormFields` carries a project only as a hidden field, set by /// `openNewForProject` and absent from the form reached from the calendar. A /// described form with no project question would be worse than that rather /// than equal to it: the update write takes the project from the submission, /// so a form that never asked would clear the project of every event edited /// from here. A select that offers "No Project" says the same thing without /// losing anything. projects: Vec, contacts: Vec, /// The reminders standing in the form, with whatever was wrong with them. standing: Repeat, } /// Read what the form needs beyond the event itself. fn asking<'a>( state: &AppState, event: Option<&'a Event>, refusal: &'a Refusal, submitted: Option<&'a quasi_router::Params>, ) -> Result, RouteError> { let projects = state .projects .list_all(DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))?; let contacts = state .contacts .list_all(DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))?; // The reminders, as one question answered zero or more times. The ceiling // is `sanitize_reminder_offsets`'s and is stated rather than applied // silently; a message about the set rides on the question, and a message // about one answer rides on its own slot. let mut standing = Repeat::answered(reminder_slots(event, submitted)) .most(MOST_REMINDERS) .adding("Add reminder") .removing("Remove"); for (at, message) in &refusal.slots { standing = standing.wrong(*at, message.clone()); } Ok(Asking { event, refusal, submitted, projects: std::iter::once(Choice::new("", "No Project")) .chain( projects .iter() .map(|project| Choice::new(project.id.to_string(), &project.name)), ) .collect(), contacts: std::iter::once(Choice::new("", "No Contact")) .chain( contacts .iter() .map(|contact| Choice::new(contact.id.to_string(), &contact.display_name)), ) .collect(), standing, }) } /// Nothing was typed, which is what a form that is not answering a refusal /// refills from. static NOTHING_TYPED: quasi_router::Params = quasi_router::Params::new(); /// What was typed, or nothing. /// /// Everything the reader typed goes back in the box it was typed into, and /// [`Field::refilled`] leaves a name it finds nothing under alone, so the /// setting needs no guard. The reminders are already the submitted slots, and /// `refilled` leaves a repeating question alone: what it re-offers is one value /// under one name, and a question answered N times has neither. fn typed_back<'a>(asking: &Asking<'a>) -> &'a quasi_router::Params { asking.submitted.unwrap_or(&NOTHING_TYPED) } /// Whether a named question was refused. fn has_error(asking: &Asking, name: &str) -> bool { asking.refusal.about(name).is_some() } /// Why it was refused, or nothing. fn error_for(asking: &Asking, name: &str) -> String { asking.refusal.about(name).unwrap_or_default() } /// Whether the event fills the whole day. fn all_day(asking: &Asking) -> bool { asking .event .is_some_and(|event| event.is_all_day_in(&Local)) } /// The event's title. fn titled<'a>(asking: &Asking<'a>) -> &'a str { asking.event.map_or("", |event| event.title.as_str()) } /// What it says about itself. fn described<'a>(asking: &Asking<'a>) -> &'a str { asking.event.map_or("", |event| event.description.as_str()) } /// The box the start opens on. /// /// Now for a new event, which is what `getEventFormFields` fills the box with: a /// calendar form with an empty start is a chore, and the common case is /// something soon. fn start_value(asking: &Asking) -> String { asking.event.map_or_else( || Local::now().format(TYPED_TIME).to_string(), |event| typed_time(event.tz_kind, event.start_local, Some(event.start_time)), ) } /// The box the end opens on. fn end_value(asking: &Asking) -> String { asking.event.map_or_else(String::new, |event| { typed_time(event.tz_kind, event.end_local, event.end_time) }) } /// Where it is. fn location_value<'a>(asking: &Asking<'a>) -> &'a str { asking.event.map_or("", Event::location_or_empty) } /// The zone it is anchored to. fn zone_value(asking: &Asking) -> String { asking .event .and_then(|event| event.timezone.clone()) .unwrap_or_default() } /// This machine's own zone, which is what the empty box shows. fn system_zone() -> String { goingson_core::tz::system_tz().name().to_owned() } /// The pattern it repeats on. fn recurrence_value<'a>(asking: &Asking<'a>) -> &'a str { asking.event.map_or(Recurrence::None.db_value(), |event| { event.recurrence.db_value() }) } /// Which of the three kinds of time it keeps. fn kind_value<'a>(asking: &Asking<'a>) -> &'a str { asking .event .map_or(TzKind::Relative, |event| event.tz_kind) .db_value() } /// What kind of block it is, if it is one. fn block_value<'a>(asking: &Asking<'a>) -> &'a str { asking .event .and_then(|event| event.block_type.as_ref()) .map_or("", DbValue::db_value) } /// The contact it is with. fn contact_value(asking: &Asking) -> String { asking .event .and_then(|event| event.contact_id) .map(|id| id.to_string()) .unwrap_or_default() } /// The project it belongs to. fn project_value(asking: &Asking) -> String { asking .event .and_then(|event| event.project_id) .map(|id| id.to_string()) .unwrap_or_default() } /// Where the form writes. fn form_path(asking: &Asking) -> String { asking.event.map_or_else( || "/events".to_owned(), |event| format!("/events/{}", event.id), ) } /// What the form is called. fn form_heading(asking: &Asking) -> String { asking.event.map_or_else( || "New event".to_owned(), |event| format!("Edit {}", event.title), ) } /// What the form's button reads. fn form_submit(asking: &Asking) -> &'static str { if asking.event.is_some() { "Save event" } else { "Create event" } } /// Whether the whole submission was refused rather than one question. fn refused_outright(asking: &Asking) -> bool { asking.refusal.notice.is_some() } /// Why it was. fn refusal_notice(asking: &Asking) -> String { asking.refusal.notice.clone().unwrap_or_default() } declare! { /// The form, in the pane the detail pane uses. /// /// Both create and edit are this: the same questions, a different address /// and a different submit label. An event being edited is the only thing /// that differs, and it is what fills the boxes. The questions are in /// `getEventFormFields`'s order. /// /// # What it does not ask, and why /// /// **The recurrence rule.** The select offers the four patterns and stops /// there, which is exactly where `quasi::tasks::edit_fields` stops and for /// the same reason: `buildRecurrenceConfigHtml` grows a second form /// underneath it whose shape changes with the pattern, and a multi-select is /// still missing from `FieldKind`. The consequence is handled rather than /// ignored: [`answers`] threads the stored [`Event::recurrence_rule`] /// through untouched, so editing the title of an event that repeats on /// Tuesdays leaves it repeating on Tuesdays. shape form_pane(asking: &Asking) -> Slot; region "events-detail" as Pane { section form_heading(asking); toned refusal_notice(asking) Tone::Danger when refused_outright(asking); form post form_path(asking) { submit form_submit(asking); field Checkbox "is_all_day" "All day" { hint "Removes the time component. The event spans the whole day."; value Node::SELECTED when all_day(asking); refilled typed_back(asking); } field Text "title" "Title" { required; placeholder "Event title"; value titled(asking); error error_for(asking, "title") when has_error(asking, "title"); refilled typed_back(asking); } field Textarea "description" "Description" { placeholder "Event details..."; value described(asking); error error_for(asking, "description") when has_error(asking, "description"); refilled typed_back(asking); } field Text "start_time" "Start Date & Time" { required; placeholder "tomorrow 3pm, friday 10:00, 2026-12-25..."; value start_value(asking); error error_for(asking, "start_time") when has_error(asking, "start_time"); refilled typed_back(asking); } field Text "end_time" "End Time (optional)" { placeholder "tomorrow 5pm, friday 12:00..."; value end_value(asking); error error_for(asking, "end_time") when has_error(asking, "end_time"); refilled typed_back(asking); } field Text "location" "Location" { placeholder "Zoom / Office / Coffee Shop"; value location_value(asking); error error_for(asking, "location") when has_error(asking, "location"); refilled typed_back(asking); } field Select "recurrence" "Recurrence" { for pattern in RECURRENCES { option Choice::new(pattern, pattern); } hint "Recurring events appear automatically on matching days"; value recurrence_value(asking); error error_for(asking, "recurrence") when has_error(asking, "recurrence"); refilled typed_back(asking); } field Select "tz_kind" "Time zone" { for offered in TZ_KINDS { option Choice::new(offered.value, offered.label); } hint "Relative follows you when you travel. Anchored stays put. \ Fixed is a moment in time."; value kind_value(asking); error error_for(asking, "tz_kind") when has_error(asking, "tz_kind"); refilled typed_back(asking); } // The zone box, out on one of the three kinds. `initTzKindConfig` is // 20 lines of DOM wiring that this one setting replaces, and every // renderer answers it without asking a route. field Text "timezone" "Anchored to" { hint "IANA zone name, e.g. America/Denver."; revealed_by Reveal::holding("tz_kind", TzKind::Local.db_value()); placeholder system_zone(); value zone_value(asking); error error_for(asking, "timezone") when has_error(asking, "timezone"); refilled typed_back(asking); } field Select "block_type" "Type" { for offered in BLOCK_TYPES { option Choice::new(offered.value, offered.label); } value block_value(asking); error error_for(asking, "block_type") when has_error(asking, "block_type"); refilled typed_back(asking); } field Select "contact_id" "Contact" { options asking.contacts.clone(); value contact_value(asking); error error_for(asking, "contact_id") when has_error(asking, "contact_id"); refilled typed_back(asking); } field Select "project_id" "Project" { options asking.projects.clone(); value project_value(asking); error error_for(asking, "project_id") when has_error(asking, "project_id"); refilled typed_back(asking); } field Number "reminder" "Reminder" { hint "Seconds before the event starts: 300 is five minutes, 3600 an hour."; unit "s"; at_least "0"; repeating asking.standing.clone(); error error_for(asking, "reminder") when has_error(asking, "reminder"); refilled typed_back(asking); } } } } /// The create form. fn new(state: &AppState, _request: quasi_router::Request) -> Result { Ok(Response::fragment( "events-detail", Node::Region(form_pane(&asking(state, None, &Refusal::default(), None)?)), )) } /// The edit form, filled from the event. /// /// Offered on an occurrence and on a rule alike. Editing a rule edits the rule, /// which is what `openEdit` does after `confirmRecurringScope` is answered with /// "the whole series"; what is missing is the other answer, and the header says /// so where the choice would be. fn edit(state: &AppState, request: quasi_router::Request) -> Result { let event = load(state, event_id(&request)?)?; Ok(Response::fragment( "events-detail", Node::Region(form_pane(&asking( state, Some(&event), &Refusal::default(), None, )?)), )) } /// One reminder, read back off its slot. /// /// Seconds, non-negative, whole. A message here belongs to this slot rather /// than to the question: a reader who mistyped the third of five reminders is /// told which one, which is the half a single error on the field cannot say. fn reminder_at(raw: &str) -> Result { match raw.trim() { "" => Err("A reminder needs a number of seconds.".to_owned()), value => match value.parse::() { Ok(seconds) if seconds >= 0 => Ok(seconds), Ok(_) => Err("A reminder cannot be before the event by a negative amount.".to_owned()), Err(_) => Err("Whole seconds, e.g. 900 for fifteen minutes.".to_owned()), }, } } /// A wall clock the reader typed, or nothing when the box was empty. /// /// [`parse_natural_date`] is the same function `events.js` reaches through the /// `parse_natural_date` command, so the two agree about what "friday 3pm" /// means and the box that accepts it here accepts it there. fn typed(raw: &str) -> Option { parse_natural_date(raw, Local::now().naive_local()) } /// The instant a typed wall clock names, read in the reader's own zone. fn instant(civil: NaiveDateTime) -> Option> { civil .and_local_timezone(Local) .single() .map(|when| when.with_timezone(&Utc)) } /// What the submission says, or what is wrong with it. /// /// Every complaint at once. Answering with the first one found is how a form is /// fixed one round trip per mistake. /// /// The existing event is read for the one thing the form cannot ask for, the /// rich recurrence rule, which is threaded through rather than dropped. See /// [`form_fields`]. fn answers( request: &quasi_router::Request, existing: Option<&Event>, ) -> Result { let mut refusal = Refusal::default(); let field = |name: &str| { request .payload .get(name) .unwrap_or_default() .trim() .to_owned() }; let title = field("title"); if title.is_empty() { refusal .fields .push(("title", "An event needs a title.".to_owned())); } else if title.chars().count() > 200 { refusal .fields .push(("title", "Maximum 200 characters".to_owned())); } let description = field("description"); if description.chars().count() > 2000 { refusal .fields .push(("description", "Maximum 2000 characters".to_owned())); } let location = field("location"); if location.chars().count() > 200 { refusal .fields .push(("location", "Maximum 200 characters".to_owned())); } let raw_start = field("start_time"); let start = if raw_start.is_empty() { refusal .fields .push(("start_time", "An event needs a start.".to_owned())); None } else { let parsed = typed(&raw_start); if parsed.is_none() { refusal.fields.push(( "start_time", "Date not recognized. Try \"tomorrow 3pm\" or \"2026-12-25 10:00\".".to_owned(), )); } parsed }; let raw_end = field("end_time"); let end = if raw_end.is_empty() { None } else { let parsed = typed(&raw_end); match parsed { None => refusal.fields.push(( "end_time", "Date not recognized. Try \"tomorrow 5pm\" or \"2026-12-25 12:00\".".to_owned(), )), Some(end) => { if start.is_some_and(|start| end <= start) { refusal .fields .push(("end_time", "End time must be after start time".to_owned())); } } } parsed }; let recurrence = super::parse_choice::(&request.payload, "recurrence", &mut refusal.fields); let kind = super::parse_choice::(&request.payload, "tz_kind", &mut refusal.fields); // A select offers a fixed set, and the empty option is one of them: it is // what clears a block type. Anything else has to be one of the four. let raw_block = field("block_type"); let block_type = if raw_block.is_empty() || BlockType::from_str_opt(&raw_block).is_some() { Some(raw_block.clone()) } else { refusal .fields .push(("block_type", "Not one of the options offered.".to_owned())); None }; // The zone is asked for on one kind and meaningless on the other two, so it // is required on that kind alone. Checked against the zone database rather // than for shape: a typo that reads like a zone name would otherwise fall // back to the reader's own zone on every read, silently. let raw_zone = field("timezone"); let mut timezone = None; if kind == Some(TzKind::Local) { if raw_zone.is_empty() { refusal .fields .push(("timezone", "An anchored event needs a zone.".to_owned())); } else if raw_zone.parse::().is_err() { refusal .fields .push(("timezone", "Unknown time zone.".to_owned())); } else { timezone = Some(raw_zone.clone()); } } let project_id = super::parse_optional_id(&request.payload, "project_id", &mut refusal.fields); let contact_id = super::parse_optional_id(&request.payload, "contact_id", &mut refusal.fields); let raw_reminders = request.payload.repeated("reminder"); if raw_reminders.len() > MOST_REMINDERS { refusal .fields .push(("reminder", format!("At most {MOST_REMINDERS} reminders."))); } let mut reminders = Vec::new(); for (at, raw) in raw_reminders.iter().enumerate() { match reminder_at(raw) { Ok(seconds) => reminders.push(seconds), Err(message) => refusal.slots.push((at, message)), } } let (Some(recurrence), Some(kind), Some(block_type)) = (recurrence, kind, block_type) else { return Err(refusal); }; let (Some(project_id), Some(contact_id)) = (project_id, contact_id) else { return Err(refusal); }; let (Some(start), Some(start_utc)) = (start, start.and_then(instant)) else { if !refusal.any() { refusal.fields.push(( "start_time", "That time does not exist in this zone.".to_owned(), )); } return Err(refusal); }; if refusal.any() { return Err(refusal); } Ok(EventInput { project_id, title, description: Some(description), start_time: start_utc, end_time: end.and_then(instant), location: (!location.is_empty()).then_some(location), recurrence: Some(recurrence.db_value().to_owned()), contact_id, block_type: Some(block_type), // Threaded rather than rebuilt: the form cannot ask for it. See // [`form_fields`]. recurrence_rule: existing.and_then(|event| event.recurrence_rule.clone()), reminder_offsets_seconds: reminders, // A tick is there by presence, which is how every renderer submits one. is_all_day: request .payload .get("is_all_day") .is_some_and(|value| !value.is_empty()), tz_kind: Some(kind.db_value().to_owned()), timezone, // The wall clock the reader typed, passed through rather than derived // back from the instant above. `_eventTz` says why and it is the whole // reason the civil columns exist: for an anchored event the instant was // read in *this* machine's zone, so deriving the civil time from it // would be wrong by the offset between here and the zone it is anchored // to. What the user meant is what they typed. start_local: Some(start), end_local: end, }) } /// A refusal from the write itself, put on the question it is about. /// /// The command validates title and end-versus-start too, and reaching it means /// this form let something through. Mapped where the name matches a question /// and shown whole where it does not, rather than hung on whichever box is /// nearest. fn refused(error: &crate::commands::ApiError) -> Refusal { let named = error .details .as_ref() .and_then(|details| details.field.as_deref()); let question = match named { Some("title") => Some("title"), Some("endTime" | "end_time") => Some("end_time"), Some("startTime" | "start_time") => Some("start_time"), _ => None, }; match question { Some(name) => Refusal { fields: vec![(name, error.message.clone())], ..Refusal::default() }, None => Refusal { notice: Some(error.message.clone()), ..Refusal::default() }, } } /// Create an event, or answer with the form saying why not. fn create(state: &AppState, request: quasi_router::Request) -> Result { let input = match answers(&request, None) { Ok(input) => input, Err(refusal) => { return Ok(Response::fragment( "events-detail", Node::Region(form_pane(&asking( state, None, &refusal, Some(&request.payload), )?)), )); } }; match create_event_now(state, input) { Ok(_) => Ok(wrote(state, &request)?.toast(Tone::Success, "Event created")), Err(error) => Ok(Response::fragment( "events-detail", Node::Region(form_pane(&asking( state, None, &refused(&error), Some(&request.payload), )?)), )), } } /// Save an edited event, or answer with the form saying why not. fn update(state: &AppState, request: quasi_router::Request) -> Result { let id = event_id(&request)?; let event = load(state, id)?; let input = match answers(&request, Some(&event)) { Ok(input) => input, Err(refusal) => { return Ok(Response::fragment( "events-detail", Node::Region(form_pane(&asking( state, Some(&event), &refusal, Some(&request.payload), )?)), )); } }; match update_event_now(state, id, input) { Ok(_) => Ok(wrote(state, &request)?.toast(Tone::Success, "Event saved")), Err(error) => Ok(Response::fragment( "events-detail", Node::Region(form_pane(&asking( state, Some(&event), &refused(&error), Some(&request.payload), )?)), )), } } /// Answer a write with the screen it happened on, re-read. /// /// The list and the detail pane both change on a create or an edit, and a /// fragment names one region. Rather than swap one and leave the other stale, /// the answer is the whole screen under the filter the write carried. Re-read /// from the database rather than patched in memory: the write is the /// database's to confirm. fn wrote(state: &AppState, request: &quasi_router::Request) -> Result { Ok(screen(&read(state, snoozed_on(request))?).into()) } /// The events screen's routes. #[must_use] pub fn routes(router: Router) -> Router { router // Above `/events/{id}`, so a literal segment is not read as an id. .get("/events/list", list) .get("/events/new", new) .get("/events", index) .get("/events/{id}/edit", edit) .get("/events/{id}", detail) .post("/events", create) .post("/events/{id}", update) .post("/events/{id}/delete", remove) } /// Whether an event is a recurring *rule* rather than one of its occurrences. /// /// `EventResponse` computes this for the frontend and the model does not carry /// it, so it is derived here from the same two facts: a recurrence is set, and /// this row is not one of the expanded instances. fn is_template(event: &Event) -> bool { event.has_recurrence() && !event.is_recurring_instance }