Skip to main content

max / goingson

24.1 KB · 693 lines History Blame Raw
1 //! The monthly review, described rather than built.
2 //!
3 //! <!-- wiki: quasi-overview -->
4 //!
5 //! # The shape
6 //!
7 //! - `GET /monthly-review` — the whole review, for `?month=` or for this month.
8 //! - `POST /monthly-review/goals` — add a goal at a position, under `text`.
9 //! - `POST /monthly-review/goals/{id}/status` — set a goal's status, under
10 //! `status`.
11 //! - `POST /monthly-review/goals/{id}/delete` — delete a goal.
12 //! - `POST /monthly-review/complete` — save the reflection.
13 //!
14 //! Every described control reaches one of those.
15 //!
16 //! # The month is an address, not a variable
17 //!
18 //! A query param, per decision 2, exactly as the week is on the sibling screen,
19 //! and with the same consequence: every action carries the month it was offered
20 //! under or acting silently moves the user to this month and writes there.
21 //! [`in_month`] is that, applied to all five routes and to all three navigation
22 //! controls.
23
24 // Handlers take their request by value because `quasi_router::Handler` is a
25 // plain `fn(&S, Request)` pointer, so the signature is the router's and not a
26 // choice made here. Same allow, for the same reason, as quasi-axum's tests.
27 #![allow(clippy::needless_pass_by_value)]
28
29 use chrono::{Datelike, NaiveDate};
30 use goingson_core::monthly_review::{self, MonthlyReviewData, ProjectPulse};
31 use goingson_core::{MonthlyGoal, MonthlyGoalStatus, Task};
32 use makeover_layout::Tone;
33 use quasi_declare::declare;
34 use quasi_router::screen::{Figure, Tag};
35 use quasi_router::{Action, Response, RouteError, Router};
36
37 use crate::commands::gather_monthly_review;
38 use crate::state::{AppState, DESKTOP_USER_ID};
39
40 #[cfg(test)]
41 mod tests;
42
43 /// How many goals a month holds.
44 ///
45 /// `monthly-review-render.js:renderGoals` counts to 3 and the repository
46 /// enforces nothing, so this is the screen's rule and it is stated once here
47 /// rather than in the two places that ask about it. Same shape as the weekly
48 /// review's `FOCUS_SLOTS`, and the same reason.
49 const GOAL_SLOTS: i32 = 3;
50
51 /// The month a route was addressed at, or this one.
52 ///
53 /// An unparseable `month` is this month rather than a 400, matching
54 /// [`resolve_month_start`](crate::commands::resolve_month_start)'s tolerance at
55 /// the command layer only in outcome:
56 /// there a bad value is a client bug worth reporting, and here it is a
57 /// hand-typed address, where landing on this month is the more useful answer
58 /// than an error page.
59 fn month_of(request: &quasi_router::Request) -> NaiveDate {
60 request
61 .carried
62 .get("month")
63 .and_then(monthly_review::parse_month)
64 .unwrap_or_else(monthly_review::current_month_start)
65 }
66
67 /// The same action, still pointed at the month it was offered under.
68 fn in_month(action: Action, month: NaiveDate) -> Action {
69 action.carrying("month", month.format("%Y-%m").to_string())
70 }
71
72 /// The month before this one, and the month after.
73 ///
74 /// Written here rather than with `Duration` because months are not a fixed
75 /// number of days: stepping 31 days back from the 1st of March lands in
76 /// January.
77 fn step(month: NaiveDate, forward: bool) -> NaiveDate {
78 let (year, number) = match (month.month(), forward) {
79 (12, true) => (month.year() + 1, 1),
80 (1, false) => (month.year() - 1, 12),
81 (m, true) => (month.year(), m + 1),
82 (m, false) => (month.year(), m - 1),
83 };
84 NaiveDate::from_ymd_opt(year, number, 1).unwrap_or(month)
85 }
86
87 /// Read the month.
88 fn load(state: &AppState, month: NaiveDate) -> Result<MonthlyReviewData, RouteError> {
89 gather_monthly_review(state, month).map_err(|error| RouteError::internal(error.to_string()))
90 }
91
92 /// The tone a project's health wears.
93 ///
94 /// The weekly review's `health_tone`, and deliberately a copy rather than a
95 /// shared helper: it is four lines, the two screens read the same core strings,
96 /// and hoisting it would put a lookup table in a `super` module that exists to
97 /// hold routers. If a third screen wants it, that is the second consumer and
98 /// the argument changes.
99 fn health_tone(status: &str) -> makeover_layout::Tone {
100 match status {
101 "healthy" => makeover_layout::Tone::Success,
102 "warning" => makeover_layout::Tone::Warning,
103 "danger" => makeover_layout::Tone::Danger,
104 _ => makeover_layout::Tone::Neutral,
105 }
106 }
107
108 declare! {
109 /// One task, the way every list on this screen writes one.
110 ///
111 /// The project is `meta` rather than a token, for the reason the weekly
112 /// review gives: a plain trailing fact with no tone of its own and no click
113 /// to answer.
114 shape task_row(task: &Task) -> Row;
115
116 row &task.title {
117 for project in task.project_name.iter() {
118 meta project;
119 }
120 }
121 }
122
123 /// One day the month recorded something on.
124 struct Marked {
125 number: u32,
126 is_today: bool,
127 is_vacation: bool,
128 /// What happened on it, or nothing.
129 counts: String,
130 }
131
132 /// Whether the day has anything to put in its trailing slot.
133 fn has_counts(day: &Marked) -> bool {
134 !day.counts.is_empty()
135 }
136
137 /// One goal, with the move it offers next.
138 ///
139 /// # The second finding
140 ///
141 /// **A control that cycles hidden state cannot be described, and should not
142 /// be.**
143 ///
144 /// `monthly-review.js:cycleGoalStatus` read the goal out of module state,
145 /// looked up `active -> done -> abandoned -> active`, and wrote the next one.
146 /// Two things were wrong with it and only one was the description layer's.
147 ///
148 /// The describable half: a button labelled with the *current* status, whose
149 /// effect is a table the user cannot see, says nothing about what pressing it
150 /// will do. Here each goal offers the move by name -- "Mark done", "Give up on
151 /// it", "Make it active again" -- so the label is the outcome.
152 ///
153 /// Naming the target also removes a race: computing the next status from a copy
154 /// read at render time lets a second window write a status derived from what it
155 /// saw rather than from what is stored. The route never has to know what the
156 /// goal was before.
157 struct Goal {
158 stored: MonthlyGoal,
159 /// What the move is called.
160 move_label: &'static str,
161 /// The status that move writes.
162 next: MonthlyGoalStatus,
163 /// What the goal is now.
164 status_label: &'static str,
165 status_tone: makeover_layout::Tone,
166 }
167
168 /// Everything the review draws, read once.
169 struct Review {
170 data: MonthlyReviewData,
171 month: NaiveDate,
172 /// The days that had something on them.
173 ///
174 /// Empty days are left out rather than listed as zeroes. Thirty-one rows of
175 /// which twenty say nothing is a worse reading of the month than eleven
176 /// that do, and the totals underneath already say how much of the month was
177 /// quiet.
178 days: Vec<Marked>,
179 goals: Vec<Goal>,
180 }
181
182 /// Read the month.
183 fn read(state: &AppState, month: NaiveDate) -> Result<Review, RouteError> {
184 let data = load(state, month)?;
185
186 let days = data
187 .days
188 .iter()
189 .filter(|day| day.completed_count > 0 || day.event_count > 0 || day.is_vacation)
190 .map(|day| {
191 let mut counts = Vec::new();
192 if day.completed_count > 0 {
193 counts.push(format!("{} done", day.completed_count));
194 }
195 if day.event_count > 0 {
196 counts.push(format!("{} events", day.event_count));
197 }
198 Marked {
199 number: day.day_number,
200 is_today: day.is_today,
201 is_vacation: day.is_vacation,
202 counts: counts.join(", "),
203 }
204 })
205 .collect();
206
207 let goals = data
208 .goals
209 .iter()
210 .map(|goal| {
211 let (move_label, next) = match goal.status {
212 MonthlyGoalStatus::Active => ("Mark done", MonthlyGoalStatus::Done),
213 MonthlyGoalStatus::Done => ("Give up on it", MonthlyGoalStatus::Abandoned),
214 MonthlyGoalStatus::Abandoned => ("Make it active again", MonthlyGoalStatus::Active),
215 };
216 let (status_label, status_tone) = match goal.status {
217 MonthlyGoalStatus::Active => ("Active", makeover_layout::Tone::Info),
218 MonthlyGoalStatus::Done => ("Done", makeover_layout::Tone::Success),
219 MonthlyGoalStatus::Abandoned => ("Abandoned", makeover_layout::Tone::Neutral),
220 };
221 Goal {
222 stored: goal.clone(),
223 move_label,
224 next,
225 status_label,
226 status_tone,
227 }
228 })
229 .collect();
230
231 Ok(Review {
232 data,
233 month,
234 days,
235 goals,
236 })
237 }
238
239 declare! {
240 /// The month at a glance.
241 ///
242 /// **`intensity` is a renderer's encoding of a number, and the description
243 /// carries the number.** `MonthDayData` carries both `completed_count` and
244 /// `intensity`, a 0-3 bucket. A shade is one renderer's way of saying "a
245 /// lot", it runs out of room at 3, and a host with room to print `12`
246 /// should print `12`. So the days below carry counts and `intensity` is not
247 /// described.
248 ///
249 /// A day off is why the counts are absent rather than zero, so it is a
250 /// token and not merely a shade on the cell.
251 ///
252 /// # The grid is not described
253 ///
254 /// A month grid is [`RegionKind::Ceded`]'s shape. A list of days that had
255 /// something on them keeps every fact the grid carries except the shape,
256 /// and inventing a `Node::Calendar` to keep the shape is a vocabulary
257 /// decision this screen has no standing to make alone.
258 shape heat_map(review: &Review) -> Vec<Node>;
259
260 section "The Month";
261
262 empty "Nothing recorded this month yet." when review.days.is_empty();
263
264 list {
265 for day in review.days.iter() {
266 row day.number.to_string() {
267 token Tag::badge("Today").tone(Tone::Info) when day.is_today;
268 token Tag::badge("Day off") when day.is_vacation;
269 meta &day.counts when has_counts(day);
270 }
271 }
272 } unless review.days.is_empty();
273 }
274
275 declare! {
276 /// What the month added up to.
277 ///
278 /// Figures rather than prose, which is what `renderStats` draws and what
279 /// the numbers are. The busiest and quietest days are dates the core crate
280 /// has already formatted, and they are absent rather than zero when the
281 /// month has not produced one: a month with no completions has no busiest
282 /// day, and "None" would be a different claim.
283 shape numbers(review: &Review) -> Vec<Node>;
284
285 section "The Numbers";
286
287 stats [] {
288 figure Figure::new(review.data.tasks_completed_count.to_string(), "Tasks Completed");
289 figure Figure::new(review.data.tasks_created_count.to_string(), "Tasks Created");
290 figure Figure::new(review.data.events_count.to_string(), "Events");
291 figure Figure::new(review.data.completion_streak.to_string(), "Longest Streak");
292
293 for busiest in review.data.busiest_day.iter() {
294 figure Figure::new(busiest, "Busiest Day");
295 }
296 for quietest in review.data.quietest_day.iter() {
297 figure Figure::new(quietest, "Quietest Day");
298 }
299 }
300 }
301
302 declare! {
303 /// The tasks the month finished.
304 ///
305 /// Core caps this at six for the card; the cap is the data's and not the
306 /// description's, so nothing is truncated again here. The count above it is
307 /// the real total, which is what makes the cap readable rather than
308 /// misleading.
309 shape accomplished(review: &Review) -> Vec<Node>;
310
311 section "Accomplished" unless review.data.tasks_completed_top.is_empty();
312
313 list {
314 for task in review.data.tasks_completed_top.iter() {
315 include task_row(task);
316 }
317 } unless review.data.tasks_completed_top.is_empty();
318 }
319
320 /// What a project's direction is called.
321 ///
322 /// `direction` is a string core writes ("growing", "shrinking", "stable"). The
323 /// direction is the fact and an arrow glyph is one renderer's spelling of it,
324 /// so this says the word and [`pulse_tone`] tones it: a project that closed
325 /// more than it opened is the good case, which no glyph conveys on its own.
326 fn pulse_label(project: &ProjectPulse) -> &'static str {
327 match project.direction.as_str() {
328 "shrinking" => "Shrinking",
329 "growing" => "Growing",
330 _ => "Stable",
331 }
332 }
333
334 /// What that direction says about itself.
335 fn pulse_tone(project: &ProjectPulse) -> makeover_layout::Tone {
336 match project.direction.as_str() {
337 "shrinking" => makeover_layout::Tone::Success,
338 "growing" => makeover_layout::Tone::Warning,
339 _ => makeover_layout::Tone::Neutral,
340 }
341 }
342
343 declare! {
344 /// Which way each project moved.
345 shape project_pulse(review: &Review) -> Vec<Node>;
346
347 section "Project Pulse" unless review.data.project_pulse.is_empty();
348
349 list {
350 for project in review.data.project_pulse.iter() {
351 row &project.name {
352 token Tag::badge(pulse_label(project)).tone(pulse_tone(project));
353 meta "{project.completed} done, {project.created} added";
354 }
355 }
356 } unless review.data.project_pulse.is_empty();
357 }
358
359 declare! {
360 /// How each project is doing, on the same three-value scale the weekly
361 /// review reads.
362 shape projects_health(review: &Review) -> Vec<Node>;
363
364 section "Project Health" unless review.data.project_health.is_empty();
365
366 list {
367 for project in review.data.project_health.iter() {
368 row &project.name {
369 token Tag::badge(&project.status).tone(health_tone(&project.status));
370 }
371 }
372 } unless review.data.project_health.is_empty();
373 }
374
375 declare! {
376 /// What the month said about itself.
377 ///
378 /// Core computes these as finished sentences, so there is nothing here to
379 /// describe beyond saying they are a list of statements rather than a
380 /// paragraph.
381 shape patterns(review: &Review) -> Vec<Node>;
382
383 section "Patterns" unless review.data.patterns.is_empty();
384
385 list {
386 for pattern in review.data.patterns.iter() {
387 row pattern;
388 }
389 } unless review.data.patterns.is_empty();
390 }
391
392 declare! {
393 /// One goal, with the move it offers and the way to drop it.
394 shape goal_row(review: &Review, goal: &Goal) -> Row;
395
396 row &goal.stored.text {
397 token Tag::badge(goal.status_label).tone(goal.status_tone);
398
399 act goal.move_label
400 to doing in_month(
401 Action::post("/monthly-review/goals/{goal.stored.id}/status")
402 .with("status", goal.next.as_str()),
403 review.month
404 );
405
406 act "Delete"
407 to doing in_month(
408 Action::post("/monthly-review/goals/{goal.stored.id}/delete"),
409 review.month
410 ) {
411 tone Danger;
412 confirm "Are you sure you want to delete this goal?";
413 }
414 }
415 }
416
417 /// Whether the month has a goal slot left.
418 fn has_room(review: &Review) -> bool {
419 i32::try_from(review.goals.len()).unwrap_or(GOAL_SLOTS) < GOAL_SLOTS
420 }
421
422 declare! {
423 /// The month's goals, and the empty slot left.
424 ///
425 /// The JS draws one empty slot per remaining position, each opening the
426 /// same modal. One form is the same offer without pretending the positions
427 /// differ: the next one is the next one.
428 ///
429 /// The JS marks the box `required: true`, so a host that can refuse an
430 /// empty one refuses it before anything is sent. The check in `add_goal` is
431 /// the backstop for a request that did not come through the form.
432 shape goals(review: &Review) -> Vec<Node>;
433
434 section "Goals";
435
436 for goal in review.goals.iter() {
437 list {
438 include goal_row(review, goal);
439 }
440 }
441
442 form doing in_month(Action::post("/monthly-review/goals"), review.month)
443 when has_room(review) {
444 submit "Add goal";
445
446 field Text "text" "Goal" {
447 placeholder "What do you want to achieve this month?";
448 required;
449 }
450 }
451 }
452
453 /// What the reflection's button reads.
454 ///
455 /// A month already reviewed is being edited rather than completed.
456 fn reflection_submit(review: &Review) -> &'static str {
457 if review.data.reflection.is_some() {
458 "Save notes"
459 } else {
460 "Complete review"
461 }
462 }
463
464 /// Whether the month has been reviewed.
465 fn reviewed(review: &Review) -> bool {
466 review.data.reflection.is_some()
467 }
468
469 /// What was said about the highlight.
470 fn highlight(review: &Review) -> String {
471 review
472 .data
473 .reflection
474 .as_ref()
475 .map(|saved| saved.highlight_text.clone())
476 .unwrap_or_default()
477 }
478
479 /// What was said about what to change.
480 fn changed(review: &Review) -> String {
481 review
482 .data
483 .reflection
484 .as_ref()
485 .map(|saved| saved.change_text.clone())
486 .unwrap_or_default()
487 }
488
489 declare! {
490 /// The reflection.
491 ///
492 /// Two stored columns rather than the weekly review's one blob, so none of
493 /// that screen's marker-parsing is needed here. The prompts are the JS's,
494 /// verbatim, including the placeholders: they are the question being asked
495 /// and not decoration.
496 ///
497 /// The draft finding the weekly review filed applies unchanged -- this
498 /// screen's JS keeps unsent keystrokes in `localStorage` too, and [`Field`]
499 /// still cannot say a value is a draft. Recorded rather than re-filed: it
500 /// is one gap with two consumers, which is the note quasicoherent already
501 /// holds.
502 shape reflection(review: &Review) -> Vec<Node>;
503
504 section "Reflection";
505
506 form doing in_month(Action::post("/monthly-review/complete"), review.month) {
507 submit reflection_submit(review);
508
509 field Textarea "highlight" "What was the highlight of this month?" {
510 placeholder "Shipped the thing I had been putting off...";
511 value highlight(review);
512 }
513
514 field Textarea "change" "What would you change?" {
515 placeholder "Too many small tasks, not enough deep work...";
516 value changed(review);
517 }
518 }
519 }
520
521 declare! {
522 /// The whole screen.
523 ///
524 /// Declared rather than built inside each route for the reason the projects
525 /// screen gives: a write lands in more than one section -- completing a goal
526 /// changes the goal list and the banner above it -- and a `Response` names
527 /// one region.
528 ///
529 /// "This month" is bare, with no month on it: it is the one control whose
530 /// whole job is to leave the month it was offered under.
531 shape screen(review: &Review) -> Screen;
532
533 screen sidebar_content "Monthly Review" {
534 at_place super::shell::MONTH;
535
536 region "month-band" as Band {
537 page &review.data.month_display;
538
539 act "Previous month"
540 to doing in_month(Action::get("/monthly-review"), step(review.month, false));
541 act "Next month"
542 to doing in_month(Action::get("/monthly-review"), step(review.month, true));
543 act "This month" to get "/monthly-review";
544 }
545
546 region "monthly-review" as Pane {
547 banner Tone::Info "This month is already reviewed. Your notes stay editable."
548 when reviewed(review);
549
550 extend heat_map(review);
551 extend numbers(review);
552 extend accomplished(review);
553 extend project_pulse(review);
554 extend projects_health(review);
555 extend goals(review);
556 extend patterns(review);
557 extend reflection(review);
558 }
559 }
560 }
561
562 /// Answer a write with the month it happened in, re-read.
563 ///
564 /// Re-read rather than patched in memory, for the reason the contacts port
565 /// gives: the write is the database's to confirm, and a screen rebuilt from
566 /// what the handler hoped happened is how a screen disagrees with its own
567 /// storage.
568 fn wrote(state: &AppState, month: NaiveDate) -> Result<Response, RouteError> {
569 Ok(screen(&read(state, month)?).into())
570 }
571
572 /// The whole review.
573 fn review(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
574 wrote(state, month_of(&request))
575 }
576
577 /// The month a write names, as the repository spells it.
578 fn month_key(month: NaiveDate) -> String {
579 month.format("%Y-%m").to_string()
580 }
581
582 /// The goal id in a path, or a 404.
583 fn goal_id(request: &quasi_router::Request) -> Result<goingson_core::MonthlyGoalId, RouteError> {
584 let raw = request
585 .captures
586 .get("id")
587 .ok_or_else(|| RouteError::not_found("no goal id"))?;
588 Ok(goingson_core::MonthlyGoalId::from(
589 uuid::Uuid::parse_str(raw).map_err(|_| RouteError::not_found("not a goal id"))?,
590 ))
591 }
592
593 /// Add a goal at the next free position.
594 ///
595 /// The position is computed here rather than sent, which is the difference from
596 /// the JS: `addGoal(month, position)` is called from a specific empty slot, so
597 /// the position is a fact about which slot was clicked. A described form has no
598 /// slot, and "the next one" is what every one of those clicks meant.
599 fn add_goal(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
600 let month = month_of(&request);
601 let text = request.payload.get("text").unwrap_or_default().trim();
602 if text.is_empty() {
603 return Err(RouteError::conflict("A goal needs some text"));
604 }
605
606 let key = month_key(month);
607 let taken = state
608 .monthly_reviews
609 .list_goals(DESKTOP_USER_ID, &key)
610 .map_err(|error| RouteError::internal(error.to_string()))?;
611 if i32::try_from(taken.len()).unwrap_or(GOAL_SLOTS) >= GOAL_SLOTS {
612 return Err(RouteError::conflict("This month already has three goals"));
613 }
614
615 // The first position nothing holds, rather than one past the count: a month
616 // whose middle goal was deleted has a free slot in the middle, and counting
617 // would collide with the last one.
618 let position = (1..=GOAL_SLOTS)
619 .find(|slot| !taken.iter().any(|goal| goal.position == *slot))
620 .ok_or_else(|| RouteError::conflict("This month already has three goals"))?;
621
622 state
623 .monthly_reviews
624 .upsert_goal(DESKTOP_USER_ID, &key, text, position)
625 .map_err(|error| RouteError::internal(error.to_string()))?;
626 wrote(state, month)
627 }
628
629 /// Set a goal's status to the one the control named.
630 fn set_goal_status(
631 state: &AppState,
632 request: quasi_router::Request,
633 ) -> Result<Response, RouteError> {
634 let month = month_of(&request);
635 let id = goal_id(&request)?;
636 let status: MonthlyGoalStatus = request
637 .payload
638 .get("status")
639 .ok_or_else(|| RouteError::not_found("no status"))?
640 .parse()
641 .map_err(|_| RouteError::not_found("not a goal status"))?;
642
643 state
644 .monthly_reviews
645 .update_goal_status(id, DESKTOP_USER_ID, &status)
646 .map_err(|error| RouteError::internal(error.to_string()))?
647 .ok_or_else(|| RouteError::not_found("no such goal"))?;
648 wrote(state, month)
649 }
650
651 /// Drop a goal.
652 fn delete_goal(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
653 let month = month_of(&request);
654 let id = goal_id(&request)?;
655
656 if !state
657 .monthly_reviews
658 .delete_goal(id, DESKTOP_USER_ID)
659 .map_err(|error| RouteError::internal(error.to_string()))?
660 {
661 return Err(RouteError::not_found("no such goal"));
662 }
663 wrote(state, month)
664 }
665
666 /// Save the reflection.
667 ///
668 /// Both answers empty still writes, and still marks the month reviewed: the
669 /// completion is the act and the writing is optional, which is the rule the
670 /// weekly review settled.
671 fn complete(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
672 let month = month_of(&request);
673 let highlight = request.payload.get("highlight").unwrap_or_default().trim();
674 let change = request.payload.get("change").unwrap_or_default().trim();
675
676 state
677 .monthly_reviews
678 .upsert_reflection(DESKTOP_USER_ID, &month_key(month), highlight, change)
679 .map_err(|error| RouteError::internal(error.to_string()))?;
680 Ok(wrote(state, month)?.toast(Tone::Success, "Month reviewed"))
681 }
682
683 /// The monthly review's routes.
684 #[must_use]
685 pub fn routes(router: Router<AppState>) -> Router<AppState> {
686 router
687 .get("/monthly-review", review)
688 .post("/monthly-review/goals", add_goal)
689 .post("/monthly-review/goals/{id}/status", set_goal_status)
690 .post("/monthly-review/goals/{id}/delete", delete_goal)
691 .post("/monthly-review/complete", complete)
692 }
693