Skip to main content

max / goingson

Describe the task overview through quasi Third screen, chosen by weight: task-overview.js is the fattest single screen left after contacts. Behind the same off-by-default feature, so the shipped screen is that file unchanged. Six routes: the overview, and the five writes every described control on it reaches. First port with something the vocabulary is meant not to reach. The completion heatmap is a month grid of counts and gets a bespoke region, which is decision 4 working rather than a shortfall: a description able to produce a calendar grid is a widget library wearing a description's name. The JS keeps filling it. Six findings, all asserted by tests so the workarounds stay visible. Three filed on makeover-layout: nothing names a proportion and three screens draw one (d0b58239), a tick that means something has no route (14612ed8), a figure with a caption is not a row (93c6a174). One filed on quasi itself, because it is about the response rather than the screen: nothing describes going somewhere else after a write (80afd652), which a delete is the first case of. One already open, now with a second consumer: rich text in a description (25822137). One left out rather than dangled: Edit opens a modal form, which is a screen of its own. Also found here: TaskCrud::delete is a soft delete, so an address outlives the task it addresses and would render a deleted task with controls offering to complete it. The JS never met this because it closes the drawer and never re-fetches. Filtered in load(). compute_streak is pub(crate) so the description shows the same figures rather than restating the rule that decides what a streak is.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-09 03:06 UTC
Signed with PGP, not checked
Commit: a76f6d83a8cd69a84801f1b3859bdecb254a25d6
Parent: f57a259
4 files changed, +897 insertions, -2 deletions
@@ -831,7 +831,10 @@
831 831 }
832 832
833 833 /// Compute streak stats from a recurrence chain (sorted by created_at DESC).
834 - fn compute_streak(chain: &[Task]) -> StreakInfo {
834 + ///
835 + /// `pub(crate)` so `quasi::tasks` describes the same figures rather than
836 + /// restating the rule that decides what a streak is.
837 + pub(crate) fn compute_streak(chain: &[Task]) -> StreakInfo {
835 838 let total_instances = chain.len() as u32;
836 839 let total_completed = chain
837 840 .iter()
@@ -36,13 +36,15 @@
36 36
37 37 pub mod contacts;
38 38 pub mod projects;
39 + pub mod tasks;
39 40
40 41 /// Every described screen's routes.
41 42 #[must_use]
42 43 pub fn router() -> Router<AppState> {
43 44 let router = Router::<AppState>::new();
44 45 let router = projects::routes(router);
45 - contacts::routes(router)
46 + let router = contacts::routes(router);
47 + tasks::routes(router)
46 48 }
47 49
48 50 /// The custom protocol serving the screens inside the app.
@@ -1,0 +1,580 @@
1 + //! The task overview, described rather than built.
2 + //!
3 + //! <!-- wiki: quasi-overview -->
4 + //!
5 + //! Third screen ported, chosen by weight from `task-overview.js`: 15 `esc()`
6 + //! calls, and the fattest single screen left after contacts. The shipped screen
7 + //! is `frontend/js/task-overview.js` exactly as before; see [the module
8 + //! above](super) for why both exist at once.
9 + //!
10 + //! This is the first port with something the vocabulary is meant not to reach.
11 + //! The completion heatmap is a month grid of counts, and a description
12 + //! expressive enough to produce one is a widget library wearing a description's
13 + //! name. It gets a [`RegionKind::Bespoke`] and stops there, which is decision 4
14 + //! working rather than a shortfall. Five other things the description could not
15 + //! say are recorded where they bite.
16 + //!
17 + //! # The shape
18 + //!
19 + //! - `GET /tasks/{id}` — the whole overview.
20 + //! - `POST /tasks/{id}/complete` — mark it done.
21 + //! - `POST /tasks/{id}/delete` — delete it.
22 + //! - `POST /tasks/{id}/subtasks` — add one.
23 + //! - `POST /tasks/{id}/subtasks/{sub}/toggle` — tick or untick one.
24 + //! - `POST /tasks/{id}/notes` — add a note.
25 + //!
26 + //! Every described control reaches one of those, which is the standard the
27 + //! contacts port set. The one control left out rather than dangled is Edit: it
28 + //! opens `form-modal.js` over the drawer, and a modal form over a screen is a
29 + //! second arrangement this screen would have to describe before it could offer
30 + //! it. Recorded rather than faked.
31 +
32 + // Handlers take their params by value because `quasi_router::Handler` is a
33 + // plain `fn(&S, Params)` pointer, so the signature is the router's and not a
34 + // choice made here. Same allow, for the same reason, as quasi-axum's tests.
35 + #![allow(clippy::needless_pass_by_value)]
36 +
37 + use chrono::{DateTime, Local, Utc};
38 + use goingson_core::{Annotation, Priority, Subtask, Task, TaskId, TaskStatus, TimeSession};
39 + use quasi_router::screen::{Act, Field, Row, Tag};
40 + use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
41 +
42 + use crate::commands::{StreakInfo, compute_streak};
43 + use crate::state::{AppState, DESKTOP_USER_ID};
44 +
45 + #[cfg(test)]
46 + mod tests;
47 +
48 + /// The tone a status badge wears.
49 + ///
50 + /// The Rust half of `task-overview.js:STATUS_COLOR`, which maps green/blue/muted
51 + /// onto the same three live statuses. `Deleted` never reaches a rendered screen
52 + /// and takes the same neutral as pending rather than a tone of its own.
53 + const fn status_tone(status: &TaskStatus) -> makeover_layout::Tone {
54 + match status {
55 + TaskStatus::Completed => makeover_layout::Tone::Success,
56 + TaskStatus::Started => makeover_layout::Tone::Info,
57 + TaskStatus::Pending | TaskStatus::Deleted => makeover_layout::Tone::Neutral,
58 + }
59 + }
60 +
61 + /// The tone a priority badge wears.
62 + ///
63 + /// `PRIORITY_COLOR`'s red/yellow/muted. Low is neutral rather than a cool
64 + /// colour for the reason `Tone`'s own docs give: a tone on everything is a tone
65 + /// on nothing.
66 + const fn priority_tone(priority: &Priority) -> makeover_layout::Tone {
67 + match priority {
68 + Priority::High => makeover_layout::Tone::Danger,
69 + Priority::Medium => makeover_layout::Tone::Warning,
70 + Priority::Low => makeover_layout::Tone::Neutral,
71 + }
72 + }
73 +
74 + /// A short local date, the way every list on this screen writes one.
75 + ///
76 + /// `toLocaleDateString('en-US', { month: 'short', day: 'numeric' })` in three
77 + /// places in the JS. One function here, because three call sites that format a
78 + /// date three ways is how a screen ends up looking assembled.
79 + fn short_date(at: DateTime<Utc>) -> String {
80 + at.with_timezone(&Local).format("%b %-d").to_string()
81 + }
82 +
83 + /// The task a route was addressed at.
84 + fn task_id(params: &quasi_router::Params) -> Result<TaskId, RouteError> {
85 + let raw = params
86 + .get("id")
87 + .ok_or_else(|| RouteError::not_found("no task id"))?;
88 + Ok(TaskId::from(
89 + uuid::Uuid::parse_str(raw).map_err(|_| RouteError::not_found("not a task id"))?,
90 + ))
91 + }
92 +
93 + /// Read the task, or answer 404.
94 + ///
95 + /// A deleted task is a 404 here even though `get_by_id` still returns it.
96 + /// `TaskCrud::delete` is a soft delete — it sets the status and `list_all`
97 + /// filters the row out — so a screen addressing one by id would otherwise
98 + /// render a deleted task as an ordinary one, complete with controls offering to
99 + /// complete it. The JS never met this because it closes the drawer on delete
100 + /// and does not re-fetch. An address that outlives the thing it addresses is
101 + /// exactly what a router has to answer for.
102 + fn load(state: &AppState, id: TaskId) -> Result<Task, RouteError> {
103 + let task = state
104 + .tasks
105 + .get_by_id(id, DESKTOP_USER_ID)
106 + .map_err(|error| RouteError::internal(error.to_string()))?
107 + .filter(|task| task.status != TaskStatus::Deleted)
108 + .ok_or_else(|| RouteError::not_found("no such task"))?;
109 + Ok(task)
110 + }
111 +
112 + /// The streak stats, for a task that has a recurrence chain.
113 + ///
114 + /// `None` for a one-off, which is what makes the whole completion-history
115 + /// section absent rather than empty. Reuses `compute_streak` from the command
116 + /// layer rather than restating it: the streak table is already Rust, and two
117 + /// copies of a rule that decides what a number means is worse than an import
118 + /// across module lines.
119 + fn streak_for(state: &AppState, task: &Task) -> Result<Option<StreakInfo>, RouteError> {
120 + if !task.has_recurrence() && task.recurrence_parent_id.is_none() {
121 + return Ok(None);
122 + }
123 + let root = task.recurrence_parent_id.unwrap_or(task.id);
124 + let chain = state
125 + .tasks
126 + .list_recurrence_chain(root, DESKTOP_USER_ID)
127 + .map_err(|error| RouteError::internal(error.to_string()))?;
128 + Ok(Some(compute_streak(&chain)))
129 + }
130 +
131 + /// One streak figure.
132 + ///
133 + /// # The first finding
134 + ///
135 + /// **A figure with a label is not a row, and there is nothing else to make it
136 + /// out of.** The four stats are the shape every dashboard has: a large value
137 + /// over a small caption, laid out as a strip. `Row` is a list item — primary
138 + /// text with trailing facts — so this reads them backwards, putting the caption
139 + /// first because that is what a row's primary slot means. Four of them then
140 + /// render as a list where the JS renders a strip of tiles.
141 + ///
142 + /// Everything survives and the arrangement does not, which is the same shape as
143 + /// the token finding that `RowPart::Tokens` closed. Filed as makeover-layout
144 + /// `93c6a174`.
145 + fn stat(label: &str, value: impl Into<String>) -> Row {
146 + Row::new(label).meta(value)
147 + }
148 +
149 + /// The completion-history section, for a recurring task.
150 + fn habit_section(streak: &StreakInfo, task_id: TaskId) -> Vec<Node> {
151 + vec![
152 + Node::section("Completion History"),
153 + Node::list([
154 + stat("Current Streak", format!("{}d", streak.current_streak)),
155 + stat("Best Streak", format!("{}d", streak.best_streak)),
156 + stat(
157 + "Completion Rate",
158 + format!("{}%", streak.completion_rate_30d.round() as i64),
159 + ),
160 + stat(
161 + "Total Completed",
162 + format!("{}/{}", streak.total_completed, streak.total_instances),
163 + ),
164 + ]),
165 + // The heatmap. A month grid of completion counts is exactly what
166 + // `Region::Bespoke` was named for: the description says a thing called
167 + // `task-heatmap` goes here and says nothing else, and
168 + // `task-overview.js:renderHeatmap` fills it. Not a workaround and not a
169 + // gap — a description able to produce a calendar grid is a widget
170 + // library with a description's name on it.
171 + //
172 + // The id carries the task so the filling code knows which chain to
173 + // render without asking the screen.
174 + Node::Region(Slot::bespoke(
175 + format!("task-heatmap-{task_id}"),
176 + "task-heatmap",
177 + )),
178 + ]
179 + }
180 +
181 + /// The badges across the top: status, priority, and whatever else is true.
182 + ///
183 + /// Tokens rather than text, so a status keeps its tone. The three conditional
184 + /// ones are the JS's, in its order: focus, overdue, snoozed.
185 + fn badges(task: &Task) -> Vec<Node> {
186 + let mut out = vec![
187 + Node::Token(Tag::badge(task.status.as_str()).tone(status_tone(&task.status))),
188 + Node::Token(Tag::badge(task.priority.as_str()).tone(priority_tone(&task.priority))),
189 + ];
190 + if task.is_focus {
191 + out.push(Node::Token(
192 + Tag::badge("Focus").tone(makeover_layout::Tone::Info),
193 + ));
194 + }
195 + if task.is_overdue() {
196 + out.push(Node::Token(
197 + Tag::badge("Overdue").tone(makeover_layout::Tone::Danger),
198 + ));
199 + }
200 + if task.is_snoozed() {
201 + out.push(Node::Token(
202 + Tag::badge("Snoozed").tone(makeover_layout::Tone::Warning),
203 + ));
204 + }
205 + out
206 + }
207 +
208 + /// The labelled facts under the badges.
209 + ///
210 + /// `Project: X`, `Due: Y` and the rest, each a row whose primary is the label
211 + /// and whose meta is the value. The JS bolds the label inside a sentence; a row
212 + /// is the nearest thing the vocabulary has to a definition list, and unlike the
213 + /// stats above the label really is the primary here.
214 + fn details(task: &Task) -> Vec<Row> {
215 + let mut rows = Vec::new();
216 + if let Some(project) = &task.project_name {
217 + rows.push(Row::new("Project").meta(project));
218 + }
219 + if task.due.is_some() {
220 + rows.push(Row::new("Due").meta(task.due_formatted()));
221 + }
222 + if task.has_recurrence() {
223 + rows.push(Row::new("Recurrence").meta(task.recurrence.as_str()));
224 + }
225 + if let Some(contact) = &task.contact_name {
226 + rows.push(Row::new("Contact").meta(contact));
227 + }
228 + if !task.tags.is_empty() {
229 + // Tokens, because a tag is a badge in the JS and `RowPart::Tokens`
230 + // exists now to keep it one.
231 + let mut row = Row::new("Tags");
232 + for tag in &task.tags {
233 + row = row.token(Tag::badge(tag));
234 + }
235 + rows.push(row);
236 + }
237 + rows
238 + }
239 +
240 + /// The metadata section.
241 + ///
242 + /// # The second finding, and it is one already open
243 + ///
244 + /// **A description carries text and this task carries markdown.**
245 + /// `TaskResponse::description_html` is `docengine::render_standard` and the JS
246 + /// drops it into a `.markdown-content` div. Nothing in the vocabulary names rich
247 + /// text and it must not be smuggled in as a string the renderer trusts. The raw
248 + /// description goes in as text, correct and lossy.
249 + ///
250 + /// Recorded here rather than filed again: this is makeover-layout `25822137`,
251 + /// found on the projects card. What this port adds is that it now has two
252 + /// consumers rather than one, which is the evidence that note asked for.
253 + fn metadata(task: &Task) -> Vec<Node> {
254 + let mut out = badges(task);
255 + if !task.description.is_empty() {
256 + // Text, not the rendered HTML. See the note above.
257 + out.push(Node::text(&task.description));
258 + }
259 + let rows = details(task);
260 + if !rows.is_empty() {
261 + out.push(Node::list(rows));
262 + }
263 + out
264 + }
265 +
266 + /// One subtask.
267 + ///
268 + /// # The third finding
269 + ///
270 + /// **A tick that means something has no route.** `Row::selected` says whether a
271 + /// row is ticked and whether it can be; nothing says what ticking it *calls*.
272 + /// That was right for the case it was added for — goingson's bulk-selection
273 + /// checkboxes are client-side state feeding a later bulk action — and a subtask
274 + /// is the other case: the tick is the write.
275 + ///
276 + /// So the tick here is drawn and inert, and the toggle is an `Act` beside it.
277 + /// Every fact survives, the affordance does not: the user clicks a button
278 + /// labelled "Done" where the shipped screen has a checkbox they click directly.
279 + /// Filed as makeover-layout `14612ed8`.
280 + ///
281 + /// A linked subtask is disabled in the JS because its state follows the task it
282 + /// links to. `Act::disabled` says that, so this one is described exactly.
283 + fn subtask_row(task: TaskId, subtask: &Subtask) -> Row {
284 + let mut row = Row::new(&subtask.text).selectable(subtask.is_completed);
285 + if subtask.linked_task_id.is_some() {
286 + row = row.token(Tag::badge("Linked"));
287 + }
288 +
289 + let toggle = Act::new(
290 + if subtask.is_completed { "Undo" } else { "Done" },
291 + Action::post(format!("/tasks/{task}/subtasks/{}/toggle", subtask.id)),
292 + );
293 + row.act(if subtask.linked_task_id.is_some() {
294 + toggle.disabled()
295 + } else {
296 + toggle
297 + })
298 + }
299 +
300 + /// The subtasks section.
301 + ///
302 + /// # The fourth finding
303 + ///
304 + /// **A heading cannot carry a count, and a proportion cannot be drawn.** The JS
305 + /// writes "Subtasks 3/7" with the count in its own small span, and a slim
306 + /// progress bar under it. `Node::section` takes one string, so the count is
307 + /// concatenated into the heading; nothing names a proportion, so the bar is
308 + /// gone and its number is in the heading too.
309 + ///
310 + /// Two separate gaps that land in one place, and only one is worth a member. A
311 + /// heading with a trailing count is a nice-to-have. A proportion is not: three
312 + /// screens draw one (subtasks, time tracking, the project dashboard), it has a
313 + /// tone in two of them, and "78%" as text is a different thing from a bar the
314 + /// eye reads without counting. Filed as makeover-layout `d0b58239`.
315 + fn subtasks_section(task: &Task) -> Vec<Node> {
316 + let done = task.subtasks_completed();
317 + let total = task.subtask_count();
318 +
319 + let mut out = vec![Node::section(format!("Subtasks {done}/{total}"))];
320 + if total > 0 {
321 + out.push(Node::list(
322 + task.subtasks
323 + .iter()
324 + .map(|subtask| subtask_row(task.id, subtask)),
325 + ));
326 + }
327 + out.push(Node::Form {
328 + action: Action::post(format!("/tasks/{}/subtasks", task.id)),
329 + submit: "Add".to_owned(),
330 + fields: vec![{
331 + let mut field =
332 + Field::new(makeover_layout::FieldKind::Text, "text", "Subtask").required();
333 + field.placeholder = Some("Add subtask...".to_owned());
334 + field
335 + }],
336 + });
337 + out
338 + }
339 +
340 + /// One tracked session.
341 + fn session_row(session: &TimeSession) -> Row {
342 + let started = session.started_at.with_timezone(&Local);
343 + Row::new(format!(
344 + "{} {}",
345 + short_date(session.started_at),
346 + started.format("%-I:%M %p")
347 + ))
348 + .meta(match session.duration_minutes {
349 + Some(minutes) => format!("{minutes}m"),
350 + // A session with no end is one running right now, which is a fact
351 + // about the task and not a missing value.
352 + None => "active".to_owned(),
353 + })
354 + }
355 +
356 + /// The time-tracking section.
357 + ///
358 + /// The same proportion gap as the subtasks section, and here it also loses a
359 + /// tone: the JS colours the bar red past the estimate and green under it, so
360 + /// over-running is visible without reading. `is_over_estimate` is in the
361 + /// heading's text instead.
362 + fn time_section(task: &Task, sessions: &[TimeSession]) -> Vec<Node> {
363 + let tracked = format!("{}m tracked", task.actual_minutes);
364 + let label = match task.estimated_minutes {
365 + Some(estimate) if estimate > 0 => {
366 + let over = if task.is_over_estimate() {
367 + ", over"
368 + } else {
369 + ""
370 + };
371 + format!("{tracked} / {estimate}m est{over}")
372 + }
373 + _ => tracked,
374 + };
375 +
376 + let mut out = vec![Node::section(format!("Time Tracking {label}"))];
377 + if !sessions.is_empty() {
378 + out.push(Node::list(sessions.iter().map(session_row)));
379 + }
380 + out
381 + }
382 +
383 + /// One note.
384 + fn annotation_row(annotation: &Annotation) -> Row {
385 + let at = annotation.timestamp.with_timezone(&Local);
386 + Row::new(&annotation.note).meta(format!(
387 + "{} {}",
388 + short_date(annotation.timestamp),
389 + at.format("%-I:%M %p")
390 + ))
391 + }
392 +
393 + /// The notes section.
394 + fn notes_section(task: &Task) -> Vec<Node> {
395 + let mut out = vec![Node::section(format!("Notes {}", task.annotations.len()))];
396 + if !task.annotations.is_empty() {
397 + out.push(Node::list(task.annotations.iter().map(annotation_row)));
398 + }
399 + out.push(Node::Form {
400 + action: Action::post(format!("/tasks/{}/notes", task.id)),
401 + submit: "Add".to_owned(),
402 + fields: vec![{
403 + let mut field = Field::new(makeover_layout::FieldKind::Text, "note", "Note").required();
404 + field.placeholder = Some("Add note...".to_owned());
405 + field
406 + }],
407 + });
408 + out
409 + }
410 +
411 + /// The whole screen.
412 + ///
413 + /// Built here rather than inside the route because every write answers with it,
414 + /// for the reason the projects screen gives: a write lands in more than one
415 + /// section and a `Response` names one region.
416 + ///
417 + /// # The fifth finding, left out rather than faked
418 + ///
419 + /// **Edit is not offered.** `tasks.openEdit` opens `form-modal.js` over the
420 + /// drawer. `Region::Modal` names the place, but a screen that offers a control
421 + /// which opens a form over itself has to describe two arrangements at once, and
422 + /// the router answers one screen at a time. Describing the edit form as its own
423 + /// address is the shape that fits, and it is a screen of its own rather than a
424 + /// control on this one.
425 + fn screen(state: &AppState, id: TaskId) -> Result<Screen, RouteError> {
426 + let task = load(state, id)?;
427 + let sessions = state
428 + .tasks
429 + .list_time_sessions(id, DESKTOP_USER_ID)
430 + .map_err(|error| RouteError::internal(error.to_string()))?;
431 + let streak = streak_for(state, &task)?;
432 +
433 + let mut band = Slot::new("task-band", RegionKind::Band).with(Node::page(&task.title));
434 + if task.status != TaskStatus::Completed {
435 + band = band.with(Node::act(
436 + "Complete",
437 + Action::post(format!("/tasks/{id}/complete")),
438 + ));
439 + }
440 + band = band.with(Node::Act(
441 + Act::new("Delete", Action::post(format!("/tasks/{id}/delete")))
442 + .tone(makeover_layout::Tone::Danger),
443 + ));
444 +
445 + let mut pane = Slot::new("task-overview", RegionKind::Pane);
446 + if let Some(streak) = &streak {
447 + pane = pane.extend(habit_section(streak, id));
448 + }
449 + pane = pane.extend(metadata(&task));
450 + // The JS hides the subtasks section on a completed task with none, on the
451 + // grounds that there is nothing to add one for any more.
452 + if !task.subtasks.is_empty() || task.status != TaskStatus::Completed {
453 + pane = pane.extend(subtasks_section(&task));
454 + }
455 + pane = pane.extend(time_section(&task, &sessions));
456 + pane = pane.extend(notes_section(&task));
457 +
458 + Ok(Screen::list_detail("Task", false).with(band).with(pane))
459 + }
460 +
461 + /// The whole overview.
462 + fn overview(state: &AppState, params: quasi_router::Params) -> Result<Response, RouteError> {
463 + Ok(screen(state, task_id(&params)?)?.into())
464 + }
465 +
466 + /// Answer a write with the screen it happened on, re-read.
467 + ///
468 + /// Re-read rather than patched in memory, for the reason the contacts port
469 + /// gives: the write is the database's to confirm, and a screen rebuilt from
470 + /// what the handler hoped happened is how a screen disagrees with its own
471 + /// storage.
472 + fn wrote(state: &AppState, id: TaskId) -> Result<Response, RouteError> {
473 + Ok(screen(state, id)?.into())
474 + }
475 +
476 + /// Mark the task complete.
477 + ///
478 + /// `complete` handles the recurring case itself, minting the next instance, so
479 + /// this does not branch on recurrence. Answering with the same address then
480 + /// shows the completed instance rather than the new one, which matches what the
481 + /// JS does: it re-opens the task it was showing.
482 + fn complete(state: &AppState, params: quasi_router::Params) -> Result<Response, RouteError> {
483 + let id = task_id(&params)?;
484 + state
485 + .tasks
486 + .complete(id, DESKTOP_USER_ID)
487 + .map_err(|error| RouteError::internal(error.to_string()))?
488 + .ok_or_else(|| RouteError::not_found("no such task"))?;
489 + wrote(state, id)
490 + }
491 +
492 + /// Delete the task.
493 + ///
494 + /// # The sixth finding
495 + ///
496 + /// **Nothing describes going somewhere else.** Every answer replaces a region
497 + /// or the screen in place. Deleting the thing a screen is about is the first
498 + /// case where the right answer is a *different* screen — the JS closes the
499 + /// drawer and leaves the list underneath — and the description has no way to
500 + /// say "show that address now". The projects and contacts writes never met this
Lines truncated
@@ -1,0 +1,390 @@
1 + //! The task overview, driven through the router against a real database.
2 + //!
3 + //! Same standard as the other two screens: 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::{NewTask, Priority};
11 + use quasi_http::Render as _;
12 + use quasi_router::{Method, Params, Response};
13 +
14 + use super::super::router;
15 + use crate::state::{AppState, DESKTOP_USER_ID};
16 +
17 + /// State with the desktop user in place, which is who the handlers read as.
18 + async fn state() -> Arc<AppState> {
19 + let (state, _) = crate::test_utils::setup_test_state().await;
20 + let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
21 + state
22 + .db
23 + .conn()
24 + .unwrap()
25 + .execute(
26 + "INSERT OR IGNORE INTO users (id, email, password_hash, display_name, created_at) \
27 + VALUES (?, ?, ?, ?, ?)",
28 + rusqlite::params![
29 + DESKTOP_USER_ID.to_string(),
30 + "desktop@localhost",
31 + "x",
32 + "Desktop User",
33 + &now,
34 + ],
35 + )
36 + .unwrap();
37 + state
38 + }
39 +
40 + fn add(state: &AppState, title: &str) -> goingson_core::Task {
41 + state
42 + .tasks
43 + .create(
44 + DESKTOP_USER_ID,
45 + NewTask::builder(title)
46 + .title(title)
47 + .priority(Priority::High)
48 + .build(),
49 + )
50 + .unwrap()
51 + }
52 +
53 + fn get(state: &AppState, path: &str) -> Response {
54 + router()
55 + .handle(state, Method::Get, path, Params::new())
56 + .expect("the route answers")
57 + }
58 +
59 + fn post(state: &AppState, path: &str, params: Params) -> Response {
60 + router()
61 + .handle(state, Method::Post, path, params)
62 + .expect("the route answers")
63 + }
64 +
65 + fn html(response: Response) -> String {
66 + match response {
67 + Response::Screen(screen) => quasi_webview::Webview::new().screen(&screen),
68 + Response::Fragment { node, .. } => quasi_webview::Webview::new().fragment(&node),
69 + }
70 + }
71 +
72 + #[tokio::test]
73 + async fn the_overview_carries_the_task_and_its_badges() {
74 + let state = state().await;
75 + let task = add(&state, "Write the port");
76 +
77 + let page = html(get(&state, &format!("/tasks/{}", task.id)));
78 +
79 + assert!(page.contains("Write the port"));
80 + // Status and priority are tokens, so they keep their tone. `H` is the short
81 + // form `Priority::as_str` gives, which is what the JS chip shows too.
82 + assert!(page.contains("Pending"));
83 + assert!(page.contains(">H<"));
84 + assert!(page.contains("tone-danger"));
85 + }
86 +
87 + #[tokio::test]
88 + async fn a_missing_task_is_a_not_found_rather_than_a_panic() {
89 + let state = state().await;
90 + let error = router()
91 + .handle(
92 + &state,
93 + Method::Get,
94 + &format!("/tasks/{}", uuid::Uuid::nil()),
95 + Params::new(),
96 + )
97 + .expect_err("no such task");
98 + assert_eq!(error.class.http_status(), 404);
99 + }
100 +
101 + #[tokio::test]
102 + async fn a_task_title_cannot_become_markup() {
103 + let state = state().await;
104 + let task = add(&state, "<script>alert(1)</script>");
105 + let page = html(get(&state, &format!("/tasks/{}", task.id)));
106 + assert!(!page.contains("<script>alert"));
107 + assert!(page.contains("&lt;script&gt;"));
108 + }
109 +
110 + #[tokio::test]
111 + async fn the_heatmap_is_a_place_and_the_description_says_nothing_about_it() {
112 + // Decision 4 working rather than a shortfall. A month grid of completion
113 + // counts is not describable and is not going to become describable; the
114 + // screen says a thing called `task-heatmap` goes here and stops.
115 + let state = state().await;
116 + let task = state
117 + .tasks
118 + .create(
119 + DESKTOP_USER_ID,
120 + NewTask::builder("Daily")
121 + .title("Daily")
122 + .recurrence(goingson_core::Recurrence::Daily)
123 + .build(),
124 + )
125 + .unwrap();
126 +
127 + let page = html(get(&state, &format!("/tasks/{}", task.id)));
128 +
129 + assert!(page.contains("Completion History"));
130 + assert!(page.contains("data-bespoke=\"task-heatmap\""));
131 + // A place and nothing else: no cells, no counts, no month.
132 + assert!(!page.contains("month-heatmap"));
133 + }
134 +
135 + #[tokio::test]
136 + async fn a_one_off_task_has_no_completion_history_at_all() {
137 + // Absent rather than empty. A streak of zero on a task that does not repeat
138 + // is a section saying nothing four times.
139 + let state = state().await;
140 + let task = add(&state, "Once");
141 + let page = html(get(&state, &format!("/tasks/{}", task.id)));
142 + assert!(!page.contains("Completion History"));
143 + assert!(!page.contains("data-bespoke"));
144 + }
145 +
146 + #[tokio::test]
147 + async fn a_stat_reads_backwards_because_nothing_names_a_figure_with_a_caption() {
148 + // The first finding, asserted. `Row` is a list item, so the caption lands in
149 + // the primary slot and the figure in the trailing one -- the opposite of the
150 + // tile the JS draws. When this assertion has to change, the finding closed.
151 + let state = state().await;
152 + let task = state
153 + .tasks
154 + .create(
155 + DESKTOP_USER_ID,
156 + NewTask::builder("Daily")
157 + .title("Daily")
158 + .recurrence(goingson_core::Recurrence::Daily)
159 + .build(),
160 + )
161 + .unwrap();
162 +
163 + let page = html(get(&state, &format!("/tasks/{}", task.id)));
164 +
165 + assert!(page.contains("Current Streak"));
166 + assert!(page.contains("row-primary"));
167 + // The figure is trailing text, which is what a row's meta slot is.
168 + assert!(page.contains("row-meta"));
169 + }
170 +
171 + #[tokio::test]
172 + async fn a_subtask_tick_is_drawn_and_the_toggle_is_a_button_beside_it() {
173 + // The third finding. `Row::selected` says ticked and tickable and nothing
174 + // says what ticking calls, so the affordance and the write come apart.
175 + let state = state().await;
176 + let task = add(&state, "Has subtasks");
177 + state
178 + .tasks
179 + .add_subtask(task.id, DESKTOP_USER_ID, "First step")
180 + .unwrap();
181 +
182 + let page = html(get(&state, &format!("/tasks/{}", task.id)));
183 +
184 + // The tick is there.
185 + assert!(page.contains("type=\"checkbox\""));
186 + // And it calls nothing: the route is on a button next to it.
187 + assert!(page.contains("Done"));
188 + assert!(page.contains("/subtasks/"));
189 + assert!(page.contains("/toggle"));
190 + }
191 +
192 + #[tokio::test]
193 + async fn a_linked_subtask_says_it_cannot_be_toggled() {
194 + // Not a workaround: `Act::disabled` says exactly what the JS's `disabled`
195 + // attribute says, so this one is described rather than approximated.
196 + let state = state().await;
197 + let task = add(&state, "Parent");
198 + let linked = add(&state, "Child");
199 + state
200 + .tasks
201 + .add_subtask_link(task.id, DESKTOP_USER_ID, linked.id)
202 + .unwrap();
203 +
204 + let page = html(get(&state, &format!("/tasks/{}", task.id)));
205 + assert!(page.contains("Linked"));
206 + assert!(page.contains("disabled"));
207 + }
208 +
209 + #[tokio::test]
210 + async fn a_proportion_is_text_because_nothing_names_a_bar() {
211 + // The fourth finding. Two progress bars on this screen, one of them toned,
212 + // and the vocabulary has neither the bar nor a count beside a heading. Both
213 + // end up concatenated into the heading string.
214 + let state = state().await;
215 + let task = add(&state, "Has subtasks");
216 + state
217 + .tasks
218 + .add_subtask(task.id, DESKTOP_USER_ID, "First step")
219 + .unwrap();
220 +
221 + let page = html(get(&state, &format!("/tasks/{}", task.id)));
222 +
223 + assert!(page.contains("Subtasks 0/1"));
224 + // The bar the JS draws under it, and its tone, are gone.
225 + assert!(!page.contains("progress-fill"));
226 + assert!(!page.contains("data-tone"));
227 + }
228 +
229 + #[tokio::test]
230 + async fn adding_a_subtask_answers_with_the_screen_it_happened_on() {
231 + let state = state().await;
232 + let task = add(&state, "Empty");
233 +
234 + let response = post(
235 + &state,
236 + &format!("/tasks/{}/subtasks", task.id),
237 + Params::new().with("text", "Added here"),
238 + );
239 + let Response::Screen(_) = &response else {
240 + panic!("a write answers with the whole screen");
241 + };
242 + let page = html(response);
243 +
244 + assert!(page.contains("Added here"));
245 + assert!(page.contains("Subtasks 0/1"));
246 + }
247 +
248 + #[tokio::test]
249 + async fn an_empty_add_changes_nothing_rather_than_failing() {
250 + // The JS returns early on an empty box. A 400 here would be the described
251 + // screen inventing an error the shipped one does not have.
252 + let state = state().await;
253 + let task = add(&state, "Empty");
254 +
255 + let page = html(post(
256 + &state,
257 + &format!("/tasks/{}/subtasks", task.id),
258 + Params::new().with("text", " "),
259 + ));
260 + assert!(page.contains("Subtasks 0/0"));
261 + }
262 +
263 + #[tokio::test]
264 + async fn toggling_a_subtask_ticks_it_and_the_button_offers_the_way_back() {
265 + let state = state().await;
266 + let task = add(&state, "Has subtasks");
267 + let subtask = state
268 + .tasks
269 + .add_subtask(task.id, DESKTOP_USER_ID, "First step")
270 + .unwrap()
271 + .unwrap();
272 +
273 + let page = html(post(
274 + &state,
275 + &format!("/tasks/{}/subtasks/{}/toggle", task.id, subtask.id),
276 + Params::new(),
277 + ));
278 +
279 + assert!(page.contains("Subtasks 1/1"));
280 + assert!(page.contains(" checked"));
281 + assert!(page.contains("Undo"));
282 + }
283 +
284 + #[tokio::test]
285 + async fn adding_a_note_puts_it_on_the_screen() {
286 + let state = state().await;
287 + let task = add(&state, "Needs a note");
288 +
289 + let page = html(post(
290 + &state,
291 + &format!("/tasks/{}/notes", task.id),
292 + Params::new().with("note", "Remember the thing"),
293 + ));
294 +
295 + assert!(page.contains("Remember the thing"));
296 + assert!(page.contains("Notes 1"));
297 + }
298 +
299 + #[tokio::test]
300 + async fn completing_a_task_takes_the_complete_control_away() {
301 + let state = state().await;
302 + let task = add(&state, "Nearly done");
303 +
304 + let page = html(post(
305 + &state,
306 + &format!("/tasks/{}/complete", task.id),
307 + Params::new(),
308 + ));
309 +
310 + assert!(page.contains("Completed"));
311 + // Offering to complete something already complete is a control that does
312 + // nothing, which is what this whole line of work is about not doing.
313 + assert!(!page.contains(">Complete<"));
314 + assert!(page.contains(">Delete<"));
315 + }
316 +
317 + #[tokio::test]
318 + async fn deleting_a_task_has_nowhere_to_send_the_user() {
319 + // The sixth finding, asserted. Every answer replaces a region or the screen
320 + // in place, and deleting the thing a screen is about is the first case
321 + // where the right answer is a different address. The JS closes the drawer.
322 + let state = state().await;
323 + let task = add(&state, "Going");
324 +
325 + let page = html(post(
326 + &state,
327 + &format!("/tasks/{}/delete", task.id),
328 + Params::new(),
329 + ));
330 +
331 + assert!(page.contains("Task deleted"));
332 + // And it is really gone.
333 + let error = router()
334 + .handle(
335 + &state,
336 + Method::Get,
337 + &format!("/tasks/{}", task.id),
338 + Params::new(),
339 + )
340 + .expect_err("deleted");
341 + assert_eq!(error.class.http_status(), 404);
342 + }
343 +
344 + #[tokio::test]
345 + async fn deleting_something_that_is_not_there_is_a_not_found() {
346 + let state = state().await;
347 + let error = router()
348 + .handle(
349 + &state,
350 + Method::Post,
351 + &format!("/tasks/{}/delete", uuid::Uuid::nil()),
352 + Params::new(),
353 + )
354 + .expect_err("no such task");
355 + assert_eq!(error.class.http_status(), 404);
356 + }
357 +
358 + #[tokio::test]
359 + async fn a_description_arrives_as_text_and_never_as_markup() {
360 + // The second finding, which is makeover-layout `25822137` meeting its second
361 + // consumer. The JS renders `descriptionHtml` through docengine; the
362 + // description carries the raw markdown as text, correct and lossy.
363 + let state = state().await;
364 + let task = state
365 + .tasks
366 + .create(
367 + DESKTOP_USER_ID,
368 + NewTask::builder("Documented")
369 + .title("Documented")
370 + .description("A **bold** claim")
371 + .build(),
372 + )
373 + .unwrap();
374 +
375 + let page = html(get(&state, &format!("/tasks/{}", task.id)));
376 +
377 + assert!(page.contains("A **bold** claim"));
378 + assert!(!page.contains("<strong>"));
379 + }
380 +
381 + #[tokio::test]
382 + async fn edit_is_absent_rather_than_dangling() {
383 + // The fifth finding. `tasks.openEdit` opens a modal form over the drawer,
384 + // which is a second arrangement this screen would have to describe. Left
385 + // out rather than offered as a control that calls nothing.
386 + let state = state().await;
387 + let task = add(&state, "Not editable here");
388 + let page = html(get(&state, &format!("/tasks/{}", task.id)));
389 + assert!(!page.contains(">Edit<"));
390 + }