Skip to main content

max / goingson

31.8 KB · 902 lines History Blame Raw
1 //! The weekly review, described rather than built.
2 //!
3 //! <!-- wiki: quasi-overview -->
4 //!
5 //! A report rather than a list of things, and the whole of it is scoped by
6 //! something other than an id: every route carries the week.
7 //!
8 //! # The shape
9 //!
10 //! - `GET /weekly-review` — the whole review, for `?week=` or for this week.
11 //! - `POST /weekly-review/focus/{id}` — put a task in the week's focus, or take
12 //! it out, under `focus`.
13 //! - `POST /weekly-review/focus/clear` — take everything out.
14 //! - `POST /weekly-review/vacation/{day}` — mark a weekday off, or on again.
15 //! - `POST /weekly-review/complete` — save the reflection and mark it reviewed.
16 //!
17 //! Every described control reaches one of those.
18 //!
19 //! # The week is an address, not a variable
20 //!
21 //! A query param, per decision 2, so a past week is reachable by address and no
22 //! state has to survive between two clicks. Every action the screen offers has
23 //! to carry the week it was offered under, or acting silently moves the user to
24 //! the current week and writes there. [`in_week`] is that, applied to all five
25 //! routes and to both arrows.
26
27 // Handlers take their request by value because `quasi_router::Handler` is a
28 // plain `fn(&S, Request)` pointer, so the signature is the router's and not a
29 // choice made here. Same allow, for the same reason, as quasi-axum's tests.
30 #![allow(clippy::needless_pass_by_value)]
31
32 use std::collections::HashMap;
33
34 use chrono::{Duration, NaiveDate};
35 use goingson_core::weekly_review::{
36 self, EventSummary, ProjectHealth, TimelineDayData, WeeklyReviewData,
37 };
38 use goingson_core::{LinkedTaskRef, Task, TaskId};
39 use makeover_layout::Tone;
40 use quasi_declare::declare;
41 use quasi_router::screen::{Figure, Tag};
42 use quasi_router::{Action, Response, RouteError, Router};
43
44 use crate::commands::{focus_blockers, gather_weekly_review};
45 use crate::state::{AppState, DESKTOP_USER_ID};
46
47 /// What each focus candidate still waits on, keyed by task id.
48 ///
49 /// The same map [`focus_blockers`] answers with, named here because it travels
50 /// through three signatures. An absent entry means nothing is in the way.
51 type FocusBlockers = HashMap<TaskId, Vec<LinkedTaskRef>>;
52
53 #[cfg(test)]
54 mod tests;
55
56 /// How many priorities the week's focus holds.
57 ///
58 /// `weekly-review-render.js:renderFocusSection` counts to 3 and the repository
59 /// enforces nothing, so this is the screen's rule and it is stated once here
60 /// rather than in the three places that ask about it.
61 const FOCUS_SLOTS: usize = 3;
62
63 /// The seven weekday names, Monday first.
64 ///
65 /// The JS draws single letters (`M T W T F S S`), which is a renderer's
66 /// abbreviation of a name and not the name. A description that said "T" would
67 /// be handing a terminal and a screen reader the same ambiguity a sighted user
68 /// resolves from position.
69 const WEEKDAYS: [&str; 7] = [
70 "Monday",
71 "Tuesday",
72 "Wednesday",
73 "Thursday",
74 "Friday",
75 "Saturday",
76 "Sunday",
77 ];
78
79 /// The week a route was addressed at, or the current one.
80 ///
81 /// An unparseable `week` is this week rather than a 400, matching
82 /// `resolve_week_start`'s tolerance at the command layer only in outcome: there
83 /// a bad value is a client bug worth reporting, and here it is a hand-typed
84 /// address, where landing on this week is the more useful answer than an error
85 /// page.
86 fn week_of(request: &quasi_router::Request) -> NaiveDate {
87 request
88 .carried
89 .get("week")
90 .and_then(weekly_review::parse_week_start)
91 .unwrap_or_else(weekly_review::current_week_start)
92 }
93
94 /// The same action, still pointed at the week it was offered under.
95 fn in_week(action: Action, week: NaiveDate) -> Action {
96 action.carrying("week", week.to_string())
97 }
98
99 /// Read the week.
100 fn load(state: &AppState, week: NaiveDate) -> Result<WeeklyReviewData, RouteError> {
101 gather_weekly_review(state, week).map_err(|error| RouteError::internal(error.to_string()))
102 }
103
104 /// The tone a project's health wears.
105 ///
106 /// `ProjectHealth::status` is a string the core crate writes, over three known
107 /// values. An unrecognised one is neutral rather than a panic, because a health
108 /// string is data and not a contract this screen can enforce.
109 fn health_tone(status: &str) -> makeover_layout::Tone {
110 match status {
111 "healthy" => makeover_layout::Tone::Success,
112 "warning" => makeover_layout::Tone::Warning,
113 "danger" => makeover_layout::Tone::Danger,
114 _ => makeover_layout::Tone::Neutral,
115 }
116 }
117
118 /// Pull one prompt's answer back out of the stored notes.
119 ///
120 /// The review stores its two answers as one string with the prompts written
121 /// into it, and `weekly-review-render.js:renderReflection` picks them apart with
122 /// two regexes. Read here rather than stored apart because the storage is the
123 /// JS screen's too and it still ships: a described screen that wrote a second
124 /// format would make the two disagree about a week they both open.
125 fn prompt_answer(notes: &str, marker: &str, until: Option<&str>) -> String {
126 let Some(start) = notes.find(marker) else {
127 return String::new();
128 };
129 let rest = &notes[start + marker.len()..];
130 let end = until.and_then(|next| rest.find(next)).unwrap_or(rest.len());
131 rest[..end].trim().to_owned()
132 }
133
134 /// What a focus candidate waits on, as one token.
135 ///
136 /// A blocked task is a legitimate focus (decision `143d71b1`), so the picker
137 /// offers it and says what stands in the way rather than hiding it. Naming the
138 /// blocker is the day plan's phrasing ([`super::day_planning::pool`]) and it is
139 /// the more useful half: "Blocked" tells the reader to go and look something up.
140 ///
141 /// A cycled task is a different offer and gets a different mark. It waits on
142 /// something that can never finish, so no amount of doing the named blocker
143 /// opens it, and [`Availability::marker`](super::Availability::marker) already
144 /// draws that distinction everywhere else.
145 ///
146 /// The out-of-order tone the day plan carries has no analogue here: a week has
147 /// no ordering to be out of.
148 fn waiting_mark(task: &Task, waits: &FocusBlockers) -> Option<Tag> {
149 if task.graph.in_cycle {
150 return Some(Tag::badge("Cycle").tone(Tone::Danger));
151 }
152 let blockers = waits.get(&task.id)?;
153 let first = blockers.first()?;
154 let label = if blockers.len() > 1 {
155 format!("after {} +{}", first.title, blockers.len() - 1)
156 } else {
157 format!("after {}", first.title)
158 };
159 Some(Tag::badge(label).tone(Tone::Warning))
160 }
161
162 declare! {
163 /// One task, the way every list on this screen writes one.
164 ///
165 /// The project is `meta` rather than a token: it is a plain trailing fact
166 /// with no tone of its own and no click to answer, which is the line
167 /// [`Row::tokens`](quasi_router::screen::Row::tokens) draws.
168 shape task_row(task: &Task) -> Row;
169
170 row &task.title {
171 for project in task.project_name.iter() {
172 meta project;
173 }
174 }
175 }
176
177 /// One place in the week's focus: filled, or open.
178 struct Place {
179 /// Which priority it is, from one.
180 number: usize,
181 /// What is in it, if anything.
182 task: Option<Task>,
183 }
184
185 /// Whether the place holds a task.
186 fn filled(place: &Place) -> bool {
187 place.task.is_some()
188 }
189
190 /// One weekday, and whether it is marked off.
191 struct Weekday {
192 /// Its index, Monday first, which is what the route takes.
193 index: usize,
194 name: &'static str,
195 off: bool,
196 }
197
198 /// Everything the review draws, read once.
199 struct Review {
200 data: WeeklyReviewData,
201 week: NaiveDate,
202 waits: FocusBlockers,
203 /// Three places, or more when more tasks are focused than there are slots.
204 ///
205 /// `tasks.is_focus` is one column and nothing enforces [`FOCUS_SLOTS`], so
206 /// a fourth focused task is possible and must not vanish into a description
207 /// that only ever draws three. The count of places is the greater of the
208 /// two, so overflow reads as a fourth priority rather than as a task that
209 /// stopped existing.
210 places: Vec<Place>,
211 days: Vec<Weekday>,
212 /// What was said last time, pulled back out of the stored notes.
213 went_well: String,
214 improve: String,
215 }
216
217 /// Read the week.
218 fn read(state: &AppState, week: NaiveDate) -> Result<Review, RouteError> {
219 let data = load(state, week)?;
220 let waits = focus_blockers(state, &data.available_for_focus)
221 .map_err(|error| RouteError::internal(error.to_string()))?;
222
223 let taken = data.focused_tasks.len();
224 let places = (0..FOCUS_SLOTS.max(taken))
225 .map(|index| Place {
226 number: index + 1,
227 task: data.focused_tasks.get(index).cloned(),
228 })
229 .collect();
230
231 let days = WEEKDAYS
232 .iter()
233 .enumerate()
234 .map(|(index, name)| Weekday {
235 index,
236 name,
237 off: data
238 .vacation_days
239 .contains(&u8::try_from(index).unwrap_or(0)),
240 })
241 .collect();
242
243 Ok(Review {
244 went_well: prompt_answer(
245 &data.notes,
246 "What went well:",
247 Some("What could be improved:"),
248 ),
249 improve: prompt_answer(&data.notes, "What could be improved:", None),
250 data,
251 week,
252 waits,
253 places,
254 days,
255 })
256 }
257
258 /// What a day of the week had on it, in words.
259 ///
260 /// The JS encodes each of these as up to three dots, capping completed at 3 and
261 /// the rest at 2. A dot is a renderer's encoding of a number and the cap is that
262 /// encoding running out of room, so the description carries the numbers and lets
263 /// a host that has room draw them as it likes. A day with 9 completed tasks
264 /// reads as 9 here and as three dots there, and the description is the one that
265 /// is right.
266 ///
267 /// Due is a future fact: a day that has passed has no tasks still due on it,
268 /// they are the overdue count. `renderDayDots` says the same with
269 /// `if (!day.isPast)`.
270 fn day_counts(day: &TimelineDayData) -> String {
271 let mut counts = Vec::new();
272 if day.completed_count > 0 {
273 counts.push(format!("{} done", day.completed_count));
274 }
275 if day.event_count > 0 {
276 counts.push(format!("{} events", day.event_count));
277 }
278 if day.overdue_count > 0 {
279 counts.push(format!("{} overdue", day.overdue_count));
280 }
281 if !day.is_past && day.due_count > 0 {
282 counts.push(format!("{} due", day.due_count));
283 }
284 counts.join(", ")
285 }
286
287 /// Whether the day had anything on it.
288 fn busy(day: &TimelineDayData) -> bool {
289 !day_counts(day).is_empty()
290 }
291
292 declare! {
293 /// The week at a glance.
294 ///
295 /// # The first finding
296 ///
297 /// **A strip that runs across is described as a list that runs down, and
298 /// two separate things are lost saying so.**
299 ///
300 /// The first loss is the direction, and it is not one worth closing. Seven
301 /// days in a row and seven days in a column are the same seven facts, and a
302 /// terminal renderer would draw the column whatever the description said.
303 /// That is [`Node::Stats`]' grouping argument pointed the other way, and it
304 /// comes out the other way: the set is already one node, so the renderer
305 /// has what it needs to decide.
306 ///
307 /// The second is real, and it is [`day_counts`]: capping a count is a thing
308 /// to stop doing rather than a thing to describe.
309 ///
310 /// A day off is why the counts are absent rather than zero, so it is a
311 /// token and not merely a style on the row.
312 shape timeline(review: &Review) -> Vec<Node>;
313
314 section "Week at a Glance";
315
316 list {
317 for day in review.data.timeline_days.iter() {
318 row "{day.day_name} {day.day_number}" {
319 token Tag::badge("Today").tone(Tone::Info) when day.is_today;
320 token Tag::badge("Day off") when day.is_vacation;
321 meta day_counts(day) when busy(day);
322 }
323 }
324 }
325 }
326
327 declare! {
328 /// One event, in the compact form both event lists here use.
329 shape event_row(event: &EventSummary) -> Row;
330
331 row &event.title {
332 meta &event.formatted_time;
333
334 for project in event.project_name.iter() {
335 token Tag::badge(project);
336 }
337 }
338 }
339
340 /// Whether the week had no events at all.
341 fn no_events(review: &Review) -> bool {
342 review
343 .data
344 .timeline_days
345 .iter()
346 .all(|day| day.events.is_empty())
347 }
348
349 declare! {
350 /// The week's events, under the day each fell on.
351 ///
352 /// A list holds rows and nothing else, so the grouping is headings between
353 /// lists rather than anything inside one. That is the vocabulary working:
354 /// the JS wraps each day in a `timeline-events-day` div because it needs
355 /// somewhere to hang a label, and a heading is what the label actually is.
356 shape week_events(review: &Review) -> Vec<Node>;
357
358 section "Week's Events" unless no_events(review);
359
360 for day in review.data.timeline_days.iter() {
361 subsection "{day.day_name} {day.day_number}" unless day.events.is_empty();
362
363 list {
364 for event in day.events.iter() {
365 include event_row(event);
366 }
367 } unless day.events.is_empty();
368 }
369 }
370
371 declare! {
372 /// What got done.
373 ///
374 /// # The second finding
375 ///
376 /// **A cap that exists to fit a card is not a fact, and the description
377 /// should not carry it.**
378 ///
379 /// The JS shows the first 6 completed tasks, the first 3 overdue and the
380 /// first 3 carried over, with nothing saying there are more. Those numbers
381 /// are the height of a card in a grid, which is the renderer's problem, and
382 /// [`Rest`](quasi_router::screen::Rest) is not the answer to them: it wants
383 /// an action, because `346567f9` was about lists with a real remainder to
384 /// go and ask for. There is no address here holding "the rest of what you
385 /// finished" and inventing one would be adding a screen to justify a cap.
386 ///
387 /// So every list on this screen is whole, and a renderer that can only draw
388 /// six rows is the thing that decides that. The count stays as a figure
389 /// beside it, which is the fact the badge was carrying.
390 shape accomplished(review: &Review) -> Vec<Node>;
391
392 section "Accomplished";
393
394 stats [] {
395 figure Figure::new(review.data.tasks_completed_count.to_string(), "Tasks Completed")
396 .tone(Tone::Success);
397 figure Figure::new(review.data.events_occurred_count.to_string(), "Events Attended");
398 }
399
400 empty "Nothing completed this week" when review.data.tasks_completed.is_empty();
401
402 list {
403 for task in review.data.tasks_completed.iter() {
404 include task_row(task);
405 }
406 } unless review.data.tasks_completed.is_empty();
407 }
408
409 /// What the overdue figure says about itself.
410 fn overdue_tone(review: &Review) -> Tone {
411 if review.data.tasks_overdue_count > 0 {
412 Tone::Danger
413 } else {
414 Tone::Neutral
415 }
416 }
417
418 /// Whether anything slipped.
419 fn slipped(review: &Review) -> bool {
420 !review.data.tasks_overdue.is_empty() || !review.data.carried_over_tasks.is_empty()
421 }
422
423 declare! {
424 /// What slipped.
425 ///
426 /// Overdue and carried-over are one list, told apart by a token rather than
427 /// by order: ordering relies on the reader noticing a red due date, which is
428 /// a distinction that survives only for someone who can see both halves at
429 /// once.
430 ///
431 /// **`meta` sets rather than appends**, so an overdue row's due date
432 /// replaces its project. That is what the hand-written rows did too, and it
433 /// is a defect rather than a decision; reported rather than repaired here,
434 /// because a conversion is the wrong place to change what a screen says.
435 shape needs_attention(review: &Review) -> Vec<Node>;
436
437 section "Needs Attention";
438
439 stats [] {
440 figure Figure::new(review.data.tasks_overdue_count.to_string(), "Overdue")
441 .tone(overdue_tone(review));
442 figure Figure::new(review.data.carried_over_count.to_string(), "Carried Over")
443 .tone(Tone::Info);
444 }
445
446 list {
447 for task in review.data.tasks_overdue.iter() {
448 row &task.title {
449 meta project_and_due(task);
450 token Tag::badge("Overdue").tone(Tone::Danger);
451 }
452 }
453
454 for task in review.data.carried_over_tasks.iter() {
455 row &task.title {
456 for project in task.project_name.iter() {
457 meta project;
458 }
459 token Tag::badge("Carried over");
460 }
461 }
462 } when slipped(review);
463 }
464
465 declare! {
466 /// What is coming.
467 shape due_this_week(review: &Review) -> Vec<Node>;
468
469 section "Due This Week";
470
471 empty "No tasks due this week" when review.data.tasks_due_next_week.is_empty();
472
473 list {
474 for task in review.data.tasks_due_next_week.iter() {
475 row &task.title {
476 meta project_and_due(task);
477 }
478 }
479 } unless review.data.tasks_due_next_week.is_empty();
480 }
481
482 /// A row's one trailing fact where the task says both its project and when it
483 /// is due.
484 ///
485 /// `meta` sets rather than appends, so writing them as two settings left only
486 /// the due date, and no overdue or due-this-week row ever showed its project.
487 fn project_and_due(task: &Task) -> String {
488 [task.project_name.clone(), Some(task.due_formatted())]
489 .into_iter()
490 .flatten()
491 .collect::<Vec<_>>()
492 .join(" · ")
493 }
494
495 /// Whether anything is focused at all.
496 fn any_focused(review: &Review) -> bool {
497 !review.data.focused_tasks.is_empty()
498 }
499
500 /// Whether the picker is offered.
501 ///
502 /// Only while there is somewhere to put one, which is
503 /// `available.length > 0 && focused.length < 3` in the JS. The repository
504 /// already caps the candidates at ten, so unlike the lists above this is a limit
505 /// in the data rather than in a card.
506 fn offers_suggestions(review: &Review) -> bool {
507 review.data.focused_tasks.len() < FOCUS_SLOTS && !review.data.available_for_focus.is_empty()
508 }
509
510 declare! {
511 /// The week's priorities.
512 ///
513 /// # The third finding, closed
514 ///
515 /// **A place awaiting content is a region, and `Slot` already had one.**
516 ///
517 /// Always three slots. A filled one holds a task; an empty one is a real
518 /// described thing: it is reachable, it is named, and it is where a chosen
519 /// task lands. Two filled slots and one empty slot is not a list of two
520 /// tasks, and describing it as one loses that there is room for a third.
521 ///
522 /// So: three [`Slot`]s, one per priority, each [`named`](Slot::named). A
523 /// region is exactly a named place, so this costs no vocabulary. It is
524 /// heavy, three regions for three slots, and the weight sits in the
525 /// description rather than in new words.
526 ///
527 /// Rejected with it: extending [`Node::StandIn`] to stand for a *place*
528 /// rather than a missing item. `StandIn` exists to stop a fake row appearing
529 /// per absence, and a place that is empty and can be landed on carries an
530 /// identity and a target; one member covering both would blur what
531 /// `StandIn` is for. It still says what is inside an empty region, which is
532 /// nothing, and that is the job it has.
533 ///
534 /// The meter goes rather than standing beside them. Three named regions
535 /// carry the count they were a summary of, and a bar reading "1 of 3" next
536 /// to three places one of which is full is the same fact drawn twice.
537 ///
538 /// Nothing here says how a task gets into a place: the suggestions below
539 /// carry the only address that fills one, and a browser's drop target and a
540 /// terminal's Enter are two renderers' answers to that one fact rather than
541 /// something to name once in words.
542 shape focus(review: &Review) -> Vec<Node>;
543
544 section "This Week's Focus";
545
546 for place in review.places.iter() {
547 region "weekly-focus-{place.number}" as Group {
548 named "Priority {place.number}";
549
550 empty "Open" unless filled(place);
551
552 list {
553 for task in place.task.iter() {
554 include focused_row(review, task);
555 }
556 } when filled(place);
557 }
558 }
559
560 act "Clear all focus" to doing in_week(
561 Action::post("/weekly-review/focus/clear"),
562 review.week
563 ) when any_focused(review);
564
565 subsection "Suggested" when offers_suggestions(review);
566
567 list {
568 for task in review.data.available_for_focus.iter() {
569 include suggested_row(review, task);
570 }
571 } when offers_suggestions(review);
572 }
573
574 declare! {
575 /// A task in one of the week's places, with the way out of it.
576 shape focused_row(review: &Review, task: &Task) -> Row;
577
578 row &task.title {
579 for project in task.project_name.iter() {
580 meta project;
581 }
582
583 act "Remove" to doing in_week(
584 Action::post("/weekly-review/focus/{task.id}").with("focus", "false"),
585 review.week
586 );
587 }
588 }
589
590 declare! {
591 /// One task the picker offers, with what stands in its way.
592 shape suggested_row(review: &Review, task: &Task) -> Row;
593
594 row &task.title {
595 for project in task.project_name.iter() {
596 meta project;
597 }
598
599 for mark in waiting_mark(task, &review.waits).into_iter() {
600 token mark;
601 }
602
603 act "Focus" to doing in_week(
604 Action::post("/weekly-review/focus/{task.id}").with("focus", "true"),
605 review.week
606 );
607 }
608 }
609
610 /// How many of a project's tasks are open, against how many it has.
611 fn project_counts(project: &ProjectHealth) -> String {
612 format!(
613 "{} active, {} total",
614 project.active_count, project.total_count
615 )
616 }
617
618 /// Whether the project is carrying anything overdue.
619 fn project_overdue(project: &ProjectHealth) -> bool {
620 project.overdue_count > 0
621 }
622
623 declare! {
624 /// How each project is doing.
625 shape projects_health(review: &Review) -> Vec<Node>;
626
627 section "Projects Health" unless review.data.project_health.is_empty();
628
629 list {
630 for project in review.data.project_health.iter() {
631 row &project.name {
632 meta project_counts(project);
633 token Tag::badge(&project.status).tone(health_tone(&project.status));
634 token Tag::badge("{project.overdue_count} overdue").tone(Tone::Danger)
635 when project_overdue(project);
636 }
637 }
638 } unless review.data.project_health.is_empty();
639 }
640
641 declare! {
642 /// The days marked off.
643 ///
644 /// The one section here where the vocabulary already had the answer and the
645 /// port did not have to argue for it. Seven independently latched things,
646 /// each answering a click, is [`Tag::chip`] with
647 /// [`latched`](quasi_router::screen::Tag::latched) -- which arrived for
648 /// filter chips and turns out to describe this without a change.
649 ///
650 /// Not one control picking one of a set: days off are seven yes-or-no
651 /// answers where any number can be yes.
652 ///
653 /// The names are written out rather than abbreviated. The JS draws single
654 /// letters (`M T W T F S S`), which is a renderer's abbreviation of a name
655 /// and not the name; a description that said "T" would be handing a terminal
656 /// and a screen reader the same ambiguity a sighted user resolves from
657 /// position.
658 shape days_off(review: &Review) -> Vec<Node>;
659
660 section "Days Off";
661
662 for day in review.days.iter() {
663 chip day.name to doing in_week(
664 Action::post("/weekly-review/vacation/{day.index}"),
665 review.week
666 ) {
667 latched day.off;
668 }
669 }
670 }
671
672 /// What the reflection's button reads.
673 fn reflection_submit(review: &Review) -> &'static str {
674 if review.data.is_completed {
675 "Save notes"
676 } else {
677 "Complete review"
678 }
679 }
680
681 declare! {
682 /// The reflection.
683 ///
684 /// # The fourth finding
685 ///
686 /// **A field cannot say its value is a draft.**
687 ///
688 /// `weekly-review.js` keeps what the user has typed in `localStorage`
689 /// against the week, restores it over the stored notes on render, and clears
690 /// it on completion, so a review survives closing the app halfway through
691 /// writing it. Nothing in [`Field`] can say that: `value` is what the field
692 /// holds, and whether the host should be keeping unsent keystrokes somewhere
693 /// is not a property of the value.
694 ///
695 /// The described screen therefore loses the draft and shows what is stored,
696 /// which is correct and worse. Filed against quasicoherent rather than
697 /// worked around, because the workaround is a route that writes on every
698 /// keystroke and that is a different feature wearing this one's name.
699 ///
700 /// The prompts themselves are the JS's, verbatim, including the
701 /// placeholders: they are the question being asked and not decoration.
702 shape reflection(review: &Review) -> Vec<Node>;
703
704 section "Reflection";
705
706 form doing in_week(Action::post("/weekly-review/complete"), review.week) {
707 submit reflection_submit(review);
708
709 field Textarea "went-well" "What went well?" {
710 placeholder "Completed the budget ahead of schedule...";
711 value &review.went_well;
712 }
713
714 field Textarea "improve" "What could be improved?" {
715 placeholder "Need to block more focus time...";
716 value &review.improve;
717 }
718 }
719 }
720
721 /// The week before this one.
722 fn last_week(review: &Review) -> NaiveDate {
723 review.week - Duration::days(7)
724 }
725
726 /// The week after.
727 fn next_week(review: &Review) -> NaiveDate {
728 review.week + Duration::days(7)
729 }
730
731 declare! {
732 /// The whole screen.
733 ///
734 /// Declared rather than built inside each route for the reason the projects
735 /// screen gives: a write lands in more than one section -- focusing a task
736 /// changes the focus list and the suggestions -- and a `Response` names one
737 /// region.
738 ///
739 /// The JS says the reviewed banner above the reflection card. It is a
740 /// screen-wide fact -- it changes what the submit button means -- so it sits
741 /// at the top rather than beside the form.
742 shape screen(review: &Review) -> Screen;
743
744 screen sidebar_content "Weekly Review" {
745 at_place super::shell::WEEK;
746
747 region "review-band" as Band {
748 page &review.data.week_display;
749
750 act "Previous week"
751 to doing in_week(Action::get("/weekly-review"), last_week(review));
752 act "Next week"
753 to doing in_week(Action::get("/weekly-review"), next_week(review));
754 }
755
756 region "weekly-review" as Pane {
757 banner Tone::Info "This week is already reviewed. Your notes stay editable."
758 when review.data.is_completed;
759
760 extend timeline(review);
761 extend week_events(review);
762 extend accomplished(review);
763 extend needs_attention(review);
764 extend due_this_week(review);
765 extend focus(review);
766 extend projects_health(review);
767 extend days_off(review);
768 extend reflection(review);
769 }
770 }
771 }
772
773 /// Answer a write with the week it happened in, re-read.
774 ///
775 /// Re-read rather than patched in memory, for the reason the contacts port
776 /// gives: the write is the database's to confirm, and a screen rebuilt from
777 /// what the handler hoped happened is how a screen disagrees with its own
778 /// storage.
779 fn wrote(state: &AppState, week: NaiveDate) -> Result<Response, RouteError> {
780 Ok(screen(&read(state, week)?).into())
781 }
782
783 /// The whole review.
784 fn review(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
785 wrote(state, week_of(&request))
786 }
787
788 /// Put a task in the week's focus, or take it out.
789 ///
790 /// One route with a `focus` param rather than two addresses: both callers know
791 /// which way they are going, since the suggestion button always adds and the
792 /// slot button always removes. A route that read the current state and flipped
793 /// it would be a third behaviour neither caller wants, and would race a second
794 /// window.
795 ///
796 /// Focus is a property of the task and not of the week (`tasks.is_focus` is one
797 /// column), so this writes the same flag whichever week it was called from.
798 fn set_focus(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
799 let raw = request
800 .captures
801 .get("id")
802 .ok_or_else(|| RouteError::not_found("no task id"))?;
803 let id = goingson_core::TaskId::from(
804 uuid::Uuid::parse_str(raw).map_err(|_| RouteError::not_found("not a task id"))?,
805 );
806 let on = request.payload.get("focus") == Some("true");
807 state
808 .tasks
809 .set_focus(id, DESKTOP_USER_ID, on)
810 .map_err(|error| RouteError::internal(error.to_string()))?
811 .ok_or_else(|| RouteError::not_found("no such task"))?;
812 wrote(state, week_of(&request))
813 }
814
815 /// Take everything out of the week's focus.
816 fn clear_focus(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
817 state
818 .tasks
819 .clear_all_focus(DESKTOP_USER_ID)
820 .map_err(|error| RouteError::internal(error.to_string()))?;
821 wrote(state, week_of(&request))
822 }
823
824 /// Mark a weekday off, or on again.
825 ///
826 /// The write takes the whole set, so a toggle is a read, a flip and a write.
827 /// That is the one place on this screen where two windows on the same week can
828 /// lose an edit, and it is the storage's shape rather than the description's.
829 ///
830 /// What it writes is contexts. `set_vacation_week` dissolves the week's
831 /// `Vacation` spans into days, applies these seven, and re-runs the rest, so a
832 /// holiday running into the week from before keeps its earlier half.
833 fn toggle_vacation(
834 state: &AppState,
835 request: quasi_router::Request,
836 ) -> Result<Response, RouteError> {
837 let week = week_of(&request);
838 let day: u8 = request
839 .captures
840 .get("day")
841 .and_then(|raw| raw.parse().ok())
842 .filter(|day| usize::from(*day) < WEEKDAYS.len())
843 .ok_or_else(|| RouteError::not_found("not a weekday"))?;
844
845 let mut days = load(state, week)?.vacation_days;
846 if let Some(at) = days.iter().position(|held| *held == day) {
847 days.remove(at);
848 } else {
849 days.push(day);
850 }
851 crate::commands::weekly_review::set_vacation_week(state, week, &days)
852 .map_err(|error| RouteError::internal(error.to_string()))?;
853 wrote(state, week)
854 }
855
856 /// Save the reflection and mark the week reviewed.
857 ///
858 /// The stored format keeps a blank line between the two answers, and
859 /// [`prompt_answer`] is what reads them back.
860 ///
861 /// An empty answer is left out rather than written as an empty heading. Both
862 /// empty leaves the notes empty, and
863 /// the review is still marked reviewed: the completion is the act, and the
864 /// writing is optional.
865 fn complete(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
866 let week = week_of(&request);
867 let went_well = request.payload.get("went-well").unwrap_or_default().trim();
868 let improve = request.payload.get("improve").unwrap_or_default().trim();
869
870 let mut notes = String::new();
871 if !went_well.is_empty() {
872 notes.push_str("What went well:\n");
873 notes.push_str(went_well);
874 notes.push_str("\n\n");
875 }
876 if !improve.is_empty() {
877 notes.push_str("What could be improved:\n");
878 notes.push_str(improve);
879 }
880
881 state
882 .weekly_reviews
883 .upsert(DESKTOP_USER_ID, week, notes.trim())
884 .map_err(|error| RouteError::internal(error.to_string()))?;
885 Ok(wrote(state, week)?.toast(makeover_layout::Tone::Success, "Week reviewed"))
886 }
887
888 /// The weekly review's routes.
889 #[must_use]
890 pub fn routes(router: Router<AppState>) -> Router<AppState> {
891 router
892 .get("/weekly-review", review)
893 // Ahead of the capture below, which the path matcher does on its own:
894 // a static segment outranks a capture, so `clear` never arrives as an
895 // id. Written in this order anyway, because a reader should not have to
896 // know that to be sure.
897 .post("/weekly-review/focus/clear", clear_focus)
898 .post("/weekly-review/focus/{id}", set_focus)
899 .post("/weekly-review/vacation/{day}", toggle_vacation)
900 .post("/weekly-review/complete", complete)
901 }
902