//! The monthly review, described rather than built. //! //! //! //! # The shape //! //! - `GET /monthly-review` — the whole review, for `?month=` or for this month. //! - `POST /monthly-review/goals` — add a goal at a position, under `text`. //! - `POST /monthly-review/goals/{id}/status` — set a goal's status, under //! `status`. //! - `POST /monthly-review/goals/{id}/delete` — delete a goal. //! - `POST /monthly-review/complete` — save the reflection. //! //! Every described control reaches one of those. //! //! # The month is an address, not a variable //! //! A query param, per decision 2, exactly as the week is on the sibling screen, //! and with the same consequence: every action carries the month it was offered //! under or acting silently moves the user to this month and writes there. //! [`in_month`] is that, applied to all five routes and to all three navigation //! controls. // 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. Same allow, for the same reason, as quasi-axum's tests. #![allow(clippy::needless_pass_by_value)] use chrono::{Datelike, NaiveDate}; use goingson_core::monthly_review::{self, MonthlyReviewData, ProjectPulse}; use goingson_core::{MonthlyGoal, MonthlyGoalStatus, Task}; use makeover_layout::Tone; use quasi_declare::declare; use quasi_router::screen::{Figure, Tag}; use quasi_router::{Action, Response, RouteError, Router}; use crate::commands::gather_monthly_review; use crate::state::{AppState, DESKTOP_USER_ID}; #[cfg(test)] mod tests; /// How many goals a month holds. /// /// `monthly-review-render.js:renderGoals` counts to 3 and the repository /// enforces nothing, so this is the screen's rule and it is stated once here /// rather than in the two places that ask about it. Same shape as the weekly /// review's `FOCUS_SLOTS`, and the same reason. const GOAL_SLOTS: i32 = 3; /// The month a route was addressed at, or this one. /// /// An unparseable `month` is this month rather than a 400, matching /// [`resolve_month_start`](crate::commands::resolve_month_start)'s tolerance at /// the command layer only in outcome: /// there a bad value is a client bug worth reporting, and here it is a /// hand-typed address, where landing on this month is the more useful answer /// than an error page. fn month_of(request: &quasi_router::Request) -> NaiveDate { request .carried .get("month") .and_then(monthly_review::parse_month) .unwrap_or_else(monthly_review::current_month_start) } /// The same action, still pointed at the month it was offered under. fn in_month(action: Action, month: NaiveDate) -> Action { action.carrying("month", month.format("%Y-%m").to_string()) } /// The month before this one, and the month after. /// /// Written here rather than with `Duration` because months are not a fixed /// number of days: stepping 31 days back from the 1st of March lands in /// January. fn step(month: NaiveDate, forward: bool) -> NaiveDate { let (year, number) = match (month.month(), forward) { (12, true) => (month.year() + 1, 1), (1, false) => (month.year() - 1, 12), (m, true) => (month.year(), m + 1), (m, false) => (month.year(), m - 1), }; NaiveDate::from_ymd_opt(year, number, 1).unwrap_or(month) } /// Read the month. fn load(state: &AppState, month: NaiveDate) -> Result { gather_monthly_review(state, month).map_err(|error| RouteError::internal(error.to_string())) } /// The tone a project's health wears. /// /// The weekly review's `health_tone`, and deliberately a copy rather than a /// shared helper: it is four lines, the two screens read the same core strings, /// and hoisting it would put a lookup table in a `super` module that exists to /// hold routers. If a third screen wants it, that is the second consumer and /// the argument changes. fn health_tone(status: &str) -> makeover_layout::Tone { match status { "healthy" => makeover_layout::Tone::Success, "warning" => makeover_layout::Tone::Warning, "danger" => makeover_layout::Tone::Danger, _ => makeover_layout::Tone::Neutral, } } declare! { /// One task, the way every list on this screen writes one. /// /// The project is `meta` rather than a token, for the reason the weekly /// review gives: a plain trailing fact with no tone of its own and no click /// to answer. shape task_row(task: &Task) -> Row; row &task.title { for project in task.project_name.iter() { meta project; } } } /// One day the month recorded something on. struct Marked { number: u32, is_today: bool, is_vacation: bool, /// What happened on it, or nothing. counts: String, } /// Whether the day has anything to put in its trailing slot. fn has_counts(day: &Marked) -> bool { !day.counts.is_empty() } /// One goal, with the move it offers next. /// /// # The second finding /// /// **A control that cycles hidden state cannot be described, and should not /// be.** /// /// `monthly-review.js:cycleGoalStatus` read the goal out of module state, /// looked up `active -> done -> abandoned -> active`, and wrote the next one. /// Two things were wrong with it and only one was the description layer's. /// /// The describable half: a button labelled with the *current* status, whose /// effect is a table the user cannot see, says nothing about what pressing it /// will do. Here each goal offers the move by name -- "Mark done", "Give up on /// it", "Make it active again" -- so the label is the outcome. /// /// Naming the target also removes a race: computing the next status from a copy /// read at render time lets a second window write a status derived from what it /// saw rather than from what is stored. The route never has to know what the /// goal was before. struct Goal { stored: MonthlyGoal, /// What the move is called. move_label: &'static str, /// The status that move writes. next: MonthlyGoalStatus, /// What the goal is now. status_label: &'static str, status_tone: makeover_layout::Tone, } /// Everything the review draws, read once. struct Review { data: MonthlyReviewData, month: NaiveDate, /// The days that had something on them. /// /// Empty days are left out rather than listed as zeroes. Thirty-one rows of /// which twenty say nothing is a worse reading of the month than eleven /// that do, and the totals underneath already say how much of the month was /// quiet. days: Vec, goals: Vec, } /// Read the month. fn read(state: &AppState, month: NaiveDate) -> Result { let data = load(state, month)?; let days = data .days .iter() .filter(|day| day.completed_count > 0 || day.event_count > 0 || day.is_vacation) .map(|day| { let mut counts = Vec::new(); if day.completed_count > 0 { counts.push(format!("{} done", day.completed_count)); } if day.event_count > 0 { counts.push(format!("{} events", day.event_count)); } Marked { number: day.day_number, is_today: day.is_today, is_vacation: day.is_vacation, counts: counts.join(", "), } }) .collect(); let goals = data .goals .iter() .map(|goal| { let (move_label, next) = match goal.status { MonthlyGoalStatus::Active => ("Mark done", MonthlyGoalStatus::Done), MonthlyGoalStatus::Done => ("Give up on it", MonthlyGoalStatus::Abandoned), MonthlyGoalStatus::Abandoned => ("Make it active again", MonthlyGoalStatus::Active), }; let (status_label, status_tone) = match goal.status { MonthlyGoalStatus::Active => ("Active", makeover_layout::Tone::Info), MonthlyGoalStatus::Done => ("Done", makeover_layout::Tone::Success), MonthlyGoalStatus::Abandoned => ("Abandoned", makeover_layout::Tone::Neutral), }; Goal { stored: goal.clone(), move_label, next, status_label, status_tone, } }) .collect(); Ok(Review { data, month, days, goals, }) } declare! { /// The month at a glance. /// /// **`intensity` is a renderer's encoding of a number, and the description /// carries the number.** `MonthDayData` carries both `completed_count` and /// `intensity`, a 0-3 bucket. A shade is one renderer's way of saying "a /// lot", it runs out of room at 3, and a host with room to print `12` /// should print `12`. So the days below carry counts and `intensity` is not /// described. /// /// A day off is why the counts are absent rather than zero, so it is a /// token and not merely a shade on the cell. /// /// # The grid is not described /// /// A month grid is [`RegionKind::Ceded`]'s shape. A list of days that had /// something on them keeps every fact the grid carries except the shape, /// and inventing a `Node::Calendar` to keep the shape is a vocabulary /// decision this screen has no standing to make alone. shape heat_map(review: &Review) -> Vec; section "The Month"; empty "Nothing recorded this month yet." when review.days.is_empty(); list { for day in review.days.iter() { row day.number.to_string() { token Tag::badge("Today").tone(Tone::Info) when day.is_today; token Tag::badge("Day off") when day.is_vacation; meta &day.counts when has_counts(day); } } } unless review.days.is_empty(); } declare! { /// What the month added up to. /// /// Figures rather than prose, which is what `renderStats` draws and what /// the numbers are. The busiest and quietest days are dates the core crate /// has already formatted, and they are absent rather than zero when the /// month has not produced one: a month with no completions has no busiest /// day, and "None" would be a different claim. shape numbers(review: &Review) -> Vec; section "The Numbers"; stats [] { figure Figure::new(review.data.tasks_completed_count.to_string(), "Tasks Completed"); figure Figure::new(review.data.tasks_created_count.to_string(), "Tasks Created"); figure Figure::new(review.data.events_count.to_string(), "Events"); figure Figure::new(review.data.completion_streak.to_string(), "Longest Streak"); for busiest in review.data.busiest_day.iter() { figure Figure::new(busiest, "Busiest Day"); } for quietest in review.data.quietest_day.iter() { figure Figure::new(quietest, "Quietest Day"); } } } declare! { /// The tasks the month finished. /// /// Core caps this at six for the card; the cap is the data's and not the /// description's, so nothing is truncated again here. The count above it is /// the real total, which is what makes the cap readable rather than /// misleading. shape accomplished(review: &Review) -> Vec; section "Accomplished" unless review.data.tasks_completed_top.is_empty(); list { for task in review.data.tasks_completed_top.iter() { include task_row(task); } } unless review.data.tasks_completed_top.is_empty(); } /// What a project's direction is called. /// /// `direction` is a string core writes ("growing", "shrinking", "stable"). The /// direction is the fact and an arrow glyph is one renderer's spelling of it, /// so this says the word and [`pulse_tone`] tones it: a project that closed /// more than it opened is the good case, which no glyph conveys on its own. fn pulse_label(project: &ProjectPulse) -> &'static str { match project.direction.as_str() { "shrinking" => "Shrinking", "growing" => "Growing", _ => "Stable", } } /// What that direction says about itself. fn pulse_tone(project: &ProjectPulse) -> makeover_layout::Tone { match project.direction.as_str() { "shrinking" => makeover_layout::Tone::Success, "growing" => makeover_layout::Tone::Warning, _ => makeover_layout::Tone::Neutral, } } declare! { /// Which way each project moved. shape project_pulse(review: &Review) -> Vec; section "Project Pulse" unless review.data.project_pulse.is_empty(); list { for project in review.data.project_pulse.iter() { row &project.name { token Tag::badge(pulse_label(project)).tone(pulse_tone(project)); meta "{project.completed} done, {project.created} added"; } } } unless review.data.project_pulse.is_empty(); } declare! { /// How each project is doing, on the same three-value scale the weekly /// review reads. shape projects_health(review: &Review) -> Vec; section "Project Health" unless review.data.project_health.is_empty(); list { for project in review.data.project_health.iter() { row &project.name { token Tag::badge(&project.status).tone(health_tone(&project.status)); } } } unless review.data.project_health.is_empty(); } declare! { /// What the month said about itself. /// /// Core computes these as finished sentences, so there is nothing here to /// describe beyond saying they are a list of statements rather than a /// paragraph. shape patterns(review: &Review) -> Vec; section "Patterns" unless review.data.patterns.is_empty(); list { for pattern in review.data.patterns.iter() { row pattern; } } unless review.data.patterns.is_empty(); } declare! { /// One goal, with the move it offers and the way to drop it. shape goal_row(review: &Review, goal: &Goal) -> Row; row &goal.stored.text { token Tag::badge(goal.status_label).tone(goal.status_tone); act goal.move_label to doing in_month( Action::post("/monthly-review/goals/{goal.stored.id}/status") .with("status", goal.next.as_str()), review.month ); act "Delete" to doing in_month( Action::post("/monthly-review/goals/{goal.stored.id}/delete"), review.month ) { tone Danger; confirm "Are you sure you want to delete this goal?"; } } } /// Whether the month has a goal slot left. fn has_room(review: &Review) -> bool { i32::try_from(review.goals.len()).unwrap_or(GOAL_SLOTS) < GOAL_SLOTS } declare! { /// The month's goals, and the empty slot left. /// /// The JS draws one empty slot per remaining position, each opening the /// same modal. One form is the same offer without pretending the positions /// differ: the next one is the next one. /// /// The JS marks the box `required: true`, so a host that can refuse an /// empty one refuses it before anything is sent. The check in `add_goal` is /// the backstop for a request that did not come through the form. shape goals(review: &Review) -> Vec; section "Goals"; for goal in review.goals.iter() { list { include goal_row(review, goal); } } form doing in_month(Action::post("/monthly-review/goals"), review.month) when has_room(review) { submit "Add goal"; field Text "text" "Goal" { placeholder "What do you want to achieve this month?"; required; } } } /// What the reflection's button reads. /// /// A month already reviewed is being edited rather than completed. fn reflection_submit(review: &Review) -> &'static str { if review.data.reflection.is_some() { "Save notes" } else { "Complete review" } } /// Whether the month has been reviewed. fn reviewed(review: &Review) -> bool { review.data.reflection.is_some() } /// What was said about the highlight. fn highlight(review: &Review) -> String { review .data .reflection .as_ref() .map(|saved| saved.highlight_text.clone()) .unwrap_or_default() } /// What was said about what to change. fn changed(review: &Review) -> String { review .data .reflection .as_ref() .map(|saved| saved.change_text.clone()) .unwrap_or_default() } declare! { /// The reflection. /// /// Two stored columns rather than the weekly review's one blob, so none of /// that screen's marker-parsing is needed here. The prompts are the JS's, /// verbatim, including the placeholders: they are the question being asked /// and not decoration. /// /// The draft finding the weekly review filed applies unchanged -- this /// screen's JS keeps unsent keystrokes in `localStorage` too, and [`Field`] /// still cannot say a value is a draft. Recorded rather than re-filed: it /// is one gap with two consumers, which is the note quasicoherent already /// holds. shape reflection(review: &Review) -> Vec; section "Reflection"; form doing in_month(Action::post("/monthly-review/complete"), review.month) { submit reflection_submit(review); field Textarea "highlight" "What was the highlight of this month?" { placeholder "Shipped the thing I had been putting off..."; value highlight(review); } field Textarea "change" "What would you change?" { placeholder "Too many small tasks, not enough deep work..."; value changed(review); } } } declare! { /// The whole screen. /// /// Declared rather than built inside each route for the reason the projects /// screen gives: a write lands in more than one section -- completing a goal /// changes the goal list and the banner above it -- and a `Response` names /// one region. /// /// "This month" is bare, with no month on it: it is the one control whose /// whole job is to leave the month it was offered under. shape screen(review: &Review) -> Screen; screen sidebar_content "Monthly Review" { at_place super::shell::MONTH; region "month-band" as Band { page &review.data.month_display; act "Previous month" to doing in_month(Action::get("/monthly-review"), step(review.month, false)); act "Next month" to doing in_month(Action::get("/monthly-review"), step(review.month, true)); act "This month" to get "/monthly-review"; } region "monthly-review" as Pane { banner Tone::Info "This month is already reviewed. Your notes stay editable." when reviewed(review); extend heat_map(review); extend numbers(review); extend accomplished(review); extend project_pulse(review); extend projects_health(review); extend goals(review); extend patterns(review); extend reflection(review); } } } /// Answer a write with the month it happened in, re-read. /// /// Re-read rather than patched in memory, for the reason the contacts port /// gives: the write is the database's to confirm, and a screen rebuilt from /// what the handler hoped happened is how a screen disagrees with its own /// storage. fn wrote(state: &AppState, month: NaiveDate) -> Result { Ok(screen(&read(state, month)?).into()) } /// The whole review. fn review(state: &AppState, request: quasi_router::Request) -> Result { wrote(state, month_of(&request)) } /// The month a write names, as the repository spells it. fn month_key(month: NaiveDate) -> String { month.format("%Y-%m").to_string() } /// The goal id in a path, or a 404. fn goal_id(request: &quasi_router::Request) -> Result { let raw = request .captures .get("id") .ok_or_else(|| RouteError::not_found("no goal id"))?; Ok(goingson_core::MonthlyGoalId::from( uuid::Uuid::parse_str(raw).map_err(|_| RouteError::not_found("not a goal id"))?, )) } /// Add a goal at the next free position. /// /// The position is computed here rather than sent, which is the difference from /// the JS: `addGoal(month, position)` is called from a specific empty slot, so /// the position is a fact about which slot was clicked. A described form has no /// slot, and "the next one" is what every one of those clicks meant. fn add_goal(state: &AppState, request: quasi_router::Request) -> Result { let month = month_of(&request); let text = request.payload.get("text").unwrap_or_default().trim(); if text.is_empty() { return Err(RouteError::conflict("A goal needs some text")); } let key = month_key(month); let taken = state .monthly_reviews .list_goals(DESKTOP_USER_ID, &key) .map_err(|error| RouteError::internal(error.to_string()))?; if i32::try_from(taken.len()).unwrap_or(GOAL_SLOTS) >= GOAL_SLOTS { return Err(RouteError::conflict("This month already has three goals")); } // The first position nothing holds, rather than one past the count: a month // whose middle goal was deleted has a free slot in the middle, and counting // would collide with the last one. let position = (1..=GOAL_SLOTS) .find(|slot| !taken.iter().any(|goal| goal.position == *slot)) .ok_or_else(|| RouteError::conflict("This month already has three goals"))?; state .monthly_reviews .upsert_goal(DESKTOP_USER_ID, &key, text, position) .map_err(|error| RouteError::internal(error.to_string()))?; wrote(state, month) } /// Set a goal's status to the one the control named. fn set_goal_status( state: &AppState, request: quasi_router::Request, ) -> Result { let month = month_of(&request); let id = goal_id(&request)?; let status: MonthlyGoalStatus = request .payload .get("status") .ok_or_else(|| RouteError::not_found("no status"))? .parse() .map_err(|_| RouteError::not_found("not a goal status"))?; state .monthly_reviews .update_goal_status(id, DESKTOP_USER_ID, &status) .map_err(|error| RouteError::internal(error.to_string()))? .ok_or_else(|| RouteError::not_found("no such goal"))?; wrote(state, month) } /// Drop a goal. fn delete_goal(state: &AppState, request: quasi_router::Request) -> Result { let month = month_of(&request); let id = goal_id(&request)?; if !state .monthly_reviews .delete_goal(id, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))? { return Err(RouteError::not_found("no such goal")); } wrote(state, month) } /// Save the reflection. /// /// Both answers empty still writes, and still marks the month reviewed: the /// completion is the act and the writing is optional, which is the rule the /// weekly review settled. fn complete(state: &AppState, request: quasi_router::Request) -> Result { let month = month_of(&request); let highlight = request.payload.get("highlight").unwrap_or_default().trim(); let change = request.payload.get("change").unwrap_or_default().trim(); state .monthly_reviews .upsert_reflection(DESKTOP_USER_ID, &month_key(month), highlight, change) .map_err(|error| RouteError::internal(error.to_string()))?; Ok(wrote(state, month)?.toast(Tone::Success, "Month reviewed")) } /// The monthly review's routes. #[must_use] pub fn routes(router: Router) -> Router { router .get("/monthly-review", review) .post("/monthly-review/goals", add_goal) .post("/monthly-review/goals/{id}/status", set_goal_status) .post("/monthly-review/goals/{id}/delete", delete_goal) .post("/monthly-review/complete", complete) }