Skip to main content

max / goingson

21.0 KB · 639 lines History Blame Raw
1 //! Contexts, authored as spans rather than checked off a week.
2 //!
3 //! <!-- wiki: quasi-overview -->
4 //!
5 //! A context is a state that frames days, as distinct from an event that
6 //! occupies them. This is the place to record one as what it is: a label, a
7 //! kind, and two dates. The other two ways in are narrow: migration 067
8 //! converts a multi-day event or a `vacation_days` run, and the weekly review's
9 //! seven checkboxes write spans through
10 //! `commands::weekly_review::set_vacation_week`, which can only say *Vacation*,
11 //! only inside one week.
12 //!
13 //! # Where it goes, and why not the calendar
14 //!
15 //! Not a calendar. `4a1237b6` still stands: a context sits behind the day's axis
16 //! rather than on it, and putting it on a timeline is the conflation the model
17 //! exists to undo. Its own screen, marked at the Day place, reached from the day
18 //! band beside the banners it explains. It adds no place to [`shell`]: a twelfth
19 //! place would be this module answering a product question rather than a
20 //! placement one.
21 //!
22 //! # The shape
23 //!
24 //! - `GET /contexts` — the document: the list, and a pane.
25 //! - `GET /contexts/new` — the create form, in the pane.
26 //! - `GET /contexts/{id}` — the edit form, in the pane, filled.
27 //! - `POST /contexts` — create.
28 //! - `POST /contexts/{id}` — re-label, re-kind, re-span.
29 //! - `POST /contexts/{id}/delete` — delete, which puts a migrated event back.
30 //!
31 //! Every write answers with the whole screen for [`super::projects::wrote`]'s
32 //! reason: a write lands in the list and in the pane at once, and a `Response`
33 //! names one region.
34 //!
35 //! # What the description cannot say, and does not fake
36 //!
37 //! **A date interval is one question with two ends, and there is no way to say
38 //! so.** `FieldKind::Interval` exists and is exactly the right shape: it carries
39 //! two names, it says the ends constrain each other, and it gives a crossing
40 //! fault one place to be reported instead of two. What it cannot say is that its
41 //! ends are dates. The kind is the value's kind, so an interval's ends are
42 //! whatever `Interval` draws, and `quasi_webview::node::refill` emits them as
43 //! the numeric pair its two measured sites wanted (audiofiles' BPM axes, the MNW
44 //! server's price pair).
45 //!
46 //! So the span is two `FieldKind::Date` fields here, and the crossing rule is
47 //! checked in [`validate`] and reported on the start. That is the honest
48 //! description of two controls with no stated relationship, and it is worse than
49 //! the one member would be: "off from the 3rd to the 17th" is one decision, and
50 //! this screen exists because storing its projection as if it were the fact felt
51 //! wrong to write.
52 //!
53 //! What that gap wants is `Interval` learning what its ends are, not a new
54 //! member.
55
56 // Handlers take their request by value because `quasi_router::Handler` is a
57 // plain `fn(&S, Request)` pointer, so the signature is the router's and not a
58 // choice made here.
59 #![allow(clippy::needless_pass_by_value)]
60
61 use chrono::NaiveDate;
62 use goingson_core::id_types::ContextId;
63 use goingson_core::models::{Context, ContextKind};
64 use quasi_declare::declare;
65 use quasi_router::layout::Tone;
66 use quasi_router::screen::{Choice, Tag};
67 use quasi_router::{Node, Response, RouteError, Router, Slot};
68
69 use crate::state::{AppState, DESKTOP_USER_ID};
70
71 #[cfg(test)]
72 mod tests;
73
74 /// The kinds a person can choose.
75 ///
76 /// Every variant except `Other`, which is what an unknown stored kind parses to
77 /// rather than something anyone picks: offering it would ask the user to file a
78 /// context as "something else" when the label is already where they say what it
79 /// is.
80 const KINDS: &[ContextKind] = &[
81 ContextKind::Vacation,
82 ContextKind::Trip,
83 ContextKind::Illness,
84 ContextKind::Sprint,
85 ];
86
87 /// What a kind is called on screen.
88 ///
89 /// Apart from [`ContextKind::as_str`], which is the stored value and the wire's.
90 /// A display name that drifted from the stored one would be a second vocabulary;
91 /// these agree today and are separate so that a rename of either is not
92 /// automatically a migration.
93 fn kind_label(kind: ContextKind) -> &'static str {
94 match kind {
95 ContextKind::Vacation => "Vacation",
96 ContextKind::Trip => "Trip",
97 ContextKind::Illness => "Illness",
98 ContextKind::Sprint => "Sprint",
99 ContextKind::Other => "Other",
100 }
101 }
102
103 /// Every context, earliest first.
104 fn all(state: &AppState) -> Result<Vec<Context>, RouteError> {
105 state
106 .contexts
107 .list_all(DESKTOP_USER_ID)
108 .map_err(|error| RouteError::internal(error.to_string()))
109 }
110
111 /// One context by id.
112 ///
113 /// `ContextRepository` has no `get`, and adding one for this screen would be
114 /// storage work the task said this is not. The list is the whole set a person
115 /// authored, so scanning it costs nothing a person would notice.
116 fn load(state: &AppState, id: ContextId) -> Result<Context, RouteError> {
117 all(state)?
118 .into_iter()
119 .find(|context| context.id == id)
120 .ok_or_else(|| RouteError::not_found("no such context"))
121 }
122
123 /// The id in the path.
124 fn context_id(request: &quasi_router::Request) -> Result<ContextId, RouteError> {
125 let raw = request
126 .captures
127 .get("id")
128 .ok_or_else(|| RouteError::not_found("no context id"))?;
129 Ok(ContextId::from(
130 uuid::Uuid::parse_str(raw).map_err(|_| RouteError::not_found("not a context id"))?,
131 ))
132 }
133
134 /// The span, in words. The list says it as prose because it is what the record
135 /// *is*.
136 fn span_words(context: &Context) -> String {
137 let days = context.days();
138 format!(
139 "{} to {} ({} day{})",
140 context.starts_on.format("%-d %b %Y"),
141 context.ends_on.format("%-d %b %Y"),
142 days,
143 if days == 1 { "" } else { "s" }
144 )
145 }
146
147 /// Whether this row is the one the pane is showing.
148 fn is_current(context: &Context, current: Option<ContextId>) -> bool {
149 current == Some(context.id)
150 }
151
152 declare! {
153 /// How a context reads in the list.
154 ///
155 /// The span is the secondary line rather than a token, because it is what
156 /// the record *is*: a label without its dates is not a context, and a token
157 /// would rank it beside the kind.
158 shape row_for(context: &Context, current: bool) -> Row;
159
160 row &context.label {
161 token Tag::badge(kind_label(context.kind));
162 secondary span_words(context);
163 token Tag::badge("From an event") when context.migrated_from_event_id.is_some();
164 current current;
165 activate to get "/contexts/{context.id}";
166 }
167 }
168
169 declare! {
170 /// The list of contexts.
171 shape list_node(contexts: &[Context], current: Option<ContextId>) -> Node;
172
173 given contexts.is_empty() {
174 true -> empty "No contexts yet." {
175 offering "Record your first context" to get "/contexts/new";
176 }
177 otherwise -> list {
178 for context in contexts.iter() {
179 include row_for(context, is_current(context, current));
180 }
181 }
182 }
183 }
184
185 /// What the pane is showing: the context being edited if there is one, what was
186 /// refused, and what was typed.
187 ///
188 /// `existing` fills the fields for an edit; `submitted` refills them after a
189 /// refusal, and wins, because what the user just typed is nearer to what they
190 /// meant than what is stored. [`Field::refilled`] is the same repair `1c4a66a4`
191 /// closed on quasi-router.
192 struct Editing<'a> {
193 existing: Option<&'a Context>,
194 errors: &'a [(&'a str, String)],
195 submitted: Option<&'a quasi_router::Params>,
196 }
197
198 impl<'a> Editing<'a> {
199 /// A fresh form.
200 const fn fresh() -> Self {
201 Self {
202 existing: None,
203 errors: &[],
204 submitted: None,
205 }
206 }
207
208 /// The form over an existing context.
209 const fn of(context: &'a Context) -> Self {
210 Self {
211 existing: Some(context),
212 errors: &[],
213 submitted: None,
214 }
215 }
216 }
217
218 /// Whether the pane is editing rather than creating.
219 fn is_edit(editing: &Editing) -> bool {
220 editing.existing.is_some()
221 }
222
223 /// What the pane is called.
224 fn pane_heading(editing: &Editing) -> String {
225 match editing.existing {
226 None => "New context".to_owned(),
227 Some(context) => format!("Editing {}", context.label),
228 }
229 }
230
231 /// Whether this context was converted from an event.
232 fn from_event(editing: &Editing) -> bool {
233 editing
234 .existing
235 .is_some_and(|context| context.migrated_from_event_id.is_some())
236 }
237
238 /// Where the form writes.
239 fn form_path(editing: &Editing) -> String {
240 match editing.existing {
241 None => "/contexts".to_owned(),
242 Some(context) => format!("/contexts/{}", context.id),
243 }
244 }
245
246 /// What the form's button reads.
247 fn submit_label(editing: &Editing) -> &'static str {
248 match editing.existing {
249 None => "Record context",
250 Some(_) => "Save",
251 }
252 }
253
254 /// The id being edited, for the addresses that name it.
255 ///
256 /// R9: every hole in a guarded member is read whether or not the member is
257 /// placed, so this answers with nothing on a create rather than refusing.
258 fn existing_id(editing: &Editing) -> String {
259 editing
260 .existing
261 .map(|context| context.id.to_string())
262 .unwrap_or_default()
263 }
264
265 /// What Delete asks before it happens.
266 ///
267 /// Provenance is the reason for the two questions: deleting a migrated context
268 /// is the one delete on this screen that does something to a record the user
269 /// did not author here.
270 fn delete_question(editing: &Editing) -> &'static str {
271 if from_event(editing) {
272 "Delete this context and put its event back?"
273 } else {
274 "Delete this context?"
275 }
276 }
277
278 /// The label a stored context holds, if the pane is editing one.
279 fn stored_label(editing: &Editing) -> Option<String> {
280 editing.existing.map(|context| context.label.clone())
281 }
282
283 /// The kind a stored context holds, as it is stored.
284 fn stored_kind<'a>(editing: &Editing<'a>) -> Option<&'a str> {
285 editing.existing.map(|context| context.kind.as_str())
286 }
287
288 /// The first day a stored context holds.
289 fn stored_start(editing: &Editing) -> Option<String> {
290 editing
291 .existing
292 .map(|context| context.starts_on.to_string())
293 }
294
295 /// The last day a stored context holds.
296 fn stored_end(editing: &Editing) -> Option<String> {
297 editing.existing.map(|context| context.ends_on.to_string())
298 }
299
300 /// Whether a named field was refused.
301 fn has_error(editing: &Editing, name: &str) -> bool {
302 editing.errors.iter().any(|(field, _)| *field == name)
303 }
304
305 /// Why it was refused, or nothing.
306 fn error_for(editing: &Editing, name: &str) -> String {
307 editing
308 .errors
309 .iter()
310 .find(|(field, _)| *field == name)
311 .map(|(_, message)| message.clone())
312 .unwrap_or_default()
313 }
314
315 /// Nothing was typed, which is what a form that is not answering a refusal
316 /// refills from.
317 static NOTHING_TYPED: quasi_router::Params = quasi_router::Params::new();
318
319 /// What was typed, or nothing.
320 ///
321 /// Empty rather than absent, because [`Field::refilled`] leaves a name it finds
322 /// nothing under alone: refilling from nothing is the same field back, so the
323 /// setting needs no guard.
324 fn typed<'a>(editing: &Editing<'a>) -> &'a quasi_router::Params {
325 editing.submitted.unwrap_or(&NOTHING_TYPED)
326 }
327
328 declare! {
329 /// The pane: a form, and for an edit the things only an edit can offer.
330 ///
331 /// The span is two `Date` fields rather than one interval. See the module
332 /// header.
333 ///
334 /// `refilled` goes last on every field because it overrides what is stored
335 /// with what was typed, which is the order the repair wants.
336 shape form_slot(editing: &Editing) -> Slot;
337
338 region "contexts-detail" as Pane {
339 section pane_heading(editing);
340
341 // Provenance, and the reversal it buys, said before the controls rather
342 // than after.
343 banner Tone::Info
344 "Converted from an event. Deleting this puts that event back on the \
345 timeline, with its times intact."
346 when from_event(editing);
347
348 form post form_path(editing) {
349 submit submit_label(editing);
350
351 field Text "label" "Label" {
352 required;
353 placeholder "Two weeks in Lisbon";
354 for label in stored_label(editing).into_iter() {
355 value label;
356 }
357 error error_for(editing, "label") when has_error(editing, "label");
358 refilled typed(editing);
359 }
360
361 field Select "kind" "Kind" {
362 for kind in KINDS.iter().copied() {
363 option Choice::new(kind.as_str(), kind_label(kind));
364 }
365 for kind in stored_kind(editing).into_iter() {
366 value kind;
367 }
368 error error_for(editing, "kind") when has_error(editing, "kind");
369 refilled typed(editing);
370 }
371
372 field Date "starts_on" "First day" {
373 required;
374 hint "The first day inside it.";
375 for start in stored_start(editing).into_iter() {
376 value start;
377 }
378 error error_for(editing, "starts_on") when has_error(editing, "starts_on");
379 refilled typed(editing);
380 }
381
382 field Date "ends_on" "Last day" {
383 required;
384 hint "Inclusive: off until the 17th means the 17th is off.";
385 for end in stored_end(editing).into_iter() {
386 value end;
387 }
388 error error_for(editing, "ends_on") when has_error(editing, "ends_on");
389 refilled typed(editing);
390 }
391 }
392
393 act "Delete" to post "/contexts/{existing_id(editing)}/delete" when is_edit(editing) {
394 tone Danger;
395 confirm delete_question(editing);
396 }
397 }
398 }
399
400 declare! {
401 /// The pane before anything is selected.
402 shape idle_pane() -> Slot;
403
404 region "contexts-detail" as Pane {
405 empty "Nothing selected";
406 }
407 }
408
409 declare! {
410 /// The whole screen.
411 ///
412 /// The Day place rather than a twelfth one: a context frames a day, and the
413 /// day view is where its banner is read.
414 shape screen(contexts: &[Context], current: Option<ContextId>, pane: Slot) -> Screen;
415
416 screen list_detail "Contexts" false {
417 at_place super::shell::DAY;
418
419 region "contexts-band" as Band {
420 page "Contexts";
421 act "New context" to get "/contexts/new";
422 }
423
424 region "contexts-list" as Pane {
425 include list_node(contexts, current);
426 }
427
428 include pane;
429 }
430 }
431
432 /// The document, with nothing selected.
433 fn document(state: &AppState, message: Option<&str>) -> Result<Response, RouteError> {
434 let contexts = all(state)?;
435 let screen = screen(&contexts, None, idle_pane());
436 Ok(match message {
437 Some(said) => Response::from(screen).toast(Tone::Success, said),
438 None => screen.into(),
439 })
440 }
441
442 /// The document.
443 fn index(state: &AppState, _request: quasi_router::Request) -> Result<Response, RouteError> {
444 document(state, None)
445 }
446
447 /// The create form, in the pane.
448 fn new(_state: &AppState, _request: quasi_router::Request) -> Result<Response, RouteError> {
449 Ok(Response::fragment(
450 "contexts-detail",
451 Node::Region(form_slot(&Editing::fresh())),
452 ))
453 }
454
455 /// The edit form, filled, in the pane.
456 fn detail(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
457 let context = load(state, context_id(&request)?)?;
458 Ok(Response::fragment(
459 "contexts-detail",
460 Node::Region(form_slot(&Editing::of(&context))),
461 ))
462 }
463
464 /// What a context has to be before it can be recorded.
465 ///
466 /// The crossing rule is checked rather than corrected. `ContextRepository`
467 /// orders the ends on the way in, on the argument that a span typed backwards is
468 /// a slip whose days are unambiguous, and that is right for a migration reading
469 /// stored data. It is wrong for a form: silently swapping what someone typed
470 /// tells them nothing, and the next thing they do is wonder why the dates moved.
471 fn validate(
472 label: &str,
473 starts_on: Option<NaiveDate>,
474 ends_on: Option<NaiveDate>,
475 ) -> Vec<(&'static str, String)> {
476 let mut errors = Vec::new();
477 if label.is_empty() {
478 errors.push(("label", "A context needs a label.".to_owned()));
479 } else if label.chars().count() > 100 {
480 errors.push(("label", "Maximum 100 characters".to_owned()));
481 }
482 if starts_on.is_none() {
483 errors.push(("starts_on", "Needs a first day.".to_owned()));
484 }
485 if ends_on.is_none() {
486 errors.push(("ends_on", "Needs a last day.".to_owned()));
487 }
488 if let (Some(starts), Some(ends)) = (starts_on, ends_on)
489 && starts > ends
490 {
491 errors.push((
492 "starts_on",
493 "The first day is after the last one.".to_owned(),
494 ));
495 }
496 errors
497 }
498
499 /// A date out of the payload, or `None` if it is missing or unparseable.
500 ///
501 /// Both cases are the same answer here because both are reported the same way:
502 /// `FieldKind::Date` states the wire format, so a value that will not parse did
503 /// not come from the control.
504 fn date(params: &quasi_router::Params, name: &str) -> Option<NaiveDate> {
505 params.get(name)?.trim().parse().ok()
506 }
507
508 /// What every write answers with.
509 ///
510 /// The whole screen, for [`super::projects`]'s reason: a write lands in the list
511 /// and in the pane at once, and a `Response` names one region. `outerMorph`
512 /// keeps focus and scroll across it.
513 fn wrote(state: &AppState, message: &str) -> Result<Response, RouteError> {
514 document(state, Some(message))
515 }
516
517 /// The fields a write reads, validated together.
518 struct Submitted {
519 label: String,
520 kind: ContextKind,
521 starts_on: NaiveDate,
522 ends_on: NaiveDate,
523 }
524
525 /// Read and check a submission, or hand back the form saying why not.
526 ///
527 /// Every complaint at once. Answering with the first one found is how a form is
528 /// fixed one round trip per mistake.
529 fn submitted(
530 request: &quasi_router::Request,
531 existing: Option<&Context>,
532 ) -> Result<Submitted, Box<Node>> {
533 let label = request
534 .payload
535 .get("label")
536 .unwrap_or_default()
537 .trim()
538 .to_owned();
539 let starts_on = date(&request.payload, "starts_on");
540 let ends_on = date(&request.payload, "ends_on");
541
542 let errors = validate(&label, starts_on, ends_on);
543 if !errors.is_empty() {
544 return Err(Box::new(Node::Region(form_slot(&Editing {
545 existing,
546 errors: &errors,
547 submitted: Some(&request.payload),
548 }))));
549 }
550
551 // Unlike a status or a project type, an unknown kind cannot be refused:
552 // `ContextKind::parse` maps one to `Other` and cannot fail, deliberately,
553 // because a row written by a newer client is still a real span of days.
554 Ok(Submitted {
555 label,
556 kind: ContextKind::parse(request.payload.get("kind").unwrap_or_default()),
557 starts_on: starts_on.expect("validated"),
558 ends_on: ends_on.expect("validated"),
559 })
560 }
561
562 /// Record a context.
563 fn create(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
564 let fields = match submitted(&request, None) {
565 Ok(fields) => fields,
566 Err(pane) => return Ok(Response::fragment("contexts-detail", *pane)),
567 };
568
569 state
570 .contexts
571 .create(
572 DESKTOP_USER_ID,
573 &fields.label,
574 fields.kind,
575 fields.starts_on,
576 fields.ends_on,
577 )
578 .map_err(|error| RouteError::internal(error.to_string()))?;
579
580 wrote(state, "Context recorded.")
581 }
582
583 /// Re-label, re-kind or re-span one.
584 fn update(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
585 let id = context_id(&request)?;
586 let existing = load(state, id)?;
587 let fields = match submitted(&request, Some(&existing)) {
588 Ok(fields) => fields,
589 Err(pane) => return Ok(Response::fragment("contexts-detail", *pane)),
590 };
591
592 state
593 .contexts
594 .update(
595 DESKTOP_USER_ID,
596 id,
597 &fields.label,
598 fields.kind,
599 fields.starts_on,
600 fields.ends_on,
601 )
602 .map_err(|error| RouteError::internal(error.to_string()))?;
603
604 wrote(state, "Context saved.")
605 }
606
607 /// Delete one, which puts a migrated event back.
608 fn remove(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
609 let id = context_id(&request)?;
610 // Read before the delete, so the message can say which of the two things
611 // happened rather than guessing.
612 let existing = load(state, id)?;
613
614 state
615 .contexts
616 .delete(DESKTOP_USER_ID, id)
617 .map_err(|error| RouteError::internal(error.to_string()))?;
618
619 wrote(
620 state,
621 match existing.migrated_from_event_id {
622 Some(_) => "Context deleted, and its event is back on the timeline.",
623 None => "Context deleted.",
624 },
625 )
626 }
627
628 /// The contexts screen's routes.
629 #[must_use]
630 pub fn routes(router: Router<AppState>) -> Router<AppState> {
631 router
632 .get("/contexts", index)
633 .get("/contexts/new", new)
634 .get("/contexts/{id}", detail)
635 .post("/contexts", create)
636 .post("/contexts/{id}", update)
637 .post("/contexts/{id}/delete", remove)
638 }
639