Skip to main content

max / goingson

The weekly review is described rather than built Fifth screen ported behind the off-by-default quasi feature, chosen by esc() weight over the events calendar, whose three grid renderers are what Region::Bespoke already carries. The week is an address rather than module state, so every route and both arrows carry it. Reading one meant lifting the command's fetch-and-aggregate into gather_weekly_review, taking &AppState and answering with core's WeeklyReviewData: a description has no boundary into JavaScript to cross. Four findings, recorded where each one bites and mirrored to the wiki. Two are the vocabulary's and are filed on quasicoherent: a place awaiting content has no name, and a Field cannot say its value is a draft. One is goingson's own, that focus is a task column with no week on it. The fourth points the other way - the JS caps each day's counts at three dots, and the description carries the numbers.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-09 22:14 UTC
Signed with PGP, not checked
Commit: a4322c98263edb923fb67450b3c912fee0b528d8
Parent: 0df3488
5 files changed, +885 insertions, -13 deletions
M Cargo.lock +8
@@ -8229,3 +8229,11 @@
8229 8229 "syn 2.0.119",
8230 8230 "winnow 1.0.4",
8231 8231 ]
8232 +
8233 + [[patch.unused]]
8234 + name = "quasi-axum"
8235 + version = "0.1.0"
8236 +
8237 + [[patch.unused]]
8238 + name = "quasi-store"
8239 + version = "0.1.0"
@@ -10,7 +10,8 @@
10 10 use tracing::instrument;
11 11
12 12 use goingson_core::weekly_review::{
13 - self, EventSummary, ProjectHealth, ProjectSummary, TimelineDayData, WeeklyReviewInput,
13 + self, EventSummary, ProjectHealth, ProjectSummary, TimelineDayData, WeeklyReviewData,
14 + WeeklyReviewInput,
14 15 };
15 16 use goingson_core::{TaskId, WeeklyReview, expand_recurrence_in_tz};
16 17
@@ -97,15 +98,16 @@
97 98
98 99 // Commands
99 100
100 - /// Gets the weekly review data for the requested week (or current week if omitted).
101 - /// Fetches data from repositories, delegates aggregation to core.
102 - #[tauri::command]
103 - #[instrument(skip_all)]
104 - pub async fn get_weekly_review(
105 - state: State<'_, Arc<AppState>>,
106 - input: Option<WeekStartInput>,
107 - ) -> Result<WeeklyReviewResponse, ApiError> {
108 - let week_start = resolve_week_start(input.as_ref().and_then(|i| i.week_start.as_deref()))?;
101 + /// The week's review, read and aggregated.
102 + ///
103 + /// Split out of [`get_weekly_review`] so the described screen in `quasi` reads
104 + /// the same week the command does. It answers with core's `WeeklyReviewData`
105 + /// rather than the response above: `TaskResponse` exists to cross into
106 + /// JavaScript, and a description has no such boundary to cross.
107 + pub fn gather_weekly_review(
108 + state: &AppState,
109 + week_start: NaiveDate,
110 + ) -> Result<WeeklyReviewData, ApiError> {
109 111 let week_end_date = weekly_review::week_end(week_start);
110 112
111 113 // Time boundaries for queries. The week's civil dates are in the user's
@@ -202,7 +204,7 @@
202 204 let projects = projects?;
203 205
204 206 // Delegate aggregation to core
205 - let data = weekly_review::compute_weekly_review(WeeklyReviewInput {
207 + Ok(weekly_review::compute_weekly_review(WeeklyReviewInput {
206 208 week_start,
207 209 review,
208 210 tasks_completed,
@@ -216,7 +218,19 @@
216 218 available_for_focus,
217 219 projects,
218 220 tz,
219 - });
221 + }))
222 + }
223 +
224 + /// Gets the weekly review data for the requested week (or current week if omitted).
225 + /// Fetches data from repositories, delegates aggregation to core.
226 + #[tauri::command]
227 + #[instrument(skip_all)]
228 + pub async fn get_weekly_review(
229 + state: State<'_, Arc<AppState>>,
230 + input: Option<WeekStartInput>,
231 + ) -> Result<WeeklyReviewResponse, ApiError> {
232 + let week_start = resolve_week_start(input.as_ref().and_then(|i| i.week_start.as_deref()))?;
233 + let data = gather_weekly_review(&state, week_start)?;
220 234
221 235 // Convert Task → TaskResponse for frontend
222 236 Ok(WeeklyReviewResponse {
@@ -37,6 +37,7 @@
37 37 pub mod contacts;
38 38 pub mod projects;
39 39 pub mod tasks;
40 + pub mod weekly_review;
40 41
41 42 /// Every described screen's routes.
42 43 #[must_use]
@@ -44,7 +45,8 @@
44 45 let router = Router::<AppState>::new();
45 46 let router = projects::routes(router);
46 47 let router = contacts::routes(router);
47 - tasks::routes(router)
48 + let router = tasks::routes(router);
49 + weekly_review::routes(router)
48 50 }
49 51
50 52 /// The custom protocol serving the screens inside the app.
@@ -1,0 +1,715 @@
1 + //! The weekly review, described rather than built.
2 + //!
3 + //! <!-- wiki: quasi-overview -->
4 + //!
5 + //! Fifth screen ported, after the project dashboard, chosen 2026-08-09 by
6 + //! weight over the events calendar.
7 + //! `weekly-review-render.js` is the heaviest un-described file left at 16
8 + //! `esc()` calls, and the calendar is heavier in lines but not in description:
9 + //! its three renderers are a month grid, a week grid and a mobile day column,
10 + //! which is the shape [`RegionKind::Bespoke`] was named for on the task
11 + //! overview's heatmap. Porting it would produce a screen that is one bespoke
12 + //! region with the JS still filling it, and would teach the vocabulary nothing.
13 + //!
14 + //! The shipped screen is `frontend/js/weekly-review.js` and its render module
15 + //! exactly as before; see [the module above](super) for why both exist at once.
16 + //!
17 + //! This is the first screen that is mostly a *report* rather than a list of
18 + //! things, and it is the first one whose whole content is scoped by something
19 + //! other than an id: every route carries the week. Four things the description
20 + //! could not say turned up, recorded where each one bites.
21 + //!
22 + //! # The shape
23 + //!
24 + //! - `GET /weekly-review` — the whole review, for `?week=` or for this week.
25 + //! - `POST /weekly-review/focus/{id}` — put a task in the week's focus, or take
26 + //! it out, under `focus`.
27 + //! - `POST /weekly-review/focus/clear` — take everything out.
28 + //! - `POST /weekly-review/vacation/{day}` — mark a weekday off, or on again.
29 + //! - `POST /weekly-review/complete` — save the reflection and mark it reviewed.
30 + //!
31 + //! Every described control reaches one of those, which is the standard the
32 + //! contacts port set.
33 + //!
34 + //! # The week is an address, not a variable
35 + //!
36 + //! `weekly-review.js` holds `currentWeekStart` in module scope and re-renders
37 + //! from it. Here it is a query param, per decision 2, so a past week is
38 + //! reachable by address and no state has to survive between two clicks. That
39 + //! carries the same consequence the projects filters did: every action the
40 + //! screen offers has to carry the week it was offered under, or acting silently
41 + //! moves the user to the current week and writes there. [`in_week`] is that,
42 + //! applied to all five routes and to both arrows.
43 +
44 + // Handlers take their params by value because `quasi_router::Handler` is a
45 + // plain `fn(&S, Params)` pointer, so the signature is the router's and not a
46 + // choice made here. Same allow, for the same reason, as quasi-axum's tests.
47 + #![allow(clippy::needless_pass_by_value)]
48 +
49 + use chrono::{Duration, NaiveDate};
50 + use goingson_core::Task;
51 + use goingson_core::weekly_review::{
52 + self, EventSummary, ProjectHealth, TimelineDayData, WeeklyReviewData,
53 + };
54 + use quasi_router::screen::{Act, Field, Figure, Meter, Row, Tag};
55 + use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
56 +
57 + use crate::commands::gather_weekly_review;
58 + use crate::state::{AppState, DESKTOP_USER_ID};
59 +
60 + #[cfg(test)]
61 + mod tests;
62 +
63 + /// How many priorities the week's focus holds.
64 + ///
65 + /// `weekly-review-render.js:renderFocusSection` counts to 3 and the repository
66 + /// enforces nothing, so this is the screen's rule and it is stated once here
67 + /// rather than in the three places that ask about it.
68 + const FOCUS_SLOTS: usize = 3;
69 +
70 + /// The seven weekday names, Monday first.
71 + ///
72 + /// The JS draws single letters (`M T W T F S S`), which is a renderer's
73 + /// abbreviation of a name and not the name. A description that said "T" would
74 + /// be handing a terminal and a screen reader the same ambiguity a sighted user
75 + /// resolves from position.
76 + const WEEKDAYS: [&str; 7] = [
77 + "Monday",
78 + "Tuesday",
79 + "Wednesday",
80 + "Thursday",
81 + "Friday",
82 + "Saturday",
83 + "Sunday",
84 + ];
85 +
86 + /// The week a route was addressed at, or the current one.
87 + ///
88 + /// An unparseable `week` is this week rather than a 400, matching
89 + /// `resolve_week_start`'s tolerance at the command layer only in outcome: there
90 + /// a bad value is a client bug worth reporting, and here it is a hand-typed
91 + /// address, where landing on this week is the more useful answer than an error
92 + /// page.
93 + fn week_of(params: &quasi_router::Params) -> NaiveDate {
94 + params
95 + .get("week")
96 + .and_then(weekly_review::parse_week_start)
97 + .unwrap_or_else(weekly_review::current_week_start)
98 + }
99 +
100 + /// The same action, still pointed at the week it was offered under.
101 + fn in_week(action: Action, week: NaiveDate) -> Action {
102 + action.with("week", week.to_string())
103 + }
104 +
105 + /// Read the week.
106 + fn load(state: &AppState, week: NaiveDate) -> Result<WeeklyReviewData, RouteError> {
107 + gather_weekly_review(state, week).map_err(|error| RouteError::internal(error.to_string()))
108 + }
109 +
110 + /// The tone a project's health wears.
111 + ///
112 + /// `ProjectHealth::status` is a string the core crate writes and the JS turns
113 + /// into a class name. Both ends of that already agree on three values, so this
114 + /// is the third reader rather than a new vocabulary; an unrecognised one is
115 + /// neutral rather than a panic, because a health string is data and not a
116 + /// contract this screen can enforce.
117 + fn health_tone(status: &str) -> makeover_layout::Tone {
118 + match status {
119 + "healthy" => makeover_layout::Tone::Success,
120 + "warning" => makeover_layout::Tone::Warning,
121 + "danger" => makeover_layout::Tone::Danger,
122 + _ => makeover_layout::Tone::Neutral,
123 + }
124 + }
125 +
126 + /// One task, the way every list on this screen writes one.
127 + ///
128 + /// The project is `meta` rather than a token: it is a plain trailing fact with
129 + /// no tone of its own and no click to answer, which is the line
130 + /// [`Row::tokens`](quasi_router::screen::Row::tokens) draws.
131 + fn task_row(task: &Task) -> Row {
132 + let row = Row::new(&task.title);
133 + match &task.project_name {
134 + Some(project) => row.meta(project),
135 + None => row,
136 + }
137 + }
138 +
139 + /// The week at a glance.
140 + ///
141 + /// # The first finding
142 + ///
143 + /// **A strip that runs across is described as a list that runs down, and two
144 + /// separate things are lost saying so.**
145 + ///
146 + /// The first loss is the direction, and it is not one worth closing. Seven days
147 + /// in a row and seven days in a column are the same seven facts, and a terminal
148 + /// renderer would draw the column whatever the description said. That is
149 + /// [`Node::Stats`]' grouping argument pointed the other way, and it comes out
150 + /// the other way: the set is already one node, so the renderer has what it
151 + /// needs to decide.
152 + ///
153 + /// The second is real. The JS encodes each day's counts as up to three dots,
154 + /// capping completed at 3 and events, overdue and due at 2. A dot is a
155 + /// renderer's encoding of a number and the cap is that encoding running out of
156 + /// room, so the description carries the numbers and lets a host that has room
157 + /// draw them as it likes. A day with 9 completed tasks reads as 9 here and as
158 + /// three dots there, and the description is the one that is right.
159 + ///
160 + /// Filed as a finding rather than a member: nothing is missing from the
161 + /// vocabulary, and what the JS does is a thing the port should stop doing.
162 + fn timeline(days: &[TimelineDayData]) -> Vec<Node> {
163 + let rows = days.iter().map(|day| {
164 + let mut counts = Vec::new();
165 + if day.completed_count > 0 {
166 + counts.push(format!("{} done", day.completed_count));
167 + }
168 + if day.event_count > 0 {
169 + counts.push(format!("{} events", day.event_count));
170 + }
171 + if day.overdue_count > 0 {
172 + counts.push(format!("{} overdue", day.overdue_count));
173 + }
174 + // Due is a future fact: a day that has passed has no tasks still due on
175 + // it, they are the overdue count above. `renderDayDots` says the same
176 + // with `if (!day.isPast)`.
177 + if !day.is_past && day.due_count > 0 {
178 + counts.push(format!("{} due", day.due_count));
179 + }
180 +
181 + let mut row = Row::new(format!("{} {}", day.day_name, day.day_number));
182 + if day.is_today {
183 + row = row.token(Tag::badge("Today").tone(makeover_layout::Tone::Info));
184 + }
185 + if day.is_vacation {
186 + // A day off is why the counts are absent rather than zero, so it is
187 + // a token and not merely a style on the row.
188 + row = row.token(Tag::badge("Day off"));
189 + }
190 + if counts.is_empty() {
191 + row
192 + } else {
193 + row.meta(counts.join(", "))
194 + }
195 + });
196 +
197 + vec![Node::section("Week at a Glance"), Node::list(rows)]
198 + }
199 +
200 + /// One event, in the compact form both event lists here use.
201 + fn event_row(event: &EventSummary) -> Row {
202 + let row = Row::new(&event.title).meta(&event.formatted_time);
203 + match &event.project_name {
204 + Some(project) => row.token(Tag::badge(project)),
205 + None => row,
206 + }
207 + }
208 +
209 + /// The week's events, under the day each fell on.
210 + ///
211 + /// A list holds rows and nothing else, so the grouping is headings between
212 + /// lists rather than anything inside one. That is the vocabulary working: the
213 + /// JS wraps each day in a `timeline-events-day` div because it needs somewhere
214 + /// to hang a label, and a heading is what the label actually is.
215 + fn week_events(days: &[TimelineDayData]) -> Vec<Node> {
216 + let with_events: Vec<&TimelineDayData> =
217 + days.iter().filter(|day| !day.events.is_empty()).collect();
218 + if with_events.is_empty() {
219 + return Vec::new();
220 + }
221 +
222 + let mut out = vec![Node::section("Week's Events")];
223 + for day in with_events {
224 + out.push(Node::Heading {
225 + level: makeover_layout::Heading::Subsection,
226 + text: format!("{} {}", day.day_name, day.day_number),
227 + });
228 + out.push(Node::list(day.events.iter().map(event_row)));
229 + }
230 + out
231 + }
232 +
233 + /// What got done.
234 + ///
235 + /// # The second finding
236 + ///
237 + /// **A cap that exists to fit a card is not a fact, and the description should
238 + /// not carry it.**
239 + ///
240 + /// The JS shows the first 6 completed tasks, the first 3 overdue and the first
241 + /// 3 carried over, with nothing saying there are more. Those numbers are the
242 + /// height of a card in a grid, which is the renderer's problem, and
243 + /// [`Rest`](quasi_router::screen::Rest) is not the answer to them: it wants an
244 + /// action, because `346567f9` was about lists with a real remainder to go and
245 + /// ask for. There is no address here holding "the rest of what you finished" and
246 + /// inventing one would be adding a screen to justify a cap.
247 + ///
248 + /// So every list on this screen is whole, and a renderer that can only draw six
249 + /// rows is the thing that decides that. The count stays as a figure beside it,
250 + /// which is the fact the badge was carrying.
251 + fn accomplished(data: &WeeklyReviewData) -> Vec<Node> {
252 + let mut out = vec![
253 + Node::section("Accomplished"),
254 + Node::stats([
255 + Figure::new(data.tasks_completed_count.to_string(), "Tasks Completed")
256 + .tone(makeover_layout::Tone::Success),
257 + Figure::new(data.events_occurred_count.to_string(), "Events Attended"),
258 + ]),
259 + ];
260 + if data.tasks_completed.is_empty() {
261 + out.push(Node::empty("Nothing completed this week"));
262 + } else {
263 + out.push(Node::list(data.tasks_completed.iter().map(task_row)));
264 + }
265 + out
266 + }
267 +
268 + /// What slipped.
269 + ///
270 + /// Overdue and carried-over are one list in the JS and one list here, told
271 + /// apart by a token rather than by order: the JS puts overdue first and relies
272 + /// on the reader noticing the red due date, which is a distinction that
273 + /// survives only for someone who can see both halves at once.
274 + fn needs_attention(data: &WeeklyReviewData) -> Vec<Node> {
275 + let mut out = vec![
276 + Node::section("Needs Attention"),
277 + Node::stats([
278 + Figure::new(data.tasks_overdue_count.to_string(), "Overdue").tone(
279 + if data.tasks_overdue_count > 0 {
280 + makeover_layout::Tone::Danger
281 + } else {
282 + makeover_layout::Tone::Neutral
283 + },
284 + ),
285 + Figure::new(data.carried_over_count.to_string(), "Carried Over")
286 + .tone(makeover_layout::Tone::Info),
287 + ]),
288 + ];
289 +
290 + let mut rows: Vec<Row> = data
291 + .tasks_overdue
292 + .iter()
293 + .map(|task| {
294 + task_row(task)
295 + .token(Tag::badge("Overdue").tone(makeover_layout::Tone::Danger))
296 + .meta(task.due_formatted())
297 + })
298 + .collect();
299 + rows.extend(
300 + data.carried_over_tasks
301 + .iter()
302 + .map(|task| task_row(task).token(Tag::badge("Carried over"))),
303 + );
304 +
305 + if !rows.is_empty() {
306 + out.push(Node::list(rows));
307 + }
308 + out
309 + }
310 +
311 + /// What is coming.
312 + fn due_this_week(data: &WeeklyReviewData) -> Vec<Node> {
313 + let mut out = vec![Node::section("Due This Week")];
314 + if data.tasks_due_next_week.is_empty() {
315 + out.push(Node::empty("No tasks due this week"));
316 + } else {
317 + out.push(Node::list(
318 + data.tasks_due_next_week
319 + .iter()
320 + .map(|task| task_row(task).meta(task.due_formatted())),
321 + ));
322 + }
323 + out
324 + }
325 +
326 + /// The week's priorities.
327 + ///
328 + /// # The third finding
329 + ///
330 + /// **A place awaiting content has no name in the vocabulary.**
331 + ///
332 + /// `renderFocusSection` always draws three slots. A filled one holds a task; an
333 + /// empty one is a real described thing — it is focusable, it says "Press Enter
334 + /// or click a task to add focus", and it is where a chosen task lands. Two
335 + /// filled slots and one empty slot is not a list of two tasks, and describing
336 + /// it as one loses that there is room for a third.
337 + ///
338 + /// Every way of saying it inside the vocabulary is worse than not saying it.
339 + /// A row reading "Priority 3 - empty" is a row standing for nothing, which is
340 + /// the thing [`Node::StandIn`] exists to avoid doing per-item, and it would
341 + /// carry no action because the JS's slot is a drop target rather than a control.
342 + ///
343 + /// So the description says the fact and drops the furniture: a
344 + /// [`Meter`] of how many of the three are taken, then the chosen ones, then
345 + /// what could fill the rest. A renderer with three boxes to draw has the number
346 + /// it needs to draw them, and one without does not have to pretend.
347 + ///
348 + /// Filed against quasicoherent rather than closed here. It is the same shape as
349 + /// the row ruling of 2026-08-08 and probably ends the same way — a slot is a
350 + /// region, and `Slot` already exists — but a region per empty priority is a
351 + /// heavy answer to a light question and it wants a second consumer before
352 + /// anyone reaches for it.
353 + fn focus(data: &WeeklyReviewData, week: NaiveDate) -> Vec<Node> {
354 + let taken = data.focused_tasks.len();
355 + let mut out = vec![
356 + Node::section("This Week's Focus"),
357 + Node::Meter(
358 + Meter::new(
359 + u32::try_from(taken).unwrap_or(u32::MAX),
360 + u32::try_from(FOCUS_SLOTS).unwrap_or(u32::MAX),
361 + )
362 + // Neutral, not success: a full focus list is a week that has been
363 + // planned, and there is nothing good or bad about the number
364 + // itself. The subtask bar's Success would read as "well done for
365 + // picking three".
366 + .label("priorities"),
367 + ),
368 + ];
369 +
370 + if data.focused_tasks.is_empty() {
371 + out.push(Node::empty("No priorities picked for this week"));
372 + } else {
373 + out.push(Node::list(data.focused_tasks.iter().map(|task| {
374 + task_row(task).act(Act::new(
375 + "Remove",
376 + in_week(
377 + Action::post(format!("/weekly-review/focus/{}", task.id))
378 + .with("focus", "false"),
379 + week,
380 + ),
381 + ))
382 + })));
383 + out.push(Node::act(
384 + "Clear all focus",
385 + in_week(Action::post("/weekly-review/focus/clear"), week),
386 + ));
387 + }
388 +
389 + // The suggestions are offered only while there is somewhere to put one,
390 + // which is `available.length > 0 && focused.length < 3` in the JS. The
391 + // repository already caps the candidates at 10, so unlike the lists above
392 + // this is a limit in the data rather than in a card.
393 + if taken < FOCUS_SLOTS && !data.available_for_focus.is_empty() {
394 + out.push(Node::Heading {
395 + level: makeover_layout::Heading::Subsection,
396 + text: "Suggested".to_owned(),
397 + });
398 + out.push(Node::list(data.available_for_focus.iter().map(|task| {
399 + task_row(task).act(Act::new(
400 + "Focus",
401 + in_week(
402 + Action::post(format!("/weekly-review/focus/{}", task.id)).with("focus", "true"),
403 + week,
404 + ),
405 + ))
406 + })));
407 + }
408 + out
409 + }
410 +
411 + /// How each project is doing.
412 + fn projects_health(health: &[ProjectHealth]) -> Vec<Node> {
413 + if health.is_empty() {
414 + return Vec::new();
415 + }
416 +
417 + vec![
418 + Node::section("Projects Health"),
419 + Node::list(health.iter().map(|project| {
420 + let mut row = Row::new(&project.name)
421 + .meta(format!(
422 + "{} active, {} total",
423 + project.active_count, project.total_count
424 + ))
425 + .token(Tag::badge(&project.status).tone(health_tone(&project.status)));
426 + if project.overdue_count > 0 {
427 + row = row.token(
428 + Tag::badge(format!("{} overdue", project.overdue_count))
429 + .tone(makeover_layout::Tone::Danger),
430 + );
431 + }
432 + row
433 + })),
434 + ]
435 + }
436 +
437 + /// The days marked off.
438 + ///
439 + /// The one section here where the vocabulary already had the answer and the
440 + /// port did not have to argue for it. Seven independently latched things, each
441 + /// answering a click, is [`Tag::chip`] with
442 + /// [`latched`](quasi_router::screen::Tag::latched) — which arrived for filter
443 + /// chips and turns out to describe this without a change.
444 + ///
445 + /// Not a [`Node::Select`]: that picks one of a set and sends the picked value,
446 + /// and days off are seven yes-or-no answers where any number can be yes.
447 + fn days_off(data: &WeeklyReviewData, week: NaiveDate) -> Vec<Node> {
448 + let mut out = vec![Node::section("Days Off")];
449 + out.extend(WEEKDAYS.iter().enumerate().map(|(index, name)| {
450 + let off = data
451 + .vacation_days
452 + .contains(&u8::try_from(index).unwrap_or(0));
453 + Node::Token(
454 + Tag::chip(
455 + *name,
456 + in_week(
457 + Action::post(format!("/weekly-review/vacation/{index}")),
458 + week,
459 + ),
460 + )
461 + .latched(off),
462 + )
463 + }));
464 + out
465 + }
466 +
467 + /// Pull one prompt's answer back out of the stored notes.
468 + ///
469 + /// The review stores its two answers as one string with the prompts written
470 + /// into it, and `weekly-review-render.js:renderReflection` picks them apart with
471 + /// two regexes. Read here rather than stored apart because the storage is the
472 + /// JS screen's too and it still ships: a described screen that wrote a second
473 + /// format would make the two disagree about a week they both open.
474 + fn prompt_answer(notes: &str, marker: &str, until: Option<&str>) -> String {
475 + let Some(start) = notes.find(marker) else {
476 + return String::new();
477 + };
478 + let rest = &notes[start + marker.len()..];
479 + let end = until.and_then(|next| rest.find(next)).unwrap_or(rest.len());
480 + rest[..end].trim().to_owned()
481 + }
482 +
483 + /// The reflection.
484 + ///
485 + /// # The fourth finding
486 + ///
487 + /// **A field cannot say its value is a draft.**
488 + ///
489 + /// `weekly-review.js` keeps what the user has typed in `localStorage` against
490 + /// the week, restores it over the stored notes on render, and clears it on
491 + /// completion, so a review survives closing the app halfway through writing it.
492 + /// Nothing in [`Field`] can say that: `value` is what the field holds, and
493 + /// whether the host should be keeping unsent keystrokes somewhere is not a
494 + /// property of the value.
495 + ///
496 + /// The described screen therefore loses the draft and shows what is stored,
497 + /// which is correct and worse. Filed against quasicoherent rather than worked
498 + /// around, because the workaround is a route that writes on every keystroke and
499 + /// that is a different feature wearing this one's name.
500 + ///
Lines truncated
@@ -1,0 +1,348 @@
1 + //! The weekly review, driven through the router against a real database.
2 + //!
3 + //! Same standard as the screens before it: no Tauri runtime and no window,
4 + //! the 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::{NewTask, Priority, weekly_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 + fn add(state: &AppState, title: &str) -> goingson_core::Task {
42 + state
43 + .tasks
44 + .create(
45 + DESKTOP_USER_ID,
46 + NewTask::builder(title)
47 + .title(title)
48 + .priority(Priority::High)
49 + .build(),
50 + )
51 + .unwrap()
52 + }
53 +
54 + /// The week the tests write into, which is the one anything created now lands
55 + /// in.
56 + fn this_week() -> String {
57 + weekly_review::current_week_start().to_string()
58 + }
59 +
60 + fn get(state: &AppState, path: &str, params: Params) -> Response {
61 + router()
62 + .handle(state, Method::Get, path, params)
63 + .expect("the route answers")
64 + }
65 +
66 + fn post(state: &AppState, path: &str, params: Params) -> Response {
67 + router()
68 + .handle(state, Method::Post, path, params)
69 + .expect("the route answers")
70 + }
71 +
72 + fn html(response: Response) -> String {
73 + match response.outcome {
74 + Outcome::Screen(screen) => quasi_webview::Webview::new().screen(&screen),
75 + Outcome::Fragment { node, .. } => quasi_webview::Webview::new().fragment(&node),
76 + // Deliberately not a wildcard, for the reason the task tests give: a
77 + // redirect has no body, and a fallback returning empty markup would
78 + // read as a screen that rendered nothing.
79 + Outcome::Goto(action) => panic!("expected content, got a redirect to {action:?}"),
80 + }
81 + }
82 +
83 + /// The review for this week.
84 + fn review(state: &AppState) -> String {
85 + html(get(state, "/weekly-review", Params::new()))
86 + }
87 +
88 + #[tokio::test]
89 + async fn the_review_carries_every_section_it_promises() {
90 + let state = state().await;
91 + let page = review(&state);
92 +
93 + for section in [
94 + "Week at a Glance",
95 + "Accomplished",
96 + "Needs Attention",
97 + "Due This Week",
98 + // Escaped, because a heading is text and the renderer is the only thing
99 + // here that ever emits markup.
100 + "This Week&#39;s Focus",
101 + "Days Off",
102 + "Reflection",
103 + ] {
104 + assert!(page.contains(section), "missing section: {section}");
105 + }
106 + }
107 +
108 + #[tokio::test]
109 + async fn a_day_carries_its_counts_as_numbers_rather_than_as_dots() {
110 + // The first finding. `renderDayDots` caps completed at three dots and the
111 + // rest at two, which is an encoding running out of room rather than a fact
112 + // about the week. Seven rows, each saying what it actually holds.
113 + let state = state().await;
114 + for index in 0..5 {
115 + let task = add(&state, &format!("Done {index}"));
116 + state.tasks.complete(task.id, DESKTOP_USER_ID).unwrap();
117 + }
118 +
119 + let page = review(&state);
120 +
121 + assert!(page.contains("5 done"), "the fifth completion survives");
122 + // Seven days, whatever any of them holds.
123 + for day in ["Mon", "Sun"] {
124 + assert!(page.contains(day));
125 + }
126 + }
127 +
128 + #[tokio::test]
129 + async fn the_focus_says_how_many_slots_are_taken_and_draws_no_empty_ones() {
130 + // The third finding. Three slots always exist in the JS; the description
131 + // has no name for a place awaiting content, so it says the count and stops
132 + // rather than emitting rows that stand for nothing.
133 + let state = state().await;
134 + let task = add(&state, "The one thing");
135 + post(
136 + &state,
137 + &format!("/weekly-review/focus/{}", task.id),
138 + Params::new().with("focus", "true"),
139 + );
140 +
141 + let page = review(&state);
142 +
143 + assert!(page.contains("The one thing"));
144 + // One of three, as a bar rather than as two empty boxes.
145 + assert!(page.contains(r#"aria-valuenow="1""#));
146 + assert!(page.contains(r#"aria-valuemax="3""#));
147 + assert!(!page.contains("Priority #2"));
148 + assert!(!page.contains("Priority 2"));
149 + }
150 +
151 + #[tokio::test]
152 + async fn focusing_and_unfocusing_go_through_the_route_and_stay_in_the_week() {
153 + let state = state().await;
154 + let task = add(&state, "Ship the port");
155 + let week = this_week();
156 +
157 + let focused = html(post(
158 + &state,
159 + &format!("/weekly-review/focus/{}", task.id),
160 + Params::new().with("focus", "true").with("week", &week),
161 + ));
162 + assert!(focused.contains("Remove"));
163 + assert!(focused.contains(r#"aria-valuenow="1""#));
164 +
165 + let cleared = html(post(
166 + &state,
167 + &format!("/weekly-review/focus/{}", task.id),
168 + Params::new().with("focus", "false").with("week", &week),
169 + ));
170 + assert!(cleared.contains(r#"aria-valuenow="0""#));
171 + assert!(cleared.contains("No priorities picked for this week"));
172 + }
173 +
174 + #[tokio::test]
175 + async fn clear_is_an_address_and_never_arrives_as_a_task_id() {
176 + // A static segment outranks a capture, which is the path matcher's rule
177 + // and not something the route order here can be relied on to enforce.
178 + let state = state().await;
179 + let task = add(&state, "Focused");
180 + post(
181 + &state,
182 + &format!("/weekly-review/focus/{}", task.id),
183 + Params::new().with("focus", "true"),
184 + );
185 +
186 + let page = html(post(&state, "/weekly-review/focus/clear", Params::new()));
187 +
188 + assert!(page.contains(r#"aria-valuenow="0""#));
189 + // Nothing is holding a slot, so nothing offers to give one up. The task
190 + // itself comes straight back under Suggested, which is the point of
191 + // clearing rather than a leak.
192 + assert!(!page.contains("Remove"));
193 + assert!(page.contains("Suggested"));
194 + }
195 +
196 + #[tokio::test]
197 + async fn a_day_off_is_a_latched_chip_and_clicking_it_twice_puts_it_back() {
198 + // The one section the vocabulary already had an answer for: seven
199 + // independently latched things, each answering a click, is a chip. Not a
200 + // Select, which picks one of a set.
201 + let state = state().await;
202 +
203 + let plain = review(&state);
204 + assert!(plain.contains("Wednesday"));
205 + assert!(!plain.contains("chip-latched"));
206 +
207 + let off = html(post(&state, "/weekly-review/vacation/2", Params::new()));
208 + assert!(off.contains("chip-latched"));
209 + assert!(off.contains(r#"aria-pressed="true""#));
210 + // The day it marks off stops reporting counts and says so.
211 + assert!(off.contains("Day off"));
212 +
213 + let on_again = html(post(&state, "/weekly-review/vacation/2", Params::new()));
214 + assert!(!on_again.contains("chip-latched"));
215 + }
216 +
217 + #[tokio::test]
218 + async fn an_eighth_weekday_is_a_not_found_rather_than_a_write() {
219 + let state = state().await;
220 + let error = router()
221 + .handle(
222 + &state,
223 + Method::Post,
224 + "/weekly-review/vacation/7",
225 + Params::new(),
226 + )
227 + .expect_err("there is no eighth day");
228 + assert_eq!(error.class.http_status(), 404);
229 + }
230 +
231 + #[tokio::test]
232 + async fn completing_writes_the_notes_the_js_screen_reads_back() {
233 + // The stored format is `completeWeeklyReview`'s, down to the blank line,
234 + // because the JS screen still opens these notes.
235 + let state = state().await;
236 +
237 + let done = post(
238 + &state,
239 + "/weekly-review/complete",
240 + Params::new()
241 + .with("went-well", "Shipped the fourth screen")
242 + .with("improve", "Started too late in the day"),
243 + );
244 + assert!(done.notice.is_some(), "completing says so");
245 +
246 + let stored = state
247 + .weekly_reviews
248 + .get_for_week(DESKTOP_USER_ID, weekly_review::current_week_start())
249 + .unwrap()
250 + .expect("the week is written");
251 + assert_eq!(
252 + stored.notes,
253 + "What went well:\nShipped the fourth screen\n\nWhat could be improved:\nStarted too late in the day"
254 + );
255 +
256 + // And the screen reads them back into the two fields it asked with.
257 + let page = review(&state);
258 + assert!(page.contains("Shipped the fourth screen"));
259 + assert!(page.contains("Started too late in the day"));
260 + assert!(page.contains("already reviewed"));
261 + assert!(page.contains("Save notes"));
262 + }
263 +
264 + #[tokio::test]
265 + async fn an_unanswered_prompt_is_left_out_rather_than_written_empty() {
266 + let state = state().await;
267 + post(
268 + &state,
269 + "/weekly-review/complete",
270 + Params::new()
271 + .with("went-well", " ")
272 + .with("improve", "Less rework"),
273 + );
274 +
275 + let stored = state
276 + .weekly_reviews
277 + .get_for_week(DESKTOP_USER_ID, weekly_review::current_week_start())
278 + .unwrap()
279 + .expect("the week is written");
280 + assert_eq!(stored.notes, "What could be improved:\nLess rework");
281 + }
282 +
283 + #[tokio::test]
284 + async fn the_reflection_shows_what_is_stored_and_cannot_say_it_is_a_draft() {
285 + // The fourth finding, asserted as the loss it is. `weekly-review.js` keeps
286 + // unsent keystrokes in localStorage against the week and restores them over
287 + // the stored notes; a Field carries a value and has no way to say the host
288 + // should be holding one that was never sent.
289 + let state = state().await;
290 + post(
291 + &state,
292 + "/weekly-review/complete",
293 + Params::new().with("went-well", "What is stored"),
294 + );
295 +
296 + let page = review(&state);
297 +
298 + assert!(page.contains("What is stored"));
299 + assert!(!page.contains("draft"));
300 + }
301 +
302 + #[tokio::test]
303 + async fn a_task_title_cannot_become_markup() {
304 + let state = state().await;
305 + let task = add(&state, "<script>alert(1)</script>");
306 + post(
307 + &state,
308 + &format!("/weekly-review/focus/{}", task.id),
309 + Params::new().with("focus", "true"),
310 + );
311 +
312 + let page = review(&state);
313 +
314 + assert!(!page.contains("<script>alert"));
315 + assert!(page.contains("&lt;script&gt;"));
316 + }
317 +
318 + #[tokio::test]
319 + async fn a_past_week_is_reachable_by_address_and_holds_none_of_this_weeks_work() {
320 + // The week is an address rather than a variable, which is the whole reason
321 + // every action carries it.
322 + let state = state().await;
323 + let task = add(&state, "This week only");
324 + state.tasks.complete(task.id, DESKTOP_USER_ID).unwrap();
325 +
326 + let long_ago = (weekly_review::current_week_start() - chrono::Duration::days(70)).to_string();
327 + let page = html(get(
328 + &state,
329 + "/weekly-review",
330 + Params::new().with("week", &long_ago),
331 + ));
332 +
333 + assert!(page.contains("Nothing completed this week"));
334 + assert!(!page.contains("This week only"));
335 + // And its arrows still point at weeks either side of the one being read.
336 + assert!(page.contains("Previous week"));
337 + }
338 +
339 + #[tokio::test]
340 + async fn an_unparseable_week_lands_on_this_one_rather_than_erroring() {
341 + let state = state().await;
342 + let page = html(get(
343 + &state,
344 + "/weekly-review",
345 + Params::new().with("week", "last tuesday"),
346 + ));
347 + assert!(page.contains("Week at a Glance"));
348 + }