|
1 |
+ |
//! The monthly review, described rather than built.
|
|
2 |
+ |
//!
|
|
3 |
+ |
//! <!-- wiki: quasi-overview -->
|
|
4 |
+ |
//!
|
|
5 |
+ |
//! Eighth screen ported, chosen 2026-08-10 by the rule the weekly review used:
|
|
6 |
+ |
//! the heaviest un-described file a description can actually express. That
|
|
7 |
+ |
//! second clause did the work here, because the four heavier candidates are all
|
|
8 |
+ |
//! waiting on something rather than on effort.
|
|
9 |
+ |
//!
|
|
10 |
+ |
//! - `events.js` (993) and `events-calendar.js` (436). A month grid, a week
|
|
11 |
+ |
//! grid and a mobile day column, which is [`RegionKind::Bespoke`]'s shape and
|
|
12 |
+ |
//! was the weekly review's reason for skipping the same files.
|
|
13 |
+ |
//! - `day-planning` (~1,585 across four files). Drag-to-paint, which nothing in
|
|
14 |
+ |
//! the vocabulary names.
|
|
15 |
+ |
//! - `time-tracking.js` (564). A floating widget on a one-second `setInterval`,
|
|
16 |
+ |
//! computing elapsed time client-side. A ticking clock is not a description.
|
|
17 |
+ |
//! - `search.js` (415). A Cmd+K overlay whose completion list depends on where
|
|
18 |
+ |
//! the caret is. The mail port called this "its own screen and its own port";
|
|
19 |
+ |
//! read closely it is a host affordance over whatever screen you are on, and
|
|
20 |
+ |
//! the port it wants is a different thing from this one.
|
|
21 |
+ |
//!
|
|
22 |
+ |
//! That leaves this (627 across two files) and `problems.js` (229), and this is
|
|
23 |
+ |
//! the heavier. It is also the one the escape.js task calls the sibling that
|
|
24 |
+ |
//! "teaches least", which is true and is not a reason to skip it: the
|
|
25 |
+ |
//! done-condition there is zero `esc()` call sites, and a port is a port. What
|
|
26 |
+ |
//! it costs in findings it returns in confirmation, and one prior finding does
|
|
27 |
+ |
//! get its second consumer below.
|
|
28 |
+ |
//!
|
|
29 |
+ |
//! The shipped screen is `frontend/js/monthly-review.js` and its render module
|
|
30 |
+ |
//! exactly as before; see [the module above](super) for why both exist at once.
|
|
31 |
+ |
//!
|
|
32 |
+ |
//! # The shape
|
|
33 |
+ |
//!
|
|
34 |
+ |
//! - `GET /monthly-review` — the whole review, for `?month=` or for this month.
|
|
35 |
+ |
//! - `POST /monthly-review/goals` — add a goal at a position, under `text`.
|
|
36 |
+ |
//! - `POST /monthly-review/goals/{id}/status` — set a goal's status, under
|
|
37 |
+ |
//! `status`.
|
|
38 |
+ |
//! - `POST /monthly-review/goals/{id}/delete` — delete a goal.
|
|
39 |
+ |
//! - `POST /monthly-review/complete` — save the reflection.
|
|
40 |
+ |
//!
|
|
41 |
+ |
//! Every described control reaches one of those, which is the standard the
|
|
42 |
+ |
//! contacts port set.
|
|
43 |
+ |
//!
|
|
44 |
+ |
//! # The month is an address, not a variable
|
|
45 |
+ |
//!
|
|
46 |
+ |
//! `monthly-review.js` holds `currentMonth` in module scope. Here it is a query
|
|
47 |
+ |
//! param, per decision 2, exactly as the week is on the sibling screen, and
|
|
48 |
+ |
//! with the same consequence: every action carries the month it was offered
|
|
49 |
+ |
//! under or acting silently moves the user to this month and writes there.
|
|
50 |
+ |
//! [`in_month`] is that, applied to all five routes and to all three
|
|
51 |
+ |
//! navigation controls.
|
|
52 |
+ |
//!
|
|
53 |
+ |
//! # What is left out, and why
|
|
54 |
+ |
//!
|
|
55 |
+ |
//! **The day summary and "Go to Day".** Clicking a heat-map cell opens a modal
|
|
56 |
+ |
//! summarising that day, whose one control switches to the day-plan view.
|
|
57 |
+ |
//! Day planning is not ported, so the destination does not exist as an address
|
|
58 |
+ |
//! yet; describing a control that reaches nothing would be worse than leaving
|
|
59 |
+ |
//! it out. It returns when day planning does.
|
|
60 |
+ |
|
|
61 |
+ |
// Handlers take their params by value because `quasi_router::Handler` is a
|
|
62 |
+ |
// plain `fn(&S, Params)` pointer, so the signature is the router's and not a
|
|
63 |
+ |
// choice made here. Same allow, for the same reason, as quasi-axum's tests.
|
|
64 |
+ |
#![allow(clippy::needless_pass_by_value)]
|
|
65 |
+ |
|
|
66 |
+ |
use chrono::{Datelike, NaiveDate};
|
|
67 |
+ |
use goingson_core::monthly_review::{self, MonthDayData, MonthlyReviewData, ProjectPulse};
|
|
68 |
+ |
use goingson_core::weekly_review::ProjectHealth;
|
|
69 |
+ |
use goingson_core::{MonthlyGoal, MonthlyGoalStatus, Task};
|
|
70 |
+ |
use quasi_router::screen::{Act, Field, Figure, Row, Tag};
|
|
71 |
+ |
use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
|
|
72 |
+ |
|
|
73 |
+ |
use crate::commands::gather_monthly_review;
|
|
74 |
+ |
use crate::state::{AppState, DESKTOP_USER_ID};
|
|
75 |
+ |
|
|
76 |
+ |
#[cfg(test)]
|
|
77 |
+ |
mod tests;
|
|
78 |
+ |
|
|
79 |
+ |
/// How many goals a month holds.
|
|
80 |
+ |
///
|
|
81 |
+ |
/// `monthly-review-render.js:renderGoals` counts to 3 and the repository
|
|
82 |
+ |
/// enforces nothing, so this is the screen's rule and it is stated once here
|
|
83 |
+ |
/// rather than in the two places that ask about it. Same shape as the weekly
|
|
84 |
+ |
/// review's `FOCUS_SLOTS`, and the same reason.
|
|
85 |
+ |
const GOAL_SLOTS: i32 = 3;
|
|
86 |
+ |
|
|
87 |
+ |
/// The month a route was addressed at, or this one.
|
|
88 |
+ |
///
|
|
89 |
+ |
/// An unparseable `month` is this month rather than a 400, matching
|
|
90 |
+ |
/// [`resolve_month_start`](crate::commands::resolve_month_start)'s tolerance at
|
|
91 |
+ |
/// the command layer only in outcome:
|
|
92 |
+ |
/// there a bad value is a client bug worth reporting, and here it is a
|
|
93 |
+ |
/// hand-typed address, where landing on this month is the more useful answer
|
|
94 |
+ |
/// than an error page.
|
|
95 |
+ |
fn month_of(params: &quasi_router::Params) -> NaiveDate {
|
|
96 |
+ |
params
|
|
97 |
+ |
.get("month")
|
|
98 |
+ |
.and_then(monthly_review::parse_month)
|
|
99 |
+ |
.unwrap_or_else(monthly_review::current_month_start)
|
|
100 |
+ |
}
|
|
101 |
+ |
|
|
102 |
+ |
/// The same action, still pointed at the month it was offered under.
|
|
103 |
+ |
fn in_month(action: Action, month: NaiveDate) -> Action {
|
|
104 |
+ |
action.with("month", month.format("%Y-%m").to_string())
|
|
105 |
+ |
}
|
|
106 |
+ |
|
|
107 |
+ |
/// The month before this one, and the month after.
|
|
108 |
+ |
///
|
|
109 |
+ |
/// Written here rather than with `Duration` because months are not a fixed
|
|
110 |
+ |
/// number of days: stepping 31 days back from the 1st of March lands in
|
|
111 |
+ |
/// January.
|
|
112 |
+ |
fn step(month: NaiveDate, forward: bool) -> NaiveDate {
|
|
113 |
+ |
let (year, number) = match (month.month(), forward) {
|
|
114 |
+ |
(12, true) => (month.year() + 1, 1),
|
|
115 |
+ |
(1, false) => (month.year() - 1, 12),
|
|
116 |
+ |
(m, true) => (month.year(), m + 1),
|
|
117 |
+ |
(m, false) => (month.year(), m - 1),
|
|
118 |
+ |
};
|
|
119 |
+ |
NaiveDate::from_ymd_opt(year, number, 1).unwrap_or(month)
|
|
120 |
+ |
}
|
|
121 |
+ |
|
|
122 |
+ |
/// Read the month.
|
|
123 |
+ |
fn load(state: &AppState, month: NaiveDate) -> Result<MonthlyReviewData, RouteError> {
|
|
124 |
+ |
gather_monthly_review(state, month).map_err(|error| RouteError::internal(error.to_string()))
|
|
125 |
+ |
}
|
|
126 |
+ |
|
|
127 |
+ |
/// The tone a project's health wears.
|
|
128 |
+ |
///
|
|
129 |
+ |
/// The weekly review's `health_tone`, and deliberately a copy rather than a
|
|
130 |
+ |
/// shared helper: it is four lines, the two screens read the same core strings,
|
|
131 |
+ |
/// and hoisting it would put a lookup table in a `super` module that exists to
|
|
132 |
+ |
/// hold routers. If a third screen wants it, that is the second consumer and
|
|
133 |
+ |
/// the argument changes.
|
|
134 |
+ |
fn health_tone(status: &str) -> makeover_layout::Tone {
|
|
135 |
+ |
match status {
|
|
136 |
+ |
"healthy" => makeover_layout::Tone::Success,
|
|
137 |
+ |
"warning" => makeover_layout::Tone::Warning,
|
|
138 |
+ |
"danger" => makeover_layout::Tone::Danger,
|
|
139 |
+ |
_ => makeover_layout::Tone::Neutral,
|
|
140 |
+ |
}
|
|
141 |
+ |
}
|
|
142 |
+ |
|
|
143 |
+ |
/// One task, the way every list on this screen writes one.
|
|
144 |
+ |
///
|
|
145 |
+ |
/// The project is `meta` rather than a token, for the reason the weekly review
|
|
146 |
+ |
/// gives: a plain trailing fact with no tone of its own and no click to answer.
|
|
147 |
+ |
fn task_row(task: &Task) -> Row {
|
|
148 |
+ |
let row = Row::new(&task.title);
|
|
149 |
+ |
match &task.project_name {
|
|
150 |
+ |
Some(project) => row.meta(project),
|
|
151 |
+ |
None => row,
|
|
152 |
+ |
}
|
|
153 |
+ |
}
|
|
154 |
+ |
|
|
155 |
+ |
/// The month at a glance.
|
|
156 |
+ |
///
|
|
157 |
+ |
/// # The first finding, and it is a second consumer rather than a new one
|
|
158 |
+ |
///
|
|
159 |
+ |
/// **`intensity` is a renderer's encoding of a number, and the description
|
|
160 |
+ |
/// carries the number.**
|
|
161 |
+ |
///
|
|
162 |
+ |
/// `MonthDayData` carries both `completed_count` and `intensity`, a 0-3 bucket
|
|
163 |
+ |
/// the JS turns into one of four background shades. That is exactly the weekly
|
|
164 |
+ |
/// review's first finding — where the JS capped each day's counts at three dots
|
|
165 |
+ |
/// — arriving at month scale from a different direction, and it comes out the
|
|
166 |
+ |
/// same way: a shade is one renderer's way of saying "a lot", it runs out of
|
|
167 |
+ |
/// room at 3, and a host with room to print `12` should print `12`. So the days
|
|
168 |
+ |
/// below carry counts and `intensity` is not described at all.
|
|
169 |
+ |
///
|
|
170 |
+ |
/// Worth recording that this is the second consumer of that finding, since one
|
|
171 |
+ |
/// consumer was not evidence the last three times the question came up.
|
|
172 |
+ |
///
|
|
173 |
+ |
/// # The grid is not described, and that is the honest answer
|
|
174 |
+ |
///
|
|
175 |
+ |
/// The JS draws a calendar: `week_count` rows of seven cells, offset by
|
|
176 |
+ |
/// `first_day_offset`, empty cells before the 1st. A month grid is
|
|
177 |
+ |
/// [`RegionKind::Bespoke`]'s shape, which is the same reason the events
|
|
178 |
+ |
/// calendar is not ported and the reason this screen was portable without it.
|
|
179 |
+ |
/// A list of days that had something on them keeps every fact the grid carries
|
|
180 |
+ |
/// except the shape, and inventing a `Node::Calendar` to keep the shape is a
|
|
181 |
+ |
/// vocabulary decision this port has no standing to make alone.
|
|
182 |
+ |
///
|
|
183 |
+ |
/// Empty days are left out rather than listed as zeroes. Thirty-one rows of
|
|
184 |
+ |
/// which twenty say nothing is a worse reading of the month than eleven that
|
|
185 |
+ |
/// do, and the totals underneath already say how much of the month was quiet.
|
|
186 |
+ |
fn heat_map(days: &[MonthDayData]) -> Vec<Node> {
|
|
187 |
+ |
let rows: Vec<Row> = 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 |
+ |
|
|
199 |
+ |
let mut row = Row::new(format!("{}", day.day_number));
|
|
200 |
+ |
if day.is_today {
|
|
201 |
+ |
row = row.token(Tag::badge("Today").tone(makeover_layout::Tone::Info));
|
|
202 |
+ |
}
|
|
203 |
+ |
if day.is_vacation {
|
|
204 |
+ |
// A day off is why the counts are absent rather than zero, so
|
|
205 |
+ |
// it is a token and not merely a shade on the cell.
|
|
206 |
+ |
row = row.token(Tag::badge("Day off"));
|
|
207 |
+ |
}
|
|
208 |
+ |
if counts.is_empty() {
|
|
209 |
+ |
row
|
|
210 |
+ |
} else {
|
|
211 |
+ |
row.meta(counts.join(", "))
|
|
212 |
+ |
}
|
|
213 |
+ |
})
|
|
214 |
+ |
.collect();
|
|
215 |
+ |
|
|
216 |
+ |
if rows.is_empty() {
|
|
217 |
+ |
return vec![
|
|
218 |
+ |
Node::section("The Month"),
|
|
219 |
+ |
Node::empty("Nothing recorded this month yet."),
|
|
220 |
+ |
];
|
|
221 |
+ |
}
|
|
222 |
+ |
vec![Node::section("The Month"), Node::list(rows)]
|
|
223 |
+ |
}
|
|
224 |
+ |
|
|
225 |
+ |
/// What the month added up to.
|
|
226 |
+ |
///
|
|
227 |
+ |
/// Figures rather than prose, which is what `renderStats` draws and what the
|
|
228 |
+ |
/// numbers are. The busiest and quietest days are dates the core crate has
|
|
229 |
+ |
/// already formatted, and they are absent rather than zero when the month has
|
|
230 |
+ |
/// not produced one: a month with no completions has no busiest day, and
|
|
231 |
+ |
/// "None" would be a different claim.
|
|
232 |
+ |
fn stats(data: &MonthlyReviewData) -> Vec<Node> {
|
|
233 |
+ |
let mut figures = vec![
|
|
234 |
+ |
Figure::new(data.tasks_completed_count.to_string(), "Tasks Completed"),
|
|
235 |
+ |
Figure::new(data.tasks_created_count.to_string(), "Tasks Created"),
|
|
236 |
+ |
Figure::new(data.events_count.to_string(), "Events"),
|
|
237 |
+ |
Figure::new(data.completion_streak.to_string(), "Longest Streak"),
|
|
238 |
+ |
];
|
|
239 |
+ |
if let Some(busiest) = &data.busiest_day {
|
|
240 |
+ |
figures.push(Figure::new(busiest, "Busiest Day"));
|
|
241 |
+ |
}
|
|
242 |
+ |
if let Some(quietest) = &data.quietest_day {
|
|
243 |
+ |
figures.push(Figure::new(quietest, "Quietest Day"));
|
|
244 |
+ |
}
|
|
245 |
+ |
|
|
246 |
+ |
vec![Node::section("The Numbers"), Node::stats(figures)]
|
|
247 |
+ |
}
|
|
248 |
+ |
|
|
249 |
+ |
/// The tasks the month finished.
|
|
250 |
+ |
///
|
|
251 |
+ |
/// Core caps this at six for the card; the cap is the data's and not the
|
|
252 |
+ |
/// description's, so nothing is truncated again here. The count above it is the
|
|
253 |
+ |
/// real total, which is what makes the cap readable rather than misleading.
|
|
254 |
+ |
fn accomplished(data: &MonthlyReviewData) -> Vec<Node> {
|
|
255 |
+ |
if data.tasks_completed_top.is_empty() {
|
|
256 |
+ |
return Vec::new();
|
|
257 |
+ |
}
|
|
258 |
+ |
vec![
|
|
259 |
+ |
Node::section("Accomplished"),
|
|
260 |
+ |
Node::list(data.tasks_completed_top.iter().map(task_row)),
|
|
261 |
+ |
]
|
|
262 |
+ |
}
|
|
263 |
+ |
|
|
264 |
+ |
/// Which way each project moved.
|
|
265 |
+ |
///
|
|
266 |
+ |
/// `direction` is a string core writes ("growing", "shrinking", "stable") and
|
|
267 |
+ |
/// the JS turns into an arrow glyph. The direction is the fact and the arrow is
|
|
268 |
+ |
/// one renderer's spelling of it, so this says the word and tones it: a project
|
|
269 |
+ |
/// that closed more than it opened is the good case, which no glyph conveys on
|
|
270 |
+ |
/// its own.
|
|
271 |
+ |
fn project_pulse(pulse: &[ProjectPulse]) -> Vec<Node> {
|
|
272 |
+ |
if pulse.is_empty() {
|
|
273 |
+ |
return Vec::new();
|
|
274 |
+ |
}
|
|
275 |
+ |
let rows = pulse.iter().map(|project| {
|
|
276 |
+ |
let (label, tone) = match project.direction.as_str() {
|
|
277 |
+ |
"shrinking" => ("Shrinking", makeover_layout::Tone::Success),
|
|
278 |
+ |
"growing" => ("Growing", makeover_layout::Tone::Warning),
|
|
279 |
+ |
_ => ("Stable", makeover_layout::Tone::Neutral),
|
|
280 |
+ |
};
|
|
281 |
+ |
Row::new(&project.name)
|
|
282 |
+ |
.token(Tag::badge(label).tone(tone))
|
|
283 |
+ |
.meta(format!(
|
|
284 |
+ |
"{} done, {} added",
|
|
285 |
+ |
project.completed, project.created
|
|
286 |
+ |
))
|
|
287 |
+ |
});
|
|
288 |
+ |
|
|
289 |
+ |
vec![Node::section("Project Pulse"), Node::list(rows)]
|
|
290 |
+ |
}
|
|
291 |
+ |
|
|
292 |
+ |
/// How each project is doing, on the same three-value scale the weekly review
|
|
293 |
+ |
/// reads.
|
|
294 |
+ |
fn projects_health(health: &[ProjectHealth]) -> Vec<Node> {
|
|
295 |
+ |
if health.is_empty() {
|
|
296 |
+ |
return Vec::new();
|
|
297 |
+ |
}
|
|
298 |
+ |
let rows = health.iter().map(|project| {
|
|
299 |
+ |
Row::new(&project.name)
|
|
300 |
+ |
.token(Tag::badge(&project.status).tone(health_tone(&project.status)))
|
|
301 |
+ |
});
|
|
302 |
+ |
|
|
303 |
+ |
vec![Node::section("Project Health"), Node::list(rows)]
|
|
304 |
+ |
}
|
|
305 |
+ |
|
|
306 |
+ |
/// What the month said about itself.
|
|
307 |
+ |
///
|
|
308 |
+ |
/// Core computes these as finished sentences, so there is nothing here to
|
|
309 |
+ |
/// describe beyond saying they are a list of statements rather than a
|
|
310 |
+ |
/// paragraph.
|
|
311 |
+ |
fn patterns(data: &MonthlyReviewData) -> Vec<Node> {
|
|
312 |
+ |
if data.patterns.is_empty() {
|
|
313 |
+ |
return Vec::new();
|
|
314 |
+ |
}
|
|
315 |
+ |
vec![
|
|
316 |
+ |
Node::section("Patterns"),
|
|
317 |
+ |
Node::list(data.patterns.iter().map(Row::new)),
|
|
318 |
+ |
]
|
|
319 |
+ |
}
|
|
320 |
+ |
|
|
321 |
+ |
/// The month's goals, and the empty slots left.
|
|
322 |
+ |
///
|
|
323 |
+ |
/// # The second finding
|
|
324 |
+ |
///
|
|
325 |
+ |
/// **A control that cycles hidden state cannot be described, and should not
|
|
326 |
+ |
/// be.**
|
|
327 |
+ |
///
|
|
328 |
+ |
/// `monthly-review.js:cycleGoalStatus` reads the goal out of module state,
|
|
329 |
+ |
/// looks up `active -> done -> abandoned -> active`, and writes the next one.
|
|
330 |
+ |
/// Two things are wrong with it and only one is the description layer's.
|
|
331 |
+ |
///
|
|
332 |
+ |
/// The describable half: a button labelled with the *current* status, whose
|
|
333 |
+ |
/// effect is a table the user cannot see, says nothing about what pressing it
|
|
334 |
+ |
/// will do. Here each goal offers the move by name — "Mark done", "Give up on
|
|
335 |
+ |
/// it", "Make it active again" — so the label is the outcome.
|
|
336 |
+ |
///
|
|
337 |
+ |
/// The half that is a real defect in the shipped screen: computing the next
|
|
338 |
+ |
/// status from a copy read at render time races a second window, which will
|
|
339 |
+ |
/// write a status derived from what it saw rather than from what is stored.
|
|
340 |
+ |
/// Naming the target explicitly removes the race as a side effect, because the
|
|
341 |
+ |
/// route no longer has to know what the goal was before.
|
|
342 |
+ |
fn goals(data: &MonthlyReviewData, month: NaiveDate) -> Vec<Node> {
|
|
343 |
+ |
let mut out = vec![Node::section("Goals")];
|
|
344 |
+ |
|
|
345 |
+ |
for goal in &data.goals {
|
|
346 |
+ |
let (label, next) = match goal.status {
|
|
347 |
+ |
MonthlyGoalStatus::Active => ("Mark done", MonthlyGoalStatus::Done),
|
|
348 |
+ |
MonthlyGoalStatus::Done => ("Give up on it", MonthlyGoalStatus::Abandoned),
|
|
349 |
+ |
MonthlyGoalStatus::Abandoned => ("Make it active again", MonthlyGoalStatus::Active),
|
|
350 |
+ |
};
|
|
351 |
+ |
out.push(Node::list([goal_row(goal, month, label, &next)]));
|
|
352 |
+ |
}
|
|
353 |
+ |
|
|
354 |
+ |
let taken = i32::try_from(data.goals.len()).unwrap_or(GOAL_SLOTS);
|
|
355 |
+ |
if taken < GOAL_SLOTS {
|
|
356 |
+ |
// The JS draws one empty slot per remaining position, each opening the
|
|
357 |
+ |
// same modal. One form is the same offer without pretending the
|
|
358 |
+ |
// positions differ: the next one is the next one.
|
|
359 |
+ |
out.push(Node::Form {
|
|
360 |
+ |
action: in_month(Action::post("/monthly-review/goals"), month),
|
|
361 |
+ |
submit: "Add goal".to_owned(),
|
|
362 |
+ |
fields: vec![{
|
|
363 |
+ |
let mut field = Field::new(makeover_layout::FieldKind::Text, "text", "Goal");
|
|
364 |
+ |
field.placeholder = Some("What do you want to achieve this month?".to_owned());
|
|
365 |
+ |
// The JS marks this `required: true`, so a host that can refuse
|
|
366 |
+ |
// an empty box refuses it before anything is sent. The check in
|
|
367 |
+ |
// `add_goal` is the backstop for a request that did not come
|
|
368 |
+ |
// through the form.
|
|
369 |
+ |
field.required = true;
|
|
370 |
+ |
field
|
|
371 |
+ |
}],
|
|
372 |
+ |
});
|
|
373 |
+ |
}
|
|
374 |
+ |
|
|
375 |
+ |
out
|
|
376 |
+ |
}
|
|
377 |
+ |
|
|
378 |
+ |
/// One goal, with the move it offers and the way to drop it.
|
|
379 |
+ |
fn goal_row(goal: &MonthlyGoal, month: NaiveDate, label: &str, next: &MonthlyGoalStatus) -> Row {
|
|
380 |
+ |
let (status_label, tone) = match goal.status {
|
|
381 |
+ |
MonthlyGoalStatus::Active => ("Active", makeover_layout::Tone::Info),
|
|
382 |
+ |
MonthlyGoalStatus::Done => ("Done", makeover_layout::Tone::Success),
|
|
383 |
+ |
MonthlyGoalStatus::Abandoned => ("Abandoned", makeover_layout::Tone::Neutral),
|
|
384 |
+ |
};
|
|
385 |
+ |
|
|
386 |
+ |
Row::new(&goal.text)
|
|
387 |
+ |
.token(Tag::badge(status_label).tone(tone))
|
|
388 |
+ |
.act(Act::new(
|
|
389 |
+ |
label,
|
|
390 |
+ |
in_month(
|
|
391 |
+ |
Action::post(format!("/monthly-review/goals/{}/status", goal.id))
|
|
392 |
+ |
.with("status", next.as_str()),
|
|
393 |
+ |
month,
|
|
394 |
+ |
),
|
|
395 |
+ |
))
|
|
396 |
+ |
.act(
|
|
397 |
+ |
Act::new(
|
|
398 |
+ |
"Delete",
|
|
399 |
+ |
in_month(
|
|
400 |
+ |
Action::post(format!("/monthly-review/goals/{}/delete", goal.id)),
|
|
401 |
+ |
month,
|
|
402 |
+ |
),
|
|
403 |
+ |
)
|
|
404 |
+ |
.tone(makeover_layout::Tone::Danger)
|
|
405 |
+ |
.confirm("Are you sure you want to delete this goal?"),
|
|
406 |
+ |
)
|
|
407 |
+ |
}
|
|
408 |
+ |
|
|
409 |
+ |
/// The reflection.
|
|
410 |
+ |
///
|
|
411 |
+ |
/// Two stored columns rather than the weekly review's one blob, so none of that
|
|
412 |
+ |
/// screen's marker-parsing is needed here. The prompts are the JS's, verbatim,
|
|
413 |
+ |
/// including the placeholders: they are the question being asked and not
|
|
414 |
+ |
/// decoration.
|
|
415 |
+ |
///
|
|
416 |
+ |
/// The draft finding the weekly review filed applies unchanged — this screen's
|
|
417 |
+ |
/// JS keeps unsent keystrokes in `localStorage` too, and [`Field`] still cannot
|
|
418 |
+ |
/// say a value is a draft. Recorded rather than re-filed: it is one gap with
|
|
419 |
+ |
/// two consumers, which is the note quasicoherent already holds.
|
|
420 |
+ |
fn reflection(data: &MonthlyReviewData, month: NaiveDate) -> Vec<Node> {
|
|
421 |
+ |
let (highlight, change) = match &data.reflection {
|
|
422 |
+ |
Some(saved) => (saved.highlight_text.clone(), saved.change_text.clone()),
|
|
423 |
+ |
None => (String::new(), String::new()),
|
|
424 |
+ |
};
|
|
425 |
+ |
let reviewed = data.reflection.is_some();
|
|
426 |
+ |
|
|
427 |
+ |
let field = |name: &str, label: &str, placeholder: &str, value: String| {
|
|
428 |
+ |
let mut field = Field::new(makeover_layout::FieldKind::Textarea, name, label).value(value);
|
|
429 |
+ |
field.placeholder = Some(placeholder.to_owned());
|
|
430 |
+ |
field
|
|
431 |
+ |
};
|
|
432 |
+ |
|
|
433 |
+ |
vec![
|
|
434 |
+ |
Node::section("Reflection"),
|
|
435 |
+ |
Node::Form {
|
|
436 |
+ |
action: in_month(Action::post("/monthly-review/complete"), month),
|
|
437 |
+ |
submit: if reviewed {
|
|
438 |
+ |
"Save notes".to_owned()
|
|
439 |
+ |
} else {
|
|
440 |
+ |
"Complete review".to_owned()
|
|
441 |
+ |
},
|
|
442 |
+ |
fields: vec![
|
|
443 |
+ |
field(
|
|
444 |
+ |
"highlight",
|
|
445 |
+ |
"What was the highlight of this month?",
|
|
446 |
+ |
"Shipped the thing I had been putting off...",
|
|
447 |
+ |
highlight,
|
|
448 |
+ |
),
|
|
449 |
+ |
field(
|
|
450 |
+ |
"change",
|
|
451 |
+ |
"What would you change?",
|
|
452 |
+ |
"Too many small tasks, not enough deep work...",
|
|
453 |
+ |
change,
|
|
454 |
+ |
),
|
|
455 |
+ |
],
|
|
456 |
+ |
},
|
|
457 |
+ |
]
|
|
458 |
+ |
}
|
|
459 |
+ |
|
|
460 |
+ |
/// The whole screen.
|
|
461 |
+ |
///
|
|
462 |
+ |
/// Built here rather than inside each route for the reason the projects screen
|
|
463 |
+ |
/// gives: a write lands in more than one section — completing a goal changes
|
|
464 |
+ |
/// the goal list and the banner above it — and a `Response` names one region.
|
|
465 |
+ |
fn screen(state: &AppState, month: NaiveDate) -> Result<Screen, RouteError> {
|
|
466 |
+ |
let data = load(state, month)?;
|
|
467 |
+ |
|
|
468 |
+ |
let band = Slot::new("month-band", RegionKind::Band)
|
|
469 |
+ |
.with(Node::page(&data.month_display))
|
|
470 |
+ |
.with(Node::act(
|
|
471 |
+ |
"Previous month",
|
|
472 |
+ |
in_month(Action::get("/monthly-review"), step(month, false)),
|
|
473 |
+ |
))
|
|
474 |
+ |
.with(Node::act(
|
|
475 |
+ |
"Next month",
|
|
476 |
+ |
in_month(Action::get("/monthly-review"), step(month, true)),
|
|
477 |
+ |
))
|
|
478 |
+ |
// Bare, with no month on it: this is the one control whose whole job is
|
|
479 |
+ |
// to leave the month it was offered under.
|
|
480 |
+ |
.with(Node::act("This month", Action::get("/monthly-review")));
|
|
481 |
+ |
|
|
482 |
+ |
let mut pane = Slot::new("monthly-review", RegionKind::Pane);
|
|
483 |
+ |
if data.reflection.is_some() {
|
|
484 |
+ |
pane = pane.with(Node::banner(
|
|
485 |
+ |
makeover_layout::Tone::Info,
|
|
486 |
+ |
"This month is already reviewed. Your notes stay editable.",
|
|
487 |
+ |
));
|
|
488 |
+ |
}
|
|
489 |
+ |
pane = pane.extend(heat_map(&data.days));
|
|
490 |
+ |
pane = pane.extend(stats(&data));
|
|
491 |
+ |
pane = pane.extend(accomplished(&data));
|
|
492 |
+ |
pane = pane.extend(project_pulse(&data.project_pulse));
|
|
493 |
+ |
pane = pane.extend(projects_health(&data.project_health));
|
|
494 |
+ |
pane = pane.extend(goals(&data, month));
|
|
495 |
+ |
pane = pane.extend(patterns(&data));
|
|
496 |
+ |
pane = pane.extend(reflection(&data, month));
|
|
497 |
+ |
|
|
498 |
+ |
Ok(Screen::sidebar_content("Monthly Review")
|
|
499 |
+ |
.with(band)
|
|
500 |
+ |
.with(pane))
|