Skip to main content

max / goingson

The monthly review is described Eighth screen ported, chosen by the weekly review's rule: the heaviest un-described file a description can actually express. The second clause did the work, because the four heavier candidates are waiting on something rather than on effort. events.js and events-calendar.js are a month grid, a week grid and a day column, which is Bespoke's shape and was the weekly review's own reason for skipping them. day-planning is drag-to-paint. time-tracking.js is a floating widget on a one-second setInterval, and a ticking clock is not a description. search.js the mail port called "its own screen and its own port"; read closely it is a Cmd+K overlay whose completion list depends on where the caret is, so it is a host affordance over whatever screen you are on. That leaves this at 627 lines and problems.js at 229. Being the weekly review's sibling makes it teach least, which is true and is not a reason to skip it. What it costs in findings it returns in confirmation. The month is an address, per decision 2, with in_month on every control except "This month" -- that one is bare because leaving the month it was offered under is its whole job. gather_monthly_review and resolve_month_start are split out of the async command the way gather_weekly_review already was, so the described screen and the command resolve the same month. TWO FINDINGS. intensity is a renderer's encoding of a number. Core computes a 0-3 bucket and the JS paints one of four shades; the description carries completed_count. That is the weekly review's first finding, where the JS capped each day at three dots, arriving at month scale from a different direction. Recorded as a SECOND consumer, since one consumer has not been evidence the last three times this question came up. The grid itself is not described: a calendar is Bespoke's shape, and inventing a word for it is not this port's call to make alone. cycleGoalStatus has a real defect under the describable one. The JS labels the button with the goal's current status and looks the next one up in a table the user cannot see, so the label says nothing about what pressing it will do. Each goal now offers the move by name. The half that is an actual bug: deriving the next status from a copy read at render time races a second window, which writes a status derived from what it saw. Naming the target explicitly removes the race, because the route no longer has to know what the goal was before. Left out: the day summary's "Go to Day". Day planning is not ported, so the destination has no address, and a described control that reaches nothing is worse than one that is absent. esc() stays at 338 and that is the expected shape, not a stalled port: a described screen does not delete its JS counterpart while the quasi feature is off, so both exist and both are counted. The count falls at the flip. Progress is the screen list, now eight.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-10 18:35 UTC
Signed with PGP, not checked
Commit: db1597ecfdfe0b793b15203ac0e730913f2ce4ae
Parent: e061bed
4 files changed, +969 insertions, -16 deletions
@@ -115,19 +115,19 @@
115 115
116 116 // Commands
117 117
118 - /// Gets the monthly review data for a given month (or current month).
119 - #[tauri::command]
120 - #[instrument(skip_all)]
121 - pub async fn get_monthly_review(
122 - state: State<'_, Arc<AppState>>,
123 - input: MonthInput,
124 - ) -> Result<MonthlyReviewResponse, ApiError> {
125 - // Resolve month boundaries
126 - let month_start = match &input.month {
127 - Some(m) => monthly_review::parse_month(m)
128 - .ok_or_else(|| ApiError::bad_request("Invalid month format, expected YYYY-MM"))?,
129 - None => monthly_review::current_month_start(),
130 - };
118 + /// The month's review, read and aggregated.
119 + ///
120 + /// Split out of [`get_monthly_review`] so the described screen in `quasi` reads
121 + /// the same month the command does, the way [`gather_weekly_review`] already
122 + /// serves the weekly one. It answers with core's `MonthlyReviewData` rather
123 + /// than the response below: `TaskResponse` exists to cross into JavaScript, and
124 + /// a description has no such boundary to cross.
125 + ///
126 + /// [`gather_weekly_review`]: super::weekly_review::gather_weekly_review
127 + pub fn gather_monthly_review(
128 + state: &AppState,
129 + month_start: NaiveDate,
130 + ) -> Result<MonthlyReviewData, ApiError> {
131 131 let month_end_date = monthly_review::month_end(month_start);
132 132
133 133 // Time boundaries. The month's civil dates are in the user's local zone;
@@ -165,9 +165,9 @@
165 165 .get_reflection(DESKTOP_USER_ID, &month_str)?;
166 166
167 167 // Collect vacation days from weekly reviews that fall within this month
168 - let vacation_days = collect_vacation_days(&state, month_start, month_end_date);
168 + let vacation_days = collect_vacation_days(state, month_start, month_end_date);
169 169
170 - let data = monthly_review::compute_monthly_review(MonthlyReviewInput {
170 + Ok(monthly_review::compute_monthly_review(MonthlyReviewInput {
171 171 month_start,
172 172 month_end: month_end_date,
173 173 tasks_completed,
@@ -179,7 +179,30 @@
179 179 reflection,
180 180 vacation_days,
181 181 tz: goingson_core::tz::system_tz(),
182 - });
182 + }))
183 + }
184 +
185 + /// Resolve the month a request asked for, or this one.
186 + ///
187 + /// Shared with the described screen so a hand-typed `?month=` is refused in one
188 + /// place rather than two.
189 + pub fn resolve_month_start(month: Option<&str>) -> Result<NaiveDate, ApiError> {
190 + match month {
191 + Some(m) => monthly_review::parse_month(m)
192 + .ok_or_else(|| ApiError::bad_request("Invalid month format, expected YYYY-MM")),
193 + None => Ok(monthly_review::current_month_start()),
194 + }
195 + }
196 +
197 + /// Gets the monthly review data for a given month (or current month).
198 + #[tauri::command]
199 + #[instrument(skip_all)]
200 + pub async fn get_monthly_review(
201 + state: State<'_, Arc<AppState>>,
202 + input: MonthInput,
203 + ) -> Result<MonthlyReviewResponse, ApiError> {
204 + let month_start = resolve_month_start(input.month.as_deref())?;
205 + let data = gather_monthly_review(&state, month_start)?;
183 206
184 207 Ok(MonthlyReviewResponse::from(data))
185 208 }
@@ -36,6 +36,7 @@
36 36
37 37 pub mod contacts;
38 38 pub mod emails;
39 + pub mod monthly_review;
39 40 pub mod projects;
40 41 pub mod settings;
41 42 pub mod tasks;
@@ -50,6 +51,7 @@
50 51 let router = tasks::routes(router);
51 52 let router = settings::routes(router);
52 53 let router = weekly_review::routes(router);
54 + let router = monthly_review::routes(router);
53 55 emails::routes(router)
54 56 }
55 57
@@ -1,0 +1,628 @@
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))
Lines truncated
@@ -1,0 +1,428 @@
1 + //! The monthly review, driven through the router against a real database.
2 + //!
3 + //! Same standard as the screens before it: no Tauri runtime and no window, the
4 + //! description asserted, and the markup only where the markup is the point.
5 + //! Every workaround this port had to take is asserted here rather than left to
6 + //! be noticed, so closing a finding is a test that has to change.
7 +
8 + use std::sync::Arc;
9 +
10 + use goingson_core::{MonthlyGoalStatus, NewTask, Priority, monthly_review};
11 + use quasi_http::Render as _;
12 + use quasi_router::Outcome;
13 + use quasi_router::{Method, Params, Response};
14 +
15 + use super::super::router;
16 + use crate::state::{AppState, DESKTOP_USER_ID};
17 +
18 + /// State with the desktop user in place, which is who the handlers read as.
19 + async fn state() -> Arc<AppState> {
20 + let (state, _) = crate::test_utils::setup_test_state().await;
21 + let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
22 + state
23 + .db
24 + .conn()
25 + .unwrap()
26 + .execute(
27 + "INSERT OR IGNORE INTO users (id, email, password_hash, display_name, created_at) \
28 + VALUES (?, ?, ?, ?, ?)",
29 + rusqlite::params![
30 + DESKTOP_USER_ID.to_string(),
31 + "desktop@localhost",
32 + "x",
33 + "Desktop User",
34 + &now,
35 + ],
36 + )
37 + .unwrap();
38 + state
39 + }
40 +
41 + /// The month the tests write into, which is the one anything created now lands
42 + /// in.
43 + fn this_month() -> String {
44 + monthly_review::current_month_start()
45 + .format("%Y-%m")
46 + .to_string()
47 + }
48 +
49 + fn get(state: &AppState, path: &str, params: Params) -> Response {
50 + router()
51 + .handle(state, Method::Get, path, params)
52 + .expect("the route answers")
53 + }
54 +
55 + fn post(state: &AppState, path: &str, params: Params) -> Response {
56 + router()
57 + .handle(state, Method::Post, path, params)
58 + .expect("the route answers")
59 + }
60 +
61 + fn html(response: Response) -> String {
62 + match response.outcome {
63 + Outcome::Screen(screen) => quasi_webview::Webview::new().screen(&screen),
64 + Outcome::Fragment { node, .. } => quasi_webview::Webview::new().fragment(&node),
65 + // Deliberately not a wildcard, for the reason the task tests give: a
66 + // redirect has no body, and a fallback returning empty markup would
67 + // read as a screen that rendered nothing.
68 + Outcome::Goto(action) => panic!("expected content, got a redirect to {action:?}"),
69 + }
70 + }
71 +
72 + /// The review for this month.
73 + fn review(state: &AppState) -> String {
74 + html(get(state, "/monthly-review", Params::new()))
75 + }
76 +
77 + /// A goal on this month, through the described route.
78 + fn add_goal(state: &AppState, text: &str) -> Response {
79 + post(
80 + state,
81 + "/monthly-review/goals",
82 + Params::new().with("month", this_month()).with("text", text),
83 + )
84 + }
85 +
86 + fn goals(state: &AppState) -> Vec<goingson_core::MonthlyGoal> {
87 + state
88 + .monthly_reviews
89 + .list_goals(DESKTOP_USER_ID, &this_month())
90 + .expect("goals read")
91 + }
92 +
93 + #[tokio::test]
94 + async fn the_review_carries_the_sections_that_have_something_to_say() {
95 + let state = state().await;
96 + let page = review(&state);
97 +
98 + // The Numbers, Goals and Reflection are unconditional: a month with nothing
99 + // in it still has counts of zero, still offers a goal, and is still a month
100 + // you can write about.
101 + for section in ["The Month", "The Numbers", "Goals", "Reflection"] {
102 + assert!(page.contains(section), "missing section: {section}");
103 + }
104 + // Accomplished, Project Pulse, Project Health and Patterns are absent
105 + // rather than empty. A heading over nothing is a claim the screen cannot
106 + // support, which is what `Node::empty` exists to say instead.
107 + for section in ["Accomplished", "Project Pulse", "Patterns"] {
108 + assert!(
109 + !page.contains(section),
110 + "an empty month should not claim: {section}"
111 + );
112 + }
113 + }
114 +
115 + #[tokio::test]
116 + async fn an_empty_month_says_so_as_a_stand_in() {
117 + let state = state().await;
118 + let page = review(&state);
119 +
120 + assert!(page.contains("Nothing recorded this month yet."));
121 + assert!(page.contains(r#"data-state="empty""#));
122 + }
123 +
124 + #[tokio::test]
125 + async fn a_day_carries_its_counts_as_numbers_rather_than_as_a_shade() {
126 + // The first finding, and the second consumer of the weekly review's. Core
127 + // computes `intensity` as a 0-3 bucket and the JS turns it into one of four
128 + // background shades. A shade runs out of room at 3; the number does not.
129 + let state = state().await;
130 + for i in 0..5 {
131 + let task = state
132 + .tasks
133 + .create(
134 + DESKTOP_USER_ID,
135 + NewTask::builder(format!("Done {i}"))
136 + .title(format!("Done {i}"))
137 + .priority(Priority::High)
138 + .build(),
139 + )
140 + .unwrap();
141 + state
142 + .tasks
143 + .complete(task.id, DESKTOP_USER_ID)
144 + .expect("completed");
145 + }
146 +
147 + let page = review(&state);
148 +
149 + assert!(
150 + page.contains("5 done"),
151 + "the real count, not a bucket: {page}"
152 + );
153 + }
154 +
155 + #[tokio::test]
156 + async fn the_month_is_an_address_and_every_control_carries_it() {
157 + // Decision 2, and the consequence the weekly review recorded: an action
158 + // offered under a month has to carry it, or acting moves the user to this
159 + // month and writes there.
160 + let state = state().await;
161 + let past = "2026-01";
162 + let page = html(get(
163 + &state,
164 + "/monthly-review",
165 + Params::new().with("month", past),
166 + ));
167 +
168 + assert!(page.contains("January 2026"), "got: {page}");
169 + // The add-goal form posts under the month being looked at.
170 + assert!(
171 + page.contains(&format!("month={past}")) || page.contains(past),
172 + "the month rides on the controls: {page}"
173 + );
174 + }
175 +
176 + #[tokio::test]
177 + async fn this_month_is_the_one_control_that_drops_the_month() {
178 + // Every other control carries the month it was offered under. This one's
179 + // whole job is to leave it, so it is bare on purpose.
180 + let state = state().await;
181 + let page = html(get(
182 + &state,
183 + "/monthly-review",
184 + Params::new().with("month", "2026-01"),
185 + ));
186 +
187 + assert!(page.contains("This month"), "got: {page}");
188 + }
189 +
190 + #[tokio::test]
191 + async fn a_hand_typed_month_lands_on_this_month_rather_than_an_error() {
192 + // Same tolerance the week has, and for the same reason: this is an address
193 + // a person can type, and the useful answer is a screen.
194 + let state = state().await;
195 + let page = html(get(
196 + &state,
197 + "/monthly-review",
198 + Params::new().with("month", "not-a-month"),
199 + ));
200 +
201 + let expected = monthly_review::format_month_display(monthly_review::current_month_start());
202 + assert!(page.contains(&expected), "got: {page}");
203 + }
204 +
205 + #[tokio::test]
206 + async fn stepping_back_from_january_lands_in_december() {
207 + // Months are not a fixed number of days, which is why the arrows do not use
208 + // `Duration`. Stepping 31 days back from 1 March lands in January.
209 + let state = state().await;
210 + let page = html(get(
211 + &state,
212 + "/monthly-review",
213 + Params::new().with("month", "2026-01"),
214 + ));
215 +
216 + assert!(
217 + page.contains("2025-12"),
218 + "previous crosses the year: {page}"
219 + );
220 + assert!(page.contains("2026-02"), "next stays in it: {page}");
221 + }
222 +
223 + #[tokio::test]
224 + async fn adding_a_goal_puts_it_on_the_month() {
225 + let state = state().await;
226 + add_goal(&state, "Ship the thing");
227 +
228 + let page = review(&state);
229 + assert!(page.contains("Ship the thing"), "got: {page}");
230 + assert_eq!(goals(&state).len(), 1);
231 + }
232 +
233 + #[tokio::test]
234 + async fn a_goal_names_the_move_it_offers_rather_than_the_state_it_is_in() {
235 + // The second finding. `cycleGoalStatus` reads the goal from module state,
236 + // looks the next status up in a table the user cannot see, and writes it.
237 + // Described, each goal offers the move by name, so the label is the
238 + // outcome.
239 + let state = state().await;
240 + add_goal(&state, "Ship the thing");
241 +
242 + let page = review(&state);
243 + assert!(page.contains("Mark done"), "got: {page}");
244 +
245 + let id = goals(&state)[0].id;
246 + post(
247 + &state,
248 + &format!("/monthly-review/goals/{id}/status"),
249 + Params::new()
250 + .with("month", this_month())
251 + .with("status", "done"),
252 + );
253 +
254 + assert_eq!(goals(&state)[0].status, MonthlyGoalStatus::Done);
255 + let page = review(&state);
256 + assert!(page.contains("Give up on it"), "the next move: {page}");
257 + }
258 +
259 + #[tokio::test]
260 + async fn the_target_status_is_named_so_two_windows_cannot_race() {
261 + // The half of the second finding that is a real defect rather than a
262 + // description gap: the JS derives the next status from a copy read at
263 + // render time, so a second window writes a status derived from what it saw.
264 + // The route takes the target explicitly, so a stale screen cannot invent
265 + // one.
266 + let state = state().await;
267 + add_goal(&state, "Ship the thing");
268 + let id = goals(&state)[0].id;
269 +
270 + // Two writes naming the same target, as two stale windows would send.
271 + for _ in 0..2 {
272 + post(
273 + &state,
274 + &format!("/monthly-review/goals/{id}/status"),
275 + Params::new()
276 + .with("month", this_month())
277 + .with("status", "abandoned"),
278 + );
279 + }
280 +
281 + assert_eq!(goals(&state)[0].status, MonthlyGoalStatus::Abandoned);
282 + }
283 +
284 + #[tokio::test]
285 + async fn a_fourth_goal_is_refused_rather_than_stored() {
286 + let state = state().await;
287 + for i in 0..3 {
288 + add_goal(&state, &format!("Goal {i}"));
289 + }
290 + assert_eq!(goals(&state).len(), 3);
291 +
292 + let refused = router().handle(
293 + &state,
294 + Method::Post,
295 + "/monthly-review/goals",
296 + Params::new()
297 + .with("month", this_month())
298 + .with("text", "One too many"),
299 + );
300 +
301 + assert!(refused.is_err(), "the fourth is refused");
302 + assert_eq!(goals(&state).len(), 3);
303 + }
304 +
305 + #[tokio::test]
306 + async fn the_form_disappears_once_the_month_is_full() {
307 + let state = state().await;
308 + assert!(review(&state).contains("Add goal"));
309 +
310 + for i in 0..3 {
311 + add_goal(&state, &format!("Goal {i}"));
312 + }
313 +
314 + assert!(
315 + !review(&state).contains("Add goal"),
316 + "no offer that cannot be taken"
317 + );
318 + }
319 +
320 + #[tokio::test]
321 + async fn a_deleted_middle_goal_frees_its_own_position() {
322 + // The position is the first one nothing holds, not one past the count. A
323 + // month whose middle goal was deleted has a free slot in the middle, and
324 + // counting would collide with the last one.
325 + let state = state().await;
326 + for i in 0..3 {
327 + add_goal(&state, &format!("Goal {i}"));
328 + }
329 + let middle = goals(&state)
330 + .iter()
331 + .find(|goal| goal.position == 2)
332 + .expect("a second goal")
333 + .id;
334 +
335 + post(
336 + &state,
337 + &format!("/monthly-review/goals/{middle}/delete"),
338 + Params::new().with("month", this_month()),
339 + );
340 + add_goal(&state, "Back in the middle");
341 +
342 + let written = goals(&state);
343 + assert_eq!(written.len(), 3);
344 + let refilled = written
345 + .iter()
346 + .find(|goal| goal.text == "Back in the middle")
347 + .expect("the new goal");
348 + assert_eq!(refilled.position, 2, "took the free slot, not a fourth");
349 + }
350 +
351 + #[tokio::test]
352 + async fn deleting_a_goal_asks_first() {
353 + let state = state().await;
354 + add_goal(&state, "Ship the thing");
355 +
356 + let page = review(&state);
357 + assert!(
358 + page.contains("hx-confirm=\"Are you sure you want to delete this goal?"),
359 + "got: {page}"
360 + );
361 + }
362 +
363 + #[tokio::test]
364 + async fn deleting_something_that_is_not_there_is_a_not_found() {
365 + let state = state().await;
366 + let missing = uuid::Uuid::new_v4();
367 +
368 + let answer = router().handle(
369 + &state,
370 + Method::Post,
371 + &format!("/monthly-review/goals/{missing}/delete"),
372 + Params::new().with("month", this_month()),
373 + );
374 +
375 + assert!(answer.is_err());
376 + }
377 +
378 + #[tokio::test]
379 + async fn the_reflection_round_trips_and_the_banner_follows_it() {
380 + let state = state().await;
381 + assert!(review(&state).contains("Complete review"));
382 +
383 + post(
384 + &state,
385 + "/monthly-review/complete",
386 + Params::new()
387 + .with("month", this_month())
388 + .with("highlight", "Shipped the port")
389 + .with("change", "Fewer small tasks")
390 + .with("_", ""),
391 + );
392 +
393 + let page = review(&state);
394 + assert!(page.contains("Shipped the port"), "got: {page}");
395 + assert!(page.contains("Fewer small tasks"), "got: {page}");
396 + // The submit changes meaning once the month is reviewed, and the banner
397 + // says why: the notes stay editable.
398 + assert!(page.contains("Save notes"), "got: {page}");
399 + assert!(page.contains("already reviewed"), "got: {page}");
400 + }
401 +
402 + #[tokio::test]
403 + async fn an_empty_reflection_still_marks_the_month_reviewed() {
404 + // The weekly review's rule: the completion is the act and the writing is
405 + // optional.
406 + let state = state().await;
407 + post(
408 + &state,
409 + "/monthly-review/complete",
410 + Params::new()
411 + .with("month", this_month())
412 + .with("highlight", " ")
413 + .with("change", ""),
414 + );
415 +
416 + assert!(review(&state).contains("already reviewed"));
417 + }
418 +
419 + #[tokio::test]
420 + async fn a_goals_text_cannot_become_markup() {
421 + // The reason a description carries text and the renderer owns escaping.
422 + let state = state().await;
423 + add_goal(&state, "<script>alert(1)</script>");
424 +
425 + let page = review(&state);
426 + assert!(!page.contains("<script>alert(1)</script>"), "got: {page}");
427 + assert!(page.contains("&lt;script&gt;"), "got: {page}");
428 + }