Skip to main content

max / goingson

Describe the task list Twelfth port, and the largest screen left: tasks.js, tasks-render.js, tasks-filter.js, task-forms.js and saved-views.js, 14 esc() sites. It is also the vocabulary's first Node::Table -- Column::reorder and Cells have existed since ce620871 with no consumer, because the eleven ports before this were lists, cards and a timeline. The routes are GET /tasks, GET /tasks/list, and the row's two writes under /tasks/list/{id}/. The writes are namespaced rather than sharing the drawer's POST /tasks/{id}/complete because a write is answered by the region it happened in and one route cannot answer both. GET /tasks did not exist until now, and the drawer's delete has been redirecting to it since 2026-08-09. Its test asserted the destination rather than following it, so nothing noticed. This closes that. Two things came out of writing the tests. Completing a task was not what completing a task means. The repository's complete() is the status transition; commands::complete_task also builds the recurring successor and inserts it in the same transaction, stops the timer and closes a finished milestone. All three described surfaces called the first one, so a weekly task completed from the board ended its chain silently -- the row left the list either way. The command's body is now a plain fn every surface calls, and the three status writes are one shared quasi::move_to rather than a copy per surface. Priority::as_str is the single letter H/M/L, a display abbreviation for the priority column, where TaskStatus::as_str next to it is the stored word. The filter chips were built with it, so every priority chip offered ?priority=H against a parser expecting High. Caught by the test that presses one. Four findings are recorded in the module header: the column description exists in build.rs and here in two vocabularies with nothing checking they agree; a table cannot say there is more, so paging is an act beside it; a table row cannot join a selection, so this port has no bulk actions rather than worse ones; and saved views are a screen of their own. 22 tests; 593 pass, clippy clean.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-16 02:38 UTC
Signed with PGP, not checked
Commit: e8cf6a77100a0b6176ca46eb55de31ef4f5ebc81
Parent: 73d570c
6 files changed, +1124 insertions, -76 deletions
@@ -671,6 +671,17 @@
671 671 ///
672 672 /// For recurring tasks, this creates the next instance with an updated due date.
673 673 ///
674 + /// The command below is a wrapper. The work is here so the described screens
675 + /// (`quasi::move_to`, and the drawer through it) complete the same way rather
676 + /// than calling the repository's `complete` and getting three quarters of it.
677 + /// The missing quarter is the interesting part, and none of it is visible from
678 + /// the repository method's name: a recurring task's successor is built *by the
679 + /// caller* and inserted in the same transaction, the running timer stops, and a
680 + /// milestone whose last task this was completes with it. `complete` alone does
681 + /// the status transition and nothing else, so a described board completing a
682 + /// weekly task ended its chain — silently, since the row simply left the list
683 + /// either way.
684 + ///
674 685 /// # Returns
675 686 ///
676 687 /// - `completed`: Whether the task was marked complete
@@ -679,12 +690,7 @@
679 690 /// # Errors
680 691 ///
681 692 /// Returns `DATABASE_ERROR` if the update or insert fails.
682 - #[tauri::command]
683 - #[instrument(skip_all)]
684 - pub async fn complete_task(
685 - state: State<'_, Arc<AppState>>,
686 - id: TaskId,
687 - ) -> Result<CompleteTaskResponse, ApiError> {
693 + pub fn complete(state: &AppState, id: TaskId) -> Result<CompleteTaskResponse, ApiError> {
688 694 // Auto-stop any running timer before completing
689 695 let _ = state.tasks.stop_timer(id, DESKTOP_USER_ID);
690 696
@@ -735,6 +741,16 @@
735 741 })
736 742 }
737 743
744 + /// Marks a task as completed. See [`complete`].
745 + #[tauri::command]
746 + #[instrument(skip_all)]
747 + pub async fn complete_task(
748 + state: State<'_, Arc<AppState>>,
749 + id: TaskId,
750 + ) -> Result<CompleteTaskResponse, ApiError> {
751 + complete(&state, id)
752 + }
753 +
738 754 // Task Overview
739 755
740 756 /// A completed instance in a recurrence chain (lightweight).
@@ -45,7 +45,7 @@
45 45 // plain `fn(&S, Request)` pointer, so the signature is the router's.
46 46 #![allow(clippy::needless_pass_by_value)]
47 47
48 - use goingson_core::{Priority, Task, TaskId, TaskStatus, UpdateTask};
48 + use goingson_core::{Priority, Task, TaskId, TaskStatus};
49 49 use makeover_layout::{Heading, Tone};
50 50 use quasi_router::screen::{Act, Meter, Row, Tag};
51 51 use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
@@ -249,62 +249,9 @@
249 249 }
250 250
251 251 // Three different writes, which is what the JS does and is not incidental.
252 - // Starting stamps a start time and completing runs the recurrence rule, so
253 - // neither is "set the status column".
254 - let message = match to {
255 - TaskStatus::Started => {
256 - state
257 - .tasks
258 - .start(id, DESKTOP_USER_ID)
259 - .map_err(|error| RouteError::internal(error.to_string()))?;
260 - "Task started."
261 - }
262 - TaskStatus::Completed => {
263 - // The timer stops with it, matching `complete_task`. A card
264 - // completed with a timer running would otherwise keep accruing.
265 - let _ = state.tasks.stop_timer(id, DESKTOP_USER_ID);
266 - state
267 - .tasks
268 - .complete(id, DESKTOP_USER_ID)
269 - .map_err(|error| RouteError::internal(error.to_string()))?
270 - .ok_or_else(|| RouteError::not_found("no such task"))?;
271 - "Task completed."
272 - }
273 - TaskStatus::Pending => {
274 - // `UpdateTask` replaces rather than patches, which is why the JS
275 - // resends every field on this path and why this does too. Built
276 - // from the task just read, so the only thing that changes is the
277 - // status; anything left out would be cleared, and moving a card
278 - // left is not a reason to lose its tags.
279 - state
280 - .tasks
281 - .update(
282 - id,
283 - DESKTOP_USER_ID,
284 - UpdateTask {
285 - project_id: task.project_id,
286 - milestone_id: task.milestone_id,
287 - contact_id: task.contact_id,
288 - title: task.title.clone(),
289 - description: task.description.clone(),
290 - status: TaskStatus::Pending,
291 - priority: task.priority,
292 - due: task.due,
293 - tags: task.tags.clone(),
294 - recurrence: task.recurrence,
295 - recurrence_rule: task.recurrence_rule.clone(),
296 - urgency: task.urgency,
297 - scheduled_start: task.scheduled_start,
298 - scheduled_duration: task.scheduled_duration,
299 - estimated_minutes: task.estimated_minutes,
300 - },
301 - )
302 - .map_err(|error| RouteError::internal(error.to_string()))?
303 - .ok_or_else(|| RouteError::not_found("no such task"))?;
304 - "Task moved to Pending."
305 - }
306 - TaskStatus::Deleted => return Err(RouteError::not_found("no such column")),
307 - };
252 + // The task list offers the same three from a row, so they live in
253 + // [`super::move_to`] rather than here.
254 + let message = super::move_to(state, &task, &to)?;
308 255
309 256 Ok(
310 257 Response::fragment("board", Node::Region(board_region(state)?))
@@ -41,7 +41,7 @@
41 41 //! exist and both are counted. The count starts falling at the flip. Progress is
42 42 //! the first list, not the number.
43 43 //!
44 - //! ## Described, and retires at the flip (22 files, 193 sites)
44 + //! ## Described, and retires at the flip (27 files, 207 sites)
45 45 //!
46 46 //! | Module | JS counterpart | Sites |
47 47 //! |---|---|---|
@@ -55,9 +55,10 @@
55 55 //! | [`monthly_review`] | `monthly-review.js` 3, `monthly-review-render.js` 5 | 8 |
56 56 //! | [`settings`] | `settings.js` | 6 |
57 57 //! | [`board`] | `tasks-kanban.js` 3, `task-board.js` 1 | 4 |
58 + //! | [`task_list`] | `tasks.js` 2, `tasks-render.js` 8, `tasks-filter.js` 2, `task-forms.js` 1, `saved-views.js` 1 | 14 |
58 59 //!
59 - //! [`projects`] carries its dashboard as a submodule, which is the eleventh
60 - //! described screen against ten modules here.
60 + //! [`projects`] carries its dashboard as a submodule, which is the twelfth
61 + //! described screen against eleven modules here.
61 62 //!
62 63 //! ## Stays JavaScript, by decision (4 files, 50 sites)
63 64 //!
@@ -69,15 +70,16 @@
69 70 //! These 50 never reach zero by porting. Retiring `escape.js` means giving them
70 71 //! typed escaping some other way, or accepting that four files keep an escaper.
71 72 //!
72 - //! ## Un-ported screens (12 files, 57 sites)
73 + //! ## Un-ported screens (7 files, 43 sites)
73 74 //!
74 75 //! A described counterpart could exist and does not. This is the candidate list,
75 76 //! and it is the only place to look for what is portable next.
76 77 //!
77 - //! - The task **list**, which is not [`tasks`]: that module is the single-task
78 - //! drawer at `GET /tasks/{id}`, and [`board`] is the kanban. The list itself
79 - //! has no counterpart. `tasks.js` 2, `tasks-render.js` 8, `tasks-filter.js` 2,
80 - //! `task-forms.js` 1, `saved-views.js` 1. 14 sites.
78 + //! The task list left this list on 2026-08-15 and is [`task_list`] now. It was
79 + //! the largest candidate here and the one this table had to name twice, because
80 + //! it is neither [`tasks`] (the single-task drawer at `GET /tasks/{id}`) nor
81 + //! [`board`] (the kanban).
82 + //!
81 83 //! - `import-external.js` 11, `import.js` 5, `export.js` 2. 18 sites.
82 84 //! - `events.js` 10. Its grid rendering is bespoke; whether the CRUD/list half
83 85 //! is a real candidate is unchecked, and per this table that is a claim to
@@ -132,12 +134,12 @@
132 134
133 135 use std::sync::Arc;
134 136
135 - use goingson_core::Task;
137 + use goingson_core::{Task, TaskStatus, UpdateTask};
136 138 use makeover_layout::Tone;
137 - use quasi_router::Router;
138 139 use quasi_router::screen::Tag;
140 + use quasi_router::{RouteError, Router};
139 141
140 - use crate::state::AppState;
142 + use crate::state::{AppState, DESKTOP_USER_ID};
141 143
142 144 pub mod board;
143 145 pub mod contacts;
@@ -147,6 +149,7 @@
147 149 pub mod problems;
148 150 pub mod projects;
149 151 pub mod settings;
152 + pub mod task_list;
150 153 pub mod tasks;
151 154 pub mod weekly_review;
152 155
@@ -234,6 +237,83 @@
234 237 }
235 238 }
236 239
240 + /// Move a task to a named status, whoever asked.
241 + ///
242 + /// Two surfaces ask: the board, where it is a drop, and the task list, where it
243 + /// is a row's own control. It lives here for the reason [`Availability`] does.
244 + /// "Set the status column" is three different writes — starting stamps a start
245 + /// time, completing runs the recurrence rule and stops the timer, and going
246 + /// back to Pending resends every field because [`UpdateTask`] replaces rather
247 + /// than patches — and a second copy of that knowledge is a second answer to
248 + /// what completing a task means.
249 + ///
250 + /// Answers the sentence to say afterwards. The caller decides what to re-read,
251 + /// because only the caller knows which region it is answering.
252 + ///
253 + /// `to` is never [`TaskStatus::Deleted`]: deleting is its own route on both
254 + /// surfaces, and a status control that could delete would put it one keystroke
255 + /// from Completed.
256 + pub(crate) fn move_to(
257 + state: &AppState,
258 + task: &Task,
259 + to: &TaskStatus,
260 + ) -> Result<&'static str, RouteError> {
261 + match to {
262 + TaskStatus::Started => {
263 + state
264 + .tasks
265 + .start(task.id, DESKTOP_USER_ID)
266 + .map_err(|error| RouteError::internal(error.to_string()))?;
267 + Ok("Task started.")
268 + }
269 + TaskStatus::Completed => {
270 + // [`crate::commands::complete`] and not the repository's `complete`,
271 + // which is a status transition and three quarters of what
272 + // completing a task means here. It was the repository's until
273 + // 2026-08-15, and the missing quarter never showed: a weekly task
274 + // completed from the board had its recurrence chain end there, a
275 + // running timer kept accruing, and a milestone whose last task it
276 + // was stayed open. The row left the list either way, which is why
277 + // nothing noticed.
278 + crate::commands::complete(state, task.id)
279 + .map_err(|error| RouteError::internal(error.to_string()))?;
280 + Ok("Task completed.")
281 + }
282 + TaskStatus::Pending => {
283 + // Built from the task just read, so the only thing that changes is
284 + // the status; anything left out would be cleared, and moving a task
285 + // back to Pending is not a reason to lose its tags.
286 + state
287 + .tasks
288 + .update(
289 + task.id,
290 + DESKTOP_USER_ID,
291 + UpdateTask {
292 + project_id: task.project_id,
293 + milestone_id: task.milestone_id,
294 + contact_id: task.contact_id,
295 + title: task.title.clone(),
296 + description: task.description.clone(),
297 + status: TaskStatus::Pending,
298 + priority: task.priority.clone(),
299 + due: task.due,
300 + tags: task.tags.clone(),
301 + recurrence: task.recurrence.clone(),
302 + recurrence_rule: task.recurrence_rule.clone(),
303 + urgency: task.urgency,
304 + scheduled_start: task.scheduled_start,
305 + scheduled_duration: task.scheduled_duration,
306 + estimated_minutes: task.estimated_minutes,
307 + },
308 + )
309 + .map_err(|error| RouteError::internal(error.to_string()))?
310 + .ok_or_else(|| RouteError::not_found("no such task"))?;
311 + Ok("Task moved to Pending.")
312 + }
313 + TaskStatus::Deleted => Err(RouteError::not_found("not a status a control can set")),
314 + }
315 + }
316 +
237 317 /// Every described screen's routes.
238 318 #[must_use]
239 319 pub fn router() -> Router<AppState> {
@@ -247,6 +327,7 @@
247 327 let router = problems::routes(router);
248 328 let router = day_planning::routes(router);
249 329 let router = board::routes(router);
330 + let router = task_list::routes(router);
250 331 emails::routes(router)
251 332 }
252 333
@@ -784,11 +784,15 @@
784 784 /// JS does: it re-opens the task it was showing.
785 785 fn complete(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
786 786 let id = task_id(&request)?;
787 - state
787 + // Through [`super::move_to`], so this drawer, the board and the task list
788 + // are one answer to what completing a task means. See the note there for
789 + // what the repository's `complete` leaves out.
790 + let task = state
788 791 .tasks
789 - .complete(id, DESKTOP_USER_ID)
792 + .get_by_id(id, DESKTOP_USER_ID)
790 793 .map_err(|error| RouteError::internal(error.to_string()))?
791 794 .ok_or_else(|| RouteError::not_found("no such task"))?;
795 + super::move_to(state, &task, &TaskStatus::Completed)?;
792 796 wrote(state, id)
793 797 }
794 798
@@ -1,0 +1,988 @@
1 + //! The task list, described rather than built.
2 + //!
3 + //! <!-- wiki: quasi-overview -->
4 + //!
5 + //! Twelfth port, and the largest un-ported screen by weight: `tasks.js` 948,
6 + //! `tasks-render.js` 406, `tasks-filter.js` 263, `task-forms.js` 499 and
7 + //! `saved-views.js` 165, carrying 14 `esc()` sites between them. It is also the
8 + //! screen [the module above](super) had to name twice to stop people reaching
9 + //! for the wrong module: [`tasks`](super::tasks) is the single-task drawer at
10 + //! `GET /tasks/{id}` and [`board`](super::board) is the kanban. This is the
11 + //! list.
12 + //!
13 + //! # It closes a dangling redirect
14 + //!
15 + //! [`super::tasks`]'s delete has answered `Response::goto(Action::get("/tasks"))`
16 + //! since 2026-08-09, and until this module there was no `GET /tasks`. Deleting
17 + //! from the drawer redirected to a route nothing served, and a test asserted the
18 + //! destination rather than that anything answered it. That is what a redirect
19 + //! costs over a fragment: the address is checked when it is followed, and
20 + //! nothing followed it.
21 + //!
22 + //! # The shape
23 + //!
24 + //! - `GET /tasks` — the whole screen.
25 + //! - `GET /tasks/list` — the table alone, which is what a filter, a sort, or
26 + //! "show more" swaps.
27 + //! - `POST /tasks/list/{id}/status` — start it, complete it, or send it back to
28 + //! Pending, carrying `status`.
29 + //! - `POST /tasks/list/{id}/delete` — delete it.
30 + //!
31 + //! The writes are under `/tasks/list/` rather than beside the drawer's own
32 + //! `POST /tasks/{id}/complete`, and that is the honest name rather than a
33 + //! workaround: a write is answered by the region it happened in, the drawer
34 + //! answers with the drawer, and one route cannot answer both. The three status
35 + //! writes themselves are [`super::move_to`], shared with the board, because
36 + //! "set the status column" is three different writes and two copies of that is
37 + //! two answers to what completing a task means.
38 + //!
39 + //! # The first table
40 + //!
41 + //! `Node::Table`, `Column::reorder` and `Cells` have been in the vocabulary
42 + //! since `ce620871` and no port had used one: the eleven before this were lists,
43 + //! cards and a timeline. The shipped screen is a real table — seven columns,
44 + //! four of them sortable, and a build script that already describes them — so
45 + //! this is where the members meet their first consumer, and three of the four
46 + //! findings below come from that.
47 + //!
48 + //! # Findings
49 + //!
50 + //! **1. The column description exists twice, in two vocabularies.** `build.rs`
51 + //! holds `TASK_COLUMNS` as `makeover_layout::Column` and emits the narrowing CSS
52 + //! and the JSON the frontend tests check themselves against; [`COLUMNS`] here
53 + //! holds the same seven columns as `quasi_router::screen::Column` because the
54 + //! runtime one carries an address and the build-time one cannot. The two agree
55 + //! today and nothing checks that they do. Not a vocabulary gap — the overlap is
56 + //! name, width and priority, which a build script could emit as a Rust const for
57 + //! this module to take the address half onto. Left alone because inventing that
58 + //! generator while porting the screen is two changes wearing one commit.
59 + //!
60 + //! **2. A table cannot say there is more.** [`Node::List`] carries a
61 + //! [`Rest`](quasi_router::screen::Rest) and `Node::Table` carries nothing of the
62 + //! kind, so paging is a [`Node::Act`] sitting under the table rather than a
63 + //! property of it. The shipped screen streams pages into a virtual scroller as
64 + //! you scroll; a description has no word for that either, and the address-shaped
65 + //! answer the mail list already settled on — the view says how many rows it
66 + //! shows, and asking for more is an address — is what this uses. What is lost is
67 + //! the renderer knowing the act belongs to the table above it.
68 + //!
69 + //! **3. A table row cannot join a selection.** [`Row::ticking`](quasi_router::screen::Row::ticking) exists,
70 + //! `Screen::selecting` and `Act::over` exist, and `Cells` deliberately has
71 + //! neither: its doc says "no table asks for one, and a member added because its
72 + //! sibling has it is a member with no consumer to tell us what it should mean."
73 + //! One asks now. The shipped list has a per-row checkbox, shift-range selection
74 + //! and a bulk-actions bar, and none of it is sayable here, so this port has no
75 + //! bulk selection at all rather than a worse version of it.
76 + //!
77 + //! **4. Saved views are a screen this one does not have.** `saved-views.js`
78 + //! stores named filter sets. Under this port a view *is* an address, which is
79 + //! most of what saving one was for, but naming and listing them is a store and a
80 + //! screen of its own and is not part of describing this one.
81 + //!
82 + //! # What the row offers, and what it does not
83 + //!
84 + //! Start, Complete, Delete, and the title opens the drawer. The shipped row's
85 + //! kebab also offers Edit, Manage Subtasks, Add Note, Attachments, Snooze,
86 + //! Schedule, Track Time and Focus. Every one of those opens a modal over the
87 + //! list, and a modal form over a screen is a second arrangement this screen
88 + //! would have to describe before it could offer it — [`super::tasks`] recorded
89 + //! that refusal for Edit and it is unchanged. What they are not is lost: they
90 + //! all live on the drawer the title opens.
91 + //!
92 + //! Delete asks for confirmation, where the shipped screen deliberately does not:
93 + //! `tasks.js` argues, correctly, that its delete is optimistic with the API call
94 + //! deferred until an undo window closes, so the undo toast *is* the recovery and
95 + //! a "cannot be undone" modal would be false. A description has no word for an
96 + //! undo window. Under this port the deletion is immediate, so it is confirmed,
97 + //! which is the same standard the drawer and the projects screen already hold.
98 + //!
99 + //! # The default order is the screen's, not the command's
100 + //!
101 + //! `list_tasks_filtered` defaults to urgency descending; `tasks-filter.js` opens
102 + //! on `due` ascending and has since it was written. This is a port of the
103 + //! screen, so the screen's default is the one kept. Urgency is therefore a sort
104 + //! this list cannot reach, exactly as on the shipped screen: it is a column in
105 + //! [`TaskSortColumn`] with no heading to press, and `sortTasks`'s "descending if
106 + //! urgency" branch has never run.
107 +
108 + // Handlers take their request by value because `quasi_router::Handler` is a
109 + // plain `fn(&S, Request)` pointer, so the signature is the router's and not a
110 + // choice made here. Same allow, for the same reason, as quasi-axum's tests.
111 + #![allow(clippy::needless_pass_by_value)]
112 +
113 + use goingson_core::{
114 + MilestoneId, Priority, ProjectId, SortDirection, Task, TaskFilterQuery, TaskId, TaskSortColumn,
115 + TaskStatus,
116 + };
117 + use makeover_layout::{Sort, Tone, Width};
118 + use quasi_router::screen::{Act, Cell, Cells, Choice, Column, Field, Figure, Meter, Tag};
119 + use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
120 +
121 + use crate::state::{AppState, DESKTOP_USER_ID};
122 +
123 + #[cfg(test)]
124 + mod tests;
125 +
126 + /// How many rows a page is.
127 + ///
128 + /// `TASK_PAGE_SIZE` in `tasks.js`, and the same number for the same reason: it
129 + /// is what one scroll's worth of streaming asks for.
130 + const PAGE: i64 = 200;
131 +
132 + /// The ceiling on `shown`.
133 + ///
134 + /// Ten pages, which is where the shipped screen's own paging would arrive after
135 + /// ten scrolls, and what stops a hand-typed address asking for a million rows.
136 + const PAGES: i64 = 10;
137 +
138 + /// The table, left to right.
139 + ///
140 + /// The same seven columns as `build.rs`'s `TASK_COLUMNS`, in the same order,
141 + /// with the same widths and the same four marked sortable — see finding 1 above
142 + /// for why the description exists in two places and what would close it. The
143 + /// priority a column has when room runs out is the build script's business,
144 + /// because it is what generates the narrowing CSS, so only the width is
145 + /// restated here; a renderer with no stylesheet reads it off the same order.
146 + const COLUMNS: [(&str, Width, Option<TaskSortColumn>); 7] = [
147 + (
148 + "description",
149 + Width::Fill,
150 + Some(TaskSortColumn::Description),
151 + ),
152 + ("project", Width::Fixed, Some(TaskSortColumn::Project)),
153 + ("priority", Width::Fixed, Some(TaskSortColumn::Priority)),
154 + ("due", Width::Fixed, Some(TaskSortColumn::Due)),
155 + ("recurrence", Width::Fixed, None),
156 + ("progress", Width::Fixed, None),
157 + ("actions", Width::Fixed, None),
158 + ];
159 +
160 + /// The statuses the filter offers, in the order the control reads.
161 + ///
162 + /// `None` is "every status", which the shipped `<select>` spells as its blank
163 + /// option. `Deleted` is not offered: a deleted task is not on the list, and
164 + /// `list_filtered` does not return one.
165 + const STATUSES: [Option<TaskStatus>; 4] = [
166 + Some(TaskStatus::Pending),
167 + Some(TaskStatus::Started),
168 + Some(TaskStatus::Completed),
169 + None,
170 + ];
171 +
172 + /// The priorities the filter offers.
173 + const PRIORITIES: [Priority; 3] = [Priority::High, Priority::Medium, Priority::Low];
174 +
175 + /// The word a priority travels and reads as.
176 + ///
177 + /// **Not `Priority::as_str`, which is the single letter `H`/`M`/`L`.** That
178 + /// method is a display abbreviation for the priority *column*, where the cell is
179 + /// one character wide; `TaskStatus::as_str` next to it is the stored word. Two
180 + /// methods, one name, two different kinds of answer — and the filter chips were
181 + /// built with the wrong one, so every priority chip on this screen offered
182 + /// `?priority=H`, which [`View::of`] answers with a 404. Caught by the test that
183 + /// presses one.
184 + const fn priority_word(priority: &Priority) -> &'static str {
185 + match priority {
186 + Priority::High => "High",
187 + Priority::Medium => "Medium",
188 + Priority::Low => "Low",
189 + }
190 + }
191 +
192 + /// The tone a priority wears. [`super::tasks`]'s `priority_tone`, which is the
193 + /// same mapping and is not re-derived here on purpose.
194 + const fn priority_tone(priority: &Priority) -> Tone {
195 + match priority {
196 + Priority::High => Tone::Danger,
197 + Priority::Medium => Tone::Warning,
198 + Priority::Low => Tone::Neutral,
199 + }
200 + }
201 +
202 + /// The word a sort column travels as.
203 + ///
204 + /// `TaskSortColumn::from_str_or_default` reads these and defaults silently,
205 + /// which is right for a command taking whatever a caller sent and wrong for an
206 + /// address; [`View::of`] parses strictly against the same words.
207 + const fn sort_word(column: TaskSortColumn) -> &'static str {
208 + match column {
209 + TaskSortColumn::Description => "description",
210 + TaskSortColumn::Project => "project",
211 + TaskSortColumn::Priority => "priority",
212 + TaskSortColumn::Due => "due",
213 + TaskSortColumn::Urgency => "urgency",
214 + }
215 + }
216 +
217 + /// A param that is present and not blank. Blank is absent, which is what the
218 + /// "all projects" option means.
219 + fn text(params: &quasi_router::Params, name: &str) -> Option<String> {
220 + params
221 + .get(name)
222 + .map(str::trim)
223 + .filter(|value| !value.is_empty())
224 + .map(str::to_owned)
225 + }
226 +
227 + /// A uuid-shaped param, or a 404.
228 + ///
229 + /// An id that does not parse is a wiring mistake, and answering it with the
230 + /// unfiltered list hides one.
231 + fn id_param<T: From<uuid::Uuid>>(
232 + params: &quasi_router::Params,
233 + name: &str,
234 + ) -> Result<Option<T>, RouteError> {
235 + match text(params, name) {
236 + None => Ok(None),
237 + Some(raw) => uuid::Uuid::parse_str(&raw)
238 + .map(|id| Some(T::from(id)))
239 + .map_err(|_| RouteError::not_found("not an id")),
240 + }
241 + }
242 +
243 + /// Which rows, in what order, and how many of them.
244 + ///
245 + /// Query params rather than module state, per decision 2. `tasks-filter.js`
246 + /// holds the sort in module scope and mirrors the filters into the query string
247 + /// by hand — `writeFiltersToUrl` and `restoreFiltersFromUrl`, two functions and
248 + /// six ids each — so the shipped screen already agreed that a view is an
249 + /// address, and paid for it twice. Here the address is the only copy.
250 + #[derive(Clone, PartialEq)]
251 + struct View {
252 + /// The status being looked at. `None` is every status.
253 + status: Option<TaskStatus>,
254 + /// The project being looked at, if it is one project.
255 + project: Option<ProjectId>,
256 + /// The milestone within that project, if it is one milestone.
257 + milestone: Option<MilestoneId>,
258 + /// The priority being looked at, if it is one priority.
259 + priority: Option<Priority>,
260 + /// Whether snoozed tasks are included.
261 + snoozed: bool,
262 + /// Whether the list is cut to what is waiting on somebody else.
263 + waiting: bool,
264 + /// What the table is ordered by.
265 + sort: TaskSortColumn,
266 + /// Which way.
267 + direction: SortDirection,
268 + /// How many rows are on screen.
269 + ///
270 + /// The mail list's answer, and its reasoning applies unchanged: the JS
271 + /// appends what it has fetched, an address cannot append, so this says how
272 + /// many rows the list shows and the query asks for that many from the top.
273 + /// The same rows arrive either way, and this address re-opens to what it
274 + /// described.
275 + shown: i64,
276 + }
277 +
278 + impl Default for View {
279 + /// What `GET /tasks` with no params is: pending work, most urgent first by
280 + /// due date, one page of it.
281 + ///
282 + /// `filter-status` opens on `pending` and `currentSortColumn` on `due`
283 + /// ascending. Both are the shipped screen's, not the command's.
284 + fn default() -> Self {
285 + Self {
286 + status: Some(TaskStatus::Pending),
287 + project: None,
288 + milestone: None,
289 + priority: None,
290 + snoozed: false,
291 + waiting: false,
292 + sort: TaskSortColumn::Due,
293 + direction: SortDirection::Asc,
294 + shown: PAGE,
295 + }
296 + }
297 + }
298 +
299 + impl View {
300 + /// The view a route was addressed at.
301 + fn of(request: &quasi_router::Request) -> Result<Self, RouteError> {
302 + let status = match text(&request.carried, "status") {
303 + None => Some(TaskStatus::Pending),
304 + Some(word) if word.eq_ignore_ascii_case("all") => None,
305 + Some(word) => Some(match word.as_str() {
306 + "Pending" => TaskStatus::Pending,
307 + "Started" => TaskStatus::Started,
308 + "Completed" => TaskStatus::Completed,
309 + _ => return Err(RouteError::not_found("not a task status")),
310 + }),
311 + };
312 +
313 + let priority = match text(&request.carried, "priority") {
314 + None => None,
315 + Some(word) => Some(match word.as_str() {
316 + "High" => Priority::High,
317 + "Medium" => Priority::Medium,
318 + "Low" => Priority::Low,
319 + _ => return Err(RouteError::not_found("not a priority")),
320 + }),
321 + };
322 +
323 + let sort = match text(&request.carried, "sort") {
324 + None => TaskSortColumn::Due,
325 + Some(word) => match word.as_str() {
326 + "description" => TaskSortColumn::Description,
327 + "project" => TaskSortColumn::Project,
328 + "priority" => TaskSortColumn::Priority,
329 + "due" => TaskSortColumn::Due,
330 + "urgency" => TaskSortColumn::Urgency,
331 + _ => return Err(RouteError::not_found("not a sortable column")),
332 + },
333 + };
334 +
335 + Ok(Self {
336 + status,
337 + project: id_param(&request.carried, "project")?,
338 + milestone: id_param(&request.carried, "milestone")?,
339 + priority,
340 + snoozed: matches!(request.carried.get("snoozed"), Some("1" | "true")),
341 + waiting: matches!(request.carried.get("waiting"), Some("1" | "true")),
342 + sort,
343 + direction: match request.carried.get("direction") {
344 + Some("desc") => SortDirection::Desc,
345 + _ => SortDirection::Asc,
346 + },
347 + // Clamped rather than refused, for the reason the mail list gives:
348 + // this is an address, and landing on the first page is a more
349 + // useful answer than an error page.
350 + shown: request
351 + .carried
352 + .get("shown")
353 + .and_then(|raw| raw.parse::<i64>().ok())
354 + .unwrap_or(PAGE)
355 + .clamp(PAGE, PAGE * PAGES),
356 + })
357 + }
358 +
359 + /// The same action, still pointed at the view it was offered under.
360 + ///
361 + /// A default is never written, so two addresses for one view cannot exist.
362 + fn carry(&self, action: Action) -> Action {
363 + let mut action = action;
364 + match &self.status {
365 + Some(TaskStatus::Pending) => {}
366 + Some(status) => action = action.carrying("status", status.as_str()),
367 + None => action = action.carrying("status", "all"),
368 + }
369 + if let Some(project) = self.project {
370 + action = action.carrying("project", project.to_string());
371 + }
372 + if let Some(milestone) = self.milestone {
373 + action = action.carrying("milestone", milestone.to_string());
374 + }
375 + if let Some(priority) = &self.priority {
376 + action = action.carrying("priority", priority_word(priority));
377 + }
378 + if self.snoozed {
379 + action = action.carrying("snoozed", "1");
380 + }
381 + if self.waiting {
382 + action = action.carrying("waiting", "1");
383 + }
384 + if self.sort != TaskSortColumn::Due {
385 + action = action.carrying("sort", sort_word(self.sort));
386 + }
387 + if self.direction == SortDirection::Desc {
388 + action = action.carrying("direction", "desc");
389 + }
390 + if self.shown != PAGE {
391 + action = action.carrying("shown", self.shown.to_string());
392 + }
393 + action
394 + }
395 +
396 + /// The address of the table under this view.
397 + fn list(&self) -> Action {
398 + self.carry(Action::get("/tasks/list"))
399 + }
400 +
401 + /// The same view showing one page.
402 + ///
403 + /// A filter change is a new set of rows, so `shown` goes back to one page.
404 + /// Carrying it would ask for 2000 rows of a project holding nine.
405 + fn first_page(&self) -> Self {
406 + Self {
407 + shown: PAGE,
408 + ..self.clone()
409 + }
410 + }
411 +
412 + /// The view ordered by this column: flipped if it is already the sort,
413 + /// ascending if it is not.
414 + ///
415 + /// `sortTasks`, which also has an "descending if urgency" branch that has
416 + /// never run because urgency has no heading to press.
417 + fn sorted_by(&self, column: TaskSortColumn) -> Self {
418 + Self {
419 + sort: column,
420 + direction: if self.sort == column {
421 + match self.direction {
422 + SortDirection::Asc => SortDirection::Desc,
423 + SortDirection::Desc => SortDirection::Asc,
424 + }
425 + } else {
426 + SortDirection::Asc
427 + },
428 + ..self.first_page()
429 + }
430 + }
431 +
432 + /// What this view asks the repository for.
433 + fn query(&self) -> TaskFilterQuery {
434 + TaskFilterQuery {
435 + status: self.status.clone(),
436 + project_id: self.project,
437 + milestone_id: self.milestone,
438 + priority: self.priority.clone(),
439 + show_snoozed: self.snoozed,
440 + waiting_only: self.waiting,
441 + offset: Some(0),
442 + limit: Some(self.shown),
443 + sort_column: Some(self.sort),
444 + sort_direction: Some(self.direction),
445 + }
446 + }
447 +
448 + /// Whether anything has been narrowed. What "Clear filters" is offered for,
449 + /// and it deliberately ignores the sort and the page: neither hides a row.
450 + fn filtered(&self) -> bool {
451 + let default = Self::default();
452 + self.status != default.status
453 + || self.project.is_some()
454 + || self.milestone.is_some()
455 + || self.priority.is_some()
456 + || self.snoozed
457 + || self.waiting
458 + }
459 + }
460 +
461 + /// Minutes, the way the row says them. `formatMinutes` in `tasks-render.js`.
462 + fn minutes(total: i32) -> String {
463 + if total >= 60 {
464 + format!("{}h {}m", total / 60, total % 60)
465 + } else {
466 + format!("{total}m")
467 + }
468 + }
469 +
470 + /// What the row says about time spent on the task.
471 + ///
472 + /// The tracked-against-estimated badge, and whether a timer is running. What it
473 + /// does not say is *how long* the running timer has been running: the shipped
474 + /// row draws a live elapsed value ticked by one app-wide interval, and who owns
475 + /// a ticking clock is quasicoherent `f00244a6`, undecided. A description that
476 + /// said "18m" here would be describing the moment it was rendered.
477 + fn time_token(task: &Task) -> Option<Tag> {
478 + if task.has_active_timer() {
479 + return Some(Tag::badge("Timer running").tone(Tone::Info));
480 + }
481 + let tracked = task.actual_minutes;
482 + match (tracked, task.estimated_minutes) {
483 + (0, None) => None,
484 + (_, Some(estimate)) => Some(
485 + Tag::badge(format!("{} / {}", minutes(tracked), minutes(estimate))).tone(
486 + if task.is_over_estimate() {
487 + Tone::Warning
488 + } else {
489 + Tone::Neutral
490 + },
491 + ),
492 + ),
493 + (_, None) => Some(Tag::badge(minutes(tracked)).tone(Tone::Neutral)),
494 + }
495 + }
496 +
497 + /// The description cell: what the task is, and everything true of it that has
498 + /// no column of its own.
499 + ///
500 + /// # Two facts the shipped cell says twice, said once here
Lines truncated
@@ -1,0 +1,591 @@
1 + //! The task list, driven through the router against a real database.
2 + //!
3 + //! Two groups are worth reading. The first is the table: this is the vocabulary's
4 + //! first `Node::Table`, so the assertions are about columns being columns and a
5 + //! heading carrying the address that reorders by it. The second is the view: a
6 + //! filter here is an address, where `tasks-filter.js` holds it in module scope
7 + //! and mirrors it into the query string by hand, so the tests state what a
8 + //! mistyped address answers rather than letting it fall back to a different list
9 + //! than the one asked for.
10 +
11 + use std::sync::Arc;
12 +
13 + use goingson_core::{
14 + NewProject, NewTask, Priority, ProjectStatus, ProjectType, Recurrence, TaskId, TaskStatus,
15 + };
16 + use quasi_http::Serves as _;
17 + use quasi_router::{Outcome, Params, Request, Response};
18 +
19 + use super::super::router;
20 + use crate::state::{AppState, DESKTOP_USER_ID};
21 +
22 + async fn state() -> Arc<AppState> {
23 + let (state, _) = crate::test_utils::setup_test_state().await;
24 + let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
25 + state
26 + .db
27 + .conn()
28 + .unwrap()
29 + .execute(
30 + "INSERT OR IGNORE INTO users (id, email, password_hash, display_name, created_at) \
31 + VALUES (?, ?, ?, ?, ?)",
32 + rusqlite::params![
33 + DESKTOP_USER_ID.to_string(),
34 + "desktop@localhost",
35 + "x",
36 + "Desktop User",
37 + &now,
38 + ],
39 + )
40 + .unwrap();
41 + state
42 + }
43 +
44 + fn task(state: &AppState, title: &str) -> TaskId {
45 + state
46 + .tasks
47 + .create(
48 + DESKTOP_USER_ID,
49 + NewTask::builder(title).priority(Priority::Medium).build(),
50 + )
51 + .unwrap()
52 + .id
53 + }
54 +
55 + fn get(state: &AppState, path: &str, params: Params) -> Response {
56 + router()
57 + .handle(state, Request::get(path).carrying(params))
58 + .expect("the route answers")
59 + }
60 +
61 + fn post(state: &AppState, path: &str, payload: Params, carried: Params) -> Response {
62 + router()
63 + .handle(
64 + state,
65 + Request::post(path).sending(payload).carrying(carried),
66 + )
67 + .expect("the route answers")
68 + }
69 +
70 + fn html(response: Response) -> String {
71 + match response.outcome {
72 + Outcome::Screen(screen) => quasi_webview::Webview::new().screen(&screen),
73 + Outcome::Fragment { node, .. } => quasi_webview::Webview::new().fragment(&node),
74 + Outcome::Goto(action) => panic!("expected content, got a redirect to {action:?}"),
75 + Outcome::Over(_) => panic!("expected content, got a screen drawn over it"),
76 + }
77 + }
78 +
79 + /// The whole screen under the default view.
80 + fn screen(state: &AppState) -> String {
81 + html(get(state, "/tasks", Params::new()))
82 + }
83 +
84 + // The screen
85 +
86 + #[tokio::test]
87 + async fn the_drawers_delete_redirect_now_lands_somewhere() {
88 + let state = state().await;
89 + let id = task(&state, "Doomed");
90 +
91 + // `super::super::tasks::remove` has answered `goto /tasks` since 2026-08-09
92 + // and nothing served that route until this module. The old test asserted the
93 + // destination; this one follows it.
94 + let response = router()
95 + .handle(&state, Request::post(format!("/tasks/{id}/delete")))
96 + .expect("the route answers");
97 + let Outcome::Goto(action) = response.outcome else {
98 + panic!("deleting from the drawer redirects");
99 + };
100 + let route = action.destination.route().expect("a route to follow");
101 + assert_eq!(route, "/tasks");
102 +
103 + let followed = router().handle(&state, Request::get(route).carrying(Params::new()));
104 + assert!(followed.is_ok(), "the redirect's destination answers");
105 + }
106 +
107 + #[tokio::test]
108 + async fn the_table_is_the_columns_the_build_script_describes_in_that_order() {
109 + let state = state().await;
110 + task(&state, "Write the thing");
111 +
112 + let markup = screen(&state);
113 +
114 + // The same seven, in the same order, as `build.rs`'s TASK_COLUMNS. Finding
115 + // 1 in the module header: the two descriptions agree and nothing but this
116 + // checks that they do.
117 + let mut at = 0;
118 + for name in [
119 + "description",
120 + "project",
121 + "priority",
122 + "due",
123 + "recurrence",
124 + "progress",
125 + "actions",
126 + ] {
127 + let found = markup[at..]
128 + .find(name)
129 + .unwrap_or_else(|| panic!("no {name} column in {markup}"));
130 + at += found + name.len();
131 + }
132 + }
133 +
134 + #[tokio::test]
135 + async fn a_row_carries_the_facts_the_js_row_carried() {
136 + let state = state().await;
137 + let project = state
138 + .projects
139 + .create(
140 + DESKTOP_USER_ID,
141 + NewProject {
142 + name: "Housekeeping".to_owned(),
143 + description: String::new(),
144 + project_type: ProjectType::SideProject,
145 + status: ProjectStatus::Active,
146 + },
147 + )
148 + .unwrap();
149 + state
150 + .tasks
151 + .create(
152 + DESKTOP_USER_ID,
153 + NewTask::builder("Write the thing")
154 + .priority(Priority::High)
155 + .project_id(project.id)
156 + .build(),
157 + )
158 + .unwrap();
159 +
160 + let markup = screen(&state);
161 +
162 + assert!(markup.contains("Write the thing"), "{markup}");
163 + assert!(markup.contains("Housekeeping"), "{markup}");
164 + // The priority column is the single letter the shipped cell shows.
165 + assert!(markup.contains(">H<"), "{markup}");
166 + // And the title opens the drawer, which is where the eight controls this
167 + // row does not offer actually live.
168 + assert!(markup.contains("/tasks/"), "{markup}");
169 + }
170 +
171 + #[tokio::test]
172 + async fn a_row_says_whether_the_task_is_available() {
173 + let state = state().await;
174 + let blocker = task(&state, "First");
175 + let blocked = task(&state, "Second");
176 + state
177 + .tasks
178 + .add_dependency(DESKTOP_USER_ID, blocked, blocker)
179 + .unwrap();
180 +
181 + let markup = screen(&state);
182 +
183 + // `Availability`, shared with the board, the project dashboard and the day
184 + // plan. Three surfaces drifted apart by each drawing this themselves; a
185 + // fourth copy here would have been the fourth.
186 + assert!(markup.contains("Blocked"), "{markup}");
187 + assert!(markup.contains("Unblocks 1"), "{markup}");
188 + }
189 +
190 + #[tokio::test]
191 + async fn the_subtask_meter_is_the_progress_column_and_not_also_a_badge() {
192 + let state = state().await;
193 + let id = task(&state, "Has subtasks");
194 + state.tasks.add_subtask(id, DESKTOP_USER_ID, "one").unwrap();
195 + state.tasks.add_subtask(id, DESKTOP_USER_ID, "two").unwrap();
196 +
197 + let markup = screen(&state);
198 +
199 + // `renderTaskRow` draws `0/2` in the description cell *and* a progress bar
200 + // in the progress column: one fact twice, computed twice, able to disagree.
201 + // The meter is the column's, so the badge is gone.
202 + assert!(markup.contains("progress"), "{markup}");
203 + assert!(!markup.contains("0/2"), "{markup}");
204 + }
205 +
206 + // The order
207 +
208 + #[tokio::test]
209 + async fn the_default_order_is_the_screens_and_not_the_commands() {
210 + let state = state().await;
211 + task(&state, "Anything");
212 +
213 + let markup = screen(&state);
214 +
215 + // `list_tasks_filtered` defaults to urgency descending and this screen has
216 + // opened on due ascending since `tasks-filter.js` was written. A port of a
217 + // screen keeps the screen's default.
218 + assert!(markup.contains("ascending"), "{markup}");
219 + // Nothing in the default address says so, because a default is never
220 + // written: two addresses for one view cannot exist.
221 + assert!(!markup.contains("sort=due"), "{markup}");
222 + }
223 +
224 + #[tokio::test]
225 + async fn a_heading_carries_the_address_that_reorders_by_it() {
226 + let state = state().await;
227 + task(&state, "Anything");
228 +
229 + let markup = screen(&state);
230 +
231 + // `Column::reorder`'s first consumer. The shipped screen writes `aria-sort`
232 + // onto the heading from module state and rebuilds the list in JS; here the
233 + // heading is an address and the order it names is what the next read gets.
234 + assert!(markup.contains("sort=project"), "{markup}");
235 + assert!(markup.contains("sort=priority"), "{markup}");
236 + // Recurrence and progress are not sortable in the build script's
237 + // description, so they carry nothing.
238 + assert!(!markup.contains("sort=recurrence"), "{markup}");
239 + }
240 +
241 + #[tokio::test]
242 + async fn pressing_the_column_already_sorted_flips_it() {
243 + let state = state().await;
244 + task(&state, "Anything");
245 +
246 + // Due is the default and is ascending, so its own heading offers descending.
247 + // Asserted on the heading's own link rather than on the markup: every row
248 + // control also carries the view it was drawn under, so a bare search for
249 + // `direction=desc` finds the Start button too.
250 + let markup = screen(&state);
251 + assert!(
252 + markup.contains(r#"class="table-sort" href="/tasks/list?direction=desc""#),
253 + "{markup}"
254 + );
255 + assert!(markup.contains(r#"aria-sort="ascending""#), "{markup}");
256 +
257 + // Under descending the same heading offers the way back, which is the
258 + // default view and therefore an address with nothing written on it.
259 + let flipped = html(get(
260 + &state,
261 + "/tasks/list",
262 + Params::new().with("direction", "desc"),
263 + ));
264 + assert!(
265 + flipped.contains(r#"class="table-sort" href="/tasks/list""#),
266 + "{flipped}"
267 + );
268 + assert!(flipped.contains(r#"aria-sort="descending""#), "{flipped}");
269 + }
270 +
271 + // The view is an address
272 +
273 + #[tokio::test]
274 + async fn a_filter_answers_the_list_alone() {
275 + let state = state().await;
276 + task(&state, "Pending one");
277 +
278 + let response = get(
279 + &state,
280 + "/tasks/list",
281 + Params::new().with("status", "Completed"),
282 + );
283 + let Outcome::Fragment { region, .. } = &response.outcome else {
284 + panic!("a filter replaces the list, not the screen");
285 + };
286 + assert_eq!(region, "tasks-list");
287 + }
288 +
289 + #[tokio::test]
290 + async fn the_status_filter_cuts_the_list_and_latches_the_chip() {
291 + let state = state().await;
292 + let done = task(&state, "Finished");
293 + task(&state, "Outstanding");
294 + state.tasks.complete(done, DESKTOP_USER_ID).unwrap();
295 +
296 + let pending = screen(&state);
297 + assert!(pending.contains("Outstanding"), "{pending}");
298 + assert!(!pending.contains("Finished"), "{pending}");
299 +
300 + let completed = html(get(
301 + &state,
302 + "/tasks",
303 + Params::new().with("status", "Completed"),
304 + ));
305 + assert!(completed.contains("Finished"), "{completed}");
306 + assert!(!completed.contains("Outstanding"), "{completed}");
307 + }
308 +
309 + #[tokio::test]
310 + async fn a_word_the_filter_does_not_know_is_a_404_and_not_a_different_list() {
311 + let state = state().await;
312 + task(&state, "Anything");
313 +
314 + // The problems inbox's rule. `TaskStatus::from_str_or_default` would answer
315 + // Pending for "Pendign" and the screen would look like it worked.
316 + for (name, value) in [
317 + ("status", "Pendign"),
318 + ("priority", "Highest"),
319 + ("sort", "urgncy"),
320 + ("project", "not-a-uuid"),
321 + ] {
322 + let answer = router().handle(
323 + &state,
324 + Request::get("/tasks/list").carrying(Params::new().with(name, value)),
325 + );
326 + assert!(answer.is_err(), "{name}={value} answered something");
327 + }
328 + }
329 +
330 + #[tokio::test]
331 + async fn the_priority_chip_clears_itself_when_it_is_the_one_latched() {
332 + let state = state().await;
333 + task(&state, "Anything");
334 +
335 + let filtered = html(get(
336 + &state,
337 + "/tasks",
338 + Params::new().with("priority", "High"),
339 + ));
340 +
341 + // Pressing a latched chip clears it, so the way back is always on screen.
342 + // The contacts tag filter's rule, and the problems source filter's. Its
343 + // own href is what carries the clearing, not the markup at large: every
344 + // other control on the screen preserves the filter it was drawn under, so
345 + // `priority=High` appears all over it and should.
346 + assert!(
347 + filtered.contains(r#"<a class="chip latched" data-act aria-current="true" href="/tasks/list" hx-get="/tasks/list" hx-swap="morph">High</a>"#),
348 + "{filtered}"
349 + );
350 + assert!(filtered.contains("priority=Medium"), "{filtered}");
351 + }
352 +
353 + #[tokio::test]
354 + async fn a_filter_change_goes_back_to_one_page() {
355 + let state = state().await;
356 + task(&state, "Anything");
357 +
358 + let deep = html(get(&state, "/tasks", Params::new().with("shown", "600")));
359 +
360 + // Carrying `shown` onto a filter would ask for 600 rows of a priority
361 + // holding nine. The mail list's rule.
362 + assert!(!deep.contains("shown=600&"), "{deep}");
363 + assert!(deep.contains("priority=High"), "{deep}");
364 + }
365 +
366 + #[tokio::test]
367 + async fn the_milestone_control_appears_only_under_a_chosen_project() {
368 + let state = state().await;
369 + let project = state
370 + .projects
371 + .create(
372 + DESKTOP_USER_ID,
373 + NewProject {
374 + name: "Housekeeping".to_owned(),
375 + description: String::new(),
376 + project_type: ProjectType::SideProject,
377 + status: ProjectStatus::Active,
378 + },
379 + )
380 + .unwrap();
381 + state
382 + .milestones
383 + .create(
384 + DESKTOP_USER_ID,
385 + goingson_core::NewMilestone {
386 + project_id: project.id,
387 + name: "Beta".to_owned(),
388 + description: String::new(),
389 + target_date: None,
390 + position: 0,
391 + },
392 + )
393 + .unwrap();
394 +
395 + let unfiltered = screen(&state);
396 + assert!(!unfiltered.contains("Beta"), "{unfiltered}");
397 +
398 + let scoped = html(get(
399 + &state,
400 + "/tasks",
401 + Params::new().with("project", project.id.to_string()),
402 + ));
403 + // `populateMilestoneFilter`'s rule: a milestone belongs to a project, so
404 + // every project's milestones at once is a control whose options mean
405 + // nothing together.
406 + assert!(scoped.contains("Beta"), "{scoped}");
407 + }
408 +
409 + // Paging
410 +
411 + #[tokio::test]
412 + async fn the_way_to_more_rows_appears_only_when_there_are_more() {
413 + let state = state().await;
414 + task(&state, "The only one");
415 +
416 + let short = screen(&state);
417 + assert!(!short.contains("Show more"), "{short}");
418 +
419 + // `shown` is clamped to at least a page, so a short list never offers it
420 + // however the address is typed.
421 + let typed_low = html(get(&state, "/tasks/list", Params::new().with("shown", "1")));
422 + assert!(!typed_low.contains("Show more"), "{typed_low}");
423 +
424 + // Past one page it does, and it names how far along the reader is, which is
425 + // what the shipped count chip says while pages stream in. Finding 2 in the
426 + // module header: this act is what a table cannot say for itself.
427 + for n in 0..super::PAGE {
428 + task(&state, &format!("Filler {n}"));
429 + }
430 + let long = screen(&state);
431 + assert!(long.contains("Show more (200 of 201)"), "{long}");
432 + assert!(long.contains("shown=400"), "{long}");
433 + }
434 +
435 + // Writes
436 +
437 + #[tokio::test]
438 + async fn starting_a_task_from_the_list_answers_the_list() {
439 + let state = state().await;
440 + let id = task(&state, "Startable");
441 +
442 + let response = post(
443 + &state,
444 + &format!("/tasks/list/{id}/status"),
445 + Params::new().with("status", "Started"),
446 + Params::new(),
447 + );
448 +
449 + let Outcome::Fragment { region, .. } = &response.outcome else {
450 + panic!("a row's control replaces the list, not the screen");
451 + };
452 + assert_eq!(region, "tasks-list");
453 +
454 + let after = state.tasks.get_by_id(id, DESKTOP_USER_ID).unwrap().unwrap();
455 + assert_eq!(after.status, TaskStatus::Started);
456 + }
457 +
458 + #[tokio::test]
459 + async fn the_write_and_the_filter_are_both_called_status_and_cannot_reach_each_other() {
460 + let state = state().await;
461 + let id = task(&state, "Startable");
462 +
463 + // The arrangement the problems inbox and the mail screen paid for on one
464 + // afternoon: `payload` and `carried` are different bags, so the target of
465 + // the write is not read back as the view it happened in.
466 + let response = post(
467 + &state,
468 + &format!("/tasks/list/{id}/status"),
469 + Params::new().with("status", "Completed"),
470 + Params::new().with("status", "Pending"),
471 + );
472 +
473 + let markup = html(response);
474 + // Answered with the Pending list it was pressed in, which no longer holds
475 + // the completed task -- not with the Completed list the write named.
476 + assert!(!markup.contains("Startable"), "{markup}");
477 + }
478 +
479 + #[tokio::test]
480 + async fn pressing_a_status_a_task_is_already_in_writes_nothing() {
481 + let state = state().await;
482 + let id = task(&state, "Stationary");
483 +
484 + let response = post(
485 + &state,
486 + &format!("/tasks/list/{id}/status"),
487 + Params::new().with("status", "Pending"),
488 + Params::new(),
489 + );
490 +
491 + let after = state.tasks.get_by_id(id, DESKTOP_USER_ID).unwrap().unwrap();
492 + assert_eq!(after.status, TaskStatus::Pending);
493 + // No toast, because nothing happened. A row can be pressed while the list
494 + // it was drawn in is stale, and a repeated press must not complete a task
495 + // twice and mint a second recurrence.
496 + assert!(response.notice.is_none(), "{:?}", response.notice);
497 + }
498 +
499 + #[tokio::test]
500 + async fn completing_a_recurring_task_shows_the_successor_it_minted() {
Lines truncated