//! Contexts, authored as spans rather than checked off a week. //! //! //! //! A context is a state that frames days, as distinct from an event that //! occupies them. This is the place to record one as what it is: a label, a //! kind, and two dates. The other two ways in are narrow: migration 067 //! converts a multi-day event or a `vacation_days` run, and the weekly review's //! seven checkboxes write spans through //! `commands::weekly_review::set_vacation_week`, which can only say *Vacation*, //! only inside one week. //! //! # Where it goes, and why not the calendar //! //! Not a calendar. `4a1237b6` still stands: a context sits behind the day's axis //! rather than on it, and putting it on a timeline is the conflation the model //! exists to undo. Its own screen, marked at the Day place, reached from the day //! band beside the banners it explains. It adds no place to [`shell`]: a twelfth //! place would be this module answering a product question rather than a //! placement one. //! //! # The shape //! //! - `GET /contexts` — the document: the list, and a pane. //! - `GET /contexts/new` — the create form, in the pane. //! - `GET /contexts/{id}` — the edit form, in the pane, filled. //! - `POST /contexts` — create. //! - `POST /contexts/{id}` — re-label, re-kind, re-span. //! - `POST /contexts/{id}/delete` — delete, which puts a migrated event back. //! //! Every write answers with the whole screen for [`super::projects::wrote`]'s //! reason: a write lands in the list and in the pane at once, and a `Response` //! names one region. //! //! # What the description cannot say, and does not fake //! //! **A date interval is one question with two ends, and there is no way to say //! so.** `FieldKind::Interval` exists and is exactly the right shape: it carries //! two names, it says the ends constrain each other, and it gives a crossing //! fault one place to be reported instead of two. What it cannot say is that its //! ends are dates. The kind is the value's kind, so an interval's ends are //! whatever `Interval` draws, and `quasi_webview::node::refill` emits them as //! the numeric pair its two measured sites wanted (audiofiles' BPM axes, the MNW //! server's price pair). //! //! So the span is two `FieldKind::Date` fields here, and the crossing rule is //! checked in [`validate`] and reported on the start. That is the honest //! description of two controls with no stated relationship, and it is worse than //! the one member would be: "off from the 3rd to the 17th" is one decision, and //! this screen exists because storing its projection as if it were the fact felt //! wrong to write. //! //! What that gap wants is `Interval` learning what its ends are, not a new //! member. // 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::NaiveDate; use goingson_core::id_types::ContextId; use goingson_core::models::{Context, ContextKind}; use quasi_declare::declare; use quasi_router::layout::Tone; use quasi_router::screen::{Choice, Tag}; use quasi_router::{Node, Response, RouteError, Router, Slot}; use crate::state::{AppState, DESKTOP_USER_ID}; #[cfg(test)] mod tests; /// The kinds a person can choose. /// /// Every variant except `Other`, which is what an unknown stored kind parses to /// rather than something anyone picks: offering it would ask the user to file a /// context as "something else" when the label is already where they say what it /// is. const KINDS: &[ContextKind] = &[ ContextKind::Vacation, ContextKind::Trip, ContextKind::Illness, ContextKind::Sprint, ]; /// What a kind is called on screen. /// /// Apart from [`ContextKind::as_str`], which is the stored value and the wire's. /// A display name that drifted from the stored one would be a second vocabulary; /// these agree today and are separate so that a rename of either is not /// automatically a migration. fn kind_label(kind: ContextKind) -> &'static str { match kind { ContextKind::Vacation => "Vacation", ContextKind::Trip => "Trip", ContextKind::Illness => "Illness", ContextKind::Sprint => "Sprint", ContextKind::Other => "Other", } } /// Every context, earliest first. fn all(state: &AppState) -> Result, RouteError> { state .contexts .list_all(DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string())) } /// One context by id. /// /// `ContextRepository` has no `get`, and adding one for this screen would be /// storage work the task said this is not. The list is the whole set a person /// authored, so scanning it costs nothing a person would notice. fn load(state: &AppState, id: ContextId) -> Result { all(state)? .into_iter() .find(|context| context.id == id) .ok_or_else(|| RouteError::not_found("no such context")) } /// The id in the path. fn context_id(request: &quasi_router::Request) -> Result { let raw = request .captures .get("id") .ok_or_else(|| RouteError::not_found("no context id"))?; Ok(ContextId::from( uuid::Uuid::parse_str(raw).map_err(|_| RouteError::not_found("not a context id"))?, )) } /// The span, in words. The list says it as prose because it is what the record /// *is*. fn span_words(context: &Context) -> String { let days = context.days(); format!( "{} to {} ({} day{})", context.starts_on.format("%-d %b %Y"), context.ends_on.format("%-d %b %Y"), days, if days == 1 { "" } else { "s" } ) } /// Whether this row is the one the pane is showing. fn is_current(context: &Context, current: Option) -> bool { current == Some(context.id) } declare! { /// How a context reads in the list. /// /// The span is the secondary line rather than a token, because it is what /// the record *is*: a label without its dates is not a context, and a token /// would rank it beside the kind. shape row_for(context: &Context, current: bool) -> Row; row &context.label { token Tag::badge(kind_label(context.kind)); secondary span_words(context); token Tag::badge("From an event") when context.migrated_from_event_id.is_some(); current current; activate to get "/contexts/{context.id}"; } } declare! { /// The list of contexts. shape list_node(contexts: &[Context], current: Option) -> Node; given contexts.is_empty() { true -> empty "No contexts yet." { offering "Record your first context" to get "/contexts/new"; } otherwise -> list { for context in contexts.iter() { include row_for(context, is_current(context, current)); } } } } /// What the pane is showing: the context being edited if there is one, what was /// refused, and what was typed. /// /// `existing` fills the fields for an edit; `submitted` refills them after a /// refusal, and wins, because what the user just typed is nearer to what they /// meant than what is stored. [`Field::refilled`] is the same repair `1c4a66a4` /// closed on quasi-router. struct Editing<'a> { existing: Option<&'a Context>, errors: &'a [(&'a str, String)], submitted: Option<&'a quasi_router::Params>, } impl<'a> Editing<'a> { /// A fresh form. const fn fresh() -> Self { Self { existing: None, errors: &[], submitted: None, } } /// The form over an existing context. const fn of(context: &'a Context) -> Self { Self { existing: Some(context), errors: &[], submitted: None, } } } /// Whether the pane is editing rather than creating. fn is_edit(editing: &Editing) -> bool { editing.existing.is_some() } /// What the pane is called. fn pane_heading(editing: &Editing) -> String { match editing.existing { None => "New context".to_owned(), Some(context) => format!("Editing {}", context.label), } } /// Whether this context was converted from an event. fn from_event(editing: &Editing) -> bool { editing .existing .is_some_and(|context| context.migrated_from_event_id.is_some()) } /// Where the form writes. fn form_path(editing: &Editing) -> String { match editing.existing { None => "/contexts".to_owned(), Some(context) => format!("/contexts/{}", context.id), } } /// What the form's button reads. fn submit_label(editing: &Editing) -> &'static str { match editing.existing { None => "Record context", Some(_) => "Save", } } /// The id being edited, for the addresses that name it. /// /// R9: every hole in a guarded member is read whether or not the member is /// placed, so this answers with nothing on a create rather than refusing. fn existing_id(editing: &Editing) -> String { editing .existing .map(|context| context.id.to_string()) .unwrap_or_default() } /// What Delete asks before it happens. /// /// Provenance is the reason for the two questions: deleting a migrated context /// is the one delete on this screen that does something to a record the user /// did not author here. fn delete_question(editing: &Editing) -> &'static str { if from_event(editing) { "Delete this context and put its event back?" } else { "Delete this context?" } } /// The label a stored context holds, if the pane is editing one. fn stored_label(editing: &Editing) -> Option { editing.existing.map(|context| context.label.clone()) } /// The kind a stored context holds, as it is stored. fn stored_kind<'a>(editing: &Editing<'a>) -> Option<&'a str> { editing.existing.map(|context| context.kind.as_str()) } /// The first day a stored context holds. fn stored_start(editing: &Editing) -> Option { editing .existing .map(|context| context.starts_on.to_string()) } /// The last day a stored context holds. fn stored_end(editing: &Editing) -> Option { editing.existing.map(|context| context.ends_on.to_string()) } /// Whether a named field was refused. fn has_error(editing: &Editing, name: &str) -> bool { editing.errors.iter().any(|(field, _)| *field == name) } /// Why it was refused, or nothing. fn error_for(editing: &Editing, name: &str) -> String { editing .errors .iter() .find(|(field, _)| *field == name) .map(|(_, message)| message.clone()) .unwrap_or_default() } /// 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. /// /// Empty rather than absent, because [`Field::refilled`] leaves a name it finds /// nothing under alone: refilling from nothing is the same field back, so the /// setting needs no guard. fn typed<'a>(editing: &Editing<'a>) -> &'a quasi_router::Params { editing.submitted.unwrap_or(&NOTHING_TYPED) } declare! { /// The pane: a form, and for an edit the things only an edit can offer. /// /// The span is two `Date` fields rather than one interval. See the module /// header. /// /// `refilled` goes last on every field because it overrides what is stored /// with what was typed, which is the order the repair wants. shape form_slot(editing: &Editing) -> Slot; region "contexts-detail" as Pane { section pane_heading(editing); // Provenance, and the reversal it buys, said before the controls rather // than after. banner Tone::Info "Converted from an event. Deleting this puts that event back on the \ timeline, with its times intact." when from_event(editing); form post form_path(editing) { submit submit_label(editing); field Text "label" "Label" { required; placeholder "Two weeks in Lisbon"; for label in stored_label(editing).into_iter() { value label; } error error_for(editing, "label") when has_error(editing, "label"); refilled typed(editing); } field Select "kind" "Kind" { for kind in KINDS.iter().copied() { option Choice::new(kind.as_str(), kind_label(kind)); } for kind in stored_kind(editing).into_iter() { value kind; } error error_for(editing, "kind") when has_error(editing, "kind"); refilled typed(editing); } field Date "starts_on" "First day" { required; hint "The first day inside it."; for start in stored_start(editing).into_iter() { value start; } error error_for(editing, "starts_on") when has_error(editing, "starts_on"); refilled typed(editing); } field Date "ends_on" "Last day" { required; hint "Inclusive: off until the 17th means the 17th is off."; for end in stored_end(editing).into_iter() { value end; } error error_for(editing, "ends_on") when has_error(editing, "ends_on"); refilled typed(editing); } } act "Delete" to post "/contexts/{existing_id(editing)}/delete" when is_edit(editing) { tone Danger; confirm delete_question(editing); } } } declare! { /// The pane before anything is selected. shape idle_pane() -> Slot; region "contexts-detail" as Pane { empty "Nothing selected"; } } declare! { /// The whole screen. /// /// The Day place rather than a twelfth one: a context frames a day, and the /// day view is where its banner is read. shape screen(contexts: &[Context], current: Option, pane: Slot) -> Screen; screen list_detail "Contexts" false { at_place super::shell::DAY; region "contexts-band" as Band { page "Contexts"; act "New context" to get "/contexts/new"; } region "contexts-list" as Pane { include list_node(contexts, current); } include pane; } } /// The document, with nothing selected. fn document(state: &AppState, message: Option<&str>) -> Result { let contexts = all(state)?; let screen = screen(&contexts, None, idle_pane()); Ok(match message { Some(said) => Response::from(screen).toast(Tone::Success, said), None => screen.into(), }) } /// The document. fn index(state: &AppState, _request: quasi_router::Request) -> Result { document(state, None) } /// The create form, in the pane. fn new(_state: &AppState, _request: quasi_router::Request) -> Result { Ok(Response::fragment( "contexts-detail", Node::Region(form_slot(&Editing::fresh())), )) } /// The edit form, filled, in the pane. fn detail(state: &AppState, request: quasi_router::Request) -> Result { let context = load(state, context_id(&request)?)?; Ok(Response::fragment( "contexts-detail", Node::Region(form_slot(&Editing::of(&context))), )) } /// What a context has to be before it can be recorded. /// /// The crossing rule is checked rather than corrected. `ContextRepository` /// orders the ends on the way in, on the argument that a span typed backwards is /// a slip whose days are unambiguous, and that is right for a migration reading /// stored data. It is wrong for a form: silently swapping what someone typed /// tells them nothing, and the next thing they do is wonder why the dates moved. fn validate( label: &str, starts_on: Option, ends_on: Option, ) -> Vec<(&'static str, String)> { let mut errors = Vec::new(); if label.is_empty() { errors.push(("label", "A context needs a label.".to_owned())); } else if label.chars().count() > 100 { errors.push(("label", "Maximum 100 characters".to_owned())); } if starts_on.is_none() { errors.push(("starts_on", "Needs a first day.".to_owned())); } if ends_on.is_none() { errors.push(("ends_on", "Needs a last day.".to_owned())); } if let (Some(starts), Some(ends)) = (starts_on, ends_on) && starts > ends { errors.push(( "starts_on", "The first day is after the last one.".to_owned(), )); } errors } /// A date out of the payload, or `None` if it is missing or unparseable. /// /// Both cases are the same answer here because both are reported the same way: /// `FieldKind::Date` states the wire format, so a value that will not parse did /// not come from the control. fn date(params: &quasi_router::Params, name: &str) -> Option { params.get(name)?.trim().parse().ok() } /// What every write answers with. /// /// The whole screen, for [`super::projects`]'s reason: a write lands in the list /// and in the pane at once, and a `Response` names one region. `outerMorph` /// keeps focus and scroll across it. fn wrote(state: &AppState, message: &str) -> Result { document(state, Some(message)) } /// The fields a write reads, validated together. struct Submitted { label: String, kind: ContextKind, starts_on: NaiveDate, ends_on: NaiveDate, } /// Read and check a submission, or hand back the form saying why not. /// /// Every complaint at once. Answering with the first one found is how a form is /// fixed one round trip per mistake. fn submitted( request: &quasi_router::Request, existing: Option<&Context>, ) -> Result> { let label = request .payload .get("label") .unwrap_or_default() .trim() .to_owned(); let starts_on = date(&request.payload, "starts_on"); let ends_on = date(&request.payload, "ends_on"); let errors = validate(&label, starts_on, ends_on); if !errors.is_empty() { return Err(Box::new(Node::Region(form_slot(&Editing { existing, errors: &errors, submitted: Some(&request.payload), })))); } // Unlike a status or a project type, an unknown kind cannot be refused: // `ContextKind::parse` maps one to `Other` and cannot fail, deliberately, // because a row written by a newer client is still a real span of days. Ok(Submitted { label, kind: ContextKind::parse(request.payload.get("kind").unwrap_or_default()), starts_on: starts_on.expect("validated"), ends_on: ends_on.expect("validated"), }) } /// Record a context. fn create(state: &AppState, request: quasi_router::Request) -> Result { let fields = match submitted(&request, None) { Ok(fields) => fields, Err(pane) => return Ok(Response::fragment("contexts-detail", *pane)), }; state .contexts .create( DESKTOP_USER_ID, &fields.label, fields.kind, fields.starts_on, fields.ends_on, ) .map_err(|error| RouteError::internal(error.to_string()))?; wrote(state, "Context recorded.") } /// Re-label, re-kind or re-span one. fn update(state: &AppState, request: quasi_router::Request) -> Result { let id = context_id(&request)?; let existing = load(state, id)?; let fields = match submitted(&request, Some(&existing)) { Ok(fields) => fields, Err(pane) => return Ok(Response::fragment("contexts-detail", *pane)), }; state .contexts .update( DESKTOP_USER_ID, id, &fields.label, fields.kind, fields.starts_on, fields.ends_on, ) .map_err(|error| RouteError::internal(error.to_string()))?; wrote(state, "Context saved.") } /// Delete one, which puts a migrated event back. fn remove(state: &AppState, request: quasi_router::Request) -> Result { let id = context_id(&request)?; // Read before the delete, so the message can say which of the two things // happened rather than guessing. let existing = load(state, id)?; state .contexts .delete(DESKTOP_USER_ID, id) .map_err(|error| RouteError::internal(error.to_string()))?; wrote( state, match existing.migrated_from_event_id { Some(_) => "Context deleted, and its event is back on the timeline.", None => "Context deleted.", }, ) } /// The contexts screen's routes. #[must_use] pub fn routes(router: Router) -> Router { router .get("/contexts", index) .get("/contexts/new", new) .get("/contexts/{id}", detail) .post("/contexts", create) .post("/contexts/{id}", update) .post("/contexts/{id}/delete", remove) }