//! The weekly review, described rather than built. //! //! //! //! A report rather than a list of things, and the whole of it is scoped by //! something other than an id: every route carries the week. //! //! # The shape //! //! - `GET /weekly-review` — the whole review, for `?week=` or for this week. //! - `POST /weekly-review/focus/{id}` — put a task in the week's focus, or take //! it out, under `focus`. //! - `POST /weekly-review/focus/clear` — take everything out. //! - `POST /weekly-review/vacation/{day}` — mark a weekday off, or on again. //! - `POST /weekly-review/complete` — save the reflection and mark it reviewed. //! //! Every described control reaches one of those. //! //! # The week is an address, not a variable //! //! A query param, per decision 2, so a past week is reachable by address and no //! state has to survive between two clicks. Every action the screen offers has //! to carry the week it was offered under, or acting silently moves the user to //! the current week and writes there. [`in_week`] is that, applied to all five //! routes and to both arrows. // 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 std::collections::HashMap; use chrono::{Duration, NaiveDate}; use goingson_core::weekly_review::{ self, EventSummary, ProjectHealth, TimelineDayData, WeeklyReviewData, }; use goingson_core::{LinkedTaskRef, Task, TaskId}; use makeover_layout::Tone; use quasi_declare::declare; use quasi_router::screen::{Figure, Tag}; use quasi_router::{Action, Response, RouteError, Router}; use crate::commands::{focus_blockers, gather_weekly_review}; use crate::state::{AppState, DESKTOP_USER_ID}; /// What each focus candidate still waits on, keyed by task id. /// /// The same map [`focus_blockers`] answers with, named here because it travels /// through three signatures. An absent entry means nothing is in the way. type FocusBlockers = HashMap>; #[cfg(test)] mod tests; /// How many priorities the week's focus holds. /// /// `weekly-review-render.js:renderFocusSection` 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 three places that ask about it. const FOCUS_SLOTS: usize = 3; /// The seven weekday names, Monday first. /// /// The JS draws single letters (`M T W T F S S`), which is a renderer's /// abbreviation of a name and not the name. A description that said "T" would /// be handing a terminal and a screen reader the same ambiguity a sighted user /// resolves from position. const WEEKDAYS: [&str; 7] = [ "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday", ]; /// The week a route was addressed at, or the current one. /// /// An unparseable `week` is this week rather than a 400, matching /// `resolve_week_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 week is the more useful answer than an error /// page. fn week_of(request: &quasi_router::Request) -> NaiveDate { request .carried .get("week") .and_then(weekly_review::parse_week_start) .unwrap_or_else(weekly_review::current_week_start) } /// The same action, still pointed at the week it was offered under. fn in_week(action: Action, week: NaiveDate) -> Action { action.carrying("week", week.to_string()) } /// Read the week. fn load(state: &AppState, week: NaiveDate) -> Result { gather_weekly_review(state, week).map_err(|error| RouteError::internal(error.to_string())) } /// The tone a project's health wears. /// /// `ProjectHealth::status` is a string the core crate writes, over three known /// values. An unrecognised one is neutral rather than a panic, because a health /// string is data and not a contract this screen can enforce. 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, } } /// Pull one prompt's answer back out of the stored notes. /// /// The review stores its two answers as one string with the prompts written /// into it, and `weekly-review-render.js:renderReflection` picks them apart with /// two regexes. Read here rather than stored apart because the storage is the /// JS screen's too and it still ships: a described screen that wrote a second /// format would make the two disagree about a week they both open. fn prompt_answer(notes: &str, marker: &str, until: Option<&str>) -> String { let Some(start) = notes.find(marker) else { return String::new(); }; let rest = ¬es[start + marker.len()..]; let end = until.and_then(|next| rest.find(next)).unwrap_or(rest.len()); rest[..end].trim().to_owned() } /// What a focus candidate waits on, as one token. /// /// A blocked task is a legitimate focus (decision `143d71b1`), so the picker /// offers it and says what stands in the way rather than hiding it. Naming the /// blocker is the day plan's phrasing ([`super::day_planning::pool`]) and it is /// the more useful half: "Blocked" tells the reader to go and look something up. /// /// A cycled task is a different offer and gets a different mark. It waits on /// something that can never finish, so no amount of doing the named blocker /// opens it, and [`Availability::marker`](super::Availability::marker) already /// draws that distinction everywhere else. /// /// The out-of-order tone the day plan carries has no analogue here: a week has /// no ordering to be out of. fn waiting_mark(task: &Task, waits: &FocusBlockers) -> Option { if task.graph.in_cycle { return Some(Tag::badge("Cycle").tone(Tone::Danger)); } let blockers = waits.get(&task.id)?; let first = blockers.first()?; let label = if blockers.len() > 1 { format!("after {} +{}", first.title, blockers.len() - 1) } else { format!("after {}", first.title) }; Some(Tag::badge(label).tone(Tone::Warning)) } declare! { /// One task, the way every list on this screen writes one. /// /// The project is `meta` rather than a token: it is a plain trailing fact /// with no tone of its own and no click to answer, which is the line /// [`Row::tokens`](quasi_router::screen::Row::tokens) draws. shape task_row(task: &Task) -> Row; row &task.title { for project in task.project_name.iter() { meta project; } } } /// One place in the week's focus: filled, or open. struct Place { /// Which priority it is, from one. number: usize, /// What is in it, if anything. task: Option, } /// Whether the place holds a task. fn filled(place: &Place) -> bool { place.task.is_some() } /// One weekday, and whether it is marked off. struct Weekday { /// Its index, Monday first, which is what the route takes. index: usize, name: &'static str, off: bool, } /// Everything the review draws, read once. struct Review { data: WeeklyReviewData, week: NaiveDate, waits: FocusBlockers, /// Three places, or more when more tasks are focused than there are slots. /// /// `tasks.is_focus` is one column and nothing enforces [`FOCUS_SLOTS`], so /// a fourth focused task is possible and must not vanish into a description /// that only ever draws three. The count of places is the greater of the /// two, so overflow reads as a fourth priority rather than as a task that /// stopped existing. places: Vec, days: Vec, /// What was said last time, pulled back out of the stored notes. went_well: String, improve: String, } /// Read the week. fn read(state: &AppState, week: NaiveDate) -> Result { let data = load(state, week)?; let waits = focus_blockers(state, &data.available_for_focus) .map_err(|error| RouteError::internal(error.to_string()))?; let taken = data.focused_tasks.len(); let places = (0..FOCUS_SLOTS.max(taken)) .map(|index| Place { number: index + 1, task: data.focused_tasks.get(index).cloned(), }) .collect(); let days = WEEKDAYS .iter() .enumerate() .map(|(index, name)| Weekday { index, name, off: data .vacation_days .contains(&u8::try_from(index).unwrap_or(0)), }) .collect(); Ok(Review { went_well: prompt_answer( &data.notes, "What went well:", Some("What could be improved:"), ), improve: prompt_answer(&data.notes, "What could be improved:", None), data, week, waits, places, days, }) } /// What a day of the week had on it, in words. /// /// The JS encodes each of these as up to three dots, capping completed at 3 and /// the rest at 2. A dot is a renderer's encoding of a number and the cap is that /// encoding running out of room, so the description carries the numbers and lets /// a host that has room draw them as it likes. A day with 9 completed tasks /// reads as 9 here and as three dots there, and the description is the one that /// is right. /// /// Due is a future fact: a day that has passed has no tasks still due on it, /// they are the overdue count. `renderDayDots` says the same with /// `if (!day.isPast)`. fn day_counts(day: &TimelineDayData) -> String { 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)); } if day.overdue_count > 0 { counts.push(format!("{} overdue", day.overdue_count)); } if !day.is_past && day.due_count > 0 { counts.push(format!("{} due", day.due_count)); } counts.join(", ") } /// Whether the day had anything on it. fn busy(day: &TimelineDayData) -> bool { !day_counts(day).is_empty() } declare! { /// The week at a glance. /// /// # The first finding /// /// **A strip that runs across is described as a list that runs down, and /// two separate things are lost saying so.** /// /// The first loss is the direction, and it is not one worth closing. Seven /// days in a row and seven days in a column are the same seven facts, and a /// terminal renderer would draw the column whatever the description said. /// That is [`Node::Stats`]' grouping argument pointed the other way, and it /// comes out the other way: the set is already one node, so the renderer /// has what it needs to decide. /// /// The second is real, and it is [`day_counts`]: capping a count is a thing /// to stop doing rather than a thing to describe. /// /// A day off is why the counts are absent rather than zero, so it is a /// token and not merely a style on the row. shape timeline(review: &Review) -> Vec; section "Week at a Glance"; list { for day in review.data.timeline_days.iter() { row "{day.day_name} {day.day_number}" { token Tag::badge("Today").tone(Tone::Info) when day.is_today; token Tag::badge("Day off") when day.is_vacation; meta day_counts(day) when busy(day); } } } } declare! { /// One event, in the compact form both event lists here use. shape event_row(event: &EventSummary) -> Row; row &event.title { meta &event.formatted_time; for project in event.project_name.iter() { token Tag::badge(project); } } } /// Whether the week had no events at all. fn no_events(review: &Review) -> bool { review .data .timeline_days .iter() .all(|day| day.events.is_empty()) } declare! { /// The week's events, under the day each fell on. /// /// A list holds rows and nothing else, so the grouping is headings between /// lists rather than anything inside one. That is the vocabulary working: /// the JS wraps each day in a `timeline-events-day` div because it needs /// somewhere to hang a label, and a heading is what the label actually is. shape week_events(review: &Review) -> Vec; section "Week's Events" unless no_events(review); for day in review.data.timeline_days.iter() { subsection "{day.day_name} {day.day_number}" unless day.events.is_empty(); list { for event in day.events.iter() { include event_row(event); } } unless day.events.is_empty(); } } declare! { /// What got done. /// /// # The second finding /// /// **A cap that exists to fit a card is not a fact, and the description /// should not carry it.** /// /// The JS shows the first 6 completed tasks, the first 3 overdue and the /// first 3 carried over, with nothing saying there are more. Those numbers /// are the height of a card in a grid, which is the renderer's problem, and /// [`Rest`](quasi_router::screen::Rest) is not the answer to them: it wants /// an action, because `346567f9` was about lists with a real remainder to /// go and ask for. There is no address here holding "the rest of what you /// finished" and inventing one would be adding a screen to justify a cap. /// /// So every list on this screen is whole, and a renderer that can only draw /// six rows is the thing that decides that. The count stays as a figure /// beside it, which is the fact the badge was carrying. shape accomplished(review: &Review) -> Vec; section "Accomplished"; stats [] { figure Figure::new(review.data.tasks_completed_count.to_string(), "Tasks Completed") .tone(Tone::Success); figure Figure::new(review.data.events_occurred_count.to_string(), "Events Attended"); } empty "Nothing completed this week" when review.data.tasks_completed.is_empty(); list { for task in review.data.tasks_completed.iter() { include task_row(task); } } unless review.data.tasks_completed.is_empty(); } /// What the overdue figure says about itself. fn overdue_tone(review: &Review) -> Tone { if review.data.tasks_overdue_count > 0 { Tone::Danger } else { Tone::Neutral } } /// Whether anything slipped. fn slipped(review: &Review) -> bool { !review.data.tasks_overdue.is_empty() || !review.data.carried_over_tasks.is_empty() } declare! { /// What slipped. /// /// Overdue and carried-over are one list, told apart by a token rather than /// by order: ordering relies on the reader noticing a red due date, which is /// a distinction that survives only for someone who can see both halves at /// once. /// /// **`meta` sets rather than appends**, so an overdue row's due date /// replaces its project. That is what the hand-written rows did too, and it /// is a defect rather than a decision; reported rather than repaired here, /// because a conversion is the wrong place to change what a screen says. shape needs_attention(review: &Review) -> Vec; section "Needs Attention"; stats [] { figure Figure::new(review.data.tasks_overdue_count.to_string(), "Overdue") .tone(overdue_tone(review)); figure Figure::new(review.data.carried_over_count.to_string(), "Carried Over") .tone(Tone::Info); } list { for task in review.data.tasks_overdue.iter() { row &task.title { meta project_and_due(task); token Tag::badge("Overdue").tone(Tone::Danger); } } for task in review.data.carried_over_tasks.iter() { row &task.title { for project in task.project_name.iter() { meta project; } token Tag::badge("Carried over"); } } } when slipped(review); } declare! { /// What is coming. shape due_this_week(review: &Review) -> Vec; section "Due This Week"; empty "No tasks due this week" when review.data.tasks_due_next_week.is_empty(); list { for task in review.data.tasks_due_next_week.iter() { row &task.title { meta project_and_due(task); } } } unless review.data.tasks_due_next_week.is_empty(); } /// A row's one trailing fact where the task says both its project and when it /// is due. /// /// `meta` sets rather than appends, so writing them as two settings left only /// the due date, and no overdue or due-this-week row ever showed its project. fn project_and_due(task: &Task) -> String { [task.project_name.clone(), Some(task.due_formatted())] .into_iter() .flatten() .collect::>() .join(" · ") } /// Whether anything is focused at all. fn any_focused(review: &Review) -> bool { !review.data.focused_tasks.is_empty() } /// Whether the picker is offered. /// /// Only while there is somewhere to put one, which is /// `available.length > 0 && focused.length < 3` in the JS. The repository /// already caps the candidates at ten, so unlike the lists above this is a limit /// in the data rather than in a card. fn offers_suggestions(review: &Review) -> bool { review.data.focused_tasks.len() < FOCUS_SLOTS && !review.data.available_for_focus.is_empty() } declare! { /// The week's priorities. /// /// # The third finding, closed /// /// **A place awaiting content is a region, and `Slot` already had one.** /// /// Always three slots. A filled one holds a task; an empty one is a real /// described thing: it is reachable, it is named, and it is where a chosen /// task lands. Two filled slots and one empty slot is not a list of two /// tasks, and describing it as one loses that there is room for a third. /// /// So: three [`Slot`]s, one per priority, each [`named`](Slot::named). A /// region is exactly a named place, so this costs no vocabulary. It is /// heavy, three regions for three slots, and the weight sits in the /// description rather than in new words. /// /// Rejected with it: extending [`Node::StandIn`] to stand for a *place* /// rather than a missing item. `StandIn` exists to stop a fake row appearing /// per absence, and a place that is empty and can be landed on carries an /// identity and a target; one member covering both would blur what /// `StandIn` is for. It still says what is inside an empty region, which is /// nothing, and that is the job it has. /// /// The meter goes rather than standing beside them. Three named regions /// carry the count they were a summary of, and a bar reading "1 of 3" next /// to three places one of which is full is the same fact drawn twice. /// /// Nothing here says how a task gets into a place: the suggestions below /// carry the only address that fills one, and a browser's drop target and a /// terminal's Enter are two renderers' answers to that one fact rather than /// something to name once in words. shape focus(review: &Review) -> Vec; section "This Week's Focus"; for place in review.places.iter() { region "weekly-focus-{place.number}" as Group { named "Priority {place.number}"; empty "Open" unless filled(place); list { for task in place.task.iter() { include focused_row(review, task); } } when filled(place); } } act "Clear all focus" to doing in_week( Action::post("/weekly-review/focus/clear"), review.week ) when any_focused(review); subsection "Suggested" when offers_suggestions(review); list { for task in review.data.available_for_focus.iter() { include suggested_row(review, task); } } when offers_suggestions(review); } declare! { /// A task in one of the week's places, with the way out of it. shape focused_row(review: &Review, task: &Task) -> Row; row &task.title { for project in task.project_name.iter() { meta project; } act "Remove" to doing in_week( Action::post("/weekly-review/focus/{task.id}").with("focus", "false"), review.week ); } } declare! { /// One task the picker offers, with what stands in its way. shape suggested_row(review: &Review, task: &Task) -> Row; row &task.title { for project in task.project_name.iter() { meta project; } for mark in waiting_mark(task, &review.waits).into_iter() { token mark; } act "Focus" to doing in_week( Action::post("/weekly-review/focus/{task.id}").with("focus", "true"), review.week ); } } /// How many of a project's tasks are open, against how many it has. fn project_counts(project: &ProjectHealth) -> String { format!( "{} active, {} total", project.active_count, project.total_count ) } /// Whether the project is carrying anything overdue. fn project_overdue(project: &ProjectHealth) -> bool { project.overdue_count > 0 } declare! { /// How each project is doing. shape projects_health(review: &Review) -> Vec; section "Projects Health" unless review.data.project_health.is_empty(); list { for project in review.data.project_health.iter() { row &project.name { meta project_counts(project); token Tag::badge(&project.status).tone(health_tone(&project.status)); token Tag::badge("{project.overdue_count} overdue").tone(Tone::Danger) when project_overdue(project); } } } unless review.data.project_health.is_empty(); } declare! { /// The days marked off. /// /// The one section here where the vocabulary already had the answer and the /// port did not have to argue for it. Seven independently latched things, /// each answering a click, is [`Tag::chip`] with /// [`latched`](quasi_router::screen::Tag::latched) -- which arrived for /// filter chips and turns out to describe this without a change. /// /// Not one control picking one of a set: days off are seven yes-or-no /// answers where any number can be yes. /// /// The names are written out rather than abbreviated. The JS draws single /// letters (`M T W T F S S`), which is a renderer's abbreviation of a name /// and not the name; a description that said "T" would be handing a terminal /// and a screen reader the same ambiguity a sighted user resolves from /// position. shape days_off(review: &Review) -> Vec; section "Days Off"; for day in review.days.iter() { chip day.name to doing in_week( Action::post("/weekly-review/vacation/{day.index}"), review.week ) { latched day.off; } } } /// What the reflection's button reads. fn reflection_submit(review: &Review) -> &'static str { if review.data.is_completed { "Save notes" } else { "Complete review" } } declare! { /// The reflection. /// /// # The fourth finding /// /// **A field cannot say its value is a draft.** /// /// `weekly-review.js` keeps what the user has typed in `localStorage` /// against the week, restores it over the stored notes on render, and clears /// it on completion, so a review survives closing the app halfway through /// writing it. Nothing in [`Field`] can say that: `value` is what the field /// holds, and whether the host should be keeping unsent keystrokes somewhere /// is not a property of the value. /// /// The described screen therefore loses the draft and shows what is stored, /// which is correct and worse. Filed against quasicoherent rather than /// worked around, because the workaround is a route that writes on every /// keystroke and that is a different feature wearing this one's name. /// /// The prompts themselves are the JS's, verbatim, including the /// placeholders: they are the question being asked and not decoration. shape reflection(review: &Review) -> Vec; section "Reflection"; form doing in_week(Action::post("/weekly-review/complete"), review.week) { submit reflection_submit(review); field Textarea "went-well" "What went well?" { placeholder "Completed the budget ahead of schedule..."; value &review.went_well; } field Textarea "improve" "What could be improved?" { placeholder "Need to block more focus time..."; value &review.improve; } } } /// The week before this one. fn last_week(review: &Review) -> NaiveDate { review.week - Duration::days(7) } /// The week after. fn next_week(review: &Review) -> NaiveDate { review.week + Duration::days(7) } 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 -- focusing a task /// changes the focus list and the suggestions -- and a `Response` names one /// region. /// /// The JS says the reviewed banner above the reflection card. It is a /// screen-wide fact -- it changes what the submit button means -- so it sits /// at the top rather than beside the form. shape screen(review: &Review) -> Screen; screen sidebar_content "Weekly Review" { at_place super::shell::WEEK; region "review-band" as Band { page &review.data.week_display; act "Previous week" to doing in_week(Action::get("/weekly-review"), last_week(review)); act "Next week" to doing in_week(Action::get("/weekly-review"), next_week(review)); } region "weekly-review" as Pane { banner Tone::Info "This week is already reviewed. Your notes stay editable." when review.data.is_completed; extend timeline(review); extend week_events(review); extend accomplished(review); extend needs_attention(review); extend due_this_week(review); extend focus(review); extend projects_health(review); extend days_off(review); extend reflection(review); } } } /// Answer a write with the week 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, week: NaiveDate) -> Result { Ok(screen(&read(state, week)?).into()) } /// The whole review. fn review(state: &AppState, request: quasi_router::Request) -> Result { wrote(state, week_of(&request)) } /// Put a task in the week's focus, or take it out. /// /// One route with a `focus` param rather than two addresses: both callers know /// which way they are going, since the suggestion button always adds and the /// slot button always removes. A route that read the current state and flipped /// it would be a third behaviour neither caller wants, and would race a second /// window. /// /// Focus is a property of the task and not of the week (`tasks.is_focus` is one /// column), so this writes the same flag whichever week it was called from. fn set_focus(state: &AppState, request: quasi_router::Request) -> Result { let raw = request .captures .get("id") .ok_or_else(|| RouteError::not_found("no task id"))?; let id = goingson_core::TaskId::from( uuid::Uuid::parse_str(raw).map_err(|_| RouteError::not_found("not a task id"))?, ); let on = request.payload.get("focus") == Some("true"); state .tasks .set_focus(id, DESKTOP_USER_ID, on) .map_err(|error| RouteError::internal(error.to_string()))? .ok_or_else(|| RouteError::not_found("no such task"))?; wrote(state, week_of(&request)) } /// Take everything out of the week's focus. fn clear_focus(state: &AppState, request: quasi_router::Request) -> Result { state .tasks .clear_all_focus(DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))?; wrote(state, week_of(&request)) } /// Mark a weekday off, or on again. /// /// The write takes the whole set, so a toggle is a read, a flip and a write. /// That is the one place on this screen where two windows on the same week can /// lose an edit, and it is the storage's shape rather than the description's. /// /// What it writes is contexts. `set_vacation_week` dissolves the week's /// `Vacation` spans into days, applies these seven, and re-runs the rest, so a /// holiday running into the week from before keeps its earlier half. fn toggle_vacation( state: &AppState, request: quasi_router::Request, ) -> Result { let week = week_of(&request); let day: u8 = request .captures .get("day") .and_then(|raw| raw.parse().ok()) .filter(|day| usize::from(*day) < WEEKDAYS.len()) .ok_or_else(|| RouteError::not_found("not a weekday"))?; let mut days = load(state, week)?.vacation_days; if let Some(at) = days.iter().position(|held| *held == day) { days.remove(at); } else { days.push(day); } crate::commands::weekly_review::set_vacation_week(state, week, &days) .map_err(|error| RouteError::internal(error.to_string()))?; wrote(state, week) } /// Save the reflection and mark the week reviewed. /// /// The stored format keeps a blank line between the two answers, and /// [`prompt_answer`] is what reads them back. /// /// An empty answer is left out rather than written as an empty heading. Both /// empty leaves the notes empty, and /// the review is still marked reviewed: the completion is the act, and the /// writing is optional. fn complete(state: &AppState, request: quasi_router::Request) -> Result { let week = week_of(&request); let went_well = request.payload.get("went-well").unwrap_or_default().trim(); let improve = request.payload.get("improve").unwrap_or_default().trim(); let mut notes = String::new(); if !went_well.is_empty() { notes.push_str("What went well:\n"); notes.push_str(went_well); notes.push_str("\n\n"); } if !improve.is_empty() { notes.push_str("What could be improved:\n"); notes.push_str(improve); } state .weekly_reviews .upsert(DESKTOP_USER_ID, week, notes.trim()) .map_err(|error| RouteError::internal(error.to_string()))?; Ok(wrote(state, week)?.toast(makeover_layout::Tone::Success, "Week reviewed")) } /// The weekly review's routes. #[must_use] pub fn routes(router: Router) -> Router { router .get("/weekly-review", review) // Ahead of the capture below, which the path matcher does on its own: // a static segment outranks a capture, so `clear` never arrives as an // id. Written in this order anyway, because a reader should not have to // know that to be sure. .post("/weekly-review/focus/clear", clear_focus) .post("/weekly-review/focus/{id}", set_focus) .post("/weekly-review/vacation/{day}", toggle_vacation) .post("/weekly-review/complete", complete) }